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 (ATB-60) (#95)

* 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.

* refactor(web): address code review feedback on ATB-60 import/export

- Bind errors in all bare catch blocks; add isProgrammingError re-throw
in export JSON parse and parseBody catch paths
- Split uploaded.text() and JSON.parse into separate try blocks for
distinct error messages and log entries
- Add logger.error to extractAppviewError catch and parseBody catch
- Add 100 KB file size guard before reading uploaded file
- Slugify colorScheme in export filename to guard against unexpected values
- Fix route registration comment: 4-segment path is distinct from 3-segment
/:rkey — registration order does not matter
- Rewrite cssOverrides drop comment to focus on portability and CSS bleed
- Update FCIS annotation to reference project one-file-per-route-group convention
- Add safety comment on fontUrls cast (isHttpsUrl verifies typeof === "string")
- Add tests: non-404 AppView error → 500, fontUrls non-array, AppView POST
network failure; change mockFetch.mock.calls[N] to .at(-1)! with URL assertion

authored by

Malpercio and committed by
GitHub
62842536 f1a0adf1

+791 -1
+462
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 + it("returns 500 when AppView returns a non-404 error for the theme", async () => { 862 + // Tests the else branch: non-ok AND non-404 status (e.g. 503) falls through 863 + // to the null-theme 500 page, not the 404 page. 864 + setupAuthenticatedSession([MANAGE_THEMES]); 865 + mockFetch.mockResolvedValueOnce(mockResponse({ error: "Service unavailable" }, false, 503)); 866 + const routes = await loadThemeRoutes(); 867 + const res = await routes.request("/admin/themes/abc123/export", { 868 + headers: { cookie: "atbb_session=token" }, 869 + }); 870 + expect(res.status).toBe(500); 871 + const html = await res.text(); 872 + expect(html.toLowerCase()).toMatch(/unavailable|failed/); 873 + }); 874 + }); 875 + 876 + describe("createAdminThemeRoutes — POST /admin/themes/import", () => { 877 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 878 + 879 + beforeEach(() => { 880 + vi.stubGlobal("fetch", mockFetch); 881 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 882 + vi.resetModules(); 883 + }); 884 + 885 + afterEach(() => { 886 + vi.unstubAllGlobals(); 887 + vi.unstubAllEnvs(); 888 + mockFetch.mockReset(); 889 + }); 890 + 891 + function mockResponse(body: unknown, ok = true, status = 200) { 892 + return { 893 + ok, 894 + status, 895 + statusText: ok ? "OK" : "Error", 896 + json: () => Promise.resolve(body), 897 + }; 898 + } 899 + 900 + function setupAuthenticatedSession(permissions: string[]) { 901 + mockFetch.mockResolvedValueOnce( 902 + mockResponse({ authenticated: true, did: "did:plc:forum", handle: "admin.bsky.social" }) 903 + ); 904 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 905 + } 906 + 907 + async function loadThemeRoutes() { 908 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 909 + return createAdminThemeRoutes("http://localhost:3000"); 910 + } 911 + 912 + function makeImportFile(content: unknown): FormData { 913 + const formData = new FormData(); 914 + formData.append( 915 + "themeFile", 916 + new Blob([JSON.stringify(content)], { type: "application/json" }), 917 + "theme.json" 918 + ); 919 + return formData; 920 + } 921 + 922 + const validImport = { 923 + name: "Imported Theme", 924 + colorScheme: "light", 925 + tokens: { "color-bg": "#ffffff", "color-text": "#000000" }, 926 + fontUrls: ["https://fonts.googleapis.com/css2?family=Inter"], 927 + }; 928 + 929 + it("redirects unauthenticated users to /login", async () => { 930 + const routes = await loadThemeRoutes(); 931 + const res = await routes.request("/admin/themes/import", { 932 + method: "POST", 933 + body: makeImportFile(validImport), 934 + }); 935 + expect(res.status).toBe(302); 936 + expect(res.headers.get("location")).toBe("/login"); 937 + }); 938 + 939 + it("returns 403 for users without manageThemes permission", async () => { 940 + setupAuthenticatedSession([]); 941 + const routes = await loadThemeRoutes(); 942 + const res = await routes.request("/admin/themes/import", { 943 + method: "POST", 944 + body: makeImportFile(validImport), 945 + headers: { cookie: "atbb_session=token" }, 946 + }); 947 + expect(res.status).toBe(403); 948 + }); 949 + 950 + it("redirects with error when no file submitted", async () => { 951 + setupAuthenticatedSession([MANAGE_THEMES]); 952 + const routes = await loadThemeRoutes(); 953 + const res = await routes.request("/admin/themes/import", { 954 + method: "POST", 955 + headers: { 956 + "content-type": "application/x-www-form-urlencoded", 957 + cookie: "atbb_session=token", 958 + }, 959 + body: "", 960 + }); 961 + expect(res.status).toBe(302); 962 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 963 + expect(loc).toContain("/admin/themes"); 964 + expect(loc.toLowerCase()).toMatch(/select|json file/); 965 + }); 966 + 967 + it("redirects with descriptive error for invalid JSON file", async () => { 968 + setupAuthenticatedSession([MANAGE_THEMES]); 969 + const routes = await loadThemeRoutes(); 970 + const formData = new FormData(); 971 + formData.append( 972 + "themeFile", 973 + new Blob(["this is not json"], { type: "application/json" }), 974 + "bad.json" 975 + ); 976 + const res = await routes.request("/admin/themes/import", { 977 + method: "POST", 978 + body: formData, 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(/json/); 985 + }); 986 + 987 + it("redirects with error when name 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({ colorScheme: "light", 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(/name/); 999 + }); 1000 + 1001 + it("redirects with error when colorScheme is missing", 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", 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|color scheme/); 1013 + }); 1014 + 1015 + it("redirects with error for invalid colorScheme value", 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: "purple", tokens: {} }), 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(/colorscheme|light.*dark|dark.*light/); 1027 + }); 1028 + 1029 + it("redirects with error when tokens object is missing", async () => { 1030 + setupAuthenticatedSession([MANAGE_THEMES]); 1031 + const routes = await loadThemeRoutes(); 1032 + const res = await routes.request("/admin/themes/import", { 1033 + method: "POST", 1034 + body: makeImportFile({ name: "Test", colorScheme: "light" }), 1035 + headers: { cookie: "atbb_session=token" }, 1036 + }); 1037 + expect(res.status).toBe(302); 1038 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 1039 + expect(loc).toContain("error="); 1040 + expect(loc.toLowerCase()).toMatch(/tokens/); 1041 + }); 1042 + 1043 + it("strips unknown token keys before forwarding to AppView", async () => { 1044 + setupAuthenticatedSession([MANAGE_THEMES]); 1045 + mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "bafytest" }, true, 201)); 1046 + const routes = await loadThemeRoutes(); 1047 + const res = await routes.request("/admin/themes/import", { 1048 + method: "POST", 1049 + body: makeImportFile({ 1050 + name: "Test", 1051 + colorScheme: "dark", 1052 + tokens: { 1053 + "color-bg": "#000000", // known token — passes through 1054 + "unknown-custom-token": "#fff", // unknown — stripped 1055 + }, 1056 + }), 1057 + headers: { cookie: "atbb_session=token" }, 1058 + }); 1059 + expect(res.status).toBe(302); 1060 + expect(res.headers.get("location")).toBe("/admin/themes"); 1061 + 1062 + const apiCall = mockFetch.mock.calls.at(-1)!; // last call: POST /api/admin/themes 1063 + expect(apiCall[0]).toContain("/api/admin/themes"); 1064 + const body = JSON.parse(apiCall[1].body) as Record<string, unknown>; 1065 + expect((body.tokens as Record<string, string>)["color-bg"]).toBe("#000000"); 1066 + expect((body.tokens as Record<string, unknown>)["unknown-custom-token"]).toBeUndefined(); 1067 + }); 1068 + 1069 + it("silently drops cssOverrides field from import", async () => { 1070 + setupAuthenticatedSession([MANAGE_THEMES]); 1071 + mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "bafytest" }, true, 201)); 1072 + const routes = await loadThemeRoutes(); 1073 + const res = await routes.request("/admin/themes/import", { 1074 + method: "POST", 1075 + body: makeImportFile({ 1076 + name: "Test", 1077 + colorScheme: "light", 1078 + tokens: { "color-bg": "#fff" }, 1079 + cssOverrides: "body { background: red; }", 1080 + }), 1081 + headers: { cookie: "atbb_session=token" }, 1082 + }); 1083 + expect(res.status).toBe(302); 1084 + const apiCall = mockFetch.mock.calls.at(-1)!; 1085 + expect(apiCall[0]).toContain("/api/admin/themes"); 1086 + const body = JSON.parse(apiCall[1].body) as Record<string, unknown>; 1087 + expect(body.cssOverrides).toBeUndefined(); 1088 + }); 1089 + 1090 + it("redirects with error for non-HTTPS fontUrls", async () => { 1091 + setupAuthenticatedSession([MANAGE_THEMES]); 1092 + const routes = await loadThemeRoutes(); 1093 + const res = await routes.request("/admin/themes/import", { 1094 + method: "POST", 1095 + body: makeImportFile({ 1096 + name: "Test", 1097 + colorScheme: "light", 1098 + tokens: {}, 1099 + fontUrls: ["http://fonts.example.com/not-https"], 1100 + }), 1101 + headers: { cookie: "atbb_session=token" }, 1102 + }); 1103 + expect(res.status).toBe(302); 1104 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 1105 + expect(loc).toContain("error="); 1106 + expect(loc.toLowerCase()).toMatch(/https|font url/); 1107 + }); 1108 + 1109 + it("redirects with error when fontUrls is a non-array value", async () => { 1110 + setupAuthenticatedSession([MANAGE_THEMES]); 1111 + const routes = await loadThemeRoutes(); 1112 + const res = await routes.request("/admin/themes/import", { 1113 + method: "POST", 1114 + body: makeImportFile({ 1115 + name: "Test", 1116 + colorScheme: "light", 1117 + tokens: {}, 1118 + fontUrls: "https://fonts.example.com/single-string-not-array", 1119 + }), 1120 + headers: { cookie: "atbb_session=token" }, 1121 + }); 1122 + expect(res.status).toBe(302); 1123 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 1124 + expect(loc).toContain("error="); 1125 + expect(loc.toLowerCase()).toMatch(/fonturl|array/); 1126 + }); 1127 + 1128 + it("redirects with error when AppView POST fetch throws (network failure)", async () => { 1129 + setupAuthenticatedSession([MANAGE_THEMES]); 1130 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1131 + const routes = await loadThemeRoutes(); 1132 + const res = await routes.request("/admin/themes/import", { 1133 + method: "POST", 1134 + body: makeImportFile(validImport), 1135 + headers: { cookie: "atbb_session=token" }, 1136 + }); 1137 + expect(res.status).toBe(302); 1138 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 1139 + expect(loc).toContain("error="); 1140 + expect(loc.toLowerCase()).toMatch(/unavailable/); 1141 + }); 1142 + 1143 + it("calls POST /api/admin/themes and redirects to /admin/themes on success", async () => { 1144 + setupAuthenticatedSession([MANAGE_THEMES]); 1145 + mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "bafytest" }, true, 201)); 1146 + const routes = await loadThemeRoutes(); 1147 + const res = await routes.request("/admin/themes/import", { 1148 + method: "POST", 1149 + body: makeImportFile(validImport), 1150 + headers: { cookie: "atbb_session=token" }, 1151 + }); 1152 + expect(res.status).toBe(302); 1153 + expect(res.headers.get("location")).toBe("/admin/themes"); 1154 + 1155 + const apiCall = mockFetch.mock.calls.at(-1)!; 1156 + expect(apiCall[0]).toBe("http://localhost:3000/api/admin/themes"); 1157 + const body = JSON.parse(apiCall[1].body) as Record<string, unknown>; 1158 + expect(body.name).toBe("Imported Theme"); 1159 + expect(body.colorScheme).toBe("light"); 1160 + expect(body.tokens).toMatchObject({ "color-bg": "#ffffff" }); 1161 + }); 1162 + 1163 + it("redirects with AppView error message when create fails", async () => { 1164 + setupAuthenticatedSession([MANAGE_THEMES]); 1165 + mockFetch.mockResolvedValueOnce( 1166 + mockResponse({ error: "Theme name already taken" }, false, 400) 1167 + ); 1168 + const routes = await loadThemeRoutes(); 1169 + const res = await routes.request("/admin/themes/import", { 1170 + method: "POST", 1171 + body: makeImportFile(validImport), 1172 + headers: { cookie: "atbb_session=token" }, 1173 + }); 1174 + expect(res.status).toBe(302); 1175 + const loc = decodeURIComponent(res.headers.get("location") ?? ""); 1176 + expect(loc).toContain("error="); 1177 + expect(loc).toContain("Theme name already taken"); 1178 + }); 1179 + });
+329 -1
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 per this project's 4 + // one-file-per-route-group convention (see other admin-*.tsx route files). 1 5 import { Hono } from "hono"; 2 6 import { BaseLayout } from "../layouts/base.js"; 3 7 import { PageHeader, EmptyState } from "../components/index.js"; ··· 90 94 try { 91 95 const data = (await res.json()) as { error?: string }; 92 96 return data.error ?? fallback; 93 - } catch { 97 + } catch (error) { 98 + logger.error("Failed to parse AppView error response body", { 99 + operation: "extractAppviewError", 100 + status: res.status, 101 + error: error instanceof Error ? error.message : String(error), 102 + }); 94 103 return fallback; 95 104 } 96 105 } ··· 100 109 if (typeof value !== "string") return null; 101 110 if (value.includes("<") || value.includes(";") || value.includes("}")) return null; 102 111 return value; 112 + } 113 + 114 + /** Produce a URL-safe filename slug from a theme name, with a fallback. */ 115 + function slugifyName(name: string): string { 116 + return name 117 + .toLowerCase() 118 + .replace(/[^a-z0-9]+/g, "-") 119 + .replace(/^-+|-+$/g, "") || "theme"; 120 + } 121 + 122 + /** Returns true only for absolute HTTPS URLs. */ 123 + function isHttpsUrl(url: unknown): boolean { 124 + if (typeof url !== "string") return false; 125 + try { 126 + return new URL(url).protocol === "https:"; 127 + } catch { 128 + return false; 129 + } 103 130 } 104 131 105 132 // ─── JSX Components ───────────────────────────────────────────────────────── ··· 383 410 Edit 384 411 </a> 385 412 413 + <a href={`/admin/themes/${themeRkey}/export`} class="btn btn-secondary btn-sm"> 414 + Export 415 + </a> 416 + 386 417 <form 387 418 method="post" 388 419 action={`/admin/themes/${themeRkey}/duplicate`} ··· 522 553 </button> 523 554 </form> 524 555 </details> 556 + 557 + {/* Import theme from JSON */} 558 + <details class="structure-add-form"> 559 + <summary class="structure-add-form__trigger">↑ Import Theme from JSON</summary> 560 + <form 561 + method="post" 562 + action="/admin/themes/import" 563 + enctype="multipart/form-data" 564 + class="structure-edit-form__body" 565 + > 566 + <div class="form-group"> 567 + <label for="import-theme-file">Theme JSON file</label> 568 + <input 569 + id="import-theme-file" 570 + type="file" 571 + name="themeFile" 572 + accept=".json" 573 + required 574 + /> 575 + <p class="form-hint"> 576 + Imports name, colorScheme, tokens, and fontUrls. 577 + CSS overrides and unknown token keys are ignored. 578 + </p> 579 + </div> 580 + <button type="submit" class="btn btn-primary"> 581 + Import Theme 582 + </button> 583 + </form> 584 + </details> 525 585 </BaseLayout> 526 586 ); 527 587 }); 528 588 589 + // ── GET /admin/themes/:rkey/export ──────────────────────────────────────── 590 + // Distinct from /:rkey — the 4-segment path cannot match the 3-segment /:rkey route. 591 + 592 + app.get("/admin/themes/:rkey/export", async (c) => { 593 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 594 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 595 + if (!auth.authenticated) return c.redirect("/login"); 596 + if (!canManageThemes(auth)) { 597 + return c.html( 598 + <BaseLayout title="Access Denied — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}> 599 + <PageHeader title="Access Denied" /> 600 + <p>You don&apos;t have permission to manage themes.</p> 601 + </BaseLayout>, 602 + 403 603 + ); 604 + } 605 + 606 + const themeRkey = c.req.param("rkey"); 607 + 608 + let theme: AdminThemeEntry | null = null; 609 + try { 610 + const res = await fetch(`${appviewUrl}/api/themes/${themeRkey}`); 611 + if (res.status === 404) { 612 + return c.html( 613 + <BaseLayout title="Theme Not Found — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}> 614 + <PageHeader title="Theme Not Found" /> 615 + <p>This theme does not exist.</p> 616 + <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> 617 + </BaseLayout>, 618 + 404 619 + ); 620 + } 621 + if (res.ok) { 622 + try { 623 + theme = (await res.json()) as AdminThemeEntry; 624 + } catch (error) { 625 + if (isProgrammingError(error)) throw error; 626 + logger.error("Failed to parse theme response for export", { 627 + operation: "GET /admin/themes/:rkey/export", 628 + themeRkey, 629 + error: error instanceof Error ? error.message : String(error), 630 + }); 631 + } 632 + } else { 633 + logger.error("AppView returned error loading theme for export", { 634 + operation: "GET /admin/themes/:rkey/export", 635 + themeRkey, 636 + status: res.status, 637 + }); 638 + } 639 + } catch (error) { 640 + if (isProgrammingError(error)) throw error; 641 + logger.error("Network error loading theme for export", { 642 + operation: "GET /admin/themes/:rkey/export", 643 + themeRkey, 644 + error: error instanceof Error ? error.message : String(error), 645 + }); 646 + } 647 + 648 + if (!theme) { 649 + return c.html( 650 + <BaseLayout title="Export Failed — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}> 651 + <PageHeader title="Export Failed" /> 652 + <p>Unable to load theme data. Please try again.</p> 653 + <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> 654 + </BaseLayout>, 655 + 500 656 + ); 657 + } 658 + 659 + // cssOverrides excluded from export — it contains raw CSS that may reference 660 + // external resources and is tied to this forum's sanitization config. 661 + const exportData = { 662 + name: theme.name, 663 + colorScheme: theme.colorScheme, 664 + tokens: theme.tokens, 665 + fontUrls: theme.fontUrls ?? [], 666 + }; 667 + 668 + const filename = `${slugifyName(theme.name)}-${slugifyName(theme.colorScheme)}.json`; 669 + c.header("Content-Type", "application/json"); 670 + c.header("Content-Disposition", `attachment; filename="${filename}"`); 671 + return c.body(JSON.stringify(exportData, null, 2), 200); 672 + }); 673 + 529 674 // ── GET /admin/themes/:rkey ──────────────────────────────────────────────── 530 675 531 676 app.get("/admin/themes/:rkey", async (c) => { ··· 814 959 `/admin/themes?error=${encodeURIComponent(msg)}`, 815 960 302 816 961 ); 962 + } 963 + 964 + return c.redirect("/admin/themes", 302); 965 + }); 966 + 967 + // ── POST /admin/themes/import ───────────────────────────────────────────── 968 + // File upload: reads the JSON file, validates structure, strips unknown tokens 969 + // and cssOverrides, then delegates to POST /api/admin/themes. 970 + 971 + app.post("/admin/themes/import", async (c) => { 972 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 973 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 974 + if (!auth.authenticated) return c.redirect("/login"); 975 + if (!canManageThemes(auth)) { 976 + return c.html( 977 + <BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}> 978 + <p>Access denied.</p> 979 + </BaseLayout>, 980 + 403 981 + ); 982 + } 983 + 984 + const cookie = c.req.header("cookie") ?? ""; 985 + 986 + let rawBody: Record<string, string | File>; 987 + try { 988 + rawBody = await c.req.parseBody(); 989 + } catch (error) { 990 + if (isProgrammingError(error)) throw error; 991 + logger.error("Failed to parse import form body", { 992 + operation: "POST /admin/themes/import", 993 + error: error instanceof Error ? error.message : String(error), 994 + }); 995 + return c.redirect( 996 + `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 997 + 302 998 + ); 999 + } 1000 + 1001 + const uploaded = rawBody.themeFile; 1002 + if (!uploaded || typeof uploaded === "string" || uploaded.size === 0) { 1003 + return c.redirect( 1004 + `/admin/themes?error=${encodeURIComponent("Please select a JSON file to import.")}`, 1005 + 302 1006 + ); 1007 + } 1008 + 1009 + const MAX_IMPORT_BYTES = 100 * 1024; // 100 KB 1010 + if (uploaded.size > MAX_IMPORT_BYTES) { 1011 + return c.redirect( 1012 + `/admin/themes?error=${encodeURIComponent("Import failed: file exceeds the 100 KB size limit.")}`, 1013 + 302 1014 + ); 1015 + } 1016 + 1017 + // Read the file text and parse as JSON — two separate try blocks so encoding 1018 + // failures and JSON syntax errors produce distinct log entries. 1019 + let text: string; 1020 + try { 1021 + text = await uploaded.text(); 1022 + } catch (error) { 1023 + if (isProgrammingError(error)) throw error; 1024 + logger.error("Failed to read uploaded theme file", { 1025 + operation: "POST /admin/themes/import", 1026 + error: error instanceof Error ? error.message : String(error), 1027 + }); 1028 + return c.redirect( 1029 + `/admin/themes?error=${encodeURIComponent("Import failed: could not read the uploaded file.")}`, 1030 + 302 1031 + ); 1032 + } 1033 + 1034 + let parsed: unknown; 1035 + try { 1036 + parsed = JSON.parse(text); 1037 + } catch { 1038 + return c.redirect( 1039 + `/admin/themes?error=${encodeURIComponent("Import failed: file is not valid JSON.")}`, 1040 + 302 1041 + ); 1042 + } 1043 + 1044 + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { 1045 + return c.redirect( 1046 + `/admin/themes?error=${encodeURIComponent("Import failed: file must be a JSON object.")}`, 1047 + 302 1048 + ); 1049 + } 1050 + 1051 + const obj = parsed as Record<string, unknown>; 1052 + 1053 + // Validate required: name 1054 + const name = typeof obj.name === "string" ? obj.name.trim() : ""; 1055 + if (!name) { 1056 + return c.redirect( 1057 + `/admin/themes?error=${encodeURIComponent('Import failed: missing required field "name".')}`, 1058 + 302 1059 + ); 1060 + } 1061 + 1062 + // Validate required: colorScheme 1063 + if (obj.colorScheme !== "light" && obj.colorScheme !== "dark") { 1064 + return c.redirect( 1065 + `/admin/themes?error=${encodeURIComponent( 1066 + 'Import failed: colorScheme must be "light" or "dark".' 1067 + )}`, 1068 + 302 1069 + ); 1070 + } 1071 + const colorScheme = obj.colorScheme; 1072 + 1073 + // Validate required: tokens (must be a plain object) 1074 + if (typeof obj.tokens !== "object" || obj.tokens === null || Array.isArray(obj.tokens)) { 1075 + return c.redirect( 1076 + `/admin/themes?error=${encodeURIComponent('Import failed: missing required field "tokens".')}`, 1077 + 302 1078 + ); 1079 + } 1080 + 1081 + // Strip unknown token keys — only recognized tokens pass through 1082 + const rawTokens = obj.tokens as Record<string, unknown>; 1083 + const tokens: Record<string, string> = {}; 1084 + for (const key of ALL_KNOWN_TOKENS) { 1085 + const val = rawTokens[key]; 1086 + if (typeof val === "string") { 1087 + tokens[key] = val; 1088 + } 1089 + } 1090 + 1091 + // Validate fontUrls — each must be an HTTPS URL 1092 + let fontUrls: string[] | undefined; 1093 + if (obj.fontUrls !== undefined) { 1094 + if (!Array.isArray(obj.fontUrls)) { 1095 + return c.redirect( 1096 + `/admin/themes?error=${encodeURIComponent("Import failed: fontUrls must be an array.")}`, 1097 + 302 1098 + ); 1099 + } 1100 + for (const url of obj.fontUrls) { 1101 + if (!isHttpsUrl(url)) { 1102 + return c.redirect( 1103 + `/admin/themes?error=${encodeURIComponent( 1104 + `Import failed: font URL must be HTTPS: ${String(url)}` 1105 + )}`, 1106 + 302 1107 + ); 1108 + } 1109 + } 1110 + // Safe: every element has passed isHttpsUrl(), which verifies typeof === "string" 1111 + fontUrls = obj.fontUrls as string[]; 1112 + } 1113 + 1114 + // cssOverrides is silently dropped — keeps the shared JSON schema portable 1115 + // (no forum-specific CSS bleeding in) and avoids importing structural overrides 1116 + // that may not make sense in the target forum's layout. 1117 + 1118 + let apiRes: Response; 1119 + try { 1120 + apiRes = await fetch(`${appviewUrl}/api/admin/themes`, { 1121 + method: "POST", 1122 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1123 + body: JSON.stringify({ 1124 + name, 1125 + colorScheme, 1126 + tokens, 1127 + ...(fontUrls !== undefined && { fontUrls }), 1128 + }), 1129 + }); 1130 + } catch (error) { 1131 + if (isProgrammingError(error)) throw error; 1132 + logger.error("Network error importing theme", { 1133 + operation: "POST /admin/themes/import", 1134 + error: error instanceof Error ? error.message : String(error), 1135 + }); 1136 + return c.redirect( 1137 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1138 + 302 1139 + ); 1140 + } 1141 + 1142 + if (!apiRes.ok) { 1143 + const msg = await extractAppviewError(apiRes, "Failed to import theme. Please try again."); 1144 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 817 1145 } 818 1146 819 1147 return c.redirect("/admin/themes", 302);