@jant/core 0.6.7 → 0.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/commands/uploads/cleanup.js +2 -0
- package/dist/{app-L1UPUArB.js → app-C-jxWmAV.js} +12421 -12033
- package/dist/app-DqHzOwL5.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-CGf2m3qp.css +2 -0
- package/dist/client/_assets/{client-B0MvB2r0.js → client-DWy1LEEk.js} +2 -2
- package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-Blg-a5Ep.js} +365 -345
- package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
- package/dist/{export-DLukCOO3.js → export-C2DIB7mm.js} +34 -9
- package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
- package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
- package/dist/{github-sync-BeDecPen.js → github-sync-7XQ5ZM6z.js} +3 -3
- package/dist/{github-sync-BtHY2AST.js → github-sync-BEFCfLKK.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
- package/package.json +1 -1
- package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
- package/src/client/__tests__/compose-bridge.test.ts +105 -0
- package/src/client/__tests__/hydrate-partial.test.ts +27 -0
- package/src/client/__tests__/note-expand.test.ts +130 -0
- package/src/client/archive-nav.js +2 -1
- package/src/client/audio-player.ts +7 -3
- package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
- package/src/client/components/compose-format-convert.ts +255 -0
- package/src/client/components/compose-types.ts +2 -0
- package/src/client/components/jant-compose-dialog.ts +110 -44
- package/src/client/components/jant-compose-editor.ts +64 -11
- package/src/client/components/jant-settings-general.ts +56 -18
- package/src/client/components/settings-types.ts +11 -0
- package/src/client/compose-bridge.ts +17 -0
- package/src/client/feed-video-player.ts +1 -1
- package/src/client/hydrate-partial.ts +25 -0
- package/src/client/note-expand.ts +63 -0
- package/src/client/settings-bridge.ts +3 -0
- package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
- package/src/client/tiptap/bubble-menu.ts +37 -4
- package/src/client.ts +1 -0
- package/src/db/migrations/0026_absent_rhodey.sql +14 -0
- package/src/db/migrations/meta/0026_snapshot.json +2511 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0024_high_violations.sql +14 -0
- package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +36 -0
- package/src/db/schema.ts +36 -0
- package/src/i18n/__tests__/middleware.test.ts +46 -0
- package/src/i18n/locales/public/en.po +41 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +41 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +41 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +37 -22
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +37 -22
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +37 -22
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +17 -8
- package/src/i18n/supported-locales.ts +5 -4
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
- package/src/lib/__tests__/markdown.test.ts +1 -1
- package/src/lib/__tests__/summary.test.ts +87 -0
- package/src/lib/__tests__/timeline.test.ts +48 -1
- package/src/lib/__tests__/tiptap-render.test.ts +4 -4
- package/src/lib/__tests__/url.test.ts +44 -0
- package/src/lib/__tests__/view.test.ts +168 -1
- package/src/lib/ids.ts +1 -0
- package/src/lib/navigation.ts +1 -0
- package/src/lib/resolve-config.ts +3 -2
- package/src/lib/summary.ts +42 -3
- package/src/lib/tiptap-render.ts +6 -2
- package/src/lib/upload.ts +16 -2
- package/src/lib/url.ts +41 -0
- package/src/lib/view.ts +102 -40
- package/src/preset.css +7 -1
- package/src/routes/api/__tests__/settings.test.ts +1 -4
- package/src/routes/api/__tests__/upload.test.ts +2 -0
- package/src/routes/api/internal/__tests__/uploads.test.ts +86 -0
- package/src/routes/api/internal/sites.ts +44 -1
- package/src/routes/api/public/__tests__/archive.test.ts +66 -0
- package/src/routes/api/public/archive.ts +22 -6
- package/src/routes/api/settings.ts +2 -1
- package/src/routes/api/telegram.ts +2 -1
- package/src/routes/auth/__tests__/setup.test.ts +14 -0
- package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
- package/src/routes/dash/custom-urls.tsx +1 -1
- package/src/routes/dash/settings.tsx +23 -7
- package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
- package/src/routes/pages/archive.tsx +116 -20
- package/src/routes/pages/collections.tsx +1 -0
- package/src/services/__tests__/media.test.ts +274 -30
- package/src/services/__tests__/post.test.ts +81 -0
- package/src/services/__tests__/settings.test.ts +55 -0
- package/src/services/bootstrap.ts +7 -0
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/export-theme/layouts/_default/baseof.html +2 -1
- package/src/services/export-theme/styles/main.css +49 -15
- package/src/services/media.ts +199 -42
- package/src/services/post.ts +22 -2
- package/src/services/search.ts +4 -4
- package/src/services/settings.ts +49 -15
- package/src/services/upload-session.ts +28 -0
- package/src/styles/tokens.css +7 -5
- package/src/styles/ui.css +163 -34
- package/src/types/bindings.ts +1 -0
- package/src/types/config.ts +14 -1
- package/src/types/props.ts +3 -0
- package/src/ui/compose/ComposeDialog.tsx +13 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
- package/src/ui/dash/settings/GeneralContent.tsx +38 -4
- package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
- package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
- package/src/ui/feed/NoteCard.tsx +54 -5
- package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
- package/src/ui/layouts/BaseLayout.tsx +1 -0
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
- package/src/ui/pages/ArchivePage.tsx +89 -6
- package/src/ui/pages/CollectionsPage.tsx +7 -1
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -3
- package/src/ui/shared/CollectionsManager.tsx +3 -0
- package/dist/app-C1QgMNRY.js +0 -6
- package/dist/client/_assets/client-BMPMuwvV.css +0 -2
package/src/preset.css
CHANGED
|
@@ -315,9 +315,15 @@ html {
|
|
|
315
315
|
|
|
316
316
|
/* Blockquotes — tinted background card with quote icon */
|
|
317
317
|
blockquote {
|
|
318
|
+
--_blockquote-pad-x: 1rem;
|
|
318
319
|
position: relative;
|
|
319
320
|
margin: 1.4rem 0;
|
|
320
|
-
padding: 1.4rem
|
|
321
|
+
padding: 1.4rem var(--_blockquote-pad-x) 0.75rem;
|
|
322
|
+
/* A footnote referenced inside a quote floats within this padded box, so its
|
|
323
|
+
gutter anchor is inset by the right padding and the note drifts left. Expose
|
|
324
|
+
that inset (same value as the padding, so the two can't drift) — the sidenote
|
|
325
|
+
rule in ui.css subtracts it to pull the note back out to the gutter. */
|
|
326
|
+
--sidenote-anchor-inset: var(--_blockquote-pad-x);
|
|
321
327
|
border: none;
|
|
322
328
|
background: var(--site-blockquote-bg);
|
|
323
329
|
border-radius: 6px;
|
|
@@ -623,7 +623,7 @@ describe("Settings API Routes", () => {
|
|
|
623
623
|
expect(res.status).toBe(401);
|
|
624
624
|
});
|
|
625
625
|
|
|
626
|
-
it("removes avatar-related settings
|
|
626
|
+
it("removes avatar-related settings", async () => {
|
|
627
627
|
const storage = createMockStorage();
|
|
628
628
|
const { app, services } = createTestApp({
|
|
629
629
|
authenticated: true,
|
|
@@ -658,9 +658,6 @@ describe("Settings API Routes", () => {
|
|
|
658
658
|
await services.settings.get("SITE_FAVICON_APPLE_TOUCH"),
|
|
659
659
|
).toBeNull();
|
|
660
660
|
expect(await services.settings.get("SITE_FAVICON_VERSION")).toBeNull();
|
|
661
|
-
expect(storage.delete).toHaveBeenCalledWith(
|
|
662
|
-
"media/sit_test00000000000000000000000/assets/favicon/apple-touch-icon.png",
|
|
663
|
-
);
|
|
664
661
|
});
|
|
665
662
|
});
|
|
666
663
|
});
|
|
@@ -118,6 +118,8 @@ describe("Upload API Routes", () => {
|
|
|
118
118
|
expect(res.status).toBe(200);
|
|
119
119
|
await expect(res.json()).resolves.toEqual({ success: true });
|
|
120
120
|
expect(await services.media.getById(media.id)).toBeNull();
|
|
121
|
+
// This mock exposes no server-side copy, so the object is retired by
|
|
122
|
+
// deleting the original key immediately (no recycle window).
|
|
121
123
|
expect(storage.delete).toHaveBeenCalledWith("media/photo.webp");
|
|
122
124
|
});
|
|
123
125
|
|
|
@@ -62,6 +62,11 @@ function createMockStorage(): StorageDriver & {
|
|
|
62
62
|
async delete(key) {
|
|
63
63
|
files.delete(key);
|
|
64
64
|
},
|
|
65
|
+
|
|
66
|
+
async copy(sourceKey, destKey) {
|
|
67
|
+
const file = files.get(sourceKey);
|
|
68
|
+
if (file) files.set(destKey, { ...file });
|
|
69
|
+
},
|
|
65
70
|
};
|
|
66
71
|
}
|
|
67
72
|
|
|
@@ -120,6 +125,8 @@ describe("Internal upload admin routes", () => {
|
|
|
120
125
|
await expect(res.json()).resolves.toEqual({
|
|
121
126
|
abortedMultipartUploads: 0,
|
|
122
127
|
deletedSessions: 1,
|
|
128
|
+
deletedOrphanMedia: 0,
|
|
129
|
+
purgedStorageObjects: 0,
|
|
123
130
|
});
|
|
124
131
|
|
|
125
132
|
const remaining = sqlite
|
|
@@ -128,4 +135,83 @@ describe("Internal upload admin routes", () => {
|
|
|
128
135
|
expect(remaining).toBeUndefined();
|
|
129
136
|
expect(storage.files.has(row.tempStorageKey)).toBe(false);
|
|
130
137
|
});
|
|
138
|
+
|
|
139
|
+
it("deletes orphaned media past the grace window and keeps fresh orphans", async () => {
|
|
140
|
+
const storage = createMockStorage();
|
|
141
|
+
const { app, services, sqlite } = createTestApp({
|
|
142
|
+
authenticated: false,
|
|
143
|
+
internalAdminToken: "internal-secret",
|
|
144
|
+
storage,
|
|
145
|
+
});
|
|
146
|
+
app.route("/api/internal/uploads", internalUploadsRoutes);
|
|
147
|
+
|
|
148
|
+
const oldStorageKey = "media/old-orphan.jpg";
|
|
149
|
+
const freshStorageKey = "media/fresh-orphan.jpg";
|
|
150
|
+
await storage.put(oldStorageKey, createFakeWebpBytes(), {
|
|
151
|
+
contentType: "image/jpeg",
|
|
152
|
+
});
|
|
153
|
+
await storage.put(freshStorageKey, createFakeWebpBytes(), {
|
|
154
|
+
contentType: "image/jpeg",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const oldOrphan = await services.media.create({
|
|
158
|
+
filename: "old.jpg",
|
|
159
|
+
originalName: "old.jpg",
|
|
160
|
+
mimeType: "image/jpeg",
|
|
161
|
+
size: 1024,
|
|
162
|
+
storageKey: oldStorageKey,
|
|
163
|
+
});
|
|
164
|
+
const freshOrphan = await services.media.create({
|
|
165
|
+
filename: "fresh.jpg",
|
|
166
|
+
originalName: "fresh.jpg",
|
|
167
|
+
mimeType: "image/jpeg",
|
|
168
|
+
size: 1024,
|
|
169
|
+
storageKey: freshStorageKey,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Backdate the first orphan beyond the 7-day grace window.
|
|
173
|
+
sqlite
|
|
174
|
+
.prepare("update media set created_at = 0 where id = ?")
|
|
175
|
+
.run(oldOrphan.id);
|
|
176
|
+
|
|
177
|
+
const res = await app.request("/api/internal/uploads/cleanup", {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: {
|
|
180
|
+
Authorization: "Bearer internal-secret",
|
|
181
|
+
"Content-Type": "application/json",
|
|
182
|
+
},
|
|
183
|
+
body: JSON.stringify({ limit: 10 }),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(res.status).toBe(200);
|
|
187
|
+
await expect(res.json()).resolves.toEqual({
|
|
188
|
+
abortedMultipartUploads: 0,
|
|
189
|
+
deletedSessions: 0,
|
|
190
|
+
deletedOrphanMedia: 1,
|
|
191
|
+
// The reaped orphan's object is freshly enqueued (30 days out), so the
|
|
192
|
+
// same sweep purges nothing yet.
|
|
193
|
+
purgedStorageObjects: 0,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Old orphan: DB row gone, original key freed immediately, bytes moved to
|
|
197
|
+
// a trash/ key recorded in storage_purge (recoverable within the window).
|
|
198
|
+
expect(
|
|
199
|
+
sqlite.prepare("select id from media where id = ?").get(oldOrphan.id),
|
|
200
|
+
).toBeUndefined();
|
|
201
|
+
expect(storage.files.has(oldStorageKey)).toBe(false);
|
|
202
|
+
expect([...storage.files.keys()].some((k) => k.startsWith("trash/"))).toBe(
|
|
203
|
+
true,
|
|
204
|
+
);
|
|
205
|
+
expect(
|
|
206
|
+
sqlite
|
|
207
|
+
.prepare("select id from storage_purge where original_key = ?")
|
|
208
|
+
.get(oldStorageKey),
|
|
209
|
+
).toBeDefined();
|
|
210
|
+
|
|
211
|
+
// Fresh orphan: untouched.
|
|
212
|
+
expect(
|
|
213
|
+
sqlite.prepare("select id from media where id = ?").get(freshOrphan.id),
|
|
214
|
+
).toBeDefined();
|
|
215
|
+
expect(storage.files.has(freshStorageKey)).toBe(true);
|
|
216
|
+
});
|
|
131
217
|
});
|
|
@@ -3,7 +3,10 @@ import { z } from "zod";
|
|
|
3
3
|
import { requireInternalAdminApi } from "../../../middleware/auth.js";
|
|
4
4
|
import { ConflictError } from "../../../lib/errors.js";
|
|
5
5
|
import { parseValidated } from "../../../lib/schemas.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getConfiguredStorageDriver,
|
|
8
|
+
getSiteResolutionMode,
|
|
9
|
+
} from "../../../lib/env.js";
|
|
7
10
|
import type { Bindings } from "../../../types.js";
|
|
8
11
|
import type { AppVariables } from "../../../types/app-context.js";
|
|
9
12
|
|
|
@@ -46,6 +49,10 @@ const SitePostCountsSchema = z.object({
|
|
|
46
49
|
siteIds: z.array(z.string().trim().min(1)).max(200),
|
|
47
50
|
});
|
|
48
51
|
|
|
52
|
+
const CleanupSiteUploadsSchema = z.object({
|
|
53
|
+
limit: z.number().int().positive().max(500).optional(),
|
|
54
|
+
});
|
|
55
|
+
|
|
49
56
|
const ManagedSiteDomainSchema = z.object({
|
|
50
57
|
host: z
|
|
51
58
|
.string()
|
|
@@ -156,6 +163,42 @@ internalSitesRoutes.get(
|
|
|
156
163
|
},
|
|
157
164
|
);
|
|
158
165
|
|
|
166
|
+
// Clean up a single managed site's expired upload sessions and orphaned media
|
|
167
|
+
// (uploaded during compose but never published). Invoked per-site by the
|
|
168
|
+
// hosted control plane's scheduled maintenance. Self-hosted operators use the
|
|
169
|
+
// host-scoped `POST /api/internal/uploads/cleanup` route / `jant uploads
|
|
170
|
+
// cleanup` CLI instead.
|
|
171
|
+
internalSitesRoutes.post(
|
|
172
|
+
"/:siteId/uploads/cleanup",
|
|
173
|
+
requireInternalAdminApi(),
|
|
174
|
+
async (c) => {
|
|
175
|
+
assertHostBasedMode(c.env);
|
|
176
|
+
|
|
177
|
+
const storage = c.var.storage;
|
|
178
|
+
if (!storage) {
|
|
179
|
+
return c.json(
|
|
180
|
+
{ error: "File storage isn't set up. Check your server config." },
|
|
181
|
+
500,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const contentType = c.req.header("Content-Type") || "";
|
|
186
|
+
const rawBody = contentType.includes("application/json")
|
|
187
|
+
? await c.req.json().catch(() => ({}))
|
|
188
|
+
: {};
|
|
189
|
+
const body = parseValidated(CleanupSiteUploadsSchema, rawBody);
|
|
190
|
+
|
|
191
|
+
const services = c.var.servicesForSite(c.req.param("siteId"));
|
|
192
|
+
const result = await services.uploads.cleanupExpired({
|
|
193
|
+
storage,
|
|
194
|
+
storageDriver: getConfiguredStorageDriver(c.env),
|
|
195
|
+
limit: body.limit,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return c.json(result);
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
|
|
159
202
|
internalSitesRoutes.get(
|
|
160
203
|
"/:siteId/export",
|
|
161
204
|
requireInternalAdminApi(),
|
|
@@ -143,6 +143,72 @@ describe("Public Archive API Routes", () => {
|
|
|
143
143
|
expect(noMediaBody.posts).toHaveLength(2);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
it("filters by title and media presence words", async () => {
|
|
147
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
148
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
149
|
+
|
|
150
|
+
const titled = await services.posts.create({
|
|
151
|
+
format: "note",
|
|
152
|
+
title: "Has title",
|
|
153
|
+
bodyMarkdown: "body",
|
|
154
|
+
});
|
|
155
|
+
const untitled = await services.posts.create({
|
|
156
|
+
format: "note",
|
|
157
|
+
bodyMarkdown: "body without title",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const withTitle = await app.request("/api/public/archive?title=any");
|
|
161
|
+
const withTitleBody = await withTitle.json();
|
|
162
|
+
expect(withTitleBody.posts.map((p: { id: string }) => p.id)).toEqual([
|
|
163
|
+
titled.id,
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
const noTitle = await app.request("/api/public/archive?title=none");
|
|
167
|
+
const noTitleBody = await noTitle.json();
|
|
168
|
+
expect(noTitleBody.posts.map((p: { id: string }) => p.id)).toEqual([
|
|
169
|
+
untitled.id,
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
const noMedia = await app.request("/api/public/archive?media=none");
|
|
173
|
+
const noMediaBody = await noMedia.json();
|
|
174
|
+
expect(noMediaBody.posts).toHaveLength(2);
|
|
175
|
+
|
|
176
|
+
const anyMedia = await app.request("/api/public/archive?media=any");
|
|
177
|
+
const anyMediaBody = await anyMedia.json();
|
|
178
|
+
expect(anyMediaBody.posts).toHaveLength(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("filters threads and single posts via replies", async () => {
|
|
182
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
183
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
184
|
+
|
|
185
|
+
const threadRoot = await services.posts.create({
|
|
186
|
+
format: "note",
|
|
187
|
+
bodyMarkdown: "thread root",
|
|
188
|
+
});
|
|
189
|
+
await services.posts.create({
|
|
190
|
+
format: "note",
|
|
191
|
+
bodyMarkdown: "reply",
|
|
192
|
+
replyToId: threadRoot.id,
|
|
193
|
+
});
|
|
194
|
+
const standalone = await services.posts.create({
|
|
195
|
+
format: "note",
|
|
196
|
+
bodyMarkdown: "standalone",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const threads = await app.request("/api/public/archive?replies=any");
|
|
200
|
+
const threadsBody = await threads.json();
|
|
201
|
+
expect(threadsBody.posts.map((p: { id: string }) => p.id)).toEqual([
|
|
202
|
+
threadRoot.id,
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
const singles = await app.request("/api/public/archive?replies=none");
|
|
206
|
+
const singlesBody = await singles.json();
|
|
207
|
+
expect(singlesBody.posts.map((p: { id: string }) => p.id)).toEqual([
|
|
208
|
+
standalone.id,
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
|
|
146
212
|
it("filters by media kind", async () => {
|
|
147
213
|
const { app, services } = createTestApp({ authenticated: false });
|
|
148
214
|
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
@@ -12,20 +12,26 @@ export const publicArchiveApiRoutes = new Hono<Env>();
|
|
|
12
12
|
|
|
13
13
|
const MediaKindSchema = z.enum(MEDIA_KINDS);
|
|
14
14
|
const MEDIA_KIND_LIST = MEDIA_KINDS.join(", ");
|
|
15
|
-
const
|
|
16
|
-
"Invalid media
|
|
15
|
+
const INVALID_MEDIA_MESSAGE =
|
|
16
|
+
"Invalid media value. Allowed: any, none, or kinds: " + MEDIA_KIND_LIST;
|
|
17
17
|
|
|
18
18
|
const BoolFlagSchema = z.enum(["0", "1"]).transform((value) => value === "1");
|
|
19
|
+
const PresenceSchema = z
|
|
20
|
+
.enum(["any", "none"])
|
|
21
|
+
.transform((value) => value === "any");
|
|
19
22
|
|
|
20
23
|
const ListPublicArchiveQuerySchema = z.object({
|
|
21
24
|
format: FormatSchema.optional(),
|
|
22
25
|
collection: z.string().optional(),
|
|
23
26
|
year: z.coerce.number().int().min(1971).optional(),
|
|
27
|
+
// Kinds list (image,video,...) or presence words: any = posts with any
|
|
28
|
+
// attachment, none = posts without attachments.
|
|
24
29
|
media: z
|
|
25
30
|
.string()
|
|
26
31
|
.optional()
|
|
27
32
|
.transform((value, ctx) => {
|
|
28
33
|
if (!value) return undefined;
|
|
34
|
+
if (value === "any" || value === "none") return value;
|
|
29
35
|
const parts = value
|
|
30
36
|
.split(",")
|
|
31
37
|
.map((part) => part.trim())
|
|
@@ -35,12 +41,15 @@ const ListPublicArchiveQuerySchema = z.object({
|
|
|
35
41
|
if (!result.success) {
|
|
36
42
|
ctx.addIssue({
|
|
37
43
|
code: z.ZodIssueCode.custom,
|
|
38
|
-
message:
|
|
44
|
+
message: INVALID_MEDIA_MESSAGE,
|
|
39
45
|
});
|
|
40
46
|
return z.NEVER;
|
|
41
47
|
}
|
|
42
48
|
return result.data;
|
|
43
49
|
}),
|
|
50
|
+
title: PresenceSchema.optional(),
|
|
51
|
+
replies: PresenceSchema.optional(),
|
|
52
|
+
// Deprecated: use media=any|none and title=any|none instead.
|
|
44
53
|
hasMedia: BoolFlagSchema.optional(),
|
|
45
54
|
hasTitle: BoolFlagSchema.optional(),
|
|
46
55
|
cursor: z.string().optional(),
|
|
@@ -54,6 +63,8 @@ publicArchiveApiRoutes.get("/", async (c) => {
|
|
|
54
63
|
collection,
|
|
55
64
|
year,
|
|
56
65
|
media,
|
|
66
|
+
title,
|
|
67
|
+
replies,
|
|
57
68
|
hasMedia,
|
|
58
69
|
hasTitle,
|
|
59
70
|
cursor,
|
|
@@ -61,6 +72,10 @@ publicArchiveApiRoutes.get("/", async (c) => {
|
|
|
61
72
|
content,
|
|
62
73
|
} = parseValidated(ListPublicArchiveQuerySchema, c.req.query());
|
|
63
74
|
|
|
75
|
+
const mediaKinds = Array.isArray(media) ? media : undefined;
|
|
76
|
+
const mediaPresence =
|
|
77
|
+
media === "any" ? true : media === "none" ? false : undefined;
|
|
78
|
+
|
|
64
79
|
let collectionIds: string[] | undefined;
|
|
65
80
|
if (collection) {
|
|
66
81
|
// Accept both "tech,art" and "tech+art", matching the page URL convention.
|
|
@@ -89,9 +104,10 @@ publicArchiveApiRoutes.get("/", async (c) => {
|
|
|
89
104
|
excludeReplies: true,
|
|
90
105
|
publishedAfter,
|
|
91
106
|
publishedBefore,
|
|
92
|
-
mediaKinds
|
|
93
|
-
hasMedia,
|
|
94
|
-
hasTitle,
|
|
107
|
+
mediaKinds,
|
|
108
|
+
hasMedia: mediaPresence ?? hasMedia,
|
|
109
|
+
hasTitle: title ?? hasTitle,
|
|
110
|
+
hasReplies: replies,
|
|
95
111
|
});
|
|
96
112
|
|
|
97
113
|
const postIds = posts.map((post) => post.id);
|
|
@@ -195,7 +195,8 @@ settingsApiRoutes.post("/avatar", requireAuthApi(), async (c) => {
|
|
|
195
195
|
|
|
196
196
|
// Remove site avatar (requires auth)
|
|
197
197
|
settingsApiRoutes.delete("/avatar", requireAuthApi(), async (c) => {
|
|
198
|
-
await c.var.services.settings.removeAvatar(
|
|
198
|
+
await c.var.services.settings.removeAvatar({
|
|
199
|
+
storage: c.var.storage,
|
|
199
200
|
media: c.var.services.media,
|
|
200
201
|
storageProvider: c.var.appConfig.storageDriver,
|
|
201
202
|
});
|
|
@@ -66,7 +66,8 @@ function uploadConfigFromEnv(env: Bindings): {
|
|
|
66
66
|
maxFileSizeMB: number;
|
|
67
67
|
} {
|
|
68
68
|
const maxFileSizeMB =
|
|
69
|
-
parseInt(getEnvString(env, "UPLOAD_MAX_FILE_SIZE_MB") ?? "
|
|
69
|
+
parseInt(getEnvString(env, "UPLOAD_MAX_FILE_SIZE_MB") ?? "1024", 10) ||
|
|
70
|
+
1024;
|
|
70
71
|
return {
|
|
71
72
|
storageDriver: getConfiguredStorageDriver(env),
|
|
72
73
|
maxFileSizeMB,
|
|
@@ -79,6 +79,20 @@ describe("Setup bootstrap logic", () => {
|
|
|
79
79
|
);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
it("pins the dashboard language to the detected catalog locale", async () => {
|
|
83
|
+
await runSetupBootstrap(services, { siteLanguage: "zh-TW" });
|
|
84
|
+
|
|
85
|
+
const rows = await services.db.select().from(settings);
|
|
86
|
+
// Content language stays verbatim; the dashboard locale is the resolved
|
|
87
|
+
// catalog (zh-Hant) so it is stable if content language later changes.
|
|
88
|
+
expect(rows.find((row) => row.key === "SITE_LANGUAGE")?.value).toBe(
|
|
89
|
+
"zh-TW",
|
|
90
|
+
);
|
|
91
|
+
expect(rows.find((row) => row.key === "DASHBOARD_LANGUAGE")?.value).toBe(
|
|
92
|
+
"zh-Hant",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
82
96
|
it("is idempotent when default navigation already exists", async () => {
|
|
83
97
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
84
98
|
await services.db.run(sql`
|
|
@@ -215,29 +215,47 @@ describe("Settings - Avatar Upload Logic", () => {
|
|
|
215
215
|
expect(await settingsService.get("SITE_FAVICON_VERSION")).toBeNull();
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
-
it("
|
|
218
|
+
it("removes the apple-touch-icon media row and retires its object", async () => {
|
|
219
219
|
const storage = createMockStorage();
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
);
|
|
220
|
+
const appleTouchKey = `media/${DEFAULT_TEST_SITE_ID}/assets/favicon/apple-touch-icon.png`;
|
|
221
|
+
const media = await mediaService.create({
|
|
222
|
+
filename: "apple-touch-icon.png",
|
|
223
|
+
originalName: "apple-touch-icon.png",
|
|
224
|
+
mimeType: "image/png",
|
|
225
|
+
size: 1234,
|
|
226
|
+
storageKey: appleTouchKey,
|
|
227
|
+
provider: "r2",
|
|
228
|
+
});
|
|
229
|
+
await settingsService.set("SITE_FAVICON_APPLE_TOUCH", appleTouchKey);
|
|
230
|
+
|
|
231
|
+
await settingsService.removeAvatar({
|
|
232
|
+
storage,
|
|
233
|
+
media: mediaService,
|
|
234
|
+
storageProvider: "r2",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Row removed and setting cleared; the object is retired via storage
|
|
238
|
+
// (this mock has no server-side copy, so it's deleted outright).
|
|
239
|
+
expect(await mediaService.getById(media.id)).toBeNull();
|
|
240
|
+
expect(await settingsService.get("SITE_FAVICON_APPLE_TOUCH")).toBeNull();
|
|
241
|
+
expect(storage.delete).toHaveBeenCalledWith(appleTouchKey);
|
|
230
242
|
});
|
|
231
243
|
|
|
232
|
-
it("
|
|
233
|
-
|
|
244
|
+
it("is a no-op for media when no apple-touch-icon key exists", async () => {
|
|
245
|
+
await settingsService.set(
|
|
246
|
+
"SITE_AVATAR",
|
|
247
|
+
`media/${DEFAULT_TEST_SITE_ID}/assets/avatar/some-id.png`,
|
|
248
|
+
);
|
|
234
249
|
|
|
235
|
-
await settingsService.removeAvatar(
|
|
250
|
+
await settingsService.removeAvatar({
|
|
251
|
+
media: mediaService,
|
|
252
|
+
storageProvider: "r2",
|
|
253
|
+
});
|
|
236
254
|
|
|
237
|
-
expect(
|
|
255
|
+
expect(await settingsService.get("SITE_AVATAR")).toBeNull();
|
|
238
256
|
});
|
|
239
257
|
|
|
240
|
-
it("
|
|
258
|
+
it("clears settings even without a media service", async () => {
|
|
241
259
|
await settingsService.set(
|
|
242
260
|
"SITE_AVATAR",
|
|
243
261
|
`media/${DEFAULT_TEST_SITE_ID}/assets/avatar/some-id.png`,
|
|
@@ -247,7 +265,7 @@ describe("Settings - Avatar Upload Logic", () => {
|
|
|
247
265
|
`media/${DEFAULT_TEST_SITE_ID}/assets/favicon/apple-touch-icon.png`,
|
|
248
266
|
);
|
|
249
267
|
|
|
250
|
-
await settingsService.removeAvatar(
|
|
268
|
+
await settingsService.removeAvatar();
|
|
251
269
|
|
|
252
270
|
expect(await settingsService.get("SITE_AVATAR")).toBeNull();
|
|
253
271
|
expect(await settingsService.get("SITE_FAVICON_APPLE_TOUCH")).toBeNull();
|
|
@@ -507,7 +507,7 @@ function NewCustomUrlContent({
|
|
|
507
507
|
type="text"
|
|
508
508
|
data-bind="archiveQuery"
|
|
509
509
|
class="input"
|
|
510
|
-
placeholder="format=note&
|
|
510
|
+
placeholder="format=note&title=none&visibility=public&view=list"
|
|
511
511
|
/>
|
|
512
512
|
<p class="text-xs text-muted-foreground mt-1">
|
|
513
513
|
{i18n._(
|
|
@@ -13,7 +13,7 @@ import { z } from "zod";
|
|
|
13
13
|
import type { Bindings } from "../../types.js";
|
|
14
14
|
import type { AppVariables } from "../../types/app-context.js";
|
|
15
15
|
import { sse, dsRedirect, dsToast } from "../../lib/sse.js";
|
|
16
|
-
import { getI18n } from "../../i18n/index.js";
|
|
16
|
+
import { getI18n, isLocale, resolveCatalogLocale } from "../../i18n/index.js";
|
|
17
17
|
import { renderPublicPage } from "../../lib/render.js";
|
|
18
18
|
import { getNavigationData } from "../../lib/navigation.js";
|
|
19
19
|
import { buildPageTitle } from "../../lib/page-title.js";
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
getHostedControlPlaneAccountUrl,
|
|
58
58
|
getHostedControlPlaneProviderLabel,
|
|
59
59
|
getHostedControlPlaneSiteDeleteUrl,
|
|
60
|
+
getHostedControlPlaneSiteSettingsUrl,
|
|
60
61
|
} from "../../lib/hosted-signin.js";
|
|
61
62
|
import { syncHostedControlPlaneSiteAvatar } from "../../lib/hosted-control-plane-sync.js";
|
|
62
63
|
import {
|
|
@@ -93,6 +94,9 @@ export const settingsRoutes = new Hono<Env>();
|
|
|
93
94
|
|
|
94
95
|
const UpdateLocaleSettingsSchema = z.object({
|
|
95
96
|
siteLanguage: z.string(),
|
|
97
|
+
// Optional for back-compat: absent = leave the dashboard language untouched,
|
|
98
|
+
// "" = clear it (follow content language).
|
|
99
|
+
dashboardLanguage: z.string().optional(),
|
|
96
100
|
cjkSerifFont: z.string(),
|
|
97
101
|
timeZone: z.string(),
|
|
98
102
|
});
|
|
@@ -265,6 +269,11 @@ function demoRestrictionResponse(c: Context<Env>, message: string): Response {
|
|
|
265
269
|
|
|
266
270
|
settingsRoutes.get("/", async (c) => {
|
|
267
271
|
const navData = await getNavigationData(c);
|
|
272
|
+
const hostedControlPlaneSiteSettingsUrl =
|
|
273
|
+
getHostedControlPlaneSiteSettingsUrl(c.env, c.var.currentSite.id);
|
|
274
|
+
const hostedControlPlaneProviderLabel = getHostedControlPlaneProviderLabel(
|
|
275
|
+
c.env,
|
|
276
|
+
);
|
|
268
277
|
|
|
269
278
|
return renderPublicPage(c, {
|
|
270
279
|
title: buildPageTitle("Settings", navData.siteName),
|
|
@@ -274,6 +283,8 @@ settingsRoutes.get("/", async (c) => {
|
|
|
274
283
|
<SettingsRootContent
|
|
275
284
|
sitePathPrefix={c.var.appConfig.sitePathPrefix}
|
|
276
285
|
demoMode={c.var.appConfig.demoMode}
|
|
286
|
+
hostedControlPlaneSiteSettingsUrl={hostedControlPlaneSiteSettingsUrl}
|
|
287
|
+
hostedControlPlaneProviderLabel={hostedControlPlaneProviderLabel}
|
|
277
288
|
/>
|
|
278
289
|
</div>
|
|
279
290
|
),
|
|
@@ -310,6 +321,11 @@ settingsRoutes.get("/general", async (c) => {
|
|
|
310
321
|
siteName={dbSiteName || ""}
|
|
311
322
|
siteDescription={dbSiteDescription || ""}
|
|
312
323
|
siteLanguage={appConfig.siteLanguage}
|
|
324
|
+
dashboardLanguage={
|
|
325
|
+
isLocale(appConfig.dashboardLanguage)
|
|
326
|
+
? appConfig.dashboardLanguage
|
|
327
|
+
: resolveCatalogLocale(appConfig.siteLanguage)
|
|
328
|
+
}
|
|
313
329
|
cjkSerifFont={appConfig.cjkSerifFont}
|
|
314
330
|
siteNameFallback={appConfig.fallbacks.siteName}
|
|
315
331
|
siteDescriptionFallback={appConfig.fallbacks.siteDescription}
|
|
@@ -419,6 +435,7 @@ settingsRoutes.post("/general/language-time", async (c) => {
|
|
|
419
435
|
await c.var.services.settings.updateLocaleSettings(body, {
|
|
420
436
|
oldLanguage: c.var.appConfig.siteLanguage,
|
|
421
437
|
oldCjkSerifFont: c.var.appConfig.cjkSerifFont,
|
|
438
|
+
oldDashboardLanguage: c.var.appConfig.dashboardLanguage,
|
|
422
439
|
});
|
|
423
440
|
|
|
424
441
|
const wantsJson = c.req.header("accept")?.includes("application/json");
|
|
@@ -643,7 +660,11 @@ settingsRoutes.post("/avatar", async (c) => {
|
|
|
643
660
|
});
|
|
644
661
|
|
|
645
662
|
settingsRoutes.post("/avatar/remove", async (c) => {
|
|
646
|
-
await c.var.services.settings.removeAvatar(
|
|
663
|
+
await c.var.services.settings.removeAvatar({
|
|
664
|
+
storage: c.var.storage,
|
|
665
|
+
media: c.var.services.media,
|
|
666
|
+
storageProvider: c.var.appConfig.storageDriver,
|
|
667
|
+
});
|
|
647
668
|
try {
|
|
648
669
|
await syncHostedControlPlaneSiteAvatar({
|
|
649
670
|
appConfig: c.var.appConfig,
|
|
@@ -990,10 +1011,6 @@ settingsRoutes.get("/account", async (c) => {
|
|
|
990
1011
|
const hostedControlPlaneProviderLabel = getHostedControlPlaneProviderLabel(
|
|
991
1012
|
c.env,
|
|
992
1013
|
);
|
|
993
|
-
const hostedControlPlaneSiteDeleteUrl = getHostedControlPlaneSiteDeleteUrl(
|
|
994
|
-
c.env,
|
|
995
|
-
c.var.currentSite.id,
|
|
996
|
-
);
|
|
997
1014
|
|
|
998
1015
|
return renderPublicPage(c, {
|
|
999
1016
|
title: buildPageTitle("Account", navData.siteName),
|
|
@@ -1010,7 +1027,6 @@ settingsRoutes.get("/account", async (c) => {
|
|
|
1010
1027
|
demoMode={c.var.appConfig.demoMode}
|
|
1011
1028
|
hostedControlPlaneAccountUrl={hostedControlPlaneAccountUrl}
|
|
1012
1029
|
hostedControlPlaneProviderLabel={hostedControlPlaneProviderLabel}
|
|
1013
|
-
hostedControlPlaneSiteDeleteUrl={hostedControlPlaneSiteDeleteUrl}
|
|
1014
1030
|
/>
|
|
1015
1031
|
</>
|
|
1016
1032
|
),
|