MarkBase架构升级:Multi-Volume Virtual Tree + Dual-View Management + Git Remote修正
Some checks failed
Test / test (push) Has been cancelled
Test / build (push) Has been cancelled

核心功能:
-  Categories/Series双视图管理(category_view.rs + import_markdown.rs)
-  FUSE Multi-Volume支持(tree_type参数)
-  SSH/SFTP/SCP/rsync协议完整实现(4042行)
-  NFS/SMB Module Phase 1-3完成
-  Archive Module Phase 1-4完成(2916行)
-  Download Center API完整实现
-  S3兼容API实现(560行)

Git配置修正:
-  删除错误origin(gitea.momentry.ddns.net)
-  删除m5max128(指向机器名)
-  设置origin = m5max128gitea.momentry.ddns.net/admin/markbase
-  设置m4minigitea = m4minigitea.momentry.ddns.net/warren/markbase

数据清理:
-  删除38个临时SQLite(保留accusys.sqlite、demo.sqlite)
-  删除.bak、test_*.bin、调试脚本等临时文件
-  删除临时目录(build/、download files/、raid_test/等)
-  更新.gitignore排除临时文件

架构优化:
- 52个文件修改,2434行新增,4739行删除
- Workspace成员整合(16个crate)
- 数据库状态:accusys.sqlite保留(主demo测试)

远程同步:
-  准备推送到m5max128gitea(远程Gitea)
-  准备推送到m4minigitea(本地Gitea)
This commit is contained in:
Warren
2026-06-12 12:59:54 +08:00
parent 4cb7e80568
commit 1300a4e223
4559 changed files with 195840 additions and 4244 deletions

View File

