Compare commits

...

4 Commits

6 changed files with 366 additions and 71 deletions

View File

@@ -11,7 +11,6 @@ use axum::{
extract::DefaultBodyLimit,
http::{HeaderValue, Method, header},
middleware,
response::Response,
};
use sqlx::PgPool;
use tower_cookies::CookieManagerLayer;
@@ -49,6 +48,19 @@ async fn main() -> Result<()> {
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
const BODY_LIMIT: usize = 1000 * 1000 * 1000 * 5;
let pool = PgPool::connect(&database_url).await?;
sqlx::migrate!().run(&pool).await?;
let file_repository = FileRepository::new(pool.clone())?;
let routes_file = routes_file(file_repository.clone())
.route_layer(middleware::from_fn(mw_require_auth))
.layer(DefaultBodyLimit::max(BODY_LIMIT));
let user_repository = UserRepository::new(pool);
let mut routes_auth = routes_auth(user_repository.clone());
if std::env::var("ENVIRONMENT").unwrap_or_default() != "test" {
let governor_conf_auth = GovernorConfigBuilder::default()
.per_second(4)
.burst_size(2)
@@ -69,25 +81,14 @@ async fn main() -> Result<()> {
}
});
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
const BODY_LIMIT: usize = 1000 * 1000 * 1000 * 5;
let pool = PgPool::connect(&database_url).await?;
sqlx::migrate!().run(&pool).await?;
let file_repository = FileRepository::new(pool.clone())?;
let routes_file = routes_file(file_repository.clone())
.route_layer(middleware::from_fn(mw_require_auth))
.layer(DefaultBodyLimit::max(BODY_LIMIT));
let user_repository = UserRepository::new(pool);
let routes_auth =
routes_auth(user_repository.clone()).layer(GovernorLayer::new(governor_conf_auth));
routes_auth = routes_auth.layer(GovernorLayer::new(governor_conf_auth));
}
let app = Router::new()
.nest("/api", routes_file)
.nest("/api/auth", routes_auth)
.merge(routes_health())
.layer(TraceLayer::new_for_http())
.layer(middleware::map_response(main_response_mapper))
.layer(middleware::from_fn_with_state(
user_repository,
mw_ctx_resolver,
@@ -115,8 +116,3 @@ async fn main() -> Result<()> {
Ok(())
}
async fn main_response_mapper(res: Response) -> Response {
info!("response mapper: {}", res.status());
res
}

View File

@@ -5,13 +5,14 @@ use axum::{
};
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(
ctx: Result<Ctx, LoftError>,
req: Request,
next: Next,
) -> Result<Response, LoftError> {
pub async fn mw_require_auth(ctx: Result<Ctx>, req: Request, next: Next) -> Result<Response> {
ctx?;
Ok(next.run(req).await)
@@ -22,7 +23,7 @@ pub async fn mw_ctx_resolver(
cookies: Cookies,
mut req: Request,
next: Next,
) -> Result<Response, LoftError> {
) -> Result<Response> {
let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string());
let result_ctx = match auth_token {
@@ -46,7 +47,7 @@ impl<S: Send + Sync> FromRequestParts<S> for Ctx {
) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<Result<Ctx, LoftError>>()
.get::<Result<Ctx>>()
.ok_or(LoftError::AuthFailCtxNotInRequestExt)?
.clone()
}

View File

@@ -27,7 +27,7 @@ async fn upload_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
mut multipart: Multipart,
) -> Result<Json<FileRecord>, LoftError> {
) -> Result<Json<FileRecord>> {
let mut uploaded: Option<(String, String, usize)> = None;
while let Some(field) = multipart.next_field().await? {
@@ -52,7 +52,7 @@ async fn get_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
Path(file_id): Path<u64>,
) -> Result<Json<FileRecord>, LoftError> {
) -> Result<Json<FileRecord>> {
let record = file_repository
.get_file(file_id as i64, ctx.user_id())
.await?;
@@ -121,7 +121,7 @@ async fn delete_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
Path(file_id): Path<u64>,
) -> Result<Json<FileRecord>, LoftError> {
) -> Result<Json<FileRecord>> {
let file = file_repository
.delete_file(file_id as i64, ctx.user_id())
.await?;
@@ -131,7 +131,7 @@ async fn delete_file(
async fn list_files(
State(file_repository): State<FileRepository>,
ctx: Ctx,
) -> Result<Json<Vec<FileRecord>>, LoftError> {
) -> Result<Json<Vec<FileRecord>>> {
let files = file_repository.list_files(ctx.user_id()).await?;
Ok(Json(files))
}

View File

@@ -26,7 +26,7 @@ async fn login(
State(user_repository): State<UserRepository>,
cookies: Cookies,
Json(payload): Json<LoginPayload>,
) -> Result<StatusCode, LoftError> {
) -> Result<StatusCode> {
let user = user_repository
.find_by_username(&payload.username)
.await?
@@ -53,7 +53,7 @@ async fn login(
async fn logout(
State(user_repository): State<UserRepository>,
cookies: Cookies,
) -> Result<StatusCode, LoftError> {
) -> Result<StatusCode> {
let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string());
if let Some(auth_token) = auth_token {
@@ -67,7 +67,7 @@ async fn logout(
async fn register(
State(user_repository): State<UserRepository>,
Json(payload): Json<LoginPayload>,
) -> Result<StatusCode, LoftError> {
) -> Result<StatusCode> {
if user_repository
.find_by_username(&payload.username)
.await?

View File

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

View File

@@ -5,6 +5,7 @@
import type { FileRecord } from '$lib/types';
import { deleteFile, downloadFile, loadFiles, logout, uploadFile } from '$lib/api';
import FileRow from '$lib/components/FileRow.svelte';
import TileCard from '$lib/components/TileCard.svelte';
import FilePreview from '$lib/components/FilePreview.svelte';
let fileRecords = $state<FileRecord[]>([]);
@@ -12,6 +13,12 @@
let previewFile = $state<FileRecord | null>(null);
let search = $state('');
let loading = $state(true);
let viewMode = $state<'rows' | 'tiles'>('rows');
function setViewMode(mode: 'rows' | 'tiles') {
viewMode = mode;
localStorage.setItem('viewMode', mode);
}
async function refresh() {
fileRecords = await loadFiles();
@@ -54,7 +61,11 @@
window.location.href = '/auth';
}
onMount(refresh);
onMount(() => {
const saved = localStorage.getItem('viewMode');
if (saved === 'rows' || saved === 'tiles') viewMode = saved;
refresh();
});
</script>
<div class="w-full px-4 py-8">
@@ -92,17 +103,69 @@
</div>
<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="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
</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>
{#if viewMode === 'rows'}
<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">
<span class="flex-1 py-2 pl-6 text-sm font-medium text-white/40">Name</span>
<span class="w-40 py-2 text-sm text-white/40">Size</span>
@@ -123,9 +186,31 @@
/>
</div>
{:else}
{#if !loading}<p class="py-2 px-4 text-white/30 text-sm">No files yet.</p>{/if}
{#if !loading}
<p class="py-2 px-4 text-white/30 text-sm">No files yet.</p>
{/if}
{/each}
</div>
{:else}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each filteredFileRecords as fileRecord (fileRecord.id)}
<div transition:fade={{ duration: 200 }} animate:flip={{ duration: 200 }}>
<TileCard
file={fileRecord}
selected={selectedFileId === fileRecord.id}
onselect={() => (selectedFileId = fileRecord.id)}
onpreview={() => (previewFile = fileRecord)}
ondownload={() => handleDownload(fileRecord.id, fileRecord.name)}
ondelete={() => handleDelete(fileRecord.id)}
/>
</div>
{:else}
{#if !loading}
<p class="col-span-full px-4 py-2 text-sm text-white/30">No files yet.</p>
{/if}
{/each}
</div>
{/if}
</div>
{#if previewFile}