initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
This commit is contained in:
commit
6a73d11c4b
124 changed files with 34856 additions and 0 deletions
244
crates/pinakes-server/src/app.rs
Normal file
244
crates/pinakes-server/src/app.rs
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue