Compare commits

..

24 Commits

Author SHA1 Message Date
824cd2ad53 refactor(backend): extract governor setup into a function 2026-06-28 17:46:16 +03:00
40d57d7c77 fix(backend): add missing sqlx cache for thumbnail update query 2026-06-28 17:43:30 +03:00
da9cd3e03b feat(backend): keep logs in daily rolling files 2026-06-28 13:05:09 +03:00
eb95eed752 refactor(backend): return concrete FuturesByteStream instead of impl Stream 2026-06-28 13:03:37 +03:00
affe9bcd14 feat: generate and display thumbnails asynchronously 2026-06-28 12:18:26 +03:00
23933f47a0 docs: update readme 2026-06-24 20:14:33 +03:00
7d06c9e93d docs: add README and env examples 2026-06-24 20:11:07 +03:00
690a7111aa chore: update justfile commands and parameterize healthcheck info
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:10:34 +03:00
1c98dae3d2 feat(frontend): add image preview and prev/next navigation 2026-06-24 19:22:48 +03:00
a40c1f7396 feat(backend): add swagger-ui endpoint via utoipa 2026-06-24 19:22:39 +03:00
d153e1c251 feat(frontend): add tile view mode
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:49:54 +03:00
be3f7da565 fix(backend): skip auth rate limiter in test env 2026-06-14 23:38:48 +03:00
91459db5ec refactor(backend): remove redundant main_response_mapper middleware 2026-06-13 18:59:52 +03:00
450b3fdf52 refactor(backend): drop redundant LoftError from Result return types 2026-06-13 17:31:03 +03:00
2616896ecb refactor(backend): replace unwraps with typed errors 2026-06-13 12:38:44 +03:00
21d65af470 test(backend): cover stream_part range requests 2026-06-07 20:20:53 +03:00
4699ffe6af feat(backend): cap upload body size at 5 GB 2026-06-07 19:54:14 +03:00
b2a7787e45 feat(backend): serve video with HTTP range requests 2026-06-07 19:53:19 +03:00
8cb1f4142b chore: remove redundant files 2026-06-06 23:20:47 +03:00
9124cc90aa ci: check sqlx queries against live db 2026-06-06 21:20:32 +03:00
8858bfa3c6 ci: add backend test workflow
Some checks failed
Backend CI / test (push) Has been cancelled
2026-06-06 20:30:05 +03:00
ec1c458bb7 feat(backend): rate limit auth routes with tower_governor 2026-06-06 17:50:36 +03:00
b5e76b0368 build: load secrets from .env instead of hardcoding 2026-06-06 16:25:29 +03:00
d2f044db07 test(frontend): add real-auth Playwright e2e suite
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:55:10 +03:00
37 changed files with 2726 additions and 573 deletions

5
.env.example Normal file
View 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

37
.github/workflows/backend.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Backend CI
on:
push:
branches: [main]
paths: ["backend/**", ".github/workflows/backend.yml"]
pull_request:
branches: [main]
paths: ["backend/**", ".github/workflows/backend.yml"]
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: pass
POSTGRES_DB: loft_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s --health-timeout 5s --health-retries 5
env:
DATABASE_URL: postgres://postgres:pass@localhost:5432/loft_test
STORAGE_PATH: /tmp/loft-files-test
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo install sqlx-cli --no-default-features --features postgres,rustls
- run: sqlx migrate run
- run: mkdir -p /tmp/loft-files-test
- run: cargo test

2
.gitignore vendored
View File

@@ -23,3 +23,5 @@ node_modules/
.env.*.local .env.*.local
docs/ docs/
logs/

View File

@@ -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
View 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
View 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

View File

@@ -0,0 +1,3 @@
DATABASE_URL=postgres://postgres:your-password-here@localhost:5432/loft_test
STORAGE_PATH=/tmp/loft-files-test
ENVIRONMENT=test

View File

@@ -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"

View File

@@ -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"

View 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"
}

View File

@@ -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"

View File

@@ -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"

999
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,11 @@ argon2 = "0.5.3"
rand = "0.10.1" 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"] }
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"

View File

@@ -0,0 +1 @@
ALTER TABLE file_records ADD COLUMN thumbnail_storage_path TEXT;

View File

@@ -1,16 +1,25 @@
use std::fmt; use std::fmt;
use axum::{http::StatusCode, response::IntoResponse}; use axum::{extract::multipart::MultipartError, http::StatusCode, response::IntoResponse};
use tracing::info; use tracing::{error, info};
pub type Result<T, E = LoftError> = std::result::Result<T, E>;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum LoftError { pub enum LoftError {
LoginFail, LoginFail,
RegisterFail, RegisterFail,
AuthFailNoAuthTokenCookie, AuthFailNoAuthTokenCookie,
AuthFailSessionNotFound,
AuthFailCtxNotInRequestExt, AuthFailCtxNotInRequestExt,
FileIdNotFound, FileIdNotFound,
UndefinedErrorType, DatabaseError(String),
ArgonError(String),
OpenDalError(String),
NoFileProvided,
MultipartError(String),
InvalidRange,
ThumbnailGenerationError(String),
} }
impl fmt::Display for LoftError { impl fmt::Display for LoftError {
@@ -19,6 +28,36 @@ impl fmt::Display for LoftError {
} }
} }
impl From<sqlx::Error> for LoftError {
fn from(value: sqlx::Error) -> Self {
LoftError::DatabaseError(value.to_string())
}
}
impl From<MultipartError> for LoftError {
fn from(value: MultipartError) -> Self {
LoftError::MultipartError(value.to_string())
}
}
impl From<opendal::Error> for LoftError {
fn from(value: opendal::Error) -> Self {
LoftError::OpenDalError(value.to_string())
}
}
impl From<argon2::password_hash::Error> for LoftError {
fn from(value: argon2::password_hash::Error) -> Self {
LoftError::ArgonError(value.to_string())
}
}
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 {
@@ -27,7 +66,8 @@ impl IntoResponse for LoftError {
Self::LoginFail Self::LoginFail
| Self::RegisterFail | Self::RegisterFail
| Self::AuthFailNoAuthTokenCookie | Self::AuthFailNoAuthTokenCookie
| Self::AuthFailCtxNotInRequestExt => { | Self::AuthFailCtxNotInRequestExt
| Self::AuthFailSessionNotFound => {
info!("UNAUTHORIZED"); info!("UNAUTHORIZED");
StatusCode::UNAUTHORIZED.into_response() StatusCode::UNAUTHORIZED.into_response()
} }
@@ -35,10 +75,28 @@ impl IntoResponse for LoftError {
info!("NOT_FOUND"); info!("NOT_FOUND");
StatusCode::NOT_FOUND.into_response() StatusCode::NOT_FOUND.into_response()
} }
Self::UndefinedErrorType => { Self::DatabaseError(e) => {
info!("INTERNAL_SERVER_ERROR"); error!("database error: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response() StatusCode::INTERNAL_SERVER_ERROR.into_response()
} }
Self::ArgonError(e) => {
error!("argon2 error: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
Self::OpenDalError(e) => {
error!("opendal storage error: {e}");
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::MultipartError(e) => {
info!("bad request: {e}");
StatusCode::BAD_REQUEST.into_response()
}
Self::InvalidRange => StatusCode::BAD_REQUEST.into_response(),
} }
} }
} }

View File

