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>
203 lines
8.0 KiB
Rust
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
|
|
}
|