diff --git a/apps/api/src/__tests__/notes.test.ts b/apps/api/src/__tests__/notes.test.ts index 52c1503..e67d9ef 100644 --- a/apps/api/src/__tests__/notes.test.ts +++ b/apps/api/src/__tests__/notes.test.ts @@ -1593,6 +1593,31 @@ describe("Chunked upload flow", () => { expect(res.status).toBe(500); expect((await res.json()).error).toBe("Corrupted upload session"); }); + + it("returns 500 when metadata is valid JSON but has the wrong shape", async () => { + const { json: initJson } = await initUpload({ chunkCount: 1 }); + const chunk = chunkData("test-chunk"); + await app.request(`/api/v1/notes/upload/${initJson.uploadId}/chunks/0`, { + method: "PUT", + headers: { "Content-Type": "application/octet-stream", "X-Chunk-Hash": sha256hex(chunk) }, + body: chunk as BodyInit, + }); + + const { uploads: uploadsTable } = await import("../db/schema.js"); + const { eq } = await import("drizzle-orm"); + // Valid JSON but missing every required metadata field. + db.update(uploadsTable) + .set({ metadata: JSON.stringify({ unexpected: "shape" }) }) + .where(eq(uploadsTable.id, initJson.uploadId)) + .run(); + + const res = await app.request(`/api/v1/notes/upload/${initJson.uploadId}/complete`, { + method: "POST", + headers: authHeaders(), + }); + expect(res.status).toBe(500); + expect((await res.json()).error).toBe("Corrupted upload session"); + }); }); describe("GET /api/v1/notes/:id/stream", () => { @@ -1850,6 +1875,30 @@ describe("GET /api/v1/notes/:id/stream", () => { expect(res.headers.get("X-Salt")).toBe(testSalt); expect(res.headers.get("X-Has-Password")).toBe("true"); }); + + it("streams chunks in order with read-ahead prefetching (more chunks than window)", async () => { + const chunkCount = 6; + const { id } = await createChunkedNote({ chunkCount }); + + const res = await app.request(`/api/v1/notes/${id}/stream`); + expect(res.status).toBe(200); + expect(res.headers.get("X-Chunk-Count")).toBe(String(chunkCount)); + + // Decode the length-prefixed framing and verify every chunk arrives + // in order with its original (client-encrypted) content. + const body = new Uint8Array(await res.arrayBuffer()); + const view = new DataView(body.buffer, body.byteOffset, body.byteLength); + const frames: string[] = []; + let offset = 0; + while (offset < body.byteLength) { + const len = view.getUint32(offset); + offset += 4; + frames.push(Buffer.from(body.subarray(offset, offset + len)).toString()); + offset += len; + } + + expect(frames).toEqual(Array.from({ length: chunkCount }, (_, i) => `chunk-data-${String(i)}`)); + }); }); describe("GET /api/v1/notes/:id/stream edge cases", () => { @@ -1885,6 +1934,7 @@ describe("GET /api/v1/notes/:id/stream edge cases", () => { async function createChunkedNoteViaApp( targetApp: ReturnType, + chunkCount = 1, ): Promise<{ id: string; deleteToken: string }> { const initRes = await targetApp.request("/api/v1/notes/upload/init", { method: "POST", @@ -1896,20 +1946,22 @@ describe("GET /api/v1/notes/:id/stream edge cases", () => { expiresIn: 3600, maxReads: 0, fileCount: 1, - chunkCount: 1, + chunkCount, }), }); const { uploadId } = (await initRes.json()) as { uploadId: string }; - const chunk = chunkData("chunk-data-0"); - await targetApp.request(`/api/v1/notes/upload/${uploadId}/chunks/0`, { - method: "PUT", - headers: { - "Content-Type": "application/octet-stream", - "X-Chunk-Hash": sha256hex(chunk), - }, - body: chunk as BodyInit, - }); + for (let i = 0; i < chunkCount; i++) { + const chunk = chunkData(`chunk-data-${String(i)}`); + await targetApp.request(`/api/v1/notes/upload/${uploadId}/chunks/${String(i)}`, { + method: "PUT", + headers: { + "Content-Type": "application/octet-stream", + "X-Chunk-Hash": sha256hex(chunk), + }, + body: chunk as BodyInit, + }); + } const completeRes = await targetApp.request(`/api/v1/notes/upload/${uploadId}/complete`, { method: "POST", @@ -1918,6 +1970,39 @@ describe("GET /api/v1/notes/:id/stream edge cases", () => { return completeRes.json() as Promise<{ id: string; deleteToken: string }>; } + it("errors the stream when a later chunk read fails mid-stream", async () => { + const realStorage = new LocalStorage(TEST_FILES_PATH); + // Create a 2-chunk note using real storage first + const { id } = await createChunkedNoteViaApp(createAppWithCustomStorage(db, realStorage), 2); + + // Stream through a storage whose chunk-1 read fails (chunk 0 still works, + // so the pre-flight check passes and headers are already sent). + const failingStorage: StorageBackend = { + save: (noteId, data) => realStorage.save(noteId, data), + read: (key) => realStorage.read(key), + delete: (key) => realStorage.delete(key), + saveChunk: (noteId, idx, data) => realStorage.saveChunk(noteId, idx, data), + readChunk: (noteId, idx) => + idx === 0 + ? realStorage.readChunk(noteId, idx) + : Promise.reject(new Error("mid-stream read failure")), + deleteChunks: (noteId, cnt) => realStorage.deleteChunks(noteId, cnt), + }; + const failApp = createAppWithCustomStorage(db, failingStorage); + + const res = await failApp.request(`/api/v1/notes/${id}/stream`); + expect(res.status).toBe(200); // Headers committed before the failure + + // Consuming the body must surface the stream error + let streamFailed = false; + try { + await res.arrayBuffer(); + } catch { + streamFailed = true; + } + expect(streamFailed).toBe(true); + }); + it("handles corrupted chunk data (too small for auth tag)", async () => { const realStorage = new LocalStorage(TEST_FILES_PATH); // Create note using real storage first diff --git a/apps/api/src/middleware/rateLimit.ts b/apps/api/src/middleware/rateLimit.ts index f9ad4d2..c39f1f2 100644 --- a/apps/api/src/middleware/rateLimit.ts +++ b/apps/api/src/middleware/rateLimit.ts @@ -96,15 +96,15 @@ export function createRateLimit(options: RateLimitOptions): RateLimitResult { if (existing === undefined || existing.resetAt <= now) { if (store.size >= MAX_STORE_SIZE && existing === undefined) { - let evicted = false; + // Bulk-evict every expired entry in one sweep: freeing all reusable + // slots at once (instead of one per request) keeps a full store from + // rejecting new clients under sustained traffic. for (const [key, entry] of store) { if (entry.resetAt <= now) { store.delete(key); - evicted = true; - break; } } - if (!evicted) { + if (store.size >= MAX_STORE_SIZE) { return c.json({ error: "Too many requests" }, 429); } } diff --git a/apps/api/src/routes/notes/chunked.ts b/apps/api/src/routes/notes/chunked.ts index 3fdde8f..ccd38b0 100644 --- a/apps/api/src/routes/notes/chunked.ts +++ b/apps/api/src/routes/notes/chunked.ts @@ -3,9 +3,11 @@ import type { OpenAPIHono } from "@hono/zod-openapi"; import { SECRETSTREAM_ABYTES, serverDecrypt, serverEncrypt } from "@secret/crypto"; import { chunkedUploadInitSchema, + isValidNoteId, NOTE_ID_LENGTH, UPLOAD_ID_LENGTH, UPLOAD_SESSION_TTL, + uploadSessionMetadataSchema, } from "@secret/shared"; import { eq } from "drizzle-orm"; import { nanoid } from "nanoid"; @@ -149,20 +151,20 @@ export function registerChunkedRoutes(app: OpenAPIHono): void { return c.json({ error: "Upload incomplete" }, 400); } - let meta: { - streamHeader: string; - clientNonce: string; - hasPassword: boolean; - expiresIn: number; - maxReads: number; - fileCount: number; - salt?: string; - }; + // Re-validate the persisted metadata instead of trusting it blindly: + // a corrupted row (bad JSON or missing fields) must not produce a + // malformed note. + let parsedMetadata: unknown; try { - meta = JSON.parse(session.metadata) as typeof meta; + parsedMetadata = JSON.parse(session.metadata); } catch { return c.json({ error: "Corrupted upload session" }, 500); } + const metaResult = uploadSessionMetadataSchema.safeParse(parsedMetadata); + if (!metaResult.success) { + return c.json({ error: "Corrupted upload session" }, 500); + } + const meta = metaResult.data; const now = new Date(); const expiresAt = new Date(now.getTime() + meta.expiresIn * 1000); @@ -219,7 +221,7 @@ export function registerChunkedRoutes(app: OpenAPIHono): void { app.get("/:id/stream", async (c) => { const id = c.req.param("id"); - if (!id || !/^[A-Za-z0-9_-]+$/.test(id) || id.length !== NOTE_ID_LENGTH) { + if (!id || !isValidNoteId(id)) { return c.json({ error: "Invalid note ID" }, 400); } @@ -268,11 +270,39 @@ export function registerChunkedRoutes(app: OpenAPIHono): void { throw err; } + // Read-ahead window: keep a few chunk reads in flight so per-chunk + // storage latency (one round-trip per chunk on S3) overlaps with + // decryption and streaming instead of accumulating sequentially. + // Rejections are captured as values so an un-awaited prefetch can + // never surface as an unhandled promise rejection. + const PREFETCH_CHUNKS = 4; + type ChunkRead = { ok: true; data: Buffer } | { ok: false; reason: unknown }; + const readChunkSafe = (index: number): Promise => + (index === 0 ? Promise.resolve(firstChunk) : storage.readChunk(id, index)).then( + (data) => ({ ok: true as const, data }), + (reason: unknown) => ({ ok: false as const, reason }), + ); + const stream = new ReadableStream({ async start(controller) { try { + const readAhead: Promise[] = []; + let nextToFetch = 0; + for (let i = 0; i < chunkCount; i++) { - const storedData = i === 0 ? firstChunk : await storage.readChunk(id, i); + // Top up the read-ahead window. + while (nextToFetch < chunkCount && readAhead.length < PREFETCH_CHUNKS) { + readAhead.push(readChunkSafe(nextToFetch)); + nextToFetch += 1; + } + + const chunkRead = await (readAhead.shift() as Promise); + if (!chunkRead.ok) { + controller.error(chunkRead.reason); + return; + } + + const storedData = chunkRead.data; const iv = storedData.subarray(0, IV_LENGTH); const encrypted = storedData.subarray(IV_LENGTH); diff --git a/apps/api/src/routes/notes/openapi-routes.ts b/apps/api/src/routes/notes/openapi-routes.ts index 9dcd833..f94c029 100644 --- a/apps/api/src/routes/notes/openapi-routes.ts +++ b/apps/api/src/routes/notes/openapi-routes.ts @@ -3,17 +3,17 @@ import { createNoteResponseSchema, createNoteSchema, deleteNoteResponseSchema, - NOTE_ID_LENGTH, noteExistsResponseSchema, + noteIdSchema, noteNotFoundResponseSchema, readNoteResponseSchema, } from "@secret/shared"; -const noteIdParam = z - .string() - .regex(/^[A-Za-z0-9_-]+$/, "Invalid note ID format") - .length(NOTE_ID_LENGTH, `Note ID must be ${String(NOTE_ID_LENGTH)} characters`) - .openapi({ param: { name: "id", in: "path" }, example: "aBcDeFgHiJkL" }); +// Reuse the shared note ID schema so the format is defined in one place. +const noteIdParam = noteIdSchema.openapi({ + param: { name: "id", in: "path" }, + example: "aBcDeFgHiJkL", +}); export const createNoteRoute = createRoute({ method: "post", diff --git a/apps/api/src/routes/notes/standard.ts b/apps/api/src/routes/notes/standard.ts index 16a6799..d51c694 100644 --- a/apps/api/src/routes/notes/standard.ts +++ b/apps/api/src/routes/notes/standard.ts @@ -1,6 +1,6 @@ import { timingSafeEqual } from "node:crypto"; import type { OpenAPIHono } from "@hono/zod-openapi"; -import { createNoteMultipartSchema, NOTE_ID_LENGTH } from "@secret/shared"; +import { createNoteMultipartSchema, isValidNoteId } from "@secret/shared"; import { eq } from "drizzle-orm"; import { notes } from "../../db/schema.js"; import { deleteOrSchedule } from "../../pendingDeletions.js"; @@ -128,7 +128,7 @@ export function registerStandardRoutes(app: OpenAPIHono): void { app.get("/:id/raw", async (c) => { const id = c.req.param("id"); - if (!id || !/^[A-Za-z0-9_-]+$/.test(id) || id.length !== NOTE_ID_LENGTH) { + if (!id || !isValidNoteId(id)) { return c.json({ error: "Invalid note ID" }, 400); } diff --git a/apps/api/src/storage/local.ts b/apps/api/src/storage/local.ts index 83de7a8..a330d5b 100644 --- a/apps/api/src/storage/local.ts +++ b/apps/api/src/storage/local.ts @@ -82,15 +82,9 @@ export class LocalStorage implements StorageBackend { async deleteChunks(noteId: string, chunkCount: number): Promise { assertChunkCount(chunkCount); + // All chunks of a note live under its own directory, so a single + // recursive removal replaces per-chunk unlink round-trips. const dirPath = this.assertSafePath(join(this.filesPath, noteId)); - for (let i = 0; i < chunkCount; i++) { - try { - const filePath = this.assertSafePath(join(dirPath, `chunk_${String(i)}`)); - await unlink(filePath); - } catch { - /* chunk already deleted or missing */ - } - } try { await rm(dirPath, { recursive: true }); } catch { diff --git a/apps/web/src/lib/components/FileCard.svelte b/apps/web/src/lib/components/FileCard.svelte new file mode 100644 index 0000000..83e85e1 --- /dev/null +++ b/apps/web/src/lib/components/FileCard.svelte @@ -0,0 +1,88 @@ + + +
  • + {#if previewUrl} + {#if category === "image"} + {name} + {:else if category === "video"} + + {:else if category === "audio"} + + {:else if category === "pdf"} + + {/if} + {/if} +
    + +
    +

    + {name} +

    +

    + {type} · {formatSize(size)} +

    +
    + +
    +
  • diff --git a/apps/web/src/lib/components/FileDropZone.svelte b/apps/web/src/lib/components/FileDropZone.svelte new file mode 100644 index 0000000..6ab406e --- /dev/null +++ b/apps/web/src/lib/components/FileDropZone.svelte @@ -0,0 +1,165 @@ + + +
    + + + {t("files_label")} + +
    fileInputEl?.click()} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + fileInputEl?.click(); + } + }} + ondragover={(e) => { + e.preventDefault(); + isDragging = true; + }} + ondragleave={() => { + isDragging = false; + }} + ondrop={handleDrop} + class="w-full cursor-pointer rounded-xl text-left transition-all" + style:background={isDragging ? "var(--accent-soft)" : "var(--bg-2)"} + style:border={`1px dashed ${isDragging ? "var(--accent)" : "var(--line-2)"}`} + style:padding="20px" + aria-label={t("files_drop")} + > + + {#if files.length === 0} +
    +
    +
    + {t("files_drop_1")} + {t("files_drop_2")} +
    +
    + {t("files_limit", { + count: maxFilesPerNote, + size: formatSize(maxFileSize), + })} +
    +
    + +
    + {:else} +
    + {#each files as f, i (f.name + i)} +
    + + + {f.name} + + + {formatSize(f.size)} + + +
    + {/each} +
    + {t("files_add_more")} ({files.length}/{maxFilesPerNote}) +
    +
    + {/if} +
    + {#if fileError} +

    {fileError}

    + {/if} +
    diff --git a/apps/web/src/lib/components/MarkdownEditor.svelte b/apps/web/src/lib/components/MarkdownEditor.svelte index 6fcc7f7..6da2b74 100644 --- a/apps/web/src/lib/components/MarkdownEditor.svelte +++ b/apps/web/src/lib/components/MarkdownEditor.svelte @@ -170,22 +170,51 @@ const toolbarBtnStyle = {#if activeTab === "write"}
    - - - - - -
    diff --git a/apps/web/src/lib/components/SecuritySettings.svelte b/apps/web/src/lib/components/SecuritySettings.svelte new file mode 100644 index 0000000..53b022c --- /dev/null +++ b/apps/web/src/lib/components/SecuritySettings.svelte @@ -0,0 +1,187 @@ + + +
    +
    + + {t("security_settings")} +
    + +
    +
    + +
    + +
    + + +
    +
    + {#if password} +
    +
    +
    +
    + {pwStrength.labelKey ? t(pwStrength.labelKey) : ""} +
    + {/if} + + {t("password_add_hint")} + +
    + +
    +
    + + +
    + +
    + + +
    +
    +
    +
    diff --git a/apps/web/src/lib/components/SkeletonLoader.svelte b/apps/web/src/lib/components/SkeletonLoader.svelte deleted file mode 100644 index 36d8611..0000000 --- a/apps/web/src/lib/components/SkeletonLoader.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - -
    -
    -
    -
    -
    -
    diff --git a/apps/web/src/lib/components/SuccessView.svelte b/apps/web/src/lib/components/SuccessView.svelte new file mode 100644 index 0000000..6a2a26c --- /dev/null +++ b/apps/web/src/lib/components/SuccessView.svelte @@ -0,0 +1,409 @@ + + +
    + +
    + + + {t("ok_eyebrow")} + +

    + {t("ok_hero_1")} + {t("ok_hero_unique")}.
    + {t("ok_hero_2")} + {t("ok_hero_3")} +

    +

    + {password ? t("ok_hero_sub_pw") : t("ok_hero_sub_nopw")} +

    +
    + + {#if clipboardError} + + {/if} + + +
    +
    + + + {t("ok_legend_server")} + + + + {t("ok_legend_key")} + +
    +
    +
    + {shareUrlParts.protocol}{shareUrlParts.host}{shareUrlParts.fragment} +
    + +
    +
    + + + + + {t("ok_email")} + + + + + {t("ok_preview")} + +
    +
    + + {#if showQR && qrCodeUrl} +
    + {t("qr_alt")} +

    + {t("ok_qr_hint")} +

    +
    + {/if} + + {#if password} +
    + + + +
    +
    {t("ok_pw_title")}
    +
    + {t("ok_pw_hint")} +
    +
    + +
    + {/if} + + +
    +
    + + {t("ok_facts_title")} +
    +
      +
    • + + + + {t("ok_facts_expiry")} + {t(expiryLabelKey)} +
    • +
    • + + + + {t("ok_facts_reads")} + {readsText} +
    • + {#if fileCount > 0} +
    • + + + + {t("ok_facts_files")} + {t("files_count", { count: fileCount })} +
    • + {/if} +
    +
    + + {#if manageUrl} +
    + + {t("delete_label")} + +
    +
    + + +
    +

    + {t("delete_warning")} +

    +
    +
    + {/if} + + +
    diff --git a/apps/web/src/lib/utils/__tests__/clipboard.test.ts b/apps/web/src/lib/utils/__tests__/clipboard.test.ts new file mode 100644 index 0000000..248402b --- /dev/null +++ b/apps/web/src/lib/utils/__tests__/clipboard.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { copyWithFeedback } from "../clipboard.js"; + +describe("copyWithFeedback", () => { + const writeText = vi.fn(); + + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + writeText.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("copies text and toggles the copied flag", async () => { + writeText.mockResolvedValue(undefined); + const setCopied = vi.fn(); + + const ok = await copyWithFeedback("secret-url", setCopied); + + expect(ok).toBe(true); + expect(writeText).toHaveBeenCalledWith("secret-url"); + expect(setCopied).toHaveBeenCalledWith(true); + + vi.advanceTimersByTime(2000); + expect(setCopied).toHaveBeenCalledWith(false); + }); + + it("respects a custom reset delay", async () => { + writeText.mockResolvedValue(undefined); + const setCopied = vi.fn(); + + await copyWithFeedback("text", setCopied, 5000); + expect(setCopied).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2000); + expect(setCopied).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(3000); + expect(setCopied).toHaveBeenCalledTimes(2); + expect(setCopied).toHaveBeenLastCalledWith(false); + }); + + it("returns false when the clipboard API rejects", async () => { + writeText.mockRejectedValue(new Error("denied")); + const setCopied = vi.fn(); + + const ok = await copyWithFeedback("text", setCopied); + + expect(ok).toBe(false); + expect(setCopied).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/utils/__tests__/fileType.test.ts b/apps/web/src/lib/utils/__tests__/fileType.test.ts new file mode 100644 index 0000000..d395341 --- /dev/null +++ b/apps/web/src/lib/utils/__tests__/fileType.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { getFileCategory, isPreviewable } from "../fileType.js"; + +describe("getFileCategory", () => { + it("categorizes image MIME types", () => { + expect(getFileCategory("image/png")).toBe("image"); + expect(getFileCategory("image/jpeg")).toBe("image"); + expect(getFileCategory("image/svg+xml")).toBe("image"); + }); + + it("categorizes video MIME types", () => { + expect(getFileCategory("video/mp4")).toBe("video"); + expect(getFileCategory("video/webm")).toBe("video"); + }); + + it("categorizes audio MIME types", () => { + expect(getFileCategory("audio/mpeg")).toBe("audio"); + expect(getFileCategory("audio/ogg")).toBe("audio"); + }); + + it("categorizes PDF", () => { + expect(getFileCategory("application/pdf")).toBe("pdf"); + }); + + it("categorizes everything else as other", () => { + expect(getFileCategory("application/zip")).toBe("other"); + expect(getFileCategory("text/plain")).toBe("other"); + expect(getFileCategory("application/octet-stream")).toBe("other"); + expect(getFileCategory("")).toBe("other"); + }); +}); + +describe("isPreviewable", () => { + it("returns true for previewable types", () => { + expect(isPreviewable("image/png")).toBe(true); + expect(isPreviewable("video/mp4")).toBe(true); + expect(isPreviewable("audio/mpeg")).toBe(true); + expect(isPreviewable("application/pdf")).toBe(true); + }); + + it("returns false for non-previewable types", () => { + expect(isPreviewable("application/zip")).toBe(false); + expect(isPreviewable("text/plain")).toBe(false); + expect(isPreviewable("")).toBe(false); + }); +}); diff --git a/apps/web/src/lib/utils/__tests__/password.test.ts b/apps/web/src/lib/utils/__tests__/password.test.ts new file mode 100644 index 0000000..af45d95 --- /dev/null +++ b/apps/web/src/lib/utils/__tests__/password.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { generatePassword, getPasswordStrength } from "../password.js"; + +describe("getPasswordStrength", () => { + it("returns a neutral result for an empty password", () => { + const result = getPasswordStrength(""); + expect(result.score).toBe(0); + expect(result.labelKey).toBeNull(); + expect(result.color).toBe("var(--muted-2)"); + }); + + it("scores a very weak password", () => { + // Short, lowercase only: 0 criteria met + const result = getPasswordStrength("abc"); + expect(result.score).toBe(0); + expect(result.labelKey).toBeNull(); + }); + + it("scores a weak password", () => { + // >= 8 chars only + const result = getPasswordStrength("abcdefgh"); + expect(result.score).toBe(1); + expect(result.labelKey).toBe("str_vweak"); + }); + + it("scores a medium password", () => { + // >= 8 chars, mixed case, digits + const result = getPasswordStrength("Abcdef12"); + expect(result.score).toBe(3); + expect(result.labelKey).toBe("str_ok"); + }); + + it("scores a strong password", () => { + // >= 14 chars, mixed case, digits + const result = getPasswordStrength("Abcdefghijkl12"); + expect(result.score).toBe(4); + expect(result.labelKey).toBe("str_strong"); + }); + + it("scores an excellent password with all criteria", () => { + // >= 14 chars, mixed case, digits, symbols + const result = getPasswordStrength("Abcdefghijk12!@"); + expect(result.score).toBe(5); + expect(result.labelKey).toBe("str_exc"); + }); + + it("assigns a color for every non-empty password", () => { + expect(getPasswordStrength("abc").color).toBe("#ef4444"); + expect(getPasswordStrength("Abcdefghijk12!@").color).toBe("#10b981"); + }); +}); + +describe("generatePassword", () => { + it("generates a password of the default length", () => { + expect(generatePassword()).toHaveLength(20); + }); + + it("generates a password of a custom length", () => { + expect(generatePassword(32)).toHaveLength(32); + expect(generatePassword(8)).toHaveLength(8); + }); + + it("only uses display-safe characters", () => { + const password = generatePassword(100); + expect(password).toMatch(/^[A-HJ-NP-Za-hj-km-z2-9!@#$%]+$/); + // Ambiguous characters are excluded + expect(password).not.toMatch(/[0OIl1]/); + }); + + it("generates different passwords on each call", () => { + expect(generatePassword()).not.toBe(generatePassword()); + }); + + it("rates its own output as excellent", () => { + const result = getPasswordStrength(generatePassword()); + expect(result.score).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/apps/web/src/lib/utils/clipboard.ts b/apps/web/src/lib/utils/clipboard.ts new file mode 100644 index 0000000..5ab7eae --- /dev/null +++ b/apps/web/src/lib/utils/clipboard.ts @@ -0,0 +1,23 @@ +/** + * Copy text to the clipboard and report a transient "copied" state. + * + * Calls `setCopied(true)` on success, then `setCopied(false)` after `resetMs`. + * Returns false when the Clipboard API is unavailable or rejects, so callers + * can surface an error message. + */ +export async function copyWithFeedback( + text: string, + setCopied: (copied: boolean) => void, + resetMs = 2000, +): Promise { + try { + await navigator.clipboard.writeText(text); + } catch { + return false; + } + setCopied(true); + setTimeout(() => { + setCopied(false); + }, resetMs); + return true; +} diff --git a/apps/web/src/lib/utils/fileType.ts b/apps/web/src/lib/utils/fileType.ts new file mode 100644 index 0000000..bdcafeb --- /dev/null +++ b/apps/web/src/lib/utils/fileType.ts @@ -0,0 +1,15 @@ +export type FileCategory = "image" | "video" | "audio" | "pdf" | "other"; + +/** Categorize a MIME type for preview rendering. */ +export function getFileCategory(mimeType: string): FileCategory { + if (mimeType.startsWith("image/")) return "image"; + if (mimeType.startsWith("video/")) return "video"; + if (mimeType.startsWith("audio/")) return "audio"; + if (mimeType === "application/pdf") return "pdf"; + return "other"; +} + +/** Whether the browser can render an inline preview for this MIME type. */ +export function isPreviewable(mimeType: string): boolean { + return getFileCategory(mimeType) !== "other"; +} diff --git a/apps/web/src/lib/utils/password.ts b/apps/web/src/lib/utils/password.ts new file mode 100644 index 0000000..a2d006b --- /dev/null +++ b/apps/web/src/lib/utils/password.ts @@ -0,0 +1,50 @@ +export type StrengthLabelKey = "str_vweak" | "str_weak" | "str_ok" | "str_strong" | "str_exc"; + +export interface PasswordStrength { + readonly score: number; + readonly labelKey: StrengthLabelKey | null; + readonly color: string; +} + +const STRENGTH_KEYS: readonly StrengthLabelKey[] = [ + "str_vweak", + "str_weak", + "str_ok", + "str_strong", + "str_exc", +]; +const STRENGTH_COLORS: readonly string[] = ["#ef4444", "#f97316", "#eab308", "#84cc16", "#10b981"]; + +/** Score a password from 0 to 5 based on length and character variety. */ +export function getPasswordStrength(password: string): PasswordStrength { + if (!password) return { score: 0, labelKey: null, color: "var(--muted-2)" }; + + let score = 0; + if (password.length >= 8) score++; + if (password.length >= 14) score++; + if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++; + if (/\d/.test(password)) score++; + if (/[^\w\s]/.test(password)) score++; + + const idx = Math.min(score - 1, 4); + return { + score, + labelKey: idx >= 0 ? (STRENGTH_KEYS[idx] ?? null) : null, + color: idx >= 0 ? (STRENGTH_COLORS[idx] ?? "#ef4444") : "#ef4444", + }; +} + +// Ambiguous characters (0/O, 1/l/I) are excluded so generated passwords +// can be read aloud or retyped without confusion. +const PASSWORD_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%"; + +/** Generate a random password from display-safe characters. */ +export function generatePassword(length = 20): string { + const values = new Uint32Array(length); + crypto.getRandomValues(values); + let password = ""; + for (let i = 0; i < length; i++) { + password += PASSWORD_CHARS[(values[i] ?? 0) % PASSWORD_CHARS.length]; + } + return password; +} diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index 4847dbd..3700fd0 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,18 +1,20 @@ @@ -312,364 +177,16 @@ const readsText = $derived( {#if shareUrl} -
    - -
    - - - {t("ok_eyebrow")} - -

    - {t("ok_hero_1")} - {t("ok_hero_unique")}.
    - {t("ok_hero_2")} - {t("ok_hero_3")} -

    -

    - {password ? t("ok_hero_sub_pw") : t("ok_hero_sub_nopw")} -

    -
    - - -
    -
    - - - {t("ok_legend_server")} - - - - {t("ok_legend_key")} - -
    -
    -
    - {#if shareUrlParts} - {shareUrlParts.protocol}{shareUrlParts.host}{shareUrlParts.fragment} - {/if} -
    - -
    -
    - - - - - {t("ok_email")} - - - - - {t("ok_preview")} - -
    -
    - - {#if showQR && qrCodeUrl} -
    - {t("qr_alt")} -

    - {t("ok_qr_hint")} -

    -
    - {/if} - - {#if password} -
    - - - -
    -
    {t("ok_pw_title")}
    -
    - {t("ok_pw_hint")} -
    -
    - -
    - {/if} - - -
    -
    - - {t("ok_facts_title")} -
    -
      -
    • - - - - {t("ok_facts_expiry")} - {t(expiryLabelKey)} -
    • -
    • - - - - {t("ok_facts_reads")} - {readsText} -
    • - {#if files.length > 0} -
    • - - - - {t("ok_facts_files")} - {t("files_count", { count: files.length })} -
    • - {/if} -
    -
    - - {#if manageUrl} -
    - - {t("delete_label")} - -
    -
    - - -
    -

    - {t("delete_warning")} -

    -
    -
    - {/if} - - -
    + {:else}
    { @@ -796,274 +313,11 @@ const readsText = $derived( {#if contentMode !== "secret"} -
    - - -
    - {/each} -
    - {t("files_add_more")} ({files.length}/{maxFilesPerNote}) -
    - - {/if} - - {#if fileError} -

    {fileError}

    - {/if} - + {/if} -
    -
    - - {t("security_settings")} -
    - -
    -
    - -
    - -
    - - -
    -
    - {#if password} -
    -
    -
    -
    - {pwStrength.label} -
    - {/if} - - {t("password_add_hint")} - -
    - -
    -
    - - -
    - -
    - - -
    -
    -
    -
    + {#if isSubmitting}
    diff --git a/apps/web/src/routes/note/[id]/+page.svelte b/apps/web/src/routes/note/[id]/+page.svelte index bddaa30..31bc9b1 100644 --- a/apps/web/src/routes/note/[id]/+page.svelte +++ b/apps/web/src/routes/note/[id]/+page.svelte @@ -4,13 +4,15 @@ import type { NotePayload } from "@secret/shared"; import { onMount } from "svelte"; import { fade, fly } from "svelte/transition"; import { page } from "$app/state"; +import FileCard from "$lib/components/FileCard.svelte"; import Icon from "$lib/components/Icon.svelte"; import StepProgress from "$lib/components/StepProgress.svelte"; import { getClient } from "$lib/client"; import { getConfig } from "$lib/config.svelte"; import { formatDateTime, t } from "$lib/i18n/index.svelte"; import { setStep } from "$lib/steps.svelte"; -import { formatSize } from "$lib/utils/format"; +import { copyWithFeedback } from "$lib/utils/clipboard"; +import { isPreviewable } from "$lib/utils/fileType"; interface NoteInfo { hasPassword: boolean; @@ -85,15 +87,9 @@ const showPwInput = $derived( const showPrimaryCta = $derived(status.state === "ready" && (!isBurn || burnAccepted)); async function copyText(text: string) { - try { - await navigator.clipboard.writeText(text); - copied = true; - setTimeout(() => { - copied = false; - }, 2000); - } catch { - /* clipboard API unavailable */ - } + await copyWithFeedback(text, (v) => { + copied = v; + }); } onMount(() => { @@ -177,37 +173,6 @@ async function handleDecrypt() { } } } - -function downloadFile(name: string, type: string, d: Uint8Array) { - const blob = new Blob([d] as BlobPart[], { type }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = name; - a.click(); - URL.revokeObjectURL(url); -} - -function isPreviewable(type: string): boolean { - return ( - type.startsWith("image/") || - type.startsWith("video/") || - type.startsWith("audio/") || - type === "application/pdf" - ); -} -function isImage(type: string): boolean { - return type.startsWith("image/"); -} -function isVideo(type: string): boolean { - return type.startsWith("video/"); -} -function isAudio(type: string): boolean { - return type.startsWith("audio/"); -} -function isPdf(type: string): boolean { - return type === "application/pdf"; -} @@ -467,8 +432,7 @@ function isPdf(type: string): boolean {
      {#each status.payload.files as file, i (file.name + i)} -
    • - {#if isImage(file.type) && status.previewUrls[i]} - {file.name} - {:else if isVideo(file.type) && status.previewUrls[i]} - - {:else if isAudio(file.type) && status.previewUrls[i]} - - {:else if isPdf(file.type) && status.previewUrls[i]} - - {/if} -
      - -
      -

      - {file.name} -

      -

      - {file.type} · {formatSize(file.size)} -

      -
      - -
      -
    • + )} + previewUrl={status.previewUrls[i] ?? ""} + index={i} + /> {/each}
    diff --git a/apps/web/src/routes/note/[id]/manage/+page.svelte b/apps/web/src/routes/note/[id]/manage/+page.svelte index 3e6d221..972677a 100644 --- a/apps/web/src/routes/note/[id]/manage/+page.svelte +++ b/apps/web/src/routes/note/[id]/manage/+page.svelte @@ -1,5 +1,6 @@