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,259 @@
|
||||
//! 자격증명 저장 — 기본은 OS 키체인, 포터블 모드는 암호화 볼트(opt-in).
|
||||
//! SQLite에는 비밀번호를 절대 저장하지 않는다 (계획서 §9).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
const SERVICE: &str = "kr.co.elio.archive";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CredError {
|
||||
#[error("키체인 오류: {0}")]
|
||||
Keyring(String),
|
||||
#[error("볼트 오류: {0}")]
|
||||
Vault(String),
|
||||
#[error("잠긴 볼트 — 패스프레이즈 필요")]
|
||||
Locked,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CredError>;
|
||||
|
||||
/// 자격증명 백엔드.
|
||||
pub enum CredStore {
|
||||
/// OS 키체인 (Windows Credential Manager / macOS Keychain)
|
||||
Keychain,
|
||||
/// 포터블 암호화 볼트 (exe 옆 vault.bin)
|
||||
Vault(Vault),
|
||||
}
|
||||
|
||||
impl CredStore {
|
||||
pub fn set(&self, key: &str, secret: &str) -> Result<()> {
|
||||
match self {
|
||||
CredStore::Keychain => {
|
||||
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| CredError::Keyring(e.to_string()))?;
|
||||
entry.set_password(secret).map_err(|e| CredError::Keyring(e.to_string()))
|
||||
}
|
||||
CredStore::Vault(v) => v.set(key, secret),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
match self {
|
||||
CredStore::Keychain => {
|
||||
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| CredError::Keyring(e.to_string()))?;
|
||||
match entry.get_password() {
|
||||
Ok(p) => Ok(Some(p)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(CredError::Keyring(e.to_string())),
|
||||
}
|
||||
}
|
||||
CredStore::Vault(v) => v.get(key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) -> Result<()> {
|
||||
match self {
|
||||
CredStore::Keychain => {
|
||||
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| CredError::Keyring(e.to_string()))?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(CredError::Keyring(e.to_string())),
|
||||
}
|
||||
}
|
||||
CredStore::Vault(v) => v.delete(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ChaCha20-Poly1305 + Argon2id 암호화 볼트 (포터블 모드).
|
||||
pub struct Vault {
|
||||
path: PathBuf,
|
||||
key: [u8; 32],
|
||||
data: std::sync::Mutex<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl Vault {
|
||||
/// 패스프레이즈로 볼트를 열거나(기존) 새로 만든다.
|
||||
pub fn open(path: PathBuf, passphrase: &str) -> Result<Vault> {
|
||||
use argon2::password_hash::rand_core::OsRng;
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, PasswordHasher};
|
||||
|
||||
// 볼트 파일의 salt를 재사용하거나 새로 생성
|
||||
let (salt_b64, ciphertext) = if path.exists() {
|
||||
let bytes = std::fs::read(&path).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let content = String::from_utf8(bytes).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let (salt, ct) = content.split_once('\n').ok_or_else(|| CredError::Vault("형식 오류".into()))?;
|
||||
(salt.to_string(), Some(ct.to_string()))
|
||||
} else {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
(salt.as_str().to_string(), None)
|
||||
};
|
||||
|
||||
let salt = SaltString::from_b64(&salt_b64).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let hash = Argon2::default()
|
||||
.hash_password(passphrase.as_bytes(), &salt)
|
||||
.map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let hash_bytes = hash.hash.ok_or_else(|| CredError::Vault("해시 없음".into()))?;
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hash_bytes.as_bytes()[..32]);
|
||||
|
||||
let data = match ciphertext {
|
||||
Some(ct) if !ct.is_empty() => decrypt_map(&key, &ct)?,
|
||||
_ => std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
let vault = Vault {
|
||||
path,
|
||||
key,
|
||||
data: std::sync::Mutex::new(data),
|
||||
};
|
||||
// salt를 즉시 기록 (새 볼트)
|
||||
vault.persist(&salt_b64)?;
|
||||
Ok(vault)
|
||||
}
|
||||
|
||||
fn persist(&self, salt_b64: &str) -> Result<()> {
|
||||
let data = self.data.lock().unwrap();
|
||||
let ct = encrypt_map(&self.key, &data)?;
|
||||
std::fs::write(&self.path, format!("{salt_b64}\n{ct}")).map_err(|e| CredError::Vault(e.to_string()))
|
||||
}
|
||||
|
||||
fn salt_from_file(&self) -> Result<String> {
|
||||
let bytes = std::fs::read(&self.path).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let content = String::from_utf8(bytes).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
Ok(content.split_once('\n').map(|(s, _)| s.to_string()).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, secret: &str) -> Result<()> {
|
||||
self.data.lock().unwrap().insert(key.to_string(), secret.to_string());
|
||||
let salt = self.salt_from_file()?;
|
||||
self.persist(&salt)
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
Ok(self.data.lock().unwrap().get(key).cloned())
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) -> Result<()> {
|
||||
self.data.lock().unwrap().remove(key);
|
||||
let salt = self.salt_from_file()?;
|
||||
self.persist(&salt)
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt_map(key: &[u8; 32], map: &std::collections::HashMap<String, String>) -> Result<String> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
||||
|
||||
let plaintext = serde_json::to_vec(map).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let cipher = ChaCha20Poly1305::new(key.into());
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ct = cipher.encrypt(nonce, plaintext.as_ref()).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
|
||||
let mut combined = nonce_bytes.to_vec();
|
||||
combined.extend_from_slice(&ct);
|
||||
Ok(base64_encode(&combined))
|
||||
}
|
||||
|
||||
fn decrypt_map(key: &[u8; 32], b64: &str) -> Result<std::collections::HashMap<String, String>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
||||
|
||||
let combined = base64_decode(b64).map_err(|e| CredError::Vault(e))?;
|
||||
if combined.len() < 12 {
|
||||
return Err(CredError::Vault("암호문 길이 오류".into()));
|
||||
}
|
||||
let (nonce_bytes, ct) = combined.split_at(12);
|
||||
let cipher = ChaCha20Poly1305::new(key.into());
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ct)
|
||||
.map_err(|_| CredError::Vault("복호화 실패 (패스프레이즈 불일치?)".into()))?;
|
||||
serde_json::from_slice(&plaintext).map_err(|e| CredError::Vault(e.to_string()))
|
||||
}
|
||||
|
||||
// 표준 base64 (URL-safe 아님) — 외부 크레이트 없이 구현
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
|
||||
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
|
||||
out.push(T[((n >> 18) & 63) as usize] as char);
|
||||
out.push(T[((n >> 12) & 63) as usize] as char);
|
||||
out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] as char } else { '=' });
|
||||
out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn base64_decode(s: &str) -> std::result::Result<Vec<u8>, String> {
|
||||
fn val(c: u8) -> std::result::Result<u32, String> {
|
||||
match c {
|
||||
b'A'..=b'Z' => Ok((c - b'A') as u32),
|
||||
b'a'..=b'z' => Ok((c - b'a' + 26) as u32),
|
||||
b'0'..=b'9' => Ok((c - b'0' + 52) as u32),
|
||||
b'+' => Ok(62),
|
||||
b'/' => Ok(63),
|
||||
_ => Err("잘못된 base64 문자".into()),
|
||||
}
|
||||
}
|
||||
let clean: Vec<u8> = s.bytes().filter(|c| *c != b'=' && !c.is_ascii_whitespace()).collect();
|
||||
let mut out = Vec::with_capacity(clean.len() / 4 * 3);
|
||||
for chunk in clean.chunks(4) {
|
||||
let mut n = 0u32;
|
||||
let mut bits = 0;
|
||||
for &c in chunk {
|
||||
n = (n << 6) | val(c)?;
|
||||
bits += 6;
|
||||
}
|
||||
n <<= 24 - bits;
|
||||
out.push((n >> 16) as u8);
|
||||
if chunk.len() > 2 {
|
||||
out.push((n >> 8) as u8);
|
||||
}
|
||||
if chunk.len() > 3 {
|
||||
out.push(n as u8);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_roundtrip() {
|
||||
for input in [&b""[..], b"a", b"ab", b"abc", b"abcd", b"hello world!"] {
|
||||
let enc = base64_encode(input);
|
||||
assert_eq!(base64_decode(&enc).unwrap(), input, "input={input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_encrypt_persist_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("vault.bin");
|
||||
|
||||
{
|
||||
let v = Vault::open(path.clone(), "correct-horse").unwrap();
|
||||
v.set("sftp:1:password", "s3cr3t비밀").unwrap();
|
||||
v.set("ftp:2:password", "another").unwrap();
|
||||
}
|
||||
// 재오픈 (같은 패스프레이즈)
|
||||
{
|
||||
let v = Vault::open(path.clone(), "correct-horse").unwrap();
|
||||
assert_eq!(v.get("sftp:1:password").unwrap().as_deref(), Some("s3cr3t비밀"));
|
||||
assert_eq!(v.get("ftp:2:password").unwrap().as_deref(), Some("another"));
|
||||
v.delete("ftp:2:password").unwrap();
|
||||
assert_eq!(v.get("ftp:2:password").unwrap(), None);
|
||||
}
|
||||
// 잘못된 패스프레이즈 → 복호화 실패
|
||||
{
|
||||
let result = Vault::open(path.clone(), "wrong-passphrase");
|
||||
assert!(result.is_err(), "잘못된 패스프레이즈는 실패해야 함");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! FTP/FTPS 백엔드 (suppaftp, tokio + rustls).
|
||||
//! FTP는 데이터 커넥션당 전송 하나라 Range는 REST 오프셋으로 구현한다.
|
||||
|
||||
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use suppaftp::tokio::AsyncFtpStream;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FtpConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub base_path: String,
|
||||
}
|
||||
|
||||
pub struct FtpFs {
|
||||
stream: Arc<Mutex<AsyncFtpStream>>,
|
||||
base: String,
|
||||
}
|
||||
|
||||
impl FtpFs {
|
||||
pub async fn connect(cfg: &FtpConfig) -> Result<FtpFs> {
|
||||
let mut stream = AsyncFtpStream::connect((cfg.host.as_str(), cfg.port))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("FTP 연결: {e}")))?;
|
||||
stream
|
||||
.login(&cfg.username, &cfg.password)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("FTP 로그인: {e}")))?;
|
||||
// 바이너리 모드
|
||||
let _ = stream.transfer_type(suppaftp::types::FileType::Binary).await;
|
||||
Ok(FtpFs {
|
||||
stream: Arc::new(Mutex::new(stream)),
|
||||
base: cfg.base_path.trim_end_matches('/').to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn full(&self, path: &str) -> String {
|
||||
let p = path.trim_start_matches('/');
|
||||
if self.base.is_empty() {
|
||||
format!("/{p}")
|
||||
} else if p.is_empty() {
|
||||
self.base.clone()
|
||||
} else {
|
||||
format!("{}/{}", self.base, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl VfsProvider for FtpFs {
|
||||
fn capabilities(&self) -> Caps {
|
||||
// FTP는 병렬 읽기·서버측 rename 지원, 하지만 Range는 REST 재연결 기반
|
||||
Caps::SERVER_MOVE
|
||||
}
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
let list = stream
|
||||
.list(Some(&full))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("LIST: {e}")))?;
|
||||
let mut out = Vec::new();
|
||||
for line in list {
|
||||
if let Some(entry) = parse_list_line(&line) {
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry> {
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
let size = stream.size(&full).await.unwrap_or(0) as u64;
|
||||
let name = full.rsplit('/').next().unwrap_or("").to_string();
|
||||
Ok(VEntry {
|
||||
name,
|
||||
is_dir: size == 0,
|
||||
size,
|
||||
mtime_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
if let Some(r) = &range {
|
||||
if r.start > 0 {
|
||||
stream
|
||||
.resume_transfer(r.start as usize)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("REST: {e}")))?;
|
||||
}
|
||||
}
|
||||
let mut data_stream = stream
|
||||
.retr_as_stream(&full)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("RETR: {e}")))?;
|
||||
// Range 길이만큼(또는 전체) 메모리로 읽는다
|
||||
let mut buf = Vec::new();
|
||||
match &range {
|
||||
Some(r) => {
|
||||
let len = r.end.saturating_sub(r.start);
|
||||
let mut limited = (&mut data_stream).take(len);
|
||||
limited.read_to_end(&mut buf).await?;
|
||||
}
|
||||
None => {
|
||||
data_stream.read_to_end(&mut buf).await?;
|
||||
}
|
||||
}
|
||||
// 데이터 커넥션 정리
|
||||
let _ = stream.finalize_retr_stream(data_stream).await;
|
||||
Ok(Box::new(std::io::Cursor::new(buf)))
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
let (f, t) = (self.full(from), self.full(to));
|
||||
let mut stream = self.stream.lock().await;
|
||||
stream.rename(&f, &t).await.map_err(|e| VfsError::Protocol(format!("RNFR/RNTO: {e}")))
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
stream.rm(&full).await.map_err(|e| VfsError::Protocol(format!("DELE: {e}")))
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
let _ = stream.mkdir(&full).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// UNIX 스타일 LIST 라인 파싱 (권한 파싱 최소화 — 디렉터리 여부 + 이름 + 크기)
|
||||
fn parse_list_line(line: &str) -> Option<VEntry> {
|
||||
// 예: "drwxr-xr-x 2 user group 4096 Jan 01 12:00 폴더명"
|
||||
let parts: Vec<&str> = line.splitn(9, char::is_whitespace).filter(|s| !s.is_empty()).collect();
|
||||
if parts.len() < 9 {
|
||||
return None;
|
||||
}
|
||||
let perms = parts[0];
|
||||
let is_dir = perms.starts_with('d');
|
||||
let size: u64 = parts[4].parse().unwrap_or(0);
|
||||
let name = parts[8].to_string();
|
||||
if name == "." || name == ".." {
|
||||
return None;
|
||||
}
|
||||
Some(VEntry {
|
||||
name,
|
||||
is_dir,
|
||||
size,
|
||||
mtime_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_unix_list() {
|
||||
let dir = parse_list_line("drwxr-xr-x 2 user group 4096 Jan 01 12:00 여행폴더").unwrap();
|
||||
assert!(dir.is_dir);
|
||||
assert_eq!(dir.name, "여행폴더");
|
||||
|
||||
let file = parse_list_line("-rw-r--r-- 1 user group 102400 Feb 15 09:30 사진.jpg").unwrap();
|
||||
assert!(!file.is_dir);
|
||||
assert_eq!(file.size, 102400);
|
||||
assert_eq!(file.name, "사진.jpg");
|
||||
|
||||
assert!(parse_list_line("drwxr-xr-x 2 u g 4096 Jan 01 12:00 .").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! VFS 추상화 — 로컬/원격 소스가 스캔·인덱싱·썸네일·재생 파이프라인을 공유하기
|
||||
//! 위한 단일 트레이트. 모든 소비자는 `VfsProvider`만 알고, 프로토콜별 구현은
|
||||
//! 백엔드 모듈에 격리된다.
|
||||
|
||||
pub mod creds;
|
||||
pub mod ftp;
|
||||
pub mod local;
|
||||
pub mod sftp;
|
||||
pub mod webdav;
|
||||
|
||||
use std::ops::Range;
|
||||
use tokio::io::AsyncRead;
|
||||
|
||||
bitflags::bitflags! {
|
||||
/// 소스별 지원 기능 — UI와 파이프라인 동작을 게이트한다.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Caps: u32 {
|
||||
/// 임의 오프셋 읽기 (재생 시킹, EXIF 썸네일 부분 다운로드)
|
||||
const RANGE_READ = 1 << 0;
|
||||
/// 서버측 이동/이름변경
|
||||
const SERVER_MOVE = 1 << 1;
|
||||
/// OS 휴지통 (없으면 앱 레벨 휴지통 사용)
|
||||
const NATIVE_TRASH = 1 << 2;
|
||||
/// 수정시각 보존 쓰기
|
||||
const MTIME_WRITE = 1 << 3;
|
||||
/// 병렬 읽기에 안전
|
||||
const PARALLEL_READS = 1 << 4;
|
||||
/// 파일 변경 감시 신뢰 가능
|
||||
const WATCHABLE = 1 << 5;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub size: u64,
|
||||
pub mtime_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VfsError {
|
||||
#[error("I/O 오류: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("경로를 찾을 수 없음: {0}")]
|
||||
NotFound(String),
|
||||
#[error("아직 구현되지 않음: {0}")]
|
||||
NotImplemented(&'static str),
|
||||
#[error("프로토콜 오류: {0}")]
|
||||
Protocol(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, VfsError>;
|
||||
|
||||
/// 스트리밍 읽기 핸들.
|
||||
pub type VfsRead = Box<dyn AsyncRead + Send + Unpin>;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait VfsProvider: Send + Sync {
|
||||
fn capabilities(&self) -> Caps;
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>>;
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry>;
|
||||
|
||||
/// 핵심 읽기 프리미티브. `range`가 None이면 파일 전체를 스트리밍한다.
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead>;
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()>;
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()>;
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
pub(crate) fn system_time_to_ms(t: std::time::SystemTime) -> i64 {
|
||||
t.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! 로컬 파일시스템 백엔드 — OS가 마운트한 네트워크 드라이브(UNC, Z:\, /Volumes)도
|
||||
//! 이 백엔드로 처리된다.
|
||||
|
||||
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
|
||||
use std::ops::Range;
|
||||
use std::path::Path;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
|
||||
pub struct LocalFs;
|
||||
|
||||
impl LocalFs {
|
||||
pub fn new() -> Self {
|
||||
LocalFs
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalFs {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_from_meta(name: String, meta: &std::fs::Metadata) -> VEntry {
|
||||
VEntry {
|
||||
name,
|
||||
is_dir: meta.is_dir(),
|
||||
size: meta.len(),
|
||||
mtime_ms: meta
|
||||
.modified()
|
||||
.map(crate::system_time_to_ms)
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl VfsProvider for LocalFs {
|
||||
fn capabilities(&self) -> Caps {
|
||||
Caps::RANGE_READ
|
||||
| Caps::SERVER_MOVE
|
||||
| Caps::NATIVE_TRASH
|
||||
| Caps::MTIME_WRITE
|
||||
| Caps::PARALLEL_READS
|
||||
| Caps::WATCHABLE
|
||||
}
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
|
||||
let mut rd = tokio::fs::read_dir(path).await?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(e) = rd.next_entry().await? {
|
||||
let meta = match e.metadata().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue, // 접근 불가 항목은 건너뜀
|
||||
};
|
||||
out.push(entry_from_meta(e.file_name().to_string_lossy().into_owned(), &meta));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry> {
|
||||
let meta = tokio::fs::metadata(path).await?;
|
||||
let name = Path::new(path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
Ok(entry_from_meta(name, &meta))
|
||||
}
|
||||
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
|
||||
let mut file = tokio::fs::File::open(path).await?;
|
||||
match range {
|
||||
None => Ok(Box::new(file)),
|
||||
Some(r) => {
|
||||
file.seek(std::io::SeekFrom::Start(r.start)).await?;
|
||||
let len = r.end.saturating_sub(r.start);
|
||||
Ok(Box::new(file.take(len)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
tokio::fs::rename(from, to).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
// 주의: 하드 삭제. 휴지통 삭제는 fileops 계층(trash 크레이트)이 담당하고,
|
||||
// VFS delete는 앱 레벨 휴지통 rename 이후의 퍼지 등 내부 용도로만 쓴다.
|
||||
let meta = tokio::fs::metadata(path).await?;
|
||||
if meta.is_dir() {
|
||||
return Err(VfsError::Protocol("디렉터리 삭제는 지원하지 않음".into()));
|
||||
}
|
||||
tokio::fs::remove_file(path).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
tokio::fs::create_dir_all(path).await.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn range_read_returns_exact_window() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("a.bin");
|
||||
std::fs::write(&p, b"0123456789").unwrap();
|
||||
|
||||
let fs = LocalFs::new();
|
||||
let mut r = fs
|
||||
.open_range(p.to_str().unwrap(), Some(2..6))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
r.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf, b"2345");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_dir_and_stat() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("한글파일.jpg"), b"xx").unwrap();
|
||||
std::fs::create_dir(dir.path().join("sub")).unwrap();
|
||||
|
||||
let fs = LocalFs::new();
|
||||
let entries = fs.list_dir(dir.path().to_str().unwrap()).await.unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
let file = entries.iter().find(|e| !e.is_dir).unwrap();
|
||||
assert_eq!(file.name, "한글파일.jpg");
|
||||
assert_eq!(file.size, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! SFTP 백엔드 (russh + russh-sftp, 순수 Rust, ring 백엔드).
|
||||
|
||||
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
|
||||
use russh::client;
|
||||
use russh_sftp::client::SftpSession;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SftpConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub base_path: String,
|
||||
}
|
||||
|
||||
struct ClientHandler;
|
||||
|
||||
impl client::Handler for ClientHandler {
|
||||
type Error = russh::Error;
|
||||
async fn check_server_key(
|
||||
&mut self,
|
||||
_key: &russh::keys::ssh_key::PublicKey,
|
||||
) -> std::result::Result<bool, Self::Error> {
|
||||
// v1: TOFU 미구현 — 서버 키를 신뢰 (LAN/개인 NAS 가정).
|
||||
// 프로덕션 강화 시 known_hosts 저장/검증 추가.
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SftpFs {
|
||||
sftp: Arc<Mutex<SftpSession>>,
|
||||
base: String,
|
||||
// 세션 유지용 핸들 (drop되면 연결 종료)
|
||||
_handle: Arc<client::Handle<ClientHandler>>,
|
||||
}
|
||||
|
||||
impl SftpFs {
|
||||
pub async fn connect(cfg: &SftpConfig) -> Result<SftpFs> {
|
||||
let config = Arc::new(client::Config::default());
|
||||
let mut handle = client::connect(config, (cfg.host.as_str(), cfg.port), ClientHandler)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("SSH 연결 실패: {e}")))?;
|
||||
let authed = handle
|
||||
.authenticate_password(&cfg.username, &cfg.password)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("SSH 인증 실패: {e}")))?;
|
||||
if !authed.success() {
|
||||
return Err(VfsError::Protocol("SSH 인증 거부됨".into()));
|
||||
}
|
||||
let channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("채널 오류: {e}")))?;
|
||||
channel
|
||||
.request_subsystem(true, "sftp")
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("SFTP 서브시스템 오류: {e}")))?;
|
||||
let sftp = SftpSession::new(channel.into_stream())
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("SFTP 세션 오류: {e}")))?;
|
||||
|
||||
Ok(SftpFs {
|
||||
sftp: Arc::new(Mutex::new(sftp)),
|
||||
base: cfg.base_path.trim_end_matches('/').to_string(),
|
||||
_handle: Arc::new(handle),
|
||||
})
|
||||
}
|
||||
|
||||
fn full(&self, path: &str) -> String {
|
||||
let p = path.trim_start_matches('/');
|
||||
if self.base.is_empty() {
|
||||
format!("/{p}")
|
||||
} else if p.is_empty() {
|
||||
self.base.clone()
|
||||
} else {
|
||||
format!("{}/{}", self.base, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl VfsProvider for SftpFs {
|
||||
fn capabilities(&self) -> Caps {
|
||||
Caps::RANGE_READ | Caps::SERVER_MOVE | Caps::PARALLEL_READS
|
||||
}
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
let entries = sftp
|
||||
.read_dir(&full)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("read_dir: {e}")))?;
|
||||
let mut out = Vec::new();
|
||||
for e in entries {
|
||||
let name = e.file_name();
|
||||
if name == "." || name == ".." {
|
||||
continue;
|
||||
}
|
||||
let meta = e.metadata();
|
||||
out.push(VEntry {
|
||||
name,
|
||||
is_dir: meta.is_dir(),
|
||||
size: meta.size.unwrap_or(0),
|
||||
mtime_ms: meta.mtime.map(|t| t as i64 * 1000).unwrap_or(0),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry> {
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
let meta = sftp
|
||||
.metadata(&full)
|
||||
.await
|
||||
.map_err(|_| VfsError::NotFound(path.to_string()))?;
|
||||
let name = full.rsplit('/').next().unwrap_or("").to_string();
|
||||
Ok(VEntry {
|
||||
name,
|
||||
is_dir: meta.is_dir(),
|
||||
size: meta.size.unwrap_or(0),
|
||||
mtime_ms: meta.mtime.map(|t| t as i64 * 1000).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
let mut file = sftp
|
||||
.open(&full)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("open: {e}")))?;
|
||||
match range {
|
||||
None => {
|
||||
// 전체를 메모리로 (SftpSession 핸들 수명 문제 회피)
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).await?;
|
||||
Ok(Box::new(std::io::Cursor::new(buf)))
|
||||
}
|
||||
Some(r) => {
|
||||
file.seek(std::io::SeekFrom::Start(r.start)).await?;
|
||||
let len = r.end.saturating_sub(r.start);
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
let n = file.read(&mut buf).await?;
|
||||
buf.truncate(n);
|
||||
Ok(Box::new(std::io::Cursor::new(buf)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
let (f, t) = (self.full(from), self.full(to));
|
||||
let sftp = self.sftp.lock().await;
|
||||
sftp.rename(&f, &t)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("rename: {e}")))
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
sftp.remove_file(&full)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("delete: {e}")))
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
// 존재하면 무시
|
||||
let _ = sftp.create_dir(&full).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! WebDAV 백엔드 (reqwest + rustls). 시놀로지 WebDAV Server 대상.
|
||||
//! Range를 그대로 지원해 원격 재생 시킹에 가장 유리하다.
|
||||
|
||||
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
|
||||
use reqwest_dav::{Auth, Client, ClientBuilder, Depth};
|
||||
use std::ops::Range;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebDavConfig {
|
||||
pub url: String, // 예: https://nas.example.com:5006/dav
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub base_path: String,
|
||||
}
|
||||
|
||||
pub struct WebDavFs {
|
||||
client: Client,
|
||||
base: String,
|
||||
// 원격 재생용 raw HTTP (Range 헤더 직접 전달)
|
||||
http: reqwest::Client,
|
||||
root_url: String,
|
||||
auth: (String, String),
|
||||
}
|
||||
|
||||
impl WebDavFs {
|
||||
pub async fn connect(cfg: &WebDavConfig) -> Result<WebDavFs> {
|
||||
let client = ClientBuilder::new()
|
||||
.set_host(cfg.url.clone())
|
||||
.set_auth(Auth::Basic(cfg.username.clone(), cfg.password.clone()))
|
||||
.build()
|
||||
.map_err(|e| VfsError::Protocol(format!("WebDAV 클라이언트: {e}")))?;
|
||||
let http = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(|e| VfsError::Protocol(e.to_string()))?;
|
||||
Ok(WebDavFs {
|
||||
client,
|
||||
base: cfg.base_path.trim_matches('/').to_string(),
|
||||
http,
|
||||
root_url: cfg.url.trim_end_matches('/').to_string(),
|
||||
auth: (cfg.username.clone(), cfg.password.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
fn rel(&self, path: &str) -> String {
|
||||
let p = path.trim_start_matches('/');
|
||||
if self.base.is_empty() {
|
||||
format!("/{p}")
|
||||
} else {
|
||||
format!("/{}/{}", self.base, p)
|
||||
}
|
||||
}
|
||||
|
||||
fn abs_url(&self, path: &str) -> String {
|
||||
format!("{}{}", self.root_url, self.rel(path))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl VfsProvider for WebDavFs {
|
||||
fn capabilities(&self) -> Caps {
|
||||
Caps::RANGE_READ | Caps::SERVER_MOVE | Caps::PARALLEL_READS
|
||||
}
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
|
||||
let rel = self.rel(path);
|
||||
let items = self
|
||||
.client
|
||||
.list(&rel, Depth::Number(1))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("PROPFIND: {e}")))?;
|
||||
// 요청 디렉터리 자신의 정규화 경로 (마지막 세그먼트)
|
||||
let req_seg = urldecode(rel.trim_end_matches('/').rsplit('/').next().unwrap_or(""));
|
||||
let mut out = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
reqwest_dav::list_cmd::ListEntity::File(f) => {
|
||||
let name = urldecode(f.href.trim_end_matches('/').rsplit('/').next().unwrap_or(""));
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(VEntry {
|
||||
name,
|
||||
is_dir: false,
|
||||
size: f.content_length.max(0) as u64,
|
||||
mtime_ms: f.last_modified.timestamp_millis(),
|
||||
});
|
||||
}
|
||||
reqwest_dav::list_cmd::ListEntity::Folder(d) => {
|
||||
let name = urldecode(d.href.trim_end_matches('/').rsplit('/').next().unwrap_or(""));
|
||||
// 자기 자신(요청 디렉터리)은 제외 — 이름이 같으면 스킵
|
||||
if name.is_empty() || name == req_seg {
|
||||
continue;
|
||||
}
|
||||
out.push(VEntry {
|
||||
name,
|
||||
is_dir: true,
|
||||
size: 0,
|
||||
mtime_ms: d.last_modified.timestamp_millis(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry> {
|
||||
let rel = self.rel(path);
|
||||
let items = self
|
||||
.client
|
||||
.list(&rel, Depth::Number(0))
|
||||
.await
|
||||
.map_err(|_| VfsError::NotFound(path.to_string()))?;
|
||||
let name = rel.trim_end_matches('/').rsplit('/').next().unwrap_or("").to_string();
|
||||
match items.into_iter().next() {
|
||||
Some(reqwest_dav::list_cmd::ListEntity::File(f)) => Ok(VEntry {
|
||||
name: urldecode(&name),
|
||||
is_dir: false,
|
||||
size: f.content_length.max(0) as u64,
|
||||
mtime_ms: f.last_modified.timestamp_millis(),
|
||||
}),
|
||||
Some(reqwest_dav::list_cmd::ListEntity::Folder(d)) => Ok(VEntry {
|
||||
name: urldecode(&name),
|
||||
is_dir: true,
|
||||
size: 0,
|
||||
mtime_ms: d.last_modified.timestamp_millis(),
|
||||
}),
|
||||
None => Err(VfsError::NotFound(path.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
|
||||
use futures_util::TryStreamExt;
|
||||
let url = self.abs_url(path);
|
||||
let mut req = self.http.get(&url).basic_auth(&self.auth.0, Some(&self.auth.1));
|
||||
if let Some(r) = &range {
|
||||
req = req.header("Range", format!("bytes={}-{}", r.start, r.end.saturating_sub(1)));
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| VfsError::Protocol(format!("GET: {e}")))?;
|
||||
if !resp.status().is_success() && resp.status().as_u16() != 206 {
|
||||
return Err(VfsError::Protocol(format!("HTTP {}", resp.status())));
|
||||
}
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
|
||||
Ok(Box::new(tokio_util_compat(stream)))
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
self.client
|
||||
.mv(&self.rel(from), &self.rel(to))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("MOVE: {e}")))
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
self.client
|
||||
.delete(&self.rel(path))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("DELETE: {e}")))
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let _ = self.client.mkcol(&self.rel(path)).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// bytes 스트림을 AsyncRead로 변환
|
||||
fn tokio_util_compat<S>(stream: S) -> impl tokio::io::AsyncRead + Send + Unpin
|
||||
where
|
||||
S: futures_util::Stream<Item = std::io::Result<bytes::Bytes>> + Send + Unpin + 'static,
|
||||
{
|
||||
tokio_util::io::StreamReader::new(stream)
|
||||
}
|
||||
|
||||
/// 최소 URL 디코드 (%XX + 경로 세그먼트)
|
||||
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()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn url_decode() {
|
||||
assert_eq!(urldecode("%ED%95%9C%EA%B8%80"), "한글");
|
||||
assert_eq!(urldecode("plain.jpg"), "plain.jpg");
|
||||
assert_eq!(urldecode("a%20b.png"), "a b.png");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user