show image preview + single file downloads

This commit is contained in:
2025-08-10 23:16:18 +02:00
parent bfe73eefb9
commit c8b52a5b3b
13 changed files with 174 additions and 26 deletions

12
backend/Cargo.lock generated
View File

@@ -144,6 +144,7 @@ dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"fastrand",
"futures-util",
"http",
@@ -292,6 +293,17 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"

View File

@@ -15,7 +15,7 @@ path = "src/bin/backend/main.rs"
anyhow = "1.0.98"
argon2 = "0.5.3"
axum = { version = "0.8.4", features = ["multipart", "query"] }
axum-extra = { version = "0.10.1", features = ["multipart"] }
axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] }
base64 = "0.22.1"
bytes = "1.10.1"
chrono = "0.4.41"

View File

@@ -2,6 +2,7 @@ use axum::{
extract::FromRequestParts,
http::{header::AUTHORIZATION, request::Parts},
};
use axum_extra::extract::CookieJar;
use crate::{
domain::warren::models::auth_session::AuthSessionIdWithType, inbound::http::responses::ApiError,
@@ -15,21 +16,38 @@ where
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let Some(header) = parts.headers.get(AUTHORIZATION) else {
return Err(ApiError::Unauthorized(
"Missing authorization header".to_string(),
));
};
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
// Release build
if !cfg!(debug_assertions) {
let jar = CookieJar::from_request_parts(parts, state).await.unwrap();
let header_str = header.to_str().map_err(|_| {
ApiError::InternalServerError(
"Failed to get authorization header as string".to_string(),
)
})?;
let Some(cookie) = jar.get(AUTHORIZATION.as_str()) else {
return Err(ApiError::Unauthorized(
"Missing authorization cookie".to_string(),
));
};
AuthSessionIdWithType::from_str(header_str)
.map(|session_id| SessionIdHeader(session_id))
.map_err(|_| ApiError::BadRequest("Invalid session id".to_string()))
AuthSessionIdWithType::from_str(cookie.value())
.map(|session_id| SessionIdHeader(session_id))
.map_err(|_| ApiError::BadRequest("Invalid session id".to_string()))
}
// Debug build
else {
let Some(header) = parts.headers.get(AUTHORIZATION) else {
return Err(ApiError::Unauthorized(
"Missing authorization header".to_string(),
));
};
let header_str = header.to_str().map_err(|_| {
ApiError::InternalServerError(
"Failed to get authorization header as string".to_string(),
)
})?;
AuthSessionIdWithType::from_str(header_str)
.map(|session_id| SessionIdHeader(session_id))
.map_err(|_| ApiError::BadRequest("Invalid session id".to_string()))
}
}
}

View File

@@ -18,6 +18,7 @@ services:
- 'SERVE_DIRECTORY=/serve'
- 'CORS_ALLOW_ORIGIN=http://localhost:8081'
- 'LOG_LEVEL=debug'
- 'MAX_FILE_FETCH_BYTES=10737418240'
volumes:
- './backend/serve:/serve:rw'
postgres:

View File

@@ -1,5 +1,5 @@
# this file is ignored when the app is built since we're using SSG
NUXT_PUBLIC_API_BASE="http://127.0.0.1:8080/api"
NUXT_COOKIES_SECURE="false"
NUXT_COOKIES_SAME_SITE="strict"
NUXT_PUBLIC_COOKIES_SECURE="false"
NUXT_PUBLIC_COOKIES_SAME_SITE="strict"

View File

