Compare commits
32 Commits
c310d69b11
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 824cd2ad53 | |||
| 40d57d7c77 | |||
| da9cd3e03b | |||
| eb95eed752 | |||
| affe9bcd14 | |||
| 23933f47a0 | |||
| 7d06c9e93d | |||
| 690a7111aa | |||
| 1c98dae3d2 | |||
| a40c1f7396 | |||
| d153e1c251 | |||
| be3f7da565 | |||
| 91459db5ec | |||
| 450b3fdf52 | |||
| 2616896ecb | |||
| 21d65af470 | |||
| 4699ffe6af | |||
| b2a7787e45 | |||
| 8cb1f4142b | |||
| 9124cc90aa | |||
| 8858bfa3c6 | |||
| ec1c458bb7 | |||
| b5e76b0368 | |||
| d2f044db07 | |||
| 0ef7728ac0 | |||
| f8c56623a4 | |||
| 91932d38ce | |||
| 0f6bfe2111 | |||
| 41ea8b598a | |||
| c1bceae5e4 | |||
| b561389175 | |||
| 882b28c883 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
**/target
|
||||||
|
**/node_modules
|
||||||
|
**/.svelte-kit
|
||||||
|
frontend/build
|
||||||
|
.git
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=your-password-here
|
||||||
|
POSTGRES_DB=loft
|
||||||
|
DATABASE_URL=postgres://postgres:your-password-here@db:5432/loft
|
||||||
|
STORAGE_PATH=/app/storage
|
||||||
37
.github/workflows/backend.yml
vendored
Normal file
37
.github/workflows/backend.yml
vendored
Normal 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
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -18,5 +18,10 @@ node_modules/
|
|||||||
|
|
||||||
# ─── env / secrets ─── #
|
# ─── env / secrets ─── #
|
||||||
.env
|
.env
|
||||||
|
.env.test
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
docs/
|
||||||
|
|
||||||
|
logs/
|
||||||
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"]
|
||||||
7
Justfile
7
Justfile
@@ -1,7 +0,0 @@
|
|||||||
backend_path := "backend"
|
|
||||||
|
|
||||||
watch:
|
|
||||||
cd {{backend_path}} && cargo watch -q -c -w src/ -x run
|
|
||||||
|
|
||||||
watch-test:
|
|
||||||
cd {{backend_path}} && cargo watch -q -c -w src/ -x test
|
|
||||||
31
README.md
Normal file
31
README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# loft
|
||||||
|
|
||||||
|
A minimal, self-hosted web app (with a cloud storage option planned), built as a learning exercise to get more familiar with Axum and Svelte.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
```bash
|
||||||
|
git clone https://git.stefiosif.dev/stefiosif/loft
|
||||||
|
cd loft
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Run in Docker (http://localhost:3000):
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Run locally (http://localhost:5173):
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
just db-setup
|
||||||
|
# inside tmux together frontend and backend
|
||||||
|
just watch
|
||||||
|
# or separately
|
||||||
|
just watch-backend
|
||||||
|
just watch-frontend
|
||||||
|
|
||||||
|
# API docs (Swagger UI, generated with utoipa): http://localhost:3000/swagger-ui
|
||||||
|
|
||||||
|
# Run tests:
|
||||||
|
cp backend/.env.test.example backend/.env.test
|
||||||
|
just watch-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
Run `just --list` to see all recipes.
|
||||||
5
backend/.env.example
Normal file
5
backend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
DATABASE_URL=postgres://postgres:your-password-here@localhost:5432/loft
|
||||||
|
STORAGE_PATH=/tmp/loft-files
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=your-password-here
|
||||||
|
POSTGRES_DB=loft
|
||||||
3
backend/.env.test.example
Normal file
3
backend/.env.test.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
DATABASE_URL=postgres://postgres:your-password-here@localhost:5432/loft_test
|
||||||
|
STORAGE_PATH=/tmp/loft-files-test
|
||||||
|
ENVIRONMENT=test
|
||||||
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"
|
||||||
|
}
|
||||||
64
backend/.sqlx/query-31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c.json
generated
Normal file
64
backend/.sqlx/query-31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c.json
generated
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "thumbnail_storage_path",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c"
|
||||||
|
}
|
||||||
68
backend/.sqlx/query-638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e.json
generated
Normal file
68
backend/.sqlx/query-638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e.json
generated
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "thumbnail_storage_path",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Int8",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
16
backend/.sqlx/query-ac8099bc9fa42a4152f2230903f9e9b51edfc01f03fd18cc2fe0ab3a956f3175.json
generated
Normal file
16
backend/.sqlx/query-ac8099bc9fa42a4152f2230903f9e9b51edfc01f03fd18cc2fe0ab3a956f3175.json
generated
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE file_records\n SET thumbnail_storage_path = $1\n WHERE id = $2\n AND user_id = $3\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Int8",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ac8099bc9fa42a4152f2230903f9e9b51edfc01f03fd18cc2fe0ab3a956f3175"
|
||||||
|
}
|
||||||
65
backend/.sqlx/query-d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0.json
generated
Normal file
65
backend/.sqlx/query-d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0.json
generated
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "thumbnail_storage_path",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0"
|
||||||
|
}
|
||||||
65
backend/.sqlx/query-f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d.json
generated
Normal file
65
backend/.sqlx/query-f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d.json
generated
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "thumbnail_storage_path",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d"
|
||||||
|
}
|
||||||
999
backend/Cargo.lock
generated
999
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE file_records ADD COLUMN thumbnail_storage_path TEXT;
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,100 @@
|
|||||||
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_http::{cors::CorsLayer, services::ServeDir, trace::TraceLayer};
|
use tower_governor::{
|
||||||
|
GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor,
|
||||||
|
};
|
||||||
|
use tower_http::{
|
||||||
|
cors::CorsLayer,
|
||||||
|
services::{ServeDir, ServeFile},
|
||||||
|
trace::TraceLayer,
|
||||||
|
};
|
||||||
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 pool = PgPool::connect(&database_url).await.unwrap();
|
|
||||||
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 database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let pool = PgPool::connect(&database_url).await?;
|
||||||
|
sqlx::migrate!().run(&pool).await?;
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -62,22 +102,47 @@ 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]),
|
||||||
)
|
)
|
||||||
.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?;
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -90,82 +68,85 @@ impl FileRepository {
|
|||||||
"#,
|
"#,
|
||||||
user_id,
|
user_id,
|
||||||
file_name,
|
file_name,
|
||||||
mime_guess::from_path(file_name).first_or_octet_stream().to_string(),
|
mime_guess::from_path(file_name)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.to_string(),
|
||||||
file_size as i64,
|
file_size as i64,
|
||||||
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,
|
let record = self.get_file(file_id, user_id).await?;
|
||||||
file_id: i64,
|
let reader = self.op.reader(&record.storage_key).await?;
|
||||||
) -> Result<impl Stream<Item = std::io::Result<Bytes>> + use<>, LoftError> {
|
let stream = reader.into_bytes_stream(0..).await?;
|
||||||
info!(
|
|
||||||
"Fetching metadata of file \"{}\" from file_records",
|
|
||||||
file_id
|
|
||||||
);
|
|
||||||
let record = self.get_file(file_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();
|
|
||||||
|
|
||||||
Ok(stream)
|
Ok(stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_file(&self, file_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 *
|
||||||
FROM file_records fr
|
FROM file_records fr
|
||||||
WHERE fr.id = $1
|
WHERE fr.id = $1
|
||||||
|
AND fr.user_id = $2
|
||||||
"#,
|
"#,
|
||||||
file_id
|
file_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) -> Result<FileRecord, LoftError> {
|
pub async fn delete_file(&self, file_id: i64, user_id: i64) -> Result<FileRecord> {
|
||||||
info!(
|
let record = self.get_file(file_id, user_id).await?;
|
||||||
"Fetching metadata of file \"{}\" from file_records",
|
self.op.delete(&record.storage_key).await?;
|
||||||
file_id
|
|
||||||
);
|
|
||||||
let record = self.get_file(file_id).await?;
|
|
||||||
info!("Deleting file bytes \"{}\"", file_id);
|
|
||||||
self.op.delete(&record.storage_key).await.unwrap();
|
|
||||||
|
|
||||||
info!("Deleting file record \"{}\"", file_id);
|
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
FileRecord,
|
FileRecord,
|
||||||
r#"
|
r#"
|
||||||
DELETE FROM file_records
|
DELETE FROM file_records
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
|
AND user_id = $2
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#,
|
"#,
|
||||||
file_id
|
file_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 *
|
||||||
@@ -175,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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,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: String,
|
|
||||||
password_hash: String,
|
|
||||||
) -> Result<User, LoftError> {
|
|
||||||
let user = sqlx::query_as!(
|
let user = sqlx::query_as!(
|
||||||
User,
|
User,
|
||||||
r#"
|
r#"
|
||||||
@@ -224,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: String) -> 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#"
|
||||||
@@ -244,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)
|
||||||
}
|
}
|
||||||
@@ -254,10 +247,10 @@ impl UserRepository {
|
|||||||
pub async fn create_session(
|
pub async fn create_session(
|
||||||
&self,
|
&self,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
token: String,
|
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)
|
||||||
@@ -269,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: String) -> 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 *
|
||||||
@@ -293,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: String) -> 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(())
|
||||||
}
|
}
|
||||||
@@ -327,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();
|
||||||
@@ -338,8 +319,14 @@ mod tests {
|
|||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_upload_and_list() {
|
async fn test_upload_and_list() {
|
||||||
let user_repository = user_repository().await.unwrap();
|
let user_repository = user_repository().await.unwrap();
|
||||||
let user1 = user_repository.create_user("username1".to_string(), "password_hash".to_string()).await.unwrap();
|
let user1 = user_repository
|
||||||
let user2 = user_repository.create_user("username2".to_string(), "password_hash".to_string()).await.unwrap();
|
.create_user("username1", "password_hash")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let user2 = user_repository
|
||||||
|
.create_user("username2", "password_hash")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let file_repository = file_repository().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
truncate_file_records(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
|
||||||
@@ -377,7 +364,10 @@ mod tests {
|
|||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_download() {
|
async fn test_download() {
|
||||||
let user_repository = user_repository().await.unwrap();
|
let user_repository = user_repository().await.unwrap();
|
||||||
let user = user_repository.create_user("username".to_string(), "password_hash".to_string()).await.unwrap();
|
let user = user_repository
|
||||||
|
.create_user("username", "password_hash")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let file_repository = file_repository().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
truncate_file_records(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
|
||||||
@@ -396,9 +386,14 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let _downloaded = file_repository.download_file(file_record.id).await.unwrap();
|
let chunks: Vec<_> = file_repository
|
||||||
|
.download_file(file_record.id, user.id)
|
||||||
//TODO: add assert
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.collect()
|
||||||
|
.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;
|
||||||
@@ -411,16 +406,89 @@ mod tests {
|
|||||||
truncate_file_records(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
file_repository.download_file(i64::MAX).await,
|
file_repository.download_file(i64::MAX, 1).await,
|
||||||
Err(LoftError::FileIdNotFound)
|
Err(LoftError::FileIdNotFound)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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() {
|
||||||
let user_repository = user_repository().await.unwrap();
|
let user_repository = user_repository().await.unwrap();
|
||||||
let user = user_repository.create_user("username".to_string(), "password_hash".to_string()).await.unwrap();
|
let user = user_repository
|
||||||
|
.create_user("username", "password_hash")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let file_repository = file_repository().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
truncate_file_records(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
|
||||||
@@ -439,10 +507,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
file_repository.delete_file(file_record.id).await.unwrap();
|
file_repository
|
||||||
|
.delete_file(file_record.id, user.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
file_repository.download_file(file_record.id).await,
|
file_repository.download_file(file_record.id, user.id).await,
|
||||||
Err(LoftError::FileIdNotFound)
|
Err(LoftError::FileIdNotFound)
|
||||||
));
|
));
|
||||||
truncate_file_records(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
@@ -455,7 +526,7 @@ mod tests {
|
|||||||
let file_repository = file_repository().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
file_repository.delete_file(99).await,
|
file_repository.delete_file(99, 1).await,
|
||||||
Err(LoftError::FileIdNotFound)
|
Err(LoftError::FileIdNotFound)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -478,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]
|
||||||
@@ -487,11 +558,8 @@ mod tests {
|
|||||||
let user_repository = user_repository().await.unwrap();
|
let user_repository = user_repository().await.unwrap();
|
||||||
truncate_users(&user_repository.pool).await;
|
truncate_users(&user_repository.pool).await;
|
||||||
|
|
||||||
let username = "picolo".to_string();
|
let username = "picolo";
|
||||||
let user = user_repository
|
let user = user_repository.create_user(&username, "pw").await.unwrap();
|
||||||
.create_user(username.clone(), "pw".to_string())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(user.username, username);
|
assert_eq!(user.username, username);
|
||||||
truncate_users(&user_repository.pool).await;
|
truncate_users(&user_repository.pool).await;
|
||||||
@@ -503,15 +571,13 @@ mod tests {
|
|||||||
let user_repository = user_repository().await.unwrap();
|
let user_repository = user_repository().await.unwrap();
|
||||||
truncate_users(&user_repository.pool).await;
|
truncate_users(&user_repository.pool).await;
|
||||||
|
|
||||||
let username = "picolo".to_string();
|
let username = "picolo";
|
||||||
user_repository
|
user_repository.create_user(username, "pw").await.unwrap();
|
||||||
.create_user(username.clone(), "pw".to_string())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let fetched_user = user_repository
|
let fetched_user = user_repository
|
||||||
.find_by_username(username.clone())
|
.find_by_username(username)
|
||||||
.await
|
.await
|
||||||
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(fetched_user.username, username);
|
assert_eq!(fetched_user.username, username);
|
||||||
@@ -533,16 +599,13 @@ mod tests {
|
|||||||
truncate_sessions(&user_repository.pool).await;
|
truncate_sessions(&user_repository.pool).await;
|
||||||
truncate_users(&user_repository.pool).await;
|
truncate_users(&user_repository.pool).await;
|
||||||
|
|
||||||
let user = user_repository
|
let user = user_repository.create_user("picolo", "pw").await.unwrap();
|
||||||
.create_user("picolo".to_string(), "pw".to_string())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let token = token();
|
let token = token();
|
||||||
|
|
||||||
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(2);
|
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(2);
|
||||||
let session = user_repository
|
let session = user_repository
|
||||||
.create_session(user.id, token.clone(), expires_at)
|
.create_session(user.id, &token, expires_at)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -560,20 +623,17 @@ mod tests {
|
|||||||
truncate_sessions(&user_repository.pool).await;
|
truncate_sessions(&user_repository.pool).await;
|
||||||
truncate_users(&user_repository.pool).await;
|
truncate_users(&user_repository.pool).await;
|
||||||
|
|
||||||
let user = user_repository
|
let user = user_repository.create_user("picolo", "pw").await.unwrap();
|
||||||
.create_user("picolo".to_string(), "pw".to_string())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let token = token();
|
let token = token();
|
||||||
|
|
||||||
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(2);
|
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(2);
|
||||||
user_repository
|
user_repository
|
||||||
.create_session(user.id, token.clone(), expires_at)
|
.create_session(user.id, &token, expires_at)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let fetched_session = user_repository.get_session(token.clone()).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);
|
||||||
@@ -589,16 +649,13 @@ mod tests {
|
|||||||
truncate_sessions(&user_repository.pool).await;
|
truncate_sessions(&user_repository.pool).await;
|
||||||
truncate_users(&user_repository.pool).await;
|
truncate_users(&user_repository.pool).await;
|
||||||
|
|
||||||
let user = user_repository
|
let user = user_repository.create_user("picolo", "pw").await.unwrap();
|
||||||
.create_user("picolo".to_string(), "pw".to_string())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let token = token();
|
let token = token();
|
||||||
|
|
||||||
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(2);
|
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(2);
|
||||||
let session = user_repository
|
let session = user_repository
|
||||||
.create_session(user.id, token.clone(), expires_at)
|
.create_session(user.id, &token, expires_at)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -606,11 +663,11 @@ mod tests {
|
|||||||
assert_eq!(session.user_id, user.id);
|
assert_eq!(session.user_id, user.id);
|
||||||
assert!(expires_at > chrono::Utc::now());
|
assert!(expires_at > chrono::Utc::now());
|
||||||
|
|
||||||
user_repository.delete_session(token.clone()).await.unwrap();
|
user_repository.delete_session(&token).await.unwrap();
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
user_repository.get_session(token.clone()).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
81
backend/src/tasks.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
|
use tokio::sync::mpsc::Receiver;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::Result,
|
||||||
|
model::{FileRecord, FileRepository},
|
||||||
|
};
|
||||||
|
|
||||||
|
// change thumbnail task to have the file id in the param
|
||||||
|
// with the file id we can call the file repository and get the file record
|
||||||
|
// which will grand us access to the opendal storage... and also the MIME type
|
||||||
|
// so we can have a match statement after a parser that will give us the type of
|
||||||
|
// process we should do in order to create the thumbnail..
|
||||||
|
// for example an image will be processed with image crate but a video will be processed with tokio::command->ffmpeg
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ThumbnailTask {
|
||||||
|
record_id: i64,
|
||||||
|
user_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThumbnailTask {
|
||||||
|
pub fn new(record_id: i64, user_id: i64) -> Self {
|
||||||
|
Self { record_id, user_id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_thumbnail_worker(
|
||||||
|
mut rx: Receiver<ThumbnailTask>,
|
||||||
|
file_repository: FileRepository,
|
||||||
|
) {
|
||||||
|
while let Some(task) = rx.recv().await {
|
||||||
|
let record_id = task.record_id;
|
||||||
|
|
||||||
|
if let Err(e) = process_task(task, &file_repository).await {
|
||||||
|
warn!("thumbnail task {} failed: {}", record_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_task(task: ThumbnailTask, file_repository: &FileRepository) -> Result<()> {
|
||||||
|
let record_id = task.record_id;
|
||||||
|
info!("task for file record {} received", record_id);
|
||||||
|
|
||||||
|
let record = file_repository
|
||||||
|
.get_file(task.record_id, task.user_id)
|
||||||
|
.await?;
|
||||||
|
let file_type = &record.file_type;
|
||||||
|
|
||||||
|
// TODO: create enum that maps file_type (which is actually mime_type) into file category (image, video, ...)
|
||||||
|
if file_type.contains("image") {
|
||||||
|
generate_for_image(&record, file_repository).await?;
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"thumbnail generation for file type {} not yet supported",
|
||||||
|
file_type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_for_image(record: &FileRecord, file_repository: &FileRepository) -> Result<()> {
|
||||||
|
let storage_key = &record.storage_key;
|
||||||
|
let bytes = file_repository.op.read(storage_key).await?.to_vec();
|
||||||
|
|
||||||
|
let image = image::load_from_memory(&bytes)?;
|
||||||
|
let thumbnail = image.thumbnail(256, 256);
|
||||||
|
let rgb = thumbnail.to_rgb8();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut enc = JpegEncoder::new_with_quality(&mut buf, 50);
|
||||||
|
enc.encode_image(&rgb)?;
|
||||||
|
|
||||||
|
let path = format!("thumbnail/{}", storage_key);
|
||||||
|
file_repository.op.write(&path, buf).await?;
|
||||||
|
file_repository
|
||||||
|
.update_thumbnail_storage_path(&path, record.id, record.user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
};
|
};
|
||||||
@@ -39,16 +41,14 @@ pub async fn mw_ctx_resolver(
|
|||||||
impl<S: Send + Sync> FromRequestParts<S> for Ctx {
|
impl<S: Send + Sync> FromRequestParts<S> for Ctx {
|
||||||
type Rejection = LoftError;
|
type Rejection = LoftError;
|
||||||
|
|
||||||
fn from_request_parts(
|
async fn from_request_parts(
|
||||||
parts: &mut axum::http::request::Parts,
|
parts: &mut axum::http::request::Parts,
|
||||||
_: &S,
|
_: &S,
|
||||||
) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
|
) -> Result<Self, Self::Rejection> {
|
||||||
async move {
|
|
||||||
parts
|
parts
|
||||||
.extensions
|
.extensions
|
||||||
.get::<Result<Ctx, LoftError>>()
|
.get::<Result<Ctx>>()
|
||||||
.ok_or(LoftError::AuthFailCtxNotInRequestExt)?
|
.ok_or(LoftError::AuthFailCtxNotInRequestExt)?
|
||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,97 +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, error::LoftError, model::{FileRecord, FileRepository}
|
ctx::Ctx,
|
||||||
|
error::{LoftError, Result},
|
||||||
|
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
|
|
||||||
|
let record = service
|
||||||
|
.file_repository
|
||||||
.create_file_record(ctx.user_id(), &name, size, &key)
|
.create_file_record(ctx.user_id(), &name, size, &key)
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(Json(file_record));
|
|
||||||
|
if let Err(e) = service
|
||||||
|
.tx
|
||||||
|
.send(ThumbnailTask::new(record.id, ctx.user_id()))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!("failed to send thumbnail task for file {}: {e}", record.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
Path(file_id): Path<u64>,
|
Path(file_id): Path<u64>,
|
||||||
) -> Result<Json<FileRecord>, LoftError> {
|
) -> Result<Json<FileRecord>> {
|
||||||
let record = file_repository.get_file(file_id as i64).await?;
|
let record = service
|
||||||
|
.file_repository
|
||||||
|
.get_file(file_id as i64, ctx.user_id())
|
||||||
|
.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,
|
||||||
Path(file_id): Path<u64>,
|
Path(file_id): Path<u64>,
|
||||||
) -> Result<impl IntoResponse, LoftError> {
|
) -> Result<impl IntoResponse> {
|
||||||
let stream = file_repository.download_file(file_id as i64).await?;
|
let stream = service
|
||||||
|
.file_repository
|
||||||
|
.download_file(file_id as i64, ctx.user_id())
|
||||||
|
.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,
|
||||||
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.delete_file(file_id as i64).await?;
|
/// 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())
|
||||||
|
.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},
|
||||||
@@ -100,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},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,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(
|
||||||
@@ -146,7 +359,7 @@ mod tests {
|
|||||||
|
|
||||||
async fn create_test_session(user_repository: &UserRepository) -> String {
|
async fn create_test_session(user_repository: &UserRepository) -> String {
|
||||||
let user = user_repository
|
let user = user_repository
|
||||||
.create_user("testuser".to_string(), "hash".to_string())
|
.create_user("testuser", "hash")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let token: String = rand::rng()
|
let token: String = rand::rng()
|
||||||
@@ -156,7 +369,7 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
let expires_at = chrono::Utc::now() + chrono::Duration::days(1);
|
let expires_at = chrono::Utc::now() + chrono::Duration::days(1);
|
||||||
user_repository
|
user_repository
|
||||||
.create_session(user.id, token.clone(), expires_at)
|
.create_session(user.id, &token, expires_at)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
token
|
token
|
||||||
@@ -238,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");
|
||||||
|
|
||||||
@@ -280,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() {
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
use axum::{Router, routing::get};
|
use axum::{Router, routing::get};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(paths(health))]
|
||||||
|
pub struct HealthApi;
|
||||||
|
|
||||||
pub fn routes_health() -> Router {
|
pub fn routes_health() -> Router {
|
||||||
Router::new().route("/health", get(health))
|
Router::new().route("/health", get(health))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Health check
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/health",
|
||||||
|
tag = "health",
|
||||||
|
responses((status = 200, description = "Service is healthy"))
|
||||||
|
)]
|
||||||
async fn health() -> &'static str {
|
async fn health() -> &'static str {
|
||||||
"up"
|
"up"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,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,68 +52,107 @@ 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);
|
||||||
|
|
||||||
|
if password_verification.is_err() {
|
||||||
|
return Err(LoftError::LoginFail);
|
||||||
|
}
|
||||||
|
|
||||||
let expires_at = chrono::Utc::now() + chrono::Duration::days(1);
|
let expires_at = chrono::Utc::now() + chrono::Duration::days(1);
|
||||||
let cookie = create_cookie();
|
let cookie = create_cookie();
|
||||||
let auth_token = cookie.value().to_string();
|
let auth_token = cookie.value();
|
||||||
cookies.add(cookie);
|
cookies.add(cookie.clone());
|
||||||
user_repository
|
user_repository
|
||||||
.create_session(user.id, auth_token, expires_at)
|
.create_session(user.id, auth_token, expires_at)
|
||||||
.await?;
|
.await?;
|
||||||
} else {
|
|
||||||
return Err(LoftError::LoginFail);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
user_repository.delete_session(auth_token.clone()).await?;
|
user_repository.delete_session(&auth_token).await?;
|
||||||
cookies.remove(Cookie::build(AUTH_TOKEN).path("/").build());
|
cookies.remove(Cookie::build(AUTH_TOKEN).path("/").build());
|
||||||
}
|
}
|
||||||
|
|
||||||
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.clone())
|
.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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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_USER}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
volumes:
|
||||||
|
- loft_db_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
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=${DATABASE_URL}
|
||||||
|
- STORAGE_PATH=${STORAGE_PATH}
|
||||||
|
volumes:
|
||||||
|
- loft_storage:${STORAGE_PATH}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
loft_db_data:
|
||||||
|
loft_storage:
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"useTabs": true,
|
"useTabs": false,
|
||||||
|
"tabWidth": 4,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
|
|||||||
47
frontend/e2e/auth.e2e.ts
Normal file
47
frontend/e2e/auth.e2e.ts
Normal 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
78
frontend/e2e/files.e2e.ts
Normal 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
26
frontend/e2e/helpers.ts
Normal 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') };
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"@neoconfetti/svelte": "^2.2.2",
|
"@neoconfetti/svelte": "^2.2.2",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
@@ -46,5 +47,8 @@
|
|||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.1.0",
|
"vitest": "^4.1.0",
|
||||||
"vitest-browser-svelte": "^2.0.2"
|
"vitest-browser-svelte": "^2.0.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"plyr": "^3.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
52
frontend/pnpm-lock.yaml
generated
52
frontend/pnpm-lock.yaml
generated
@@ -7,6 +7,10 @@ settings:
|
|||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
dependencies:
|
||||||
|
plyr:
|
||||||
|
specifier: ^3.8.4
|
||||||
|
version: 3.8.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/compat':
|
'@eslint/compat':
|
||||||
specifier: ^2.0.3
|
specifier: ^2.0.3
|
||||||
@@ -26,6 +30,9 @@ importers:
|
|||||||
'@sveltejs/adapter-auto':
|
'@sveltejs/adapter-auto':
|
||||||
specifier: ^7.0.0
|
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)))
|
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':
|
'@sveltejs/kit':
|
||||||
specifier: ^2.50.2
|
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))
|
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:
|
peerDependencies:
|
||||||
'@sveltejs/kit': ^2.0.0
|
'@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':
|
'@sveltejs/kit@2.55.0':
|
||||||
resolution: {integrity: sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==}
|
resolution: {integrity: sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==}
|
||||||
engines: {node: '>=18.13'}
|
engines: {node: '>=18.13'}
|
||||||
@@ -803,6 +815,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
core-js@3.49.0:
|
||||||
|
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -812,6 +827,9 @@ packages:
|
|||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
custom-event-polyfill@1.0.7:
|
||||||
|
resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1119,6 +1137,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
loadjs@4.3.0:
|
||||||
|
resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==}
|
||||||
|
|
||||||
locate-character@3.0.0:
|
locate-character@3.0.0:
|
||||||
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
||||||
|
|
||||||
@@ -1199,6 +1220,9 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
plyr@3.8.4:
|
||||||
|
resolution: {integrity: sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==}
|
||||||
|
|
||||||
pngjs@7.0.0:
|
pngjs@7.0.0:
|
||||||
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
|
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
|
||||||
engines: {node: '>=14.19.0'}
|
engines: {node: '>=14.19.0'}
|
||||||
@@ -1313,6 +1337,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
rangetouch@2.0.1:
|
||||||
|
resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==}
|
||||||
|
|
||||||
readdirp@4.1.2:
|
readdirp@4.1.2:
|
||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||||
engines: {node: '>= 14.18.0'}
|
engines: {node: '>= 14.18.0'}
|
||||||
@@ -1434,6 +1461,9 @@ packages:
|
|||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
|
|
||||||
|
url-polyfill@1.1.14:
|
||||||
|
resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==}
|
||||||
|
|
||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
@@ -1812,6 +1842,10 @@ snapshots:
|
|||||||
dependencies:
|
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))
|
||||||
|
|
||||||
|
'@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))':
|
'@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:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
@@ -2151,6 +2185,8 @@ snapshots:
|
|||||||
|
|
||||||
cookie@0.6.0: {}
|
cookie@0.6.0: {}
|
||||||
|
|
||||||
|
core-js@3.49.0: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -2159,6 +2195,8 @@ snapshots:
|
|||||||
|
|
||||||
cssesc@3.0.0: {}
|
cssesc@3.0.0: {}
|
||||||
|
|
||||||
|
custom-event-polyfill@1.0.7: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -2455,6 +2493,8 @@ snapshots:
|
|||||||
|
|
||||||
lilconfig@2.1.0: {}
|
lilconfig@2.1.0: {}
|
||||||
|
|
||||||
|
loadjs@4.3.0: {}
|
||||||
|
|
||||||
locate-character@3.0.0: {}
|
locate-character@3.0.0: {}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
@@ -2518,6 +2558,14 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
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: {}
|
pngjs@7.0.0: {}
|
||||||
|
|
||||||
postcss-load-config@3.1.4(postcss@8.5.8):
|
postcss-load-config@3.1.4(postcss@8.5.8):
|
||||||
@@ -2568,6 +2616,8 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
rangetouch@2.0.1: {}
|
||||||
|
|
||||||
readdirp@4.1.2: {}
|
readdirp@4.1.2: {}
|
||||||
|
|
||||||
rollup@4.60.1:
|
rollup@4.60.1:
|
||||||
@@ -2716,6 +2766,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
url-polyfill@1.1.14: {}
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0):
|
vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0):
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@700&display=swap" rel="stylesheet" />
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Caveat:wght@700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
<meta name="text-scale" content="scale" />
|
<meta name="text-scale" content="scale" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
73
frontend/src/lib/api.ts
Normal file
73
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { FileRecord } from '$lib/types';
|
||||||
|
|
||||||
|
export async function loadFiles(): Promise<FileRecord[]> {
|
||||||
|
const res = await fetch('/api/files', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = '/auth';
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load files: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(file: File): Promise<FileRecord> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const res = await fetch('/api/files', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Upload failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFile(fileId: number): Promise<Blob> {
|
||||||
|
const res = await fetch(`/api/files/${fileId}/download`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Download failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFile(fileId: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/files/${fileId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Delete failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submit(activeTab: string, username: string, password: string): Promise<void> {
|
||||||
|
const endpoint = activeTab === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||||
|
const res = await fetch(`${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Invalid credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||||
|
}
|
||||||
147
frontend/src/lib/components/FilePreview.svelte
Normal file
147
frontend/src/lib/components/FilePreview.svelte
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FileRecord } from '$lib/types';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
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();
|
||||||
|
|
||||||
|
const isVideo = $derived(file.fileType.includes('video'));
|
||||||
|
const isImage = $derived(file.fileType.includes('image'));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
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_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onclick={onclose}
|
||||||
|
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
|
||||||
|
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()}
|
||||||
|
>
|
||||||
|
{#key file.id}
|
||||||
|
{#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
|
||||||
|
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="pointer-events-auto cursor-pointer text-white/70 transition-colors hover:text-white"
|
||||||
|
onclick={onclose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<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="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
167
frontend/src/lib/components/FileRow.svelte
Normal file
167
frontend/src/lib/components/FileRow.svelte
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FileRecord } from '$lib/types';
|
||||||
|
import { formatName, formatSize } from '$lib/format';
|
||||||
|
|
||||||
|
let {
|
||||||
|
file,
|
||||||
|
selected,
|
||||||
|
onselect,
|
||||||
|
onpreview,
|
||||||
|
ondownload,
|
||||||
|
ondelete
|
||||||
|
}: {
|
||||||
|
file: FileRecord;
|
||||||
|
selected: boolean;
|
||||||
|
onselect: () => void;
|
||||||
|
onpreview: () => void;
|
||||||
|
ondownload: () => void;
|
||||||
|
ondelete: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let confirming = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onpreview();
|
||||||
|
} else if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onselect();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onclick={onselect}
|
||||||
|
ondblclick={onpreview}
|
||||||
|
class="flex items-stretch border-l transition-all hover:bg-white/2 {selected
|
||||||
|
? 'border-sky-400/50 bg-white/2 ring-1 ring-sky-400/50 ring-inset'
|
||||||
|
: 'border-sky-200/20 border-l-transparent hover:border-l-sky-400/50'}"
|
||||||
|
>
|
||||||
|
<span class="flex-1 truncate py-2 pl-6 text-sm font-medium text-white"
|
||||||
|
>{formatName(file.name)}</span
|
||||||
|
>
|
||||||
|
<span class="w-40 py-2 text-sm text-white/40">{formatSize(file.size)}</span>
|
||||||
|
<span class="w-48 py-2 text-sm text-white/40">{new Date(file.uploadedAt).toLocaleString()}</span
|
||||||
|
>
|
||||||
|
<div class="w-px self-stretch bg-sky-200/20"></div>
|
||||||
|
<div class="relative flex w-32 items-center justify-center gap-4">
|
||||||
|
<!-- Download -->
|
||||||
|
<button
|
||||||
|
class="cursor-pointer text-white/40 transition-all hover:scale-110 hover:text-emerald-400"
|
||||||
|
title="Download"
|
||||||
|
onclick={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>
|
||||||
|
<!-- TODO: Copy -->
|
||||||
|
<button
|
||||||
|
class="cursor-pointer text-white/40 transition-all hover:scale-110 hover:text-blue-400"
|
||||||
|
title="Copy link"
|
||||||
|
>
|
||||||
|
<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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Delete -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
class="cursor-pointer text-white/40 transition-all hover:scale-110 hover:text-red-400"
|
||||||
|
title="Delete"
|
||||||
|
onclick={() => (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 confirming}
|
||||||
|
<div class="absolute top-1/2 left-6 z-10 flex -translate-y-1/2 items-center">
|
||||||
|
<div
|
||||||
|
class="h-0 w-0 border-t-4 border-r-4 border-b-4 border-t-transparent border-r-sky-200/20 border-b-transparent"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 rounded border border-sky-200/20 bg-[#0f1117] px-3 py-2 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-white/40">Confirm action</span>
|
||||||
|
<button
|
||||||
|
class="cursor-pointer text-white/60 transition-colors hover:text-white"
|
||||||
|
title="Confirm"
|
||||||
|
onclick={() => {
|
||||||
|
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 text-white/60 transition-colors hover:text-white"
|
||||||
|
title="Cancel"
|
||||||
|
onclick={() => (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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
5
frontend/src/lib/components/ImagePreview.svelte
Normal file
5
frontend/src/lib/components/ImagePreview.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { src, alt }: { src: string; alt: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<img {src} {alt} class="block max-h-[85vh] max-w-[95vw] object-contain" />
|
||||||
213
frontend/src/lib/components/TileCard.svelte
Normal file
213
frontend/src/lib/components/TileCard.svelte
Normal 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>
|
||||||
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>
|
||||||
13
frontend/src/lib/format.ts
Normal file
13
frontend/src/lib/format.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function formatName(name: string): string {
|
||||||
|
if (name.length > 65) {
|
||||||
|
name = name.slice(0, 65).concat('...');
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1000) return bytes + 'B';
|
||||||
|
if (bytes < 1000 * 1000) return (bytes / 1000).toFixed(1) + 'KB';
|
||||||
|
if (bytes < 1000 * 1000 * 1000) return (bytes / 1000 / 1000).toFixed(1) + 'MB';
|
||||||
|
return (bytes / 1000 / 1000 / 1000).toFixed(1) + 'GB';
|
||||||
|
}
|
||||||
8
frontend/src/lib/types.ts
Normal file
8
frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type FileRecord = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
fileType: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
thumbnailStoragePath: string | null;
|
||||||
|
};
|
||||||
@@ -7,8 +7,7 @@
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
<main>{@render children()}</main>
|
<main>{@render children()}</main>
|
||||||
|
|
||||||
<footer>
|
<footer></footer>
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
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,70 +1,69 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { flip } from 'svelte/animate'
|
import { flip } from 'svelte/animate';
|
||||||
|
import type { FileRecord } from '$lib/types';
|
||||||
type FileRecord = {
|
import { deleteFile, downloadFile, loadFiles, logout, uploadFile } from '$lib/api';
|
||||||
id: number;
|
import FileRow from '$lib/components/FileRow.svelte';
|
||||||
name: string;
|
import TileCard from '$lib/components/TileCard.svelte';
|
||||||
size: number;
|
import FilePreview from '$lib/components/FilePreview.svelte';
|
||||||
uploadedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let fileRecords = $state<FileRecord[]>([]);
|
let fileRecords = $state<FileRecord[]>([]);
|
||||||
let confirmDeleteId = $state<number | null>(null);
|
|
||||||
let selectedFileId = $state<number | null>(null);
|
let selectedFileId = $state<number | null>(null);
|
||||||
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');
|
||||||
|
|
||||||
let filteredFileRecords = $derived(fileRecords.filter(f =>
|
function setViewMode(mode: 'rows' | 'tiles') {
|
||||||
f.name.toLowerCase().includes(search.toLowerCase())
|
viewMode = mode;
|
||||||
));
|
localStorage.setItem('viewMode', mode);
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
|
||||||
|
|
||||||
function openFilePicker() {
|
|
||||||
fileInput.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFiles() {
|
async function refresh() {
|
||||||
const res = await fetch('http://localhost:3000/api/files', {
|
fileRecords = await loadFiles();
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
|
||||||
window.location.href = '/auth';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fileRecords = await res.json();
|
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(loadFiles);
|
let filteredFileRecords = $derived(
|
||||||
|
fileRecords.filter((f) => f.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
async function uploadFile(event: Event) {
|
let previewIndex = $derived.by(() => {
|
||||||
const input = event.target as HTMLInputElement;
|
if (!previewFile) {
|
||||||
const file = input.files?.[0];
|
return -1;
|
||||||
|
}
|
||||||
if (!file) return;
|
const id = previewFile.id;
|
||||||
|
return filteredFileRecords.findIndex((f) => f.id === id);
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
await fetch('http://localhost:3000/api/files', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
body: formData
|
|
||||||
});
|
});
|
||||||
input.value = '';
|
let hasPrev = $derived(previewIndex > 0);
|
||||||
await loadFiles();
|
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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFile(fileId: number, fileName: string) {
|
let fileInput: HTMLInputElement;
|
||||||
const res = await fetch(`http://localhost:3000/api/files/${fileId}/download`, {
|
|
||||||
credentials: 'include'
|
async function handleUpload(event: Event) {
|
||||||
});
|
const input = event.target as HTMLInputElement;
|
||||||
const blob = await res.blob();
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
await uploadFile(file);
|
||||||
|
input.value = '';
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload(fileId: number, fileName: string) {
|
||||||
|
const blob = await downloadFile(fileId);
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -73,40 +72,34 @@
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyLink() {
|
async function handleDelete(fileId: number) {
|
||||||
await loadFiles();
|
await deleteFile(fileId);
|
||||||
//TODO: Generate sharable link with expiration date, add param fileId: number
|
await refresh();
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteFile(fileId: number) {
|
|
||||||
await fetch(`http://localhost:3000/api/files/${fileId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
await loadFiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatName(name: string): string {
|
|
||||||
if (name.length > 65) {
|
|
||||||
name = name.slice(0, 65).concat("...");
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
|
||||||
if (bytes < 1000) return bytes + 'B';
|
|
||||||
if (bytes < 1000 * 1000) return (bytes / 1000).toFixed(1) + 'KB';
|
|
||||||
if (bytes < 1000 * 1000 * 1000) return (bytes / 1000 / 1000).toFixed(1) + 'MB';
|
|
||||||
return (bytes / 1000 / 1000 / 1000).toFixed(1) + 'GB';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch('http://localhost:3000/api/auth/logout', {
|
await logout();
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
window.location.href = '/auth';
|
window.location.href = '/auth';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const pending = fileRecords.some(
|
||||||
|
(f) => f.fileType.includes('image') && !f.thumbnailStoragePath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = setInterval(refresh, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
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">
|
||||||
@@ -114,8 +107,18 @@
|
|||||||
<h1 class="text-4xl text-white" style="font-family: 'Caveat', cursive;">loft</h1>
|
<h1 class="text-4xl text-white" style="font-family: 'Caveat', cursive;">loft</h1>
|
||||||
<button
|
<button
|
||||||
onclick={handleLogout}
|
onclick={handleLogout}
|
||||||
class="fixed top-4 right-4 flex items-center gap-1.5 px-2 py-1 border border-sky-200/20 text-white/60 hover:border-sky-200/40 text-sm transition-colors rounded-sm cursor-pointer">
|
class="fixed top-4 right-4 flex cursor-pointer items-center gap-1.5 rounded-sm border border-sky-200/20 px-2 py-1 text-sm text-white/60 transition-colors hover:border-sky-200/40"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
<polyline points="16 17 21 12 16 7" />
|
<polyline points="16 17 21 12 16 7" />
|
||||||
<line x1="21" y1="12" x2="9" y2="12" />
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
@@ -124,92 +127,97 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center mb-4">
|
<div class="mb-4 flex justify-center">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search files..."
|
placeholder="Search files..."
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
class="w-96 px-4 py-1.5 rounded-xl bg-white/5 border border-sky-200/20 transition-colors hover:border-sky-200/40 text-white placeholder-white/30 text-sm focus:outline-none focus:ring-1 focus:ring-white/10"
|
class="w-96 rounded-xl border border-sky-200/20 bg-white/5 px-4 py-1.5 text-sm text-white placeholder-white/30 transition-colors hover:border-sky-200/40 focus:ring-1 focus:ring-white/10 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="file" bind:this={fileInput} onchange={uploadFile} 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 class="px-2 py-1 border border-sky-200/20 text-white/60 hover:border-sky-200/40 text-sm transition-colors rounded-sm cursor-pointer" onclick={openFilePicker}>
|
<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"
|
||||||
|
onclick={() => fileInput.click()}
|
||||||
|
>
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
|
<div class="flex gap-0.5 rounded-sm border border-sky-200/20 p-0.5">
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded-sm px-1.5 py-1 transition-colors {viewMode === 'rows'
|
||||||
|
? 'bg-white/10 text-white'
|
||||||
|
: 'text-white/40 hover:text-white/70'}"
|
||||||
|
title="List view"
|
||||||
|
aria-label="List view"
|
||||||
|
onclick={() => setViewMode('rows')}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{#if viewMode === 'rows'}
|
||||||
<div class="rounded-sm border border-sky-200/20">
|
<div class="rounded-sm border border-sky-200/20">
|
||||||
<!-- Header row -->
|
|
||||||
<div class="flex items-stretch border-b border-sky-200/20 bg-white/5">
|
<div class="flex items-stretch border-b border-sky-200/20 bg-white/5">
|
||||||
<span class="font-medium text-white/40 text-sm flex-1 py-2 pl-6">Name</span>
|
<span class="flex-1 py-2 pl-6 text-sm font-medium text-white/40">Name</span>
|
||||||
<span class="text-sm text-white/40 w-40 py-2">Size</span>
|
<span class="w-40 py-2 text-sm text-white/40">Size</span>
|
||||||
<span class="text-sm text-white/40 w-48 py-2">Uploaded at</span>
|
<span class="w-48 py-2 text-sm text-white/40">Uploaded at</span>
|
||||||
<div class="w-px bg-sky-200/20 self-stretch"></div>
|
<div class="w-px self-stretch bg-sky-200/20"></div>
|
||||||
<span class="text-sm text-white/40 w-32 text-center py-2">Actions</span>
|
<span class="w-32 py-2 text-center text-sm text-white/40">Actions</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each filteredFileRecords as fileRecord (fileRecord.id)}
|
{#each filteredFileRecords as fileRecord (fileRecord.id)}
|
||||||
<div
|
<div transition:fade={{ duration: 200 }} animate:flip={{ duration: 200 }}>
|
||||||
transition:fade={{ duration: 200 }}
|
<FileRow
|
||||||
animate:flip={{ duration: 200 }}
|
file={fileRecord}
|
||||||
onclick={() => selectedFileId = fileRecord.id}
|
selected={selectedFileId === fileRecord.id}
|
||||||
ondblclick={() => previewFile = fileRecord}
|
onselect={() => (selectedFileId = fileRecord.id)}
|
||||||
class="flex items-stretch border-l hover:bg-white/2 transition-all {selectedFileId === fileRecord.id ? 'border-sky-400/50 bg-white/2 ring-1 ring-inset ring-sky-400/50' : 'border-sky-200/20 border-l-transparent hover:border-l-sky-400/50'}"
|
onpreview={() => (previewFile = fileRecord)}
|
||||||
>
|
ondownload={() => handleDownload(fileRecord.id, fileRecord.name)}
|
||||||
<span class="font-medium text-white text-sm flex-1 truncate py-2 pl-6">{formatName(fileRecord.name)}</span>
|
ondelete={() => handleDelete(fileRecord.id)}
|
||||||
<span class="text-sm text-white/40 w-40 py-2">{formatSize(fileRecord.size)}</span>
|
/>
|
||||||
<span class="text-sm text-white/40 w-48 py-2">{new Date(fileRecord.uploadedAt).toLocaleString()}</span>
|
|
||||||
<div class="w-px bg-sky-200/20 self-stretch"></div>
|
|
||||||
<div class="flex items-center gap-4 w-32 justify-center relative">
|
|
||||||
<!-- Download -->
|
|
||||||
<button class="text-white/40 hover:text-emerald-400 hover:scale-110 transition-all cursor-pointer" title="Download" onclick={() => downloadFile(fileRecord.id, fileRecord.name)}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-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>
|
|
||||||
<!-- TODO: Copy -->
|
|
||||||
<button class="text-white/40 hover:text-blue-400 hover:scale-110 transition-all cursor-pointer" title="Copy link" onclick={() => copyLink()}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<!-- Delete -->
|
|
||||||
<div class="relative">
|
|
||||||
<button class="text-white/40 hover:text-red-400 hover:scale-110 transition-all cursor-pointer" title="Delete" onclick={() => confirmDeleteId = fileRecord.id}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-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 confirmDeleteId === fileRecord.id}
|
|
||||||
<div class="absolute left-6 top-1/2 -translate-y-1/2 flex items-center z-10">
|
|
||||||
<div class="w-0 h-0 border-t-4 border-b-4 border-r-4 border-t-transparent border-b-transparent border-r-sky-200/20"></div>
|
|
||||||
<div class="bg-[#0f1117] border border-sky-200/20 rounded px-3 py-2 flex items-center gap-2 whitespace-nowrap">
|
|
||||||
<span class="text-white/40 text-xs">Confirm action</span>
|
|
||||||
<button class="text-white/60 hover:text-white transition-colors cursor-pointer" title="Confirm" onclick={() => { deleteFile(fileRecord.id); confirmDeleteId = null; }}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-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="text-white/60 hover:text-white transition-colors cursor-pointer" title="Cancel" onclick={() => confirmDeleteId = null}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if !loading}
|
{#if !loading}
|
||||||
@@ -217,30 +225,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
||||||
<div
|
<FilePreview
|
||||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
file={previewFile}
|
||||||
onclick={() => previewFile = null}
|
onclose={() => (previewFile = null)}
|
||||||
transition:fade={{ duration: 150 }}
|
onprev={goPrev}
|
||||||
>
|
onnext={goNext}
|
||||||
<div
|
{hasPrev}
|
||||||
class="bg-[#0f1117] border border-sky-200/20 rounded-sm w-2/3 h-2/3 flex flex-col p-6"
|
{hasNext}
|
||||||
onclick={(e) => e.stopPropagation()}
|
/>
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<span class="text-white text-sm font-medium">{previewFile.name}</span>
|
|
||||||
<button class="text-white/40 hover:text-white transition-colors cursor-pointer" onclick={() => previewFile = null}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 flex items-center justify-center text-white/20 text-sm">
|
|
||||||
preview coming soon
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,42 +1,41 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { submit } from '$lib/api';
|
||||||
|
|
||||||
let activeTab = $state<'login' | 'register'>('login');
|
let activeTab = $state<'login' | 'register'>('login');
|
||||||
let username = $state('');
|
let username = $state('');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const endpoint = activeTab === 'login' ? '/api/auth/login' : '/api/auth/register';
|
try {
|
||||||
const res = await fetch(`http://localhost:3000${endpoint}`, {
|
await submit(activeTab, username, password);
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username, password })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
} else {
|
} catch {
|
||||||
error = 'Invalid credentials';
|
error = 'Invalid credentials';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex-1 flex items-center justify-center -mt-16">
|
<div class="-mt-16 flex flex-1 items-center justify-center">
|
||||||
<div class="w-full max-w-xs">
|
<div class="w-full max-w-xs">
|
||||||
<h1 class="text-4xl text-white mb-8" style="font-family: 'Caveat', cursive;">loft</h1>
|
<h1 class="mb-8 text-4xl text-white" style="font-family: 'Caveat', cursive;">loft</h1>
|
||||||
|
|
||||||
<div class="flex mb-6 border-b border-sky-200/20">
|
<div class="mb-6 flex border-b border-sky-200/20">
|
||||||
<button
|
<button
|
||||||
onclick={() => activeTab = 'login'}
|
data-testid="auth-tab-login"
|
||||||
class="flex-1 py-2 text-sm transition-colors cursor-pointer {activeTab === 'login' ? 'text-white border-b border-white -mb-px' : 'text-white/40 hover:text-white/60'}"
|
onclick={() => (activeTab = 'login')}
|
||||||
|
class="flex-1 cursor-pointer py-2 text-sm transition-colors {activeTab === 'login'
|
||||||
|
? '-mb-px border-b border-white text-white'
|
||||||
|
: 'text-white/40 hover:text-white/60'}"
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => activeTab = 'register'}
|
data-testid="auth-tab-register"
|
||||||
class="flex-1 py-2 text-sm transition-colors cursor-pointer {activeTab === 'register' ? 'text-white border-b border-white -mb-px' : 'text-white/40 hover:text-white/60'}"
|
onclick={() => (activeTab = 'register')}
|
||||||
|
class="flex-1 cursor-pointer py-2 text-sm transition-colors {activeTab === 'register'
|
||||||
|
? '-mb-px border-b border-white text-white'
|
||||||
|
: 'text-white/40 hover:text-white/60'}"
|
||||||
>
|
>
|
||||||
Register
|
Register
|
||||||
</button>
|
</button>
|
||||||
@@ -44,29 +43,31 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="username" class="text-white/30 text-xs">Username</label>
|
<label for="username" class="text-xs text-white/30">Username</label>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
class="px-4 py-2 rounded-sm !bg-[#0f1117] border border-sky-200/15 transition-colors hover:bg-black/20 hover:border-sky-200/30 text-white/80 text-sm focus:outline-none focus:border-sky-200/40"
|
class="rounded-sm border border-sky-200/15 bg-[#0f1117]! px-4 py-2 text-sm text-white/80 transition-colors hover:border-sky-200/30 hover:bg-black/20 focus:border-sky-200/40 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="password" class="text-white/30 text-xs">Password</label>
|
<label for="password" class="text-xs text-white/30">Password</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
class="px-4 py-2 rounded-sm !bg-[#0f1117] border border-sky-200/15 transition-colors hover:bg-black/20 hover:border-sky-200/30 text-white/80 text-sm focus:outline-none focus:border-sky-200/40"
|
class="rounded-sm border border-sky-200/15 bg-[#0f1117]! px-4 py-2 text-sm text-white/80 transition-colors hover:border-sky-200/30 hover:bg-black/20 focus:border-sky-200/40 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if error}<p class="text-red-400/60 text-xs">{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 px-4 py-2 border border-sky-200/15 text-white/40 hover:border-sky-200/30 hover:text-white/60 text-sm transition-colors rounded-sm cursor-pointer">
|
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"
|
||||||
|
>
|
||||||
{activeTab === 'login' ? 'Login' : 'Register'}
|
{activeTab === 'login' ? 'Login' : 'Register'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@plugin '@tailwindcss/forms';
|
|
||||||
@plugin '@tailwindcss/typography';
|
|
||||||
@import '@fontsource/fira-mono';
|
@import '@fontsource/fira-mono';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -13,6 +11,8 @@
|
|||||||
--column-margin-top: 4rem;
|
--column-margin-top: 4rem;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
--plyr-color-main: var(--color-sky-400);
|
||||||
|
--plyr-video-background: #0f1117;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import adapter from '@sveltejs/adapter-auto';
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
kit: {
|
kit: {
|
||||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
adapter: adapter({ fallback: 'index.html' })
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000'
|
||||||
|
}
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
expect: { requireAssertions: true },
|
expect: { requireAssertions: true },
|
||||||
projects: [
|
projects: [
|
||||||
|
|||||||
106
justfile
Normal file
106
justfile
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
backend_path := "backend"
|
||||||
|
frontend_path := "frontend"
|
||||||
|
|
||||||
|
# Run backend + frontend watchers in split tmux panes (run from inside tmux)
|
||||||
|
[group('dev')]
|
||||||
|
watch:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
tmux split-window -h 'just watch-frontend'
|
||||||
|
just watch-backend
|
||||||
|
|
||||||
|
# Run the backend with hot reload
|
||||||
|
[group('dev')]
|
||||||
|
watch-backend:
|
||||||
|
cd {{backend_path}} && cargo watch -q -c -w src/ -x run
|
||||||
|
|
||||||
|
# Run the frontend dev server
|
||||||
|
[group('dev')]
|
||||||
|
watch-frontend:
|
||||||
|
cd {{frontend_path}} && pnpm dev
|
||||||
|
|
||||||
|
# Run the backend tests in watch mode (sets up the test DB first)
|
||||||
|
[group('test')]
|
||||||
|
watch-test: test-db-setup
|
||||||
|
cd {{backend_path}} && cargo watch -q -c -w src/ -x test
|
||||||
|
|
||||||
|
# Run the Playwright e2e suite (sets up the test DB + servers)
|
||||||
|
[group('test')]
|
||||||
|
e2e:
|
||||||
|
mkdir -p /tmp/loft-files-test
|
||||||
|
cd {{backend_path}} && set -a && . ./.env.test && set +a && (sqlx database create 2>/dev/null || true) && sqlx migrate run
|
||||||
|
docker compose exec -T db psql -U postgres -d loft_test -c "TRUNCATE users, sessions, file_records CASCADE"
|
||||||
|
cd {{frontend_path}} && pnpm exec playwright test
|
||||||
|
|
||||||
|
# Format backend + frontend
|
||||||
|
[group('quality')]
|
||||||
|
fmt: fmt-backend fmt-frontend
|
||||||
|
|
||||||
|
# Format the backend (cargo fmt)
|
||||||
|
[group('quality')]
|
||||||
|
fmt-backend:
|
||||||
|
cd {{backend_path}} && cargo fmt
|
||||||
|
|
||||||
|
# Format the frontend (prettier)
|
||||||
|
[group('quality')]
|
||||||
|
fmt-frontend:
|
||||||
|
cd {{frontend_path}} && pnpm exec prettier --write .
|
||||||
|
|
||||||
|
# Lint the backend (cargo clippy)
|
||||||
|
[group('quality')]
|
||||||
|
lint-backend:
|
||||||
|
cd {{backend_path}} && cargo clippy
|
||||||
|
|
||||||
|
# Stage the given files (git add)
|
||||||
|
[group('git')]
|
||||||
|
stage +files:
|
||||||
|
git add {{files}}
|
||||||
|
|
||||||
|
# Commit staged changes with a message
|
||||||
|
[group('git')]
|
||||||
|
commit message:
|
||||||
|
git commit -m "{{message}}"
|
||||||
|
|
||||||
|
# Commit staged changes with a Claude co-author trailer
|
||||||
|
[group('git')]
|
||||||
|
co-commit message:
|
||||||
|
git commit -m "{{message}}" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
|
||||||
|
# Push main to both the github and gitea remotes
|
||||||
|
[group('git')]
|
||||||
|
push:
|
||||||
|
git push -u github main && git push -u gitea main
|
||||||
|
|
||||||
|
# Create (if missing) + migrate the test database
|
||||||
|
[group('db')]
|
||||||
|
test-db-setup: db-up
|
||||||
|
cd {{backend_path}} && set -a && . ./.env.test && set +a && (sqlx database create 2>/dev/null || true) && sqlx migrate run
|
||||||
|
|
||||||
|
# Bring up the dev database container
|
||||||
|
[group('db')]
|
||||||
|
db-up:
|
||||||
|
docker compose up -d db
|
||||||
|
|
||||||
|
# Create (if missing) + migrate the dev database
|
||||||
|
[group('db')]
|
||||||
|
db-setup: db-up
|
||||||
|
cd {{backend_path}} && set -a && . ./.env && set +a && (sqlx database create 2>/dev/null || true) && sqlx migrate run
|
||||||
|
|
||||||
|
# Apply pending migrations to the dev database
|
||||||
|
[group('db')]
|
||||||
|
migrate:
|
||||||
|
cd {{backend_path}} && set -a && . ./.env && set +a && sqlx migrate run
|
||||||
|
|
||||||
|
# Open a psql shell on the dev database
|
||||||
|
[group('db')]
|
||||||
|
db-shell:
|
||||||
|
docker compose exec db psql -U postgres -d loft
|
||||||
|
|
||||||
|
# Run a SQL query against the dev database
|
||||||
|
[group('db')]
|
||||||
|
db-query query:
|
||||||
|
docker compose exec db psql -U postgres -d loft -c "{{query}}"
|
||||||
|
|
||||||
|
# Open a shell inside the app container
|
||||||
|
[group('db')]
|
||||||
|
app-shell:
|
||||||
|
docker compose exec app sh
|
||||||
Reference in New Issue
Block a user