@valentinkolb/cloud 0.5.0 → 0.5.2
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 +1 -1
- package/scripts/build.ts +32 -2
- package/src/_internal/define-app.ts +5 -14
- package/src/_internal/static-assets.ts +86 -0
- package/src/api/auth.ts +3 -3
- package/src/services/auth-flows/ipa.ts +47 -5
- package/src/services/auth-flows/magic-link.ts +89 -18
- package/src/services/notifications/index.ts +82 -11
- package/src/services/settings/defaults.ts +15 -0
- package/src/shared/redirect.test.ts +9 -0
- package/src/shared/redirect.ts +5 -1
- package/src/styles/global.css +7 -20
- package/src/styles/utilities-markdown-editor.css +2 -2
package/package.json
CHANGED
package/scripts/build.ts
CHANGED
|
@@ -21,11 +21,13 @@
|
|
|
21
21
|
* it ships a `scripts/build-extras.ts` that this script runs at the end.
|
|
22
22
|
*/
|
|
23
23
|
import { existsSync } from "node:fs";
|
|
24
|
-
import { cp, mkdir, readdir, rm } from "node:fs/promises";
|
|
25
|
-
import { dirname, resolve } from "node:path";
|
|
24
|
+
import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
25
|
+
import { dirname, extname, resolve } from "node:path";
|
|
26
26
|
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { brotliCompress, constants as zlibConstants, gzip } from "node:zlib";
|
|
27
28
|
import tailwind from "bun-plugin-tailwind";
|
|
28
29
|
import { Glob, CryptoHasher } from "bun";
|
|
30
|
+
import { promisify } from "node:util";
|
|
29
31
|
|
|
30
32
|
const appId = process.env.APP_ID;
|
|
31
33
|
if (!appId) throw new Error("APP_ID env var required");
|
|
@@ -47,6 +49,8 @@ if (!existsSync(appDir)) throw new Error(`Unknown app dir: ${appDir} (set APP_DI
|
|
|
47
49
|
|
|
48
50
|
const dist = resolve(root, "dist");
|
|
49
51
|
const distPublic = resolve(dist, "public");
|
|
52
|
+
const compressBrotli = promisify(brotliCompress);
|
|
53
|
+
const compressGzip = promisify(gzip);
|
|
50
54
|
|
|
51
55
|
await rm(dist, { recursive: true, force: true });
|
|
52
56
|
await mkdir(distPublic, { recursive: true });
|
|
@@ -59,6 +63,29 @@ const islandId = (file: string): string => {
|
|
|
59
63
|
return new CryptoHasher("md5").update(rel).digest("hex").slice(0, 12);
|
|
60
64
|
};
|
|
61
65
|
|
|
66
|
+
const compressibleExtensions = new Set([".css", ".html", ".js", ".json", ".map", ".svg", ".txt", ".xml"]);
|
|
67
|
+
|
|
68
|
+
async function precompressDistAssets(dir: string): Promise<void> {
|
|
69
|
+
if (!existsSync(dir)) return;
|
|
70
|
+
|
|
71
|
+
for await (const file of new Glob("**/*").scan({ cwd: dir, absolute: true, onlyFiles: true })) {
|
|
72
|
+
if (file.endsWith(".br") || file.endsWith(".gz")) continue;
|
|
73
|
+
if (!compressibleExtensions.has(extname(file))) continue;
|
|
74
|
+
|
|
75
|
+
const source = await readFile(file);
|
|
76
|
+
const [br, gz] = await Promise.all([
|
|
77
|
+
compressBrotli(source, {
|
|
78
|
+
params: {
|
|
79
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY,
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
compressGzip(source, { level: zlibConstants.Z_BEST_COMPRESSION }),
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
await Promise.all([writeFile(`${file}.br`, br), writeFile(`${file}.gz`, gz)]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
62
89
|
// Register the app's SSR plugin (Solid JSX transform + island bundler).
|
|
63
90
|
// In the monorepo this resolves via `packages/<id>/src/config`; in standalone
|
|
64
91
|
// it resolves via the appDir path (because the script's relative imports
|
|
@@ -133,4 +160,7 @@ if (existsSync(extras)) {
|
|
|
133
160
|
await import(extras);
|
|
134
161
|
}
|
|
135
162
|
|
|
163
|
+
await precompressDistAssets(resolve(dist, "public"));
|
|
164
|
+
await precompressDistAssets(resolve(dist, "_ssr"));
|
|
165
|
+
|
|
136
166
|
console.log(`Built ${appId} → ${dist}`);
|
|
@@ -9,7 +9,6 @@ import type { SsrConfig } from "@valentinkolb/ssr";
|
|
|
9
9
|
import { createConfig as createSsrConfig } from "@valentinkolb/ssr";
|
|
10
10
|
import { createSSRHandler, routes } from "@valentinkolb/ssr/hono";
|
|
11
11
|
import { Hono } from "hono";
|
|
12
|
-
import { serveStatic } from "hono/bun";
|
|
13
12
|
import { generateSpecs } from "hono-openapi";
|
|
14
13
|
import type { AppCapabilities, AppLifecycle, AppMeta, AppSearchContext, CloudContext } from "../contracts/app";
|
|
15
14
|
import type { AppRegistryEntry } from "../contracts/registry";
|
|
@@ -23,6 +22,7 @@ import { createSettingsAPI, type SettingsAPI } from "../services/settings/api";
|
|
|
23
22
|
import { registerSettings, type SettingDef } from "../services/settings/defaults";
|
|
24
23
|
import { createHeartbeat } from "./heartbeat";
|
|
25
24
|
import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./runtime-watcher";
|
|
25
|
+
import { servePublicAsset } from "./static-assets";
|
|
26
26
|
|
|
27
27
|
/** Cache-busting version stamp — changes on every server start / rebuild. */
|
|
28
28
|
const v = Date.now();
|
|
@@ -252,6 +252,9 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
|
|
|
252
252
|
<meta name="mobile-web-app-capable" content="yes">
|
|
253
253
|
<link rel="icon" href="/branding/favicon">
|
|
254
254
|
<style data-cloud-css-layers>@layer theme, base, components, utilities;</style>
|
|
255
|
+
<link rel="preload" href="/public/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
|
256
|
+
<link rel="stylesheet" href="/public/fonts.css?v=${v}">
|
|
257
|
+
<link rel="stylesheet" href="/public/tabler-icons.css?v=${v}">
|
|
255
258
|
<link rel="stylesheet" href="/public/${opts.id}/app.css?v=${v}">
|
|
256
259
|
<link rel="stylesheet" href="/public/global.css?v=${v}">
|
|
257
260
|
<script>${themeBootstrapScript}</script>
|
|
@@ -349,19 +352,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
|
|
|
349
352
|
|
|
350
353
|
const server = new Hono()
|
|
351
354
|
.route(ssrMountPath, routes(config))
|
|
352
|
-
.
|
|
353
|
-
"/public/*",
|
|
354
|
-
serveStatic({
|
|
355
|
-
root: "./",
|
|
356
|
-
onFound: (_path, c) => {
|
|
357
|
-
c.header("Cache-Control", isDevelopment ? "no-store" : "public, max-age=31536000, immutable");
|
|
358
|
-
},
|
|
359
|
-
}),
|
|
360
|
-
)
|
|
361
|
-
// serveStatic calls next() on miss — terminate /public/* here so a
|
|
362
|
-
// missing asset is a clean 404 instead of falling through to the app
|
|
363
|
-
// fetch (which might render an HTML page for the missing path).
|
|
364
|
-
.all("/public/*", (c) => c.notFound());
|
|
355
|
+
.all("/public/*", servePublicAsset(isDevelopment));
|
|
365
356
|
|
|
366
357
|
if (startOpts.capabilities?.search) {
|
|
367
358
|
const searchRun = startOpts.capabilities.search.run;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { resolve, sep } from "node:path";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
|
|
4
|
+
const publicRoot = resolve(process.cwd(), "public");
|
|
5
|
+
|
|
6
|
+
function decodePathname(pathname: string): string | null {
|
|
7
|
+
try {
|
|
8
|
+
return decodeURIComponent(pathname);
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolvePublicAsset(pathname: string): string | null {
|
|
15
|
+
const decoded = decodePathname(pathname);
|
|
16
|
+
if (!decoded?.startsWith("/public/")) return null;
|
|
17
|
+
|
|
18
|
+
const path = resolve(process.cwd(), decoded.slice(1));
|
|
19
|
+
if (path !== publicRoot && !path.startsWith(`${publicRoot}${sep}`)) return null;
|
|
20
|
+
return path;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function acceptsEncoding(header: string | null, encoding: "br" | "gzip"): boolean {
|
|
24
|
+
if (!header) return false;
|
|
25
|
+
|
|
26
|
+
return header.split(",").some((part) => {
|
|
27
|
+
const [name, ...params] = part.trim().split(";");
|
|
28
|
+
if (name?.trim().toLowerCase() !== encoding) return false;
|
|
29
|
+
return !params.some((param) => {
|
|
30
|
+
const [key, value] = param.trim().split("=");
|
|
31
|
+
return key?.toLowerCase() === "q" && Number(value) === 0;
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function encodedFile(path: string, encoding: "br" | "gzip"): Promise<Bun.BunFile | null> {
|
|
37
|
+
const suffix = encoding === "br" ? ".br" : ".gz";
|
|
38
|
+
const file = Bun.file(`${path}${suffix}`);
|
|
39
|
+
return (await file.exists()) ? file : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function servePublicAsset(isDevelopment: boolean) {
|
|
43
|
+
return async (c: Context) => {
|
|
44
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
|
45
|
+
c.header("Allow", "GET, HEAD");
|
|
46
|
+
return c.text("Method Not Allowed", 405);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const path = resolvePublicAsset(new URL(c.req.url).pathname);
|
|
50
|
+
if (!path) return c.notFound();
|
|
51
|
+
|
|
52
|
+
const sourceFile = Bun.file(path);
|
|
53
|
+
if (!(await sourceFile.exists())) return c.notFound();
|
|
54
|
+
|
|
55
|
+
const acceptEncoding = c.req.header("Accept-Encoding") ?? null;
|
|
56
|
+
let selected = sourceFile;
|
|
57
|
+
let selectedEncoding: "br" | "gzip" | null = null;
|
|
58
|
+
|
|
59
|
+
if (acceptsEncoding(acceptEncoding, "br")) {
|
|
60
|
+
const br = await encodedFile(path, "br");
|
|
61
|
+
if (br) {
|
|
62
|
+
selected = br;
|
|
63
|
+
selectedEncoding = "br";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!selectedEncoding && acceptsEncoding(acceptEncoding, "gzip")) {
|
|
68
|
+
const gz = await encodedFile(path, "gzip");
|
|
69
|
+
if (gz) {
|
|
70
|
+
selected = gz;
|
|
71
|
+
selectedEncoding = "gzip";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const headers = new Headers({
|
|
76
|
+
"Cache-Control": isDevelopment ? "no-store" : "public, max-age=31536000, immutable",
|
|
77
|
+
"Content-Length": String(selected.size),
|
|
78
|
+
"Content-Type": sourceFile.type || "application/octet-stream",
|
|
79
|
+
Vary: "Accept-Encoding",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (selectedEncoding) headers.set("Content-Encoding", selectedEncoding);
|
|
83
|
+
|
|
84
|
+
return new Response(c.req.method === "HEAD" ? null : selected, { headers });
|
|
85
|
+
};
|
|
86
|
+
}
|
package/src/api/auth.ts
CHANGED
|
@@ -49,8 +49,8 @@ const app = new Hono<AuthContext>()
|
|
|
49
49
|
|
|
50
50
|
const loginResult = await authFlows.ipa.login({ username, password });
|
|
51
51
|
if (!loginResult.ok && loginResult.reason === "password_expired") {
|
|
52
|
-
log.info("Login failed", { uid:
|
|
53
|
-
return c.json({ message: "Password expired", passwordExpired: true }, 401);
|
|
52
|
+
log.info("Login failed", { uid: loginResult.uid, reason: "password_expired" });
|
|
53
|
+
return c.json({ message: "Password expired", passwordExpired: true, ipaUid: loginResult.uid }, 401);
|
|
54
54
|
}
|
|
55
55
|
if (!loginResult.ok) {
|
|
56
56
|
log.info("Login failed", {
|
|
@@ -63,7 +63,7 @@ const app = new Hono<AuthContext>()
|
|
|
63
63
|
// Store minimal session in Redis
|
|
64
64
|
const sessionToken = await auth.session.create(c, loginResult.userId);
|
|
65
65
|
|
|
66
|
-
log.info("Login successful", { uid:
|
|
66
|
+
log.info("Login successful", { uid: loginResult.user.uid });
|
|
67
67
|
return c.json({
|
|
68
68
|
session_token: sessionToken,
|
|
69
69
|
user: loginResult.user,
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import { accounts } from "../accounts";
|
|
3
|
+
import { logger } from "../logging";
|
|
3
4
|
import { providers } from "../providers";
|
|
4
5
|
import type { User } from "../../contracts/shared";
|
|
5
6
|
|
|
6
7
|
type IpaLoginFailure =
|
|
7
|
-
| { ok: false; status: 401; reason: "password_expired"; message: string }
|
|
8
|
+
| { ok: false; status: 401; reason: "password_expired"; message: string; uid: string }
|
|
8
9
|
| { ok: false; status: 401; reason: "invalid_credentials"; message: string }
|
|
9
10
|
| { ok: false; status: 400; reason: "user_not_synced"; message: string }
|
|
10
11
|
| { ok: false; status: 400; reason: "user_not_found"; message: string }
|
|
@@ -20,6 +21,42 @@ type IpaLoginSuccess = {
|
|
|
20
21
|
|
|
21
22
|
export type IpaLoginFlowResult = IpaLoginSuccess | IpaLoginFailure;
|
|
22
23
|
|
|
24
|
+
const log = logger("auth:ipa");
|
|
25
|
+
const DUMMY_LOGIN_UID = "__cloud_invalid_ipa_email_login__";
|
|
26
|
+
|
|
27
|
+
const normalizeEmail = (value: string): string => value.trim().toLowerCase();
|
|
28
|
+
|
|
29
|
+
const resolveIpaLoginUid = async (identifier: string): Promise<string | null> => {
|
|
30
|
+
const trimmed = identifier.trim();
|
|
31
|
+
if (!trimmed) return null;
|
|
32
|
+
if (!trimmed.includes("@")) return trimmed;
|
|
33
|
+
|
|
34
|
+
const rows = await sql<{ uid: string }[]>`
|
|
35
|
+
SELECT uid
|
|
36
|
+
FROM auth.users
|
|
37
|
+
WHERE provider = 'ipa'
|
|
38
|
+
AND lower(btrim(mail)) = ${normalizeEmail(trimmed)}
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
if (rows.length !== 1) {
|
|
42
|
+
if (rows.length > 1) {
|
|
43
|
+
log.warn("FreeIPA email login skipped: ambiguous email", {
|
|
44
|
+
email: normalizeEmail(trimmed),
|
|
45
|
+
matches: rows.length,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return rows[0]!.uid;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const failInvalidCredentials = async (params: { identifier: string; password: string }): Promise<IpaLoginFailure> => {
|
|
54
|
+
if (params.identifier.trim().includes("@")) {
|
|
55
|
+
await providers.ipa.auth.login(DUMMY_LOGIN_UID, params.password).catch(() => undefined);
|
|
56
|
+
}
|
|
57
|
+
return { ok: false, status: 401, reason: "invalid_credentials", message: "Invalid username or password" };
|
|
58
|
+
};
|
|
59
|
+
|
|
23
60
|
const loadSyncedIpaUser = async (uid: string): Promise<{ ok: true; userId: string; user: User } | IpaLoginFailure> => {
|
|
24
61
|
const userRows = await sql`
|
|
25
62
|
SELECT id FROM auth.users
|
|
@@ -50,9 +87,14 @@ const loadSyncedIpaUser = async (uid: string): Promise<{ ok: true; userId: strin
|
|
|
50
87
|
};
|
|
51
88
|
|
|
52
89
|
export const login = async (params: { username: string; password: string }): Promise<IpaLoginFlowResult> => {
|
|
53
|
-
const
|
|
90
|
+
const uid = await resolveIpaLoginUid(params.username);
|
|
91
|
+
if (!uid) {
|
|
92
|
+
return failInvalidCredentials({ identifier: params.username, password: params.password });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const loginResult = await providers.ipa.auth.login(uid, params.password);
|
|
54
96
|
if (loginResult.status === "password_expired") {
|
|
55
|
-
return { ok: false, status: 401, reason: "password_expired", message: "Password expired" };
|
|
97
|
+
return { ok: false, status: 401, reason: "password_expired", message: "Password expired", uid };
|
|
56
98
|
}
|
|
57
99
|
if (loginResult.status !== "success") {
|
|
58
100
|
return { ok: false, status: 401, reason: "invalid_credentials", message: "Invalid username or password" };
|
|
@@ -61,7 +103,7 @@ export const login = async (params: { username: string; password: string }): Pro
|
|
|
61
103
|
// Must reach a "synced" outcome before granting a session. Stale mirror rows
|
|
62
104
|
// (expired remotely, dropped from sync scope, or fetch failures) must never
|
|
63
105
|
// grant a fresh local session on the back of successful FreeIPA credentials.
|
|
64
|
-
const syncOutcome = await providers.ipa.sync.user(
|
|
106
|
+
const syncOutcome = await providers.ipa.sync.user(uid);
|
|
65
107
|
switch (syncOutcome.status) {
|
|
66
108
|
case "synced":
|
|
67
109
|
break;
|
|
@@ -97,7 +139,7 @@ export const login = async (params: { username: string; password: string }): Pro
|
|
|
97
139
|
};
|
|
98
140
|
}
|
|
99
141
|
|
|
100
|
-
const userResult = await loadSyncedIpaUser(
|
|
142
|
+
const userResult = await loadSyncedIpaUser(uid);
|
|
101
143
|
if (!userResult.ok) return userResult;
|
|
102
144
|
|
|
103
145
|
return {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sql } from "bun";
|
|
1
|
+
import { redis, sql } from "bun";
|
|
2
2
|
import { accounts } from "../accounts";
|
|
3
3
|
import { notifications } from "../notifications";
|
|
4
4
|
import { providers } from "../providers";
|
|
@@ -6,33 +6,95 @@ import * as settings from "../settings";
|
|
|
6
6
|
import { renderTemplate } from "../settings/templates";
|
|
7
7
|
import type { User } from "../../contracts/shared";
|
|
8
8
|
import { createAuthLoginUrl } from "../../shared/redirect";
|
|
9
|
+
import { logger } from "../logging";
|
|
10
|
+
|
|
11
|
+
const log = logger("auth:magic-link");
|
|
12
|
+
const IPA_HINT_COOLDOWN_SECONDS = 300;
|
|
13
|
+
|
|
14
|
+
const normalizeEmail = (email: string): string => email.trim().toLowerCase();
|
|
15
|
+
const ipaHintCooldownKey = (email: string): string => `ipa-email-login-hint-cooldown:${email}`;
|
|
16
|
+
|
|
17
|
+
const getAppUrl = async (): Promise<string> => {
|
|
18
|
+
const rawAppUrl = await settings.get<string>("app.url");
|
|
19
|
+
return rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const hasIpaAccountForEmail = async (email: string): Promise<boolean> => {
|
|
23
|
+
const rows = await sql<{ exists: boolean }[]>`
|
|
24
|
+
SELECT EXISTS (
|
|
25
|
+
SELECT 1
|
|
26
|
+
FROM auth.users
|
|
27
|
+
WHERE provider = 'ipa'
|
|
28
|
+
AND lower(btrim(mail)) = ${email}
|
|
29
|
+
) AS exists
|
|
30
|
+
`;
|
|
31
|
+
return Boolean(rows[0]?.exists);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const claimIpaHintCooldown = async (email: string): Promise<boolean> => {
|
|
35
|
+
const result = await redis.send("SET", [
|
|
36
|
+
ipaHintCooldownKey(email),
|
|
37
|
+
"1",
|
|
38
|
+
"EX",
|
|
39
|
+
String(IPA_HINT_COOLDOWN_SECONDS),
|
|
40
|
+
"NX",
|
|
41
|
+
]);
|
|
42
|
+
return result === "OK";
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const sendIpaEmailLoginHint = async (params: { email: string; redirectTo?: string }): Promise<void> => {
|
|
46
|
+
const appUrl = await getAppUrl();
|
|
47
|
+
const loginUrl = createAuthLoginUrl(appUrl, {
|
|
48
|
+
method: "ipa",
|
|
49
|
+
redirectTo: params.redirectTo,
|
|
50
|
+
});
|
|
51
|
+
const [appName, contactEmail, template] = await Promise.all([
|
|
52
|
+
settings.get<string>("app.name"),
|
|
53
|
+
settings.get<string>("app.contact_email"),
|
|
54
|
+
settings.get<string>("mail.ipa_email_login_hint"),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
await notifications.send({
|
|
58
|
+
type: "email",
|
|
59
|
+
recipient: params.email,
|
|
60
|
+
subject: `${appName} FreeIPA Sign In`,
|
|
61
|
+
rawHtml: renderTemplate(template, {
|
|
62
|
+
EMAIL: params.email,
|
|
63
|
+
LOGIN_URL: loginUrl,
|
|
64
|
+
APP_NAME: appName,
|
|
65
|
+
CONTACT_EMAIL: contactEmail?.trim() ?? "",
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
};
|
|
9
69
|
|
|
10
70
|
export const request = async (params: { email: string; redirectTo?: string }): Promise<
|
|
11
71
|
| { ok: true }
|
|
12
72
|
| { ok: false; status: 400; message: string }
|
|
13
73
|
> => {
|
|
14
|
-
const
|
|
74
|
+
const email = normalizeEmail(params.email);
|
|
75
|
+
const hasIpaUser = await hasIpaAccountForEmail(email);
|
|
76
|
+
const userRows = hasIpaUser ? [] : await sql`SELECT uid, provider FROM auth.users WHERE lower(btrim(mail)) = ${email}`;
|
|
15
77
|
const hasLocalUser = userRows.some((row: { provider: string | null }) => row.provider === "local");
|
|
16
|
-
const hasIpaUser = userRows.some((row: { provider: string | null }) => row.provider === "ipa");
|
|
17
78
|
const allowSelfRegistration = await settings.get<boolean>("user.allow_self_registration");
|
|
18
79
|
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
80
|
+
if (hasIpaUser) {
|
|
81
|
+
if (await claimIpaHintCooldown(email)) {
|
|
82
|
+
void sendIpaEmailLoginHint({ email, redirectTo: params.redirectTo }).catch((error) => {
|
|
83
|
+
log.warn("Failed to send FreeIPA email-login hint", {
|
|
84
|
+
email,
|
|
85
|
+
error: error instanceof Error ? error.message : String(error),
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return { ok: true };
|
|
25
90
|
}
|
|
26
91
|
|
|
27
|
-
if (!hasLocalUser &&
|
|
28
|
-
// Return ok without sending email to prevent account enumeration.
|
|
29
|
-
// IPA-only users must authenticate via Kerberos, not magic-link.
|
|
92
|
+
if (!hasLocalUser && !allowSelfRegistration) {
|
|
30
93
|
return { ok: true };
|
|
31
94
|
}
|
|
32
95
|
|
|
33
|
-
const token = await providers.local.auth.createMagicLinkToken({ email
|
|
34
|
-
const
|
|
35
|
-
const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
|
|
96
|
+
const token = await providers.local.auth.createMagicLinkToken({ email, ttlSeconds: 300 });
|
|
97
|
+
const appUrl = await getAppUrl();
|
|
36
98
|
const magicLink = createAuthLoginUrl(appUrl, { token, redirectTo: params.redirectTo });
|
|
37
99
|
|
|
38
100
|
const appName = await settings.get<string>("app.name");
|
|
@@ -40,7 +102,7 @@ export const request = async (params: { email: string; redirectTo?: string }): P
|
|
|
40
102
|
|
|
41
103
|
await notifications.send({
|
|
42
104
|
type: "email",
|
|
43
|
-
recipient:
|
|
105
|
+
recipient: email,
|
|
44
106
|
subject: `${appName} Login Code`,
|
|
45
107
|
rawHtml: renderTemplate(template, {
|
|
46
108
|
TOKEN: token,
|
|
@@ -63,13 +125,22 @@ export const verify = async (params: { token: string }): Promise<
|
|
|
63
125
|
}
|
|
64
126
|
|
|
65
127
|
const { email } = payload;
|
|
128
|
+
const normalizedEmail = normalizeEmail(email);
|
|
129
|
+
if (await hasIpaAccountForEmail(normalizedEmail)) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
status: 401,
|
|
133
|
+
message: "This email address belongs to a FreeIPA-managed account. Sign in with FreeIPA.",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
66
137
|
// Reject expired accounts at login time, not just during cleanup. Without
|
|
67
138
|
// this, an expired local user / guest could still authenticate in the
|
|
68
139
|
// window between expiry and the next lifecycle run.
|
|
69
140
|
const userRows = await sql`
|
|
70
141
|
SELECT id, account_expires
|
|
71
142
|
FROM auth.users
|
|
72
|
-
WHERE mail = ${
|
|
143
|
+
WHERE lower(btrim(mail)) = ${normalizedEmail} AND provider = 'local'
|
|
73
144
|
AND (account_expires IS NULL OR account_expires > now())
|
|
74
145
|
ORDER BY profile = 'user' DESC
|
|
75
146
|
LIMIT 1
|
|
@@ -84,7 +155,7 @@ export const verify = async (params: { token: string }): Promise<
|
|
|
84
155
|
const expiredRows = await sql`
|
|
85
156
|
SELECT id
|
|
86
157
|
FROM auth.users
|
|
87
|
-
WHERE mail = ${
|
|
158
|
+
WHERE lower(btrim(mail)) = ${normalizedEmail} AND provider = 'local'
|
|
88
159
|
AND account_expires IS NOT NULL AND account_expires <= now()
|
|
89
160
|
LIMIT 1
|
|
90
161
|
`;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
|
-
import { sendEmail } from "./email";
|
|
3
2
|
import type { PaginationParams } from "../../contracts/shared";
|
|
4
|
-
import { escapeLikePattern } from "../postgres";
|
|
5
3
|
import { logger } from "../logging";
|
|
4
|
+
import { escapeLikePattern } from "../postgres";
|
|
5
|
+
import { sendEmail } from "./email";
|
|
6
6
|
|
|
7
7
|
const log = logger("notifications");
|
|
8
8
|
|
|
9
9
|
export type NotificationType = "email";
|
|
10
10
|
export type NotificationStatus = "sent" | "pending" | "error";
|
|
11
|
+
export type NotificationStatusSummary = Record<NotificationStatus, number>;
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Computes notification delivery status from sent/error timestamps.
|
|
@@ -56,6 +57,12 @@ export type NotificationMessage = {
|
|
|
56
57
|
status: NotificationStatus;
|
|
57
58
|
};
|
|
58
59
|
|
|
60
|
+
const emptyStatusSummary = (): NotificationStatusSummary => ({
|
|
61
|
+
sent: 0,
|
|
62
|
+
pending: 0,
|
|
63
|
+
error: 0,
|
|
64
|
+
});
|
|
65
|
+
|
|
59
66
|
type DbNotificationRow = {
|
|
60
67
|
id: string;
|
|
61
68
|
type: NotificationType;
|
|
@@ -143,23 +150,26 @@ export const sendToUser = async (params: SendToUserParams): Promise<{ ok: true;
|
|
|
143
150
|
*/
|
|
144
151
|
export const list = async (
|
|
145
152
|
pagination: PaginationParams,
|
|
146
|
-
options?: { sentBy?: string; isAdmin?: boolean; search?: string },
|
|
153
|
+
options?: { sentBy?: string; isAdmin?: boolean; search?: string; status?: NotificationStatus },
|
|
147
154
|
): Promise<{ notifications: NotificationMessage[]; total: number }> => {
|
|
148
155
|
const { offset, perPage } = pagination;
|
|
149
|
-
const { sentBy, isAdmin, search } = options ?? {};
|
|
156
|
+
const { sentBy, isAdmin, search, status } = options ?? {};
|
|
150
157
|
|
|
151
158
|
// Build query based on permissions
|
|
152
159
|
let countRows: Array<{ count: number | string }> = [];
|
|
153
160
|
let dataRows: DbNotificationRow[] = [];
|
|
154
161
|
|
|
155
162
|
const searchPattern = search ? `%${escapeLikePattern(search)}%` : null;
|
|
163
|
+
const statusFilter = status ?? null;
|
|
156
164
|
|
|
157
165
|
if (isAdmin) {
|
|
158
166
|
// Admins see all notifications
|
|
159
167
|
if (searchPattern) {
|
|
160
168
|
countRows = await sql`
|
|
161
169
|
SELECT COUNT(*)::int as count FROM notifications.messages
|
|
162
|
-
WHERE
|
|
170
|
+
WHERE
|
|
171
|
+
(${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
|
|
172
|
+
AND (subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\')
|
|
163
173
|
`;
|
|
164
174
|
dataRows = await sql`
|
|
165
175
|
SELECT
|
|
@@ -168,12 +178,17 @@ export const list = async (
|
|
|
168
178
|
u.display_name as sent_by_name
|
|
169
179
|
FROM notifications.messages m
|
|
170
180
|
LEFT JOIN auth.users u ON m.sent_by = u.id
|
|
171
|
-
WHERE
|
|
181
|
+
WHERE
|
|
182
|
+
(${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
|
|
183
|
+
AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
|
|
172
184
|
ORDER BY m.created_at DESC
|
|
173
185
|
LIMIT ${perPage} OFFSET ${offset}
|
|
174
186
|
`;
|
|
175
187
|
} else {
|
|
176
|
-
countRows = await sql`
|
|
188
|
+
countRows = await sql`
|
|
189
|
+
SELECT COUNT(*)::int as count FROM notifications.messages
|
|
190
|
+
WHERE ${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter}
|
|
191
|
+
`;
|
|
177
192
|
dataRows = await sql`
|
|
178
193
|
SELECT
|
|
179
194
|
m.id, m.type, m.recipient, m.subject, m.content,
|
|
@@ -181,6 +196,7 @@ export const list = async (
|
|
|
181
196
|
u.display_name as sent_by_name
|
|
182
197
|
FROM notifications.messages m
|
|
183
198
|
LEFT JOIN auth.users u ON m.sent_by = u.id
|
|
199
|
+
WHERE ${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter}
|
|
184
200
|
ORDER BY m.created_at DESC
|
|
185
201
|
LIMIT ${perPage} OFFSET ${offset}
|
|
186
202
|
`;
|
|
@@ -190,7 +206,10 @@ export const list = async (
|
|
|
190
206
|
if (searchPattern) {
|
|
191
207
|
countRows = await sql`
|
|
192
208
|
SELECT COUNT(*)::int as count FROM notifications.messages
|
|
193
|
-
WHERE
|
|
209
|
+
WHERE
|
|
210
|
+
sent_by = ${sentBy}
|
|
211
|
+
AND (${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
|
|
212
|
+
AND (subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\')
|
|
194
213
|
`;
|
|
195
214
|
dataRows = await sql`
|
|
196
215
|
SELECT
|
|
@@ -199,12 +218,20 @@ export const list = async (
|
|
|
199
218
|
u.display_name as sent_by_name
|
|
200
219
|
FROM notifications.messages m
|
|
201
220
|
LEFT JOIN auth.users u ON m.sent_by = u.id
|
|
202
|
-
WHERE
|
|
221
|
+
WHERE
|
|
222
|
+
m.sent_by = ${sentBy}
|
|
223
|
+
AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
|
|
224
|
+
AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
|
|
203
225
|
ORDER BY m.created_at DESC
|
|
204
226
|
LIMIT ${perPage} OFFSET ${offset}
|
|
205
227
|
`;
|
|
206
228
|
} else {
|
|
207
|
-
countRows = await sql`
|
|
229
|
+
countRows = await sql`
|
|
230
|
+
SELECT COUNT(*)::int as count FROM notifications.messages
|
|
231
|
+
WHERE
|
|
232
|
+
sent_by = ${sentBy}
|
|
233
|
+
AND (${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
|
|
234
|
+
`;
|
|
208
235
|
dataRows = await sql`
|
|
209
236
|
SELECT
|
|
210
237
|
m.id, m.type, m.recipient, m.subject, m.content,
|
|
@@ -212,7 +239,9 @@ export const list = async (
|
|
|
212
239
|
u.display_name as sent_by_name
|
|
213
240
|
FROM notifications.messages m
|
|
214
241
|
LEFT JOIN auth.users u ON m.sent_by = u.id
|
|
215
|
-
WHERE
|
|
242
|
+
WHERE
|
|
243
|
+
m.sent_by = ${sentBy}
|
|
244
|
+
AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
|
|
216
245
|
ORDER BY m.created_at DESC
|
|
217
246
|
LIMIT ${perPage} OFFSET ${offset}
|
|
218
247
|
`;
|
|
@@ -246,6 +275,47 @@ export const list = async (
|
|
|
246
275
|
return { notifications, total };
|
|
247
276
|
};
|
|
248
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Count current notification statuses for recent entries.
|
|
280
|
+
*/
|
|
281
|
+
export const getStatusSummary = async (options?: {
|
|
282
|
+
sentBy?: string;
|
|
283
|
+
isAdmin?: boolean;
|
|
284
|
+
days?: number;
|
|
285
|
+
}): Promise<NotificationStatusSummary> => {
|
|
286
|
+
const { sentBy, isAdmin, days = 7 } = options ?? {};
|
|
287
|
+
const windowDays = Math.max(1, Math.floor(days));
|
|
288
|
+
const summary = emptyStatusSummary();
|
|
289
|
+
|
|
290
|
+
let rows: Array<{ status: NotificationStatus; count: number | string }> = [];
|
|
291
|
+
if (isAdmin) {
|
|
292
|
+
rows = await sql`
|
|
293
|
+
SELECT
|
|
294
|
+
CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END as status,
|
|
295
|
+
COUNT(*)::int as count
|
|
296
|
+
FROM notifications.messages
|
|
297
|
+
WHERE created_at >= now() - (${windowDays}::int * interval '1 day')
|
|
298
|
+
GROUP BY status
|
|
299
|
+
`;
|
|
300
|
+
} else if (sentBy) {
|
|
301
|
+
rows = await sql`
|
|
302
|
+
SELECT
|
|
303
|
+
CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END as status,
|
|
304
|
+
COUNT(*)::int as count
|
|
305
|
+
FROM notifications.messages
|
|
306
|
+
WHERE sent_by = ${sentBy} AND created_at >= now() - (${windowDays}::int * interval '1 day')
|
|
307
|
+
GROUP BY status
|
|
308
|
+
`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for (const row of rows) {
|
|
312
|
+
const count = typeof row.count === "string" ? Number.parseInt(row.count, 10) : row.count;
|
|
313
|
+
summary[row.status] = Number.isFinite(count) ? count : 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return summary;
|
|
317
|
+
};
|
|
318
|
+
|
|
249
319
|
/**
|
|
250
320
|
* Get a single notification by ID.
|
|
251
321
|
*/
|
|
@@ -410,4 +480,5 @@ export const notifications = {
|
|
|
410
480
|
update,
|
|
411
481
|
getPendingSystemCount,
|
|
412
482
|
sendAllPendingSystem,
|
|
483
|
+
getStatusSummary,
|
|
413
484
|
};
|
|
@@ -438,6 +438,21 @@ export const SETTINGS: SettingDef[] = [
|
|
|
438
438
|
group: "mail",
|
|
439
439
|
templateVars: ["TOKEN", "MAGIC_LINK", "APP_NAME"],
|
|
440
440
|
},
|
|
441
|
+
{
|
|
442
|
+
key: "mail.ipa_email_login_hint",
|
|
443
|
+
label: "FreeIPA Email Login Hint Template",
|
|
444
|
+
kind: "template",
|
|
445
|
+
default: `<p>A sign-in link was requested for <code style="background:#f4f4f5;padding:2px 6px;border-radius:4px;">{{EMAIL}}</code>.</p>
|
|
446
|
+
<p>This email address belongs to a FreeIPA-managed account. Please sign in with your FreeIPA username and password. If your email address is unique in FreeIPA, you can also use it instead of your username.</p>
|
|
447
|
+
<p style="text-align:center;margin:24px 0;">
|
|
448
|
+
<a href="{{LOGIN_URL}}" target="_blank" style="color:#3b82f6;text-decoration:underline;">Open FreeIPA sign-in</a>
|
|
449
|
+
</p>
|
|
450
|
+
<p style="color:#71717a;font-size:12px;margin:0 0 8px 0;">No email login code was created. If you didn't request this, please ignore this email.</p>
|
|
451
|
+
{{#CONTACT_EMAIL}}<p style="color:#71717a;font-size:12px;margin:0;">If you need help, contact <a href="mailto:{{CONTACT_EMAIL}}">{{CONTACT_EMAIL}}</a>.</p>{{/CONTACT_EMAIL}}`,
|
|
452
|
+
description: "FreeIPA email-login hint template (HTML). Subject: {{APP_NAME}} FreeIPA Sign In",
|
|
453
|
+
group: "mail",
|
|
454
|
+
templateVars: ["EMAIL", "LOGIN_URL", "CONTACT_EMAIL", "APP_NAME"],
|
|
455
|
+
},
|
|
441
456
|
{
|
|
442
457
|
key: "mail.password_reset",
|
|
443
458
|
label: "Password Reset Template",
|
|
@@ -33,6 +33,15 @@ describe("redirect helpers", () => {
|
|
|
33
33
|
expect(externalUrl).toBe("https://cloud.example/auth/login?token=token-id");
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
test("builds method-specific login links with safe redirects only", () => {
|
|
37
|
+
const url = createAuthLoginUrl("https://cloud.example", {
|
|
38
|
+
method: "ipa",
|
|
39
|
+
redirectTo: "/app/dashboard",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(url).toBe("https://cloud.example/auth/login?method=ipa&redirectTo=%2Fapp%2Fdashboard");
|
|
43
|
+
});
|
|
44
|
+
|
|
36
45
|
test("builds password reset links with safe redirects only", () => {
|
|
37
46
|
const safeUrl = createAuthPasswordResetUrl("https://cloud.example", {
|
|
38
47
|
token: "token-id",
|
package/src/shared/redirect.ts
CHANGED
|
@@ -30,9 +30,13 @@ export const createLoginRedirectUrl = (requestUrl: string): string => {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
/** Build an absolute auth login URL while preserving only safe local redirects. */
|
|
33
|
-
export const createAuthLoginUrl = (
|
|
33
|
+
export const createAuthLoginUrl = (
|
|
34
|
+
appUrl: string,
|
|
35
|
+
params: { token?: string; method?: "email" | "ipa" | "admin"; redirectTo?: string | null | undefined } = {},
|
|
36
|
+
): string => {
|
|
34
37
|
const url = new URL(`${appUrl.replace(/\/$/, "")}/auth/login`);
|
|
35
38
|
if (params.token) url.searchParams.set("token", params.token);
|
|
39
|
+
if (params.method) url.searchParams.set("method", params.method);
|
|
36
40
|
|
|
37
41
|
const redirectTo = normalizeRedirectTo(params.redirectTo);
|
|
38
42
|
if (redirectTo) url.searchParams.set("redirectTo", redirectTo);
|
package/src/styles/global.css
CHANGED
|
@@ -9,25 +9,12 @@
|
|
|
9
9
|
@custom-variant dark (&:where(.dark, .dark *));
|
|
10
10
|
@custom-variant light (html:not(.dark) &);
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@import "@fontsource/ibm-plex-sans/400.css";
|
|
19
|
-
@import "@fontsource/ibm-plex-sans/400-italic.css";
|
|
20
|
-
@import "@fontsource/ibm-plex-sans/500.css";
|
|
21
|
-
@import "@fontsource/ibm-plex-sans/600.css";
|
|
22
|
-
@import "@fontsource/ibm-plex-sans/700.css";
|
|
23
|
-
@import "@fontsource/ibm-plex-mono/400.css";
|
|
24
|
-
@import "@fontsource/ibm-plex-mono/400-italic.css";
|
|
25
|
-
@import "@fontsource/ibm-plex-mono/500.css";
|
|
26
|
-
@import "@fontsource/ibm-plex-mono/600.css";
|
|
27
|
-
@import "@fontsource/ibm-plex-sans-condensed/400.css";
|
|
28
|
-
@import "@fontsource/ibm-plex-sans-condensed/500.css";
|
|
29
|
-
@import "@fontsource/ibm-plex-sans-condensed/600.css";
|
|
30
|
-
@import "@fontsource/ibm-plex-sans-condensed/700.css";
|
|
12
|
+
/* IBM Plex font-face rules are emitted as `/public/fonts.css` during the core
|
|
13
|
+
build. Keeping them out of global.css avoids Bun inlining the font binaries
|
|
14
|
+
as base64 into the render-blocking stylesheet. */
|
|
15
|
+
/* Tabler icon font rules are emitted as `/public/tabler-icons.css` during the
|
|
16
|
+
core build and preloaded by the shared HTML template to reduce icon flicker
|
|
17
|
+
without changing icon layout semantics. */
|
|
31
18
|
|
|
32
19
|
@import "./tokens.css";
|
|
33
20
|
@import "./utilities-buttons.css";
|
|
@@ -174,7 +161,7 @@
|
|
|
174
161
|
beat stdlib's one-class embedded <style> the same way the dark-mode
|
|
175
162
|
overrides above do. font-family is otherwise unset by stdlib, so the
|
|
176
163
|
chart would inherit the app sans — we pin the numeric labels to the
|
|
177
|
-
platform mono stack (IBM Plex Mono via
|
|
164
|
+
platform mono stack (IBM Plex Mono via `/public/fonts.css`). */
|
|
178
165
|
.stdlib-chart .stdlib-chart-tick-label,
|
|
179
166
|
.stdlib-chart .stdlib-chart-bar-value,
|
|
180
167
|
.stdlib-chart .stdlib-chart-axis-label {
|
|
@@ -146,8 +146,8 @@
|
|
|
146
146
|
4. `text-rendering: geometricPrecision` — disables kerning so
|
|
147
147
|
even non-monospace fallback fonts don't shift glyph X
|
|
148
148
|
positions across layers. */
|
|
149
|
-
/* IBM Plex Mono is shipped via
|
|
150
|
-
+ 600 weights
|
|
149
|
+
/* IBM Plex Mono is shipped via `/public/fonts.css` (regular + italic +
|
|
150
|
+
500 + 600 weights). Pinning the font here means the
|
|
151
151
|
editor looks identical across OSes and we control the italic
|
|
152
152
|
variant precisely. The bold/heading "weight" is faked via
|
|
153
153
|
text-shadow further down so we never actually request a weight
|