@acms-subir/core 0.0.1
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/cli.mjs +75 -0
- package/integration.mjs +286 -0
- package/package.json +89 -0
- package/src/actions/index.ts +502 -0
- package/src/components/DesignManager.tsx +542 -0
- package/src/components/LinksManager.tsx +694 -0
- package/src/components/Modal.astro +65 -0
- package/src/components/NavManager.tsx +168 -0
- package/src/components/WidgetRenderer.astro +84 -0
- package/src/components/builder/PageBuilder.tsx +575 -0
- package/src/components/editor/ImagePicker.tsx +215 -0
- package/src/components/editor/PostEditor.tsx +1011 -0
- package/src/components/editor/PreviewOverlay.tsx +77 -0
- package/src/components/editor/extensions/calendar.tsx +230 -0
- package/src/components/editor/extensions/cards.tsx +488 -0
- package/src/components/editor/extensions/codeBlock.tsx +70 -0
- package/src/components/editor/extensions/infoCards.tsx +270 -0
- package/src/components/editor/extensions/mediaCards.tsx +376 -0
- package/src/components/editor/extensions/postList.tsx +130 -0
- package/src/components/editor/extensions/slash.tsx +250 -0
- package/src/components/editor/extensions/tableTyping.ts +63 -0
- package/src/components/editor/imaging.ts +146 -0
- package/src/components/media/CropModal.tsx +229 -0
- package/src/components/media/MediaLibrary.tsx +295 -0
- package/src/layouts/Admin.astro +119 -0
- package/src/layouts/Site.astro +218 -0
- package/src/lib/auth.ts +94 -0
- package/src/lib/backup/unzip.ts +38 -0
- package/src/lib/backup/zip.ts +105 -0
- package/src/lib/blocksToHtml.ts +68 -0
- package/src/lib/brand.ts +26 -0
- package/src/lib/calendar/ics.ts +402 -0
- package/src/lib/calendar/render.ts +275 -0
- package/src/lib/calendar/src.ts +68 -0
- package/src/lib/content.ts +53 -0
- package/src/lib/dates.ts +34 -0
- package/src/lib/dynamicBlocks.ts +115 -0
- package/src/lib/env.ts +6 -0
- package/src/lib/highlightCode.ts +44 -0
- package/src/lib/i18n.ts +40 -0
- package/src/lib/markdown.ts +7 -0
- package/src/lib/media/store.ts +95 -0
- package/src/lib/net.ts +51 -0
- package/src/lib/preview.ts +28 -0
- package/src/lib/repo/d1.ts +44 -0
- package/src/lib/repo/index.ts +23 -0
- package/src/lib/repo/migrations.ts +231 -0
- package/src/lib/repo/search.ts +14 -0
- package/src/lib/repo/sql.ts +721 -0
- package/src/lib/repo/sqlite.ts +43 -0
- package/src/lib/repo/types.ts +132 -0
- package/src/lib/schema.ts +286 -0
- package/src/lib/seo.ts +37 -0
- package/src/lib/theme.ts +206 -0
- package/src/lib/widgets.ts +108 -0
- package/src/middleware.ts +109 -0
- package/src/pages/[...slug].astro +43 -0
- package/src/pages/admin/c/[collection]/[id].astro +56 -0
- package/src/pages/admin/c/[collection]/index.astro +163 -0
- package/src/pages/admin/design.astro +42 -0
- package/src/pages/admin/index.astro +145 -0
- package/src/pages/admin/links.astro +21 -0
- package/src/pages/admin/login.astro +83 -0
- package/src/pages/admin/logout.ts +7 -0
- package/src/pages/admin/media.astro +13 -0
- package/src/pages/admin/navigation.astro +17 -0
- package/src/pages/admin/pages/[id].astro +49 -0
- package/src/pages/admin/pages/index.astro +100 -0
- package/src/pages/admin/posts/[id].astro +6 -0
- package/src/pages/admin/posts/index.astro +7 -0
- package/src/pages/admin/settings.astro +512 -0
- package/src/pages/admin/tags.astro +101 -0
- package/src/pages/admin/users.astro +149 -0
- package/src/pages/api/backup.ts +63 -0
- package/src/pages/api/drafts.ts +106 -0
- package/src/pages/api/media/upload.ts +89 -0
- package/src/pages/api/restore.ts +102 -0
- package/src/pages/author/[slug].astro +66 -0
- package/src/pages/blog/[slug].astro +240 -0
- package/src/pages/blog/index.astro +51 -0
- package/src/pages/blog/tag/[slug].astro +83 -0
- package/src/pages/embed/[pageId]/[blockId].ts +45 -0
- package/src/pages/go/[id].ts +10 -0
- package/src/pages/index.astro +52 -0
- package/src/pages/links.astro +110 -0
- package/src/pages/llms.txt.ts +47 -0
- package/src/pages/media/[...key].ts +18 -0
- package/src/pages/preview/[type]/[id].astro +52 -0
- package/src/pages/robots.txt.ts +28 -0
- package/src/pages/rss.xml.ts +58 -0
- package/src/pages/search.astro +83 -0
- package/src/pages/sitemap.xml.ts +55 -0
- package/src/styles/admin.css +652 -0
- package/src/styles/builder.css +121 -0
- package/src/styles/cards.css +176 -0
- package/src/styles/editor.css +596 -0
- package/src/styles/site.css +427 -0
- package/src/themes/editorial/templates/home.astro +127 -0
- package/src/themes/editorial/templates/post-minimal.astro +50 -0
- package/src/themes/editorial/theme.css +108 -0
- package/src/themes/editorial/theme.json +20 -0
- package/src/themes/field/theme.json +17 -0
- package/src/themes/ink/theme.json +17 -0
- package/src/themes/night/theme.css +7 -0
- package/src/themes/night/theme.json +17 -0
- package/src/virtual.d.ts +25 -0
- package/src/worker.ts +28 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { defineAction, ActionError } from 'astro:actions';
|
|
2
|
+
import { z } from 'astro:schema';
|
|
3
|
+
import type { ActionAPIContext } from 'astro:actions';
|
|
4
|
+
import { getRepo } from '../lib/repo';
|
|
5
|
+
import { getMediaStore } from '../lib/media/store';
|
|
6
|
+
import { verifySessionToken, SESSION_COOKIE } from '../lib/auth';
|
|
7
|
+
import { parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
|
|
8
|
+
import { env } from '../lib/env';
|
|
9
|
+
import { CONTENT, collectionUrl } from '../lib/content';
|
|
10
|
+
import { invalidateRedirectCache } from '../middleware';
|
|
11
|
+
import { isPrivateHost } from '../lib/net';
|
|
12
|
+
import { normalizeCalendarSrc } from '../lib/calendar/src';
|
|
13
|
+
import { getCalendarEvents } from '../lib/calendar/ics';
|
|
14
|
+
import { siteLocale } from '../lib/dates';
|
|
15
|
+
import { normalizePageLocale } from '../lib/i18n';
|
|
16
|
+
|
|
17
|
+
// All admin mutations live here. The middleware only guards /admin pages,
|
|
18
|
+
// so every action re-checks the session itself.
|
|
19
|
+
function requireAdmin(ctx: ActionAPIContext) {
|
|
20
|
+
if (!verifySessionToken(ctx.cookies.get(SESSION_COOKIE)?.value)) {
|
|
21
|
+
throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function decodeEntities(s: string): string {
|
|
26
|
+
return s
|
|
27
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
28
|
+
.replace(/"/g, '"').replace(/�?39;|'/g, "'").replace(/'/gi, "'");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Schemas here use astro:schema's zod only — mixing in the project's own zod
|
|
32
|
+
// instance breaks the action client's type inference (it falls back to the
|
|
33
|
+
// FormData overload).
|
|
34
|
+
const nullableTs = z.number().nullable();
|
|
35
|
+
const StatusInput = z.enum(['draft', 'scheduled', 'published', 'archived']);
|
|
36
|
+
const BlockInput = z.object({
|
|
37
|
+
id: z.string(),
|
|
38
|
+
type: z.string(),
|
|
39
|
+
config: z.record(z.any()),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const server = {
|
|
43
|
+
posts: {
|
|
44
|
+
save: defineAction({
|
|
45
|
+
input: z.object({
|
|
46
|
+
id: z.string(),
|
|
47
|
+
title: z.string().optional(),
|
|
48
|
+
slug: z.string().optional(),
|
|
49
|
+
collection: z.string().optional(),
|
|
50
|
+
body: z.string().optional(),
|
|
51
|
+
bodyJson: z.string().optional(),
|
|
52
|
+
bodyHtml: z.string().optional(),
|
|
53
|
+
excerpt: z.string().optional(),
|
|
54
|
+
tags: z.array(z.string()).optional(),
|
|
55
|
+
cover: z.string().optional(),
|
|
56
|
+
coverAlt: z.string().optional(),
|
|
57
|
+
seoTitle: z.string().optional(),
|
|
58
|
+
seoDesc: z.string().optional(),
|
|
59
|
+
status: StatusInput.optional(),
|
|
60
|
+
featured: z.boolean().optional(),
|
|
61
|
+
authorId: z.string().nullable().optional(),
|
|
62
|
+
authorIds: z.array(z.string()).optional(),
|
|
63
|
+
template: z.string().optional(),
|
|
64
|
+
publishedAt: nullableTs.optional(),
|
|
65
|
+
scheduledAt: nullableTs.optional(),
|
|
66
|
+
}),
|
|
67
|
+
handler: async ({ id, ...data }, ctx) => {
|
|
68
|
+
requireAdmin(ctx);
|
|
69
|
+
const repo = await getRepo(ctx.locals);
|
|
70
|
+
if (data.slug !== undefined) data.slug = slugify(data.slug);
|
|
71
|
+
if (data.collection !== undefined && !CONTENT.collections[data.collection]) {
|
|
72
|
+
throw new ActionError({ code: 'BAD_REQUEST', message: `Unknown collection "${data.collection}".` });
|
|
73
|
+
}
|
|
74
|
+
const movesUrl = data.slug !== undefined || data.collection !== undefined;
|
|
75
|
+
const before = movesUrl ? await repo.getPost(id) : null;
|
|
76
|
+
await repo.updatePost(id, data);
|
|
77
|
+
// any new tag names become registry entries with slugs for archives
|
|
78
|
+
if (data.tags !== undefined) await repo.ensureTags(data.tags);
|
|
79
|
+
const post = await repo.getPost(id);
|
|
80
|
+
if (!post) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
|
|
81
|
+
// a published post whose slug or collection changed leaves a 301
|
|
82
|
+
// behind so old links keep working
|
|
83
|
+
if (before && before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
|
|
84
|
+
await repo
|
|
85
|
+
.createRedirect({
|
|
86
|
+
fromPath: collectionUrl(before.collection, before.slug),
|
|
87
|
+
toPath: collectionUrl(post.collection, post.slug),
|
|
88
|
+
})
|
|
89
|
+
.catch(() => {});
|
|
90
|
+
invalidateRedirectCache();
|
|
91
|
+
}
|
|
92
|
+
return { post };
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
delete: defineAction({
|
|
96
|
+
input: z.object({ id: z.string() }),
|
|
97
|
+
handler: async ({ id }, ctx) => {
|
|
98
|
+
requireAdmin(ctx);
|
|
99
|
+
const repo = await getRepo(ctx.locals);
|
|
100
|
+
await repo.deletePost(id);
|
|
101
|
+
return { ok: true };
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
pages: {
|
|
107
|
+
save: defineAction({
|
|
108
|
+
input: z.object({
|
|
109
|
+
id: z.string(),
|
|
110
|
+
title: z.string().optional(),
|
|
111
|
+
slug: z.string().optional(),
|
|
112
|
+
status: StatusInput.optional(),
|
|
113
|
+
isHome: z.boolean().optional(),
|
|
114
|
+
locale: z.string().optional(),
|
|
115
|
+
seoTitle: z.string().optional(),
|
|
116
|
+
seoDesc: z.string().optional(),
|
|
117
|
+
template: z.string().optional(),
|
|
118
|
+
bodyJson: z.string().optional(),
|
|
119
|
+
bodyHtml: z.string().optional(),
|
|
120
|
+
blocks: z.array(BlockInput).optional(),
|
|
121
|
+
}),
|
|
122
|
+
handler: async ({ id, blocks, ...data }, ctx) => {
|
|
123
|
+
requireAdmin(ctx);
|
|
124
|
+
const repo = await getRepo(ctx.locals);
|
|
125
|
+
if (data.slug !== undefined) data.slug = slugify(data.slug);
|
|
126
|
+
// default locale stores as '', unknown codes fall back to it
|
|
127
|
+
if (data.locale !== undefined) data.locale = normalizePageLocale(data.locale);
|
|
128
|
+
const before = data.slug !== undefined ? await repo.getPage(id) : null;
|
|
129
|
+
const cleanBlocks =
|
|
130
|
+
blocks === undefined
|
|
131
|
+
? undefined
|
|
132
|
+
: blocks
|
|
133
|
+
.filter((b) => widgetTypes.includes(b.type as WidgetType))
|
|
134
|
+
.map((b) => ({ ...b, type: b.type as WidgetType, config: parseConfig(b.type as WidgetType, b.config) as Record<string, unknown> }));
|
|
135
|
+
await repo.updatePage(id, { ...data, blocks: cleanBlocks });
|
|
136
|
+
const page = await repo.getPage(id);
|
|
137
|
+
if (!page) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
|
|
138
|
+
// renaming a published slug leaves a 301 behind so old links keep working
|
|
139
|
+
if (before && before.status === 'published' && before.slug !== page.slug && !page.isHome) {
|
|
140
|
+
await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
|
|
141
|
+
invalidateRedirectCache();
|
|
142
|
+
}
|
|
143
|
+
return { page };
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
delete: defineAction({
|
|
147
|
+
input: z.object({ id: z.string() }),
|
|
148
|
+
handler: async ({ id }, ctx) => {
|
|
149
|
+
requireAdmin(ctx);
|
|
150
|
+
const repo = await getRepo(ctx.locals);
|
|
151
|
+
await repo.deletePage(id);
|
|
152
|
+
return { ok: true };
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
media: {
|
|
158
|
+
list: defineAction({
|
|
159
|
+
input: z.object({}),
|
|
160
|
+
handler: async (_input, ctx) => {
|
|
161
|
+
requireAdmin(ctx);
|
|
162
|
+
const repo = await getRepo(ctx.locals);
|
|
163
|
+
return { media: await repo.listMedia() };
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
updateAlt: defineAction({
|
|
167
|
+
input: z.object({ id: z.string(), alt: z.string() }),
|
|
168
|
+
handler: async ({ id, alt }, ctx) => {
|
|
169
|
+
requireAdmin(ctx);
|
|
170
|
+
const repo = await getRepo(ctx.locals);
|
|
171
|
+
await repo.updateMedia(id, { alt });
|
|
172
|
+
return { ok: true };
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
delete: defineAction({
|
|
176
|
+
input: z.object({ id: z.string() }),
|
|
177
|
+
handler: async ({ id }, ctx) => {
|
|
178
|
+
requireAdmin(ctx);
|
|
179
|
+
const repo = await getRepo(ctx.locals);
|
|
180
|
+
const media = await repo.getMedia(id);
|
|
181
|
+
if (media) {
|
|
182
|
+
const store = getMediaStore(ctx.locals);
|
|
183
|
+
await Promise.all([media.key, ...media.variants.map((v) => v.key)].map((k) => store.delete(k)));
|
|
184
|
+
await repo.deleteMedia(id);
|
|
185
|
+
}
|
|
186
|
+
return { ok: true };
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
links: {
|
|
192
|
+
create: defineAction({
|
|
193
|
+
input: z.object({
|
|
194
|
+
kind: z.enum(['link', 'email', 'phone', 'heading', 'text', 'spacer', 'vcard', 'html']).default('link'),
|
|
195
|
+
label: z.string().default(''),
|
|
196
|
+
url: z.string().default(''),
|
|
197
|
+
icon: z.string().default(''),
|
|
198
|
+
html: z.string().default(''),
|
|
199
|
+
meta: z.record(z.any()).default({}),
|
|
200
|
+
}),
|
|
201
|
+
handler: async (data, ctx) => {
|
|
202
|
+
requireAdmin(ctx);
|
|
203
|
+
const repo = await getRepo(ctx.locals);
|
|
204
|
+
return { link: await repo.createLink(data) };
|
|
205
|
+
},
|
|
206
|
+
}),
|
|
207
|
+
update: defineAction({
|
|
208
|
+
input: z.object({
|
|
209
|
+
id: z.string(),
|
|
210
|
+
label: z.string().optional(),
|
|
211
|
+
url: z.string().optional(),
|
|
212
|
+
icon: z.string().optional(),
|
|
213
|
+
html: z.string().optional(),
|
|
214
|
+
meta: z.record(z.any()).optional(),
|
|
215
|
+
}),
|
|
216
|
+
handler: async ({ id, ...data }, ctx) => {
|
|
217
|
+
requireAdmin(ctx);
|
|
218
|
+
const repo = await getRepo(ctx.locals);
|
|
219
|
+
await repo.updateLink(id, data);
|
|
220
|
+
return { ok: true };
|
|
221
|
+
},
|
|
222
|
+
}),
|
|
223
|
+
reorder: defineAction({
|
|
224
|
+
input: z.object({ ids: z.array(z.string()) }),
|
|
225
|
+
handler: async ({ ids }, ctx) => {
|
|
226
|
+
requireAdmin(ctx);
|
|
227
|
+
const repo = await getRepo(ctx.locals);
|
|
228
|
+
await repo.reorderLinks(ids);
|
|
229
|
+
return { ok: true };
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
delete: defineAction({
|
|
233
|
+
input: z.object({ id: z.string() }),
|
|
234
|
+
handler: async ({ id }, ctx) => {
|
|
235
|
+
requireAdmin(ctx);
|
|
236
|
+
const repo = await getRepo(ctx.locals);
|
|
237
|
+
await repo.deleteLink(id);
|
|
238
|
+
return { ok: true };
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
nav: {
|
|
244
|
+
create: defineAction({
|
|
245
|
+
input: z.object({
|
|
246
|
+
label: z.string().default('New link'),
|
|
247
|
+
url: z.string().default('/'),
|
|
248
|
+
area: z.enum(['header', 'footer', 'social']).default('header'),
|
|
249
|
+
}),
|
|
250
|
+
handler: async (data, ctx) => {
|
|
251
|
+
requireAdmin(ctx);
|
|
252
|
+
const repo = await getRepo(ctx.locals);
|
|
253
|
+
return { item: await repo.createNavItem(data) };
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
update: defineAction({
|
|
257
|
+
input: z.object({
|
|
258
|
+
id: z.string(),
|
|
259
|
+
label: z.string().optional(),
|
|
260
|
+
url: z.string().optional(),
|
|
261
|
+
area: z.enum(['header', 'footer', 'social']).optional(),
|
|
262
|
+
}),
|
|
263
|
+
handler: async ({ id, ...data }, ctx) => {
|
|
264
|
+
requireAdmin(ctx);
|
|
265
|
+
const repo = await getRepo(ctx.locals);
|
|
266
|
+
await repo.updateNavItem(id, data);
|
|
267
|
+
return { ok: true };
|
|
268
|
+
},
|
|
269
|
+
}),
|
|
270
|
+
reorder: defineAction({
|
|
271
|
+
input: z.object({ ids: z.array(z.string()) }),
|
|
272
|
+
handler: async ({ ids }, ctx) => {
|
|
273
|
+
requireAdmin(ctx);
|
|
274
|
+
const repo = await getRepo(ctx.locals);
|
|
275
|
+
await repo.reorderNavItems(ids);
|
|
276
|
+
return { ok: true };
|
|
277
|
+
},
|
|
278
|
+
}),
|
|
279
|
+
delete: defineAction({
|
|
280
|
+
input: z.object({ id: z.string() }),
|
|
281
|
+
handler: async ({ id }, ctx) => {
|
|
282
|
+
requireAdmin(ctx);
|
|
283
|
+
const repo = await getRepo(ctx.locals);
|
|
284
|
+
await repo.deleteNavItem(id);
|
|
285
|
+
return { ok: true };
|
|
286
|
+
},
|
|
287
|
+
}),
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
settings: {
|
|
291
|
+
update: defineAction({
|
|
292
|
+
input: z.object({
|
|
293
|
+
siteTitle: z.string().optional(),
|
|
294
|
+
tagline: z.string().optional(),
|
|
295
|
+
footerText: z.string().optional(),
|
|
296
|
+
linksName: z.string().optional(),
|
|
297
|
+
linksBio: z.string().optional(),
|
|
298
|
+
linksAvatar: z.string().optional(),
|
|
299
|
+
activeTheme: z.string().optional(),
|
|
300
|
+
designOverrides: z.record(z.any()).optional(),
|
|
301
|
+
siteLogo: z.string().optional(),
|
|
302
|
+
siteFavicon: z.string().optional(),
|
|
303
|
+
siteCover: z.string().optional(),
|
|
304
|
+
}),
|
|
305
|
+
handler: async (data, ctx) => {
|
|
306
|
+
requireAdmin(ctx);
|
|
307
|
+
const repo = await getRepo(ctx.locals);
|
|
308
|
+
await repo.updateSettings(data);
|
|
309
|
+
return { ok: true };
|
|
310
|
+
},
|
|
311
|
+
}),
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
tags: {
|
|
315
|
+
list: defineAction({
|
|
316
|
+
input: z.object({}),
|
|
317
|
+
handler: async (_input, ctx) => {
|
|
318
|
+
requireAdmin(ctx);
|
|
319
|
+
const repo = await getRepo(ctx.locals);
|
|
320
|
+
return { tags: await repo.listTags() };
|
|
321
|
+
},
|
|
322
|
+
}),
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
users: {
|
|
326
|
+
list: defineAction({
|
|
327
|
+
input: z.object({}),
|
|
328
|
+
handler: async (_input, ctx) => {
|
|
329
|
+
requireAdmin(ctx);
|
|
330
|
+
const repo = await getRepo(ctx.locals);
|
|
331
|
+
return { users: await repo.listUsers() };
|
|
332
|
+
},
|
|
333
|
+
}),
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
bookmark: {
|
|
337
|
+
// Server-side link-metadata fetch for the bookmark card (no CORS in the
|
|
338
|
+
// browser). Fetches the page, scrapes OG/meta tags, returns a compact
|
|
339
|
+
// shape. Guards: https/http only, no private hosts, 1.5 MB / 8 s caps.
|
|
340
|
+
fetch: defineAction({
|
|
341
|
+
input: z.object({ url: z.string().url() }),
|
|
342
|
+
handler: async ({ url }, ctx) => {
|
|
343
|
+
requireAdmin(ctx);
|
|
344
|
+
// redirects are followed manually so EVERY hop is re-checked against
|
|
345
|
+
// the private-host guard (a public URL 302ing to 127.0.0.1 or a cloud
|
|
346
|
+
// metadata IP must not slip through)
|
|
347
|
+
let current = url;
|
|
348
|
+
let res: Response | null = null;
|
|
349
|
+
for (let hop = 0; hop < 5; hop++) {
|
|
350
|
+
const u = new URL(current);
|
|
351
|
+
if (!/^https?:$/.test(u.protocol) || isPrivateHost(u.hostname)) {
|
|
352
|
+
throw new ActionError({ code: 'BAD_REQUEST', message: 'That URL can’t be bookmarked.' });
|
|
353
|
+
}
|
|
354
|
+
res = await fetch(u.href, {
|
|
355
|
+
redirect: 'manual',
|
|
356
|
+
signal: AbortSignal.timeout(8000),
|
|
357
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; acms-bookmark/1.0)', Accept: 'text/html' },
|
|
358
|
+
}).catch(() => null);
|
|
359
|
+
if (res && res.status >= 300 && res.status < 400) {
|
|
360
|
+
const loc = res.headers.get('location');
|
|
361
|
+
if (!loc) break;
|
|
362
|
+
current = new URL(loc, u.href).href;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
if (!res || !res.ok) throw new ActionError({ code: 'BAD_GATEWAY', message: 'Couldn’t reach that page.' });
|
|
368
|
+
const finalUrl = current;
|
|
369
|
+
const html = (await res.text()).slice(0, 1_500_000);
|
|
370
|
+
|
|
371
|
+
const tag = (patterns: RegExp[]) => {
|
|
372
|
+
for (const p of patterns) {
|
|
373
|
+
const m = html.match(p);
|
|
374
|
+
if (m?.[1]) return decodeEntities(m[1].trim());
|
|
375
|
+
}
|
|
376
|
+
return '';
|
|
377
|
+
};
|
|
378
|
+
const metaContent = (name: string) => [
|
|
379
|
+
new RegExp(`<meta[^>]+(?:property|name)=["']${name}["'][^>]+content=["']([^"']+)["']`, 'i'),
|
|
380
|
+
new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${name}["']`, 'i'),
|
|
381
|
+
];
|
|
382
|
+
const abs = (u: string) => {
|
|
383
|
+
if (!u) return '';
|
|
384
|
+
try { return new URL(u, finalUrl).href; } catch { return ''; }
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const title = tag(metaContent('og:title')) || tag(metaContent('twitter:title')) || tag([/<title[^>]*>([^<]+)<\/title>/i]);
|
|
388
|
+
const description = tag(metaContent('og:description')) || tag(metaContent('twitter:description')) || tag(metaContent('description'));
|
|
389
|
+
const image = abs(tag(metaContent('og:image')) || tag(metaContent('twitter:image')));
|
|
390
|
+
const siteName = tag(metaContent('og:site_name')) || new URL(finalUrl).hostname;
|
|
391
|
+
const favicon = abs(
|
|
392
|
+
tag([
|
|
393
|
+
/<link[^>]+rel=["'](?:shortcut )?icon["'][^>]+href=["']([^"']+)["']/i,
|
|
394
|
+
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["'](?:shortcut )?icon["']/i,
|
|
395
|
+
]) || '/favicon.ico'
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
return { meta: { title, description, image, favicon, siteName } };
|
|
399
|
+
},
|
|
400
|
+
}),
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
calendar: {
|
|
404
|
+
// Editor-side check for the calendar card: normalize whatever the author
|
|
405
|
+
// pasted, fetch the feed(s), and return the next few events so the card
|
|
406
|
+
// can show a live preview (and reject dead URLs at paste time). The
|
|
407
|
+
// public site never calls this — it renders via expandDynamicBlocks.
|
|
408
|
+
preview: defineAction({
|
|
409
|
+
input: z.object({ sources: z.array(z.string()).min(1).max(6) }),
|
|
410
|
+
handler: async ({ sources }, ctx) => {
|
|
411
|
+
requireAdmin(ctx);
|
|
412
|
+
const normalized: string[] = [];
|
|
413
|
+
for (const raw of sources) {
|
|
414
|
+
const url = normalizeCalendarSrc(raw);
|
|
415
|
+
if (!url) {
|
|
416
|
+
throw new ActionError({
|
|
417
|
+
code: 'BAD_REQUEST',
|
|
418
|
+
message: 'Paste a Google Calendar link, a calendar ID, or an .ics feed URL.',
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
normalized.push(url);
|
|
422
|
+
}
|
|
423
|
+
const repo = await getRepo(ctx.locals);
|
|
424
|
+
const settings = await repo.getSettings();
|
|
425
|
+
const tz = settings.timezone || 'UTC';
|
|
426
|
+
const now = Date.now();
|
|
427
|
+
const { events, errors } = await getCalendarEvents(normalized, {
|
|
428
|
+
displayTz: tz, now,
|
|
429
|
+
windowStartMs: now - 86_400_000,
|
|
430
|
+
windowEndMs: now + 120 * 86_400_000,
|
|
431
|
+
});
|
|
432
|
+
if (!events.length && errors.length) throw new ActionError({ code: 'BAD_GATEWAY', message: errors[0] });
|
|
433
|
+
const locale = siteLocale(settings);
|
|
434
|
+
const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
|
|
435
|
+
const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
436
|
+
const upcoming = events
|
|
437
|
+
.filter((e) => e.end > now)
|
|
438
|
+
.slice(0, 5)
|
|
439
|
+
.map((e) => ({
|
|
440
|
+
when: dayFmt.format(e.start) + (e.allDay ? '' : ` · ${timeFmt.format(e.start)}`),
|
|
441
|
+
title: e.title,
|
|
442
|
+
cal: e.cal,
|
|
443
|
+
}));
|
|
444
|
+
return { sources: normalized, upcoming, total: events.length };
|
|
445
|
+
},
|
|
446
|
+
}),
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
unsplash: {
|
|
450
|
+
search: defineAction({
|
|
451
|
+
input: z.object({ query: z.string().min(1), page: z.number().int().min(1).default(1) }),
|
|
452
|
+
handler: async ({ query, page }, ctx) => {
|
|
453
|
+
requireAdmin(ctx);
|
|
454
|
+
const key = env('UNSPLASH_ACCESS_KEY');
|
|
455
|
+
if (!key) throw new ActionError({ code: 'PRECONDITION_FAILED', message: 'Unsplash is not configured.' });
|
|
456
|
+
const res = await fetch(
|
|
457
|
+
`https://api.unsplash.com/search/photos?query=${encodeURIComponent(query)}&page=${page}&per_page=24`,
|
|
458
|
+
{ headers: { Authorization: `Client-ID ${key}` } }
|
|
459
|
+
);
|
|
460
|
+
if (!res.ok) throw new ActionError({ code: 'BAD_GATEWAY', message: `Unsplash error ${res.status}` });
|
|
461
|
+
const data = (await res.json()) as {
|
|
462
|
+
total_pages: number;
|
|
463
|
+
results: {
|
|
464
|
+
id: string;
|
|
465
|
+
description: string | null;
|
|
466
|
+
alt_description: string | null;
|
|
467
|
+
width: number;
|
|
468
|
+
height: number;
|
|
469
|
+
urls: { regular: string; small: string; full: string };
|
|
470
|
+
links: { download_location: string };
|
|
471
|
+
user: { name: string; links: { html: string } };
|
|
472
|
+
}[];
|
|
473
|
+
};
|
|
474
|
+
return {
|
|
475
|
+
totalPages: data.total_pages,
|
|
476
|
+
photos: data.results.map((p) => ({
|
|
477
|
+
id: p.id,
|
|
478
|
+
alt: p.alt_description ?? p.description ?? '',
|
|
479
|
+
width: p.width,
|
|
480
|
+
height: p.height,
|
|
481
|
+
thumb: p.urls.small,
|
|
482
|
+
regular: p.urls.regular,
|
|
483
|
+
downloadLocation: p.links.download_location,
|
|
484
|
+
authorName: p.user.name,
|
|
485
|
+
authorUrl: p.user.links.html,
|
|
486
|
+
})),
|
|
487
|
+
};
|
|
488
|
+
},
|
|
489
|
+
}),
|
|
490
|
+
// Unsplash API guideline: trigger the download endpoint when a photo is used.
|
|
491
|
+
track: defineAction({
|
|
492
|
+
input: z.object({ downloadLocation: z.string().url() }),
|
|
493
|
+
handler: async ({ downloadLocation }, ctx) => {
|
|
494
|
+
requireAdmin(ctx);
|
|
495
|
+
const key = env('UNSPLASH_ACCESS_KEY');
|
|
496
|
+
if (!key || !downloadLocation.startsWith('https://api.unsplash.com/')) return { ok: false };
|
|
497
|
+
await fetch(downloadLocation, { headers: { Authorization: `Client-ID ${key}` } }).catch(() => {});
|
|
498
|
+
return { ok: true };
|
|
499
|
+
},
|
|
500
|
+
}),
|
|
501
|
+
},
|
|
502
|
+
};
|