diff --git a/apps/api/src/dashboard/routes.test.ts b/apps/api/src/dashboard/routes.test.ts new file mode 100644 index 00000000..31d4aa8c --- /dev/null +++ b/apps/api/src/dashboard/routes.test.ts @@ -0,0 +1,445 @@ +import assert from "node:assert/strict"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; +import type { Server } from "node:http"; +import { after, afterEach, before, describe, mock, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import express, { + type NextFunction, + type Request, + type Response, +} from "express"; +import { + createAccount, + createApiKey, + createMediaRecord, + getMediaRecord, + getSignature, + setDb, + type AccountUser, + type ApiKeyRecord, + type AppDb, +} from "@/db"; +import * as schema from "../db/schema/index.js"; +import type { MedialitAuth } from "../auth/better-auth"; +import mediaService from "../media/service"; +import { mediaKind } from "../media/kind"; +import { createDashboardRouter } from "./routes"; + +const PUBLIC_API_URL = "https://api.example.test"; + +/* Sessions come from a test header instead of a cookie. */ +const fakeAuth = { + auth: { + api: { + getSession: async ({ headers }: { headers: Headers }) => { + const userId = headers.get("x-test-user"); + return userId ? { user: { id: userId } } : null; + }, + }, + }, + publicApiUrl: PUBLIC_API_URL, +} as unknown as MedialitAuth; + +let server: Server; +let baseUrl: string; +let owner: AccountUser; +let stranger: AccountUser; +let ownerApp: ApiKeyRecord; +let strangerApp: ApiKeyRecord; + +/* Covers every kind, including the mixed-case types the old SQL missed. */ +const ownerFiles = [ + { mediaId: "img-png", mimeType: "image/png", size: 100 }, + { mediaId: "img-upper", mimeType: "IMAGE/JPEG", size: 200 }, + { mediaId: "vid-mp4", mimeType: "video/mp4", size: 1000 }, + { mediaId: "pdf-lower", mimeType: "application/pdf", size: 300 }, + { mediaId: "pdf-upper", mimeType: "application/PDF", size: 400 }, + { mediaId: "audio-mp3", mimeType: "audio/mpeg", size: 50 }, + { mediaId: "text-plain", mimeType: "text/plain", size: 5 }, +]; + +async function addMedia( + user: AccountUser, + app: ApiKeyRecord, + file: { mediaId: string; mimeType: string; size: number }, + options: { temp?: boolean; access?: "public" | "private" } = {}, +) { + await createMediaRecord({ + fileName: `${file.mediaId}.bin`, + mediaId: file.mediaId, + userId: user.id, + apikey: app.key, + originalFileName: `${file.mediaId}.bin`, + mimeType: file.mimeType, + size: file.size, + thumbnailGenerated: false, + accessControl: options.access ?? "public", + temp: options.temp ?? false, + }); +} + +function call( + method: string, + route: string, + options: { user?: AccountUser | null; body?: unknown } = {}, +) { + const user = options.user === undefined ? owner : options.user; + const headers: Record = {}; + if (user) headers["x-test-user"] = user.id; + if (options.body !== undefined) + headers["content-type"] = "application/json"; + return fetch(`${baseUrl}${route}`, { + method, + headers, + body: + options.body === undefined + ? undefined + : JSON.stringify(options.body), + }); +} + +async function json( + method: string, + route: string, + options: { user?: AccountUser | null; body?: unknown } = {}, +) { + const response = await call(method, route, options); + return { status: response.status, body: await response.json() }; +} + +before(async () => { + const client = new PGlite(); + const db = drizzle(client, { schema }); + await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../drizzle", + ), + }); + setDb(db as unknown as AppDb); + + owner = await createAccount({ email: "owner@example.com" }); + stranger = await createAccount({ email: "stranger@example.com" }); + ownerApp = await createApiKey({ userId: owner.id, name: "Photos" }); + strangerApp = await createApiKey({ userId: stranger.id, name: "Theirs" }); + + for (const file of ownerFiles) await addMedia(owner, ownerApp, file); + // Temporary uploads never count. + await addMedia( + owner, + ownerApp, + { mediaId: "temp-png", mimeType: "image/png", size: 9999 }, + { temp: true }, + ); + await addMedia(stranger, strangerApp, { + mediaId: "stranger-png", + mimeType: "image/png", + size: 700, + }); + + const app = express(); + app.use(express.json()); + app.use(createDashboardRouter(fakeAuth)); + app.use( + (error: Error, _req: Request, res: Response, _next: NextFunction) => { + res.status(500).json({ error: error.message }); + }, + ); + server = app.listen(0); + await new Promise((resolve) => server.once("listening", resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +after(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +afterEach(() => { + mock.restoreAll(); +}); + +describe("dashboard routes: session and ownership", () => { + test("rejects requests without a session", async () => { + for (const route of [ + "/api/account", + "/api/apps", + "/api/apps/overview", + `/api/apps/${ownerApp.keyId}/media`, + ]) { + const response = await call("GET", route, { user: null }); + assert.equal(response.status, 401, route); + } + const signature = await call( + "POST", + `/api/apps/${ownerApp.keyId}/media/signature`, + { user: null, body: {} }, + ); + assert.equal(signature.status, 401); + }); + + test("hides another user's app", async () => { + const keyId = strangerApp.keyId; + const attempts: Array<[string, string, unknown?]> = [ + ["GET", `/api/apps/${keyId}`], + ["GET", `/api/apps/${keyId}/summary`], + ["GET", `/api/apps/${keyId}/media`], + ["GET", `/api/apps/${keyId}/media/count`], + ["GET", `/api/apps/${keyId}/media/stranger-png`], + ["POST", `/api/apps/${keyId}/media/signature`, {}], + ["POST", `/api/apps/${keyId}/media/stranger-png/seal`, {}], + [ + "POST", + `/api/apps/${keyId}/media/delete`, + { mediaIds: ["stranger-png"] }, + ], + ]; + for (const [method, route, body] of attempts) { + const response = await json(method, route, { body }); + assert.equal(response.status, 404, `${method} ${route}`); + assert.equal(response.body.error, "Apikey not found"); + } + assert.notEqual( + await getMediaRecord({ + userId: stranger.id, + apikey: strangerApp.key, + mediaId: "stranger-png", + }), + null, + ); + }); +}); + +describe("dashboard routes: summaries", () => { + test("counts each kind once, whatever the type's case", async () => { + const { status, body } = await json( + "GET", + `/api/apps/${ownerApp.keyId}/summary`, + ); + assert.equal(status, 200); + assert.equal(body.count, 7); + assert.equal(body.images, 2); + assert.equal(body.videos, 1); + assert.equal(body.pdfs, 2); + assert.equal(body.others, 2); + assert.equal(body.imageStorage, 300); + assert.equal(body.videoStorage, 1000); + assert.equal(body.pdfStorage, 700); + assert.equal(body.otherStorage, 55); + assert.equal( + body.imageStorage + + body.videoStorage + + body.pdfStorage + + body.otherStorage, + body.storage, + ); + assert.equal(body.share, 100); + }); + + test("overview totals match the per-app summaries", async () => { + const { status, body } = await json("GET", "/api/apps/overview"); + assert.equal(status, 200); + assert.equal(body.appCount, 2); // Photos and the default app + const photos = body.apps.find( + (app: { keyId: string }) => app.keyId === ownerApp.keyId, + ); + const summary = await json( + "GET", + `/api/apps/${ownerApp.keyId}/summary`, + ); + assert.deepEqual(photos, summary.body); + assert.equal(body.totalOthers, 2); + assert.equal(body.largestApp.keyId, ownerApp.keyId); + }); +}); + +describe("dashboard routes: media list", () => { + test("tags each file with the kind the filters use", async () => { + const { status, body } = await json( + "GET", + `/api/apps/${ownerApp.keyId}/media?limit=100`, + ); + assert.equal(status, 200); + assert.equal(body.length, 7); + for (const item of body) { + assert.equal(item.kind, mediaKind(item.mimeType), item.mediaId); + } + + for (const kind of ["image", "video", "pdf", "other"]) { + const list = await json( + "GET", + `/api/apps/${ownerApp.keyId}/media?limit=100&kind=${kind}`, + ); + const count = await json( + "GET", + `/api/apps/${ownerApp.keyId}/media/count?kind=${kind}`, + ); + assert.ok( + list.body.every((item: { kind: string }) => item.kind === kind), + kind, + ); + assert.equal(count.body.count, list.body.length, kind); + } + }); + + test("rejects a bad page or page size", async () => { + for (const query of [ + "page=-1", + "page=0", + "page=1.5", + "page=abc", + "limit=0", + "limit=101", + "limit=-5", + ]) { + const response = await call( + "GET", + `/api/apps/${ownerApp.keyId}/media?${query}`, + ); + assert.equal(response.status, 400, query); + } + const largest = await json( + "GET", + `/api/apps/${ownerApp.keyId}/media?page=2&limit=3&sort=largest`, + ); + assert.equal(largest.status, 200); + assert.deepEqual( + largest.body.map((item: { mediaId: string }) => item.mediaId), + ["img-upper", "img-png", "audio-mp3"], + ); + }); + + test("returns one file with its kind", async () => { + const { status, body } = await json( + "GET", + `/api/apps/${ownerApp.keyId}/media/pdf-upper`, + ); + assert.equal(status, 200); + assert.equal(body.kind, "pdf"); + assert.ok(body.file); + }); +}); + +describe("dashboard routes: uploads", () => { + test("issues a signature bound to the app, with the upload endpoint", async () => { + const { status, body } = await json( + "POST", + `/api/apps/${ownerApp.keyId}/media/signature`, + { body: { group: "avatars" } }, + ); + assert.equal(status, 200); + assert.equal(body.endpoint, PUBLIC_API_URL); + const stored = await getSignature(body.signature); + assert.equal(stored?.apikey, ownerApp.key); + assert.equal(stored?.userId, owner.id); + assert.equal(stored?.group, "avatars"); + }); + + test("rejects a signature with a bad group", async () => { + const response = await call( + "POST", + `/api/apps/${ownerApp.keyId}/media/signature`, + { body: { group: 42 } }, + ); + assert.equal(response.status, 400); + }); + + test("seals through the media service and maps its errors", async () => { + const missing = await json( + "POST", + `/api/apps/${ownerApp.keyId}/media/no-such-media/seal`, + { body: {} }, + ); + assert.equal(missing.status, 404); + assert.equal(missing.body.error, "Media not found"); + + const seal = mock.method(mediaService, "sealMedia", async () => ({ + mediaId: "temp-png", + originalFileName: "temp-png.bin", + mimeType: "image/png", + size: 9999, + access: "public", + file: "https://files.example.test/temp-png", + thumbnail: "", + })); + const sealed = await json( + "POST", + `/api/apps/${ownerApp.keyId}/media/temp-png/seal`, + { body: {} }, + ); + assert.equal(sealed.status, 200); + assert.equal(sealed.body.kind, "image"); + assert.deepEqual(seal.mock.calls[0].arguments[0], { + userId: owner.id, + apikey: ownerApp.key, + mediaId: "temp-png", + }); + }); +}); + +describe("dashboard routes: bulk delete", () => { + test("validates the id list", async () => { + for (const mediaIds of [ + [], + [""], + [42], + "img-png", + Array.from({ length: 101 }, (_, index) => `id-${index}`), + ]) { + const response = await call( + "POST", + `/api/apps/${ownerApp.keyId}/media/delete`, + { body: { mediaIds } }, + ); + assert.equal(response.status, 400, JSON.stringify(mediaIds)); + } + }); + + test("deletes what it finds and counts the rest as failed", async () => { + const removed: string[] = []; + mock.method( + mediaService, + "removeStoredFiles", + async (media: { mediaId: string }) => { + if (media.mediaId === "audio-mp3") { + throw new Error("storage unavailable"); + } + removed.push(media.mediaId); + }, + ); + + const { status, body } = await json( + "POST", + `/api/apps/${ownerApp.keyId}/media/delete`, + { + body: { + mediaIds: [ + "text-plain", + "text-plain", // duplicates count once + "audio-mp3", // storage fails + "stranger-png", // another user's file + "no-such-media", + ], + }, + }, + ); + assert.equal(status, 200); + assert.deepEqual(body, { deleted: 1, failed: 3 }); + assert.deepEqual(removed, ["text-plain"]); + + const lookup = (userId: string, apikey: string, mediaId: string) => + getMediaRecord({ userId, apikey, mediaId }); + assert.equal(await lookup(owner.id, ownerApp.key, "text-plain"), null); + assert.notEqual( + await lookup(owner.id, ownerApp.key, "audio-mp3"), + null, + ); + assert.notEqual( + await lookup(stranger.id, strangerApp.key, "stranger-png"), + null, + ); + }); +}); diff --git a/apps/api/src/dashboard/routes.ts b/apps/api/src/dashboard/routes.ts index 90a45898..9ea56f82 100644 --- a/apps/api/src/dashboard/routes.ts +++ b/apps/api/src/dashboard/routes.ts @@ -5,14 +5,18 @@ import { type Response, } from "express"; import { fromNodeHeaders } from "better-auth/node"; +import rateLimit from "express-rate-limit"; import { createApiKey, findUserById, getApiKeyByKeyId, listApiKeys, + listApiKeyMediaSummaries, renameApiKey, + setDefaultApiKey, softDeleteApiKey, type AccountUser, + type ApiKeyMediaSummary, type ApiKeyRecord, } from "@/db"; import type { MedialitAuth } from "../auth/better-auth"; @@ -23,7 +27,12 @@ import { } from "../billing/entitlements"; import { deploymentMode } from "../billing/catalog"; import mediaService from "../media/service"; +import { sealErrorStatus } from "../media/handlers"; +import { mediaKind, parseMediaKind } from "../media/kind"; import { getMediaCount, getTotalSpace } from "../media/queries"; +import { validatePresigningOptions } from "../signature/handlers"; +import { generateSignature } from "../signature/service"; +import logger from "../services/log"; function publicApp(row: ApiKeyRecord, includeSecret: boolean) { return { @@ -34,29 +43,14 @@ function publicApp(row: ApiKeyRecord, includeSecret: boolean) { }; } -async function sessionUser( - auth: MedialitAuth, - req: Request, - res: Response, -): Promise { - const session = await auth.auth.api.getSession({ - headers: fromNodeHeaders(req.headers), - }); - if (!session?.user?.id) { - res.status(401).json({ error: "Unauthenticated" }); - return null; - } - const user = await findUserById(session.user.id); - if (!user) { - res.status(401).json({ error: "Unauthenticated" }); - return null; - } - return user; +/* Set by the session middleware below for every /api/account and /api/apps + request, so handlers never resolve the session again. */ +function currentUser(res: Response): AccountUser { + return res.locals.user as AccountUser; } -async function ownedApp(auth: MedialitAuth, req: Request, res: Response) { - const user = await sessionUser(auth, req, res); - if (!user) return null; +async function ownedApp(req: Request, res: Response) { + const user = currentUser(res); const key = await getApiKeyByKeyId(user.id, String(req.params.keyId)); if (!key) { res.status(404).json({ error: "Apikey not found" }); @@ -75,8 +69,149 @@ function catchAsync( }; } +const MAX_BULK_DELETE = 100; +const MAX_PAGE_SIZE = 100; +const DEFAULT_PAGE_SIZE = 10; +const MAX_SEARCH_LENGTH = 200; + +/* Reads a whole number from a query value. A missing value gives the + fallback; anything else that isn't a whole number gives null. */ +function queryInteger(value: unknown, fallback: number): number | null { + if (value === undefined || value === "") return fallback; + if (typeof value !== "string" || !/^\d+$/.test(value)) return null; + return Number(value); +} + +function querySearch(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const search = value.trim().slice(0, MAX_SEARCH_LENGTH); + return search || undefined; +} + +const MEDIA_SORTS = ["newest", "oldest", "name", "largest"] as const; +type MediaSort = (typeof MEDIA_SORTS)[number]; + +function parseMediaSort(value: unknown): MediaSort | undefined { + return MEDIA_SORTS.find((sort) => sort === value); +} + +function withKind(media: T) { + return { ...media, kind: mediaKind(media.mimeType ?? "") }; +} + +type AppSummary = { + keyId: string; + name: string; + default: boolean; + count: number; + storage: number; + images: number; + videos: number; + pdfs: number; + others: number; + imageStorage: number; + videoStorage: number; + pdfStorage: number; + otherStorage: number; + lastUpload: Date | null; + /** Percent of the account's storage, one decimal place. */ + share: number; +}; + +function summarizeApp( + key: ApiKeyRecord, + summary: ApiKeyMediaSummary | undefined, +) { + return { + keyId: key.keyId, + name: key.name, + default: key.default, + count: summary?.count ?? 0, + storage: summary?.storage ?? 0, + images: summary?.images ?? 0, + videos: summary?.videos ?? 0, + pdfs: summary?.pdfs ?? 0, + others: summary?.others ?? 0, + imageStorage: summary?.imageStorage ?? 0, + videoStorage: summary?.videoStorage ?? 0, + pdfStorage: summary?.pdfStorage ?? 0, + otherStorage: summary?.otherStorage ?? 0, + lastUpload: summary?.lastUpload ?? null, + }; +} + +/* Per-app usage for every app the user has, newest upload first, with each + app's share of the account's storage. */ +async function loadAppSummaries(userId: string) { + const [keys, summaries] = await Promise.all([ + listApiKeys(userId), + listApiKeyMediaSummaries(userId), + ]); + const summaryByKey = new Map( + summaries.map((summary) => [summary.apikey, summary]), + ); + const apps = keys.map((key) => + summarizeApp(key, summaryByKey.get(key.key)), + ); + const totalStorage = apps.reduce((total, app) => total + app.storage, 0); + const storageShare = (storage: number) => { + if (!totalStorage) return 0; + return Number(((storage / totalStorage) * 100).toFixed(1)); + }; + + apps.sort((left, right) => { + const leftUpload = left.lastUpload?.getTime() ?? 0; + const rightUpload = right.lastUpload?.getTime() ?? 0; + return rightUpload - leftUpload; + }); + + return apps.map( + (app): AppSummary => ({ ...app, share: storageShare(app.storage) }), + ); +} + +/* Dashboard budget per signed-in user. A person clicking through the UI makes + far fewer calls than an integration, so this is separate from the API-key + limit. Keyed by user, not IP, so users behind one NAT don't share a bucket. */ +const dashboardLimiter = rateLimit({ + windowMs: 60_000, + max: 300, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (_req, res) => `user:${currentUser(res).id}`, + message: { + error: "too_many_requests", + error_description: "Too many requests.", + }, +}); + export function createDashboardRouter(auth: MedialitAuth) { const router = Router(); + + /* Resolve the session once, before the limiter so it can key on the + user. Handlers read the user from res.locals. */ + router.use( + ["/api/account", "/api/apps"], + async (req: Request, res: Response, next: NextFunction) => { + try { + const session = await auth.auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }); + const user = session?.user?.id + ? await findUserById(session.user.id) + : null; + if (!user) { + res.status(401).json({ error: "Unauthenticated" }); + return; + } + res.locals.user = user; + next(); + } catch (error) { + next(error); + } + }, + dashboardLimiter, + ); for (const method of ["get", "post", "patch", "delete"] as const) { const original = router[method].bind(router); (router as any)[method] = ( @@ -85,9 +220,8 @@ export function createDashboardRouter(auth: MedialitAuth) { ) => original(path, catchAsync(handler)); } - router.get("/api/account", async (req, res) => { - const user = await sessionUser(auth, req, res); - if (!user) return; + router.get("/api/account", async (_req, res) => { + const user = currentUser(res); const account = await resolveAccountBilling(user); res.json({ id: user.id, @@ -101,23 +235,62 @@ export function createDashboardRouter(auth: MedialitAuth) { }); }); - router.get("/api/apps", async (req, res) => { - const user = await sessionUser(auth, req, res); - if (!user) return; - const rows = await listApiKeys(user.id); + router.get("/api/apps", async (_req, res) => { + const rows = await listApiKeys(currentUser(res).id); res.json(rows.map((row) => publicApp(row, false))); }); + // Session-only dashboard data; the public OpenAPI spec covers customer API routes. + router.get("/api/apps/overview", async (_req, res) => { + const apps = await loadAppSummaries(currentUser(res).id); + const totals = apps.reduce( + (result, app) => ({ + files: result.files + app.count, + storage: result.storage + app.storage, + images: result.images + app.images, + videos: result.videos + app.videos, + pdfs: result.pdfs + app.pdfs, + others: result.others + app.others, + }), + { files: 0, storage: 0, images: 0, videos: 0, pdfs: 0, others: 0 }, + ); + const largest = apps.reduce( + (current, app) => + !current || app.storage > current.storage ? app : current, + null, + ); + + res.json({ + apps, + appCount: apps.length, + totalFiles: totals.files, + totalStorage: totals.storage, + totalImages: totals.images, + totalVideos: totals.videos, + totalPdfs: totals.pdfs, + totalOthers: totals.others, + largestApp: largest + ? { + keyId: largest.keyId, + name: largest.name, + storage: largest.storage, + share: largest.share, + } + : null, + }); + }); + router.post("/api/apps", async (req, res) => { - const user = await sessionUser(auth, req, res); - if (!user) return; const name = String(req.body?.name || "").trim(); if (!name) { res.status(400).json({ error: "Name is required" }); return; } try { - const created = await createApiKey({ userId: user.id, name }); + const created = await createApiKey({ + userId: currentUser(res).id, + name, + }); res.status(201).json({ key: created.key, keyId: created.keyId, @@ -128,18 +301,32 @@ export function createDashboardRouter(auth: MedialitAuth) { } }); + router.get("/api/apps/:keyId/summary", async (req, res) => { + const owned = await ownedApp(req, res); + if (!owned) return; + const apps = await loadAppSummaries(owned.user.id); + const app = apps.find((summary) => summary.keyId === owned.key.keyId); + if (!app) { + res.status(404).json({ error: "Apikey not found" }); + return; + } + res.json(app); + }); + router.get("/api/apps/:keyId/media/count", async (req, res) => { - const owned = await ownedApp(auth, req, res); + const owned = await ownedApp(req, res); if (!owned) return; const count = await getMediaCount({ userId: owned.user.id, apikey: owned.key.key, + search: querySearch(req.query.search), + kind: parseMediaKind(req.query.kind), }); res.json({ count }); }); router.get("/api/apps/:keyId/stats", async (req, res) => { - const owned = await ownedApp(auth, req, res); + const owned = await ownedApp(req, res); if (!owned) return; const storage = await getTotalSpace({ userId: owned.user.id, @@ -151,8 +338,43 @@ export function createDashboardRouter(auth: MedialitAuth) { }); }); + /* A signature for one browser (or app) upload to this app. The client + uploads straight to `endpoint` with it, then seals the result below. */ + router.post("/api/apps/:keyId/media/signature", async (req, res) => { + const owned = await ownedApp(req, res); + if (!owned) return; + const { error } = validatePresigningOptions(req.body); + if (error) { + res.status(400).json({ error: error.message }); + return; + } + const signature = await generateSignature({ + userId: owned.user.id, + apikey: owned.key.key, + group: req.body?.group, + }); + res.json({ signature, endpoint: auth.publicApiUrl }); + }); + + router.post("/api/apps/:keyId/media/:mediaId/seal", async (req, res) => { + const owned = await ownedApp(req, res); + if (!owned) return; + try { + const media = await mediaService.sealMedia({ + userId: owned.user.id, + apikey: owned.key.key, + mediaId: String(req.params.mediaId), + }); + res.json(withKind(media)); + } catch (err: any) { + const status = sealErrorStatus(err); + if (status === 500) logger.error({ err }, err.message); + res.status(status).json({ error: err.message }); + } + }); + router.get("/api/apps/:keyId/media/:mediaId", async (req, res) => { - const owned = await ownedApp(auth, req, res); + const owned = await ownedApp(req, res); if (!owned) return; const media = await mediaService.getMediaDetails({ userId: owned.user.id, @@ -163,31 +385,80 @@ export function createDashboardRouter(auth: MedialitAuth) { res.status(404).json({ error: "Not found" }); return; } - res.json(media); + res.json(withKind(media)); + }); + + router.delete("/api/apps/:keyId/media/:mediaId", async (req, res) => { + const owned = await ownedApp(req, res); + if (!owned) return; + await mediaService.deleteMedia({ + userId: owned.user.id, + apikey: owned.key.key, + mediaId: String(req.params.mediaId), + }); + res.json({ message: "success" }); + }); + + router.post("/api/apps/:keyId/media/delete", async (req, res) => { + const owned = await ownedApp(req, res); + if (!owned) return; + const mediaIds = req.body?.mediaIds; + if ( + !Array.isArray(mediaIds) || + mediaIds.length === 0 || + mediaIds.length > MAX_BULK_DELETE || + !mediaIds.every((id) => typeof id === "string" && id.length > 0) + ) { + res.status(400).json({ + error: `mediaIds must be between 1 and ${MAX_BULK_DELETE} ids`, + }); + return; + } + const result = await mediaService.deleteMediaMany({ + userId: owned.user.id, + apikey: owned.key.key, + mediaIds, + }); + res.json(result); }); router.get("/api/apps/:keyId/media", async (req, res) => { - const owned = await ownedApp(auth, req, res); + const owned = await ownedApp(req, res); if (!owned) return; - const page = Number(req.query.page || 1); - const limit = Number(req.query.limit || 10); + const page = queryInteger(req.query.page, 1); + if (page === null || page < 1) { + res.status(400).json({ + error: "page must be a whole number from 1", + }); + return; + } + const limit = queryInteger(req.query.limit, DEFAULT_PAGE_SIZE); + if (limit === null || limit < 1 || limit > MAX_PAGE_SIZE) { + res.status(400).json({ + error: `limit must be a whole number from 1 to ${MAX_PAGE_SIZE}`, + }); + return; + } const result = await mediaService.getPage({ userId: owned.user.id, apikey: owned.key.key, - page: Number.isFinite(page) ? page : 1, - recordsPerPage: Number.isFinite(limit) ? limit : 10, + page, + recordsPerPage: limit, + search: querySearch(req.query.search), + kind: parseMediaKind(req.query.kind), + sort: parseMediaSort(req.query.sort), }); - res.json(result); + res.json(result.map(withKind)); }); router.get("/api/apps/:keyId", async (req, res) => { - const owned = await ownedApp(auth, req, res); + const owned = await ownedApp(req, res); if (!owned) return; res.json(publicApp(owned.key, true)); }); router.patch("/api/apps/:keyId", async (req, res) => { - const owned = await ownedApp(auth, req, res); + const owned = await ownedApp(req, res); if (!owned) return; const name = String(req.body?.name || "").trim(); if (!name) { @@ -206,8 +477,19 @@ export function createDashboardRouter(auth: MedialitAuth) { } }); + router.patch("/api/apps/:keyId/default", async (req, res) => { + const owned = await ownedApp(req, res); + if (!owned) return; + try { + await setDefaultApiKey(owned.user.id, owned.key.keyId); + res.json({ success: true }); + } catch (error: any) { + res.status(400).json({ error: error.message }); + } + }); + router.delete("/api/apps/:keyId", async (req, res) => { - const owned = await ownedApp(auth, req, res); + const owned = await ownedApp(req, res); if (!owned) return; try { await softDeleteApiKey(owned.user.id, owned.key.keyId); diff --git a/apps/api/src/db/repository.ts b/apps/api/src/db/repository.ts index 782810b3..5039558e 100644 --- a/apps/api/src/db/repository.ts +++ b/apps/api/src/db/repository.ts @@ -1,6 +1,17 @@ import { randomUUID } from "node:crypto"; import { getUniqueId } from "@medialit/utils"; -import { and, asc, desc, eq, like, lt, sql } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + ilike, + inArray, + like, + lt, + sql, + type SQL, +} from "drizzle-orm"; import { getDb } from "./client.js"; import { apiKeys, @@ -65,6 +76,21 @@ export type MediaRecord = { updatedAt: Date; }; +export type ApiKeyMediaSummary = { + apikey: string; + count: number; + storage: number; + images: number; + videos: number; + pdfs: number; + others: number; + imageStorage: number; + videoStorage: number; + pdfStorage: number; + otherStorage: number; + lastUpload: Date | null; +}; + export type MediaSettingsRecord = { userId: string; apikey: string; @@ -383,6 +409,44 @@ export async function renameApiKey(input: { .where(and(...filters)); } +export async function setDefaultApiKey( + userId: string, + keyId: string, +): Promise { + const db = getDb(); + await db.transaction(async (tx) => { + const [target] = await tx + .select({ id: apiKeys.id, isDefault: apiKeys.isDefault }) + .from(apiKeys) + .where( + and( + eq(apiKeys.userId, userId), + eq(apiKeys.keyId, keyId), + eq(apiKeys.deleted, false), + ), + ) + .limit(1); + if (!target) throw new Error("Apikey not found"); + if (target.isDefault) return; + + const timestamp = now(); + await tx + .update(apiKeys) + .set({ isDefault: false, updatedAt: timestamp }) + .where( + and( + eq(apiKeys.userId, userId), + eq(apiKeys.isDefault, true), + eq(apiKeys.deleted, false), + ), + ); + await tx + .update(apiKeys) + .set({ isDefault: true, updatedAt: timestamp }) + .where(eq(apiKeys.id, target.id)); + }); +} + export async function softDeleteApiKey( userId: string, keyId: string, @@ -441,6 +505,24 @@ function escapeLike(value: string): string { return value.replace(/[\\%_]/g, (char) => `\\${char}`); } +export type MediaKindFilter = "image" | "video" | "pdf" | "other"; + +/* SQL twin of mediaKind() in media/kind.ts; keep the two in step. The kinds + never overlap: image and video go by type prefix, pdf is any other type + that names pdf, and other is everything left. */ +function mediaKindCondition(kind: MediaKindFilter): SQL { + const mimeType = sql`lower(${media.mimeType})`; + const isImage = sql`${mimeType} like 'image/%'`; + const isVideo = sql`${mimeType} like 'video/%'`; + const namesPdf = sql`${mimeType} like '%pdf%'`; + if (kind === "image") return sql`(${isImage})`; + if (kind === "video") return sql`(${isVideo})`; + if (kind === "pdf") { + return sql`(not (${isImage}) and not (${isVideo}) and ${namesPdf})`; + } + return sql`(not (${isImage}) and not (${isVideo}) and not (${namesPdf}))`; +} + export async function createMediaRecord( input: Omit & { id?: string; @@ -498,6 +580,8 @@ export async function countMedia(input: { apikey: string; access?: "public" | "private"; group?: string; + search?: string; + kind?: MediaKindFilter; }): Promise { const db = getDb(); const filters = [ @@ -509,6 +593,15 @@ export async function countMedia(input: { if (input.group && input.group.trim()) { filters.push(like(media.group, `${escapeLike(input.group.trim())}%`)); } + if (input.search?.trim()) { + filters.push( + ilike( + media.originalFileName, + `%${escapeLike(input.search.trim())}%`, + ), + ); + } + if (input.kind) filters.push(mediaKindCondition(input.kind)); const [row] = await db .select({ count: sql`count(*)::int` }) .from(media) @@ -523,6 +616,9 @@ export async function listMedia(input: { group?: string; page?: number; recordsPerPage?: number; + search?: string; + kind?: MediaKindFilter; + sort?: "newest" | "oldest" | "name" | "largest"; }): Promise { const db = getDb(); const limit = input.recordsPerPage || 10; @@ -536,11 +632,28 @@ export async function listMedia(input: { if (input.group && input.group.trim()) { filters.push(like(media.group, `${escapeLike(input.group.trim())}%`)); } + if (input.search?.trim()) { + filters.push( + ilike( + media.originalFileName, + `%${escapeLike(input.search.trim())}%`, + ), + ); + } + if (input.kind) filters.push(mediaKindCondition(input.kind)); + const orderBy = + input.sort === "oldest" + ? asc(media.createdAt) + : input.sort === "name" + ? asc(media.originalFileName) + : input.sort === "largest" + ? desc(media.size) + : desc(media.createdAt); const rows = await db .select() .from(media) .where(and(...filters)) - .orderBy(desc(media.createdAt), desc(media.mediaId)) + .orderBy(orderBy, desc(media.mediaId)) .limit(limit) .offset(offset); return rows.map(mapMedia); @@ -560,6 +673,72 @@ export async function totalMediaSize(input: { return Number(row?.total ?? 0); } +export async function listApiKeyMediaSummaries( + userId: string, + apikey?: string, +): Promise { + const db = getDb(); + const filters = [eq(media.userId, userId), eq(media.temp, false)]; + if (apikey) filters.push(eq(media.apikey, apikey)); + const countOf = (kind: MediaKindFilter) => + sql`count(*) filter (where ${mediaKindCondition(kind)})::int`; + const storageOf = (kind: MediaKindFilter) => + sql`coalesce(sum(${media.size}) filter (where ${mediaKindCondition(kind)}), 0)::float8`; + const rows = await db + .select({ + apikey: media.apikey, + count: sql`count(*)::int`, + storage: sql`coalesce(sum(${media.size}), 0)::float8`, + images: countOf("image"), + videos: countOf("video"), + pdfs: countOf("pdf"), + others: countOf("other"), + imageStorage: storageOf("image"), + videoStorage: storageOf("video"), + pdfStorage: storageOf("pdf"), + otherStorage: storageOf("other"), + lastUpload: sql`max(${media.createdAt})`, + }) + .from(media) + .where(and(...filters)) + .groupBy(media.apikey); + + return rows.map((row) => ({ + ...row, + count: Number(row.count), + storage: Number(row.storage), + images: Number(row.images), + videos: Number(row.videos), + pdfs: Number(row.pdfs), + others: Number(row.others), + imageStorage: Number(row.imageStorage), + videoStorage: Number(row.videoStorage), + pdfStorage: Number(row.pdfStorage), + otherStorage: Number(row.otherStorage), + lastUpload: row.lastUpload ? new Date(row.lastUpload) : null, + })); +} + +export async function listMediaRecordsByIds(input: { + userId: string; + apikey: string; + mediaIds: string[]; +}): Promise { + if (input.mediaIds.length === 0) return []; + const db = getDb(); + const rows = await db + .select() + .from(media) + .where( + and( + eq(media.userId, input.userId), + eq(media.apikey, input.apikey), + inArray(media.mediaId, input.mediaIds), + ), + ); + return rows.map(mapMedia); +} + export async function deleteMediaRecord( userId: string, mediaId: string, diff --git a/apps/api/src/media/GetPageProps.ts b/apps/api/src/media/GetPageProps.ts index 2e7c5dd3..d294ab5c 100644 --- a/apps/api/src/media/GetPageProps.ts +++ b/apps/api/src/media/GetPageProps.ts @@ -7,4 +7,7 @@ export default interface GetPageProps { access?: AccessControl; group?: string; recordsPerPage?: number; + search?: string; + kind?: "image" | "video" | "pdf" | "other"; + sort?: "newest" | "oldest" | "name" | "largest"; } diff --git a/apps/api/src/media/handlers.ts b/apps/api/src/media/handlers.ts index dd9d33c7..e5105f6a 100644 --- a/apps/api/src/media/handlers.ts +++ b/apps/api/src/media/handlers.ts @@ -153,6 +153,14 @@ export async function deleteMedia(req: any, res: any) { } } +/* HTTP status for an error from mediaService.sealMedia. Shared by the + API-key route and the dashboard's session route. */ +export function sealErrorStatus(err: { message?: string }): number { + if (err.message === "Media not found") return 404; + if (err.message === "Media is already sealed") return 409; + return 500; +} + export async function sealMedia(req: any, res: any) { const { mediaId } = req.params; @@ -165,12 +173,7 @@ export async function sealMedia(req: any, res: any) { return res.status(200).json(media); } catch (err: any) { - const statusCode = - err.message === "Media not found" - ? 404 - : err.message === "Media is already sealed" - ? 409 - : 500; + const statusCode = sealErrorStatus(err); if (statusCode === 500) { logger.error({ err }, err.message); } diff --git a/apps/api/src/media/kind.ts b/apps/api/src/media/kind.ts new file mode 100644 index 00000000..61da0473 --- /dev/null +++ b/apps/api/src/media/kind.ts @@ -0,0 +1,16 @@ +export const MEDIA_KINDS = ["image", "video", "pdf", "other"] as const; +export type MediaKind = (typeof MEDIA_KINDS)[number]; + +/* The one rule for what kind a file is. mediaKindCondition() in + db/repository.ts is its SQL twin; keep the two in step. */ +export function mediaKind(mimeType: string): MediaKind { + const type = mimeType.toLowerCase(); + if (type.startsWith("image/")) return "image"; + if (type.startsWith("video/")) return "video"; + if (type.includes("pdf")) return "pdf"; + return "other"; +} + +export function parseMediaKind(value: unknown): MediaKind | undefined { + return MEDIA_KINDS.find((kind) => kind === value); +} diff --git a/apps/api/src/media/queries.ts b/apps/api/src/media/queries.ts index d6b5f387..0334da44 100644 --- a/apps/api/src/media/queries.ts +++ b/apps/api/src/media/queries.ts @@ -19,13 +19,17 @@ export function buildMediaCountQuery({ apikey, access, group, + search, + kind, }: { userId: string; apikey: string; access?: AccessControl; group?: string; + search?: string; + kind?: GetPageProps["kind"]; }) { - return { userId: String(userId), apikey, access, group }; + return { userId: String(userId), apikey, access, group, search, kind }; } function escapeRegex(value: string): string { @@ -52,11 +56,15 @@ export async function getMediaCount({ apikey, access, group, + search, + kind, }: { userId: string; apikey: string; access?: AccessControl; group?: string; + search?: string; + kind?: GetPageProps["kind"]; }): Promise { return countMedia({ userId: String(userId), @@ -68,6 +76,8 @@ export async function getMediaCount({ ? "public" : undefined, group, + search, + kind, }); } @@ -88,7 +98,10 @@ export async function getPaginatedMedia({ page, group, recordsPerPage, -}: GetPageProps): Promise { + search, + kind, + sort, +}: GetPageProps): Promise> { return listMedia({ userId: String(userId), apikey, @@ -100,6 +113,9 @@ export async function getPaginatedMedia({ group, page, recordsPerPage: recordsPerPage || numberOfRecordsPerPage, + search, + kind, + sort, }); } diff --git a/apps/api/src/media/schemas.ts b/apps/api/src/media/schemas.ts index bd91acbc..57d81df1 100644 --- a/apps/api/src/media/schemas.ts +++ b/apps/api/src/media/schemas.ts @@ -35,6 +35,7 @@ export interface MediaListItemResponse { thumbnail: string; caption?: string; group?: string; + createdAt: Date; } export const mediaResponseSchema = Joi.object({ @@ -58,6 +59,7 @@ export const mediaListItemResponseSchema = Joi.object({ thumbnail: Joi.string().uri().optional().allow(""), caption: Joi.string().optional().allow(""), group: Joi.string().optional(), + createdAt: Joi.date().iso().required(), }); export const mediaCountResponseSchema = Joi.object({ diff --git a/apps/api/src/media/service.ts b/apps/api/src/media/service.ts index 339eac3c..526a386f 100644 --- a/apps/api/src/media/service.ts +++ b/apps/api/src/media/service.ts @@ -38,7 +38,7 @@ import { getPaginatedMedia, createMedia, } from "./queries"; -import { sealMediaRecord } from "@/db"; +import { listMediaRecordsByIds, sealMediaRecord } from "@/db"; import * as presignedUrlService from "../signature/service"; import getTags from "./utils/get-tags"; import { getPublicFileUrl, getThumbnailUrl } from "./utils/get-public-urls"; @@ -198,6 +198,7 @@ type MappedMedia = Partial< > & { access: AccessControl; thumbnail: string; + createdAt: Date; }; async function getPage({ @@ -207,6 +208,9 @@ async function getPage({ page, group, recordsPerPage, + search, + kind, + sort, }: GetPageProps): Promise { const result = await getPaginatedMedia({ userId, @@ -215,6 +219,9 @@ async function getPage({ page, group, recordsPerPage, + search, + kind, + sort, }); const mappedResult = result.map( (media): MappedMedia => ({ @@ -229,6 +236,7 @@ async function getPage({ thumbnail: media.thumbnailGenerated ? getThumbnailUrl(media) : "", caption: media.caption, group: media.group, + createdAt: media.createdAt, }), ); @@ -330,18 +338,8 @@ async function getPrivateFileUrl(media: MediaWithUserId, thumb?: boolean) { : await generateSignedUrl(key, bucket); } -async function deleteMedia({ - userId, - apikey, - mediaId, -}: { - userId: string; - apikey: string; - mediaId: string; -}): Promise { - const media = await getMedia({ userId, apikey, mediaId }); - if (!media) return; - +/* Removes a media item's main file and thumbnail from storage. */ +async function removeStoredFiles(media: MediaWithUserId): Promise { // Determine which bucket the main file is in const mainBucket = media.temp || media.accessControl === Constants.AccessControl.PRIVATE @@ -355,7 +353,7 @@ async function deleteMedia({ const fileExtension = path.extname(media.fileName).replace(".", ""); const key = generateKey({ - mediaId, + mediaId: media.mediaId, path: mainPath, filename: `main.${fileExtension}`, }); @@ -368,16 +366,73 @@ async function deleteMedia({ ? Constants.PathKey.PRIVATE : Constants.PathKey.PUBLIC; const thumbKey = generateKey({ - mediaId, + mediaId: media.mediaId, path: thumbPath, filename: "thumb.webp", }); await deleteObject({ Key: thumbKey, Bucket: thumbBucket }); } +} + +async function deleteMedia({ + userId, + apikey, + mediaId, +}: { + userId: string; + apikey: string; + mediaId: string; +}): Promise { + const media = await getMedia({ userId, apikey, mediaId }); + if (!media) return; + await mediaService.removeStoredFiles(media); await deleteMediaQuery(userId, mediaId); } +const DELETE_CONCURRENCY = 10; + +/* Deletes several media items of one app. Looks them all up in one query; + an id that doesn't exist, or whose files can't be removed, counts as + failed. */ +async function deleteMediaMany({ + userId, + apikey, + mediaIds, +}: { + userId: string; + apikey: string; + mediaIds: string[]; +}): Promise<{ deleted: number; failed: number }> { + const uniqueIds = Array.from(new Set(mediaIds)); + const found = await listMediaRecordsByIds({ + userId, + apikey, + mediaIds: uniqueIds, + }); + + let deleted = 0; + for (let start = 0; start < found.length; start += DELETE_CONCURRENCY) { + const results = await Promise.allSettled( + found + .slice(start, start + DELETE_CONCURRENCY) + .map(async (media) => { + await mediaService.removeStoredFiles(media); + await deleteMediaQuery(userId, media.mediaId); + }), + ); + for (const result of results) { + if (result.status === "fulfilled") { + deleted++; + } else { + logger.error({ err: result.reason }, "Failed to delete media"); + } + } + } + + return { deleted, failed: uniqueIds.length - deleted }; +} + async function sealMedia({ userId, apikey, @@ -490,11 +545,15 @@ async function sealMedia({ return await getMediaResponse(updatedMedia); } -export default { +const mediaService = { upload, getPage, getMediaCount: getMediaCountWithFilter, getMediaDetails, deleteMedia, + deleteMediaMany, + removeStoredFiles, sealMedia, }; + +export default mediaService; diff --git a/apps/api/src/signature/handlers.ts b/apps/api/src/signature/handlers.ts index 527e91e8..00f7fcb8 100644 --- a/apps/api/src/signature/handlers.ts +++ b/apps/api/src/signature/handlers.ts @@ -3,12 +3,15 @@ import Joi from "joi"; import logger from "../services/log"; import * as preSignedUrlService from "./service"; -function validatePresigningOptions(req: Request): Joi.ValidationResult { +/* Checks the options a signature can carry. Shared by the API-key route + and the dashboard's session route. */ +export function validatePresigningOptions( + body: Request["body"], +): Joi.ValidationResult { const uploadSchema = Joi.object({ group: Joi.string().optional(), }); - const { group } = req.body; - return uploadSchema.validate({ group }); + return uploadSchema.validate({ group: body?.group }); } export async function getSignature( @@ -16,7 +19,7 @@ export async function getSignature( res: any, next: (...args: any[]) => void, ) { - const { error } = validatePresigningOptions(req); + const { error } = validatePresigningOptions(req.body); if (error) { return res.status(400).json({ error: error.message }); } diff --git a/apps/api/src/swagger_output.json b/apps/api/src/swagger_output.json index e641056f..bee22a91 100644 --- a/apps/api/src/swagger_output.json +++ b/apps/api/src/swagger_output.json @@ -123,6 +123,9 @@ } } }, + "403": { + "description": "Forbidden" + }, "500": { "description": "Internal Server Error", "content": { @@ -203,6 +206,9 @@ } } }, + "403": { + "description": "Forbidden" + }, "500": { "description": "Internal Server Error", "content": { @@ -272,6 +278,9 @@ } } }, + "403": { + "description": "Forbidden" + }, "500": { "description": "Internal Server Error", "content": { @@ -470,6 +479,9 @@ } } }, + "403": { + "description": "Forbidden" + }, "500": { "description": "Internal Server Error", "content": { @@ -540,6 +552,9 @@ } } }, + "403": { + "description": "Forbidden" + }, "500": { "description": "Internal Server Error", "content": { @@ -621,6 +636,9 @@ } } }, + "403": { + "description": "Forbidden" + }, "404": { "description": "Media not found", "content": { @@ -706,6 +724,9 @@ } } }, + "403": { + "description": "Forbidden" + }, "500": { "description": "Internal Server Error", "content": { @@ -797,6 +818,9 @@ } } }, + "403": { + "description": "Forbidden" + }, "404": { "description": "Media not found", "content": { @@ -897,6 +921,9 @@ } } }, + "403": { + "description": "Forbidden" + }, "500": { "description": "Internal Server Error", "content": { @@ -1019,6 +1046,10 @@ }, "group": { "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" } }, "required": [ @@ -1026,7 +1057,8 @@ "originalFileName", "mimeType", "size", - "access" + "access", + "createdAt" ], "additionalProperties": false }, diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md new file mode 100644 index 00000000..e186738a --- /dev/null +++ b/apps/web/AGENTS.md @@ -0,0 +1,30 @@ +# Web App Development Guide + +## Development Tips + +- Do not implement login related UI or API routes (which contains authorization related business logic) in the web app. All auth related stuff is controlled by the apps/api microservice +- Use Bun 1.4.1 and run package-specific commands with `bun --filter @medialit/web `. +- Keep the web app a thin client; all product behavior and authorization belong to the API. +- Use `@codelitdev/design-system` for shared visual components and styles. +- Run local dependencies from the repository root with `docker compose -f docker-compose.local.yml up`. Run the API and web app on the host, and do not run this compose file alongside `docker-compose.yml`. +- Use the API and Postgres for product data; do not add another store for the same records. +- Start the web app with `bun --filter @medialit/web dev`. +- Always use Tailwind for styling. Use custom CSS only when it is technically challenging to achieve the same result with Tailwind. + +## PR Review + +- Verify the web app adds no login UI, authorization business logic, or auth-related API routes. +- Verify product behavior is implemented by the API and the web app consumes the API's responses and actions. +- Review loading, empty, and error states, along with accessible labels and responsive layouts. +- Check that shared UI follows `@codelitdev/design-system` patterns. +- Run the relevant web checks, such as `bun --filter @medialit/web lint` and `bun --filter @medialit/web build`. + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/apps/web/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index f7909293..b0511640 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /app COPY package.json bun.lock ./ COPY apps/web/package.json ./apps/web/package.json COPY packages/models/package.json ./packages/models/package.json +COPY packages/uploader/package.json ./packages/uploader/package.json RUN bun install --frozen-lockfile --filter @medialit/web @@ -12,9 +13,12 @@ FROM deps AS builder COPY tsconfig.json ./ COPY apps/web ./apps/web COPY packages/models ./packages/models +COPY packages/uploader ./packages/uploader ENV NEXT_TELEMETRY_DISABLED=1 +# The uploader resolves to its built dist, which is not checked in. +RUN bun --filter @medialit/uploader build RUN bun --filter @medialit/web build FROM node:22-alpine AS runner diff --git a/apps/web/app/account/billing/action.ts b/apps/web/app/account/billing/action.ts index ebf8a40e..51d01c94 100644 --- a/apps/web/app/account/billing/action.ts +++ b/apps/web/app/account/billing/action.ts @@ -1,6 +1,5 @@ "use server"; -import { auth } from "@/auth"; import { serverApi } from "@/lib/server-api"; type BillingIntent = "checkout" | "portal" | "cancel" | "resume"; @@ -25,10 +24,6 @@ async function postBilling( body: Record = {}, ): Promise { try { - const session = await auth(); - if (!session?.user) { - throw new Error("Unauthorized"); - } const tokenResponse = await serverApi( "/api/account/billing/action-token", { method: "POST", body: JSON.stringify({ intent }) }, diff --git a/apps/web/app/account/billing/billing-button.tsx b/apps/web/app/account/billing/billing-button.tsx index 96f34030..a2e12426 100644 --- a/apps/web/app/account/billing/billing-button.tsx +++ b/apps/web/app/account/billing/billing-button.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; -import { useToast } from "@/components/ui/use-toast"; import { openBillingPortal, startProCheckout } from "./action"; +import { toast } from "sonner"; /** Starts a provider checkout or opens the provider's billing portal. */ export default function BillingButton({ @@ -17,7 +17,6 @@ export default function BillingButton({ className?: string; children: React.ReactNode; }) { - const { toast } = useToast(); const [pending, setPending] = useState(false); // Pressing back from the provider's page can restore this page from the @@ -50,8 +49,7 @@ export default function BillingButton({ } setPending(false); if (!result.success) { - toast({ - title: "Uh oh!", + toast.error("Uh oh!", { description: result.error || "Billing request failed", }); } diff --git a/apps/web/app/account/billing/cancel-subscription-button.tsx b/apps/web/app/account/billing/cancel-subscription-button.tsx index d792b833..99b49928 100644 --- a/apps/web/app/account/billing/cancel-subscription-button.tsx +++ b/apps/web/app/account/billing/cancel-subscription-button.tsx @@ -4,8 +4,8 @@ import { Button } from "@/components/ui/button"; import { useFormStatus } from "react-dom"; import { cancelSubscription } from "./action"; import { useEffect, useState, useActionState } from "react"; -import { useToast } from "@/components/ui/use-toast"; import { useRouter } from "next/navigation"; +import { toast } from "sonner"; import { Dialog, DialogContent, @@ -25,15 +25,13 @@ export default function CancelSubscriptionButton({ const [formState, formAction] = useActionState(cancelSubscription, { success: false, } as Awaited>); - const { toast } = useToast(); const router = useRouter(); const [open, setOpen] = useState(false); useEffect(() => { if (formState.success) { setOpen(false); - toast({ - title: "We are sorry to see you go", + toast.success("We are sorry to see you go", { description: "Your subscription is cancelled. Pro stays active until the end of the paid period.", }); @@ -44,8 +42,7 @@ export default function CancelSubscriptionButton({ return; } if (formState.error) { - toast({ - title: "Uh oh!", + toast.error("Uh oh!", { description: formState.error, }); } @@ -54,7 +51,7 @@ export default function CancelSubscriptionButton({ return ( - @@ -84,12 +81,7 @@ export default function CancelSubscriptionButton({ function Submit({ children }: { children: React.ReactNode }) { const status = useFormStatus(); return ( - ); diff --git a/apps/web/app/account/billing/layout.tsx b/apps/web/app/account/billing/layout.tsx index 58f86d8f..53b686d1 100644 --- a/apps/web/app/account/billing/layout.tsx +++ b/apps/web/app/account/billing/layout.tsx @@ -1,36 +1,24 @@ -import React from "react"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, -} from "@/components/ui/breadcrumb"; -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { redirect } from "next/navigation"; import { auth } from "@/auth"; -const SchoolDetailsLayout = async ({ children }: { children: ReactNode }) => { +export default async function BillingLayout({ + children, +}: { + children: ReactNode; +}) { const session = await auth(); - - if (!session) { - return redirect("/login?from=/account/billing"); - } + if (!session) redirect("/login?next=/account/billing"); return ( <> -
- - - - All apps - - - -

Billing

- {children} -
+
+
+

Billing

+

Manage your plan and payment settings.

+
+
+ {children} ); -}; - -export default SchoolDetailsLayout as any; +} diff --git a/apps/web/app/account/billing/page.tsx b/apps/web/app/account/billing/page.tsx index 1c189bbb..f26f4f1d 100644 --- a/apps/web/app/account/billing/page.tsx +++ b/apps/web/app/account/billing/page.tsx @@ -1,12 +1,5 @@ import { getSubscriber } from "@/app/actions"; import { redirect } from "next/navigation"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import PricingPlans, { type BillingPlans } from "./pricing-plans"; import { serverApi } from "@/lib/server-api"; @@ -17,26 +10,24 @@ const Billing = async () => { } if (user.plan === "oss") { return ( -
-

- This installation is unlocked. Storage and upload size are - not limited by a plan. -

- - - - OSS - - - Full access, with no checkout. - - - -

- Unlimited storage and file size. -

-
-
+
+
+ + + Active + +

Open source plan

+

+ This installation is unlocked. Storage and upload size + are not limited by a plan. +

+
+
+ Storage + Unlimited + Maximum upload size + Unlimited +
); } @@ -47,11 +38,6 @@ const Billing = async () => { const billing: BillingPlans = await response.json(); return (
-

- Leave all your upload woes to us! We take care of your file - uploads so that you focus on your users. -

-
); diff --git a/apps/web/app/account/billing/pricing-plans.tsx b/apps/web/app/account/billing/pricing-plans.tsx index 81aa571a..455808c2 100644 --- a/apps/web/app/account/billing/pricing-plans.tsx +++ b/apps/web/app/account/billing/pricing-plans.tsx @@ -140,7 +140,7 @@ function PricingPane({ {features.map((feature) => (

{feature}

@@ -152,7 +152,8 @@ function PricingPane({ {name === "Basic" && current && ( diff --git a/apps/web/app/account/billing/resume-subscription-button.tsx b/apps/web/app/account/billing/resume-subscription-button.tsx index 6ae3d6b1..168cca6e 100644 --- a/apps/web/app/account/billing/resume-subscription-button.tsx +++ b/apps/web/app/account/billing/resume-subscription-button.tsx @@ -3,9 +3,9 @@ import { Button } from "@/components/ui/button"; import { useFormStatus } from "react-dom"; import { resumeSubscription } from "./action"; -import { useToast } from "@/components/ui/use-toast"; import { useEffect, useActionState } from "react"; import { useRouter } from "next/navigation"; +import { toast } from "sonner"; export default function ResumeSubscriptionButton({ paidThroughAt, @@ -18,7 +18,6 @@ export default function ResumeSubscriptionButton({ const [formState, formAction] = useActionState(resumeSubscription, { success: false, } as Awaited>); - const { toast } = useToast(); const router = useRouter(); useEffect(() => { @@ -27,8 +26,7 @@ export default function ResumeSubscriptionButton({ window.location.href = formState.url; return; } - toast({ - title: "Welcome back!", + toast.success("Welcome back!", { description: "Your subscription has been resumed", }); router.refresh(); @@ -38,8 +36,7 @@ export default function ResumeSubscriptionButton({ return; } if (formState.error) { - toast({ - title: "Uh oh!", + toast.error("Uh oh!", { description: formState.error, }); } diff --git a/apps/web/app/account/page.tsx b/apps/web/app/account/page.tsx new file mode 100644 index 00000000..014939e9 --- /dev/null +++ b/apps/web/app/account/page.tsx @@ -0,0 +1,106 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { ArrowUpRight, CreditCard, UserRound } from "lucide-react"; +import { auth } from "@/auth"; +import { getSubscriber, getUser } from "@/app/actions"; + +export default async function AccountPage() { + const [session, account, user] = await Promise.all([ + auth(), + getSubscriber(), + getUser(), + ]); + if (!session) redirect("/login?next=/account"); + if (!account) redirect("/login?next=/account"); + + const planName = + account.plan === "oss" + ? "Open source" + : account.plan === "pro" + ? "Pro" + : "Basic"; + const subscriptionStatus = account.subscription?.status; + + return ( + <> +
+
+

Account settings

+

+ Profile and subscription details for your MediaLit + account. +

+
+
+ +
+
+
+ + +
+

Profile

+

+ Account information used to sign in. +

+
+
+
+ Name + {user?.name || "—"} + Email + {account.email} +
+
+ +
+
+ + +
+

Plan

+

+ Your plan sets storage and upload limits. +

+
+
+
+
+ + {planName} + + {subscriptionStatus ? ( + + {subscriptionStatus === "past_due" + ? "Payment overdue" + : subscriptionStatus === "cancelling" + ? "Cancelling" + : "Active"} + + ) : null} +
+ + Manage billing
+ {account.subscription?.paidThroughAt ? ( +

+ Access continues through{" "} + {new Date( + account.subscription.paidThroughAt, + ).toLocaleDateString()} + . +

+ ) : null} +
+
+ + ); +} diff --git a/apps/web/app/actions.ts b/apps/web/app/actions.ts index 97e72031..efc5a475 100644 --- a/apps/web/app/actions.ts +++ b/apps/web/app/actions.ts @@ -14,6 +14,8 @@ export async function getUser(): Promise { return session?.user ?? null; } +/* Server actions below leave the session check to the API: it answers 401 + without one, so checking here first would only add a round trip. */ export async function getSubscriber(): Promise< | (Pick & { plan: "oss" | "basic" | "pro"; @@ -27,28 +29,72 @@ export async function getSubscriber(): Promise< }) | null > { - const session = await auth(); - if (!session?.user) return null; const response = await serverApi("/api/account"); if (!response.ok) return null; return response.json(); } export async function getApiKeys() { - const session = await auth(); - if (!session?.user) return; const response = await serverApi("/api/apps"); if (!response.ok) return; return response.json(); } +export type AppSummary = { + keyId: string; + name: string; + default: boolean; + count: number; + storage: number; + images: number; + videos: number; + pdfs: number; + others: number; + imageStorage: number; + videoStorage: number; + pdfStorage: number; + otherStorage: number; + lastUpload: string | null; + /** Percent of the account's storage. */ + share: number; +}; + +export type AppsDashboardData = { + apps: AppSummary[]; + appCount: number; + totalFiles: number; + totalStorage: number; + totalImages: number; + totalVideos: number; + totalPdfs: number; + totalOthers: number; + largestApp: { + keyId: string; + name: string; + storage: number; + share: number; + } | null; +}; + +export async function getAppsDashboard(): Promise { + const response = await serverApi("/api/apps/overview"); + if (!response.ok) return null; + return response.json(); +} + +export async function getAppSummary(keyId: string): Promise { + const response = await serverApi( + `/api/apps/${encodeURIComponent(keyId)}/summary`, + ); + if (!response.ok) { + throw new Error(await errorMessage(response)); + } + return response.json(); +} + export async function getApikeyUsingKeyId( keyId: string, ): Promise | null> { - const session = await auth(); - if (!session?.user) { - throw new Error("Unauthenticated"); - } const response = await serverApi(`/api/apps/${encodeURIComponent(keyId)}`); if (response.status === 404) return null; if (!response.ok) { @@ -60,7 +106,7 @@ export async function getApikeyUsingKeyId( export async function createNewApiKey( prevState: Record, formData: FormData, -): Promise<{ success: boolean; error?: string }> { +): Promise<{ success: boolean; error?: string; keyId?: string }> { const name = formData.get("apiKey") as string; try { const response = await serverApi("/api/apps", { @@ -70,7 +116,8 @@ export async function createNewApiKey( if (!response.ok) { return { success: false, error: await errorMessage(response) }; } - return { success: true }; + const data = await response.json(); + return { success: true, keyId: data.keyId }; } catch (err: any) { return { success: false, error: err.message }; } diff --git a/apps/web/app/app/[keyid]/connect/connect-guide.tsx b/apps/web/app/app/[keyid]/connect/connect-guide.tsx new file mode 100644 index 00000000..5b40476c --- /dev/null +++ b/apps/web/app/app/[keyid]/connect/connect-guide.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useState } from "react"; +import { Check, ChevronDown, Copy, ExternalLink } from "lucide-react"; + +export default function ConnectGuide({ + appName, + apiEndpoint, + includeCliEndpoint, + serverUrl, +}: { + appName: string; + apiEndpoint: string; + includeCliEndpoint: boolean; + serverUrl: string; +}) { + const [copied, setCopied] = useState(null); + const [copyError, setCopyError] = useState(null); + + async function copyText(text: string, key: string) { + try { + await navigator.clipboard.writeText(text); + setCopied(key); + setCopyError(null); + window.setTimeout(() => setCopied(null), 1800); + } catch { + setCopyError(key); + } + } + + const cliCommands = [ + { + name: "Install the CLI", + key: "cli-install", + command: "npm install -g @medialit/cli", + }, + { + name: "Log in and choose this app", + key: "cli-login", + command: `medialit login${includeCliEndpoint ? ` --endpoint "${apiEndpoint}"` : ""}`, + }, + { + name: "Upload a file", + key: "cli-upload", + command: "medialit upload ./photo.jpg --public", + }, + ]; + + function mcpUrlRow(id: string, copyKey: string) { + return ( + <> + +
+ + +
+ {copyError === copyKey ? ( +

+ Could not copy the server URL. Select and copy it + instead. +

+ ) : null} + + ); + } + + return ( +
+
+ +

MCP

+
+
+

+ Add MediaLit as an MCP server so your AI assistant can + find files, upload new ones, copy their URLs, and change + who can see them in {appName}. No API key needed. +

+ {mcpUrlRow("mcp-server-url", "mcp-url")} +
    +
  1. + Open your AI client’s MCP settings and add a remote + server. +
  2. +
  3. + Choose an HTTP or Streamable HTTP connection, then + paste the MCP server URL above. +
  4. +
  5. + Connect and sign in to MediaLit, then select{" "} + {appName} when prompted. +
  6. +
+
+

Things to ask once it’s connected

+
    +
  • + “Upload ./assets/hero.png to {appName} and give + me its URL.” +
  • +
  • + “Which videos in {appName} are larger than 2 + MB?” +
  • +
  • “Make every PDF in {appName} private.”
  • +
+
+
+
+ +
+ +

CLI

+
+
+

+ Install MediaLit’s CLI, sign in in your browser and + select this app, then upload files from your terminal. +

+
+ {cliCommands.map(({ name, key, command }) => ( +
+
+ {name} + +
+ {command} +
+ ))} +
+ {copyError?.startsWith("cli-") ? ( +

+ Could not copy the command. Select and copy it + instead. +

+ ) : null} +
+
+ +
+
+ SDK and REST API +

+ Use this app’s API key to upload and serve files from + your own product. +

+
+ + Read the docs +
+
+ ); +} diff --git a/apps/web/app/app/[keyid]/connect/page.tsx b/apps/web/app/app/[keyid]/connect/page.tsx new file mode 100644 index 00000000..78136a2f --- /dev/null +++ b/apps/web/app/app/[keyid]/connect/page.tsx @@ -0,0 +1,39 @@ +import { redirect } from "next/navigation"; +import { headers } from "next/headers"; +import { auth } from "@/auth"; +import { getApikeyUsingKeyId } from "@/app/actions"; +import { cliNeedsEndpoint, getPublicApiBase } from "@/lib/api-base"; +import ConnectGuide from "./connect-guide"; + +export default async function ConnectPage(props: { + params: Promise<{ keyid: string }>; +}) { + const [{ keyid }, session] = await Promise.all([props.params, auth()]); + if (!session) redirect("/login"); + + const app = await getApikeyUsingKeyId(keyid); + if (!app) redirect("/"); + + const apiBase = getPublicApiBase(await headers()); + const includeCliEndpoint = cliNeedsEndpoint(apiBase); + + return ( + <> +
+
+

Connect

+

+ Work with {app.name || "this app"} from an AI assistant + or your own code. +

+
+
+ + + ); +} diff --git a/apps/web/app/app/[keyid]/files/actions.ts b/apps/web/app/app/[keyid]/files/actions.ts index 34cac726..518fe0a7 100644 --- a/apps/web/app/app/[keyid]/files/actions.ts +++ b/apps/web/app/app/[keyid]/files/actions.ts @@ -1,19 +1,34 @@ "use server"; -import { auth } from "@/auth"; import { serverApi } from "@/lib/server-api"; -import { Media } from "@medialit/models"; +import type { MediaListItem } from "@/lib/media"; + +export type MediaFilters = { + search?: string; + kind?: "image" | "video" | "pdf" | "other"; + sort?: "newest" | "oldest" | "name" | "largest"; +}; + +function filtersQuery(filters: MediaFilters = {}) { + const query = new URLSearchParams(); + if (filters.search?.trim()) query.set("search", filters.search.trim()); + if (filters.kind) query.set("kind", filters.kind); + if (filters.sort && filters.sort !== "newest") + query.set("sort", filters.sort); + return query; +} export async function getMediaFiles( keyid: string, page: number, -): Promise { - const session = await auth(); - if (!session?.user) { - throw new Error("Unauthenticated"); - } + limit = 16, + filters: MediaFilters = {}, +): Promise { + const query = filtersQuery(filters); + query.set("page", String(page || 1)); + query.set("limit", String(limit)); const response = await serverApi( - `/api/apps/${encodeURIComponent(keyid)}/media?page=${page || 1}&limit=10`, + `/api/apps/${encodeURIComponent(keyid)}/media?${query.toString()}`, ); if (!response.ok) { const data = await response.json().catch(() => ({})); @@ -22,13 +37,11 @@ export async function getMediaFiles( return response.json(); } -export async function getCount(keyid: string) { - const session = await auth(); - if (!session?.user) { - throw new Error("Unauthenticated"); - } +export async function getCount(keyid: string, filters: MediaFilters = {}) { + const query = filtersQuery(filters); + const suffix = query.size ? `?${query.toString()}` : ""; const response = await serverApi( - `/api/apps/${encodeURIComponent(keyid)}/media/count`, + `/api/apps/${encodeURIComponent(keyid)}/media/count${suffix}`, ); if (!response.ok) { const data = await response.json().catch(() => ({})); diff --git a/apps/web/app/app/[keyid]/files/empty-files.tsx b/apps/web/app/app/[keyid]/files/empty-files.tsx new file mode 100644 index 00000000..c6ddbb8e --- /dev/null +++ b/apps/web/app/app/[keyid]/files/empty-files.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useRef, useState, type DragEvent } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Check, Copy, LoaderCircle, Upload } from "lucide-react"; +import { toast } from "sonner"; +import { + uploadFilesToApp, + uploadLabel, + type UploadStatus, +} from "./upload-files"; + +type ToolTab = "mcp" | "cli" | "sdk"; + +const toolTabs: { key: ToolTab; label: string }[] = [ + { key: "mcp", label: "AI assistant (MCP)" }, + { key: "cli", label: "CLI" }, + { key: "sdk", label: "SDK and API" }, +]; + +const panelClass = (active: boolean) => + `[grid-area:1/1] ${active ? "" : "invisible"}`; + +const stepClass = "grid list-none grid-cols-[26px_minmax(0,1fr)] gap-3"; +const stepNumberClass = + "grid h-[26px] w-[26px] place-items-center rounded-full bg-muted text-[13px] font-semibold text-muted-foreground"; + +export default function EmptyFiles({ + keyid, + appName, + serverUrl, + apiEndpoint, + includeCliEndpoint, +}: { + keyid: string; + appName: string; + serverUrl: string; + apiEndpoint: string; + includeCliEndpoint: boolean; +}) { + const inputRef = useRef(null); + const router = useRouter(); + const [dragging, setDragging] = useState(false); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(null); + const [error, setError] = useState(""); + const [tab, setTab] = useState("mcp"); + const [copied, setCopied] = useState(null); + + async function upload(files: File[]) { + if (!files.length) return; + + setBusy(true); + setError(""); + setStatus(null); + const result = await uploadFilesToApp(keyid, files, setStatus); + setStatus(null); + if (result.error) setError(result.error); + setBusy(false); + if (result.uploaded) { + toast.success( + result.uploaded === 1 + ? "File uploaded" + : `${result.uploaded} files uploaded`, + ); + router.refresh(); + } + } + + function handleDrop(event: DragEvent) { + event.preventDefault(); + setDragging(false); + if (busy) return; + upload(Array.from(event.dataTransfer.files)); + } + + async function copyText(text: string, key: string) { + try { + await navigator.clipboard.writeText(text); + setCopied(key); + window.setTimeout(() => setCopied(null), 1800); + } catch { + setError("Could not copy. Select the text and copy it instead."); + } + } + + const cliCommands = [ + { key: "cli-install", command: "npm install -g @medialit/cli" }, + { + key: "cli-login", + command: `medialit login${includeCliEndpoint ? ` --endpoint "${apiEndpoint}"` : ""}`, + }, + { + key: "cli-upload", + command: "medialit upload ./assets/hero.png --public", + }, + ]; + + const assistantPrompt = `Upload ./assets/hero.png to ${appName} and give me its URL.`; + + function copyBox(text: string, copyKey: string) { + return ( +
+ + {text} + + +
+ ); + } + + return ( +
+
{ + event.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + > + { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + upload(files); + }} + /> +
+ {busy ? ( +
+

+ {busy + ? uploadLabel(status) + : `Drop files to upload to ${appName}`} +

+

+ or{" "} + +

+ + Images, videos, PDFs and other files + + {error ? ( + + {error} + + ) : null} +
+ +
+
+

+ Or upload from your tools +

+ + All options in Connect → + +
+ +
+ {toolTabs.map(({ key, label }) => ( + + ))} +
+ +
+
+
    +
  1. + 1 +
    +

    + Add a remote MCP server in your AI + client and paste this URL. +

    + {copyBox(serverUrl, "mcp-url")} +
    +
  2. +
  3. + 2 +
    +

    + Sign in to MediaLit and choose{" "} + {appName} when asked. +

    +
    +
  4. +
  5. + 3 +
    +

    + Ask your assistant to upload something: +

    + {copyBox(assistantPrompt, "mcp-prompt")} +
    +
  6. +
+
+ +
+
    +
  1. + 1 +
    +

    + Install the MediaLit CLI. +

    + {copyBox( + cliCommands[0].command, + cliCommands[0].key, + )} +
    +
  2. +
  3. + 2 +
    +

    + Log in in your browser and choose{" "} + {appName}. +

    + {copyBox( + cliCommands[1].command, + cliCommands[1].key, + )} +
    +
  4. +
  5. + 3 +
    +

    + Upload a file from your terminal. +

    + {copyBox( + cliCommands[2].command, + cliCommands[2].key, + )} +
    +
  6. +
+
+ +
+
+

+ Use {appName}’s API key to upload and serve + files from your own product. +

+ + Read the docs + +
+
+
+
+
+ ); +} diff --git a/apps/web/app/app/[keyid]/files/file-preview/file-interactivity.tsx b/apps/web/app/app/[keyid]/files/file-preview/file-interactivity.tsx index 8b7c217b..c60e475d 100644 --- a/apps/web/app/app/[keyid]/files/file-preview/file-interactivity.tsx +++ b/apps/web/app/app/[keyid]/files/file-preview/file-interactivity.tsx @@ -1,93 +1,85 @@ "use client"; +import { toast } from "sonner"; +import { Copy } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { useToast } from "@/components/ui/use-toast"; -import { Media } from "@medialit/models"; -import { useState } from "react"; export default function FileInteractivity({ media, - keyid, + fileUrl, + loadError, }: { - media: Media & { - access: "public" | "private"; - }; - keyid: string; + media: { access: "public" | "private" }; + fileUrl: string; + loadError: boolean; }) { - const { toast } = useToast(); - const [fileDirectLink, setFileDirectLink] = useState(); - const [loading, setLoading] = useState(false); - - const directLink = async () => { - setLoading(true); - try { - const response = await fetch(`/app/${keyid}/files/get-media`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - mediaId: media.mediaId, - keyId: keyid, - }), - }); + const isPublic = media.access !== "private"; - if (!response.ok) { - throw new Error( - `Some error occured while fetching direct link`, - ); - } - const data = await response.json(); - - if (data?.media?.file) { - setFileDirectLink(data.media.file); - navigator.clipboard.writeText(data.media.file); - toast({ - description: "Direct link has been copied to the clipboard", - }); - } - } catch (e) { - toast({ - description: "Error in fetching direct link", - }); - } finally { - setLoading(false); - } + const copyUrl = () => { + navigator.clipboard.writeText(fileUrl); + toast.success("Success", { + description: isPublic + ? "Link has been copied to the clipboard" + : "Temporary link has been copied to the clipboard", + }); }; return ( -
-
- -
- - +
+ {isPublic ? ( +
+ +
+ + +
-
-
- -
- - + ) : null} + +
+
+

+ {isPublic ? "Public" : "Private"} +

+

+ {isPublic + ? "Anyone with the URL can view this file. Access is set at upload and can’t be changed." + : "Only signed links that expire can open this file. Access is set at upload and can’t be changed."} +

+ + {isPublic ? "Public" : "Private"} + +
+ +
+ + {isPublic ? null : ( +

+ This link stops working after a short time. +

+ )}
); diff --git a/apps/web/app/app/[keyid]/files/file-preview/index.tsx b/apps/web/app/app/[keyid]/files/file-preview/index.tsx index 8610e8c6..6dabf01b 100644 --- a/apps/web/app/app/[keyid]/files/file-preview/index.tsx +++ b/apps/web/app/app/[keyid]/files/file-preview/index.tsx @@ -1,179 +1,357 @@ -import { Media } from "@medialit/models"; +"use client"; + +import type { ReactNode } from "react"; import Image from "next/image"; +import { Checkbox } from "@codelitdev/design-system"; +import { + FileArchive, + FileAudio, + FileCode2, + FileImage, + FileText, + FileVideo, + File as FileIcon, + Globe, + LockKeyhole, +} from "lucide-react"; +import { Loader } from "@codelitdev/design-system"; +import type { MediaListItem } from "@/lib/media"; +import { formatFileSize, formatRelativeUpload } from "@/lib/media-format"; import { Dialog, DialogContent, - DialogHeader, DialogTitle, DialogTrigger, - DialogClose, } from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import { Card, CardContent, CardFooter } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; import FileInteractivity from "./file-interactivity"; -import { - FileText, - FileImage, - FileVideo, - FileAudio, - FileCode, - FileArchive, - File, -} from "lucide-react"; +import { useFileUrl } from "./use-file-url"; -const FileTypeIcon = ({ mimeType }: { mimeType: string }) => { - const [type] = mimeType.split("/"); - const iconSize = 48; +/* The API decides each file's kind; these are just its labels. */ +const kindLabels: Record = { + image: "Image", + video: "Video", + pdf: "PDF", + other: "File", +}; - switch (type) { - case "image": - return ; - case "video": - return ; - case "audio": - return ; - case "text": - return ; - case "application": - if (mimeType.includes("pdf")) { - return ; - } else if ( - mimeType.includes("zip") || - mimeType.includes("rar") || - mimeType.includes("tar") - ) { - return ( - - ); - } else if ( - mimeType.includes("json") || - mimeType.includes("javascript") || - mimeType.includes("xml") - ) { - return ; - } - return ; - default: - return ; +export function FileTypeIcon({ mimeType }: { mimeType: string }) { + if (mimeType.startsWith("image/")) return