@@ -1,18 +1,24 @@
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 anyhow::Result; use anyhow::Result;
use axum::{ use axum::{
Router, Router,
extract::DefaultBodyLimit, extract::DefaultBodyLimit,
http::{HeaderValue, Method, header}, http::{HeaderValue, Method, header},
middleware, middleware,
response::Response,
}; };
use sqlx::PgPool; use sqlx::PgPool;
use tokio::sync::mpsc::{self};
use tower_cookies::CookieManagerLayer; use tower_cookies::CookieManagerLayer;
use tower_governor::{
GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor,
};
use tower_http::{ use tower_http::{
cors::CorsLayer, cors::CorsLayer,
services::{ServeDir, ServeFile}, services::{ServeDir, ServeFile},
@@ -20,47 +26,75 @@ 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");
let pool = PgPool::connect(&database_url).await.unwrap(); 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 routes_file = routes_file(file_repository.clone())
.route_layer(middleware::from_fn(mw_require_auth))
.layer(DefaultBodyLimit::disable());
let user_repository = UserRepository::new(pool)?; let file_repository = FileRepository::new(pool.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))
.layer(DefaultBodyLimit::max(BODY_LIMIT));
let user_repository = UserRepository::new(pool);
let 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)
};
let mut openapi = AuthApi::openapi();
openapi.merge(FileApi::openapi());
openapi.merge(HealthApi::openapi());
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::map_response(main_response_mapper))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(
user_repository, user_repository,
mw_ctx_resolver, mw_ctx_resolver,
@@ -68,7 +102,7 @@ async fn main() -> Result<()> {
.layer(CookieManagerLayer::new()) .layer(CookieManagerLayer::new())
.layer( .layer(
CorsLayer::new() CorsLayer::new()
.allow_origin("http://localhost:5173".parse::<HeaderValue>().unwrap()) .allow_origin("http://localhost:5173".parse::<HeaderValue>()?)
.allow_methods([Method::GET, Method::POST, Method::DELETE]) .allow_methods([Method::GET, Method::POST, Method::DELETE])
.allow_credentials(true) .allow_credentials(true)
.allow_headers([header::CONTENT_TYPE]), .allow_headers([header::CONTENT_TYPE]),
@@ -78,14 +112,37 @@ async fn main() -> Result<()> {
); );
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
info!("listening on {}", listener.local_addr().unwrap()); info!("listening on {}", listener.local_addr()?);
axum::serve(listener, app).await?; axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(()) Ok(())
} }
async fn main_response_mapper(res: Response) -> Response { fn apply_governor(routes_auth: Router) -> Router {
info!("response mapper: {}", res.status()); let governor_conf_auth = GovernorConfigBuilder::default()
res .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))
} }

View File