@@ -12,6 +12,7 @@ import {
fetchFile,
} from '~/lib/api/warrens';
import type { DirectoryEntry } from '#shared/types';
import { toast } from 'vue-sonner';
const warrenStore = useWarrenStore();
const copyStore = useCopyStore();
@@ -110,6 +111,30 @@ function onCopy() {
entry.name
);
}
async function onDownload() {
if (warrenStore.current == null) {
return;
}
if (entry.fileType !== 'file') {
toast.warning('Download', {
description: 'Directory downloads are not supported yet',
});
return;
}
const anchor = document.createElement('a');
anchor.download = entry.name;
anchor.href = getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
);
anchor.rel = 'noopener';
anchor.target = '_blank';
anchor.click();
}
</script>
<template>
@@ -128,6 +153,11 @@ function onCopy() {
>
<div class="flex flex-row items-center">
<Icon
v-if="
entry.fileType !== 'file' ||
entry.mimeType == null ||
!entry.mimeType.startsWith('image/')
"
class="size-6"
:name="
entry.fileType === 'file'
@@ -135,6 +165,23 @@ function onCopy() {
: 'lucide:folder'
"
/>
<object
v-else
:type="entry.mimeType"
class="size-6 object-cover"
width="24"
height="24"
:data="
getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current!.warrenId}&path=${joinPaths(warrenStore.current!.path, entry.name)}`
)
"
>
<Icon
class="size-6"
:name="getFileIcon(entry.mimeType)"
/>
</object>
</div>
<div
@@ -159,6 +206,13 @@ function onCopy() {
<Icon name="lucide:copy" />
Copy
</ContextMenuItem>
<ContextMenuItem
:disabled="entry.fileType !== 'file'"
@select="onDownload"
>
<Icon name="lucide:download" />
Download
</ContextMenuItem>
<ContextMenuSeparator />

View File

@@ -1,4 +1,4 @@
import type { AuthSession } from '#shared/types/auth';
import type { AuthSession, AuthUser } from '#shared/types/auth';
const SAME_SITE_SETTINGS = ['strict', 'lax', 'none'] as const;
@@ -12,9 +12,36 @@ export function useAuthSession() {
return useCookie('auth_session', {
default: () => null as AuthSession | null,
secure: config.cookiesSecure.toLowerCase() === 'true',
secure: config.cookiesSecure,
sameSite,
path: '/',
httpOnly: false,
});
}
export function setAuthSession(value: {
type: 'WarrenAuth';
id: string;
user: AuthUser;
expiresAt: number;
}) {
useAuthSession().value = value;
let cookie = `authorization=WarrenAuth ${value.id}; path=/; SameSite=Lax; Secure;`;
const config = useRuntimeConfig().public;
console.log('config', config);
const cookieDomain = config.authCookieDomain;
if (cookieDomain != null && cookieDomain.length > 0) {
cookie += ` domain=${cookieDomain}`;
}
console.log(cookie);
document.cookie = cookie;
}
export function clearAuthSession() {
useAuthSession().value = null;
document.cookie = '';
}

View File

@@ -32,12 +32,12 @@ export async function loginUser(
const token = data.value.data.token;
const { user, expiresAt } = data.value.data;
useAuthSession().value = {
setAuthSession({
type: 'WarrenAuth',
id: token,
user,
expiresAt,
};
});
toast.success('Login', {
description: `Successfully logged in`,

View File

@@ -1,5 +1,5 @@
export async function logout() {
useAuthSession().value = null;
clearAuthSession();
useAdminStore().$reset();
useWarrenStore().$reset();
await navigateTo({

View File

@@ -57,12 +57,12 @@ export async function oidcLoginUser(
const token = data.value.data.token;
const { user, expiresAt } = data.value.data;
useAuthSession().value = {
setAuthSession({
type: 'WarrenAuth',
id: token,
user,
expiresAt,
};
});
toast.success('OpenID Connect', {
description: `Successfully logged in`,

View File

@@ -9,7 +9,9 @@ export function getAuthHeader(): ['authorization', string] | null {
export function getApiHeaders(
includeAuth: boolean = true
): Record<string, string> {
const headers: Record<string, string> = {};
const headers: Record<string, string> = {
cookie: document.cookie,
};
if (includeAuth) {
const header = getAuthHeader();

View File

@@ -301,6 +301,40 @@ export async function fetchFile(
};
}
export async function fetchFileStream(
warrenId: string,
path: string,
fileName: string
): Promise<
{ success: true; stream: ReadableStream<Uint8Array> } | { success: false }
> {
if (!path.endsWith('/')) {
path += '/';
}
path += fileName;
const { data, error } = await useFetch<ReadableStream<Uint8Array>>(
getApiUrl(`warrens/files/cat?warrenId=${warrenId}&path=${path}`),
{
method: 'GET',
headers: getApiHeaders(),
responseType: 'stream',
cache: 'default',
}
);
if (data.value == null || error.value != null) {
return {
success: false,
};
}
return {
success: true,
stream: data.value,
};
}
export async function moveFile(
warrenId: string,
currentPath: string,

View File

@@ -58,7 +58,7 @@ export default defineNuxtConfig({
runtimeConfig: {
public: {
apiBase: '/api',
cookiesSecure: 'false',
cookiesSecure: false,
cookiesSameSite: 'strict',
},
},