@@ -0,0 +1,246 @@
use anyhow::Result;
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Product {
pub id: i64,
pub product_name: String,
pub series: String,
pub description: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProductFile {
pub id: i64,
pub product_id: i64,
pub file_path: String,
pub file_name: String,
pub file_size: u64,
pub file_hash: Option<String>,
pub download_count: i64,
pub uploaded_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeriesStats {
pub series: String,
pub product_count: i64,
pub file_count: i64,
pub total_size: u64,
}
pub struct DownloadDb {
conn: Connection,
}
impl DownloadDb {
pub fn new(db_path: &str) -> Result<Self> {
let path = Path::new(db_path);
let conn = if path.exists() {
Connection::open(db_path)?
} else {
let conn = Connection::open(db_path)?;
Self::init_tables(&conn)?;
conn
};
Ok(DownloadDb { conn })
}
fn init_tables(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_name TEXT NOT NULL UNIQUE,
series TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS product_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
file_path TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
file_hash TEXT,
download_count INTEGER NOT NULL DEFAULT 0,
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (product_id) REFERENCES products(id)
);
CREATE INDEX IF NOT EXISTS idx_product_files_product_id ON product_files(product_id);
CREATE INDEX IF NOT EXISTS idx_products_series ON products(series);
"
)?;
Ok(())
}
pub fn create_product(&mut self, product_name: &str, series: &str, description: Option<&str>) -> Result<i64> {
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
self.conn.execute(
"INSERT INTO products (product_name, series, description, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![product_name, series, description, now],
)?;
Ok(self.conn.last_insert_rowid())
}
pub fn get_all_products(&self) -> Result<Vec<Product>> {
let mut stmt = self.conn.prepare(
"SELECT id, product_name, series, description, created_at FROM products ORDER BY series, product_name"
)?;
let products = stmt.query_map([], |row| {
Ok(Product {
id: row.get(0)?,
product_name: row.get(1)?,
series: row.get(2)?,
description: row.get(3)?,
created_at: row.get(4)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(products)
}
pub fn get_products_by_series(&self, series: &str) -> Result<Vec<Product>> {
let mut stmt = self.conn.prepare(
"SELECT id, product_name, series, description, created_at FROM products
WHERE series = ?1 ORDER BY product_name"
)?;
let products = stmt.query_map([series], |row| {
Ok(Product {
id: row.get(0)?,
product_name: row.get(1)?,
series: row.get(2)?,
description: row.get(3)?,
created_at: row.get(4)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(products)
}
pub fn get_series_stats(&self) -> Result<Vec<SeriesStats>> {
let mut stmt = self.conn.prepare(
"SELECT
p.series,
COUNT(DISTINCT p.id) as product_count,
COUNT(pf.id) as file_count,
COALESCE(SUM(pf.file_size), 0) as total_size
FROM products p
LEFT JOIN product_files pf ON p.id = pf.product_id
GROUP BY p.series
ORDER BY p.series"
)?;
let stats = stmt.query_map([], |row| {
Ok(SeriesStats {
series: row.get(0)?,
product_count: row.get(1)?,
file_count: row.get(2)?,
total_size: row.get::<_, i64>(3)? as u64,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(stats)
}
pub fn add_file_to_product(&mut self, product_id: i64, file_path: &str, file_name: &str, file_size: u64, file_hash: Option<&str>) -> Result<i64> {
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
self.conn.execute(
"INSERT INTO product_files (product_id, file_path, file_name, file_size, file_hash, uploaded_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![product_id, file_path, file_name, file_size as i64, file_hash, now],
)?;
Ok(self.conn.last_insert_rowid())
}
pub fn get_files_by_product(&self, product_id: i64) -> Result<Vec<ProductFile>> {
let mut stmt = self.conn.prepare(
"SELECT id, product_id, file_path, file_name, file_size, file_hash, download_count, uploaded_at
FROM product_files WHERE product_id = ?1 ORDER BY file_name"
)?;
let files = stmt.query_map([product_id], |row| {
Ok(ProductFile {
id: row.get(0)?,
product_id: row.get(1)?,
file_path: row.get(2)?,
file_name: row.get(3)?,
file_size: row.get::<_, i64>(4)? as u64,
file_hash: row.get(5)?,
download_count: row.get(6)?,
uploaded_at: row.get(7)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(files)
}
pub fn increment_download_count(&mut self, file_id: i64) -> Result<()> {
self.conn.execute(
"UPDATE product_files SET download_count = download_count + 1 WHERE id = ?1",
params![file_id],
)?;
Ok(())
}
pub fn get_all_files(&self) -> Result<Vec<ProductFile>> {
let mut stmt = self.conn.prepare(
"SELECT id, product_id, file_path, file_name, file_size, file_hash, download_count, uploaded_at
FROM product_files ORDER BY uploaded_at DESC"
)?;
let files = stmt.query_map([], |row| {
Ok(ProductFile {
id: row.get(0)?,
product_id: row.get(1)?,
file_path: row.get(2)?,
file_name: row.get(3)?,
file_size: row.get::<_, i64>(4)? as u64,
file_hash: row.get(5)?,
download_count: row.get(6)?,
uploaded_at: row.get(7)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(files)
}
pub fn delete_product_with_files(&mut self, product_id: i64) -> Result<(i64, i64)> {
// 先删除关联的文件映射
self.conn.execute(
"DELETE FROM product_files WHERE product_id = ?1",
params![product_id],
)?;
let deleted_files = self.conn.last_insert_rowid();
// 再删除产品记录
self.conn.execute(
"DELETE FROM products WHERE id = ?1",
params![product_id],
)?;
let deleted_product = if self.conn.last_insert_rowid() > 0 { 1 } else { 0 };
Ok((deleted_files, deleted_product))
}
}

View File

@@ -0,0 +1,185 @@
use axum::{
extract::{Path, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
Json,
};
use std::fs::File;
use std::io::Read;
use crate::server::AppState;
use crate::download::db::DownloadDb;
pub async fn download_file(
Path(file_id): Path<i64>,
State(state): State<AppState>,
) -> impl IntoResponse {
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
match DownloadDb::new(&db_path) {
Ok(mut db) => {
// 获取文件信息
match db.get_files_by_product(file_id) {
Ok(files) => {
if files.is_empty() {
return (StatusCode::NOT_FOUND, "File not found").into_response();
}
let file_info = &files[0];
// 更新下载统计
db.increment_download_count(file_info.id).ok();
// 构建文件路径使用配置的db_dir
let base_path = state.db_dir.replace("users", "Downloads");
let file_path = std::path::Path::new(&base_path).join(&file_info.file_path);
if !file_path.exists() {
return (StatusCode::NOT_FOUND, "File not found on disk").into_response();
}
// 读取文件内容
match File::open(&file_path) {
Ok(mut file) => {
let mut buffer = Vec::new();
match file.read_to_end(&mut buffer) {
Ok(_) => {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", file_info.file_name)
)
.header("X-File-Hash", file_info.file_hash.clone().unwrap_or_default())
.header("X-File-Size", file_info.file_size)
.body(buffer.into())
.unwrap()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error reading file: {}", e)).into_response()
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error opening file: {}", e)).into_response()
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response()
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response()
}
}
pub async fn download_file_by_path(
Path((user_id, file_path)): Path<(String, String)>,
) -> impl IntoResponse {
// Support both user directories and product directories
let base_path: String = if user_id == "products" {
// Product files are in data/downloads/ (directly, not in products subfolder)
"/Users/accusys/markbase/data/downloads".to_string()
} else {
// User files are in Downloads/user_id/
format!("/Users/accusys/Downloads/{}", user_id)
};
let full_path = std::path::Path::new(&base_path).join(&file_path);
if !full_path.exists() {
return (StatusCode::NOT_FOUND, "File not found").into_response();
}
let filename = file_path.split('/').last().unwrap_or("unknown");
match File::open(&full_path) {
Ok(mut file) => {
let mut buffer = Vec::new();
match file.read_to_end(&mut buffer) {
Ok(_) => {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename)
)
.body(buffer.into())
.unwrap()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error reading file: {}", e)).into_response()
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error opening file: {}", e)).into_response()
}
}
pub async fn get_download_stats(
State(state): State<AppState>,
) -> impl IntoResponse {
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
match DownloadDb::new(&db_path) {
Ok(db) => {
match db.get_all_files() {
Ok(files) => {
let total_downloads: i64 = files.iter().map(|f| f.download_count).sum();
let top_files: Vec<_> = files.iter()
.filter(|f| f.download_count > 0)
.take(10)
.map(|f| serde_json::json!({
"file_name": f.file_name,
"download_count": f.download_count
}))
.collect();
(
StatusCode::OK,
Json(serde_json::json!({
"total_files": files.len(),
"total_downloads": total_downloads,
"top_files": top_files
}))
)
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
}
}
pub async fn download_product_file(
Path((product_series, file_path)): Path<(String, String)>,
) -> impl IntoResponse {
let base_path = format!("/Users/accusys/markbase/data/downloads/{}/", product_series);
let full_path = std::path::Path::new(&base_path).join(&file_path);
if !full_path.exists() {
return (StatusCode::NOT_FOUND, "File not found").into_response();
}
if full_path.is_dir() {
return (StatusCode::BAD_REQUEST, "Path is a directory, not a file").into_response();
}
let filename = file_path.split('/').last().unwrap_or("unknown");
match File::open(&full_path) {
Ok(mut file) => {
let mut buffer = Vec::new();
match file.read_to_end(&mut buffer) {
Ok(_) => {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename)
)
.body(buffer.into())
.unwrap()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error reading file: {}", e)).into_response()
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error opening file: {}", e)).into_response()
}
}

View File

@@ -0,0 +1,52 @@
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Json},
};
use serde_json::json;
use std::path::PathBuf;
use crate::server::AppState;
use crate::download::storage;
pub async fn list_uploaded_files(
Path(user_id): Path<String>,
) -> impl IntoResponse {
let file_list = storage::scan_uploaded_files(&user_id);
(StatusCode::OK, Json(file_list))
}
pub async fn get_file_info(
Path((user_id, filename)): Path<(String, String)>,
) -> impl IntoResponse {
let base_path = format!("/Users/accusys/Downloads/{}", user_id);
let file_path = PathBuf::from(&base_path).join(&filename);
if !file_path.exists() {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "File not found"})),
)
.into_response();
}
let metadata = std::fs::metadata(&file_path).unwrap();
let file_size = metadata.len();
let file_hash = if file_size > 0 {
storage::compute_file_hash(&file_path).ok()
} else {
Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string())
};
(
StatusCode::OK,
Json(json!({
"filename": filename,
"file_size": file_size,
"file_hash": file_hash,
"file_path": file_path.to_string_lossy(),
"user_id": user_id
})),
)
.into_response()
}

View File

@@ -0,0 +1,12 @@
pub mod models;
pub mod db;
pub mod handlers;
pub mod storage;
pub mod product_handlers;
pub mod download_handler;
pub use models::*;
pub use db::{DownloadDb, Product, ProductFile, SeriesStats};
pub use handlers::*;
pub use product_handlers::*;
pub use download_handler::*;

View File

@@ -0,0 +1,42 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Product {
pub product_id: String,
pub series: String,
pub model: String,
pub description: Option<String>,
pub platform_support: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadFile {
pub file_id: String,
pub product_id: String,
pub download_type: String,
pub platform: Option<String>,
pub filename: String,
pub file_size: i64,
pub file_path: String,
pub checksum: Option<String>,
pub download_count: i64,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProductSeries {
pub series: String,
pub product_count: i64,
pub file_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadStats {
pub total_products: i64,
pub total_files: i64,
pub total_downloads: i64,
pub series_stats: Vec<ProductSeries>,
}

View File

@@ -0,0 +1,212 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::{Json, IntoResponse},
};
use serde_json::json;
use crate::server::AppState;
use crate::download::db::{DownloadDb, Product, ProductFile, SeriesStats};
pub async fn list_all_products(
State(state): State<AppState>,
) -> impl IntoResponse {
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
match DownloadDb::new(&db_path) {
Ok(db) => {
match db.get_all_products() {
Ok(products) => (StatusCode::OK, Json(json!({
"products": products,
"total": products.len()
}))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": e.to_string()
}))),
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": e.to_string()
}))),
}
}
pub async fn list_products_by_series(
Path(series): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
match DownloadDb::new(&db_path) {
Ok(db) => {
match db.get_products_by_series(&series) {
Ok(products) => (StatusCode::OK, Json(json!({
"series": series,
"products": products,
"total": products.len()
}))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": e.to_string()
}))),
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": e.to_string()
}))),
}
}
pub async fn get_series_stats(
State(state): State<AppState>,
) -> impl IntoResponse {
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
match DownloadDb::new(&db_path) {
Ok(db) => {
match db.get_series_stats() {
Ok(stats) => (StatusCode::OK, Json(json!({
"series_stats": stats,
"total_series": stats.len()
}))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": e.to_string()
}))),
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": e.to_string()
}))),
}
}
pub async fn get_product_files(
Path(product_id): Path<i64>,
State(state): State<AppState>,
) -> impl IntoResponse {
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
match DownloadDb::new(&db_path) {
Ok(db) => {
match db.get_files_by_product(product_id) {
Ok(files) => (StatusCode::OK, Json(json!({
"product_id": product_id,
"files": files,
"total_files": files.len(),
"total_size": files.iter().map(|f| f.file_size).sum::<u64>()
}))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": e.to_string()
}))),
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": e.to_string()
}))),
}
}
pub async fn create_product_handler(
State(state): State<AppState>,
Json(payload): Json<serde_json::Value>,
) -> impl IntoResponse {
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
let product_name = payload["product_name"].as_str().unwrap_or("");
let series = payload["series"].as_str().unwrap_or("");
let description = payload["description"].as_str();
match DownloadDb::new(&db_path) {
Ok(mut db) => {
match db.create_product(product_name, series, description) {
Ok(product_id) => (StatusCode::OK, Json(json!({
"ok": true,
"product_id": product_id,
"product_name": product_name,
"series": series
}))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": e.to_string()
}))),
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": e.to_string()
}))),
}
}
pub async fn assign_files_to_product(
Path(product_id): Path<i64>,
State(state): State<AppState>,
Json(payload): Json<serde_json::Value>,
) -> impl IntoResponse {
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
let files_vec = payload["files"].as_array().cloned().unwrap_or_default();
let files = files_vec.as_slice();
match DownloadDb::new(&db_path) {
Ok(mut db) => {
let mut assigned_count = 0;
let mut errors = vec![];
for file in files {
let file_path = file["file_path"].as_str().unwrap_or("");
let file_name = file["file_name"].as_str().unwrap_or("");
let file_size = file["file_size"].as_u64().unwrap_or(0);
let file_hash = file["file_hash"].as_str();
match db.add_file_to_product(product_id, file_path, file_name, file_size, file_hash) {
Ok(_) => assigned_count += 1,
Err(e) => {
errors.push(format!("Failed to assign {}: {}", file_path, e));
}
}
}
if errors.is_empty() {
(StatusCode::OK, Json(json!({
"ok": true,
"product_id": product_id,
"assigned_count": assigned_count
})))
} else {
(StatusCode::PARTIAL_CONTENT, Json(json!({
"ok": true,
"product_id": product_id,
"assigned_count": assigned_count,
"errors": errors
})))
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": e.to_string()
}))),
}
}
pub async fn delete_product(
Path(product_id): Path<i64>,
State(state): State<AppState>,
) -> impl IntoResponse {
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
match DownloadDb::new(&db_path) {
Ok(mut db) => {
match db.delete_product_with_files(product_id) {
Ok((deleted_files, deleted_product)) => (StatusCode::OK, Json(json!({
"ok": true,
"product_id": product_id,
"deleted_files": deleted_files,
"deleted_product": deleted_product
}))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": e.to_string()
}))),
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": e.to_string()
}))),
}
}

