Compare commits

...

2 Commits

Author SHA1 Message Date
user
1310ff3220 now files do presigned url setup 2026-03-02 21:40:02 +02:00
user
34aa52ec7c well yuh 2026-03-02 21:26:47 +02:00
10 changed files with 245 additions and 10 deletions

View File

@@ -51,6 +51,10 @@ const deleteFileInputSchema = v.object({
fileId: v.string(),
});
const getFileAccessUrlInputSchema = v.object({
fileId: v.string(),
});
export const deleteFileSC = command(deleteFileInputSchema, async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
@@ -64,6 +68,22 @@ export const deleteFileSC = command(deleteFileInputSchema, async (input) => {
: { data: null, error: res.error };
});
export const getFileAccessUrlSQ = query(
getFileAccessUrlInputSchema,
async (input) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) {
return unauthorized(fctx);
}
const res = await fc.getFileAccessUrl(fctx, input.fileId, fctx.userId, 3600);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const cleanupDanglingFilesSC = command(v.object({}), async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);

View File

@@ -1,5 +1,10 @@
import type { File } from "@pkg/logic/domains/files/data";
import { cleanupDanglingFilesSC, deleteFileSC, getFilesSQ } from "./files.remote";
import {
cleanupDanglingFilesSC,
deleteFileSC,
getFileAccessUrlSQ,
getFilesSQ,
} from "./files.remote";
import { toast } from "svelte-sonner";
class FilesViewModel {
@@ -104,6 +109,25 @@ class FilesViewModel {
this.cleanupLoading = false;
}
}
async openFile(fileId: string) {
try {
const result = await getFileAccessUrlSQ({ fileId });
if (result?.error || !result?.data?.url) {
toast.error(result?.error?.message || "Failed to open file", {
description: result?.error?.description || "Please try again later",
});
return;
}
window.open(result.data.url, "_blank", "noopener,noreferrer");
} catch (error) {
toast.error("Failed to open file", {
description:
error instanceof Error ? error.message : "Please try again later",
});
}
}
}
export const filesVM = new FilesViewModel();

View File

@@ -1,4 +1,5 @@
import { getMobileController } from "@pkg/logic/domains/mobile/controller";
import { getFileController } from "@pkg/logic/domains/files/controller";
import {
mobilePaginationSchema,
listMobileDeviceMediaFiltersSchema,
@@ -12,6 +13,7 @@ import { command, getRequestEvent, query } from "$app/server";
import * as v from "valibot";
const mc = getMobileController();
const fc = getFileController();
const getDevicesInputSchema = v.object({
search: v.optional(v.string()),
@@ -104,9 +106,34 @@ export const getDeviceMediaSQ = query(
});
const res = await mc.listDeviceMedia(fctx, filters, input.pagination);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
if (res.isErr()) {
return { data: null, error: res.error };
}
const rows = res.value.data;
const withAccessUrls = await Promise.all(
rows.map(async (row) => {
const access = await fc.getFileAccessUrl(
fctx,
row.fileId,
fctx.userId!,
3600,
);
return {
...row,
r2Url: access.isOk() ? access.value.url : null,
};
}),
);
return {
data: {
...res.value,
data: withAccessUrls,
},
error: null,
};
},
);

View File

@@ -98,14 +98,13 @@
{new Date(item.uploadedAt).toLocaleString()}
</Table.Cell>
<Table.Cell>
<a
href={item.r2Url}
target="_blank"
rel="noreferrer"
<button
type="button"
onclick={() => void filesVM.openFile(item.id)}
class="text-xs text-primary underline"
>
Open
</a>
</button>
</Table.Cell>
<Table.Cell>
<Button

View File

