Compare commits
10 Commits
d153e1c251
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 824cd2ad53 | |||
| 40d57d7c77 | |||
| da9cd3e03b | |||
| eb95eed752 | |||
| affe9bcd14 | |||
| 23933f47a0 | |||
| 7d06c9e93d | |||
| 690a7111aa | |||
| 1c98dae3d2 | |||
| a40c1f7396 |
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=your-password-here
|
||||||
|
POSTGRES_DB=loft
|
||||||
|
DATABASE_URL=postgres://postgres:your-password-here@db:5432/loft
|
||||||
|
STORAGE_PATH=/app/storage
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,3 +23,5 @@ node_modules/
|
|||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
docs/
|
docs/
|
||||||
|
|
||||||
|
logs/
|
||||||
7
Justfile
7
Justfile
@@ -1,7 +0,0 @@
|
|||||||
backend_path := "backend"
|
|
||||||
|
|
||||||
watch:
|
|
||||||
cd {{backend_path}} && cargo watch -q -c -w src/ -x run
|
|
||||||
|
|
||||||
watch-test:
|
|
||||||
cd {{backend_path}} && cargo watch -q -c -w src/ -x test
|
|
||||||
31
README.md
Normal file
31
README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# loft
|
||||||
|
|
||||||
|
A minimal, self-hosted web app (with a cloud storage option planned), built as a learning exercise to get more familiar with Axum and Svelte.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
```bash
|
||||||
|
git clone https://git.stefiosif.dev/stefiosif/loft
|
||||||
|
cd loft
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Run in Docker (http://localhost:3000):
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Run locally (http://localhost:5173):
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
just db-setup
|
||||||
|
# inside tmux together frontend and backend
|
||||||
|
just watch
|
||||||
|
# or separately
|
||||||
|
just watch-backend
|
||||||
|
just watch-frontend
|
||||||
|
|
||||||
|
# API docs (Swagger UI, generated with utoipa): http://localhost:3000/swagger-ui
|
||||||
|
|
||||||
|
# Run tests:
|
||||||
|
cp backend/.env.test.example backend/.env.test
|
||||||
|
just watch-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
Run `just --list` to see all recipes.
|
||||||
5
backend/.env.example
Normal file
5
backend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
DATABASE_URL=postgres://postgres:your-password-here@localhost:5432/loft
|
||||||
|
STORAGE_PATH=/tmp/loft-files
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=your-password-here
|
||||||
|
POSTGRES_DB=loft
|
||||||
3
backend/.env.test.example
Normal file
3
backend/.env.test.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
DATABASE_URL=postgres://postgres:your-password-here@localhost:5432/loft_test
|
||||||
|
STORAGE_PATH=/tmp/loft-files-test
|
||||||
|
ENVIRONMENT=test
|
||||||
@@ -37,6 +37,11 @@
|
|||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "user_id",
|
"name": "user_id",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "thumbnail_storage_path",
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -51,7 +56,8 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c"
|
"hash": "31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c"
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "user_id",
|
"name": "user_id",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "thumbnail_storage_path",
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -55,7 +60,8 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e"
|
"hash": "638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e"
|
||||||
|
|||||||
16
backend/.sqlx/query-ac8099bc9fa42a4152f2230903f9e9b51edfc01f03fd18cc2fe0ab3a956f3175.json
generated
Normal file
16
backend/.sqlx/query-ac8099bc9fa42a4152f2230903f9e9b51edfc01f03fd18cc2fe0ab3a956f3175.json
generated
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE file_records\n SET thumbnail_storage_path = $1\n WHERE id = $2\n AND user_id = $3\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Int8",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ac8099bc9fa42a4152f2230903f9e9b51edfc01f03fd18cc2fe0ab3a956f3175"
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@
|
|||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "user_id",
|
"name": "user_id",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "thumbnail_storage_path",
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -52,7 +57,8 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0"
|
"hash": "d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0"
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "user_id",
|
"name": "user_id",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "thumbnail_storage_path",
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -52,7 +57,8 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d"
|
"hash": "f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d"
|
||||||
|
|||||||
781
backend/Cargo.lock
generated
781
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,10 @@ rand = "0.10.1"
|
|||||||
futures-util = "0.3.32"
|
futures-util = "0.3.32"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
tower_governor = { version = "0.8.0", default-features = false, features = ["axum", "tracing"] }
|
tower_governor = { version = "0.8.0", default-features = false, features = ["axum", "tracing"] }
|
||||||
|
utoipa = { version = "5.5.0", features = ["axum_extras", "chrono"] }
|
||||||
|
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
|
||||||
|
image = "0.25.10"
|
||||||
|
tracing-appender = "0.2.5"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
axum-test = "20.0.0"
|
axum-test = "20.0.0"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE file_records ADD COLUMN thumbnail_storage_path TEXT;
|
||||||
@@ -19,6 +19,7 @@ pub enum LoftError {
|
|||||||
NoFileProvided,
|
NoFileProvided,
|
||||||
MultipartError(String),
|
MultipartError(String),
|
||||||
InvalidRange,
|
InvalidRange,
|
||||||
|
ThumbnailGenerationError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for LoftError {
|
impl fmt::Display for LoftError {
|
||||||
@@ -51,6 +52,12 @@ impl From<argon2::password_hash::Error> for LoftError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<image::ImageError> for LoftError {
|
||||||
|
fn from(value: image::ImageError) -> Self {
|
||||||
|
LoftError::ThumbnailGenerationError(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl std::error::Error for LoftError {}
|
impl std::error::Error for LoftError {}
|
||||||
|
|
||||||
impl IntoResponse for LoftError {
|
impl IntoResponse for LoftError {
|
||||||
@@ -80,6 +87,10 @@ impl IntoResponse for LoftError {
|
|||||||
error!("opendal storage error: {e}");
|
error!("opendal storage error: {e}");
|
||||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
}
|
}
|
||||||
|
Self::ThumbnailGenerationError(e) => {
|
||||||
|
error!("thumbnail generation error: {e}");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
|
}
|
||||||
Self::NoFileProvided => StatusCode::BAD_REQUEST.into_response(),
|
Self::NoFileProvided => StatusCode::BAD_REQUEST.into_response(),
|
||||||
Self::MultipartError(e) => {
|
Self::MultipartError(e) => {
|
||||||
info!("bad request: {e}");
|
info!("bad request: {e}");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod ctx;
|
mod ctx;
|
||||||
mod error;
|
mod error;
|
||||||
mod model;
|
mod model;
|
||||||
|
mod tasks;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
use std::{net::SocketAddr, time::Duration};
|
use std::{net::SocketAddr, time::Duration};
|
||||||
@@ -13,6 +14,7 @@ use axum::{
|
|||||||
middleware,
|
middleware,
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use tokio::sync::mpsc::{self};
|
||||||
use tower_cookies::CookieManagerLayer;
|
use tower_cookies::CookieManagerLayer;
|
||||||
use tower_governor::{
|
use tower_governor::{
|
||||||
GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor,
|
GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor,
|
||||||
@@ -24,70 +26,74 @@ use tower_http::{
|
|||||||
};
|
};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
use utoipa_swagger_ui::SwaggerUi;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{FileRepository, UserRepository},
|
model::{FileRepository, UserRepository},
|
||||||
web::{
|
web::{
|
||||||
mw_auth::{mw_ctx_resolver, mw_require_auth},
|
mw_auth::{mw_ctx_resolver, mw_require_auth},
|
||||||
routes_file::routes_file,
|
routes_file::{FileApi, FileState, routes_file},
|
||||||
routes_health::routes_health,
|
routes_health::{HealthApi, routes_health},
|
||||||
routes_login::routes_auth,
|
routes_login::{AuthApi, routes_auth},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BUFFER_SIZE: usize = 100;
|
||||||
|
const BODY_LIMIT: usize = 1000 * 1000 * 1000 * 5;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
let file_appender = tracing_appender::rolling::daily("logs", "loft.log");
|
||||||
|
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(
|
.with(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||||
format!("{}=info,tower_http=debug", env!("CARGO_CRATE_NAME")).into()
|
format!("{}=info,tower_http=debug", env!("CARGO_CRATE_NAME")).into()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::fmt::layer()
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_writer(non_blocking),
|
||||||
|
)
|
||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
const BODY_LIMIT: usize = 1000 * 1000 * 1000 * 5;
|
|
||||||
let pool = PgPool::connect(&database_url).await?;
|
let pool = PgPool::connect(&database_url).await?;
|
||||||
sqlx::migrate!().run(&pool).await?;
|
sqlx::migrate!().run(&pool).await?;
|
||||||
|
|
||||||
let file_repository = FileRepository::new(pool.clone())?;
|
let file_repository = FileRepository::new(pool.clone())?;
|
||||||
let routes_file = routes_file(file_repository.clone())
|
|
||||||
|
let (tx, rx) = mpsc::channel(BUFFER_SIZE);
|
||||||
|
tokio::spawn(tasks::run_thumbnail_worker(rx, file_repository.clone()));
|
||||||
|
|
||||||
|
let file_service = FileState::new(file_repository.clone(), tx);
|
||||||
|
let routes_file = routes_file(file_service)
|
||||||
.route_layer(middleware::from_fn(mw_require_auth))
|
.route_layer(middleware::from_fn(mw_require_auth))
|
||||||
.layer(DefaultBodyLimit::max(BODY_LIMIT));
|
.layer(DefaultBodyLimit::max(BODY_LIMIT));
|
||||||
|
|
||||||
let user_repository = UserRepository::new(pool);
|
let user_repository = UserRepository::new(pool);
|
||||||
let mut routes_auth = routes_auth(user_repository.clone());
|
let routes_auth = routes_auth(user_repository.clone());
|
||||||
|
let routes_auth = if std::env::var("ENVIRONMENT").unwrap_or_default() == "test" {
|
||||||
|
routes_auth
|
||||||
|
} else {
|
||||||
|
apply_governor(routes_auth)
|
||||||
|
};
|
||||||
|
|
||||||
if std::env::var("ENVIRONMENT").unwrap_or_default() != "test" {
|
let mut openapi = AuthApi::openapi();
|
||||||
let governor_conf_auth = GovernorConfigBuilder::default()
|
openapi.merge(FileApi::openapi());
|
||||||
.per_second(4)
|
openapi.merge(HealthApi::openapi());
|
||||||
.burst_size(2)
|
|
||||||
.key_extractor(SmartIpKeyExtractor)
|
|
||||||
.finish()
|
|
||||||
.expect("failed to initialize rate limiter configurations");
|
|
||||||
let governor_auth_limiter = governor_conf_auth.limiter().clone();
|
|
||||||
let interval = Duration::from_secs(60);
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
loop {
|
|
||||||
std::thread::sleep(interval);
|
|
||||||
let len = governor_auth_limiter.len();
|
|
||||||
if len > 0 {
|
|
||||||
info!("rate limiting auth storage size: {len}");
|
|
||||||
}
|
|
||||||
governor_auth_limiter.retain_recent();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
routes_auth = routes_auth.layer(GovernorLayer::new(governor_conf_auth));
|
|
||||||
}
|
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/api", routes_file)
|
.nest("/api", routes_file)
|
||||||
.nest("/api/auth", routes_auth)
|
.nest("/api/auth", routes_auth)
|
||||||
.merge(routes_health())
|
.merge(routes_health())
|
||||||
|
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
user_repository,
|
user_repository,
|
||||||
@@ -116,3 +122,27 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_governor(routes_auth: Router) -> Router {
|
||||||
|
let governor_conf_auth = GovernorConfigBuilder::default()
|
||||||
|
.per_second(4)
|
||||||
|
.burst_size(2)
|
||||||
|
.key_extractor(SmartIpKeyExtractor)
|
||||||
|
.finish()
|
||||||
|
.expect("failed to initialize rate limiter configurations");
|
||||||
|
let governor_auth_limiter = governor_conf_auth.limiter().clone();
|
||||||
|
let interval = Duration::from_secs(60);
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(interval);
|
||||||
|
let len = governor_auth_limiter.len();
|
||||||
|
if len > 0 {
|
||||||
|
info!("rate limiting auth storage size: {len}");
|
||||||
|
}
|
||||||
|
governor_auth_limiter.retain_recent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes_auth.layer(GovernorLayer::new(governor_conf_auth))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
use axum::{body::Bytes, extract::multipart::MultipartError};
|
use axum::{body::Bytes, extract::multipart::MultipartError};
|
||||||
use futures_util::{Stream, StreamExt};
|
use futures_util::{Stream, StreamExt};
|
||||||
use opendal::{Operator, layers::LoggingLayer, services};
|
use opendal::{FuturesBytesStream, Operator, layers::LoggingLayer, services};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sqlx::{PgPool, prelude::FromRow};
|
use sqlx::{PgPool, prelude::FromRow};
|
||||||
|
|
||||||
use crate::error::{LoftError, Result};
|
use crate::error::{LoftError, Result};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, FromRow)]
|
#[derive(Clone, Debug, Serialize, FromRow, utoipa::ToSchema)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FileRecord {
|
pub struct FileRecord {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -17,6 +17,7 @@ pub struct FileRecord {
|
|||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing)]
|
||||||
pub storage_key: String,
|
pub storage_key: String,
|
||||||
pub uploaded_at: chrono::DateTime<chrono::Utc>,
|
pub uploaded_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub thumbnail_storage_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -79,24 +80,28 @@ impl FileRepository {
|
|||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_file(
|
pub async fn download_file(&self, file_id: i64, user_id: i64) -> Result<FuturesBytesStream> {
|
||||||
&self,
|
|
||||||
file_id: i64,
|
|
||||||
user_id: i64,
|
|
||||||
) -> Result<impl Stream<Item = std::io::Result<Bytes>> + use<>> {
|
|
||||||
let record = self.get_file(file_id, user_id).await?;
|
let record = self.get_file(file_id, user_id).await?;
|
||||||
let reader = self.op.reader(&record.storage_key).await?;
|
let reader = self.op.reader(&record.storage_key).await?;
|
||||||
let stream = reader.into_bytes_stream(0..).await?;
|
let stream = reader.into_bytes_stream(0..).await?;
|
||||||
Ok(stream)
|
Ok(stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn thumbnail(&self, file_id: i64, user_id: i64) -> Result<FuturesBytesStream> {
|
||||||
|
let record = self.get_file(file_id, user_id).await?;
|
||||||
|
let path = format!("thumbnail/{}", &record.storage_key);
|
||||||
|
let reader = self.op.reader(&path).await?;
|
||||||
|
let stream = reader.into_bytes_stream(0..).await?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn stream_part(
|
pub async fn stream_part(
|
||||||
&self,
|
&self,
|
||||||
file_id: i64,
|
file_id: i64,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
from: u64,
|
from: u64,
|
||||||
to: u64,
|
to: u64,
|
||||||
) -> Result<impl Stream<Item = std::io::Result<Bytes>> + use<>> {
|
) -> Result<FuturesBytesStream> {
|
||||||
let record = self.get_file(file_id, user_id).await?;
|
let record = self.get_file(file_id, user_id).await?;
|
||||||
let reader = self.op.reader(&record.storage_key).await?;
|
let reader = self.op.reader(&record.storage_key).await?;
|
||||||
let stream = reader.into_bytes_stream(from..to).await?;
|
let stream = reader.into_bytes_stream(from..to).await?;
|
||||||
@@ -155,6 +160,29 @@ impl FileRepository {
|
|||||||
|
|
||||||
Ok(records)
|
Ok(records)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_thumbnail_storage_path(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
file_record_id: i64,
|
||||||
|
user_id: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
UPDATE file_records
|
||||||
|
SET thumbnail_storage_path = $1
|
||||||
|
WHERE id = $2
|
||||||
|
AND user_id = $3
|
||||||
|
"#,
|
||||||
|
path,
|
||||||
|
file_record_id,
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, FromRow)]
|
#[derive(Clone, Debug, FromRow)]
|
||||||
@@ -383,6 +411,37 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_update_thumbnail_storage_path() {
|
||||||
|
let user_repository = user_repository().await.unwrap();
|
||||||
|
let user = user_repository
|
||||||
|
.create_user("username", "password_hash")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let file_repository = file_repository().await.unwrap();
|
||||||
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
|
||||||
|
let record = file_repository
|
||||||
|
.create_file_record(user.id, "a.png", 2, "a.png-uuid")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(record.thumbnail_storage_path, None);
|
||||||
|
|
||||||
|
let path = "thumbnail/a.png-uuid";
|
||||||
|
file_repository
|
||||||
|
.update_thumbnail_storage_path(path, record.id, user.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let updated = file_repository.get_file(record.id, user.id).await.unwrap();
|
||||||
|
assert_eq!(updated.thumbnail_storage_path, Some(path.to_string()));
|
||||||
|
|
||||||
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_stream_part() {
|
async fn test_stream_part() {
|
||||||
|
|||||||
81
backend/src/tasks.rs
Normal file
81
backend/src/tasks.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
|
use tokio::sync::mpsc::Receiver;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::Result,
|
||||||
|
model::{FileRecord, FileRepository},
|
||||||
|
};
|
||||||
|
|
||||||
|
// change thumbnail task to have the file id in the param
|
||||||
|
// with the file id we can call the file repository and get the file record
|
||||||
|
// which will grand us access to the opendal storage... and also the MIME type
|
||||||
|
// so we can have a match statement after a parser that will give us the type of
|
||||||
|
// process we should do in order to create the thumbnail..
|
||||||
|
// for example an image will be processed with image crate but a video will be processed with tokio::command->ffmpeg
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ThumbnailTask {
|
||||||
|
record_id: i64,
|
||||||
|
user_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThumbnailTask {
|
||||||
|
pub fn new(record_id: i64, user_id: i64) -> Self {
|
||||||
|
Self { record_id, user_id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_thumbnail_worker(
|
||||||
|
mut rx: Receiver<ThumbnailTask>,
|
||||||
|
file_repository: FileRepository,
|
||||||
|
) {
|
||||||
|
while let Some(task) = rx.recv().await {
|
||||||
|
let record_id = task.record_id;
|
||||||
|
|
||||||
|
if let Err(e) = process_task(task, &file_repository).await {
|
||||||
|
warn!("thumbnail task {} failed: {}", record_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_task(task: ThumbnailTask, file_repository: &FileRepository) -> Result<()> {
|
||||||
|
let record_id = task.record_id;
|
||||||
|
info!("task for file record {} received", record_id);
|
||||||
|
|
||||||
|
let record = file_repository
|
||||||
|
.get_file(task.record_id, task.user_id)
|
||||||
|
.await?;
|
||||||
|
let file_type = &record.file_type;
|
||||||
|
|
||||||
|
// TODO: create enum that maps file_type (which is actually mime_type) into file category (image, video, ...)
|
||||||
|
if file_type.contains("image") {
|
||||||
|
generate_for_image(&record, file_repository).await?;
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"thumbnail generation for file type {} not yet supported",
|
||||||
|
file_type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_for_image(record: &FileRecord, file_repository: &FileRepository) -> Result<()> {
|
||||||
|
let storage_key = &record.storage_key;
|
||||||
|
let bytes = file_repository.op.read(storage_key).await?.to_vec();
|
||||||
|
|
||||||
|
let image = image::load_from_memory(&bytes)?;
|
||||||
|
let thumbnail = image.thumbnail(256, 256);
|
||||||
|
let rgb = thumbnail.to_rgb8();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut enc = JpegEncoder::new_with_quality(&mut buf, 50);
|
||||||
|
enc.encode_image(&rgb)?;
|
||||||
|
|
||||||
|
let path = format!("thumbnail/{}", storage_key);
|
||||||
|
file_repository.op.write(&path, buf).await?;
|
||||||
|
file_repository
|
||||||
|
.update_thumbnail_storage_path(&path, record.id, record.user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -7,88 +7,222 @@ use axum::{
|
|||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use sqlx::types::uuid;
|
use sqlx::types::uuid;
|
||||||
|
use tokio::sync::mpsc::Sender;
|
||||||
|
use tracing::warn;
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ctx::Ctx,
|
ctx::Ctx,
|
||||||
error::{LoftError, Result},
|
error::{LoftError, Result},
|
||||||
model::{FileRecord, FileRepository},
|
model::{FileRecord, FileRepository},
|
||||||
|
tasks::ThumbnailTask,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes_file(file_repository: FileRepository) -> Router {
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(
|
||||||
|
list_files,
|
||||||
|
upload_file,
|
||||||
|
get_file,
|
||||||
|
delete_file,
|
||||||
|
download_file,
|
||||||
|
thumbnail,
|
||||||
|
stream_part
|
||||||
|
),
|
||||||
|
components(schemas(FileRecord))
|
||||||
|
)]
|
||||||
|
pub struct FileApi;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FileState {
|
||||||
|
pub file_repository: FileRepository,
|
||||||
|
pub tx: Sender<ThumbnailTask>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileState {
|
||||||
|
pub fn new(file_repository: FileRepository, tx: Sender<ThumbnailTask>) -> Self {
|
||||||
|
Self {
|
||||||
|
file_repository,
|
||||||
|
tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes_file(file_service: FileState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/files", get(list_files).post(upload_file))
|
.route("/files", get(list_files).post(upload_file))
|
||||||
.route("/files/{id}", get(get_file).delete(delete_file))
|
.route("/files/{id}", get(get_file).delete(delete_file))
|
||||||
.route("/files/{id}/download", get(download_file))
|
.route("/files/{id}/download", get(download_file))
|
||||||
|
.route("/files/{id}/thumbnail", get(thumbnail))
|
||||||
.route("/files/{id}/stream_part", get(stream_part))
|
.route("/files/{id}/stream_part", get(stream_part))
|
||||||
.with_state(file_repository)
|
.with_state(file_service)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upload a file
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/files",
|
||||||
|
tag = "files",
|
||||||
|
security(("cookie_auth" = [])),
|
||||||
|
request_body(content_type = "multipart/form-data", description = "Multipart form with a `file` field"),
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "Uploaded file metadata", body = FileRecord),
|
||||||
|
(status = 400, description = "No file provided or malformed upload"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
async fn upload_file(
|
async fn upload_file(
|
||||||
State(file_repository): State<FileRepository>,
|
State(service): State<FileState>,
|
||||||
ctx: Ctx,
|
ctx: Ctx,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<FileRecord>> {
|
) -> Result<(StatusCode, Json<FileRecord>)> {
|
||||||
let mut uploaded: Option<(String, String, usize)> = None;
|
let mut uploaded: Option<(String, String, usize)> = None;
|
||||||
|
|
||||||
while let Some(field) = multipart.next_field().await? {
|
while let Some(field) = multipart.next_field().await? {
|
||||||
if field.name() == Some("file") {
|
if field.name() == Some("file") {
|
||||||
let name = field.file_name().map(str::to_string).unwrap_or_default();
|
let name = field.file_name().map(str::to_string).unwrap_or_default();
|
||||||
let key = format!("{name}-{}", uuid::Uuid::new_v4());
|
let key = format!("{name}-{}", uuid::Uuid::new_v4());
|
||||||
let size = file_repository.upload_file(field, &key).await?;
|
let size = service.file_repository.upload_file(field, &key).await?;
|
||||||
uploaded = Some((name, key, size));
|
uploaded = Some((name, key, size));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (name, key, size) = uploaded.ok_or(LoftError::NoFileProvided)?;
|
let (name, key, size) = uploaded.ok_or(LoftError::NoFileProvided)?;
|
||||||
|
|
||||||
let file_record = file_repository
|
let record = service
|
||||||
|
.file_repository
|
||||||
.create_file_record(ctx.user_id(), &name, size, &key)
|
.create_file_record(ctx.user_id(), &name, size, &key)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(file_record))
|
if let Err(e) = service
|
||||||
|
.tx
|
||||||
|
.send(ThumbnailTask::new(record.id, ctx.user_id()))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!("failed to send thumbnail task for file {}: {e}", record.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(record)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a file's metadata
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/files/{id}",
|
||||||
|
tag = "files",
|
||||||
|
security(("cookie_auth" = [])),
|
||||||
|
params(("id" = u64, Path, description = "File id")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "File metadata", body = FileRecord),
|
||||||
|
(status = 404, description = "File not found"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
async fn get_file(
|
async fn get_file(
|
||||||
State(file_repository): State<FileRepository>,
|
State(service): State<FileState>,
|
||||||
ctx: Ctx,
|
ctx: Ctx,
|
||||||
Path(file_id): Path<u64>,
|
Path(file_id): Path<u64>,
|
||||||
) -> Result<Json<FileRecord>> {
|
) -> Result<Json<FileRecord>> {
|
||||||
let record = file_repository
|
let record = service
|
||||||
|
.file_repository
|
||||||
.get_file(file_id as i64, ctx.user_id())
|
.get_file(file_id as i64, ctx.user_id())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(record))
|
Ok(Json(record))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download a file
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/files/{id}/download",
|
||||||
|
tag = "files",
|
||||||
|
security(("cookie_auth" = [])),
|
||||||
|
params(("id" = u64, Path, description = "File id")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "File bytes", content_type = "application/octet-stream"),
|
||||||
|
(status = 404, description = "File not found"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
async fn download_file(
|
async fn download_file(
|
||||||
State(file_repository): State<FileRepository>,
|
State(service): State<FileState>,
|
||||||
ctx: Ctx,
|
ctx: Ctx,
|
||||||
Path(file_id): Path<u64>,
|
Path(file_id): Path<u64>,
|
||||||
) -> Result<impl IntoResponse> {
|
) -> Result<impl IntoResponse> {
|
||||||
let stream = file_repository
|
let stream = service
|
||||||
|
.file_repository
|
||||||
.download_file(file_id as i64, ctx.user_id())
|
.download_file(file_id as i64, ctx.user_id())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Body::from_stream(stream))
|
Ok(Body::from_stream(stream))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download a file thumbnail
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/files/{id}/thumbnail",
|
||||||
|
tag = "files",
|
||||||
|
security(("cookie_auth" = [])),
|
||||||
|
params(("id" = u64, Path, description = "File id")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "File bytes", content_type = "application/octet-stream"),
|
||||||
|
(status = 404, description = "File not found"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn thumbnail(
|
||||||
|
State(service): State<FileState>,
|
||||||
|
ctx: Ctx,
|
||||||
|
Path(file_id): Path<u64>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let stream = service
|
||||||
|
.file_repository
|
||||||
|
.thumbnail(file_id as i64, ctx.user_id())
|
||||||
|
.await?;
|
||||||
|
Ok(Body::from_stream(stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream a byte range of a file (HTTP 206)
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/files/{id}/stream_part",
|
||||||
|
tag = "files",
|
||||||
|
security(("cookie_auth" = [])),
|
||||||
|
params(
|
||||||
|
("id" = u64, Path, description = "File id"),
|
||||||
|
("Range" = Option<String>, Header, description = "Byte range, e.g. bytes=0-1023")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 206, description = "Partial content"),
|
||||||
|
(status = 400, description = "Invalid range"),
|
||||||
|
(status = 404, description = "File not found"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
async fn stream_part(
|
async fn stream_part(
|
||||||
State(file_repository): State<FileRepository>,
|
State(service): State<FileState>,
|
||||||
ctx: Ctx,
|
ctx: Ctx,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Path(file_id): Path<u64>,
|
Path(file_id): Path<u64>,
|
||||||
) -> Result<impl IntoResponse> {
|
) -> Result<impl IntoResponse> {
|
||||||
let file_record = file_repository
|
let record = service
|
||||||
|
.file_repository
|
||||||
.get_file(file_id as i64, ctx.user_id())
|
.get_file(file_id as i64, ctx.user_id())
|
||||||
.await?;
|
.await?;
|
||||||
let file_size = file_record.size as u64;
|
let file_size = record.size as u64;
|
||||||
let (start, end): (u64, u64) = parse_range(&headers, file_size)?;
|
let (start, end): (u64, u64) = parse_range(&headers, file_size)?;
|
||||||
|
|
||||||
let stream = file_repository
|
let stream = service
|
||||||
|
.file_repository
|
||||||
.stream_part(file_id as i64, ctx.user_id(), start, end + 1)
|
.stream_part(file_id as i64, ctx.user_id(), start, end + 1)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let respones = Response::builder()
|
let respones = Response::builder()
|
||||||
.status(StatusCode::PARTIAL_CONTENT)
|
.status(StatusCode::PARTIAL_CONTENT)
|
||||||
.header(header::CONTENT_TYPE, file_record.file_type)
|
.header(header::CONTENT_TYPE, record.file_type)
|
||||||
.header(header::CONTENT_LENGTH, end - start + 1)
|
.header(header::CONTENT_LENGTH, end - start + 1)
|
||||||
.header(
|
.header(
|
||||||
header::CONTENT_RANGE,
|
header::CONTENT_RANGE,
|
||||||
@@ -117,28 +251,56 @@ fn parse_range(headers: &HeaderMap, file_size: u64) -> Result<(u64, u64)> {
|
|||||||
Ok((start, end))
|
Ok((start, end))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete a file
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/files/{id}",
|
||||||
|
tag = "files",
|
||||||
|
security(("cookie_auth" = [])),
|
||||||
|
params(("id" = u64, Path, description = "File id")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Deleted file metadata", body = FileRecord),
|
||||||
|
(status = 404, description = "File not found"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
async fn delete_file(
|
async fn delete_file(
|
||||||
State(file_repository): State<FileRepository>,
|
State(service): State<FileState>,
|
||||||
ctx: Ctx,
|
ctx: Ctx,
|
||||||
Path(file_id): Path<u64>,
|
Path(file_id): Path<u64>,
|
||||||
) -> Result<Json<FileRecord>> {
|
) -> Result<Json<FileRecord>> {
|
||||||
let file = file_repository
|
let file = service
|
||||||
|
.file_repository
|
||||||
.delete_file(file_id as i64, ctx.user_id())
|
.delete_file(file_id as i64, ctx.user_id())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(file))
|
Ok(Json(file))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_files(
|
/// List the current user's files
|
||||||
State(file_repository): State<FileRepository>,
|
#[utoipa::path(
|
||||||
ctx: Ctx,
|
get,
|
||||||
) -> Result<Json<Vec<FileRecord>>> {
|
path = "/api/files",
|
||||||
let files = file_repository.list_files(ctx.user_id()).await?;
|
tag = "files",
|
||||||
|
security(("cookie_auth" = [])),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "List user's files", body = Vec<FileRecord>),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn list_files(State(service): State<FileState>, ctx: Ctx) -> Result<Json<Vec<FileRecord>>> {
|
||||||
|
let files = service.file_repository.list_files(ctx.user_id()).await?;
|
||||||
Ok(Json(files))
|
Ok(Json(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use axum::{Router, http::header, middleware};
|
use axum::{
|
||||||
|
Router,
|
||||||
|
http::{StatusCode, header},
|
||||||
|
middleware,
|
||||||
|
};
|
||||||
use axum_test::{
|
use axum_test::{
|
||||||
TestServer,
|
TestServer,
|
||||||
multipart::{MultipartForm, Part},
|
multipart::{MultipartForm, Part},
|
||||||
@@ -146,13 +308,14 @@ mod tests {
|
|||||||
use rand::RngExt;
|
use rand::RngExt;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
use tower_cookies::CookieManagerLayer;
|
use tower_cookies::CookieManagerLayer;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{FileRepository, UserRepository},
|
model::{FileRepository, UserRepository},
|
||||||
web::{
|
web::{
|
||||||
mw_auth::{mw_ctx_resolver, mw_require_auth},
|
mw_auth::{mw_ctx_resolver, mw_require_auth},
|
||||||
routes_file::routes_file,
|
routes_file::{FileState, routes_file},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,8 +340,12 @@ mod tests {
|
|||||||
async fn test_server() -> TestServer {
|
async fn test_server() -> TestServer {
|
||||||
let user_repository = user_repository().await;
|
let user_repository = user_repository().await;
|
||||||
let file_repository = file_repository().await;
|
let file_repository = file_repository().await;
|
||||||
|
|
||||||
|
let (tx, _) = mpsc::channel(100);
|
||||||
|
let file_service = FileState::new(file_repository.clone(), tx);
|
||||||
|
|
||||||
let routes_file =
|
let routes_file =
|
||||||
routes_file(file_repository.clone()).route_layer(middleware::from_fn(mw_require_auth));
|
routes_file(file_service).route_layer(middleware::from_fn(mw_require_auth));
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/api", routes_file)
|
.nest("/api", routes_file)
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
@@ -284,7 +451,7 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
res.assert_status_ok();
|
res.assert_status(StatusCode::CREATED);
|
||||||
let file = res.json::<serde_json::Value>();
|
let file = res.json::<serde_json::Value>();
|
||||||
assert_eq!(file["name"], "a.jpg");
|
assert_eq!(file["name"], "a.jpg");
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
use axum::{Router, routing::get};
|
use axum::{Router, routing::get};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(paths(health))]
|
||||||
|
pub struct HealthApi;
|
||||||
|
|
||||||
pub fn routes_health() -> Router {
|
pub fn routes_health() -> Router {
|
||||||
Router::new().route("/health", get(health))
|
Router::new().route("/health", get(health))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Health check
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/health",
|
||||||
|
tag = "health",
|
||||||
|
responses((status = 200, description = "Service is healthy"))
|
||||||
|
)]
|
||||||
async fn health() -> &'static str {
|
async fn health() -> &'static str {
|
||||||
"up"
|
"up"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ use argon2::{
|
|||||||
};
|
};
|
||||||
use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
|
use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
|
||||||
use rand::RngExt;
|
use rand::RngExt;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use tower_cookies::{Cookie, Cookies};
|
use tower_cookies::{Cookie, Cookies};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
use utoipa::{
|
||||||
|
Modify, OpenApi,
|
||||||
|
openapi::security::{ApiKey, ApiKeyValue, SecurityScheme},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{LoftError, Result},
|
error::{LoftError, Result},
|
||||||
@@ -14,6 +18,32 @@ use crate::{
|
|||||||
web::AUTH_TOKEN,
|
web::AUTH_TOKEN,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
info(
|
||||||
|
title = "loft API",
|
||||||
|
description = "Self-hosted media storage (Axum + SvelteKit)"
|
||||||
|
),
|
||||||
|
modifiers(&SecurityAddon),
|
||||||
|
paths(login, logout, register),
|
||||||
|
components(schemas(LoginPayload))
|
||||||
|
)]
|
||||||
|
pub struct AuthApi;
|
||||||
|
|
||||||
|
/// Registers the session-cookie auth scheme so protected endpoints show as secured.
|
||||||
|
struct SecurityAddon;
|
||||||
|
|
||||||
|
impl Modify for SecurityAddon {
|
||||||
|
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||||
|
if let Some(components) = openapi.components.as_mut() {
|
||||||
|
components.add_security_scheme(
|
||||||
|
"cookie_auth",
|
||||||
|
SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new(AUTH_TOKEN))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes_auth(user_repository: UserRepository) -> Router {
|
pub fn routes_auth(user_repository: UserRepository) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/login", post(login))
|
.route("/login", post(login))
|
||||||
@@ -22,6 +52,18 @@ pub fn routes_auth(user_repository: UserRepository) -> Router {
|
|||||||
.with_state(user_repository)
|
.with_state(user_repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Log in and start a session
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/login",
|
||||||
|
tag = "auth",
|
||||||
|
request_body = LoginPayload,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Login successful"),
|
||||||
|
(status = 401, description = "Login failed"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
async fn login(
|
async fn login(
|
||||||
State(user_repository): State<UserRepository>,
|
State(user_repository): State<UserRepository>,
|
||||||
cookies: Cookies,
|
cookies: Cookies,
|
||||||
@@ -50,6 +92,17 @@ async fn login(
|
|||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Log out and clear the session
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/logout",
|
||||||
|
tag = "auth",
|
||||||
|
// request_body = LoginPayload,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Logout successful"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
async fn logout(
|
async fn logout(
|
||||||
State(user_repository): State<UserRepository>,
|
State(user_repository): State<UserRepository>,
|
||||||
cookies: Cookies,
|
cookies: Cookies,
|
||||||
@@ -64,6 +117,18 @@ async fn logout(
|
|||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register a new account
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/register",
|
||||||
|
tag = "auth",
|
||||||
|
// request_body = LoginPayload,
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "Registration successful"),
|
||||||
|
(status = 401, description = "Registration failed"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
async fn register(
|
async fn register(
|
||||||
State(user_repository): State<UserRepository>,
|
State(user_repository): State<UserRepository>,
|
||||||
Json(payload): Json<LoginPayload>,
|
Json(payload): Json<LoginPayload>,
|
||||||
@@ -76,7 +141,7 @@ async fn register(
|
|||||||
warn!(
|
warn!(
|
||||||
"Register fail, username {} already exists",
|
"Register fail, username {} already exists",
|
||||||
&payload.username
|
&payload.username
|
||||||
); // also fix "Login fail" typo
|
);
|
||||||
return Err(LoftError::RegisterFail);
|
return Err(LoftError::RegisterFail);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +173,7 @@ fn create_cookie() -> Cookie<'static> {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
struct LoginPayload {
|
struct LoginPayload {
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- loft_db_data:/var/lib/postgresql/data
|
- loft_db_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres -d loft"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -2,16 +2,45 @@
|
|||||||
import type { FileRecord } from '$lib/types';
|
import type { FileRecord } from '$lib/types';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import VideoPreview from './VideoPreview.svelte';
|
import VideoPreview from './VideoPreview.svelte';
|
||||||
|
import ImagePreview from './ImagePreview.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
file,
|
||||||
|
onclose,
|
||||||
|
onprev,
|
||||||
|
onnext,
|
||||||
|
hasPrev = false,
|
||||||
|
hasNext = false
|
||||||
|
}: {
|
||||||
|
file: FileRecord;
|
||||||
|
onclose: () => void;
|
||||||
|
onprev?: () => void;
|
||||||
|
onnext?: () => void;
|
||||||
|
hasPrev?: boolean;
|
||||||
|
hasNext?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let { file, onclose }: { file: FileRecord; onclose: () => void } = $props();
|
|
||||||
const isVideo = $derived(file.fileType.includes('video'));
|
const isVideo = $derived(file.fileType.includes('video'));
|
||||||
</script>
|
const isImage = $derived(file.fileType.includes('image'));
|
||||||
|
|
||||||
<svelte:window
|
$effect(() => {
|
||||||
onkeydown={(e) => {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') onclose();
|
if (e.key === 'Escape') {
|
||||||
}}
|
onclose();
|
||||||
/>
|
} else if (e.key === 'ArrowLeft' && hasPrev) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onprev?.();
|
||||||
|
} else if (e.key === 'ArrowRight' && hasNext) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onnext?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeydown, { capture: true });
|
||||||
|
return () => window.removeEventListener('keydown', onKeydown, { capture: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
@@ -20,12 +49,73 @@
|
|||||||
onclick={onclose}
|
onclick={onclose}
|
||||||
transition:fade={{ duration: 150 }}
|
transition:fade={{ duration: 150 }}
|
||||||
>
|
>
|
||||||
|
{#if hasPrev}
|
||||||
|
<button
|
||||||
|
class="absolute top-1/2 left-4 z-10 -translate-y-1/2 cursor-pointer rounded-full bg-black/40 p-2 text-white/70 transition-colors hover:bg-black/60 hover:text-white"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onprev?.();
|
||||||
|
}}
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasNext}
|
||||||
|
<button
|
||||||
|
class="absolute top-1/2 right-4 z-10 -translate-y-1/2 cursor-pointer rounded-full bg-black/40 p-2 text-white/70 transition-colors hover:bg-black/60 hover:text-white"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onnext?.();
|
||||||
|
}}
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative aspect-video h-[85vh] max-w-[95vw] overflow-hidden rounded-xl border border-sky-200/20 bg-[#0f1117]"
|
class="relative overflow-hidden rounded-xl border border-sky-200/20 bg-[#0f1117] {isImage
|
||||||
|
? 'max-h-[85vh] max-w-[95vw]'
|
||||||
|
: 'aspect-video h-[85vh] max-w-[95vw]'}"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{#if isVideo}
|
{#key file.id}
|
||||||
<VideoPreview src={`/api/files/${file.id}/stream_part`} />
|
{#if isVideo}
|
||||||
|
<VideoPreview src={`/api/files/${file.id}/stream_part`} />
|
||||||
|
{:else if isImage}
|
||||||
|
<ImagePreview src={`/api/files/${file.id}/download`} alt={file.name} />
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-full w-full items-center justify-center text-sm text-white/40">
|
||||||
|
No preview available
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
|
|
||||||
|
{#if isVideo || isImage}
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-between bg-linear-to-b from-black/70 to-transparent px-4 py-3"
|
class="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-between bg-linear-to-b from-black/70 to-transparent px-4 py-3"
|
||||||
>
|
>
|
||||||
@@ -52,10 +142,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<div class="flex h-full w-full items-center justify-center text-sm text-white/40">
|
|
||||||
No preview available
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
5
frontend/src/lib/components/ImagePreview.svelte
Normal file
5
frontend/src/lib/components/ImagePreview.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { src, alt }: { src: string; alt: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<img {src} {alt} class="block max-h-[85vh] max-w-[95vw] object-contain" />
|
||||||
@@ -40,9 +40,9 @@
|
|||||||
class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg border transition-all hover:border-sky-400/50
|
class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg border transition-all hover:border-sky-400/50
|
||||||
{selected ? 'border-sky-400/50 ring-1 ring-sky-400/50' : 'border-sky-200/20'}"
|
{selected ? 'border-sky-400/50 ring-1 ring-sky-400/50' : 'border-sky-200/20'}"
|
||||||
>
|
>
|
||||||
{#if isImage}
|
{#if isImage && file.thumbnailStoragePath}
|
||||||
<img
|
<img
|
||||||
src={`/api/files/${file.id}/download`}
|
src={`/api/files/${file.id}/thumbnail`}
|
||||||
alt={file.name}
|
alt={file.name}
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export type FileRecord = {
|
|||||||
size: number;
|
size: number;
|
||||||
fileType: string;
|
fileType: string;
|
||||||
uploadedAt: string;
|
uploadedAt: string;
|
||||||
|
thumbnailStoragePath: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,27 @@
|
|||||||
fileRecords.filter((f) => f.name.toLowerCase().includes(search.toLowerCase()))
|
fileRecords.filter((f) => f.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let previewIndex = $derived.by(() => {
|
||||||
|
if (!previewFile) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const id = previewFile.id;
|
||||||
|
return filteredFileRecords.findIndex((f) => f.id === id);
|
||||||
|
});
|
||||||
|
let hasPrev = $derived(previewIndex > 0);
|
||||||
|
let hasNext = $derived(previewIndex >= 0 && previewIndex < filteredFileRecords.length - 1);
|
||||||
|
|
||||||
|
function goPrev() {
|
||||||
|
if (hasPrev) {
|
||||||
|
previewFile = filteredFileRecords[previewIndex - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function goNext() {
|
||||||
|
if (hasNext) {
|
||||||
|
previewFile = filteredFileRecords[previewIndex + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
async function handleUpload(event: Event) {
|
async function handleUpload(event: Event) {
|
||||||
@@ -61,6 +82,19 @@
|
|||||||
window.location.href = '/auth';
|
window.location.href = '/auth';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const pending = fileRecords.some(
|
||||||
|
(f) => f.fileType.includes('image') && !f.thumbnailStoragePath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = setInterval(refresh, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const saved = localStorage.getItem('viewMode');
|
const saved = localStorage.getItem('viewMode');
|
||||||
if (saved === 'rows' || saved === 'tiles') viewMode = saved;
|
if (saved === 'rows' || saved === 'tiles') viewMode = saved;
|
||||||
@@ -214,5 +248,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if previewFile}
|
{#if previewFile}
|
||||||
<FilePreview file={previewFile} onclose={() => (previewFile = null)} />
|
<FilePreview
|
||||||
|
file={previewFile}
|
||||||
|
onclose={() => (previewFile = null)}
|
||||||
|
onprev={goPrev}
|
||||||
|
onnext={goNext}
|
||||||
|
{hasPrev}
|
||||||
|
{hasNext}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
106
justfile
Normal file
106
justfile
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
backend_path := "backend"
|
||||||
|
frontend_path := "frontend"
|
||||||
|
|
||||||
|
# Run backend + frontend watchers in split tmux panes (run from inside tmux)
|
||||||
|
[group('dev')]
|
||||||
|
watch:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
tmux split-window -h 'just watch-frontend'
|
||||||
|
just watch-backend
|
||||||
|
|
||||||
|
# Run the backend with hot reload
|
||||||
|
[group('dev')]
|
||||||
|
watch-backend:
|
||||||
|
cd {{backend_path}} && cargo watch -q -c -w src/ -x run
|
||||||
|
|
||||||
|
# Run the frontend dev server
|
||||||
|
[group('dev')]
|
||||||
|
watch-frontend:
|
||||||
|
cd {{frontend_path}} && pnpm dev
|
||||||
|
|
||||||
|
# Run the backend tests in watch mode (sets up the test DB first)
|
||||||
|
[group('test')]
|
||||||
|
watch-test: test-db-setup
|
||||||
|
cd {{backend_path}} && cargo watch -q -c -w src/ -x test
|
||||||
|
|
||||||
|
# Run the Playwright e2e suite (sets up the test DB + servers)
|
||||||
|
[group('test')]
|
||||||
|
e2e:
|
||||||
|
mkdir -p /tmp/loft-files-test
|
||||||
|
cd {{backend_path}} && set -a && . ./.env.test && set +a && (sqlx database create 2>/dev/null || true) && sqlx migrate run
|
||||||
|
docker compose exec -T db psql -U postgres -d loft_test -c "TRUNCATE users, sessions, file_records CASCADE"
|
||||||
|
cd {{frontend_path}} && pnpm exec playwright test
|
||||||
|
|
||||||
|
# Format backend + frontend
|
||||||
|
[group('quality')]
|
||||||
|
fmt: fmt-backend fmt-frontend
|
||||||
|
|
||||||
|
# Format the backend (cargo fmt)
|
||||||
|
[group('quality')]
|
||||||
|
fmt-backend:
|
||||||
|
cd {{backend_path}} && cargo fmt
|
||||||
|
|
||||||
|
# Format the frontend (prettier)
|
||||||
|
[group('quality')]
|
||||||
|
fmt-frontend:
|
||||||
|
cd {{frontend_path}} && pnpm exec prettier --write .
|
||||||
|
|
||||||
|
# Lint the backend (cargo clippy)
|
||||||
|
[group('quality')]
|
||||||
|
lint-backend:
|
||||||
|
cd {{backend_path}} && cargo clippy
|
||||||
|
|
||||||
|
# Stage the given files (git add)
|
||||||
|
[group('git')]
|
||||||
|
stage +files:
|
||||||
|
git add {{files}}
|
||||||
|
|
||||||
|
# Commit staged changes with a message
|
||||||
|
[group('git')]
|
||||||
|
commit message:
|
||||||
|
git commit -m "{{message}}"
|
||||||
|
|
||||||
|
# Commit staged changes with a Claude co-author trailer
|
||||||
|
[group('git')]
|
||||||
|
co-commit message:
|
||||||
|
git commit -m "{{message}}" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
|
||||||
|
# Push main to both the github and gitea remotes
|
||||||
|
[group('git')]
|
||||||
|
push:
|
||||||
|
git push -u github main && git push -u gitea main
|
||||||
|
|
||||||
|
# Create (if missing) + migrate the test database
|
||||||
|
[group('db')]
|
||||||
|
test-db-setup: db-up
|
||||||
|
cd {{backend_path}} && set -a && . ./.env.test && set +a && (sqlx database create 2>/dev/null || true) && sqlx migrate run
|
||||||
|
|
||||||
|
# Bring up the dev database container
|
||||||
|
[group('db')]
|
||||||
|
db-up:
|
||||||
|
docker compose up -d db
|
||||||
|
|
||||||
|
# Create (if missing) + migrate the dev database
|
||||||
|
[group('db')]
|
||||||
|
db-setup: db-up
|
||||||
|
cd {{backend_path}} && set -a && . ./.env && set +a && (sqlx database create 2>/dev/null || true) && sqlx migrate run
|
||||||
|
|
||||||
|
# Apply pending migrations to the dev database
|
||||||
|
[group('db')]
|
||||||
|
migrate:
|
||||||
|
cd {{backend_path}} && set -a && . ./.env && set +a && sqlx migrate run
|
||||||
|
|
||||||
|
# Open a psql shell on the dev database
|
||||||
|
[group('db')]
|
||||||
|
db-shell:
|
||||||
|
docker compose exec db psql -U postgres -d loft
|
||||||
|
|
||||||
|
# Run a SQL query against the dev database
|
||||||
|
[group('db')]
|
||||||
|
db-query query:
|
||||||
|
docker compose exec db psql -U postgres -d loft -c "{{query}}"
|
||||||
|
|
||||||
|
# Open a shell inside the app container
|
||||||
|
[group('db')]
|
||||||
|
app-shell:
|
||||||
|
docker compose exec app sh
|
||||||
Reference in New Issue
Block a user