Compare commits
4 Commits
41ea8b598a
...
0ef7728ac0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ef7728ac0 | |||
| f8c56623a4 | |||
| 91932d38ce | |||
| 0f6bfe2111 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
**/target
|
||||
**/node_modules
|
||||
**/.svelte-kit
|
||||
frontend/build
|
||||
.git
|
||||
**/.env
|
||||
**/.env.*
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,5 +18,8 @@ node_modules/
|
||||
|
||||
# ─── env / secrets ─── #
|
||||
.env
|
||||
.env.test
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
docs/
|
||||
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
WORKDIR /frontend
|
||||
RUN npm install -g pnpm@10
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY frontend/ ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM rust:1-bookworm AS backend-builder
|
||||
WORKDIR /backend
|
||||
ENV SQLX_OFFLINE=true
|
||||
COPY backend/ ./
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:12-slim AS runtime
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /app/storage
|
||||
COPY --from=backend-builder /backend/target/release/backend /app/backend
|
||||
COPY --from=frontend-builder /frontend/build /app/static
|
||||
ENV STORAGE_PATH=/app/storage
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["/app/backend"]
|
||||
14
backend/.sqlx/query-11e96cfd8c2736f13ce55975ea910dd68640f6f14e38a4b3342d514804e3de27.json
generated
Normal file
14
backend/.sqlx/query-11e96cfd8c2736f13ce55975ea910dd68640f6f14e38a4b3342d514804e3de27.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM sessions WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "11e96cfd8c2736f13ce55975ea910dd68640f6f14e38a4b3342d514804e3de27"
|
||||
}
|
||||
40
backend/.sqlx/query-24fd2306e40be21dc82660ec3f42f95928849706ecb75fe1cb8b606aa3cf2971.json
generated
Normal file
40
backend/.sqlx/query-24fd2306e40be21dc82660ec3f42f95928849706ecb75fe1cb8b606aa3cf2971.json
generated
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT *\n FROM users u\n WHERE u.username = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "password_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "24fd2306e40be21dc82660ec3f42f95928849706ecb75fe1cb8b606aa3cf2971"
|
||||
}
|
||||
58
backend/.sqlx/query-31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c.json
generated
Normal file
58
backend/.sqlx/query-31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c.json
generated
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT *\n FROM file_records fr\n WHERE user_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "file_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "size",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "uploaded_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "storage_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c"
|
||||
}
|
||||
62
backend/.sqlx/query-638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e.json
generated
Normal file
62
backend/.sqlx/query-638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e.json
generated
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO file_records (user_id, name, file_type, size, storage_key)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING *\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "file_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "size",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "uploaded_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "storage_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e"
|
||||
}
|
||||
41
backend/.sqlx/query-65d6e198b36dc758d3da0e29374e5fabc8633a63f6c213b61a7e1902baad157b.json
generated
Normal file
41
backend/.sqlx/query-65d6e198b36dc758d3da0e29374e5fabc8633a63f6c213b61a7e1902baad157b.json
generated
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO users (username, password_hash)\n VALUES ($1, $2)\n RETURNING *\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "password_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "65d6e198b36dc758d3da0e29374e5fabc8633a63f6c213b61a7e1902baad157b"
|
||||
}
|
||||
40
backend/.sqlx/query-7d78918485f2c98b302a9d4c0801ba3a0f3af9273db03691317ab5e8676d61b7.json
generated
Normal file
40
backend/.sqlx/query-7d78918485f2c98b302a9d4c0801ba3a0f3af9273db03691317ab5e8676d61b7.json
generated
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT *\n FROM sessions s\n WHERE s.id = $1\n AND s.expires_at > NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7d78918485f2c98b302a9d4c0801ba3a0f3af9273db03691317ab5e8676d61b7"
|
||||
}
|
||||
42
backend/.sqlx/query-89c32ebfbbdf3f7d6fe47becb7b074a737b1627bc9c4cabbf2abe145d842b8f9.json
generated
Normal file
42
backend/.sqlx/query-89c32ebfbbdf3f7d6fe47becb7b074a737b1627bc9c4cabbf2abe145d842b8f9.json
generated
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO sessions (id, user_id, expires_at)\n VALUES ($1, $2, $3)\n RETURNING *\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "89c32ebfbbdf3f7d6fe47becb7b074a737b1627bc9c4cabbf2abe145d842b8f9"
|
||||
}
|
||||
59
backend/.sqlx/query-d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0.json
generated
Normal file
59
backend/.sqlx/query-d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0.json
generated
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT *\n FROM file_records fr\n WHERE fr.id = $1\n AND fr.user_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "file_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "size",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "uploaded_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "storage_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0"
|
||||
}
|
||||
59
backend/.sqlx/query-f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d.json
generated
Normal file
59
backend/.sqlx/query-f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d.json
generated
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM file_records\n WHERE id = $1\n AND user_id = $2\n RETURNING *\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "file_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "size",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "uploaded_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "storage_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d"
|
||||
}
|
||||
@@ -13,7 +13,11 @@ use axum::{
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use tower_cookies::CookieManagerLayer;
|
||||
use tower_http::{cors::CorsLayer, services::ServeDir, trace::TraceLayer};
|
||||
use tower_http::{
|
||||
cors::CorsLayer,
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use tracing::info;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
@@ -39,8 +43,10 @@ async fn main() -> Result<()> {
|
||||
.init();
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let pool = PgPool::connect(&database_url).await.unwrap();
|
||||
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))
|
||||
@@ -67,7 +73,9 @@ async fn main() -> Result<()> {
|
||||
.allow_credentials(true)
|
||||
.allow_headers([header::CONTENT_TYPE]),
|
||||
)
|
||||
.fallback_service(ServeDir::new("./"));
|
||||
.fallback_service(
|
||||
ServeDir::new("/app/static").fallback(ServeFile::new("/app/static/index.html")),
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||
info!("listening on {}", listener.local_addr().unwrap());
|
||||
|
||||
@@ -106,12 +106,13 @@ impl FileRepository {
|
||||
pub async fn download_file(
|
||||
&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).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.unwrap();
|
||||
let stream = reader.into_bytes_stream(0..).await.unwrap();
|
||||
@@ -119,7 +120,7 @@ impl FileRepository {
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
pub async fn get_file(&self, file_id: i64) -> Result<FileRecord, LoftError> {
|
||||
pub async fn get_file(&self, file_id: i64, user_id: i64) -> Result<FileRecord, LoftError> {
|
||||
info!(
|
||||
"Fetching metadata of file \"{}\" from file_records",
|
||||
file_id
|
||||
@@ -130,8 +131,10 @@ impl FileRepository {
|
||||
SELECT *
|
||||
FROM file_records fr
|
||||
WHERE fr.id = $1
|
||||
AND fr.user_id = $2
|
||||
"#,
|
||||
file_id
|
||||
file_id,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
@@ -141,12 +144,12 @@ impl FileRepository {
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn delete_file(&self, file_id: i64) -> Result<FileRecord, LoftError> {
|
||||
pub async fn delete_file(&self, file_id: i64, user_id: i64) -> Result<FileRecord, LoftError> {
|
||||
info!(
|
||||
"Fetching metadata of file \"{}\" from file_records",
|
||||
file_id
|
||||
);
|
||||
let record = self.get_file(file_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.unwrap();
|
||||
|
||||
@@ -156,9 +159,11 @@ impl FileRepository {
|
||||
r#"
|
||||
DELETE FROM file_records
|
||||
WHERE id = $1
|
||||
AND user_id = $2
|
||||
RETURNING *
|
||||
"#,
|
||||
file_id
|
||||
file_id,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
@@ -407,7 +412,10 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _downloaded = file_repository.download_file(file_record.id).await.unwrap();
|
||||
let _downloaded = file_repository
|
||||
.download_file(file_record.id, user.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
//TODO: add assert
|
||||
|
||||
@@ -422,7 +430,7 @@ mod tests {
|
||||
truncate_file_records(&file_repository.pool).await;
|
||||
|
||||
assert!(matches!(
|
||||
file_repository.download_file(i64::MAX).await,
|
||||
file_repository.download_file(i64::MAX, 1).await,
|
||||
Err(LoftError::FileIdNotFound)
|
||||
));
|
||||
}
|
||||
@@ -453,10 +461,13 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
file_repository.delete_file(file_record.id).await.unwrap();
|
||||
file_repository
|
||||
.delete_file(file_record.id, user.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
file_repository.download_file(file_record.id).await,
|
||||
file_repository.download_file(file_record.id, user.id).await,
|
||||
Err(LoftError::FileIdNotFound)
|
||||
));
|
||||
truncate_file_records(&file_repository.pool).await;
|
||||
@@ -469,7 +480,7 @@ mod tests {
|
||||
let file_repository = file_repository().await.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
file_repository.delete_file(99).await,
|
||||
file_repository.delete_file(99, 1).await,
|
||||
Err(LoftError::FileIdNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -57,28 +57,37 @@ async fn upload_file(
|
||||
#[axum::debug_handler]
|
||||
async fn get_file(
|
||||
State(file_repository): State<FileRepository>,
|
||||
ctx: Ctx,
|
||||
Path(file_id): Path<u64>,
|
||||
) -> Result<Json<FileRecord>, LoftError> {
|
||||
let record = file_repository.get_file(file_id as i64).await?;
|
||||
let record = file_repository
|
||||
.get_file(file_id as i64, ctx.user_id())
|
||||
.await?;
|
||||
Ok(Json(record))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
async fn download_file(
|
||||
State(file_repository): State<FileRepository>,
|
||||
ctx: Ctx,
|
||||
Path(file_id): Path<u64>,
|
||||
) -> Result<impl IntoResponse, LoftError> {
|
||||
let stream = file_repository.download_file(file_id as i64).await?;
|
||||
let stream = file_repository
|
||||
.download_file(file_id as i64, ctx.user_id())
|
||||
.await?;
|
||||
Ok(Body::from_stream(stream))
|
||||
}
|
||||
|
||||
async fn delete_file(
|
||||
State(file_repository): State<FileRepository>,
|
||||
ctx: Ctx,
|
||||
Path(file_id): Path<u64>,
|
||||
) -> Result<Json<FileRecord>, LoftError> {
|
||||
info!("handler: delete_file");
|
||||
|
||||
let file = file_repository.delete_file(file_id as i64).await?;
|
||||
let file = file_repository
|
||||
.delete_file(file_id as i64, ctx.user_id())
|
||||
.await?;
|
||||
Ok(Json(file))
|
||||
}
|
||||
|
||||
|
||||
36
compose.yml
Normal file
36
compose.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
db:
|
||||
image: docker.io/library/postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=pass
|
||||
- POSTGRES_DB=loft
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- loft_db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d loft"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
app:
|
||||
image: loft
|
||||
restart: unless-stopped
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- DATABASE_URL=postgres://postgres:pass@db:5432/loft
|
||||
- STORAGE_PATH=/app/storage
|
||||
volumes:
|
||||
- loft_storage:/app/storage
|
||||
|
||||
volumes:
|
||||
loft_db_data:
|
||||
loft_storage:
|
||||
@@ -23,6 +23,7 @@
|
||||
"@neoconfetti/svelte": "^2.2.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
@@ -46,5 +47,8 @@
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.0",
|
||||
"vitest-browser-svelte": "^2.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"plyr": "^3.8.4"
|
||||
}
|
||||
}
|
||||
|
||||
52
frontend/pnpm-lock.yaml
generated
52
frontend/pnpm-lock.yaml
generated
@@ -7,6 +7,10 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
plyr:
|
||||
specifier: ^3.8.4
|
||||
version: 3.8.4
|
||||
devDependencies:
|
||||
'@eslint/compat':
|
||||
specifier: ^2.0.3
|
||||
@@ -26,6 +30,9 @@ importers:
|
||||
'@sveltejs/adapter-auto':
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))
|
||||
'@sveltejs/adapter-static':
|
||||
specifier: ^3.0.10
|
||||
version: 3.0.10(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))
|
||||
'@sveltejs/kit':
|
||||
specifier: ^2.50.2
|
||||
version: 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
@@ -491,6 +498,11 @@ packages:
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^2.0.0
|
||||
|
||||
'@sveltejs/adapter-static@3.0.10':
|
||||
resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==}
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^2.0.0
|
||||
|
||||
'@sveltejs/kit@2.55.0':
|
||||
resolution: {integrity: sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==}
|
||||
engines: {node: '>=18.13'}
|
||||
@@ -803,6 +815,9 @@ packages:
|
||||
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
core-js@3.49.0:
|
||||
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -812,6 +827,9 @@ packages:
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
custom-event-polyfill@1.0.7:
|
||||
resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -1119,6 +1137,9 @@ packages:
|
||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
loadjs@4.3.0:
|
||||
resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==}
|
||||
|
||||
locate-character@3.0.0:
|
||||
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
||||
|
||||
@@ -1199,6 +1220,9 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
plyr@3.8.4:
|
||||
resolution: {integrity: sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==}
|
||||
|
||||
pngjs@7.0.0:
|
||||
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
|
||||
engines: {node: '>=14.19.0'}
|
||||
@@ -1313,6 +1337,9 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
rangetouch@2.0.1:
|
||||
resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==}
|
||||
|
||||
readdirp@4.1.2:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
@@ -1434,6 +1461,9 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
url-polyfill@1.1.14:
|
||||
resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
@@ -1812,6 +1842,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
|
||||
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))':
|
||||
dependencies:
|
||||
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
|
||||
'@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -2151,6 +2185,8 @@ snapshots:
|
||||
|
||||
cookie@0.6.0: {}
|
||||
|
||||
core-js@3.49.0: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -2159,6 +2195,8 @@ snapshots:
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
custom-event-polyfill@1.0.7: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -2455,6 +2493,8 @@ snapshots:
|
||||
|
||||
lilconfig@2.1.0: {}
|
||||
|
||||
loadjs@4.3.0: {}
|
||||
|
||||
locate-character@3.0.0: {}
|
||||
|
||||
locate-path@6.0.0:
|
||||
@@ -2518,6 +2558,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
plyr@3.8.4:
|
||||
dependencies:
|
||||
core-js: 3.49.0
|
||||
custom-event-polyfill: 1.0.7
|
||||
loadjs: 4.3.0
|
||||
rangetouch: 2.0.1
|
||||
url-polyfill: 1.1.14
|
||||
|
||||
pngjs@7.0.0: {}
|
||||
|
||||
postcss-load-config@3.1.4(postcss@8.5.8):
|
||||
@@ -2568,6 +2616,8 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
rangetouch@2.0.1: {}
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
rollup@4.60.1:
|
||||
@@ -2716,6 +2766,8 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
url-polyfill@1.1.14: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0):
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { FileRecord } from '$lib/types';
|
||||
import { fade } from 'svelte/transition';
|
||||
import VideoPreview from './VideoPreview.svelte';
|
||||
|
||||
let { file, onclose }: { file: FileRecord; onclose: () => void } = $props();
|
||||
const isVideo = $derived(file.fileType.includes('video'));
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
@@ -14,18 +16,24 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onclick={onclose}
|
||||
transition:fade={{ duration: 150 }}
|
||||
>
|
||||
<div
|
||||
class="flex h-2/3 w-2/3 flex-col rounded-sm border border-sky-200/20 bg-[#0f1117] p-6"
|
||||
class="relative aspect-video h-[85vh] max-w-[95vw] overflow-hidden rounded-xl border border-sky-200/20 bg-[#0f1117]"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-white">{file.name}</span>
|
||||
{#if isVideo}
|
||||
<VideoPreview src={`/api/files/${file.id}/download`} />
|
||||
<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"
|
||||
>
|
||||
<span class="pointer-events-auto truncate pr-3 text-sm font-medium text-white"
|
||||
>{file.name}</span
|
||||
>
|
||||
<button
|
||||
class="cursor-pointer text-white/40 transition-colors hover:text-white"
|
||||
class="pointer-events-auto cursor-pointer text-white/70 transition-colors hover:text-white"
|
||||
onclick={onclose}
|
||||
aria-label="Close"
|
||||
>
|
||||
@@ -44,8 +52,10 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-center text-sm text-white/20">
|
||||
preview coming soon
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center text-sm text-white/40">
|
||||
No preview available
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
28
frontend/src/lib/components/VideoPreview.svelte
Normal file
28
frontend/src/lib/components/VideoPreview.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import Plyr from 'plyr';
|
||||
import 'plyr/dist/plyr.css';
|
||||
|
||||
let { src }: { src: string } = $props();
|
||||
let videoElement: HTMLVideoElement;
|
||||
|
||||
$effect(() => {
|
||||
const player = new Plyr(videoElement, {
|
||||
ratio: '16:9',
|
||||
controls: [
|
||||
'play-large',
|
||||
'play',
|
||||
'progress',
|
||||
'current-time',
|
||||
'mute',
|
||||
'volume',
|
||||
'captions',
|
||||
'settings',
|
||||
'fullscreen'
|
||||
]
|
||||
});
|
||||
return () => player.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video bind:this={videoElement} {src} poster="/video-placeholder.png" playsinline controls></video>
|
||||
@@ -2,5 +2,6 @@ export type FileRecord = {
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
fileType: string;
|
||||
uploadedAt: string;
|
||||
};
|
||||
|
||||
2
frontend/src/routes/+layout.ts
Normal file
2
frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
@@ -1,6 +1,4 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
@import '@fontsource/fira-mono';
|
||||
|
||||
:root {
|
||||
@@ -13,6 +11,8 @@
|
||||
--column-margin-top: 4rem;
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text);
|
||||
--plyr-color-main: var(--color-sky-400);
|
||||
--plyr-video-background: #0f1117;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
adapter: adapter({ fallback: 'index.html' })
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user