@jant/core 0.3.25 → 0.3.27
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/dist/app.js +70 -563
- package/dist/auth.js +3 -0
- package/dist/client.js +1 -0
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/lib/avatar-upload.js +134 -0
- package/dist/lib/config.js +39 -0
- package/dist/lib/constants.js +10 -10
- package/dist/lib/favicon.js +102 -0
- package/dist/lib/image.js +13 -17
- package/dist/lib/media-helpers.js +2 -2
- package/dist/lib/navigation.js +23 -3
- package/dist/lib/render.js +10 -1
- package/dist/lib/schemas.js +31 -0
- package/dist/lib/timezones.js +388 -0
- package/dist/lib/view.js +1 -1
- package/dist/routes/api/posts.js +1 -1
- package/dist/routes/api/upload.js +3 -3
- package/dist/routes/auth/reset.js +221 -0
- package/dist/routes/auth/setup.js +194 -0
- package/dist/routes/auth/signin.js +176 -0
- package/dist/routes/dash/collections.js +23 -415
- package/dist/routes/dash/media.js +12 -392
- package/dist/routes/dash/pages.js +7 -330
- package/dist/routes/dash/redirects.js +18 -12
- package/dist/routes/dash/settings.js +198 -577
- package/dist/routes/feed/rss.js +2 -1
- package/dist/routes/feed/sitemap.js +4 -2
- package/dist/routes/pages/featured.js +5 -1
- package/dist/routes/pages/home.js +26 -1
- package/dist/routes/pages/latest.js +45 -0
- package/dist/services/post.js +30 -50
- package/dist/types/bindings.js +3 -0
- package/dist/types/config.js +147 -0
- package/dist/types/constants.js +27 -0
- package/dist/types/entities.js +3 -0
- package/dist/types/operations.js +3 -0
- package/dist/types/props.js +3 -0
- package/dist/types/views.js +5 -0
- package/dist/types.js +8 -111
- package/dist/ui/color-themes.js +33 -33
- package/dist/ui/compose/ComposeDialog.js +36 -21
- package/dist/ui/dash/PageForm.js +21 -15
- package/dist/ui/dash/PostForm.js +22 -16
- package/dist/ui/dash/collections/CollectionForm.js +152 -0
- package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
- package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
- package/dist/ui/dash/media/MediaListContent.js +166 -0
- package/dist/ui/dash/media/ViewMediaContent.js +212 -0
- package/dist/ui/dash/pages/LinkFormContent.js +130 -0
- package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
- package/dist/ui/dash/settings/AccountContent.js +209 -0
- package/dist/ui/dash/settings/AppearanceContent.js +259 -0
- package/dist/ui/dash/settings/GeneralContent.js +536 -0
- package/dist/ui/dash/settings/SettingsNav.js +41 -0
- package/dist/ui/font-themes.js +36 -0
- package/dist/ui/layouts/BaseLayout.js +24 -2
- package/dist/ui/layouts/SiteLayout.js +47 -19
- package/package.json +1 -1
- package/src/app.tsx +95 -553
- package/src/auth.ts +4 -1
- package/src/client.ts +1 -0
- package/src/i18n/locales/en.po +240 -175
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +240 -175
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +240 -175
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/config.test.ts +192 -0
- package/src/lib/__tests__/favicon.test.ts +151 -0
- package/src/lib/__tests__/image.test.ts +2 -6
- package/src/lib/__tests__/timezones.test.ts +61 -0
- package/src/lib/__tests__/view.test.ts +2 -2
- package/src/lib/avatar-upload.ts +165 -0
- package/src/lib/config.ts +47 -0
- package/src/lib/constants.ts +19 -11
- package/src/lib/favicon.ts +115 -0
- package/src/lib/image.ts +13 -21
- package/src/lib/media-helpers.ts +2 -2
- package/src/lib/navigation.ts +33 -2
- package/src/lib/render.tsx +15 -1
- package/src/lib/schemas.ts +39 -0
- package/src/lib/timezones.ts +325 -0
- package/src/lib/view.ts +1 -1
- package/src/routes/api/posts.ts +1 -1
- package/src/routes/api/upload.ts +2 -3
- package/src/routes/auth/reset.tsx +239 -0
- package/src/routes/auth/setup.tsx +189 -0
- package/src/routes/auth/signin.tsx +163 -0
- package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
- package/src/routes/dash/collections.tsx +17 -366
- package/src/routes/dash/media.tsx +12 -414
- package/src/routes/dash/pages.tsx +8 -348
- package/src/routes/dash/redirects.tsx +20 -14
- package/src/routes/dash/settings.tsx +243 -534
- package/src/routes/feed/__tests__/rss.test.ts +141 -0
- package/src/routes/feed/rss.ts +3 -1
- package/src/routes/feed/sitemap.ts +4 -2
- package/src/routes/pages/featured.tsx +7 -1
- package/src/routes/pages/home.tsx +25 -2
- package/src/routes/pages/latest.tsx +59 -0
- package/src/services/post.ts +34 -66
- package/src/styles/components.css +0 -65
- package/src/styles/tokens.css +1 -1
- package/src/styles/ui.css +24 -40
- package/src/types/bindings.ts +30 -0
- package/src/types/config.ts +183 -0
- package/src/types/constants.ts +26 -0
- package/src/types/entities.ts +109 -0
- package/src/types/operations.ts +88 -0
- package/src/types/props.ts +115 -0
- package/src/types/views.ts +172 -0
- package/src/types.ts +8 -644
- package/src/ui/__tests__/font-themes.test.ts +34 -0
- package/src/ui/color-themes.ts +34 -34
- package/src/ui/compose/ComposeDialog.tsx +40 -21
- package/src/ui/dash/PageForm.tsx +25 -19
- package/src/ui/dash/PostForm.tsx +26 -20
- package/src/ui/dash/collections/CollectionForm.tsx +153 -0
- package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
- package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
- package/src/ui/dash/media/MediaListContent.tsx +201 -0
- package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
- package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
- package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
- package/src/ui/dash/settings/AccountContent.tsx +176 -0
- package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
- package/src/ui/dash/settings/GeneralContent.tsx +533 -0
- package/src/ui/dash/settings/SettingsNav.tsx +56 -0
- package/src/ui/font-themes.ts +54 -0
- package/src/ui/layouts/BaseLayout.tsx +17 -0
- package/src/ui/layouts/SiteLayout.tsx +45 -31
package/src/app.tsx
CHANGED
|
@@ -3,15 +3,17 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { Hono } from "hono";
|
|
6
|
-
import type { FC } from "hono/jsx";
|
|
7
6
|
import { createDatabase } from "./db/index.js";
|
|
8
7
|
import { createServices, type Services } from "./services/index.js";
|
|
9
8
|
import { createAuth, type Auth } from "./auth.js";
|
|
10
9
|
import { i18nMiddleware } from "./i18n/index.js";
|
|
11
|
-
import { useLingui } from "@lingui/react/macro";
|
|
12
10
|
import type { Bindings, JantConfig } from "./types.js";
|
|
13
11
|
import { SETTINGS_KEYS } from "./lib/constants.js";
|
|
14
|
-
|
|
12
|
+
|
|
13
|
+
// Routes - Auth
|
|
14
|
+
import { setupRoutes } from "./routes/auth/setup.js";
|
|
15
|
+
import { signinRoutes } from "./routes/auth/signin.js";
|
|
16
|
+
import { resetRoutes } from "./routes/auth/reset.js";
|
|
15
17
|
|
|
16
18
|
// Routes - Pages
|
|
17
19
|
import { homeRoutes } from "./routes/pages/home.js";
|
|
@@ -21,6 +23,7 @@ import { collectionRoutes } from "./routes/pages/collection.js";
|
|
|
21
23
|
import { archiveRoutes } from "./routes/pages/archive.js";
|
|
22
24
|
import { searchRoutes } from "./routes/pages/search.js";
|
|
23
25
|
import { featuredRoutes } from "./routes/pages/featured.js";
|
|
26
|
+
import { latestRoutes } from "./routes/pages/latest.js";
|
|
24
27
|
import { collectionsPageRoutes } from "./routes/pages/collections.js";
|
|
25
28
|
|
|
26
29
|
// Routes - Dashboard
|
|
@@ -51,11 +54,11 @@ import { sitemapRoutes } from "./routes/feed/sitemap.js";
|
|
|
51
54
|
import { requireAuth } from "./middleware/auth.js";
|
|
52
55
|
import { requireOnboarding } from "./middleware/onboarding.js";
|
|
53
56
|
|
|
54
|
-
// Layouts for auth pages
|
|
55
|
-
import { BaseLayout } from "./ui/layouts/BaseLayout.js";
|
|
56
|
-
import { dsRedirect, dsToast } from "./lib/sse.js";
|
|
57
57
|
import { getAvailableThemes, buildThemeStyle } from "./lib/theme.js";
|
|
58
58
|
import { createStorageDriver, type StorageDriver } from "./lib/storage.js";
|
|
59
|
+
import { BUILTIN_FONT_THEMES } from "./ui/font-themes.js";
|
|
60
|
+
import { getMediaUrl, getPublicUrlForProvider } from "./lib/image.js";
|
|
61
|
+
import { base64ToUint8Array } from "./lib/favicon.js";
|
|
59
62
|
|
|
60
63
|
// Extend Hono's context variables
|
|
61
64
|
export interface AppVariables {
|
|
@@ -66,6 +69,8 @@ export interface AppVariables {
|
|
|
66
69
|
customCSS: string;
|
|
67
70
|
isAuthenticated: boolean;
|
|
68
71
|
storage: StorageDriver | null;
|
|
72
|
+
faviconUrl?: string;
|
|
73
|
+
noindex?: boolean;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
export type App = Hono<{ Bindings: Bindings; Variables: AppVariables }>;
|
|
@@ -97,8 +102,6 @@ export function createApp(config: JantConfig = {}): App {
|
|
|
97
102
|
// Initialize services, auth, and config middleware
|
|
98
103
|
app.use("*", async (c, next) => {
|
|
99
104
|
// Use withSession() to enable D1 Read Replication
|
|
100
|
-
// Automatically routes read queries to the nearest replica for lower latency
|
|
101
|
-
// See: https://developers.cloudflare.com/d1/best-practices/read-replication/
|
|
102
105
|
const session = c.env.DB.withSession();
|
|
103
106
|
|
|
104
107
|
// Note: Drizzle ORM doesn't officially support D1DatabaseSession yet (issue #2226)
|
|
@@ -109,11 +112,20 @@ export function createApp(config: JantConfig = {}): App {
|
|
|
109
112
|
c.set("config", resolvedConfig);
|
|
110
113
|
c.set("storage", createStorageDriver(c.env));
|
|
111
114
|
|
|
115
|
+
if (!c.env.AUTH_SECRET) {
|
|
116
|
+
// eslint-disable-next-line no-console -- Startup warning is intentional
|
|
117
|
+
console.warn(
|
|
118
|
+
"[Jant] AUTH_SECRET is not set. Authentication is disabled. Set AUTH_SECRET in .dev.vars or wrangler.toml to enable auth.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
112
122
|
if (c.env.AUTH_SECRET) {
|
|
113
123
|
const baseURL = c.env.SITE_URL || new URL(c.req.url).origin;
|
|
124
|
+
const requestUrl = new URL(c.req.url);
|
|
114
125
|
const auth = createAuth(session as unknown as D1Database, {
|
|
115
126
|
secret: c.env.AUTH_SECRET,
|
|
116
127
|
baseURL,
|
|
128
|
+
useSecureCookies: requestUrl.protocol === "https:",
|
|
117
129
|
});
|
|
118
130
|
c.set("auth", auth);
|
|
119
131
|
}
|
|
@@ -124,23 +136,50 @@ export function createApp(config: JantConfig = {}): App {
|
|
|
124
136
|
// Onboarding gate — redirect to /setup if not yet initialized
|
|
125
137
|
app.use("*", requireOnboarding());
|
|
126
138
|
|
|
127
|
-
// Theme middleware - resolve active color theme, custom CSS, and auth state
|
|
139
|
+
// Theme middleware - resolve active color theme, font theme, custom CSS, and auth state
|
|
128
140
|
app.use("*", async (c, next) => {
|
|
129
|
-
const [themeId, customCSS] =
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
141
|
+
const [themeId, fontThemeId, customCSS, noindexValue, avatarKey] =
|
|
142
|
+
await Promise.all([
|
|
143
|
+
c.var.services.settings.get(SETTINGS_KEYS.THEME),
|
|
144
|
+
c.var.services.settings.get("FONT_THEME"),
|
|
145
|
+
c.var.services.settings.get(SETTINGS_KEYS.CUSTOM_CSS),
|
|
146
|
+
c.var.services.settings.get("NOINDEX"),
|
|
147
|
+
c.var.services.settings.get("SITE_AVATAR"),
|
|
148
|
+
]);
|
|
133
149
|
const themes = getAvailableThemes(resolvedConfig);
|
|
134
150
|
const activeTheme = themeId
|
|
135
151
|
? themes.find((t) => t.id === themeId)
|
|
136
152
|
: undefined;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
153
|
+
|
|
154
|
+
// Build font override CSS variables
|
|
155
|
+
const fontTheme = fontThemeId
|
|
156
|
+
? BUILTIN_FONT_THEMES.find((f) => f.id === fontThemeId)
|
|
157
|
+
: undefined;
|
|
158
|
+
const fontOverrides: Record<string, string> = {};
|
|
159
|
+
if (fontTheme) {
|
|
160
|
+
fontOverrides["--font-body"] = fontTheme.fontFamily;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const themeStyle = buildThemeStyle(activeTheme, {
|
|
164
|
+
...resolvedConfig.cssVariables,
|
|
165
|
+
...fontOverrides,
|
|
166
|
+
});
|
|
141
167
|
c.set("themeStyle", themeStyle);
|
|
142
168
|
c.set("customCSS", customCSS ?? "");
|
|
143
169
|
|
|
170
|
+
// Noindex
|
|
171
|
+
c.set("noindex", noindexValue === "true");
|
|
172
|
+
|
|
173
|
+
// Resolve favicon from avatar storage key
|
|
174
|
+
if (avatarKey) {
|
|
175
|
+
const publicUrl = getPublicUrlForProvider(
|
|
176
|
+
c.env.STORAGE_DRIVER || "r2",
|
|
177
|
+
c.env.R2_PUBLIC_URL,
|
|
178
|
+
c.env.S3_PUBLIC_URL,
|
|
179
|
+
);
|
|
180
|
+
c.set("faviconUrl", getMediaUrl(avatarKey, publicUrl));
|
|
181
|
+
}
|
|
182
|
+
|
|
144
183
|
// Check auth state for data-authenticated attribute on <body>
|
|
145
184
|
let isAuthenticated = false;
|
|
146
185
|
if (c.var.auth) {
|
|
@@ -196,6 +235,31 @@ export function createApp(config: JantConfig = {}): App {
|
|
|
196
235
|
}),
|
|
197
236
|
);
|
|
198
237
|
|
|
238
|
+
// Favicon routes - serve from DB settings (small files, avoids R2 round-trip)
|
|
239
|
+
app.get("/favicon.ico", async (c) => {
|
|
240
|
+
const data = await c.var.services.settings.get("SITE_FAVICON_ICO");
|
|
241
|
+
if (!data) return c.notFound();
|
|
242
|
+
|
|
243
|
+
return new Response(base64ToUint8Array(data), {
|
|
244
|
+
headers: {
|
|
245
|
+
"Content-Type": "image/x-icon",
|
|
246
|
+
"Cache-Control": "public, max-age=86400",
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
app.get("/apple-touch-icon.png", async (c) => {
|
|
252
|
+
const data = await c.var.services.settings.get("SITE_FAVICON_APPLE_TOUCH");
|
|
253
|
+
if (!data) return c.notFound();
|
|
254
|
+
|
|
255
|
+
return new Response(base64ToUint8Array(data), {
|
|
256
|
+
headers: {
|
|
257
|
+
"Content-Type": "image/png",
|
|
258
|
+
"Cache-Control": "public, max-age=86400",
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
199
263
|
// better-auth handler
|
|
200
264
|
app.all("/api/auth/*", async (c) => {
|
|
201
265
|
if (!c.var.auth) {
|
|
@@ -211,529 +275,10 @@ export function createApp(config: JantConfig = {}): App {
|
|
|
211
275
|
app.route("/api/collections", collectionsApiRoutes);
|
|
212
276
|
app.route("/api/settings", settingsApiRoutes);
|
|
213
277
|
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return (
|
|
219
|
-
<div class="min-h-screen flex items-center justify-center">
|
|
220
|
-
<div class="card max-w-md w-full">
|
|
221
|
-
<header>
|
|
222
|
-
<h2>
|
|
223
|
-
{t({
|
|
224
|
-
message: "Welcome to Jant",
|
|
225
|
-
comment: "@context: Setup page welcome heading",
|
|
226
|
-
})}
|
|
227
|
-
</h2>
|
|
228
|
-
<p>
|
|
229
|
-
{t({
|
|
230
|
-
message: "Create your admin account.",
|
|
231
|
-
comment: "@context: Setup page description",
|
|
232
|
-
})}
|
|
233
|
-
</p>
|
|
234
|
-
</header>
|
|
235
|
-
<section>
|
|
236
|
-
<form
|
|
237
|
-
data-signals="{name: '', email: '', password: ''}"
|
|
238
|
-
data-on:submit__prevent="@post('/setup')"
|
|
239
|
-
data-indicator="_loading"
|
|
240
|
-
class="flex flex-col gap-4"
|
|
241
|
-
>
|
|
242
|
-
<div class="field">
|
|
243
|
-
<label class="label">
|
|
244
|
-
{t({
|
|
245
|
-
message: "Your Name",
|
|
246
|
-
comment: "@context: Setup form field - user name",
|
|
247
|
-
})}
|
|
248
|
-
</label>
|
|
249
|
-
<input
|
|
250
|
-
type="text"
|
|
251
|
-
data-bind="name"
|
|
252
|
-
class="input"
|
|
253
|
-
required
|
|
254
|
-
placeholder="John Doe"
|
|
255
|
-
/>
|
|
256
|
-
</div>
|
|
257
|
-
<div class="field">
|
|
258
|
-
<label class="label">
|
|
259
|
-
{t({
|
|
260
|
-
message: "Email",
|
|
261
|
-
comment: "@context: Setup/signin form field - email",
|
|
262
|
-
})}
|
|
263
|
-
</label>
|
|
264
|
-
<input
|
|
265
|
-
type="email"
|
|
266
|
-
data-bind="email"
|
|
267
|
-
class="input"
|
|
268
|
-
required
|
|
269
|
-
placeholder="you@example.com"
|
|
270
|
-
/>
|
|
271
|
-
</div>
|
|
272
|
-
<div class="field">
|
|
273
|
-
<label class="label">
|
|
274
|
-
{t({
|
|
275
|
-
message: "Password",
|
|
276
|
-
comment: "@context: Setup/signin form field - password",
|
|
277
|
-
})}
|
|
278
|
-
</label>
|
|
279
|
-
<input
|
|
280
|
-
type="password"
|
|
281
|
-
data-bind="password"
|
|
282
|
-
class="input"
|
|
283
|
-
required
|
|
284
|
-
minLength={8}
|
|
285
|
-
/>
|
|
286
|
-
</div>
|
|
287
|
-
<button type="submit" class="btn" data-attr-disabled="$_loading">
|
|
288
|
-
<span data-show="!$_loading">
|
|
289
|
-
{t({
|
|
290
|
-
message: "Complete Setup",
|
|
291
|
-
comment: "@context: Setup form submit button",
|
|
292
|
-
})}
|
|
293
|
-
</span>
|
|
294
|
-
<span data-show="$_loading">
|
|
295
|
-
{t({
|
|
296
|
-
message: "Processing...",
|
|
297
|
-
comment:
|
|
298
|
-
"@context: Loading text shown on submit button while request is in progress",
|
|
299
|
-
})}
|
|
300
|
-
</span>
|
|
301
|
-
</button>
|
|
302
|
-
</form>
|
|
303
|
-
</section>
|
|
304
|
-
</div>
|
|
305
|
-
</div>
|
|
306
|
-
);
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
// Setup page
|
|
310
|
-
app.get("/setup", async (c) => {
|
|
311
|
-
const isComplete = await c.var.services.settings.isOnboardingComplete();
|
|
312
|
-
if (isComplete) return c.redirect("/");
|
|
313
|
-
|
|
314
|
-
return c.html(
|
|
315
|
-
<BaseLayout title="Setup - Jant" c={c}>
|
|
316
|
-
<SetupContent />
|
|
317
|
-
</BaseLayout>,
|
|
318
|
-
);
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
app.post("/setup", async (c) => {
|
|
322
|
-
const isComplete = await c.var.services.settings.isOnboardingComplete();
|
|
323
|
-
if (isComplete) return c.redirect("/");
|
|
324
|
-
|
|
325
|
-
const body = await c.req.json<{
|
|
326
|
-
name: string;
|
|
327
|
-
email: string;
|
|
328
|
-
password: string;
|
|
329
|
-
}>();
|
|
330
|
-
const { name, email, password } = body;
|
|
331
|
-
|
|
332
|
-
if (!name || !email || !password) {
|
|
333
|
-
return dsToast("All fields are required", "error");
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
if (password.length < 8) {
|
|
337
|
-
return dsToast("Password must be at least 8 characters", "error");
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
if (!c.var.auth) {
|
|
341
|
-
return dsToast("AUTH_SECRET not configured", "error");
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
try {
|
|
345
|
-
const signUpResponse = await c.var.auth.api.signUpEmail({
|
|
346
|
-
body: { name, email, password },
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
if (!signUpResponse || "error" in signUpResponse) {
|
|
350
|
-
return dsToast("Failed to create account", "error");
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
await c.var.services.settings.completeOnboarding();
|
|
354
|
-
|
|
355
|
-
// Seed default navigation items
|
|
356
|
-
await c.var.services.navItems.create({
|
|
357
|
-
type: "link",
|
|
358
|
-
label: "Featured",
|
|
359
|
-
url: "/featured",
|
|
360
|
-
});
|
|
361
|
-
await c.var.services.navItems.create({
|
|
362
|
-
type: "link",
|
|
363
|
-
label: "Collections",
|
|
364
|
-
url: "/collections",
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
return dsRedirect("/signin?setup");
|
|
368
|
-
} catch (err) {
|
|
369
|
-
// eslint-disable-next-line no-console -- Error logging is intentional
|
|
370
|
-
console.error("Setup error:", err);
|
|
371
|
-
return dsToast("Failed to create account", "error");
|
|
372
|
-
}
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
// Signin page component
|
|
376
|
-
const SigninContent: FC<{
|
|
377
|
-
demoEmail?: string;
|
|
378
|
-
demoPassword?: string;
|
|
379
|
-
}> = ({ demoEmail, demoPassword }) => {
|
|
380
|
-
const { t } = useLingui();
|
|
381
|
-
const signals = JSON.stringify({
|
|
382
|
-
email: demoEmail || "",
|
|
383
|
-
password: demoPassword || "",
|
|
384
|
-
}).replace(/</g, "\\u003c");
|
|
385
|
-
|
|
386
|
-
return (
|
|
387
|
-
<div class="min-h-screen flex items-center justify-center">
|
|
388
|
-
<div class="card max-w-md w-full">
|
|
389
|
-
<header>
|
|
390
|
-
<h2>
|
|
391
|
-
{t({
|
|
392
|
-
message: "Sign In",
|
|
393
|
-
comment: "@context: Sign in page heading",
|
|
394
|
-
})}
|
|
395
|
-
</h2>
|
|
396
|
-
</header>
|
|
397
|
-
<section>
|
|
398
|
-
{demoEmail && demoPassword && (
|
|
399
|
-
<p class="text-muted-foreground text-sm mb-4">
|
|
400
|
-
{t({
|
|
401
|
-
message: "Demo account pre-filled. Just click Sign In.",
|
|
402
|
-
comment:
|
|
403
|
-
"@context: Hint shown on signin page when demo credentials are pre-filled",
|
|
404
|
-
})}
|
|
405
|
-
</p>
|
|
406
|
-
)}
|
|
407
|
-
<form
|
|
408
|
-
data-signals={signals}
|
|
409
|
-
data-on:submit__prevent="@post('/signin')"
|
|
410
|
-
data-indicator="_loading"
|
|
411
|
-
class="flex flex-col gap-4"
|
|
412
|
-
>
|
|
413
|
-
<div class="field">
|
|
414
|
-
<label class="label">
|
|
415
|
-
{t({
|
|
416
|
-
message: "Email",
|
|
417
|
-
comment: "@context: Setup/signin form field - email",
|
|
418
|
-
})}
|
|
419
|
-
</label>
|
|
420
|
-
<input type="email" data-bind="email" class="input" required />
|
|
421
|
-
</div>
|
|
422
|
-
<div class="field">
|
|
423
|
-
<label class="label">
|
|
424
|
-
{t({
|
|
425
|
-
message: "Password",
|
|
426
|
-
comment: "@context: Setup/signin form field - password",
|
|
427
|
-
})}
|
|
428
|
-
</label>
|
|
429
|
-
<input
|
|
430
|
-
type="password"
|
|
431
|
-
data-bind="password"
|
|
432
|
-
class="input"
|
|
433
|
-
required
|
|
434
|
-
/>
|
|
435
|
-
</div>
|
|
436
|
-
<button type="submit" class="btn" data-attr-disabled="$_loading">
|
|
437
|
-
<span data-show="!$_loading">
|
|
438
|
-
{t({
|
|
439
|
-
message: "Sign In",
|
|
440
|
-
comment: "@context: Sign in form submit button",
|
|
441
|
-
})}
|
|
442
|
-
</span>
|
|
443
|
-
<span data-show="$_loading">
|
|
444
|
-
{t({
|
|
445
|
-
message: "Processing...",
|
|
446
|
-
comment:
|
|
447
|
-
"@context: Loading text shown on submit button while request is in progress",
|
|
448
|
-
})}
|
|
449
|
-
</span>
|
|
450
|
-
</button>
|
|
451
|
-
</form>
|
|
452
|
-
</section>
|
|
453
|
-
</div>
|
|
454
|
-
</div>
|
|
455
|
-
);
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
// Signin page
|
|
459
|
-
app.get("/signin", async (c) => {
|
|
460
|
-
const isSetup = c.req.query("setup") !== undefined;
|
|
461
|
-
const isReset = c.req.query("reset") !== undefined;
|
|
462
|
-
let toast: { message: string } | undefined;
|
|
463
|
-
if (isSetup) {
|
|
464
|
-
toast = { message: "Account created successfully. Please sign in." };
|
|
465
|
-
} else if (isReset) {
|
|
466
|
-
toast = { message: "Password reset successfully. Please sign in." };
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
return c.html(
|
|
470
|
-
<BaseLayout title="Sign In - Jant" c={c} toast={toast}>
|
|
471
|
-
<SigninContent
|
|
472
|
-
demoEmail={c.env.DEMO_EMAIL}
|
|
473
|
-
demoPassword={c.env.DEMO_PASSWORD}
|
|
474
|
-
/>
|
|
475
|
-
</BaseLayout>,
|
|
476
|
-
);
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
app.post("/signin", async (c) => {
|
|
480
|
-
if (!c.var.auth) {
|
|
481
|
-
return dsToast("Auth not configured", "error");
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
const body = await c.req.json<{ email: string; password: string }>();
|
|
485
|
-
const { email, password } = body;
|
|
486
|
-
|
|
487
|
-
try {
|
|
488
|
-
const { headers } = await c.var.auth.api.signInEmail({
|
|
489
|
-
returnHeaders: true,
|
|
490
|
-
body: { email, password },
|
|
491
|
-
headers: c.req.raw.headers,
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
return dsRedirect("/dash", { headers });
|
|
495
|
-
} catch {
|
|
496
|
-
return dsToast("Invalid email or password", "error");
|
|
497
|
-
}
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
app.get("/signout", async (c) => {
|
|
501
|
-
if (c.var.auth) {
|
|
502
|
-
try {
|
|
503
|
-
await c.var.auth.api.signOut({ headers: c.req.raw.headers });
|
|
504
|
-
} catch {
|
|
505
|
-
// Ignore signout errors
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
return c.redirect("/");
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
// Password reset via one-time token
|
|
512
|
-
const ResetContent: FC<{ token: string }> = ({ token }) => {
|
|
513
|
-
const { t } = useLingui();
|
|
514
|
-
const signals = JSON.stringify({
|
|
515
|
-
password: "",
|
|
516
|
-
confirmPassword: "",
|
|
517
|
-
token,
|
|
518
|
-
}).replace(/</g, "\\u003c");
|
|
519
|
-
|
|
520
|
-
return (
|
|
521
|
-
<div class="min-h-screen flex items-center justify-center">
|
|
522
|
-
<div class="card max-w-md w-full">
|
|
523
|
-
<header>
|
|
524
|
-
<h2>
|
|
525
|
-
{t({
|
|
526
|
-
message: "Reset Password",
|
|
527
|
-
comment: "@context: Password reset page heading",
|
|
528
|
-
})}
|
|
529
|
-
</h2>
|
|
530
|
-
<p>
|
|
531
|
-
{t({
|
|
532
|
-
message: "Enter your new password.",
|
|
533
|
-
comment: "@context: Password reset page description",
|
|
534
|
-
})}
|
|
535
|
-
</p>
|
|
536
|
-
</header>
|
|
537
|
-
<section>
|
|
538
|
-
<form
|
|
539
|
-
data-signals={signals}
|
|
540
|
-
data-on:submit__prevent="@post('/reset')"
|
|
541
|
-
data-indicator="_loading"
|
|
542
|
-
class="flex flex-col gap-4"
|
|
543
|
-
>
|
|
544
|
-
<div class="field">
|
|
545
|
-
<label class="label">
|
|
546
|
-
{t({
|
|
547
|
-
message: "New Password",
|
|
548
|
-
comment: "@context: Password reset form field",
|
|
549
|
-
})}
|
|
550
|
-
</label>
|
|
551
|
-
<input
|
|
552
|
-
type="password"
|
|
553
|
-
data-bind="password"
|
|
554
|
-
class="input"
|
|
555
|
-
required
|
|
556
|
-
minLength={8}
|
|
557
|
-
autocomplete="new-password"
|
|
558
|
-
/>
|
|
559
|
-
</div>
|
|
560
|
-
<div class="field">
|
|
561
|
-
<label class="label">
|
|
562
|
-
{t({
|
|
563
|
-
message: "Confirm Password",
|
|
564
|
-
comment: "@context: Password reset form field",
|
|
565
|
-
})}
|
|
566
|
-
</label>
|
|
567
|
-
<input
|
|
568
|
-
type="password"
|
|
569
|
-
data-bind="confirmPassword"
|
|
570
|
-
class="input"
|
|
571
|
-
required
|
|
572
|
-
minLength={8}
|
|
573
|
-
autocomplete="new-password"
|
|
574
|
-
/>
|
|
575
|
-
</div>
|
|
576
|
-
<button type="submit" class="btn" data-attr-disabled="$_loading">
|
|
577
|
-
<span data-show="!$_loading">
|
|
578
|
-
{t({
|
|
579
|
-
message: "Reset Password",
|
|
580
|
-
comment: "@context: Password reset form submit button",
|
|
581
|
-
})}
|
|
582
|
-
</span>
|
|
583
|
-
<span data-show="$_loading">
|
|
584
|
-
{t({
|
|
585
|
-
message: "Processing...",
|
|
586
|
-
comment:
|
|
587
|
-
"@context: Loading text shown on submit button while request is in progress",
|
|
588
|
-
})}
|
|
589
|
-
</span>
|
|
590
|
-
</button>
|
|
591
|
-
</form>
|
|
592
|
-
</section>
|
|
593
|
-
</div>
|
|
594
|
-
</div>
|
|
595
|
-
);
|
|
596
|
-
};
|
|
597
|
-
|
|
598
|
-
const ResetErrorContent: FC = () => {
|
|
599
|
-
const { t } = useLingui();
|
|
600
|
-
|
|
601
|
-
return (
|
|
602
|
-
<div class="min-h-screen flex items-center justify-center">
|
|
603
|
-
<div class="card max-w-md w-full">
|
|
604
|
-
<header>
|
|
605
|
-
<h2>
|
|
606
|
-
{t({
|
|
607
|
-
message: "Invalid or Expired Link",
|
|
608
|
-
comment: "@context: Password reset error heading",
|
|
609
|
-
})}
|
|
610
|
-
</h2>
|
|
611
|
-
</header>
|
|
612
|
-
<section>
|
|
613
|
-
<p class="text-muted-foreground">
|
|
614
|
-
{t({
|
|
615
|
-
message:
|
|
616
|
-
"This password reset link is invalid or has expired. Please generate a new one.",
|
|
617
|
-
comment: "@context: Password reset error description",
|
|
618
|
-
})}
|
|
619
|
-
</p>
|
|
620
|
-
</section>
|
|
621
|
-
</div>
|
|
622
|
-
</div>
|
|
623
|
-
);
|
|
624
|
-
};
|
|
625
|
-
|
|
626
|
-
app.get("/reset", async (c) => {
|
|
627
|
-
const token = c.req.query("token");
|
|
628
|
-
if (!token) {
|
|
629
|
-
return c.html(
|
|
630
|
-
<BaseLayout title="Reset Password - Jant" c={c}>
|
|
631
|
-
<ResetErrorContent />
|
|
632
|
-
</BaseLayout>,
|
|
633
|
-
);
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
const stored = await c.var.services.settings.get(
|
|
637
|
-
SETTINGS_KEYS.PASSWORD_RESET_TOKEN,
|
|
638
|
-
);
|
|
639
|
-
if (!stored) {
|
|
640
|
-
return c.html(
|
|
641
|
-
<BaseLayout title="Reset Password - Jant" c={c}>
|
|
642
|
-
<ResetErrorContent />
|
|
643
|
-
</BaseLayout>,
|
|
644
|
-
);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
const separatorIndex = stored.lastIndexOf(":");
|
|
648
|
-
const storedToken = stored.substring(0, separatorIndex);
|
|
649
|
-
const expiry = parseInt(stored.substring(separatorIndex + 1), 10);
|
|
650
|
-
const now = Math.floor(Date.now() / 1000);
|
|
651
|
-
|
|
652
|
-
if (token !== storedToken || now > expiry) {
|
|
653
|
-
return c.html(
|
|
654
|
-
<BaseLayout title="Reset Password - Jant" c={c}>
|
|
655
|
-
<ResetErrorContent />
|
|
656
|
-
</BaseLayout>,
|
|
657
|
-
);
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
return c.html(
|
|
661
|
-
<BaseLayout title="Reset Password - Jant" c={c}>
|
|
662
|
-
<ResetContent token={token} />
|
|
663
|
-
</BaseLayout>,
|
|
664
|
-
);
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
app.post("/reset", async (c) => {
|
|
668
|
-
const body = await c.req.json<{
|
|
669
|
-
password: string;
|
|
670
|
-
confirmPassword: string;
|
|
671
|
-
token: string;
|
|
672
|
-
}>();
|
|
673
|
-
const { password, confirmPassword, token } = body;
|
|
674
|
-
|
|
675
|
-
// Validate token
|
|
676
|
-
const stored = await c.var.services.settings.get(
|
|
677
|
-
SETTINGS_KEYS.PASSWORD_RESET_TOKEN,
|
|
678
|
-
);
|
|
679
|
-
if (!stored) {
|
|
680
|
-
return dsToast("Invalid or expired reset link.", "error");
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
const separatorIndex = stored.lastIndexOf(":");
|
|
684
|
-
const storedToken = stored.substring(0, separatorIndex);
|
|
685
|
-
const expiry = parseInt(stored.substring(separatorIndex + 1), 10);
|
|
686
|
-
const now = Math.floor(Date.now() / 1000);
|
|
687
|
-
|
|
688
|
-
if (token !== storedToken || now > expiry) {
|
|
689
|
-
return dsToast("Invalid or expired reset link.", "error");
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
// Validate passwords
|
|
693
|
-
if (!password || password.length < 8) {
|
|
694
|
-
return dsToast("Password must be at least 8 characters.", "error");
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
if (password !== confirmPassword) {
|
|
698
|
-
return dsToast("Passwords do not match.", "error");
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
try {
|
|
702
|
-
const hashedPassword = await hashPassword(password);
|
|
703
|
-
const db = c.env.DB.withSession() as unknown as D1Database;
|
|
704
|
-
|
|
705
|
-
// Get admin user
|
|
706
|
-
const userResult = await db
|
|
707
|
-
.prepare("SELECT id FROM user LIMIT 1")
|
|
708
|
-
.first<{ id: string }>();
|
|
709
|
-
if (!userResult) {
|
|
710
|
-
return dsToast("No user account found.", "error");
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// Update password
|
|
714
|
-
await db
|
|
715
|
-
.prepare(
|
|
716
|
-
"UPDATE account SET password = ? WHERE user_id = ? AND provider_id = 'credential'",
|
|
717
|
-
)
|
|
718
|
-
.bind(hashedPassword, userResult.id)
|
|
719
|
-
.run();
|
|
720
|
-
|
|
721
|
-
// Delete all sessions
|
|
722
|
-
await db
|
|
723
|
-
.prepare("DELETE FROM session WHERE user_id = ?")
|
|
724
|
-
.bind(userResult.id)
|
|
725
|
-
.run();
|
|
726
|
-
|
|
727
|
-
// Delete the reset token
|
|
728
|
-
await c.var.services.settings.remove(SETTINGS_KEYS.PASSWORD_RESET_TOKEN);
|
|
729
|
-
|
|
730
|
-
return dsRedirect("/signin?reset");
|
|
731
|
-
} catch (err) {
|
|
732
|
-
// eslint-disable-next-line no-console -- Error logging is intentional
|
|
733
|
-
console.error("Password reset error:", err);
|
|
734
|
-
return dsToast("Failed to reset password.", "error");
|
|
735
|
-
}
|
|
736
|
-
});
|
|
278
|
+
// Auth routes
|
|
279
|
+
app.route("/", setupRoutes);
|
|
280
|
+
app.route("/", signinRoutes);
|
|
281
|
+
app.route("/", resetRoutes);
|
|
737
282
|
|
|
738
283
|
// Dashboard routes (protected)
|
|
739
284
|
app.use("/dash/*", requireAuth());
|
|
@@ -748,29 +293,25 @@ export function createApp(config: JantConfig = {}): App {
|
|
|
748
293
|
app.route("/api/upload", uploadApiRoutes);
|
|
749
294
|
app.route("/api/search", searchApiRoutes);
|
|
750
295
|
|
|
751
|
-
// Media files from storage (
|
|
752
|
-
app.get("/media
|
|
296
|
+
// Media files from storage (path matches storage key: media/YYYY/MM/uuid.ext)
|
|
297
|
+
app.get("/media/*", async (c) => {
|
|
753
298
|
const storage = c.var.storage;
|
|
754
299
|
if (!storage) {
|
|
755
300
|
return c.notFound();
|
|
756
301
|
}
|
|
757
302
|
|
|
758
|
-
//
|
|
759
|
-
const
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
const media = await c.var.services.media.getById(mediaId);
|
|
763
|
-
if (!media) {
|
|
764
|
-
return c.notFound();
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
const object = await storage.get(media.storageKey);
|
|
303
|
+
// The storage key is the full path without the leading "/"
|
|
304
|
+
const storageKey = c.req.path.slice(1);
|
|
305
|
+
const object = await storage.get(storageKey);
|
|
768
306
|
if (!object) {
|
|
769
307
|
return c.notFound();
|
|
770
308
|
}
|
|
771
309
|
|
|
772
310
|
const headers = new Headers();
|
|
773
|
-
headers.set(
|
|
311
|
+
headers.set(
|
|
312
|
+
"Content-Type",
|
|
313
|
+
object.contentType || "application/octet-stream",
|
|
314
|
+
);
|
|
774
315
|
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
775
316
|
|
|
776
317
|
return new Response(object.body, { headers });
|
|
@@ -787,6 +328,7 @@ export function createApp(config: JantConfig = {}): App {
|
|
|
787
328
|
app.route("/search", searchRoutes);
|
|
788
329
|
app.route("/archive", archiveRoutes);
|
|
789
330
|
app.route("/featured", featuredRoutes);
|
|
331
|
+
app.route("/latest", latestRoutes);
|
|
790
332
|
app.route("/collections", collectionsPageRoutes);
|
|
791
333
|
app.route("/c", collectionRoutes);
|
|
792
334
|
app.route("/p", postRoutes);
|