WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

feat(web): theme import/export JSON for admin theme list page (ATB-60)

Adds GET /admin/themes/:rkey/export (JSON attachment download, excludes
cssOverrides) and POST /admin/themes/import (file upload with per-field
validation, strips unknown tokens, drops cssOverrides, delegates to
existing POST /api/admin/themes). Export and import buttons added to the
theme list page. 26 new tests covering auth, validation, and happy paths.

+705
+412
apps/web/src/routes/__tests__/admin-themes.test.tsx
··· 715 715 expect(decodeURIComponent(location).toLowerCase()).toMatch(/invalid|unknown|preset/); 716 716 }); 717 717 }); 718 + 719 + describe("createAdminThemeRoutes — GET /admin/themes/:rkey/export", () => { 720 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 721 + 722 + beforeEach(() => { 723 + vi.stubGlobal("fetch", mockFetch); 724 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 725 + vi.resetModules(); 726 + }); 727 + 728 + afterEach(() => { 729 + vi.unstubAllGlobals(); 730 + vi.unstubAllEnvs(); 731 + mockFetch.mockReset(); 732 + }); 733 + 734 + function mockResponse(body: unknown, ok = true, status = 200) { 735 + return { 736 + ok, 737 + status, 738 + statusText: ok ? "OK" : "Error", 739 + json: () => Promise.resolve(body), 740 + }; 741 + } 742 + 743 + function setupAuthenticatedSession(permissions: string[]) { 744 + mockFetch.mockResolvedValueOnce( 745 + mockResponse({ authenticated: true, did: "did:plc:forum", handle: "admin.bsky.social" }) 746 + ); 747 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 748 + } 749 + 750 + async function loadThemeRoutes() { 751 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 752 + return createAdminThemeRoutes("http://localhost:3000"); 753 + } 754 + 755 + const sampleTheme = { 756 + id: "1", 757 + uri: "at://did:plc:forum/space.atbb.forum.theme/abc123", 758 + cid: "bafytest", 759 + name: "My Export Theme", 760 + colorScheme: "light", 761 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 762 + cssOverrides: "body { margin: 0; }", 763 + fontUrls: ["https://fonts.googleapis.com/css2?family=Inter"], 764 + createdAt: "2026-01-01T00:00:00.000Z", 765 + indexedAt: "2026-01-01T00:00:00.000Z", 766 + }; 767 + 768 + it("redirects unauthenticated users to /login", async () => { 769 + const routes = await loadThemeRoutes(); 770 + const res = await routes.request("/admin/themes/abc123/export"); 771 + expect(res.status).toBe(302); 772 + expect(res.headers.get("location")).toBe("/login"); 773 + }); 774 + 775 + it("returns 403 for users without manageThemes permission", async () => { 776 + setupAuthenticatedSession([]); 777 + const routes = await loadThemeRoutes(); 778 + const res = await routes.request("/admin/themes/abc123/export", { 779 + headers: { cookie: "atbb_session=token" }, 780 + }); 781 + expect(res.status).toBe(403); 782 + }); 783 + 784 + it("returns 404 when AppView returns 404 for the theme", async () => { 785 + setupAuthenticatedSession([MANAGE_THEMES]); 786 + mockFetch.mockResolvedValueOnce(mockResponse({ error: "Theme not found" }, false, 404)); 787 + const routes = await loadThemeRoutes(); 788 + const res = await routes.request("/admin/themes/abc123/export", { 789 + headers: { cookie: "atbb_session=token" }, 790 + }); 791 + expect(res.status).toBe(404); 792 + }); 793 + 794 + it("returns 500 when AppView fetch throws", async () => { 795 + setupAuthenticatedSession([MANAGE_THEMES]); 796 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 797 + const routes = await loadThemeRoutes(); 798 + const res = await routes.request("/admin/themes/abc123/export", { 799 + headers: { cookie: "atbb_session=token" }, 800 + }); 801 + expect(res.status).toBe(500); 802 + }); 803 + 804 + it("returns JSON with Content-Disposition: attachment header", async () => { 805 + setupAuthenticatedSession([MANAGE_THEMES]); 806 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 807 + const routes = await loadThemeRoutes(); 808 + const res = await routes.request("/admin/themes/abc123/export", { 809 + headers: { cookie: "atbb_session=token" }, 810 + }); 811 + expect(res.status).toBe(200); 812 + expect(res.headers.get("content-type")).toContain("application/json"); 813 + const disposition = res.headers.get("content-disposition") ?? ""; 814 + expect(disposition).toContain("attachment"); 815 + expect(disposition).toContain(".json"); 816 + }); 817 + 818 + it("includes name, colorScheme, tokens, and fontUrls in export", async () => { 819 + setupAuthenticatedSession([MANAGE_THEMES]); 820 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 821 + const routes = await loadThemeRoutes(); 822 + const res = await routes.request("/admin/themes/abc123/export", { 823 + headers: { cookie: "atbb_session=token" }, 824 + }); 825 + expect(res.status).toBe(200); 826 + const body = await res.json() as Record<string, unknown>; 827 + expect(body.name).toBe("My Export Theme"); 828 + expect(body.colorScheme).toBe("light"); 829 + expect(body.tokens).toEqual({ "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }); 830 + expect(body.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Inter"]); 831 + }); 832 + 833 + it("excludes cssOverrides from export", async () => { 834 + setupAuthenticatedSession([MANAGE_THEMES]); 835 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 836 + const routes = await loadThemeRoutes(); 837 + const res = await routes.request("/admin/themes/abc123/export", { 838 + headers: { cookie: "atbb_session=token" }, 839 + }); 840 + expect(res.status).toBe(200); 841 + const body = await res.json() as Record<string, unknown>; 842 + expect(body.cssOverrides).toBeUndefined(); 843 + }); 844 + 845 + it("slugifies theme name into the download filename", async () => { 846 + setupAuthenticatedSession([MANAGE_THEMES]); 847 + mockFetch.mockResolvedValueOnce( 848 + mockResponse({ ...sampleTheme, name: "My Fancy Theme!" }) 849 + ); 850 + const routes = await loadThemeRoutes(); 851 + const res = await routes.request("/admin/themes/abc123/export", { 852 + headers: { cookie: "atbb_session=token" }, 853 + }); 854 + expect(res.status).toBe(200); 855 + const disposition = res.headers.get("content-disposition") ?? ""; 856 + // Slugified name "My Fancy Theme!" → "my-fancy-theme", plus "-light.json" 857 + expect(disposition).toContain("my-fancy-theme"); 858 + expect(disposition).toContain("light"); 859 + }); 860 + }); 861 + 862 + describe("createAdminThemeRoutes — POST /admin/themes/import", () => { 863 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 864 + 865 + beforeEach(() => { 866 + vi.stubGlobal("fetch", mockFetch); 867 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 868 + vi.resetModules(); 869 + }); 870 + 871 + afterEach(() => { 872 + vi.unstubAllGlobals(); 873 + vi.unstubAllEnvs(); 874 + mockFetch.mockReset(); 875 + }); 876 + 877 + function mockResponse(body: unknown, ok = true, status = 200) { 878 + return { 879 + ok, 880 + status, 881 + statusText: ok ? "OK" : "Error", 882 + json: () => Promise.resolve(body), 883 + }; 884 + } 885 + 886 + function setupAuthenticatedSession(permissions: string[]) { 887 + mockFetch.mockResolvedValueOnce( 888 + mockResponse({ authenticated: true, did: "did:plc:forum", handle: "admin.bsky.social" }) 889 + ); 890 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 891 + } 892 + 893 + async function loadThemeRoutes() { 894 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 895 + return createAdminThemeRoutes("http://localhost:3000"); 896 + } 897 + 898 + function makeImportFile(content: unknown): FormData { 899 + const formData = new FormData(); 900 + formData.append( 901 + "themeFile", 902 + new Blob([JSON.stringify(content)], { type: "application/json" }), 903 + "theme.json" 904 + ); 905 + return formData; 906 + } 907 + 908 + const validImport = { 909 + name: "Imported Theme", 910 + colorScheme: "light", 911 + tokens: { "color-bg": "#ffffff", "color-text": "#000000" }, 912 + fontUrls: ["https://fonts.googleapis.com/css2?family=Inter"], 913 + }; 914 + 915 + it("redirects unauthenticated users to /login", async () => { 916 + const routes = await loadThemeRoutes(); 917 + const res = await routes.request("/admin/themes/import", { 918 + method: "POST", 919 + body: makeImportFile(validImport), 920 + }); 921 + expect(res.status).toBe(302); 922 + expect(res.headers.get("location")).toBe("/login"); 923 + }); 924 + 925 + it("returns 403 for users without manageThemes permission", async () => { 926 + setupAuthenticatedSession([]); 927 + const routes = await loadThemeRoutes(); 928 + const res = await routes.request("/admin/themes/import", { 929 + method: "POST", 930 + body: makeImportFile(validImport), 931 + headers: { cookie: "atbb_session=token" }, 932 + }); 933 + expect(res.status).toBe(403); 934 + }); 935 + 936 + it("redirects with error when no file submitted", async () => { 937 + setupAuthenticatedSession([MANAGE_THEMES]); 938 + const routes = await loadThemeRoutes(); 939 + const res = await routes.request("/admin/themes/import", { 940 + method: "POST", 941 + headers: { 942 + "content-type": "application/x-www-form-urlencoded", 943 + cookie: "atbb_session=token", 944 + }, 945 + body: "", 946 + }); 947 + expect(res.status).toBe(302); 948 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 949 + expect(loc).toContain("/admin/themes"); 950 + expect(loc.toLowerCase()).toMatch(/select|json file/); 951 + }); 952 + 953 + it("redirects with descriptive error for invalid JSON file", async () => { 954 + setupAuthenticatedSession([MANAGE_THEMES]); 955 + const routes = await loadThemeRoutes(); 956 + const formData = new FormData(); 957 + formData.append( 958 + "themeFile", 959 + new Blob(["this is not json"], { type: "application/json" }), 960 + "bad.json" 961 + ); 962 + const res = await routes.request("/admin/themes/import", { 963 + method: "POST", 964 + body: formData, 965 + headers: { cookie: "atbb_session=token" }, 966 + }); 967 + expect(res.status).toBe(302); 968 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 969 + expect(loc).toContain("error="); 970 + expect(loc.toLowerCase()).toMatch(/json/); 971 + }); 972 + 973 + it("redirects with error when name is missing", async () => { 974 + setupAuthenticatedSession([MANAGE_THEMES]); 975 + const routes = await loadThemeRoutes(); 976 + const res = await routes.request("/admin/themes/import", { 977 + method: "POST", 978 + body: makeImportFile({ colorScheme: "light", tokens: {} }), 979 + headers: { cookie: "atbb_session=token" }, 980 + }); 981 + expect(res.status).toBe(302); 982 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 983 + expect(loc).toContain("error="); 984 + expect(loc.toLowerCase()).toMatch(/name/); 985 + }); 986 + 987 + it("redirects with error when colorScheme is missing", async () => { 988 + setupAuthenticatedSession([MANAGE_THEMES]); 989 + const routes = await loadThemeRoutes(); 990 + const res = await routes.request("/admin/themes/import", { 991 + method: "POST", 992 + body: makeImportFile({ name: "Test", tokens: {} }), 993 + headers: { cookie: "atbb_session=token" }, 994 + }); 995 + expect(res.status).toBe(302); 996 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 997 + expect(loc).toContain("error="); 998 + expect(loc.toLowerCase()).toMatch(/colorscheme|color scheme/); 999 + }); 1000 + 1001 + it("redirects with error for invalid colorScheme value", async () => { 1002 + setupAuthenticatedSession([MANAGE_THEMES]); 1003 + const routes = await loadThemeRoutes(); 1004 + const res = await routes.request("/admin/themes/import", { 1005 + method: "POST", 1006 + body: makeImportFile({ name: "Test", colorScheme: "purple", tokens: {} }), 1007 + headers: { cookie: "atbb_session=token" }, 1008 + }); 1009 + expect(res.status).toBe(302); 1010 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 1011 + expect(loc).toContain("error="); 1012 + expect(loc.toLowerCase()).toMatch(/colorscheme|light.*dark|dark.*light/); 1013 + }); 1014 + 1015 + it("redirects with error when tokens object is missing", async () => { 1016 + setupAuthenticatedSession([MANAGE_THEMES]); 1017 + const routes = await loadThemeRoutes(); 1018 + const res = await routes.request("/admin/themes/import", { 1019 + method: "POST", 1020 + body: makeImportFile({ name: "Test", colorScheme: "light" }), 1021 + headers: { cookie: "atbb_session=token" }, 1022 + }); 1023 + expect(res.status).toBe(302); 1024 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 1025 + expect(loc).toContain("error="); 1026 + expect(loc.toLowerCase()).toMatch(/tokens/); 1027 + }); 1028 + 1029 + it("strips unknown token keys before forwarding to AppView", async () => { 1030 + setupAuthenticatedSession([MANAGE_THEMES]); 1031 + mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "bafytest" }, true, 201)); 1032 + const routes = await loadThemeRoutes(); 1033 + const res = await routes.request("/admin/themes/import", { 1034 + method: "POST", 1035 + body: makeImportFile({ 1036 + name: "Test", 1037 + colorScheme: "dark", 1038 + tokens: { 1039 + "color-bg": "#000000", // known token — passes through 1040 + "unknown-custom-token": "#fff", // unknown — stripped 1041 + }, 1042 + }), 1043 + headers: { cookie: "atbb_session=token" }, 1044 + }); 1045 + expect(res.status).toBe(302); 1046 + expect(res.headers.get("location")).toBe("/admin/themes"); 1047 + 1048 + const apiCall = mockFetch.mock.calls[2]; // third call: POST /api/admin/themes 1049 + const body = JSON.parse(apiCall[1].body) as Record<string, unknown>; 1050 + expect((body.tokens as Record<string, string>)["color-bg"]).toBe("#000000"); 1051 + expect((body.tokens as Record<string, unknown>)["unknown-custom-token"]).toBeUndefined(); 1052 + }); 1053 + 1054 + it("silently drops cssOverrides field from import", async () => { 1055 + setupAuthenticatedSession([MANAGE_THEMES]); 1056 + mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "bafytest" }, true, 201)); 1057 + const routes = await loadThemeRoutes(); 1058 + const res = await routes.request("/admin/themes/import", { 1059 + method: "POST", 1060 + body: makeImportFile({ 1061 + name: "Test", 1062 + colorScheme: "light", 1063 + tokens: { "color-bg": "#fff" }, 1064 + cssOverrides: "body { background: red; }", 1065 + }), 1066 + headers: { cookie: "atbb_session=token" }, 1067 + }); 1068 + expect(res.status).toBe(302); 1069 + const apiCall = mockFetch.mock.calls[2]; 1070 + const body = JSON.parse(apiCall[1].body) as Record<string, unknown>; 1071 + expect(body.cssOverrides).toBeUndefined(); 1072 + }); 1073 + 1074 + it("redirects with error for non-HTTPS fontUrls", async () => { 1075 + setupAuthenticatedSession([MANAGE_THEMES]); 1076 + const routes = await loadThemeRoutes(); 1077 + const res = await routes.request("/admin/themes/import", { 1078 + method: "POST", 1079 + body: makeImportFile({ 1080 + name: "Test", 1081 + colorScheme: "light", 1082 + tokens: {}, 1083 + fontUrls: ["http://fonts.example.com/not-https"], 1084 + }), 1085 + headers: { cookie: "atbb_session=token" }, 1086 + }); 1087 + expect(res.status).toBe(302); 1088 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 1089 + expect(loc).toContain("error="); 1090 + expect(loc.toLowerCase()).toMatch(/https|font url/); 1091 + }); 1092 + 1093 + it("calls POST /api/admin/themes and redirects to /admin/themes on success", async () => { 1094 + setupAuthenticatedSession([MANAGE_THEMES]); 1095 + mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "bafytest" }, true, 201)); 1096 + const routes = await loadThemeRoutes(); 1097 + const res = await routes.request("/admin/themes/import", { 1098 + method: "POST", 1099 + body: makeImportFile(validImport), 1100 + headers: { cookie: "atbb_session=token" }, 1101 + }); 1102 + expect(res.status).toBe(302); 1103 + expect(res.headers.get("location")).toBe("/admin/themes"); 1104 + 1105 + const apiCall = mockFetch.mock.calls[2]; 1106 + expect(apiCall[0]).toBe("http://localhost:3000/api/admin/themes"); 1107 + const body = JSON.parse(apiCall[1].body) as Record<string, unknown>; 1108 + expect(body.name).toBe("Imported Theme"); 1109 + expect(body.colorScheme).toBe("light"); 1110 + expect(body.tokens).toMatchObject({ "color-bg": "#ffffff" }); 1111 + }); 1112 + 1113 + it("redirects with AppView error message when create fails", async () => { 1114 + setupAuthenticatedSession([MANAGE_THEMES]); 1115 + mockFetch.mockResolvedValueOnce( 1116 + mockResponse({ error: "Theme name already taken" }, false, 400) 1117 + ); 1118 + const routes = await loadThemeRoutes(); 1119 + const res = await routes.request("/admin/themes/import", { 1120 + method: "POST", 1121 + body: makeImportFile(validImport), 1122 + headers: { cookie: "atbb_session=token" }, 1123 + }); 1124 + expect(res.status).toBe(302); 1125 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 1126 + expect(loc).toContain("error="); 1127 + expect(loc).toContain("Theme name already taken"); 1128 + }); 1129 + });
+293
apps/web/src/routes/admin-themes.tsx
··· 1 + // pattern: Mixed (unavoidable) 2 + // Reason: Hono route file — JSX components, pure helpers (slugifyName, isHttpsUrl, 3 + // sanitizeTokenValue), and route handlers (I/O) must coexist in one file per the 4 + // framework's factory-function pattern used across the web package. 1 5 import { Hono } from "hono"; 2 6 import { BaseLayout } from "../layouts/base.js"; 3 7 import { PageHeader, EmptyState } from "../components/index.js"; ··· 100 104 if (typeof value !== "string") return null; 101 105 if (value.includes("<") || value.includes(";") || value.includes("}")) return null; 102 106 return value; 107 + } 108 + 109 + /** Produce a URL-safe filename slug from a theme name, with a fallback. */ 110 + function slugifyName(name: string): string { 111 + return name 112 + .toLowerCase() 113 + .replace(/[^a-z0-9]+/g, "-") 114 + .replace(/^-+|-+$/g, "") || "theme"; 115 + } 116 + 117 + /** Returns true only for absolute HTTPS URLs. */ 118 + function isHttpsUrl(url: unknown): boolean { 119 + if (typeof url !== "string") return false; 120 + try { 121 + return new URL(url).protocol === "https:"; 122 + } catch { 123 + return false; 124 + } 103 125 } 104 126 105 127 // ─── JSX Components ───────────────────────────────────────────────────────── ··· 383 405 Edit 384 406 </a> 385 407 408 + <a href={`/admin/themes/${themeRkey}/export`} class="btn btn-secondary btn-sm"> 409 + Export 410 + </a> 411 + 386 412 <form 387 413 method="post" 388 414 action={`/admin/themes/${themeRkey}/duplicate`} ··· 522 548 </button> 523 549 </form> 524 550 </details> 551 + 552 + {/* Import theme from JSON */} 553 + <details class="structure-add-form"> 554 + <summary class="structure-add-form__trigger">↑ Import Theme from JSON</summary> 555 + <form 556 + method="post" 557 + action="/admin/themes/import" 558 + enctype="multipart/form-data" 559 + class="structure-edit-form__body" 560 + > 561 + <div class="form-group"> 562 + <label for="import-theme-file">Theme JSON file</label> 563 + <input 564 + id="import-theme-file" 565 + type="file" 566 + name="themeFile" 567 + accept=".json" 568 + required 569 + /> 570 + <p class="form-hint"> 571 + Imports name, colorScheme, tokens, and fontUrls. 572 + CSS overrides and unknown token keys are ignored. 573 + </p> 574 + </div> 575 + <button type="submit" class="btn btn-primary"> 576 + Import Theme 577 + </button> 578 + </form> 579 + </details> 525 580 </BaseLayout> 526 581 ); 527 582 }); 528 583 584 + // ── GET /admin/themes/:rkey/export ──────────────────────────────────────── 585 + // Must be registered before /:rkey so Hono matches the literal "/export" suffix first. 586 + 587 + app.get("/admin/themes/:rkey/export", async (c) => { 588 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 589 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 590 + if (!auth.authenticated) return c.redirect("/login"); 591 + if (!canManageThemes(auth)) { 592 + return c.html( 593 + <BaseLayout title="Access Denied — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}> 594 + <PageHeader title="Access Denied" /> 595 + <p>You don&apos;t have permission to manage themes.</p> 596 + </BaseLayout>, 597 + 403 598 + ); 599 + } 600 + 601 + const themeRkey = c.req.param("rkey"); 602 + 603 + let theme: AdminThemeEntry | null = null; 604 + try { 605 + const res = await fetch(`${appviewUrl}/api/themes/${themeRkey}`); 606 + if (res.status === 404) { 607 + return c.html( 608 + <BaseLayout title="Theme Not Found — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}> 609 + <PageHeader title="Theme Not Found" /> 610 + <p>This theme does not exist.</p> 611 + <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> 612 + </BaseLayout>, 613 + 404 614 + ); 615 + } 616 + if (res.ok) { 617 + try { 618 + theme = (await res.json()) as AdminThemeEntry; 619 + } catch { 620 + logger.error("Failed to parse theme response for export", { 621 + operation: "GET /admin/themes/:rkey/export", 622 + themeRkey, 623 + }); 624 + } 625 + } else { 626 + logger.error("AppView returned error loading theme for export", { 627 + operation: "GET /admin/themes/:rkey/export", 628 + themeRkey, 629 + status: res.status, 630 + }); 631 + } 632 + } catch (error) { 633 + if (isProgrammingError(error)) throw error; 634 + logger.error("Network error loading theme for export", { 635 + operation: "GET /admin/themes/:rkey/export", 636 + themeRkey, 637 + error: error instanceof Error ? error.message : String(error), 638 + }); 639 + } 640 + 641 + if (!theme) { 642 + return c.html( 643 + <BaseLayout title="Export Failed — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}> 644 + <PageHeader title="Export Failed" /> 645 + <p>Unable to load theme data. Please try again.</p> 646 + <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> 647 + </BaseLayout>, 648 + 500 649 + ); 650 + } 651 + 652 + // cssOverrides excluded from export — it contains raw CSS that may reference 653 + // external resources and is tied to this forum's sanitization config. 654 + const exportData = { 655 + name: theme.name, 656 + colorScheme: theme.colorScheme, 657 + tokens: theme.tokens, 658 + fontUrls: theme.fontUrls ?? [], 659 + }; 660 + 661 + const filename = `${slugifyName(theme.name)}-${theme.colorScheme}.json`; 662 + c.header("Content-Type", "application/json"); 663 + c.header("Content-Disposition", `attachment; filename="${filename}"`); 664 + return c.body(JSON.stringify(exportData, null, 2), 200); 665 + }); 666 + 529 667 // ── GET /admin/themes/:rkey ──────────────────────────────────────────────── 530 668 531 669 app.get("/admin/themes/:rkey", async (c) => { ··· 814 952 `/admin/themes?error=${encodeURIComponent(msg)}`, 815 953 302 816 954 ); 955 + } 956 + 957 + return c.redirect("/admin/themes", 302); 958 + }); 959 + 960 + // ── POST /admin/themes/import ───────────────────────────────────────────── 961 + // File upload: reads the JSON file, validates structure, strips unknown tokens 962 + // and cssOverrides, then delegates to POST /api/admin/themes. 963 + 964 + app.post("/admin/themes/import", async (c) => { 965 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 966 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 967 + if (!auth.authenticated) return c.redirect("/login"); 968 + if (!canManageThemes(auth)) { 969 + return c.html( 970 + <BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}> 971 + <p>Access denied.</p> 972 + </BaseLayout>, 973 + 403 974 + ); 975 + } 976 + 977 + const cookie = c.req.header("cookie") ?? ""; 978 + 979 + let rawBody: Record<string, string | File>; 980 + try { 981 + rawBody = await c.req.parseBody(); 982 + } catch (error) { 983 + if (isProgrammingError(error)) throw error; 984 + return c.redirect( 985 + `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 986 + 302 987 + ); 988 + } 989 + 990 + const uploaded = rawBody.themeFile; 991 + if (!uploaded || typeof uploaded === "string" || uploaded.size === 0) { 992 + return c.redirect( 993 + `/admin/themes?error=${encodeURIComponent("Please select a JSON file to import.")}`, 994 + 302 995 + ); 996 + } 997 + 998 + // Parse the uploaded JSON file 999 + let parsed: unknown; 1000 + try { 1001 + const text = await uploaded.text(); 1002 + parsed = JSON.parse(text); 1003 + } catch { 1004 + return c.redirect( 1005 + `/admin/themes?error=${encodeURIComponent("Import failed: file is not valid JSON.")}`, 1006 + 302 1007 + ); 1008 + } 1009 + 1010 + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { 1011 + return c.redirect( 1012 + `/admin/themes?error=${encodeURIComponent("Import failed: file must be a JSON object.")}`, 1013 + 302 1014 + ); 1015 + } 1016 + 1017 + const obj = parsed as Record<string, unknown>; 1018 + 1019 + // Validate required: name 1020 + const name = typeof obj.name === "string" ? obj.name.trim() : ""; 1021 + if (!name) { 1022 + return c.redirect( 1023 + `/admin/themes?error=${encodeURIComponent('Import failed: missing required field "name".')}`, 1024 + 302 1025 + ); 1026 + } 1027 + 1028 + // Validate required: colorScheme 1029 + if (obj.colorScheme !== "light" && obj.colorScheme !== "dark") { 1030 + return c.redirect( 1031 + `/admin/themes?error=${encodeURIComponent( 1032 + 'Import failed: colorScheme must be "light" or "dark".' 1033 + )}`, 1034 + 302 1035 + ); 1036 + } 1037 + const colorScheme = obj.colorScheme; 1038 + 1039 + // Validate required: tokens (must be a plain object) 1040 + if (typeof obj.tokens !== "object" || obj.tokens === null || Array.isArray(obj.tokens)) { 1041 + return c.redirect( 1042 + `/admin/themes?error=${encodeURIComponent('Import failed: missing required field "tokens".')}`, 1043 + 302 1044 + ); 1045 + } 1046 + 1047 + // Strip unknown token keys — only recognized tokens pass through 1048 + const rawTokens = obj.tokens as Record<string, unknown>; 1049 + const tokens: Record<string, string> = {}; 1050 + for (const key of ALL_KNOWN_TOKENS) { 1051 + const val = rawTokens[key]; 1052 + if (typeof val === "string") { 1053 + tokens[key] = val; 1054 + } 1055 + } 1056 + 1057 + // Validate fontUrls — each must be an HTTPS URL 1058 + let fontUrls: string[] | undefined; 1059 + if (obj.fontUrls !== undefined) { 1060 + if (!Array.isArray(obj.fontUrls)) { 1061 + return c.redirect( 1062 + `/admin/themes?error=${encodeURIComponent("Import failed: fontUrls must be an array.")}`, 1063 + 302 1064 + ); 1065 + } 1066 + for (const url of obj.fontUrls) { 1067 + if (!isHttpsUrl(url)) { 1068 + return c.redirect( 1069 + `/admin/themes?error=${encodeURIComponent( 1070 + `Import failed: font URL must be HTTPS: ${String(url)}` 1071 + )}`, 1072 + 302 1073 + ); 1074 + } 1075 + } 1076 + fontUrls = obj.fontUrls as string[]; 1077 + } 1078 + 1079 + // cssOverrides is silently dropped — not safe to import until sanitization 1080 + // is universally applied at write time (it already is via ATB-62, but drop 1081 + // on import keeps the JSON schema clean and avoids accidental CSS bleed). 1082 + 1083 + let apiRes: Response; 1084 + try { 1085 + apiRes = await fetch(`${appviewUrl}/api/admin/themes`, { 1086 + method: "POST", 1087 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1088 + body: JSON.stringify({ 1089 + name, 1090 + colorScheme, 1091 + tokens, 1092 + ...(fontUrls !== undefined && { fontUrls }), 1093 + }), 1094 + }); 1095 + } catch (error) { 1096 + if (isProgrammingError(error)) throw error; 1097 + logger.error("Network error importing theme", { 1098 + operation: "POST /admin/themes/import", 1099 + error: error instanceof Error ? error.message : String(error), 1100 + }); 1101 + return c.redirect( 1102 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1103 + 302 1104 + ); 1105 + } 1106 + 1107 + if (!apiRes.ok) { 1108 + const msg = await extractAppviewError(apiRes, "Failed to import theme. Please try again."); 1109 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 817 1110 } 818 1111 819 1112 return c.redirect("/admin/themes", 302);