This commit is contained in:
2025-10-09 22:16:29 +02:00
commit 768f8cb21c
33 changed files with 12047 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# macOS
.DS_Store
# Rust
/backend/target/
/backend/.env
/backend/.env.*
/backend/.cargo/
# Node/Frontend
/frontend/node_modules/
/frontend/dist/
/frontend/.env
/frontend/.env.*
# Logs and temp
*.log
*.tmp
*.swp
# Docker artifacts
*.pid
# Environment overrides
.env
.env.local
+3566
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
[package]
name = "paperless-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
# Web framework
axum = { version = "0.7", features = ["multipart"] }
tokio = { version = "1", features = ["full"] }
tower = { version = "0.4", features = ["make"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
axum-extra = { version = "0.9", features = ["typed-header"] }
# Database
diesel = { version = "2.1", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
diesel_migrations = "2.1"
uuid = { version = "1.6", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
# S3
aws-config = "1.1"
aws-sdk-s3 = "1.14"
aws-credential-types = "1.2"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Utilities
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenv = "0.15"
sha2 = "0.10"
hex = "0.4"
bytes = "1.5"
# Error handling
thiserror = "1.0"
anyhow = "1.0"
# Authentication & security
argon2 = "0.5"
jsonwebtoken = "9"
# Misc
rand = "0.8"
+6
View File
@@ -0,0 +1,6 @@
[print_schema]
file = "src/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
[migrations_directory]
dir = "migrations"
+19
View File
@@ -0,0 +1,19 @@
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use rand::thread_rng;
use std::env;
fn main() {
let password = env::args()
.nth(1)
.expect("Usage: cargo run --example hash_password <password>");
let salt = SaltString::generate(&mut thread_rng());
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.expect("hashing failed")
.to_string();
println!("{}", hash);
}
@@ -0,0 +1,11 @@
DROP INDEX IF EXISTS idx_document_tags_tag;
DROP TABLE IF EXISTS document_tags;
DROP INDEX IF EXISTS idx_document_versions_document;
DROP TABLE IF EXISTS document_versions;
DROP INDEX IF EXISTS idx_documents_deleted_at;
DROP INDEX IF EXISTS idx_documents_folder;
DROP TABLE IF EXISTS documents;
DROP INDEX IF EXISTS idx_folders_parent;
DROP TABLE IF EXISTS folders;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS users;
@@ -0,0 +1,77 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE users (
id UUID PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE folders (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
path_cache VARCHAR(1000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name)
);
CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE TABLE documents (
id UUID PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
content_type VARCHAR(100),
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
current_version INTEGER NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX idx_documents_folder ON documents(folder_id);
CREATE INDEX idx_documents_deleted_at ON documents(deleted_at);
CREATE TABLE document_versions (
id UUID PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL,
s3_key VARCHAR(500) NOT NULL,
size_bytes BIGINT NOT NULL,
checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
);
CREATE INDEX idx_document_versions_document ON document_versions(document_id);
CREATE TABLE tags (
id UUID PRIMARY KEY,
label VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE document_tags (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
assigned_by UUID REFERENCES users(id),
PRIMARY KEY (document_id, tag_id)
);
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
INSERT INTO users (id, username, password_hash, role)
VALUES (
gen_random_uuid(),
'admin',
'$argon2id$v=19$m=19456,t=2,p=1$UMkfsNut028fmZupy9JoQg$/YFvGQoEZ2hhMiDCyv68ZROF97GcwAxxRwRgwSbpX5U',
'admin'
);
+63
View File
@@ -0,0 +1,63 @@
use anyhow::Result;
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::config::AppConfig;
#[derive(Clone)]
pub struct JwtService {
encoding: EncodingKey,
decoding: DecodingKey,
issuer: String,
audience: String,
expiry: Duration,
}
impl JwtService {
pub fn from_config(config: &AppConfig) -> Result<Self> {
Ok(Self {
encoding: EncodingKey::from_secret(config.jwt_secret.as_bytes()),
decoding: DecodingKey::from_secret(config.jwt_secret.as_bytes()),
issuer: config.jwt_issuer.clone(),
audience: config.jwt_audience.clone(),
expiry: Duration::minutes(config.jwt_expiry_minutes),
})
}
pub fn generate_token(&self, user_id: Uuid, username: &str, role: &str) -> Result<String> {
let now = Utc::now();
let exp = now + self.expiry;
let claims = Claims {
sub: user_id,
username: username.to_owned(),
role: role.to_owned(),
iss: self.issuer.clone(),
aud: self.audience.clone(),
iat: now.timestamp() as usize,
exp: exp.timestamp() as usize,
};
Ok(encode(&Header::default(), &claims, &self.encoding)?)
}
pub fn verify_token(&self, token: &str) -> Result<Claims> {
let mut validation = Validation::default();
validation.set_audience(&[self.audience.clone()]);
validation.set_issuer(&[self.issuer.clone()]);
let data = decode::<Claims>(token, &self.decoding, &validation)?;
Ok(data.claims)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
pub sub: Uuid,
pub username: String,
pub role: String,
pub iss: String,
pub aud: String,
pub iat: usize,
pub exp: usize,
}
+42
View File
@@ -0,0 +1,42 @@
pub mod jwt;
pub mod password;
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use axum_extra::headers::{authorization::Bearer, Authorization};
use axum_extra::TypedHeader;
use serde::{Deserialize, Serialize};
use crate::{error::AppError, state::AppState};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthenticatedUser {
pub user_id: uuid::Uuid,
pub username: String,
pub role: String,
}
#[async_trait]
impl FromRequestParts<AppState> for AuthenticatedUser {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let TypedHeader(Authorization(bearer)) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
.await
.map_err(|_| AppError::unauthorized())?;
let claims = state
.jwt
.verify_token(bearer.token())
.map_err(|_| AppError::unauthorized())?;
Ok(AuthenticatedUser {
user_id: claims.sub,
username: claims.username,
role: claims.role,
})
}
}
+12
View File
@@ -0,0 +1,12 @@
use anyhow::{anyhow, Result};
use argon2::{
password_hash::{PasswordHash, PasswordVerifier},
Argon2,
};
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
+58
View File
@@ -0,0 +1,58 @@
use std::env;
use anyhow::{Context, Result};
#[derive(Clone, Debug)]
pub struct AppConfig {
pub database_url: String,
pub server_host: String,
pub server_port: u16,
pub jwt_secret: String,
pub jwt_issuer: String,
pub jwt_audience: String,
pub jwt_expiry_minutes: i64,
pub aws_endpoint_url: Option<String>,
pub aws_access_key_id: Option<String>,
pub aws_secret_access_key: Option<String>,
pub aws_region: String,
pub s3_bucket: String,
}
impl AppConfig {
pub fn from_env() -> Result<Self> {
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let server_port = env::var("SERVER_PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.context("SERVER_PORT must be a valid u16")?;
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "paperless-neo".to_string());
let jwt_audience =
env::var("JWT_AUDIENCE").unwrap_or_else(|_| "paperless-neo-clients".to_string());
let jwt_expiry_minutes = env::var("JWT_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("JWT_EXPIRY_MINUTES must be an integer")?;
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
let aws_region = env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
Ok(Self {
database_url,
server_host,
server_port,
jwt_secret,
jwt_issuer,
jwt_audience,
jwt_expiry_minutes,
aws_endpoint_url,
aws_access_key_id,
aws_secret_access_key,
aws_region,
s3_bucket,
})
}
}
+15
View File
@@ -0,0 +1,15 @@
use std::time::Duration;
use diesel::pg::PgConnection;
use diesel::r2d2::{ConnectionManager, Pool};
pub type PgPool = Pool<ConnectionManager<PgConnection>>;
pub fn init_pool(database_url: &str) -> anyhow::Result<PgPool> {
let manager = ConnectionManager::<PgConnection>::new(database_url);
let pool = Pool::builder()
.max_size(16)
.connection_timeout(Duration::from_secs(10))
.build(manager)?;
Ok(pool)
}
+88
View File
@@ -0,0 +1,88 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use std::fmt::Display;
pub type AppResult<T> = Result<T, AppError>;
#[derive(Debug)]
pub struct AppError {
status: StatusCode,
message: String,
}
impl AppError {
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self {
status,
message: message.into(),
}
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, message)
}
pub fn unauthorized() -> Self {
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
}
pub fn not_found() -> Self {
Self::new(StatusCode::NOT_FOUND, "resource not found")
}
pub fn internal<E: Display>(error: E) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.status;
let body = Json(ErrorResponse {
error: self.message,
});
(status, body).into_response()
}
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}
impl From<diesel::result::Error> for AppError {
fn from(value: diesel::result::Error) -> Self {
match value {
diesel::result::Error::NotFound => AppError::not_found(),
_ => AppError::internal(value),
}
}
}
impl From<jsonwebtoken::errors::Error> for AppError {
fn from(value: jsonwebtoken::errors::Error) -> Self {
AppError::internal(value)
}
}
impl From<anyhow::Error> for AppError {
fn from(value: anyhow::Error) -> Self {
AppError::internal(value)
}
}
impl From<std::io::Error> for AppError {
fn from(value: std::io::Error) -> Self {
AppError::internal(value)
}
}
impl From<serde_json::Error> for AppError {
fn from(value: serde_json::Error) -> Self {
AppError::internal(value)
}
}
+52
View File
@@ -0,0 +1,52 @@
mod auth;
mod config;
mod db;
mod error;
mod models;
mod routes;
mod s3;
mod schema;
mod state;
use std::net::SocketAddr;
use tokio::net::TcpListener;
use tower::make::Shared;
use tracing_subscriber::EnvFilter;
use crate::auth::jwt::JwtService;
use crate::config::AppConfig;
use crate::s3::build_client;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
init_tracing();
let config = AppConfig::from_env()?;
let pool = db::init_pool(&config.database_url)?;
let s3_client = build_client(&config).await?;
let jwt = JwtService::from_config(&config)?;
let state = AppState::new(pool, config, s3_client, jwt);
let router = routes::create_router(state.clone());
let addr: SocketAddr =
format!("{}:{}", state.config.server_host, state.config.server_port).parse()?;
let listener = TcpListener::bind(addr).await?;
tracing::info!("listening on {}", addr);
axum::serve(listener, Shared::new(router)).await?;
Ok(())
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.compact()
.init();
}
+137
View File
@@ -0,0 +1,137 @@
use chrono::NaiveDateTime;
use diesel::prelude::*;
use uuid::Uuid;
use crate::schema::*;
#[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = users)]
pub struct User {
pub id: Uuid,
pub username: String,
pub password_hash: String,
pub role: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = users)]
pub struct NewUser {
pub id: Uuid,
pub username: String,
pub password_hash: String,
pub role: String,
}
#[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = folders)]
pub struct Folder {
pub id: Uuid,
pub name: String,
pub parent_id: Option<Uuid>,
pub path_cache: Option<String>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = folders)]
pub struct NewFolder {
pub id: Uuid,
pub name: String,
pub parent_id: Option<Uuid>,
pub path_cache: Option<String>,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = documents)]
#[diesel(belongs_to(Folder, foreign_key = folder_id))]
pub struct Document {
pub id: Uuid,
pub filename: String,
pub original_name: String,
pub content_type: Option<String>,
pub folder_id: Option<Uuid>,
pub current_version: i32,
pub uploaded_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub metadata: serde_json::Value,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = documents)]
pub struct NewDocument {
pub id: Uuid,
pub filename: String,
pub original_name: String,
pub content_type: Option<String>,
pub folder_id: Option<Uuid>,
pub current_version: i32,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = document_versions)]
#[diesel(belongs_to(Document))]
pub struct DocumentVersion {
pub id: Uuid,
pub document_id: Uuid,
pub version_number: i32,
pub s3_key: String,
pub size_bytes: i64,
pub checksum: String,
pub created_at: NaiveDateTime,
pub operations_summary: serde_json::Value,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = document_versions)]
pub struct NewDocumentVersion {
pub id: Uuid,
pub document_id: Uuid,
pub version_number: i32,
pub s3_key: String,
pub size_bytes: i64,
pub checksum: String,
pub operations_summary: serde_json::Value,
}
#[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = tags)]
pub struct Tag {
pub id: Uuid,
pub label: String,
pub color: Option<String>,
pub created_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = tags)]
pub struct NewTag {
pub id: Uuid,
pub label: String,
pub color: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Queryable, Associations)]
#[diesel(table_name = document_tags)]
#[diesel(belongs_to(Document))]
#[diesel(belongs_to(Tag))]
#[diesel(primary_key(document_id, tag_id))]
pub struct DocumentTag {
pub document_id: Uuid,
pub tag_id: Uuid,
pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = document_tags)]
pub struct NewDocumentTag {
pub document_id: Uuid,
pub tag_id: Uuid,
pub assigned_by: Option<Uuid>,
}
+61
View File
@@ -0,0 +1,61 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use crate::{
auth::{password, AuthenticatedUser},
error::{AppError, AppResult},
models::User,
schema::users::dsl,
state::AppState,
};
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
}
pub async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> AppResult<Json<LoginResponse>> {
let mut conn = state.db()?;
let user: User = dsl::users
.filter(dsl::username.eq(&payload.username))
.first(&mut conn)?;
let valid = password::verify_password(&payload.password, &user.password_hash)
.map_err(|_| AppError::unauthorized())?;
if !valid {
return Err(AppError::unauthorized());
}
let token = state
.jwt
.generate_token(user.id, &user.username, &user.role)
.map_err(AppError::from)?;
Ok(Json(LoginResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}))
}
pub async fn logout(_user: AuthenticatedUser) -> impl IntoResponse {
StatusCode::NO_CONTENT
}
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user)
}
+534
View File
@@ -0,0 +1,534 @@
use std::collections::HashMap;
use std::time::Duration;
use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream;
use axum::extract::{Json, Multipart, Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use chrono::{DateTime, NaiveDateTime, Utc};
use diesel::dsl::exists;
use diesel::{prelude::*, PgConnection};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::auth::AuthenticatedUser;
use crate::error::{AppError, AppResult};
use crate::models::{
Document, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag,
};
use crate::schema::{document_tags, document_versions, documents, folders, tags};
use crate::state::AppState;
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
#[derive(Deserialize)]
pub struct DocumentListQuery {
pub folder_id: Option<Uuid>,
#[serde(default)]
pub include_deleted: bool,
}
#[derive(Serialize)]
pub struct TagResponse {
pub id: Uuid,
pub label: String,
pub color: Option<String>,
}
impl From<Tag> for TagResponse {
fn from(tag: Tag) -> Self {
Self {
id: tag.id,
label: tag.label,
color: tag.color,
}
}
}
#[derive(Serialize)]
pub struct DocumentResponse {
pub id: Uuid,
pub filename: String,
pub original_name: String,
pub content_type: Option<String>,
pub folder_id: Option<Uuid>,
pub current_version: i32,
pub uploaded_at: String,
pub updated_at: String,
pub deleted_at: Option<String>,
pub metadata: Value,
pub tags: Vec<TagResponse>,
}
#[derive(Serialize)]
pub struct DocumentVersionResponse {
pub id: Uuid,
pub version_number: i32,
pub s3_key: String,
pub size_bytes: i64,
pub checksum: String,
pub created_at: String,
pub operations_summary: Value,
}
#[derive(Serialize)]
pub struct DocumentDetailResponse {
pub document: DocumentResponse,
pub current_version: DocumentVersionResponse,
}
#[derive(Serialize)]
pub struct DocumentDownloadResponse {
pub url: String,
pub expires_in: u64,
pub filename: String,
pub content_type: Option<String>,
pub size_bytes: i64,
}
#[derive(Deserialize)]
pub struct MoveDocumentRequest {
pub folder_id: Option<Uuid>,
}
#[derive(Deserialize)]
pub struct AssignTagsRequest {
pub tag_ids: Vec<Uuid>,
}
pub async fn list_documents(
State(state): State<AppState>,
Query(query): Query<DocumentListQuery>,
) -> AppResult<Json<Vec<DocumentResponse>>> {
let mut conn = state.db()?;
let mut base_query = documents::table.into_boxed();
if !query.include_deleted {
base_query = base_query.filter(documents::deleted_at.is_null());
}
match query.folder_id {
Some(folder_id) => {
base_query = base_query.filter(documents::folder_id.eq(Some(folder_id)));
}
None => {
base_query = base_query.filter(documents::folder_id.is_null());
}
}
let docs: Vec<Document> = base_query
.order(documents::uploaded_at.desc())
.load(&mut conn)?;
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let response = docs
.into_iter()
.map(|doc| {
let tags = tags_map.get(&doc.id).cloned();
to_document_response(doc, tags)
})
.collect();
Ok(Json(response))
}
pub async fn get_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
) -> AppResult<Json<DocumentDetailResponse>> {
let mut conn = state.db()?;
let doc: Document = documents::table.find(document_id).first(&mut conn)?;
if doc.deleted_at.is_some() {
return Err(AppError::not_found());
}
let current_version: DocumentVersion = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::version_number.eq(doc.current_version))
.first(&mut conn)?;
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
Ok(Json(DocumentDetailResponse {
document: to_document_response(doc, tags_map.get(&document_id).cloned()),
current_version: to_version_response(current_version),
}))
}
pub async fn upload_document(
State(state): State<AppState>,
_user: AuthenticatedUser,
mut multipart: Multipart,
) -> AppResult<Json<DocumentDetailResponse>> {
let mut file_bytes: Option<Vec<u8>> = None;
let mut original_name: Option<String> = None;
let mut content_type: Option<String> = None;
let mut folder_id: Option<Uuid> = None;
let mut metadata: Value = Value::Object(Default::default());
while let Some(field) = multipart
.next_field()
.await
.map_err(|err| AppError::bad_request(format!("invalid multipart data: {err}")))?
{
let name = field.name().map(|n| n.to_string());
match name.as_deref() {
Some("file") => {
let file_name = field.file_name().map(|n| n.to_string());
original_name = file_name.clone();
content_type = field.content_type().map(|mime| mime.to_string());
let data = field.bytes().await.map_err(|err| {
AppError::bad_request(format!("failed to read file bytes: {err}"))
})?;
file_bytes = Some(data.to_vec());
}
Some("folder_id") => {
let value = field
.text()
.await
.map_err(|err| AppError::bad_request(format!("invalid folder id: {err}")))?;
if !value.trim().is_empty() {
let parsed = Uuid::parse_str(value.trim())
.map_err(|_| AppError::bad_request("folder_id must be a valid UUID"))?;
folder_id = Some(parsed);
}
}
Some("metadata") => {
let value = field
.text()
.await
.map_err(|err| AppError::bad_request(format!("invalid metadata: {err}")))?;
metadata = serde_json::from_str(&value).map_err(|err| {
AppError::bad_request(format!("metadata must be valid JSON: {err}"))
})?;
}
_ => {}
}
}
let file_bytes = file_bytes.ok_or_else(|| AppError::bad_request("file field is required"))?;
let original_name = original_name.unwrap_or_else(|| "upload.bin".to_string());
if let Some(folder_id) = folder_id {
ensure_folder_exists(&state, folder_id)?;
}
let doc_id = Uuid::new_v4();
let version_id = Uuid::new_v4();
let version_number = 1;
let stored_filename = original_name.clone();
let checksum = Sha256::digest(&file_bytes);
let checksum_hex = hex::encode(checksum);
let size_bytes = file_bytes.len() as i64;
let s3_key = format!("documents/{doc_id}/v{version_number}/{version_id}");
{
let mut conn = state.db()?;
let existing = documents::table
.inner_join(
document_versions::table.on(document_versions::document_id
.eq(documents::id)
.and(document_versions::version_number.eq(documents::current_version))),
)
.filter(document_versions::checksum.eq(&checksum_hex))
.select((documents::all_columns, document_versions::all_columns))
.first::<(Document, DocumentVersion)>(&mut conn)
.optional()?;
if let Some((mut document, version)) = existing {
if document.deleted_at.is_some() {
let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document.id))
.set((
documents::deleted_at.eq(None::<NaiveDateTime>),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
document.deleted_at = None;
document.updated_at = now;
}
let tags_map = load_tags_for_documents(&mut conn, &[document.id])?;
let tags = tags_map.get(&document.id).cloned();
return Ok(Json(DocumentDetailResponse {
document: to_document_response(document, tags),
current_version: to_version_response(version),
}));
}
}
let mut put_request = state
.s3
.put_object()
.bucket(&state.config.s3_bucket)
.key(&s3_key)
.body(ByteStream::from(file_bytes.clone()));
if let Some(ref ct) = content_type {
put_request = put_request.content_type(ct.clone());
}
put_request
.send()
.await
.map_err(|err| AppError::internal(format!("failed to upload to s3: {err}")))?;
let metadata_value = if metadata.is_null() {
Value::Object(Default::default())
} else {
metadata
};
let (document, version) = {
let mut conn = state.db()?;
conn.transaction(|conn| {
let new_document = NewDocument {
id: doc_id,
filename: stored_filename.clone(),
original_name: original_name.clone(),
content_type: content_type.clone(),
folder_id,
current_version: version_number,
metadata: metadata_value.clone(),
};
diesel::insert_into(documents::table)
.values(&new_document)
.execute(conn)?;
let new_version = NewDocumentVersion {
id: version_id,
document_id: doc_id,
version_number,
s3_key: s3_key.clone(),
size_bytes,
checksum: checksum_hex.clone(),
operations_summary: Value::Object(Default::default()),
};
diesel::insert_into(document_versions::table)
.values(&new_version)
.execute(conn)?;
let document: Document = documents::table.find(doc_id).first(conn)?;
let version: DocumentVersion = document_versions::table.find(version_id).first(conn)?;
Ok::<_, diesel::result::Error>((document, version))
})?
};
let response = DocumentDetailResponse {
document: to_document_response(document, None),
current_version: to_version_response(version),
};
Ok(Json(response))
}
pub async fn download_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
) -> AppResult<Json<DocumentDownloadResponse>> {
let mut conn = state.db()?;
let doc: Document = documents::table.find(document_id).first(&mut conn)?;
if doc.deleted_at.is_some() {
return Err(AppError::not_found());
}
let version: DocumentVersion = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::version_number.eq(doc.current_version))
.first(&mut conn)?;
let presign_config = PresigningConfig::builder()
.expires_in(Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS))
.build()
.map_err(|err| AppError::internal(format!("failed to build presigning config: {err}")))?;
let presigned = state
.s3
.get_object()
.bucket(&state.config.s3_bucket)
.key(&version.s3_key)
.presigned(presign_config)
.await
.map_err(|err| AppError::internal(format!("failed to generate download URL: {err}")))?;
Ok(Json(DocumentDownloadResponse {
url: presigned.uri().to_string(),
expires_in: PRESIGNED_URL_EXPIRY_SECONDS,
filename: doc.original_name.clone(),
content_type: doc.content_type.clone(),
size_bytes: version.size_bytes,
}))
}
pub async fn delete_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
) -> AppResult<impl IntoResponse> {
let mut conn = state.db()?;
let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document_id))
.set((
documents::deleted_at.eq(Some(now)),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn move_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
Json(payload): Json<MoveDocumentRequest>,
) -> AppResult<impl IntoResponse> {
if let Some(folder_id) = payload.folder_id {
ensure_folder_exists(&state, folder_id)?;
}
let mut conn = state.db()?;
let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document_id))
.set((
documents::folder_id.eq(payload.folder_id),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn assign_tags(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
user: AuthenticatedUser,
Json(payload): Json<AssignTagsRequest>,
) -> AppResult<impl IntoResponse> {
if payload.tag_ids.is_empty() {
return Err(AppError::bad_request("tag_ids must not be empty"));
}
let mut conn = state.db()?;
// Ensure document exists
documents::table
.find(document_id)
.first::<Document>(&mut conn)?;
// Ensure tags exist
let existing_tags: Vec<Tag> = tags::table
.filter(tags::id.eq_any(&payload.tag_ids))
.load(&mut conn)?;
if existing_tags.len() != payload.tag_ids.len() {
return Err(AppError::bad_request("one or more tags do not exist"));
}
let new_tags: Vec<NewDocumentTag> = payload
.tag_ids
.iter()
.map(|tag_id| NewDocumentTag {
document_id,
tag_id: *tag_id,
assigned_by: Some(user.user_id),
})
.collect();
diesel::insert_into(document_tags::table)
.values(&new_tags)
.on_conflict_do_nothing()
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn remove_tag(
State(state): State<AppState>,
Path((document_id, tag_id)): Path<(Uuid, Uuid)>,
) -> AppResult<impl IntoResponse> {
let mut conn = state.db()?;
diesel::delete(
document_tags::table
.filter(document_tags::document_id.eq(document_id))
.filter(document_tags::tag_id.eq(tag_id)),
)
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
}
fn ensure_folder_exists(state: &AppState, folder_id: Uuid) -> AppResult<()> {
let mut conn = state.db()?;
let exists: bool = diesel::select(exists(folders::table.filter(folders::id.eq(folder_id))))
.get_result(&mut conn)?;
if !exists {
return Err(AppError::bad_request("folder does not exist"));
}
Ok(())
}
pub(crate) fn load_tags_for_documents(
conn: &mut PgConnection,
document_ids: &[Uuid],
) -> AppResult<HashMap<Uuid, Vec<Tag>>> {
if document_ids.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(Uuid, Tag)> = document_tags::table
.inner_join(tags::table)
.filter(document_tags::document_id.eq_any(document_ids))
.select((document_tags::document_id, tags::all_columns))
.load(conn)?;
let mut map: HashMap<Uuid, Vec<Tag>> = HashMap::new();
for (doc_id, tag) in rows {
map.entry(doc_id).or_default().push(tag);
}
Ok(map)
}
pub(crate) fn to_document_response(doc: Document, tags: Option<Vec<Tag>>) -> DocumentResponse {
DocumentResponse {
id: doc.id,
filename: doc.filename,
original_name: doc.original_name,
content_type: doc.content_type,
folder_id: doc.folder_id,
current_version: doc.current_version,
uploaded_at: to_iso(doc.uploaded_at),
updated_at: to_iso(doc.updated_at),
deleted_at: doc.deleted_at.map(to_iso),
metadata: doc.metadata,
tags: tags
.unwrap_or_default()
.into_iter()
.map(TagResponse::from)
.collect(),
}
}
fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
DocumentVersionResponse {
id: version.id,
version_number: version.version_number,
s3_key: version.s3_key,
size_bytes: version.size_bytes,
checksum: version.checksum,
created_at: to_iso(version.created_at),
operations_summary: version.operations_summary,
}
}
pub(crate) fn to_iso(dt: NaiveDateTime) -> String {
DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).to_rfc3339()
}
+327
View File
@@ -0,0 +1,327 @@
use axum::{
extract::{Json, Path, Query, State},
http::StatusCode,
};
use diesel::{dsl::exists, prelude::*, PgConnection};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, Folder, NewFolder};
use crate::schema::{document_tags, documents, folders};
use crate::state::AppState;
use super::documents::{load_tags_for_documents, to_document_response, to_iso, DocumentResponse};
#[derive(Deserialize)]
pub struct CreateFolderRequest {
pub name: String,
pub parent_id: Option<Uuid>,
}
#[derive(Serialize)]
pub struct FolderResponse {
pub folder: FolderInfo,
}
#[derive(Serialize)]
pub struct FolderContentsResponse {
pub folder: Option<FolderInfo>,
pub subfolders: Vec<FolderInfo>,
pub documents: Vec<DocumentResponse>,
}
#[derive(Deserialize)]
pub struct DocumentSearchQuery {
pub query: Option<String>,
pub tags: Option<String>,
}
#[derive(Serialize)]
pub struct FolderInfo {
pub id: Uuid,
pub name: String,
pub parent_id: Option<Uuid>,
pub path_cache: Option<String>,
pub created_at: String,
pub updated_at: String,
}
pub async fn create_folder(
State(state): State<AppState>,
Json(payload): Json<CreateFolderRequest>,
) -> AppResult<Json<FolderResponse>> {
if payload.name.trim().is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
let mut conn = state.db()?;
let path_cache = build_path_cache(&mut conn, payload.parent_id, &payload.name)?;
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: payload.name.trim().to_string(),
parent_id: payload.parent_id,
path_cache,
};
diesel::insert_into(folders::table)
.values(&new_folder)
.execute(&mut conn)?;
let folder: Folder = folders::table.find(new_folder.id).first(&mut conn)?;
Ok(Json(FolderResponse {
folder: folder_to_info(folder),
}))
}
pub async fn list_folder_contents(
State(state): State<AppState>,
Path(folder_identifier): Path<String>,
) -> AppResult<Json<FolderContentsResponse>> {
let mut conn = state.db()?;
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
None
} else {
Some(
Uuid::parse_str(&folder_identifier)
.map_err(|_| AppError::bad_request("folder identifier must be 'root' or a UUID"))?,
)
};
let folder = match folder_id {
Some(id) => Some(folder_to_info(
folders::table.find(id).first::<Folder>(&mut conn)?,
)),
None => None,
};
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
folders::table
.filter(folders::parent_id.eq(parent_id))
.order(folders::name.asc())
.load(&mut conn)?
} else {
folders::table
.filter(folders::parent_id.is_null())
.order(folders::name.asc())
.load(&mut conn)?
};
let subfolders = child_folders.into_iter().map(folder_to_info).collect();
let docs_query = documents::table
.filter(documents::deleted_at.is_null())
.order(documents::uploaded_at.desc());
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
docs_query
.filter(documents::folder_id.eq(current_folder))
.load(&mut conn)?
} else {
docs_query
.filter(documents::folder_id.is_null())
.load(&mut conn)?
};
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let documents = docs
.into_iter()
.map(|doc| {
let tags = tags_map.get(&doc.id).cloned();
to_document_response(doc, tags)
})
.collect();
Ok(Json(FolderContentsResponse {
folder,
subfolders,
documents,
}))
}
pub async fn search_documents(
State(state): State<AppState>,
Path(folder_identifier): Path<String>,
Query(params): Query<DocumentSearchQuery>,
) -> AppResult<Json<Vec<DocumentResponse>>> {
let mut conn = state.db()?;
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
None
} else {
Some(
Uuid::parse_str(&folder_identifier)
.map_err(|_| AppError::bad_request("folder identifier must be 'root' or a UUID"))?,
)
};
let mut docs_query = documents::table
.filter(documents::deleted_at.is_null())
.into_boxed();
if let Some(folder_id) = folder_id {
let descendant_ids = gather_descendant_folder_ids(&mut conn, folder_id)?;
docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids));
}
if let Some(query) = params
.query
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
let pattern = format!("%{}%", query);
docs_query = docs_query.filter(documents::original_name.ilike(pattern));
}
if let Some(tags_param) = params
.tags
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
let tag_ids: Result<Vec<Uuid>, _> = tags_param
.split(',')
.map(|s| Uuid::parse_str(s.trim()))
.collect();
if let Ok(ids) = tag_ids {
if !ids.is_empty() {
let mut doc_id_set: Option<HashSet<Uuid>> = None;
for tag_id in &ids {
let docs_for_tag: Vec<Uuid> = document_tags::table
.filter(document_tags::tag_id.eq(*tag_id))
.select(document_tags::document_id)
.load(&mut conn)?;
let docs_set: HashSet<Uuid> = docs_for_tag.into_iter().collect();
doc_id_set = Some(match doc_id_set {
Some(existing) => existing.intersection(&docs_set).cloned().collect(),
None => docs_set,
});
if let Some(ref set) = doc_id_set {
if set.is_empty() {
break;
}
}
}
let matching_doc_ids: Vec<Uuid> =
doc_id_set.unwrap_or_default().into_iter().collect();
if matching_doc_ids.is_empty() {
return Ok(Json(vec![]));
}
docs_query = docs_query.filter(documents::id.eq_any(matching_doc_ids));
}
}
}
let docs: Vec<Document> = docs_query
.order(documents::uploaded_at.desc())
.load(&mut conn)?;
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let response = docs
.into_iter()
.map(|doc| {
let tags = tags_map.get(&doc.id).cloned();
to_document_response(doc, tags)
})
.collect();
Ok(Json(response))
}
pub async fn delete_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>,
) -> AppResult<StatusCode> {
let mut conn = state.db()?;
conn.transaction::<_, AppError, _>(|conn| {
folders::table.find(folder_id).first::<Folder>(conn)?;
let has_child_folders: bool = diesel::select(exists(
folders::table.filter(folders::parent_id.eq(Some(folder_id))),
))
.get_result(conn)?;
if has_child_folders {
return Err(AppError::bad_request(
"folder must be empty before deletion",
));
}
let has_documents: bool = diesel::select(exists(
documents::table
.filter(documents::folder_id.eq(Some(folder_id)))
.filter(documents::deleted_at.is_null()),
))
.get_result(conn)?;
if has_documents {
return Err(AppError::bad_request(
"folder must be empty before deletion",
));
}
diesel::delete(folders::table.find(folder_id)).execute(conn)?;
Ok(())
})?;
Ok(StatusCode::NO_CONTENT)
}
fn build_path_cache(
conn: &mut PgConnection,
parent_id: Option<Uuid>,
name: &str,
) -> AppResult<Option<String>> {
let path = if let Some(parent_id) = parent_id {
let parent: Folder = folders::table.find(parent_id).first(conn)?;
let base = parent
.path_cache
.unwrap_or_else(|| "/".to_string())
.trim_end_matches('/')
.to_string();
format!("{}/{}", base, name)
} else {
format!("/{}", name)
};
Ok(Some(path))
}
fn folder_to_info(folder: Folder) -> FolderInfo {
FolderInfo {
id: folder.id,
name: folder.name,
parent_id: folder.parent_id,
path_cache: folder.path_cache,
created_at: to_iso(folder.created_at),
updated_at: to_iso(folder.updated_at),
}
}
fn gather_descendant_folder_ids(conn: &mut PgConnection, folder_id: Uuid) -> AppResult<Vec<Uuid>> {
let mut ids = vec![folder_id];
let mut queue = vec![folder_id];
while let Some(current) = queue.pop() {
let child_ids: Vec<Uuid> = folders::table
.filter(folders::parent_id.eq(Some(current)))
.select(folders::id)
.load(conn)?;
queue.extend(child_ids.iter().copied());
ids.extend(child_ids);
}
Ok(ids)
}
+6
View File
@@ -0,0 +1,6 @@
use axum::{http::StatusCode, response::Json};
use serde_json::json;
pub async fn health_check() -> (StatusCode, Json<serde_json::Value>) {
(StatusCode::OK, Json(json!({ "status": "ok" })))
}
+64
View File
@@ -0,0 +1,64 @@
use axum::{
extract::DefaultBodyLimit,
middleware,
routing::{delete, get, patch, post},
Router,
};
use tower_http::cors::{Any, CorsLayer};
use crate::{auth::AuthenticatedUser, state::AppState};
pub mod auth;
pub mod documents;
pub mod folders;
pub mod health;
pub mod tags;
pub fn create_router(state: AppState) -> Router<()> {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let auth_routes = Router::new()
.route("/login", post(auth::login))
.route("/logout", post(auth::logout))
.route("/me", get(auth::me));
let documents_routes = Router::new()
.route(
"/",
get(documents::list_documents).post(documents::upload_document),
)
.route(
"/:id",
get(documents::get_document).delete(documents::delete_document),
)
.route("/:id/download", get(documents::download_document))
.route("/:id/folder", patch(documents::move_document))
.route("/:id/tags", post(documents::assign_tags))
.route("/:id/tags/:tag_id", delete(documents::remove_tag));
let folders_routes = Router::new()
.route("/", post(folders::create_folder))
.route("/:id", delete(folders::delete_folder))
.route("/:id/contents", get(folders::list_folder_contents))
.route("/:id/documents", get(folders::search_documents));
let tags_routes = Router::new().route("/", get(tags::list_tags).post(tags::create_tag));
let protected_state = state.clone();
let protected_routes = Router::new()
.nest("/api/documents", documents_routes)
.nest("/api/folders", folders_routes)
.nest("/api/tags", tags_routes)
.route("/api/health", get(health::health_check))
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
Router::new()
.nest("/api/auth", auth_routes)
.merge(protected_routes)
.with_state(state)
.layer(cors)
.layer(DefaultBodyLimit::max(1024 * 1024 * 512))
}
+57
View File
@@ -0,0 +1,57 @@
use axum::{extract::State, Json};
use diesel::prelude::*;
use serde::Deserialize;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{NewTag, Tag};
use crate::schema::tags;
use crate::state::AppState;
use super::documents::TagResponse;
#[derive(Deserialize)]
pub struct CreateTagRequest {
pub label: String,
pub color: Option<String>,
}
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagResponse>>> {
let mut conn = state.db()?;
let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?;
let response = tag_list.into_iter().map(TagResponse::from).collect();
Ok(Json(response))
}
pub async fn create_tag(
State(state): State<AppState>,
Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagResponse>> {
if payload.label.trim().is_empty() {
return Err(AppError::bad_request("label must not be empty"));
}
let mut conn = state.db()?;
let new_tag = NewTag {
id: Uuid::new_v4(),
label: payload.label.trim().to_string(),
color: payload.color,
};
match diesel::insert_into(tags::table)
.values(&new_tag)
.execute(&mut conn)
{
Ok(_) => {}
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_,
)) => {
return Err(AppError::bad_request("tag label already exists"));
}
Err(err) => return Err(AppError::from(err)),
}
let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?;
Ok(Json(TagResponse::from(tag)))
}
+38
View File
@@ -0,0 +1,38 @@
use anyhow::Result;
use aws_config::meta::region::RegionProviderChain;
use aws_credential_types::Credentials;
use aws_sdk_s3::{
config::{Builder as S3ConfigBuilder, Region},
Client as S3Client,
};
use crate::config::AppConfig;
pub async fn build_client(config: &AppConfig) -> Result<S3Client> {
let region = Region::new(config.aws_region.clone());
let region_provider = RegionProviderChain::first_try(Some(region))
.or_default_provider()
.or_else("us-east-1");
#[allow(deprecated)]
let mut loader = aws_config::from_env().region(region_provider);
if let Some(endpoint) = &config.aws_endpoint_url {
loader = loader.endpoint_url(endpoint);
}
if let (Some(access_key), Some(secret_key)) = (
config.aws_access_key_id.clone(),
config.aws_secret_access_key.clone(),
) {
let credentials = Credentials::new(access_key, secret_key, None, None, "static");
loader = loader.credentials_provider(credentials);
}
let base_config = loader.load().await;
let s3_config = S3ConfigBuilder::from(&base_config)
.force_path_style(true)
.build();
Ok(S3Client::from_conf(s3_config))
}
+98
View File
@@ -0,0 +1,98 @@
// @generated automatically by Diesel CLI.
diesel::table! {
document_tags (document_id, tag_id) {
document_id -> Uuid,
tag_id -> Uuid,
assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>,
}
}
diesel::table! {
document_versions (id) {
id -> Uuid,
document_id -> Uuid,
version_number -> Int4,
#[max_length = 500]
s3_key -> Varchar,
#[max_length = 100]
s3_bucket -> Varchar,
size_bytes -> Int8,
#[max_length = 64]
checksum -> Varchar,
created_at -> Timestamptz,
operations_summary -> Jsonb,
}
}
diesel::table! {
documents (id) {
id -> Uuid,
#[max_length = 255]
filename -> Varchar,
#[max_length = 255]
original_name -> Varchar,
#[max_length = 100]
content_type -> Nullable<Varchar>,
folder_id -> Nullable<Uuid>,
current_version -> Int4,
uploaded_at -> Timestamptz,
updated_at -> Timestamptz,
deleted_at -> Nullable<Timestamptz>,
metadata -> Jsonb,
}
}
diesel::table! {
folders (id) {
id -> Uuid,
#[max_length = 255]
name -> Varchar,
parent_id -> Nullable<Uuid>,
#[max_length = 1000]
path_cache -> Nullable<Varchar>,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
}
diesel::table! {
tags (id) {
id -> Uuid,
#[max_length = 100]
label -> Varchar,
#[max_length = 7]
color -> Nullable<Varchar>,
created_at -> Timestamptz,
}
}
diesel::table! {
users (id) {
id -> Uuid,
#[max_length = 100]
username -> Varchar,
#[max_length = 255]
password_hash -> Varchar,
#[max_length = 16]
role -> Varchar,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
}
diesel::joinable!(document_tags -> documents (document_id));
diesel::joinable!(document_tags -> tags (tag_id));
diesel::joinable!(document_tags -> users (assigned_by));
diesel::joinable!(document_versions -> documents (document_id));
diesel::joinable!(documents -> folders (folder_id));
diesel::allow_tables_to_appear_in_same_query!(
document_tags,
document_versions,
documents,
folders,
tags,
users,
);
+41
View File
@@ -0,0 +1,41 @@
use std::sync::Arc;
use aws_sdk_s3::Client as S3Client;
use diesel::{
pg::PgConnection,
r2d2::{ConnectionManager, PooledConnection},
};
use crate::{
auth::jwt::JwtService,
config::AppConfig,
db::PgPool,
error::{AppError, AppResult},
};
type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub config: Arc<AppConfig>,
pub s3: S3Client,
pub jwt: JwtService,
}
impl AppState {
pub fn new(pool: PgPool, config: AppConfig, s3: S3Client, jwt: JwtService) -> Self {
Self {
pool,
config: Arc::new(config),
s3,
jwt,
}
}
pub fn db(&self) -> AppResult<PgPooledConnection> {
self.pool
.get()
.map_err(|err| AppError::internal(format!("database pool error: {err}")))
}
}
+53
View File
@@ -0,0 +1,53 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: paperless
POSTGRES_PASSWORD: paperless_dev
POSTGRES_DB: paperless
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/migrations:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U paperless"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000" # S3 API
- "9001:9001" # Console
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
createbuckets:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin;
/usr/bin/mc mb myminio/documents --ignore-existing;
/usr/bin/mc anonymous set download myminio/documents;
exit 0;
"
volumes:
postgres_data:
minio_data:
+3
View File
@@ -0,0 +1,3 @@
node_modules
/dist
/.env.local
+43
View File
@@ -0,0 +1,43 @@
# Paperless-NEO Frontend
A minimal Webpack-powered SPA to interact with the Paperless-NEO Milestone 1 backend.
## Prerequisites
- Node.js 18+
- Backend API running locally on `http://127.0.0.1:3000`
## Setup
```bash
cd frontend
npm install
```
## Development
```bash
npm run dev
```
- Starts `webpack-dev-server` on <http://localhost:5173>
- Proxies `/api` requests to the backend (no CORS needed)
- Edit files in `src/` and the browser reloads automatically
## Production Build
```bash
npm run build
```
- Output written to `dist/`
- Set `API_BASE_URL` in `.env.local` if the API is not served from the same origin.
## Features
- Finder-style layout: folder tree, document table, and detail pane with metadata & tags
- Drag-and-drop moves (documents between folders) and file uploads (window-wide or onto a folder)
- Search box plus tag chips filter documents across the selected folder and all descendants
- Tag management (create/assign/remove) from the detail panel
- Login via the seeded admin account (`admin` / `adminadmin`) with stored JWT session
- Inline status banner for quick feedback on API interactions
+4855
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "paperless-neo-frontend",
"version": "0.1.0",
"private": true,
"description": "Rudimentary Webpack SPA for Paperless-NEO Milestone 1",
"scripts": {
"dev": "webpack serve --mode development --open",
"build": "webpack --mode production",
"lint": "echo \"No linting configured\""
},
"dependencies": {
"axios": "1.7.7"
},
"devDependencies": {
"css-loader": "7.1.2",
"dotenv": "16.4.5",
"html-webpack-plugin": "5.6.3",
"style-loader": "4.0.0",
"webpack": "5.95.0",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.1.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Paperless-NEO</title>
</head>
<body>
<main id="app"></main>
</body>
</html>
File diff suppressed because it is too large Load Diff
+469
View File
@@ -0,0 +1,469 @@
:root {
color-scheme: light dark;
--bg: #f3f4f6;
--surface: rgba(255, 255, 255, 0.9);
--surface-strong: rgba(255, 255, 255, 0.95);
--fg: #1f2937;
--muted: #6b7280;
--border: #d1d5db;
--accent: #2563eb;
--accent-soft: rgba(37, 99, 235, 0.12);
--accent-strong: rgba(37, 99, 235, 0.2);
--danger: #dc2626;
--success: #10b981;
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 2rem clamp(1rem, 3vw, 3rem);
box-sizing: border-box;
}
header.global-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
gap: 1rem;
}
header.global-header h1 {
margin: 0;
font-size: 1.9rem;
}
button {
font: inherit;
border-radius: 8px;
border: none;
padding: 0.55rem 1.1rem;
background: var(--accent);
color: white;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.15s ease;
}
button[disabled] {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
button:hover:not([disabled]) {
transform: translateY(-1px);
box-shadow: 0 10px 20px var(--accent-strong);
}
button.secondary {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
}
button.danger {
background: var(--danger);
color: #fff;
box-shadow: none;
}
button.danger:hover:not([disabled]) {
box-shadow: 0 8px 16px rgba(220, 38, 38, 0.2);
}
button.icon-button {
padding: 0.35rem 0.65rem;
min-width: auto;
}
button.icon-button:hover:not([disabled]) {
transform: none;
}
.dashboard-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 320px;
gap: 1.5rem;
align-items: start;
flex: 1;
}
.sidebar,
.table-panel,
.detail-panel {
background: var(--surface);
border-radius: 16px;
padding: 1.25rem;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
backdrop-filter: blur(14px);
}
.sidebar {
position: relative;
min-height: calc(100vh - 6rem);
}
.sidebar h2,
.table-panel h2,
.detail-panel h2 {
margin: 0 0 1rem;
font-size: 1.1rem;
}
.folder-tree {
list-style: none;
margin: 0;
padding: 0;
}
.folder-node {
margin: 0;
}
.folder-row {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.4rem 0.55rem;
border-radius: 8px;
cursor: pointer;
color: var(--fg);
}
.folder-row span.name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.folder-row:hover {
background: var(--accent-soft);
}
.folder-row.active {
background: var(--accent);
color: white;
}
.folder-row .toggle {
width: 1.1rem;
text-align: center;
font-size: 0.9rem;
user-select: none;
}
.folder-row .toggle.invisible {
visibility: hidden;
}
.folder-children {
list-style: none;
margin: 0 0 0 1rem;
padding: 0;
}
.folder-row.is-drop-target {
outline: 2px dashed var(--accent);
outline-offset: 2px;
}
.table-panel {
overflow: hidden;
}
.table-panel header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.8rem;
gap: 1rem;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.table-panel table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
.table-panel thead {
background: var(--accent-soft);
}
.table-panel th,
.table-panel td {
padding: 0.65rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.table-panel th.actions-column,
.table-panel td.actions {
width: 1%;
white-space: nowrap;
}
.table-panel tbody tr {
background: var(--surface-strong);
transition: background 0.15s ease;
}
.table-panel tbody tr:nth-child(every) {
background: var(--surface-strong);
}
.table-panel tbody tr:hover {
background: rgba(37, 99, 235, 0.08);
}
.table-panel tbody tr.folder {
cursor: pointer;
font-weight: 600;
}
.table-panel tbody tr.document {
cursor: pointer;
}
.table-panel tbody tr.document.selected {
background: rgba(37, 99, 235, 0.2);
}
.table-panel tbody tr.document.dragging {
opacity: 0.4;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-bottom: 1rem;
}
.filter-bar input[type='search'] {
flex: 1;
min-width: 220px;
}
.tag-filters {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.tag-filter {
border: 1px solid var(--accent);
background: transparent;
color: var(--accent);
padding: 0.3rem 0.7rem;
border-radius: 999px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.tag-filter:hover {
box-shadow: 0 8px 16px var(--accent-strong);
}
.tag-filter.active {
background: var(--accent);
color: white;
}
.filter-actions {
display: flex;
gap: 0.4rem;
}
.search-hint {
margin-top: 0.75rem;
font-size: 0.85rem;
color: var(--muted);
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.75rem;
margin-right: 0.35rem;
}
.empty-state {
border: 2px dashed var(--border);
border-radius: 12px;
text-align: center;
padding: 2.5rem 1rem;
color: var(--muted);
margin-top: 1.5rem;
}
.detail-panel {
position: relative;
min-height: calc(100vh - 6rem);
display: flex;
flex-direction: column;
gap: 1rem;
}
.detail-panel .meta {
font-size: 0.9rem;
color: var(--muted);
}
.detail-panel dl {
margin: 0;
}
.detail-panel dt {
font-weight: 600;
margin-top: 0.8rem;
}
.detail-panel dd {
margin: 0.2rem 0 0;
}
.detail-panel .tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.6rem;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: var(--accent-soft);
color: var(--accent);
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
}
.tag-pill button {
background: none;
border: none;
color: inherit;
padding: 0;
cursor: pointer;
font-size: 0.85rem;
}
input,
textarea,
select {
font: inherit;
border-radius: 8px;
border: 1px solid var(--border);
padding: 0.55rem 0.6rem;
background: var(--surface-strong);
color: inherit;
width: 100%;
box-sizing: border-box;
}
textarea {
resize: vertical;
}
form.inline {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.status-banner {
padding: 0.6rem 1rem;
border-radius: 10px;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.status-banner.info {
background: rgba(107, 114, 128, 0.12);
color: var(--muted);
}
.status-banner.success {
background: rgba(16, 185, 129, 0.15);
color: var(--success);
}
.status-banner.error {
background: rgba(220, 38, 38, 0.15);
color: var(--danger);
}
.drop-overlay {
position: fixed;
inset: 0;
background: rgba(37, 99, 235, 0.25);
backdrop-filter: blur(8px);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
}
.drop-overlay.active {
display: flex;
}
.drop-overlay__content {
background: rgba(17, 24, 39, 0.85);
color: white;
padding: 2rem 3rem;
border-radius: 20px;
text-align: center;
font-size: 1.2rem;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.35);
}
@media (max-width: 1080px) {
.dashboard-layout {
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
grid-template-rows: auto auto;
}
.detail-panel {
grid-column: 1 / -1;
}
}
@media (max-width: 768px) {
#app {
padding: 1.5rem 1rem 4rem;
}
header.global-header {
flex-direction: column;
align-items: flex-start;
}
.dashboard-layout {
grid-template-columns: 1fr;
}
.sidebar,
.table-panel,
.detail-panel {
min-height: auto;
}
}
+52
View File
@@ -0,0 +1,52 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const dotenv = require('dotenv');
const env = dotenv.config({ path: path.resolve(__dirname, '.env.local') }).parsed || {};
const API_BASE_URL =
env.API_BASE_URL || process.env.API_BASE_URL || 'http://127.0.0.1:3000';
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.[contenthash].js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
module: {
rules: [
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'src/index.html'),
favicon: false,
}),
new webpack.DefinePlugin({
'process.env.API_BASE_URL': JSON.stringify(API_BASE_URL),
}),
],
devServer: {
static: {
directory: path.join(__dirname, 'public'),
},
compress: true,
port: 5173,
historyApiFallback: true,
open: true,
proxy: [
{
context: ['/api'],
target: API_BASE_URL,
changeOrigin: true,
},
],
},
devtool: 'source-map',
};