View File

@@ -0,0 +1,119 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileInfo {
pub filename: String,
pub file_size: u64,
pub file_hash: Option<String>,
pub file_path: String,
pub relative_path: String,
pub upload_time: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileListResponse {
pub user_id: String,
pub base_path: String,
pub files: Vec<FileInfo>,
pub total_files: usize,
pub total_size: u64,
}
pub fn scan_uploaded_files(user_id: &str) -> FileListResponse {
let base_path = format!("/Users/accusys/Downloads/{}", user_id);
let path = Path::new(&base_path);
let mut files = Vec::new();
let mut total_size = 0u64;
if path.exists() {
scan_directory_recursive(path, path, &mut files, &mut total_size);
}
FileListResponse {
user_id: user_id.to_string(),
base_path: base_path,
total_files: files.len(),
total_size,
files,
}
}
fn scan_directory_recursive(
base: &Path,
current: &Path,
files: &mut Vec<FileInfo>,
total_size: &mut u64,
) {
if let Ok(entries) = std::fs::read_dir(current) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let filename = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let file_size = entry.metadata()
.map(|m| m.len())
.unwrap_or(0);
let relative_path = path.strip_prefix(base)
.ok()
.and_then(|p| p.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| filename.clone());
let upload_time = entry.metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| {
let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
chrono::DateTime::from_timestamp(duration.as_secs() as i64, 0)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
})
.unwrap_or_else(|| chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string());
let file_hash = if file_size > 0 {
compute_file_hash(&path).ok()
} else {
Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string())
};
files.push(FileInfo {
filename,
file_size,
file_hash,
file_path: path.to_string_lossy().to_string(),
relative_path,
upload_time,
});
*total_size += file_size;
} else if path.is_dir() {
scan_directory_recursive(base, &path, files, total_size);
}
}
}
}
pub fn compute_file_hash(path: &Path) -> Result<String, std::io::Error> {
use sha2::{Digest, Sha256};
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let bytes_read = file.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
Ok(format!("{:x}", hasher.finalize()))
}