@@ -1,14 +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::{Deserialize, Serialize}; use serde::Serialize;
use sqlx::{PgPool, prelude::FromRow}; use sqlx::{PgPool, prelude::FromRow};
use std::fmt::Display;
use tracing::info;
use crate::error::LoftError; 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,
@@ -19,23 +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, Debug, Serialize, Deserialize)]
pub enum FileType {
Image,
Video,
Document,
}
impl Display for FileType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FileType::Image => write!(f, "Image"),
FileType::Video => write!(f, "Video"),
FileType::Document => write!(f, "Document"),
}
}
} }
#[derive(Clone)] #[derive(Clone)]
@@ -45,13 +27,11 @@ pub struct FileRepository {
} }
impl FileRepository { impl FileRepository {
pub fn new(pool: PgPool) -> Result<Self, LoftError> { pub fn new(pool: PgPool) -> Result<Self> {
let storage_path = std::env::var("STORAGE_PATH").expect("STORAGE_PATH must be set"); let storage_path = std::env::var("STORAGE_PATH").expect("STORAGE_PATH must be set");
let op = Operator::new(services::Fs::default().root(&storage_path)) let op = Operator::new(services::Fs::default().root(&storage_path))?
.unwrap()
.layer(LoggingLayer::default()) .layer(LoggingLayer::default())
.finish(); .finish();
//.map_err(|x| LoftError::customerror)?;
Ok(Self { pool, op }) Ok(Self { pool, op })
} }
@@ -60,16 +40,15 @@ impl FileRepository {
&self, &self,
mut file_byte_stream: impl Stream<Item = Result<Bytes, MultipartError>> + Unpin, mut file_byte_stream: impl Stream<Item = Result<Bytes, MultipartError>> + Unpin,
file_storage_key: &str, file_storage_key: &str,
) -> Result<usize, LoftError> { ) -> Result<usize> {
let mut writer = self.op.writer(file_storage_key).await.unwrap(); let mut writer = self.op.writer(file_storage_key).await?;
let mut total_size = 0; let mut total_size = 0;
while let Some(chunk) = file_byte_stream.next().await { while let Some(chunk) = file_byte_stream.next().await {
let chunk = chunk.unwrap(); let chunk = chunk?;
total_size += chunk.len(); total_size += chunk.len();
writer.write(chunk).await.unwrap(); writer.write(chunk).await?;
} }
// must writer.close().await?;
writer.close().await.unwrap();
Ok(total_size) Ok(total_size)
} }
@@ -79,9 +58,8 @@ impl FileRepository {
file_name: &str, file_name: &str,
file_size: usize, file_size: usize,
file_storage_key: &str, file_storage_key: &str,
) -> Result<FileRecord, LoftError> { ) -> Result<FileRecord> {
info!("Saving metadata of file \"{}\" in file_records", file_name); let record = sqlx::query_as!(
let file_record = sqlx::query_as!(
FileRecord, FileRecord,
r#" r#"
INSERT INTO file_records (user_id, name, file_type, size, storage_key) INSERT INTO file_records (user_id, name, file_type, size, storage_key)
@@ -97,35 +75,41 @@ impl FileRepository {
file_storage_key file_storage_key
) )
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await?;
.unwrap();
Ok(file_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<>, LoftError> {
info!(
"Fetching metadata of file \"{}\" from file_records",
file_id
);
let record = self.get_file(file_id, user_id).await?; let record = self.get_file(file_id, user_id).await?;
info!("Downloading file \"{}\"", file_id); let reader = self.op.reader(&record.storage_key).await?;
let reader = self.op.reader(&record.storage_key).await.unwrap(); let stream = reader.into_bytes_stream(0..).await?;
let stream = reader.into_bytes_stream(0..).await.unwrap();
Ok(stream) Ok(stream)
} }
pub async fn get_file(&self, file_id: i64, user_id: i64) -> Result<FileRecord, LoftError> { pub async fn thumbnail(&self, file_id: i64, user_id: i64) -> Result<FuturesBytesStream> {
info!( let record = self.get_file(file_id, user_id).await?;
"Fetching metadata of file \"{}\" from file_records", let path = format!("thumbnail/{}", &record.storage_key);
file_id let reader = self.op.reader(&path).await?;
); let stream = reader.into_bytes_stream(0..).await?;
let record = sqlx::query_as!( Ok(stream)
}
pub async fn stream_part(
&self,
file_id: i64,
user_id: i64,
from: u64,
to: u64,
) -> Result<FuturesBytesStream> {
let record = self.get_file(file_id, user_id).await?;
let reader = self.op.reader(&record.storage_key).await?;
let stream = reader.into_bytes_stream(from..to).await?;
Ok(stream)
}
pub async fn get_file(&self, file_id: i64, user_id: i64) -> Result<FileRecord> {
sqlx::query_as!(
FileRecord, FileRecord,
r#" r#"
SELECT * SELECT *
@@ -137,23 +121,14 @@ impl FileRepository {
user_id user_id
) )
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await?
.unwrap() .ok_or(LoftError::FileIdNotFound)
.ok_or(LoftError::FileIdNotFound)?;
Ok(record)
} }
pub async fn delete_file(&self, file_id: i64, user_id: i64) -> Result<FileRecord, LoftError> { pub async fn delete_file(&self, file_id: i64, user_id: i64) -> Result<FileRecord> {
info!(
"Fetching metadata of file \"{}\" from file_records",
file_id
);
let record = self.get_file(file_id, user_id).await?; let record = self.get_file(file_id, user_id).await?;
info!("Deleting file bytes \"{}\"", file_id); self.op.delete(&record.storage_key).await?;
self.op.delete(&record.storage_key).await.unwrap();
info!("Deleting file record \"{}\"", file_id);
sqlx::query_as!( sqlx::query_as!(
FileRecord, FileRecord,
r#" r#"
@@ -166,13 +141,12 @@ impl FileRepository {
user_id user_id
) )
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await?
.unwrap()
.ok_or(LoftError::FileIdNotFound) .ok_or(LoftError::FileIdNotFound)
} }
pub async fn list_files(&self, user_id: i64) -> Result<Vec<FileRecord>, LoftError> { pub async fn list_files(&self, user_id: i64) -> Result<Vec<FileRecord>> {
let files = sqlx::query_as!( let records = sqlx::query_as!(
FileRecord, FileRecord,
r#" r#"
SELECT * SELECT *
@@ -182,10 +156,32 @@ impl FileRepository {
user_id user_id
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await?;
.unwrap();
Ok(files) 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(())
} }
} }
@@ -211,15 +207,11 @@ pub struct UserRepository {
} }
impl UserRepository { impl UserRepository {
pub fn new(pool: PgPool) -> Result<Self, LoftError> { pub fn new(pool: PgPool) -> Self {
Ok(Self { pool }) Self { pool }
} }
pub async fn create_user( pub async fn create_user(&self, username: &str, password_hash: &str) -> Result<User> {
&self,
username: &str,
password_hash: &str,
) -> Result<User, LoftError> {
let user = sqlx::query_as!( let user = sqlx::query_as!(
User, User,
r#" r#"
@@ -231,16 +223,12 @@ impl UserRepository {
password_hash password_hash
) )
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await?;
.unwrap();
info!("Persisted user: {}", username);
Ok(user) Ok(user)
} }
pub async fn find_by_username(&self, username: &str) -> Result<User, LoftError> { pub async fn find_by_username(&self, username: &str) -> Result<Option<User>> {
info!("Fetching username \"{}\" from users", username);
let user = sqlx::query_as!( let user = sqlx::query_as!(
User, User,
r#" r#"
@@ -251,9 +239,7 @@ impl UserRepository {
username username
) )
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await?;
.unwrap()
.ok_or(LoftError::LoginFail)?;
Ok(user) Ok(user)
} }
@@ -263,8 +249,8 @@ impl UserRepository {
user_id: i64, user_id: i64,
token: &str, token: &str,
expires_at: chrono::DateTime<chrono::Utc>, expires_at: chrono::DateTime<chrono::Utc>,
) -> Result<Session, LoftError> { ) -> Result<Session> {
let session = sqlx::query_as!( let sessions = sqlx::query_as!(
Session, Session,
r#" r#"
INSERT INTO sessions (id, user_id, expires_at) INSERT INTO sessions (id, user_id, expires_at)
@@ -276,20 +262,13 @@ impl UserRepository {
expires_at expires_at
) )
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await?;
.unwrap();
info!( Ok(sessions)
"Persisted session: {} with expiration date: {}",
token, expires_at
);
Ok(session)
} }
pub async fn get_session(&self, token: &str) -> Result<Session, LoftError> { pub async fn get_session(&self, token: &str) -> Result<Option<Session>> {
info!("Fetching session \"{}\" from sessions", token); let sessions = sqlx::query_as!(
let session = sqlx::query_as!(
Session, Session,
r#" r#"
SELECT * SELECT *
@@ -300,20 +279,15 @@ impl UserRepository {
token token
) )
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await?;
.unwrap()
.ok_or(LoftError::LoginFail)?;
Ok(session) Ok(sessions)
} }
pub async fn delete_session(&self, token: &str) -> Result<(), LoftError> { pub async fn delete_session(&self, token: &str) -> Result<()> {
sqlx::query!(r#"DELETE FROM sessions WHERE id = $1"#, token) sqlx::query!(r#"DELETE FROM sessions WHERE id = $1"#, token)
.execute(&self.pool) .execute(&self.pool)
.await .await?;
.unwrap();
info!("Deleted session \"{}\"", token);
Ok(()) Ok(())
} }
@@ -334,7 +308,7 @@ mod tests {
.unwrap(); .unwrap();
} }
async fn file_repository() -> Result<FileRepository, LoftError> { async fn file_repository() -> Result<FileRepository> {
dotenvy::from_filename(".env.test").ok(); dotenvy::from_filename(".env.test").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");
let pool = PgPool::connect(&database_url).await.unwrap(); let pool = PgPool::connect(&database_url).await.unwrap();
@@ -412,12 +386,14 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let _downloaded = file_repository let chunks: Vec<_> = file_repository
.download_file(file_record.id, user.id) .download_file(file_record.id, user.id)
.await .await
.unwrap(); .unwrap()
.collect()
//TODO: add assert .await;
let downloaded: Vec<u8> = chunks.into_iter().flat_map(|c| c.unwrap()).collect();
assert_eq!(downloaded, vec![0u8; 10]);
truncate_file_records(&file_repository.pool).await; truncate_file_records(&file_repository.pool).await;
truncate_users(&user_repository.pool).await; truncate_users(&user_repository.pool).await;
@@ -435,6 +411,76 @@ 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]
#[serial_test::serial]
async fn test_stream_part() {
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 file_name = "a.png";
let file_storage_key = format!("{}-{}", file_name, uuid::Uuid::new_v4());
let file_size = file_repository
.upload_file(
stream::iter(vec![Ok(Bytes::from((0u8..10).collect::<Vec<u8>>()))]),
&file_storage_key,
)
.await
.unwrap();
let file_record = file_repository
.create_file_record(user.id, "a.jpg", file_size, &file_storage_key)
.await
.unwrap();
let chunks: Vec<_> = file_repository
.stream_part(file_record.id, user.id, 2, 6)
.await
.unwrap()
.collect()
.await;
let partial: Vec<u8> = chunks.into_iter().flat_map(|c| c.unwrap()).collect();
assert_eq!(partial, vec![2u8, 3, 4, 5]);
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_delete() { async fn test_delete() {
@@ -503,7 +549,7 @@ mod tests {
dotenvy::from_filename(".env.test").ok(); dotenvy::from_filename(".env.test").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");
let pool = PgPool::connect(&database_url).await.unwrap(); let pool = PgPool::connect(&database_url).await.unwrap();
Ok(UserRepository::new(pool)?) Ok(UserRepository::new(pool))
} }
#[tokio::test] #[tokio::test]
@@ -528,7 +574,11 @@ mod tests {
let username = "picolo"; let username = "picolo";
user_repository.create_user(username, "pw").await.unwrap(); user_repository.create_user(username, "pw").await.unwrap();
let fetched_user = user_repository.find_by_username(username).await.unwrap(); let fetched_user = user_repository
.find_by_username(username)
.await
.unwrap()
.unwrap();
assert_eq!(fetched_user.username, username); assert_eq!(fetched_user.username, username);
truncate_users(&user_repository.pool).await; truncate_users(&user_repository.pool).await;
@@ -583,7 +633,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let fetched_session = user_repository.get_session(&token).await.unwrap(); let fetched_session = user_repository.get_session(&token).await.unwrap().unwrap();
assert_eq!(fetched_session.id, token); assert_eq!(fetched_session.id, token);
assert_eq!(fetched_session.user_id, user.id); assert_eq!(fetched_session.user_id, user.id);
@@ -617,7 +667,7 @@ mod tests {
assert!(matches!( assert!(matches!(
user_repository.get_session(&token).await, user_repository.get_session(&token).await,
Err(LoftError::LoginFail) Ok(None)
)); ));
truncate_sessions(&user_repository.pool).await; truncate_sessions(&user_repository.pool).await;

81
backend/src/tasks.rs Normal file
View 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(())
}

View File

@@ -5,13 +5,14 @@ use axum::{
}; };
use tower_cookies::Cookies; use tower_cookies::Cookies;
use crate::{ctx::Ctx, error::LoftError, model::UserRepository, web::AUTH_TOKEN}; use crate::{
ctx::Ctx,
error::{LoftError, Result},
model::UserRepository,
web::AUTH_TOKEN,
};
pub async fn mw_require_auth( pub async fn mw_require_auth(ctx: Result<Ctx>, req: Request, next: Next) -> Result<Response> {
ctx: Result<Ctx, LoftError>,
req: Request,
next: Next,
) -> Result<Response, LoftError> {
ctx?; ctx?;
Ok(next.run(req).await) Ok(next.run(req).await)
@@ -22,13 +23,14 @@ pub async fn mw_ctx_resolver(
cookies: Cookies, cookies: Cookies,
mut req: Request, mut req: Request,
next: Next, next: Next,
) -> Result<Response, LoftError> { ) -> Result<Response> {
let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string()); let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string());
let result_ctx = match auth_token { let result_ctx = match auth_token {
Some(token) => user_repository Some(token) => user_repository
.get_session(&token) .get_session(&token)
.await .await
.and_then(|s| s.ok_or(LoftError::AuthFailSessionNotFound))
.map(|s| Ctx::new(s.user_id)), .map(|s| Ctx::new(s.user_id)),
None => Err(LoftError::AuthFailNoAuthTokenCookie), None => Err(LoftError::AuthFailNoAuthTokenCookie),
}; };
@@ -45,7 +47,7 @@ impl<S: Send + Sync> FromRequestParts<S> for Ctx {
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
parts parts
.extensions .extensions
.get::<Result<Ctx, LoftError>>() .get::<Result<Ctx>>()
.ok_or(LoftError::AuthFailCtxNotInRequestExt)? .ok_or(LoftError::AuthFailCtxNotInRequestExt)?
.clone() .clone()
} }

View File

@@ -2,108 +2,305 @@ use axum::{
Json, Router, Json, Router,
body::Body, body::Body,
extract::{Multipart, Path, State}, extract::{Multipart, Path, State},
http::{HeaderMap, Response, StatusCode, header},
response::IntoResponse, response::IntoResponse,
routing::get, routing::get,
}; };
use sqlx::types::uuid; use sqlx::types::uuid;
use tracing::info; use tokio::sync::mpsc::Sender;
use tracing::warn;
use utoipa::OpenApi;
use crate::{ use crate::{
ctx::Ctx, ctx::Ctx,
error::LoftError, 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))
.with_state(file_repository) .route("/files/{id}/thumbnail", get(thumbnail))
.route("/files/{id}/stream_part", get(stream_part))
.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>, LoftError> { ) -> Result<(StatusCode, Json<FileRecord>)> {
info!("handler: upload_file"); let mut uploaded: Option<(String, String, usize)> = None;
let mut file_name = None; while let Some(field) = multipart.next_field().await? {
let mut file_storage_key = None; if field.name() == Some("file") {
let mut file_size = None; let name = field.file_name().map(str::to_string).unwrap_or_default();
let key = format!("{name}-{}", uuid::Uuid::new_v4());
while let Some(field) = multipart.next_field().await.unwrap() { let size = service.file_repository.upload_file(field, &key).await?;
if field.name().unwrap() == "file" { uploaded = Some((name, key, size));
let name = field.file_name().map(|s| s.to_string()).unwrap_or_default();
let key = format!("{}-{}", name, uuid::Uuid::new_v4());
let size = file_repository.upload_file(field, &key).await?;
file_name = Some(name);
file_storage_key = Some(key);
file_size = Some(size);
} }
} }
if let (Some(name), Some(key), Some(size)) = (file_name, file_storage_key, file_size) { let (name, key, size) = uploaded.ok_or(LoftError::NoFileProvided)?;
let file_record = file_repository
.create_file_record(ctx.user_id(), &name, size, &key) let record = service
.await?; .file_repository
return Ok(Json(file_record)); .create_file_record(ctx.user_id(), &name, size, &key)
.await?;
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)
} }
Err(LoftError::UndefinedErrorType) Ok((StatusCode::CREATED, Json(record)))
} }
#[axum::debug_handler] /// 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>, LoftError> { ) -> 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))
} }
#[axum::debug_handler] /// 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, LoftError> { ) -> 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))
} }
async fn delete_file( /// Download a file thumbnail
State(file_repository): State<FileRepository>, #[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, ctx: Ctx,
Path(file_id): Path<u64>, Path(file_id): Path<u64>,
) -> Result<Json<FileRecord>, LoftError> { ) -> Result<impl IntoResponse> {
info!("handler: delete_file"); let stream = service
.file_repository
.thumbnail(file_id as i64, ctx.user_id())
.await?;
Ok(Body::from_stream(stream))
}
let file = file_repository /// 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(
State(service): State<FileState>,
ctx: Ctx,
headers: HeaderMap,
Path(file_id): Path<u64>,
) -> Result<impl IntoResponse> {
let record = service
.file_repository
.get_file(file_id as i64, ctx.user_id())
.await?;
let file_size = record.size as u64;
let (start, end): (u64, u64) = parse_range(&headers, file_size)?;
let stream = service
.file_repository
.stream_part(file_id as i64, ctx.user_id(), start, end + 1)
.await?;
let respones = Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, record.file_type)
.header(header::CONTENT_LENGTH, end - start + 1)
.header(
header::CONTENT_RANGE,
format!("bytes {start}-{end}/{file_size}"),
)
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from_stream(stream))
.expect("Failed to build response with valid headers");
Ok(respones)
}
fn parse_range(headers: &HeaderMap, file_size: u64) -> Result<(u64, u64)> {
let range = headers.get(header::RANGE).ok_or(LoftError::InvalidRange)?;
let str = range.to_str().map_err(|_| LoftError::InvalidRange)?;
let tuple = str
.strip_prefix("bytes=")
.and_then(|s| s.split_once("-"))
.ok_or(LoftError::InvalidRange)?;
let start = tuple
.0
.parse::<u64>()
.map_err(|_| LoftError::InvalidRange)?;
let end = tuple.1.parse::<u64>().unwrap_or(file_size - 1);
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(
State(service): State<FileState>,
ctx: Ctx,
Path(file_id): Path<u64>,
) -> Result<Json<FileRecord>> {
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>>, LoftError> { path = "/api/files",
info!("handler: list_files"); tag = "files",
security(("cookie_auth" = [])),
let files = file_repository.list_files(ctx.user_id()).await?; 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, middleware}; use axum::{
Router,
http::{StatusCode, header},
middleware,
};
use axum_test::{ use axum_test::{
TestServer, TestServer,
multipart::{MultipartForm, Part}, multipart::{MultipartForm, Part},
@@ -111,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},
}, },
}; };
@@ -135,15 +333,19 @@ mod tests {
dotenvy::from_filename(".env.test").ok(); dotenvy::from_filename(".env.test").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");
let pool = PgPool::connect(&database_url).await.unwrap(); let pool = PgPool::connect(&database_url).await.unwrap();
let user_repository = UserRepository::new(pool).unwrap(); let user_repository = UserRepository::new(pool);
user_repository user_repository
} }
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(
@@ -249,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");
@@ -291,6 +493,71 @@ mod tests {
truncate(&file_repository.pool).await; truncate(&file_repository.pool).await;
} }
#[tokio::test]
#[serial_test::serial]
async fn test_stream_part_range() {
let user_repository = user_repository().await;
let token = create_test_session(&user_repository).await;
let file_repository = file_repository().await;
truncate(&file_repository.pool).await;
let server = test_server().await;
let post_res = server
.post("/api/files")
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
.multipart(MultipartForm::new().add_part(
"file",
Part::bytes(b"0123456789".to_vec()).file_name("a.txt"),
))
.await;
let id = post_res.json::<serde_json::Value>()["id"].as_i64().unwrap();
let res = server
.get(&format!("/api/files/{id}/stream_part"))
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
.add_header(axum::http::header::RANGE, "bytes=2-5")
.await;
res.assert_status(axum::http::StatusCode::PARTIAL_CONTENT);
assert_eq!(res.as_bytes(), b"2345".as_ref());
assert_eq!(res.header(header::CONTENT_RANGE), "bytes 2-5/10");
assert_eq!(res.header(header::CONTENT_LENGTH), "4");
assert_eq!(res.header(header::ACCEPT_RANGES), "bytes");
truncate(&file_repository.pool).await;
}
#[tokio::test]
#[serial_test::serial]
async fn test_stream_part_open_ended() {
let user_repository = user_repository().await;
let token = create_test_session(&user_repository).await;
let file_repository = file_repository().await;
truncate(&file_repository.pool).await;
let server = test_server().await;
let post_res = server
.post("/api/files")
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
.multipart(MultipartForm::new().add_part(
"file",
Part::bytes(b"0123456789".to_vec()).file_name("a.txt"),
))
.await;
let id = post_res.json::<serde_json::Value>()["id"].as_i64().unwrap();
let res = server
.get(&format!("/api/files/{id}/stream_part"))
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
.add_header(axum::http::header::RANGE, "bytes=0-")
.await;
res.assert_status(axum::http::StatusCode::PARTIAL_CONTENT);
assert_eq!(res.as_bytes(), b"0123456789".as_ref());
assert_eq!(res.header(header::CONTENT_RANGE), "bytes 0-9/10");
assert_eq!(res.header(header::CONTENT_LENGTH), "10");
truncate(&file_repository.pool).await;
}
#[tokio::test] #[tokio::test]
#[serial_test::serial] #[serial_test::serial]
async fn test_download_file_not_found() { async fn test_download_file_not_found() {

View File

@@ -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"
} }