@@ -43,6 +43,33 @@ export class FileController {
});
}
getFileAccessUrl(
fctx: FlowExecCtx,
fileId: string,
userId: string,
expiresIn: number = 3600,
) {
return traceResultAsync({
name: "logic.files.controller.getFileAccessUrl",
fctx,
attributes: {
"app.user.id": userId,
"app.file.id": fileId,
"app.file.access_ttl_sec": expiresIn,
},
fn: () =>
this.fileRepo
.getFileById(fctx, fileId, userId)
.andThen((file) =>
this.storageRepo.generatePresignedDownloadUrl(
fctx,
file.objectKey,
expiresIn,
),
),
});
}
uploadFile(
fctx: FlowExecCtx,
userId: string,

View File

@@ -91,6 +91,14 @@ export type PresignedUploadResponse = v.InferOutput<
typeof presignedUploadResponseSchema
>;
export const presignedFileAccessResponseSchema = v.object({
url: v.string(),
expiresIn: v.pipe(v.number(), v.integer()),
});
export type PresignedFileAccessResponse = v.InferOutput<
typeof presignedFileAccessResponseSchema
>;
export const fileUploadResultSchema = v.object({
success: v.boolean(),
file: v.optional(fileSchema),

View File

@@ -1,6 +1,9 @@
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context";
import type { PresignedUploadResponse } from "./data";
import type {
PresignedFileAccessResponse,
PresignedUploadResponse,
} from "./data";
import { R2StorageClient } from "@pkg/objectstorage";
import { type Err } from "@pkg/result";
import { fileErrors } from "./errors";
@@ -209,6 +212,78 @@ export class StorageRepository {
});
}
generatePresignedDownloadUrl(
fctx: FlowExecCtx,
objectKey: string,
expiresIn: number,
): ResultAsync<PresignedFileAccessResponse, Err> {
const startedAt = Date.now();
logDomainEvent({
event: "files.storage.presigned_download.started",
fctx,
meta: { objectKey, expiresIn },
});
return ResultAsync.fromPromise(
this.storageClient.generatePresignedDownloadUrl(objectKey, expiresIn),
(error) => {
logDomainEvent({
level: "error",
event: "files.storage.presigned_download.failed",
fctx,
durationMs: Date.now() - startedAt,
error,
meta: { objectKey },
});
return fileErrors.presignedUrlFailed(
fctx,
error instanceof Error ? error.message : String(error),
);
},
).andThen((result) => {
if (result.error) {
logDomainEvent({
level: "error",
event: "files.storage.presigned_download.failed",
fctx,
durationMs: Date.now() - startedAt,
error: result.error,
meta: { objectKey, stage: "storage_response" },
});
return errAsync(
fileErrors.presignedUrlFailed(fctx, String(result.error)),
);
}
const data = result.data;
if (!data?.downloadUrl) {
logDomainEvent({
level: "error",
event: "files.storage.presigned_download.failed",
fctx,
durationMs: Date.now() - startedAt,
error: {
code: "NO_PRESIGNED_DATA",
message: "No presigned download data returned",
},
meta: { objectKey },
});
return errAsync(fileErrors.noPresignedData(fctx));
}
logDomainEvent({
event: "files.storage.presigned_download.succeeded",
fctx,
durationMs: Date.now() - startedAt,
meta: { objectKey, expiresIn },
});
return okAsync({
url: data.downloadUrl,
expiresIn: data.expiresIn,
});
});
}
deleteFile(
fctx: FlowExecCtx,
objectKey: string,

View File

@@ -11,6 +11,7 @@ import {
import type {
FileMetadata,
FileUploadConfig,
PresignedDownloadResult,
PresignedUrlResult,
UploadOptions,
UploadResult,
@@ -266,6 +267,46 @@ export class R2StorageClient {
}
}
/**
* Generate presigned URL for private object download/view
*/
async generatePresignedDownloadUrl(
objectKey: string,
expiresIn: number = 3600,
): Promise<Result<PresignedDownloadResult>> {
try {
const command = new GetObjectCommand({
Bucket: this.config.bucketName,
Key: objectKey,
});
const downloadUrl = await getSignedUrl(this.s3Client, command, {
expiresIn,
});
logger.info(`Generated presigned download URL for ${objectKey}`);
return {
data: {
downloadUrl,
expiresIn,
},
};
} catch (error) {
logger.error("Failed to generate presigned download URL:", error);
return {
error: getError(
{
code: ERROR_CODES.STORAGE_ERROR,
message: "Failed to generate presigned download URL",
description: "Could not create download URL",
detail: "S3 presigned URL generation failed",
},
error,
),
};
}
}
/**
* Get file from R2
*/

View File

@@ -60,6 +60,14 @@ export const presignedUrlResultSchema = v.object({
});
export type PresignedUrlResult = v.InferOutput<typeof presignedUrlResultSchema>;
export const presignedDownloadResultSchema = v.object({
downloadUrl: v.string(),
expiresIn: v.pipe(v.number(), v.integer()),
});
export type PresignedDownloadResult = v.InferOutput<
typeof presignedDownloadResultSchema
>;
// File Validation Result Schema
export const fileValidationResultSchema = v.object({
isValid: v.boolean(),

6
pnpm-lock.yaml generated
View File

@@ -65,6 +65,9 @@ importers:
'@pkg/settings':
specifier: workspace:*
version: link:../../packages/settings
argon2:
specifier: ^0.43.0
version: 0.43.1
better-auth:
specifier: ^1.4.20
version: 1.4.20(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.6)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.6)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(bun-types@1.3.9)(kysely@0.28.11)(postgres@3.4.8))(svelte@5.53.6)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
@@ -83,6 +86,9 @@ importers:
qrcode:
specifier: ^1.5.4
version: 1.5.4
sharp:
specifier: ^0.34.5
version: 0.34.5
valibot:
specifier: ^1.2.0
version: 1.2.0(typescript@5.9.3)