initial commit

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
This commit is contained in:
raf 2026-01-30 22:05:46 +03:00
commit 6a73d11c4b
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
124 changed files with 34856 additions and 0 deletions

View file

@ -0,0 +1,244 @@
use std::sync::Arc;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::http::{HeaderValue, Method, header};
use axum::middleware;
use axum::routing::{delete, get, patch, post, put};
use tower_governor::GovernorLayer;
use tower_governor::governor::GovernorConfigBuilder;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use crate::auth;
use crate::routes;
use crate::state::AppState;
pub fn create_router(state: AppState) -> Router {
// Global rate limit: 100 requests/sec per IP
let global_governor = Arc::new(
GovernorConfigBuilder::default()
.per_second(1)
.burst_size(100)
.finish()
.unwrap(),
);
// Strict rate limit for login: 5 requests/min per IP
let login_governor = Arc::new(
GovernorConfigBuilder::default()
.per_second(12) // replenish one every 12 seconds
.burst_size(5)
.finish()
.unwrap(),
);
// Login route with strict rate limiting
let login_route = Router::new()
.route("/auth/login", post(routes::auth::login))
.layer(GovernorLayer {
config: login_governor,
});
// Read-only routes: any authenticated user (Viewer+)
let viewer_routes = Router::new()
.route("/health", get(routes::health::health))
.route("/media/count", get(routes::media::get_media_count))
.route("/media", get(routes::media::list_media))
.route("/media/{id}", get(routes::media::get_media))
.route("/media/{id}/stream", get(routes::media::stream_media))
.route("/media/{id}/thumbnail", get(routes::media::get_thumbnail))
.route("/media/{media_id}/tags", get(routes::tags::get_media_tags))
.route("/search", get(routes::search::search))
.route("/search", post(routes::search::search_post))
.route("/tags", get(routes::tags::list_tags))
.route("/tags/{id}", get(routes::tags::get_tag))
.route("/collections", get(routes::collections::list_collections))
.route(
"/collections/{id}",
get(routes::collections::get_collection),
)
.route(
"/collections/{id}/members",
get(routes::collections::get_members),
)
.route("/audit", get(routes::audit::list_audit))
.route("/scan/status", get(routes::scan::scan_status))
.route("/config", get(routes::config::get_config))
.route("/config/ui", get(routes::config::get_ui_config))
.route("/database/stats", get(routes::database::database_stats))
.route("/duplicates", get(routes::duplicates::list_duplicates))
// Statistics
.route("/statistics", get(routes::statistics::library_statistics))
// Scheduled tasks (read)
.route(
"/tasks/scheduled",
get(routes::scheduled_tasks::list_scheduled_tasks),
)
// Jobs
.route("/jobs", get(routes::jobs::list_jobs))
.route("/jobs/{id}", get(routes::jobs::get_job))
// Saved searches (read)
.route(
"/searches/saved",
get(routes::saved_searches::list_saved_searches),
)
// Webhooks (read)
.route("/webhooks", get(routes::webhooks::list_webhooks))
// Auth endpoints (self-service) — login handled separately with stricter rate limit
.route("/auth/logout", post(routes::auth::logout))
.route("/auth/me", get(routes::auth::me));
// Write routes: Editor+ required
let editor_routes = Router::new()
.route("/media/import", post(routes::media::import_media))
.route(
"/media/import/options",
post(routes::media::import_with_options),
)
.route("/media/import/batch", post(routes::media::batch_import))
.route(
"/media/import/directory",
post(routes::media::import_directory_endpoint),
)
.route(
"/media/import/preview",
post(routes::media::preview_directory),
)
.route("/media/batch/tag", post(routes::media::batch_tag))
.route("/media/batch/delete", post(routes::media::batch_delete))
.route("/media/batch/update", patch(routes::media::batch_update))
.route(
"/media/batch/collection",
post(routes::media::batch_add_to_collection),
)
.route("/media/all", delete(routes::media::delete_all_media))
.route("/media/{id}", patch(routes::media::update_media))
.route("/media/{id}", delete(routes::media::delete_media))
.route("/media/{id}/open", post(routes::media::open_media))
.route(
"/media/{id}/custom-fields",
post(routes::media::set_custom_field),
)
.route(
"/media/{id}/custom-fields/{name}",
delete(routes::media::delete_custom_field),
)
.route("/tags", post(routes::tags::create_tag))
.route("/tags/{id}", delete(routes::tags::delete_tag))
.route("/media/{media_id}/tags", post(routes::tags::tag_media))
.route(
"/media/{media_id}/tags/{tag_id}",
delete(routes::tags::untag_media),
)
.route("/collections", post(routes::collections::create_collection))
.route(
"/collections/{id}",
delete(routes::collections::delete_collection),
)
.route(
"/collections/{id}/members",
post(routes::collections::add_member),
)
.route(
"/collections/{collection_id}/members/{media_id}",
delete(routes::collections::remove_member),
)
.route("/scan", post(routes::scan::trigger_scan))
.route("/jobs/{id}/cancel", post(routes::jobs::cancel_job))
// Saved searches (write)
.route(
"/searches/saved",
post(routes::saved_searches::create_saved_search),
)
.route(
"/searches/saved/{id}",
delete(routes::saved_searches::delete_saved_search),
)
// Integrity
.route(
"/jobs/orphan-detection",
post(routes::integrity::trigger_orphan_detection),
)
.route(
"/jobs/verify-integrity",
post(routes::integrity::trigger_verify_integrity),
)
.route(
"/jobs/cleanup-thumbnails",
post(routes::integrity::trigger_cleanup_thumbnails),
)
.route(
"/jobs/generate-thumbnails",
post(routes::integrity::generate_all_thumbnails),
)
.route("/orphans/resolve", post(routes::integrity::resolve_orphans))
// Export
.route("/jobs/export", post(routes::export::trigger_export))
.route(
"/jobs/export/options",
post(routes::export::trigger_export_with_options),
)
// Scheduled tasks (write)
.route(
"/tasks/scheduled/{id}/toggle",
post(routes::scheduled_tasks::toggle_scheduled_task),
)
.route(
"/tasks/scheduled/{id}/run-now",
post(routes::scheduled_tasks::run_scheduled_task_now),
)
// Webhooks
.route("/webhooks/test", post(routes::webhooks::test_webhook))
.layer(middleware::from_fn(auth::require_editor));
// Admin-only routes: destructive/config operations
let admin_routes = Router::new()
.route(
"/config/scanning",
put(routes::config::update_scanning_config),
)
.route("/config/roots", post(routes::config::add_root))
.route("/config/roots", delete(routes::config::remove_root))
.route("/config/ui", put(routes::config::update_ui_config))
.route("/database/vacuum", post(routes::database::vacuum_database))
.route("/database/clear", post(routes::database::clear_database))
.layer(middleware::from_fn(auth::require_admin));
let api = Router::new()
.merge(login_route)
.merge(viewer_routes)
.merge(editor_routes)
.merge(admin_routes);
// CORS: allow same-origin by default, plus the desktop UI origin
let cors = CorsLayer::new()
.allow_origin([
"http://localhost:3000".parse::<HeaderValue>().unwrap(),
"http://127.0.0.1:3000".parse::<HeaderValue>().unwrap(),
"tauri://localhost".parse::<HeaderValue>().unwrap(),
])
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
])
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
.allow_credentials(true);
Router::new()
.nest("/api/v1", api)
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
.layer(middleware::from_fn_with_state(
state.clone(),
auth::require_auth,
))
.layer(GovernorLayer {
config: global_governor,
})
.layer(TraceLayer::new_for_http())
.layer(cors)
.with_state(state)
}