View File

@@ -4,10 +4,45 @@ 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 utoipa::{
Modify, OpenApi,
openapi::security::{ApiKey, ApiKeyValue, SecurityScheme},
};
use crate::{error::LoftError, model::UserRepository, web::AUTH_TOKEN}; use crate::{
error::{LoftError, Result},
model::UserRepository,
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()
@@ -17,36 +52,61 @@ 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,
Json(payload): Json<LoginPayload>, Json(payload): Json<LoginPayload>,
) -> Result<StatusCode, LoftError> { ) -> Result<StatusCode> {
let user = user_repository.find_by_username(&payload.username).await?; let user = user_repository
// TODO: replace unwrap with ? .find_by_username(&payload.username)
let parsed_hash = PasswordHash::new(&user.password_hash).unwrap(); .await?
if Argon2::default() .ok_or(LoftError::LoginFail)?;
.verify_password(payload.password.as_bytes(), &parsed_hash) let parsed_hash = PasswordHash::new(&user.password_hash)?;
.is_ok() let password_verification =
{ Argon2::default().verify_password(payload.password.as_bytes(), &parsed_hash);
let expires_at = chrono::Utc::now() + chrono::Duration::days(1);
let cookie = create_cookie(); if password_verification.is_err() {
let auth_token = cookie.value();
cookies.add(cookie.clone());
user_repository
.create_session(user.id, auth_token, expires_at)
.await?;
} else {
return Err(LoftError::LoginFail); return Err(LoftError::LoginFail);
} }
let expires_at = chrono::Utc::now() + chrono::Duration::days(1);
let cookie = create_cookie();
let auth_token = cookie.value();
cookies.add(cookie.clone());
user_repository
.create_session(user.id, auth_token, expires_at)
.await?;
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,
) -> Result<StatusCode, LoftError> { ) -> Result<StatusCode> {
let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string()); let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string());
if let Some(auth_token) = auth_token { if let Some(auth_token) = auth_token {
@@ -57,28 +117,42 @@ 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>,
) -> Result<StatusCode, LoftError> { ) -> Result<StatusCode> {
if user_repository if user_repository
.find_by_username(&payload.username) .find_by_username(&payload.username)
.await .await?
.is_ok() .is_some()
{ {
warn!(
"Register fail, username {} already exists",
&payload.username
);
return Err(LoftError::RegisterFail); return Err(LoftError::RegisterFail);
} }
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default(); let argon2 = Argon2::default();
// TODO: replace unwrap let password_hash = &argon2
let password_hash = argon2 .hash_password(payload.password.as_bytes(), &salt)?
.hash_password(payload.password.as_bytes(), &salt)
.unwrap()
.to_string(); .to_string();
user_repository user_repository
.create_user(&payload.username, &password_hash) .create_user(&payload.username, password_hash)
.await?; .await?;
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
@@ -99,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,
@@ -135,7 +209,7 @@ mod tests {
dotenvy::from_filename(".env.test").ok(); dotenvy::from_filename(".env.test").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");
let pool = PgPool::connect(&database_url).await.unwrap(); let pool = PgPool::connect(&database_url).await.unwrap();
let user_repository = UserRepository::new(pool).unwrap(); let user_repository = UserRepository::new(pool);
user_repository user_repository
} }

View File

@@ -3,15 +3,15 @@ services:
image: docker.io/library/postgres:16 image: docker.io/library/postgres:16
restart: unless-stopped restart: unless-stopped
environment: environment:
- POSTGRES_USER=postgres - POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=pass - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=loft - POSTGRES_DB=${POSTGRES_DB}
ports: ports:
- "5432:5432" - "127.0.0.1:5432:5432"
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
@@ -21,15 +21,15 @@ services:
restart: unless-stopped restart: unless-stopped
build: . build: .
ports: ports:
- "3000:3000" - "3000:3000"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
- DATABASE_URL=postgres://postgres:pass@db:5432/loft - DATABASE_URL=${DATABASE_URL}
- STORAGE_PATH=/app/storage - STORAGE_PATH=${STORAGE_PATH}
volumes: volumes:
- loft_storage:/app/storage - loft_storage:${STORAGE_PATH}
volumes: volumes:
loft_db_data: loft_db_data:

47
frontend/e2e/auth.e2e.ts Normal file
View File

@@ -0,0 +1,47 @@
import { test, expect } from '@playwright/test';
import { uniqueUser, registerAndLogin } from './helpers';
test('registering via the form creates an account', async ({ page }) => {
const user = uniqueUser();
await page.goto('/auth');
await page.getByTestId('auth-tab-register').click();
await page.locator('#username').fill(user.username);
await page.locator('#password').fill(user.password);
const register = page.waitForResponse((r) => r.url().includes('/api/auth/register'));
await page.getByTestId('auth-submit').click();
expect((await register).status()).toBe(201);
});
test('logging in via the form reaches the app', async ({ page }) => {
const user = uniqueUser();
// pre-create the account, then exercise the login form (single clean navigation)
const reg = await page.request.post('/api/auth/register', { data: user });
expect(reg.ok()).toBeTruthy();
await page.goto('/auth');
await page.locator('#username').fill(user.username);
await page.locator('#password').fill(user.password);
await page.getByTestId('auth-submit').click();
await expect(page.getByRole('button', { name: 'Import' })).toBeVisible();
});
test('login with wrong credentials shows an error', async ({ page }) => {
await page.goto('/auth');
await page.locator('#username').fill('nope_does_not_exist');
await page.locator('#password').fill('wrongpassword');
await page.getByTestId('auth-submit').click();
await expect(page.getByText('Invalid credentials')).toBeVisible();
});
test('signing out returns to the auth page', async ({ page }) => {
await registerAndLogin(page);
await page.goto('/');
await expect(page.getByRole('button', { name: 'Import' })).toBeVisible();
await page.getByRole('button', { name: 'Sign out' }).click();
await expect(page).toHaveURL(/\/auth$/);
await expect(page.getByTestId('auth-tab-login')).toBeVisible();
});

78
frontend/e2e/files.e2e.ts Normal file
View File

@@ -0,0 +1,78 @@
import { test, expect } from '@playwright/test';
import { registerAndLogin, textFile } from './helpers';
// every test runs as a fresh logged-in user, so the file list starts empty
// and tests never see each other's files (the list is per-user on the backend).
test.beforeEach(async ({ page }) => {
await registerAndLogin(page);
await page.goto('/');
});
test('a new user sees the empty state', async ({ page }) => {
await expect(page.getByText('No files yet.')).toBeVisible();
});
test('uploading a file shows it in the list', async ({ page }) => {
await page.locator('input[type="file"]').setInputFiles(textFile('hello.txt'));
await expect(page.getByText('hello.txt')).toBeVisible();
});
test('downloading a file triggers a download', async ({ page }) => {
await page.locator('input[type="file"]').setInputFiles(textFile('report.txt'));
await expect(page.getByText('report.txt')).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.locator('[title="Download"]').first().click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('report.txt');
});
test('deleting a file with confirmation removes it', async ({ page }) => {
await page.locator('input[type="file"]').setInputFiles(textFile('trash.txt'));
await expect(page.getByText('trash.txt')).toBeVisible();
await page.locator('[title="Delete"]').first().click();
await expect(page.getByText('Confirm action')).toBeVisible();
await page.locator('[title="Confirm"]').click();
await expect(page.getByText('trash.txt')).toHaveCount(0);
});
test('search filters the file list', async ({ page }) => {
const input = page.locator('input[type="file"]');
await input.setInputFiles(textFile('alpha.txt'));
await expect(page.getByText('alpha.txt')).toBeVisible();
await input.setInputFiles(textFile('bravo.txt'));
await expect(page.getByText('bravo.txt')).toBeVisible();
await page.getByPlaceholder('Search files...').fill('alpha');
await expect(page.getByText('alpha.txt')).toBeVisible();
await expect(page.getByText('bravo.txt')).toHaveCount(0);
});
test('previewing a video opens the player', async ({ page }) => {
await page.locator('input[type="file"]').setInputFiles({
name: 'clip.mp4',
mimeType: 'video/mp4',
buffer: Buffer.from('fake mp4 bytes')
});
await expect(page.getByText('clip.mp4')).toBeVisible();
await page.getByText('clip.mp4').dblclick();
await expect(page.getByRole('button', { name: 'Close' })).toBeVisible();
await expect(page.locator('video')).toHaveCount(1);
await page.keyboard.press('Escape');
await expect(page.getByRole('button', { name: 'Close' })).toHaveCount(0);
});
test('previewing a non-video shows no preview available', async ({ page }) => {
await page.locator('input[type="file"]').setInputFiles(textFile('notes.txt'));
await expect(page.getByText('notes.txt')).toBeVisible();
await page.getByText('notes.txt').dblclick();
await expect(page.getByText('No preview available')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByText('No preview available')).toHaveCount(0);
});

26
frontend/e2e/helpers.ts Normal file
View File

@@ -0,0 +1,26 @@
import { expect, type Page } from '@playwright/test';
/** A fresh, unique account per call so tests never collide in the shared test DB. */
export function uniqueUser() {
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
return { username: `e2e_${id}`, password: 'password123' };
}
/**
* Registers a brand-new user and logs in via the real /api/auth flow.
* `page.request` shares the browser context's cookie jar, so the session
* cookie it sets makes subsequent page navigations authenticated.
*/
export async function registerAndLogin(page: Page) {
const user = uniqueUser();
const register = await page.request.post('/api/auth/register', { data: user });
expect(register.ok(), 'register should succeed').toBeTruthy();
const login = await page.request.post('/api/auth/login', { data: user });
expect(login.ok(), 'login should succeed').toBeTruthy();
return user;
}
/** A small in-memory upload fixture (no files on disk needed). */
export function textFile(name: string) {
return { name, mimeType: 'text/plain', buffer: Buffer.from('e2e test content') };
}

View File

@@ -1,6 +1,31 @@
import { defineConfig } from '@playwright/test'; import { defineConfig } from '@playwright/test';
// e2e runs against the dev server (vite :5173), which proxies /api to a backend
// instance pointed at the isolated `loft_test` database (see .env.test).
export default defineConfig({ export default defineConfig({
webServer: { command: 'npm run build && npm run preview', port: 4173 }, testDir: './e2e',
testMatch: '**/*.e2e.{ts,js}' testMatch: '**/*.e2e.{ts,js}',
fullyParallel: false,
workers: 1,
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry'
},
webServer: [
{
// backend on :3000, pointed at the test DB via .env.test
command: 'cd ../backend && set -a && . ./.env.test && set +a && cargo run',
url: 'http://localhost:3000/health',
reuseExistingServer: true,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe'
},
{
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: true,
timeout: 60_000
}
]
}); });

