Files
Archive/src-tauri/crates/archive-vfs/examples/webdav_server.rs
T
강 한andClaude Opus 4.8 394d1b9805 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>
2026-07-16 22:04:06 +09:00

203 lines
8.0 KiB
Rust

//! 개발/테스트용 최소 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
}