Compare commits
2 Commits
939d4ca8a8
...
1310ff3220
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1310ff3220 | ||
|
|
34aa52ec7c |
@@ -51,6 +51,10 @@ const deleteFileInputSchema = v.object({
|
|||||||
fileId: v.string(),
|
fileId: v.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getFileAccessUrlInputSchema = v.object({
|
||||||
|
fileId: v.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const deleteFileSC = command(deleteFileInputSchema, async (input) => {
|
export const deleteFileSC = command(deleteFileInputSchema, async (input) => {
|
||||||
const event = getRequestEvent();
|
const event = getRequestEvent();
|
||||||
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
|
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
|
||||||
@@ -64,6 +68,22 @@ export const deleteFileSC = command(deleteFileInputSchema, async (input) => {
|
|||||||
: { data: null, error: res.error };
|
: { 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 () => {
|
export const cleanupDanglingFilesSC = command(v.object({}), async () => {
|
||||||
const event = getRequestEvent();
|
const event = getRequestEvent();
|
||||||
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
|
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { File } from "@pkg/logic/domains/files/data";
|
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";
|
import { toast } from "svelte-sonner";
|
||||||
|
|
||||||
class FilesViewModel {
|
class FilesViewModel {
|
||||||
@@ -104,6 +109,25 @@ class FilesViewModel {
|
|||||||
this.cleanupLoading = false;
|
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();
|
export const filesVM = new FilesViewModel();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getMobileController } from "@pkg/logic/domains/mobile/controller";
|
import { getMobileController } from "@pkg/logic/domains/mobile/controller";
|
||||||
|
import { getFileController } from "@pkg/logic/domains/files/controller";
|
||||||
import {
|
import {
|
||||||
mobilePaginationSchema,
|
mobilePaginationSchema,
|
||||||
listMobileDeviceMediaFiltersSchema,
|
listMobileDeviceMediaFiltersSchema,
|
||||||
@@ -12,6 +13,7 @@ import { command, getRequestEvent, query } from "$app/server";
|
|||||||
import * as v from "valibot";
|
import * as v from "valibot";
|
||||||
|
|
||||||
const mc = getMobileController();
|
const mc = getMobileController();
|
||||||
|
const fc = getFileController();
|
||||||
|
|
||||||
const getDevicesInputSchema = v.object({
|
const getDevicesInputSchema = v.object({
|
||||||
search: v.optional(v.string()),
|
search: v.optional(v.string()),
|
||||||
@@ -104,9 +106,34 @@ export const getDeviceMediaSQ = query(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const res = await mc.listDeviceMedia(fctx, filters, input.pagination);
|
const res = await mc.listDeviceMedia(fctx, filters, input.pagination);
|
||||||
return res.isOk()
|
if (res.isErr()) {
|
||||||
? { data: res.value, error: null }
|
return { data: null, error: res.error };
|
||||||
: { 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,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -98,14 +98,13 @@
|
|||||||
{new Date(item.uploadedAt).toLocaleString()}
|
{new Date(item.uploadedAt).toLocaleString()}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<a
|
<button
|
||||||
href={item.r2Url}
|
type="button"
|
||||||
target="_blank"
|
onclick={() => void filesVM.openFile(item.id)}
|
||||||
rel="noreferrer"
|
|
||||||
class="text-xs text-primary underline"
|
class="text-xs text-primary underline"
|
||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
</a>
|
</button>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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(
|
uploadFile(
|
||||||
fctx: FlowExecCtx,
|
fctx: FlowExecCtx,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ export type PresignedUploadResponse = v.InferOutput<
|
|||||||
typeof presignedUploadResponseSchema
|
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({
|
export const fileUploadResultSchema = v.object({
|
||||||
success: v.boolean(),
|
success: v.boolean(),
|
||||||
file: v.optional(fileSchema),
|
file: v.optional(fileSchema),
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { ResultAsync, errAsync, okAsync } from "neverthrow";
|
import { ResultAsync, errAsync, okAsync } from "neverthrow";
|
||||||
import { FlowExecCtx } from "@core/flow.execution.context";
|
import { FlowExecCtx } from "@core/flow.execution.context";
|
||||||
import type { PresignedUploadResponse } from "./data";
|
import type {
|
||||||
|
PresignedFileAccessResponse,
|
||||||
|
PresignedUploadResponse,
|
||||||
|
} from "./data";
|
||||||
import { R2StorageClient } from "@pkg/objectstorage";
|
import { R2StorageClient } from "@pkg/objectstorage";
|
||||||
import { type Err } from "@pkg/result";
|
import { type Err } from "@pkg/result";
|
||||||
import { fileErrors } from "./errors";
|
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(
|
deleteFile(
|
||||||
fctx: FlowExecCtx,
|
fctx: FlowExecCtx,
|
||||||
objectKey: string,
|
objectKey: string,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
FileMetadata,
|
FileMetadata,
|
||||||
FileUploadConfig,
|
FileUploadConfig,
|
||||||
|
PresignedDownloadResult,
|
||||||
PresignedUrlResult,
|
PresignedUrlResult,
|
||||||
UploadOptions,
|
UploadOptions,
|
||||||
UploadResult,
|
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
|
* Get file from R2
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ export const presignedUrlResultSchema = v.object({
|
|||||||
});
|
});
|
||||||
export type PresignedUrlResult = v.InferOutput<typeof presignedUrlResultSchema>;
|
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
|
// File Validation Result Schema
|
||||||
export const fileValidationResultSchema = v.object({
|
export const fileValidationResultSchema = v.object({
|
||||||
isValid: v.boolean(),
|
isValid: v.boolean(),
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -65,6 +65,9 @@ importers:
|
|||||||
'@pkg/settings':
|
'@pkg/settings':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/settings
|
version: link:../../packages/settings
|
||||||
|
argon2:
|
||||||
|
specifier: ^0.43.0
|
||||||
|
version: 0.43.1
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.4.20
|
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))
|
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:
|
qrcode:
|
||||||
specifier: ^1.5.4
|
specifier: ^1.5.4
|
||||||
version: 1.5.4
|
version: 1.5.4
|
||||||
|
sharp:
|
||||||
|
specifier: ^0.34.5
|
||||||
|
version: 0.34.5
|
||||||
valibot:
|
valibot:
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.2.0(typescript@5.9.3)
|
version: 1.2.0(typescript@5.9.3)
|
||||||
|
|||||||
Reference in New Issue
Block a user