View File

@@ -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}/download`} /> {#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>

View 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" />

View File

@@ -0,0 +1,213 @@
<script lang="ts">
import type { FileRecord } from '$lib/types';
import { formatName } from '$lib/format';
let {
file,
selected,
onselect,
onpreview,
ondownload,
ondelete
}: {
file: FileRecord;
selected: boolean;
onselect: () => void;
onpreview: () => void;
ondownload: () => void;
ondelete: () => void;
} = $props();
const isImage = $derived(file.fileType.includes('image'));
const isVideo = $derived(file.fileType.includes('video'));
let confirming = $state(false);
</script>
<div
role="button"
tabindex="0"
onclick={onselect}
ondblclick={onpreview}
onkeydown={(e) => {
if (e.key === 'Enter') {
onpreview();
} else if (e.key === ' ') {
e.preventDefault();
onselect();
}
}}
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'}"
>
{#if isImage && file.thumbnailStoragePath}
<img
src={`/api/files/${file.id}/thumbnail`}
alt={file.name}
class="h-full w-full object-cover"
/>
{:else}
<div class="flex h-full w-full items-center justify-center bg-white/5 text-white/30">
{#if isVideo}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="2" y="2" width="20" height="20" rx="2.18" />
<line x1="7" y1="2" x2="7" y2="22" />
<line x1="17" y1="2" x2="17" y2="22" />
<line x1="2" y1="12" x2="22" y2="12" />
<line x1="2" y1="7" x2="7" y2="7" />
<line x1="2" y1="17" x2="7" y2="17" />
<line x1="17" y1="17" x2="22" y2="17" />
<line x1="17" y1="7" x2="22" y2="7" />
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
{/if}
</div>
{/if}
{#if isVideo}
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-black/50">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-white"
viewBox="0 0 24 24"
fill="currentColor"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</div>
</div>
{/if}
<div
class="pointer-events-none absolute inset-x-0 bottom-0 bg-linear-to-t from-black/80 to-transparent px-2 py-1.5"
>
<span class="block truncate text-xs font-medium text-white">{formatName(file.name)}</span>
</div>
<div
class="absolute top-1 right-1 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
{#if confirming}
<button
class="cursor-pointer rounded bg-black/60 p-1 text-white/70 transition-colors hover:text-emerald-400"
title="Confirm delete"
aria-label="Confirm delete"
onclick={(e) => {
e.stopPropagation();
ondelete();
confirming = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</button>
<button
class="cursor-pointer rounded bg-black/60 p-1 text-white/70 transition-colors hover:text-white"
title="Cancel"
aria-label="Cancel"
onclick={(e) => {
e.stopPropagation();
confirming = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{:else}
<button
class="cursor-pointer rounded bg-black/60 p-1 text-white/70 transition-colors hover:text-emerald-400"
title="Download"
aria-label="Download"
onclick={(e) => {
e.stopPropagation();
ondownload();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
<button
class="cursor-pointer rounded bg-black/60 p-1 text-white/70 transition-colors hover:text-red-400"
title="Delete"
aria-label="Delete"
onclick={(e) => {
e.stopPropagation();
confirming = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
{/if}
</div>
</div>

View File

@@ -4,4 +4,5 @@ export type FileRecord = {
size: number; size: number;
fileType: string; fileType: string;
uploadedAt: string; uploadedAt: string;
thumbnailStoragePath: string | null;
}; };

View File

@@ -5,6 +5,7 @@
import type { FileRecord } from '$lib/types'; import type { FileRecord } from '$lib/types';
import { deleteFile, downloadFile, loadFiles, logout, uploadFile } from '$lib/api'; import { deleteFile, downloadFile, loadFiles, logout, uploadFile } from '$lib/api';
import FileRow from '$lib/components/FileRow.svelte'; import FileRow from '$lib/components/FileRow.svelte';
import TileCard from '$lib/components/TileCard.svelte';
import FilePreview from '$lib/components/FilePreview.svelte'; import FilePreview from '$lib/components/FilePreview.svelte';
let fileRecords = $state<FileRecord[]>([]); let fileRecords = $state<FileRecord[]>([]);
@@ -12,6 +13,12 @@
let previewFile = $state<FileRecord | null>(null); let previewFile = $state<FileRecord | null>(null);
let search = $state(''); let search = $state('');
let loading = $state(true); let loading = $state(true);
let viewMode = $state<'rows' | 'tiles'>('rows');
function setViewMode(mode: 'rows' | 'tiles') {
viewMode = mode;
localStorage.setItem('viewMode', mode);
}
async function refresh() { async function refresh() {
fileRecords = await loadFiles(); fileRecords = await loadFiles();
@@ -22,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) {
@@ -54,7 +82,24 @@
window.location.href = '/auth'; window.location.href = '/auth';
} }
onMount(refresh); $effect(() => {
const pending = fileRecords.some(
(f) => f.fileType.includes('image') && !f.thumbnailStoragePath
);
if (!pending) {
return;
}
const id = setInterval(refresh, 1000);
return () => clearInterval(id);
});
onMount(() => {
const saved = localStorage.getItem('viewMode');
if (saved === 'rows' || saved === 'tiles') viewMode = saved;
refresh();
});
</script> </script>
<div class="w-full px-4 py-8"> <div class="w-full px-4 py-8">
@@ -92,42 +137,123 @@
</div> </div>
<input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" /> <input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" />
<div class="mb-1"> <div class="mb-1 flex items-center justify-between">
<button <button
class="cursor-pointer rounded-sm border border-sky-200/20 px-2 py-1 text-sm text-white/60 transition-colors hover:border-sky-200/40" class="cursor-pointer rounded-sm border border-sky-200/20 px-2 py-1 text-sm text-white/60 transition-colors hover:border-sky-200/40"
onclick={() => fileInput.click()} onclick={() => fileInput.click()}
> >
Import Import
</button> </button>
</div> <div class="flex gap-0.5 rounded-sm border border-sky-200/20 p-0.5">
<button
<div class="rounded-sm border border-sky-200/20"> class="cursor-pointer rounded-sm px-1.5 py-1 transition-colors {viewMode === 'rows'
<!-- Header row --> ? 'bg-white/10 text-white'
<div class="flex items-stretch border-b border-sky-200/20 bg-white/5"> : 'text-white/40 hover:text-white/70'}"
<span class="flex-1 py-2 pl-6 text-sm font-medium text-white/40">Name</span> title="List view"
<span class="w-40 py-2 text-sm text-white/40">Size</span> aria-label="List view"
<span class="w-48 py-2 text-sm text-white/40">Uploaded at</span> onclick={() => setViewMode('rows')}
<div class="w-px self-stretch bg-sky-200/20"></div> >
<span class="w-32 py-2 text-center text-sm text-white/40">Actions</span> <svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
</button>
<button
class="cursor-pointer rounded-sm px-1.5 py-1 transition-colors {viewMode === 'tiles'
? 'bg-white/10 text-white'
: 'text-white/40 hover:text-white/70'}"
title="Tile view"
aria-label="Tile view"
onclick={() => setViewMode('tiles')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</button>
</div> </div>
{#each filteredFileRecords as fileRecord (fileRecord.id)}
<div transition:fade={{ duration: 200 }} animate:flip={{ duration: 200 }}>
<FileRow
file={fileRecord}
selected={selectedFileId === fileRecord.id}
onselect={() => (selectedFileId = fileRecord.id)}
onpreview={() => (previewFile = fileRecord)}
ondownload={() => handleDownload(fileRecord.id, fileRecord.name)}
ondelete={() => handleDelete(fileRecord.id)}
/>
</div>
{:else}
{#if !loading}<p class="py-2 px-4 text-white/30 text-sm">No files yet.</p>{/if}
{/each}
</div> </div>
{#if viewMode === 'rows'}
<div class="rounded-sm border border-sky-200/20">
<div class="flex items-stretch border-b border-sky-200/20 bg-white/5">
<span class="flex-1 py-2 pl-6 text-sm font-medium text-white/40">Name</span>
<span class="w-40 py-2 text-sm text-white/40">Size</span>
<span class="w-48 py-2 text-sm text-white/40">Uploaded at</span>
<div class="w-px self-stretch bg-sky-200/20"></div>
<span class="w-32 py-2 text-center text-sm text-white/40">Actions</span>
</div>
{#each filteredFileRecords as fileRecord (fileRecord.id)}
<div transition:fade={{ duration: 200 }} animate:flip={{ duration: 200 }}>
<FileRow
file={fileRecord}
selected={selectedFileId === fileRecord.id}
onselect={() => (selectedFileId = fileRecord.id)}
onpreview={() => (previewFile = fileRecord)}
ondownload={() => handleDownload(fileRecord.id, fileRecord.name)}
ondelete={() => handleDelete(fileRecord.id)}
/>
</div>
{:else}
{#if !loading}
<p class="py-2 px-4 text-white/30 text-sm">No files yet.</p>
{/if}
{/each}
</div>
{:else}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each filteredFileRecords as fileRecord (fileRecord.id)}
<div transition:fade={{ duration: 200 }} animate:flip={{ duration: 200 }}>
<TileCard
file={fileRecord}
selected={selectedFileId === fileRecord.id}
onselect={() => (selectedFileId = fileRecord.id)}
onpreview={() => (previewFile = fileRecord)}
ondownload={() => handleDownload(fileRecord.id, fileRecord.name)}
ondelete={() => handleDelete(fileRecord.id)}
/>
</div>
{:else}
{#if !loading}
<p class="col-span-full px-4 py-2 text-sm text-white/30">No files yet.</p>
{/if}
{/each}
</div>
{/if}
</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}

View File

@@ -22,6 +22,7 @@
<div class="mb-6 flex border-b border-sky-200/20"> <div class="mb-6 flex border-b border-sky-200/20">
<button <button
data-testid="auth-tab-login"
onclick={() => (activeTab = 'login')} onclick={() => (activeTab = 'login')}
class="flex-1 cursor-pointer py-2 text-sm transition-colors {activeTab === 'login' class="flex-1 cursor-pointer py-2 text-sm transition-colors {activeTab === 'login'
? '-mb-px border-b border-white text-white' ? '-mb-px border-b border-white text-white'
@@ -30,9 +31,9 @@
Login Login
</button> </button>
<button <button
data-testid="auth-tab-register"
onclick={() => (activeTab = 'register')} onclick={() => (activeTab = 'register')}
class="flex-1 cursor-pointer py-2 text-sm transition-colors {activeTab === class="flex-1 cursor-pointer py-2 text-sm transition-colors {activeTab === 'register'
'register'
? '-mb-px border-b border-white text-white' ? '-mb-px border-b border-white text-white'
: 'text-white/40 hover:text-white/60'}" : 'text-white/40 hover:text-white/60'}"
> >
@@ -63,6 +64,7 @@
</div> </div>
{#if error}<p class="text-xs text-red-400/60">{error}</p>{/if} {#if error}<p class="text-xs text-red-400/60">{error}</p>{/if}
<button <button
data-testid="auth-submit"
onclick={handleSubmit} onclick={handleSubmit}
class="mt-1 cursor-pointer rounded-sm border border-sky-200/15 px-4 py-2 text-sm text-white/40 transition-colors hover:border-sky-200/30 hover:text-white/60" class="mt-1 cursor-pointer rounded-sm border border-sky-200/15 px-4 py-2 text-sm text-white/40 transition-colors hover:border-sky-200/30 hover:text-white/60"
> >

106
justfile Normal file
View 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

View File

@@ -1,7 +0,0 @@
{
"devDependencies": {
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.8.0"
}
}

253
pnpm-lock.yaml generated
View File

@@ -1,253 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
prettier:
specifier: ^3.8.3
version: 3.8.3
prettier-plugin-svelte:
specifier: ^4.1.0
version: 4.1.0(prettier@3.8.3)(svelte@5.56.0)
prettier-plugin-tailwindcss:
specifier: ^0.8.0
version: 0.8.0(prettier-plugin-svelte@4.1.0(prettier@3.8.3)(svelte@5.56.0))(prettier@3.8.3)
packages:
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@sveltejs/acorn-typescript@1.0.10':
resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==}
peerDependencies:
acorn: ^8.9.0
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
aria-query@5.3.1:
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
engines: {node: '>= 0.4'}
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
devalue@5.8.1:
resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==}
esm-env@1.2.2:
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
esrap@2.2.9:
resolution: {integrity: sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==}
peerDependencies:
'@typescript-eslint/types': ^8.2.0
peerDependenciesMeta:
'@typescript-eslint/types':
optional: true
is-reference@3.0.3:
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
prettier-plugin-svelte@4.1.0:
resolution: {integrity: sha512-YZkhA2Q9oOerFFG9tq+2f98WYT7Z2JgrybJrAyrB78jpsH9i/DdgplXemehuFPgsldetFNCcR/yCcYlDjPy94Q==}
engines: {node: '>=20'}
peerDependencies:
prettier: ^3.0.0
svelte: ^5.0.0
prettier-plugin-tailwindcss@0.8.0:
resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==}
engines: {node: '>=20.19'}
peerDependencies:
'@ianvs/prettier-plugin-sort-imports': '*'
'@prettier/plugin-hermes': '*'
'@prettier/plugin-oxc': '*'
'@prettier/plugin-pug': '*'
'@shopify/prettier-plugin-liquid': '*'
'@trivago/prettier-plugin-sort-imports': '*'
'@zackad/prettier-plugin-twig': '*'
prettier: ^3.0
prettier-plugin-astro: '*'
prettier-plugin-css-order: '*'
prettier-plugin-jsdoc: '*'
prettier-plugin-marko: '*'
prettier-plugin-multiline-arrays: '*'
prettier-plugin-organize-attributes: '*'
prettier-plugin-organize-imports: '*'
prettier-plugin-sort-imports: '*'
prettier-plugin-svelte: '*'
peerDependenciesMeta:
'@ianvs/prettier-plugin-sort-imports':
optional: true
'@prettier/plugin-hermes':
optional: true
'@prettier/plugin-oxc':
optional: true
'@prettier/plugin-pug':
optional: true
'@shopify/prettier-plugin-liquid':
optional: true
'@trivago/prettier-plugin-sort-imports':
optional: true
'@zackad/prettier-plugin-twig':
optional: true
prettier-plugin-astro:
optional: true
prettier-plugin-css-order:
optional: true
prettier-plugin-jsdoc:
optional: true
prettier-plugin-marko:
optional: true
prettier-plugin-multiline-arrays:
optional: true
prettier-plugin-organize-attributes:
optional: true
prettier-plugin-organize-imports:
optional: true
prettier-plugin-sort-imports:
optional: true
prettier-plugin-svelte:
optional: true
prettier@3.8.3:
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
engines: {node: '>=14'}
hasBin: true
svelte@5.56.0:
resolution: {integrity: sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg==}
engines: {node: '>=18'}
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
snapshots:
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@sveltejs/acorn-typescript@1.0.10(acorn@8.16.0)':
dependencies:
acorn: 8.16.0
'@types/estree@1.0.9': {}
'@types/trusted-types@2.0.7': {}
acorn@8.16.0: {}
aria-query@5.3.1: {}
axobject-query@4.1.0: {}
clsx@2.1.1: {}
devalue@5.8.1: {}
esm-env@1.2.2: {}
esrap@2.2.9:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
is-reference@3.0.3:
dependencies:
'@types/estree': 1.0.9
locate-character@3.0.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
prettier-plugin-svelte@4.1.0(prettier@3.8.3)(svelte@5.56.0):
dependencies:
prettier: 3.8.3
svelte: 5.56.0
prettier-plugin-tailwindcss@0.8.0(prettier-plugin-svelte@4.1.0(prettier@3.8.3)(svelte@5.56.0))(prettier@3.8.3):
dependencies:
prettier: 3.8.3
optionalDependencies:
prettier-plugin-svelte: 4.1.0(prettier@3.8.3)(svelte@5.56.0)
prettier@3.8.3: {}
svelte@5.56.0:
dependencies:
'@jridgewell/remapping': 2.3.5
'@jridgewell/sourcemap-codec': 1.5.5
'@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0)
'@types/estree': 1.0.9
'@types/trusted-types': 2.0.7
acorn: 8.16.0
aria-query: 5.3.1
axobject-query: 4.1.0
clsx: 2.1.1
devalue: 5.8.1
esm-env: 1.2.2
esrap: 2.2.9
is-reference: 3.0.3
locate-character: 3.0.0
magic-string: 0.30.21
zimmerframe: 1.1.4
transitivePeerDependencies:
- '@typescript-eslint/types'
zimmerframe@1.1.4: {}