Archive v0.1.0 — 사진/영상 라이브러리 관리 프로그램
Tauri v2 + SolidJS + SQLite + ffmpeg 사이드카로 구현한 크로스플랫폼 사진·영상 관리 앱. 로컬/NAS(SFTP·WebDAV·FTP) 소스, 가상 그리드, 인앱 재생, 태그/이동/삭제/undo, 중복 탐지, 포터블 배포. - archive-db: SQLite 스키마·마이그레이션·단일 writer 스레드 + FTS5 trigram - archive-vfs: VFS 4백엔드(local/sftp/ftp/webdav) + 자격증명(키체인/볼트) - archive-indexer: 스캔·해시·썸네일·중복탐지·태그·파일작업·유지보수 - archive-media: localhost HTTP 미디어 서버(Range) + ffmpeg 스트림 잡 - 프론트: 3-pane UI, justified 가상 그리드, 라이트박스, 중복 검토 패널 Rust 테스트 49개 통과. CI: win x64/arm64 포터블 zip + macOS universal dmg. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
//! 개발/테스트용 최소 WebDAV 서버 — 실제 디렉터리를 서빙한다.
|
||||
//! 사용: cargo run -p archive-vfs --example webdav_server -- <포트> <디렉터리>
|
||||
//! PROPFIND(Depth 0/1) + GET(Range) + MOVE + DELETE 지원. 인증 없음(로컬 전용).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let port: u16 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(8080);
|
||||
let root = PathBuf::from(args.get(2).cloned().unwrap_or_else(|| ".".into()));
|
||||
let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
println!("WebDAV 서버: http://127.0.0.1:{port}/ root={}", root.display());
|
||||
loop {
|
||||
let (sock, _) = listener.accept().await.unwrap();
|
||||
let root = root.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = handle(sock, root).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(mut sock: TcpStream, root: PathBuf) -> std::io::Result<()> {
|
||||
let mut buf = vec![0u8; 16384];
|
||||
let n = sock.read(&mut buf).await?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let req = String::from_utf8_lossy(&buf[..n]);
|
||||
let mut lines = req.lines();
|
||||
let first = lines.next().unwrap_or("");
|
||||
let mut parts = first.split_whitespace();
|
||||
let method = parts.next().unwrap_or("");
|
||||
let raw_path = parts.next().unwrap_or("/");
|
||||
let path = urldecode(raw_path);
|
||||
let depth = req
|
||||
.lines()
|
||||
.find(|l| l.to_ascii_lowercase().starts_with("depth:"))
|
||||
.and_then(|l| l.split(':').nth(1))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "1".into());
|
||||
|
||||
let fs_path = root.join(path.trim_start_matches('/'));
|
||||
|
||||
match method {
|
||||
"OPTIONS" => {
|
||||
write_head(&mut sock, "200 OK", "text/plain", 0, None, "DAV: 1\r\nAllow: OPTIONS, GET, PROPFIND, MOVE, DELETE, MKCOL, PUT\r\n").await?;
|
||||
}
|
||||
"PROPFIND" => {
|
||||
let xml = build_propfind(&root, &fs_path, &path, &depth);
|
||||
write_head(&mut sock, "207 Multi-Status", "application/xml; charset=utf-8", xml.len(), None, "").await?;
|
||||
sock.write_all(xml.as_bytes()).await?;
|
||||
}
|
||||
"GET" => {
|
||||
if let Ok(data) = tokio::fs::read(&fs_path).await {
|
||||
let range = req
|
||||
.lines()
|
||||
.find(|l| l.to_ascii_lowercase().starts_with("range:"))
|
||||
.and_then(|l| l.split("bytes=").nth(1))
|
||||
.map(|s| s.trim().to_string());
|
||||
match range {
|
||||
Some(spec) => {
|
||||
let (s, e) = spec.split_once('-').unwrap_or(("0", ""));
|
||||
let start: usize = s.trim().parse().unwrap_or(0);
|
||||
let end: usize = e.trim().parse().unwrap_or(data.len().saturating_sub(1));
|
||||
let end = end.min(data.len().saturating_sub(1));
|
||||
let slice = &data[start..=end];
|
||||
let cr = format!("bytes {}-{}/{}", start, end, data.len());
|
||||
write_head(&mut sock, "206 Partial Content", "application/octet-stream", slice.len(), Some(&cr), "").await?;
|
||||
sock.write_all(slice).await?;
|
||||
}
|
||||
None => {
|
||||
write_head(&mut sock, "200 OK", "application/octet-stream", data.len(), None, "").await?;
|
||||
sock.write_all(&data).await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
write_head(&mut sock, "404 Not Found", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
}
|
||||
"MOVE" => {
|
||||
let dest = req
|
||||
.lines()
|
||||
.find(|l| l.to_ascii_lowercase().starts_with("destination:"))
|
||||
.and_then(|l| l.splitn(2, ':').nth(1))
|
||||
.map(|s| urldecode(s.trim()))
|
||||
.unwrap_or_default();
|
||||
// Destination은 전체 URL — 경로 부분만 추출
|
||||
let dest_path = dest.split("//").nth(1).and_then(|s| s.split_once('/')).map(|(_, p)| format!("/{p}")).unwrap_or(dest);
|
||||
let dfs = root.join(dest_path.trim_start_matches('/'));
|
||||
let _ = tokio::fs::rename(&fs_path, &dfs).await;
|
||||
write_head(&mut sock, "201 Created", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
"DELETE" => {
|
||||
let _ = tokio::fs::remove_file(&fs_path).await;
|
||||
write_head(&mut sock, "204 No Content", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
"MKCOL" => {
|
||||
let _ = tokio::fs::create_dir_all(&fs_path).await;
|
||||
write_head(&mut sock, "201 Created", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
_ => {
|
||||
write_head(&mut sock, "405 Method Not Allowed", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
}
|
||||
sock.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_head(
|
||||
sock: &mut TcpStream,
|
||||
status: &str,
|
||||
ctype: &str,
|
||||
len: usize,
|
||||
content_range: Option<&str>,
|
||||
extra: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let mut head = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {len}\r\nAccept-Ranges: bytes\r\n"
|
||||
);
|
||||
if let Some(cr) = content_range {
|
||||
head.push_str(&format!("Content-Range: {cr}\r\n"));
|
||||
}
|
||||
head.push_str(extra);
|
||||
head.push_str("Connection: close\r\n\r\n");
|
||||
sock.write_all(head.as_bytes()).await
|
||||
}
|
||||
|
||||
fn build_propfind(root: &Path, fs_path: &Path, url_path: &str, depth: &str) -> String {
|
||||
let mut responses = String::new();
|
||||
let base = url_path.trim_end_matches('/');
|
||||
|
||||
// 요청 대상 자신
|
||||
if fs_path.is_dir() {
|
||||
responses.push_str(&dav_folder(&format!("{base}/")));
|
||||
if depth != "0" {
|
||||
if let Ok(rd) = std::fs::read_dir(fs_path) {
|
||||
for entry in rd.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let child_url = format!("{base}/{}", urlencode(&name));
|
||||
if entry.path().is_dir() {
|
||||
responses.push_str(&dav_folder(&format!("{child_url}/")));
|
||||
} else {
|
||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
responses.push_str(&dav_file(&child_url, size));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let size = fs_path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
responses.push_str(&dav_file(base, size));
|
||||
}
|
||||
let _ = root;
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:">{responses}</D:multistatus>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn dav_folder(href: &str) -> String {
|
||||
format!(
|
||||
r#"<D:response><D:href>{href}</D:href><D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype><D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn dav_file(href: &str, size: u64) -> String {
|
||||
format!(
|
||||
r#"<D:response><D:href>{href}</D:href><D:propstat><D:prop><D:resourcetype/><D:getcontentlength>{size}</D:getcontentlength><D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn urldecode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
|
||||
out.push(v);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
fn urlencode(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user