@valentinkolb/cloud 0.1.0

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.
Files changed (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
@@ -0,0 +1,294 @@
1
+ import { sql } from "bun";
2
+ import type { PaginationParams } from "../../contracts/shared";
3
+ import { registerSettings, registerGroupLabel } from "../settings/defaults";
4
+ import { escapeLikePattern, parsePgJsonRecord, toPgTextArray } from "../postgres";
5
+
6
+ // ── Settings Registration ──────────────────────────────────────────────
7
+
8
+ registerGroupLabel("logs", "Logging");
9
+ registerSettings([
10
+ {
11
+ key: "logs.retention_days",
12
+ kind: "number",
13
+ default: 30,
14
+ description: "Automatically delete log entries older than this many days",
15
+ group: "logs",
16
+ },
17
+ ]);
18
+
19
+ // ==========================
20
+ // Types
21
+ // ==========================
22
+
23
+ type LogLevel = "debug" | "info" | "warn" | "error";
24
+
25
+ type WriteParams = {
26
+ level: LogLevel;
27
+ source: string;
28
+ message: string;
29
+ metadata?: Record<string, unknown>;
30
+ };
31
+
32
+ type Logger = {
33
+ debug: (message: string, metadata?: Record<string, unknown>) => void;
34
+ info: (message: string, metadata?: Record<string, unknown>) => void;
35
+ warn: (message: string, metadata?: Record<string, unknown>) => void;
36
+ error: (message: string, metadata?: Record<string, unknown>) => void;
37
+ };
38
+
39
+ export type LogEntry = {
40
+ id: number;
41
+ level: LogLevel;
42
+ source: string;
43
+ message: string;
44
+ metadata: Record<string, unknown> | null;
45
+ createdAt: string;
46
+ };
47
+
48
+ type DbLogRow = {
49
+ id: number;
50
+ level: string;
51
+ source: string;
52
+ message: string;
53
+ metadata: Record<string, unknown> | null;
54
+ created_at: string;
55
+ };
56
+
57
+ // ==========================
58
+ // Helpers
59
+ // ==========================
60
+
61
+ /**
62
+ * Maps one logging row to the public log entry shape.
63
+ */
64
+ const mapRow = (row: DbLogRow): LogEntry => ({
65
+ id: row.id,
66
+ level: row.level as LogLevel,
67
+ source: row.source,
68
+ message: row.message,
69
+ metadata: parsePgJsonRecord(row.metadata),
70
+ createdAt: row.created_at,
71
+ });
72
+
73
+ // ==========================
74
+ // Core: Write + Logger
75
+ // ==========================
76
+
77
+ /**
78
+ * Keys whose values are scrubbed from log metadata before console + DB write.
79
+ * Defense-in-depth: future code that accidentally passes a token/password/
80
+ * cookie into `logger.info(...)` won't leak it to the persistent log.
81
+ *
82
+ * Match is case-insensitive and substring-based on the key (e.g. `apiKey`,
83
+ * `accessToken`, `clientSecret` all trip).
84
+ */
85
+ const SENSITIVE_KEY_PATTERN = /(password|secret|token|cookie|authorization|api[_-]?key|private[_-]?key|session)/i;
86
+ const REDACTED = "[REDACTED]";
87
+
88
+ const redactMetadata = (input: unknown): unknown => {
89
+ if (input === null || typeof input !== "object") return input;
90
+ if (Array.isArray(input)) return input.map(redactMetadata);
91
+ const out: Record<string, unknown> = {};
92
+ for (const [key, value] of Object.entries(input)) {
93
+ out[key] = SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : redactMetadata(value);
94
+ }
95
+ return out;
96
+ };
97
+
98
+ /** Fire-and-forget write — mirrors to console, then inserts to DB async. */
99
+ function write(params: WriteParams): void {
100
+ const safeMetadata = params.metadata ? (redactMetadata(params.metadata) as Record<string, unknown>) : undefined;
101
+ const prefix = `[${params.source}]`;
102
+ const consoleFn = params.level === "error" ? console.error : params.level === "warn" ? console.warn : console.log;
103
+ consoleFn(prefix, params.message, ...(safeMetadata ? [safeMetadata] : []));
104
+
105
+ sql`
106
+ INSERT INTO logging.entries (level, source, message, metadata)
107
+ VALUES (
108
+ ${params.level},
109
+ ${params.source},
110
+ ${params.message},
111
+ ${safeMetadata ? JSON.stringify(safeMetadata) : null}::jsonb
112
+ )
113
+ `.catch((err: Error) => console.error("[logging] DB write failed:", err.message));
114
+ }
115
+
116
+ /**
117
+ * Stateless logger factory. Creates an object with .debug/.info/.warn/.error methods
118
+ * bound to the given source. Can be called inline or cached — no state is held.
119
+ *
120
+ * @example
121
+ * // Inline
122
+ * logger("yjs").info("Document loaded", { noteId });
123
+ *
124
+ * // Cached
125
+ * const log = logger("weather");
126
+ * log.error("API error", { status: 500 });
127
+ */
128
+ export function logger(source: string): Logger {
129
+ return {
130
+ debug: (msg, meta) => write({ level: "debug", source, message: msg, metadata: meta }),
131
+ info: (msg, meta) => write({ level: "info", source, message: msg, metadata: meta }),
132
+ warn: (msg, meta) => write({ level: "warn", source, message: msg, metadata: meta }),
133
+ error: (msg, meta) => write({ level: "error", source, message: msg, metadata: meta }),
134
+ };
135
+ }
136
+
137
+ // ==========================
138
+ // Admin: List / Sources / Cleanup
139
+ // ==========================
140
+
141
+ /** List log entries with pagination and optional filters. */
142
+ const list = async (
143
+ pagination: PaginationParams,
144
+ options?: {
145
+ source?: string;
146
+ sources?: string[];
147
+ level?: string;
148
+ search?: string;
149
+ },
150
+ ): Promise<{ entries: LogEntry[]; total: number }> => {
151
+ const { offset, perPage } = pagination;
152
+ const { source, sources, level, search } = options ?? {};
153
+
154
+ const searchPattern = search ? `%${escapeLikePattern(search)}%` : null;
155
+ const filterSource = source && source !== "all" ? source : null;
156
+ const filterSources =
157
+ !filterSource && Array.isArray(sources) && sources.length > 0 ? [...new Set(sources.map((value) => value.trim()).filter(Boolean))] : null;
158
+ const filterSourcesLiteral = filterSources ? toPgTextArray(filterSources) : null;
159
+ const filterLevel = level && level !== "all" ? level : null;
160
+ const hasSourceList = !!filterSources && filterSources.length > 0;
161
+
162
+ let countRows: Array<{ count: number }> = [];
163
+ let dataRows: DbLogRow[] = [];
164
+
165
+ if (filterSource) {
166
+ countRows = await sql`
167
+ SELECT COUNT(*)::int as count
168
+ FROM logging.entries
169
+ WHERE source = ${filterSource}
170
+ AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
171
+ AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\')
172
+ `;
173
+
174
+ dataRows = await sql`
175
+ SELECT id, level, source, message, metadata, created_at
176
+ FROM logging.entries
177
+ WHERE source = ${filterSource}
178
+ AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
179
+ AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\')
180
+ ORDER BY created_at DESC
181
+ LIMIT ${perPage} OFFSET ${offset}
182
+ `;
183
+ } else if (hasSourceList) {
184
+ countRows = await sql`
185
+ SELECT COUNT(*)::int as count
186
+ FROM logging.entries
187
+ WHERE source = ANY(${filterSourcesLiteral}::text[])
188
+ AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
189
+ AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\')
190
+ `;
191
+
192
+ dataRows = await sql`
193
+ SELECT id, level, source, message, metadata, created_at
194
+ FROM logging.entries
195
+ WHERE source = ANY(${filterSourcesLiteral}::text[])
196
+ AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
197
+ AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\')
198
+ ORDER BY created_at DESC
199
+ LIMIT ${perPage} OFFSET ${offset}
200
+ `;
201
+ } else {
202
+ countRows = await sql`
203
+ SELECT COUNT(*)::int as count
204
+ FROM logging.entries
205
+ WHERE (${filterLevel}::text IS NULL OR level = ${filterLevel})
206
+ AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\')
207
+ `;
208
+
209
+ dataRows = await sql`
210
+ SELECT id, level, source, message, metadata, created_at
211
+ FROM logging.entries
212
+ WHERE (${filterLevel}::text IS NULL OR level = ${filterLevel})
213
+ AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\')
214
+ ORDER BY created_at DESC
215
+ LIMIT ${perPage} OFFSET ${offset}
216
+ `;
217
+ }
218
+
219
+ return {
220
+ entries: dataRows.map((row: DbLogRow) => mapRow(row)),
221
+ total: countRows[0]?.count ?? 0,
222
+ };
223
+ };
224
+
225
+ /** Get all unique source names (for filter dropdown). */
226
+ const getSources = async (): Promise<string[]> => {
227
+ const rows = await sql`
228
+ SELECT DISTINCT source FROM logging.entries ORDER BY source
229
+ `;
230
+ return rows.map((row: { source: string }) => row.source);
231
+ };
232
+
233
+ /** Delete log entries older than the given number of days. */
234
+ const cleanup = async (olderThanDays: number): Promise<{ deleted: number }> => {
235
+ const rows = await sql`
236
+ DELETE FROM logging.entries
237
+ WHERE created_at < now() - ${olderThanDays + " days"}::interval
238
+ `;
239
+ return { deleted: rows.count };
240
+ };
241
+
242
+ export type LogSummary = {
243
+ total: number;
244
+ errors24h: number;
245
+ warnings24h: number;
246
+ total24h: number;
247
+ sources: number;
248
+ lastErrorAt: string | null;
249
+ };
250
+
251
+ /**
252
+ * Aggregated stats for the admin dashboard. Single SQL roundtrip, but split
253
+ * into independent subqueries so each one can use `idx_logging_entries_level`
254
+ * and `idx_logging_level_created_at` (composite, see migration). The original
255
+ * `COUNT(*) FILTER (...)` form forced a full-table scan because there was no
256
+ * top-level `WHERE`.
257
+ *
258
+ * `last_error_at::text` because `bun.sql` returns timestamps as `Date` and the
259
+ * `LogSummary.lastErrorAt: string | null` contract expects an ISO-ish string.
260
+ */
261
+ const summary = async (): Promise<LogSummary> => {
262
+ const [row] = await sql<{
263
+ total: number;
264
+ total_24h: number;
265
+ errors_24h: number;
266
+ warnings_24h: number;
267
+ sources: number;
268
+ last_error_at: string | null;
269
+ }[]>`
270
+ SELECT
271
+ (SELECT COUNT(*)::int FROM logging.entries) AS total,
272
+ (SELECT COUNT(*)::int FROM logging.entries WHERE created_at > now() - interval '24 hours') AS total_24h,
273
+ (SELECT COUNT(*)::int FROM logging.entries WHERE level = 'error' AND created_at > now() - interval '24 hours') AS errors_24h,
274
+ (SELECT COUNT(*)::int FROM logging.entries WHERE level = 'warn' AND created_at > now() - interval '24 hours') AS warnings_24h,
275
+ (SELECT COUNT(*)::int FROM (SELECT DISTINCT source FROM logging.entries) s) AS sources,
276
+ (SELECT created_at::text FROM logging.entries WHERE level = 'error' ORDER BY created_at DESC LIMIT 1) AS last_error_at
277
+ `;
278
+ return {
279
+ total: row?.total ?? 0,
280
+ errors24h: row?.errors_24h ?? 0,
281
+ warnings24h: row?.warnings_24h ?? 0,
282
+ total24h: row?.total_24h ?? 0,
283
+ sources: row?.sources ?? 0,
284
+ lastErrorAt: row?.last_error_at ?? null,
285
+ };
286
+ };
287
+
288
+ /** Admin service object for querying/managing logs. */
289
+ export const logging = {
290
+ list,
291
+ getSources,
292
+ cleanup,
293
+ summary,
294
+ };
@@ -0,0 +1,123 @@
1
+ import { createTransport, type Transporter } from "nodemailer";
2
+ import sanitizeHtml from "sanitize-html";
3
+ import * as settings from "../settings";
4
+ import { coreSettings } from "../settings/api";
5
+
6
+ /** Lazily-created transporter — uses current settings on each send. */
7
+ const getTransporter = async (): Promise<Transporter> => {
8
+ const host = await settings.get<string>("mail.noreply.smtp_host");
9
+ const port = await settings.get<number>("mail.noreply.smtp_port");
10
+ const user = await settings.get<string>("mail.noreply.user");
11
+ const pass = await settings.get<string>("mail.noreply.password");
12
+
13
+ return createTransport({
14
+ host,
15
+ port,
16
+ secure: port === 465,
17
+ auth: { user, pass },
18
+ });
19
+ };
20
+
21
+ /** Sanitize plain text content (no HTML allowed). */
22
+ const sanitizeContent = (content: string): string => sanitizeHtml(content, { allowedTags: [], allowedAttributes: {} });
23
+
24
+ /** Sanitize rich HTML content (links, basic formatting allowed). */
25
+ const sanitizeRawHtml = (html: string): string =>
26
+ sanitizeHtml(html, {
27
+ allowedTags: ["a", "strong", "em", "p", "br", "span", "code"],
28
+ allowedAttributes: {
29
+ a: ["href", "target", "style"],
30
+ span: ["style"],
31
+ p: ["style"],
32
+ code: ["style"],
33
+ },
34
+ allowedSchemes: ["http", "https", "mailto"],
35
+ });
36
+
37
+ /** Send an email with the standard HTML template. */
38
+ export const sendEmail = async (to: string, subject: string, opts: { content?: string; rawHtml?: string }): Promise<void> => {
39
+ const rawAppUrl = await settings.get<string>("app.url");
40
+ const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
41
+ const appName = await settings.get<string>("app.name");
42
+ const emailFrom = await settings.get<string>("mail.noreply.from");
43
+
44
+ let body = "";
45
+ if (opts.rawHtml) {
46
+ body = sanitizeRawHtml(opts.rawHtml);
47
+ } else if (opts.content) {
48
+ body = `<p>${sanitizeContent(opts.content)}</p>`;
49
+ }
50
+
51
+ const html = await buildHtml(appUrl, appName, body);
52
+ const transporter = await getTransporter();
53
+
54
+ await transporter.sendMail({
55
+ from: `"${appName}" <${emailFrom}>`,
56
+ to,
57
+ subject,
58
+ html,
59
+ });
60
+ };
61
+
62
+ /**
63
+ * Builds the default HTML mail template wrapper when no raw HTML was provided.
64
+ */
65
+ const buildHtml = async (appUrl: string, appName: string, content: string) => {
66
+ const logoUri = await coreSettings.get<string>("app.logo");
67
+ return `
68
+ <!DOCTYPE html>
69
+ <html>
70
+ <head>
71
+ <meta charset="utf-8">
72
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
73
+ </head>
74
+ <body style="margin:0;padding:0;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
75
+ <table width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px;">
76
+ <tr><td align="center">
77
+ <table width="100%" style="max-width:520px;" cellpadding="0" cellspacing="0">
78
+
79
+ <!-- Header -->
80
+ <tr><td style="background:#ffffff;padding:20px 24px;border-radius:12px 12px 0 0;border:1px solid #e4e4e7;border-bottom:none;">
81
+ <table cellpadding="0" cellspacing="0"><tr>
82
+ ${
83
+ logoUri
84
+ ? `<td style="padding-right:12px;vertical-align:middle;">
85
+ <img src="${logoUri}" alt="Logo" width="28" height="28" style="display:block;">
86
+ </td>`
87
+ : ""
88
+ }
89
+ <td style="vertical-align:middle;">
90
+ <span style="font-size:16px;font-weight:600;color:#18181b;">${appName}</span>
91
+ </td>
92
+ </tr></table>
93
+ </td></tr>
94
+
95
+ <!-- Content -->
96
+ <tr><td style="background:#ffffff;padding:28px 24px;border-left:1px solid #e4e4e7;border-right:1px solid #e4e4e7;">
97
+ <div style="font-size:14px;line-height:1.6;color:#27272a;">
98
+ ${content}
99
+ </div>
100
+ </td></tr>
101
+
102
+ <!-- Footer -->
103
+ <tr><td style="background:#fafafa;padding:16px 24px;border-radius:0 0 12px 12px;border:1px solid #e4e4e7;border-top:none;">
104
+ <p style="margin:0 0 8px;font-size:11px;color:#71717a;text-align:center;">
105
+ <a href="${appUrl}/impressum" style="color:#71717a;text-decoration:underline;">Imprint</a>
106
+ &nbsp;&middot;&nbsp;
107
+ <a href="${appUrl}/legal/terms" style="color:#71717a;text-decoration:underline;">Terms</a>
108
+ &nbsp;&middot;&nbsp;
109
+ <a href="${appUrl}/legal/privacy" style="color:#71717a;text-decoration:underline;">Privacy</a>
110
+ </p>
111
+ <p style="margin:0;font-size:11px;color:#a1a1aa;text-align:center;">
112
+ This message was sent automatically. Please do not reply to this email.
113
+ </p>
114
+ </td></tr>
115
+
116
+ </table>
117
+ </td></tr>
118
+ </table>
119
+ </body>
120
+ <!-- the answer is 42 ;D -->
121
+ </html>
122
+ `;
123
+ };