@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.
- package/package.json +69 -0
- package/public/logo.svg +1 -0
- package/scripts/build.ts +113 -0
- package/scripts/preload.ts +73 -0
- package/src/_internal/define-app.ts +399 -0
- package/src/_internal/heartbeat.ts +33 -0
- package/src/_internal/registry.ts +100 -0
- package/src/_internal/runtime-context.ts +38 -0
- package/src/api/accounts-entities.ts +134 -0
- package/src/api/admin-lifecycle.ts +210 -0
- package/src/api/auth/schemas.ts +28 -0
- package/src/api/auth.ts +230 -0
- package/src/api/index.ts +66 -0
- package/src/api/me.ts +206 -0
- package/src/api/search/schemas.ts +43 -0
- package/src/api/search.ts +130 -0
- package/src/clients/core.ts +19 -0
- package/src/config/env.ts +23 -0
- package/src/config/index.ts +6 -0
- package/src/config/ssr.ts +58 -0
- package/src/contracts/app.ts +140 -0
- package/src/contracts/index.ts +5 -0
- package/src/contracts/profile.ts +67 -0
- package/src/contracts/registry.ts +50 -0
- package/src/contracts/settings-types.ts +84 -0
- package/src/contracts/shared.ts +258 -0
- package/src/contracts/widgets.ts +121 -0
- package/src/index.ts +6 -0
- package/src/server/api/index.ts +1 -0
- package/src/server/api/respond.ts +55 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/index.ts +62 -0
- package/src/server/middleware/auth.ts +168 -0
- package/src/server/middleware/index.ts +7 -0
- package/src/server/middleware/middleware.ts +47 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +126 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/validator.ts +35 -0
- package/src/server/services/access.ts +294 -0
- package/src/server/services/freeipa/client.ts +100 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +78 -0
- package/src/server/services/freeipa/tls.ts +48 -0
- package/src/server/services/freeipa/util.ts +60 -0
- package/src/server/services/geo.ts +154 -0
- package/src/server/services/index.ts +28 -0
- package/src/server/services/services.ts +13 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +907 -0
- package/src/services/account-lifecycle/scheduler.ts +347 -0
- package/src/services/account-model.ts +21 -0
- package/src/services/accounts/app.ts +966 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +45 -0
- package/src/services/accounts/entities.ts +529 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +246 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +64 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +491 -0
- package/src/services/accounts/model.ts +135 -0
- package/src/services/accounts/switching.ts +117 -0
- package/src/services/accounts/users.ts +714 -0
- package/src/services/auth-flows/index.ts +6 -0
- package/src/services/auth-flows/ipa.ts +128 -0
- package/src/services/auth-flows/magic-link.ts +119 -0
- package/src/services/freeipa-config.ts +89 -0
- package/src/services/index.ts +46 -0
- package/src/services/ipa/auth.ts +122 -0
- package/src/services/ipa/groups.ts +684 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/profile.ts +90 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/sync.ts +740 -0
- package/src/services/ipa/users.ts +794 -0
- package/src/services/logging/index.ts +294 -0
- package/src/services/notifications/email.ts +123 -0
- package/src/services/notifications/index.ts +413 -0
- package/src/services/postgres.ts +51 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +13 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +255 -0
- package/src/services/session/index.ts +137 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +101 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +824 -0
- package/src/services/settings/index.ts +203 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +179 -0
- package/src/services/settings/templates.ts +10 -0
- package/src/services/weather/forecast.ts +287 -0
- package/src/services/weather/geo.ts +110 -0
- package/src/services/weather/index.ts +99 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +125 -0
- package/src/services/weather/migrate.ts +22 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/shared/account-display.ts +17 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/icons.ts +109 -0
- package/src/shared/index.ts +10 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +58 -0
- package/src/shared/markdown/extensions/images.ts +43 -0
- package/src/shared/markdown/extensions/info-blocks.ts +93 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +34 -0
- package/src/shared/markdown/extensions/tables.ts +88 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/index.ts +97 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/ssr/AdminLayout.tsx +42 -0
- package/src/ssr/AdminSidebar.tsx +95 -0
- package/src/ssr/Footer.island.tsx +62 -0
- package/src/ssr/GlobalSearchDialog.tsx +389 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
- package/src/ssr/Layout.tsx +326 -0
- package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
- package/src/ssr/NavMenu.island.tsx +108 -0
- package/src/ssr/ThemeToggleRail.island.tsx +27 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/SearchBar.island.tsx +77 -0
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/runtime.ts +22 -0
- package/src/styles/base-popover.css +28 -0
- package/src/styles/effects.css +65 -0
- package/src/styles/global.css +133 -0
- package/src/styles/input.css +54 -0
- package/src/styles/tokens.css +35 -0
- package/src/styles/utilities-buttons.css +125 -0
- package/src/styles/utilities-feedback.css +65 -0
- package/src/styles/utilities-layout.css +122 -0
- package/src/styles/utilities-navigation.css +196 -0
- package/src/types/ambient.d.ts +8 -0
- package/src/ui/admin-settings.tsx +148 -0
- package/src/ui/dialog-core.ts +146 -0
- package/src/ui/filter/FilterChip.tsx +196 -0
- package/src/ui/filter/index.ts +2 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/input/Checkbox.tsx +55 -0
- package/src/ui/input/ColorInput.tsx +122 -0
- package/src/ui/input/DateTimeInput.tsx +86 -0
- package/src/ui/input/ImageInput.tsx +170 -0
- package/src/ui/input/NumberInput.tsx +113 -0
- package/src/ui/input/PinInput.tsx +169 -0
- package/src/ui/input/SegmentedControl.tsx +99 -0
- package/src/ui/input/Select.tsx +288 -0
- package/src/ui/input/SelectChip.tsx +61 -0
- package/src/ui/input/Slider.tsx +118 -0
- package/src/ui/input/Switch.tsx +62 -0
- package/src/ui/input/TagsInput.tsx +115 -0
- package/src/ui/input/TextInput.tsx +160 -0
- package/src/ui/input/index.ts +13 -0
- package/src/ui/input/types.ts +42 -0
- package/src/ui/input/util.tsx +105 -0
- package/src/ui/ipa/Avatar.tsx +28 -0
- package/src/ui/ipa/GroupView.tsx +36 -0
- package/src/ui/ipa/LoginBtn.tsx +16 -0
- package/src/ui/ipa/UserView.tsx +58 -0
- package/src/ui/ipa/index.ts +4 -0
- package/src/ui/misc/ContextMenu.tsx +211 -0
- package/src/ui/misc/CopyButton.tsx +28 -0
- package/src/ui/misc/Dropdown.tsx +194 -0
- package/src/ui/misc/EntitySearch.tsx +213 -0
- package/src/ui/misc/Lightbox.tsx +194 -0
- package/src/ui/misc/LinkCard.tsx +34 -0
- package/src/ui/misc/LogEntriesTable.tsx +61 -0
- package/src/ui/misc/MarkdownView.tsx +65 -0
- package/src/ui/misc/Pagination.tsx +51 -0
- package/src/ui/misc/PermissionEditor.tsx +379 -0
- package/src/ui/misc/ProgressBar.tsx +47 -0
- package/src/ui/misc/RemoveBtn.tsx +27 -0
- package/src/ui/misc/StatCell.tsx +90 -0
- package/src/ui/misc/index.ts +18 -0
- package/src/ui/navigation.ts +32 -0
- package/src/ui/prompts.tsx +854 -0
- package/src/ui/sidebar.tsx +468 -0
- package/src/ui/widgets/Widget.tsx +62 -0
- package/src/ui/widgets/WidgetCard.tsx +19 -0
- package/src/ui/widgets/WidgetHero.tsx +39 -0
- package/src/ui/widgets/WidgetList.tsx +84 -0
- package/src/ui/widgets/WidgetPills.tsx +68 -0
- package/src/ui/widgets/WidgetStat.tsx +67 -0
- package/src/ui/widgets/WidgetStatus.tsx +62 -0
- 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
|
+
·
|
|
107
|
+
<a href="${appUrl}/legal/terms" style="color:#71717a;text-decoration:underline;">Terms</a>
|
|
108
|
+
·
|
|
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
|
+
};
|