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,131 @@
|
||||
//! WebDAV 백엔드 통합 테스트 — 자체 완결형(in-process) 최소 WebDAV 서버 대상.
|
||||
//! PROPFIND(Depth 1) + Range GET을 검증한다 (브라우즈 + 원격 재생 경로).
|
||||
|
||||
use archive_vfs::webdav::{WebDavConfig, WebDavFs};
|
||||
use archive_vfs::VfsProvider;
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// 아주 작은 WebDAV 서버: 고정 디렉터리 하나(/dav)에 파일 2개.
|
||||
/// PROPFIND → multistatus XML, GET → Range 지원.
|
||||
async fn run_server() -> SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let _ = handle(&mut sock).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
const FILE_A: &[u8] = b"0123456789ABCDEF"; // 16바이트
|
||||
const FILE_B: &[u8] = b"hello webdav world"; // 18바이트
|
||||
|
||||
async fn handle(sock: &mut tokio::net::TcpStream) -> Result<(), Infallible> {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let req = String::from_utf8_lossy(&buf[..n]);
|
||||
let method = req.split_whitespace().next().unwrap_or("");
|
||||
let path = req.split_whitespace().nth(1).unwrap_or("/");
|
||||
|
||||
let response: Vec<u8> = match method {
|
||||
"PROPFIND" => {
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response><D:href>/dav/</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>
|
||||
<D:response><D:href>/dav/a.jpg</D:href><D:propstat><D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:getcontentlength>{}</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>
|
||||
<D:response><D:href>/dav/b.mp4</D:href><D:propstat><D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:getcontentlength>{}</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>
|
||||
</D:multistatus>"#,
|
||||
FILE_A.len(),
|
||||
FILE_B.len()
|
||||
);
|
||||
http_response("207 Multi-Status", "application/xml", xml.as_bytes(), None)
|
||||
}
|
||||
"GET" => {
|
||||
let body = if path.contains("a.jpg") { FILE_A } else { FILE_B };
|
||||
// Range 헤더 파싱
|
||||
if let Some(range_line) = req.lines().find(|l| l.to_ascii_lowercase().starts_with("range:")) {
|
||||
let spec = range_line.split("bytes=").nth(1).unwrap_or("").trim();
|
||||
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(body.len() - 1);
|
||||
let slice = &body[start..=end.min(body.len() - 1)];
|
||||
let cr = format!("bytes {}-{}/{}", start, end.min(body.len() - 1), body.len());
|
||||
http_response("206 Partial Content", "application/octet-stream", slice, Some(&cr))
|
||||
} else {
|
||||
http_response("200 OK", "application/octet-stream", body, None)
|
||||
}
|
||||
}
|
||||
_ => http_response("405 Method Not Allowed", "text/plain", b"", None),
|
||||
};
|
||||
let _ = sock.write_all(&response).await;
|
||||
let _ = sock.flush().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn http_response(status: &str, ctype: &str, body: &[u8], content_range: Option<&str>) -> Vec<u8> {
|
||||
let mut head = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n",
|
||||
body.len()
|
||||
);
|
||||
if let Some(cr) = content_range {
|
||||
head.push_str(&format!("Content-Range: {cr}\r\n"));
|
||||
}
|
||||
head.push_str("Connection: close\r\n\r\n");
|
||||
let mut out = head.into_bytes();
|
||||
out.extend_from_slice(body);
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webdav_list_and_range_read() {
|
||||
let addr = run_server().await;
|
||||
let fs = WebDavFs::connect(&WebDavConfig {
|
||||
url: format!("http://{addr}/dav"),
|
||||
username: "u".into(),
|
||||
password: "p".into(),
|
||||
base_path: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// list_dir → 파일 2개 (디렉터리 자신 제외)
|
||||
let entries = fs.list_dir("").await.unwrap();
|
||||
let files: Vec<_> = entries.iter().filter(|e| !e.is_dir).collect();
|
||||
assert_eq!(files.len(), 2, "파일 2개여야 함: {entries:?}");
|
||||
let a = files.iter().find(|e| e.name == "a.jpg").expect("a.jpg 없음");
|
||||
assert_eq!(a.size, FILE_A.len() as u64);
|
||||
|
||||
// 전체 읽기
|
||||
let mut r = fs.open_range("a.jpg", None).await.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
r.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(buf, FILE_A);
|
||||
|
||||
// Range 읽기 (재생 시킹 시나리오)
|
||||
let mut r = fs.open_range("a.jpg", Some(4..10)).await.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
r.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(buf, &FILE_A[4..10]);
|
||||
}
|
||||
Reference in New Issue
Block a user