@trylight/app 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -4
- package/dist/client.d.ts +1 -0
- package/dist/client.js +1 -0
- package/dist/images.d.ts +7 -0
- package/dist/images.js +12 -0
- package/dist/schema.d.ts +84 -0
- package/dist/schema.js +16 -2
- package/dist/server/images.d.ts +40 -0
- package/dist/server/images.js +246 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +4 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -7,9 +7,9 @@ unchanged; entities and the schema sync are new here.
|
|
|
7
7
|
|
|
8
8
|
| Import | What |
|
|
9
9
|
|---|---|
|
|
10
|
-
| `@trylight/app/schema` | `entity(name, columns, access)`: a Drizzle table plus `id`, `created_at`, `updated_at`, `created_by`, `field_sources`; `lightChanges` (the audit table); `fieldSource` (the `{url, evidence}` schema) |
|
|
11
|
-
| `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed)
|
|
12
|
-
| `@trylight/app/server` | `createLightServer({ schema, contracts })` → `light.db` (Drizzle), `light.session(request)`, `light.signIn(email, password)`, and the route handlers `light.entities`, `light.invoke.handler`, `light.chat` |
|
|
10
|
+
| `@trylight/app/schema` | `entity(name, columns, access)`: a Drizzle table plus `id`, `created_at`, `updated_at`, `created_by`, `field_sources`; `lightChanges` (the audit table); `lightImages` (the app's copy of its logos and photos); `fieldSource` (the `{url, evidence}` schema) |
|
|
11
|
+
| `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed), `app.contracts` (`useInvocation`), and `imageSrc(url)` (where a logo or photo loads from) |
|
|
12
|
+
| `@trylight/app/server` | `createLightServer({ schema, contracts })` → `light.db` (Drizzle), `light.session(request)`, `light.signIn(email, password)`, and the route handlers `light.entities`, `light.image`, `light.invoke.handler`, `light.chat` |
|
|
13
13
|
| `@trylight/app/platform` | the door for server functions, framing, analytics/error policies, the pane bridge |
|
|
14
14
|
| `@trylight/app/vite` | `lightDb()`: the dev server syncs `db/schema` into the database on every change |
|
|
15
15
|
| `light-db` (bin) | `sync`, `migrate`, `check`: the app's `pnpm db:*` scripts |
|
|
@@ -33,6 +33,17 @@ cached list and row at once and stays pending until the table's refetch lands
|
|
|
33
33
|
|
|
34
34
|
Joins and aggregates stay typed server functions over `light.db`.
|
|
35
35
|
|
|
36
|
+
## Images
|
|
37
|
+
|
|
38
|
+
`/api/light/image?src=…` serves a logo or photo a row points at from the
|
|
39
|
+
app's own copy in `light_images`: the first view by a member (or any view
|
|
40
|
+
with no sign-in configured) fetches and keeps it, and every later one, and
|
|
41
|
+
the CDN for a year, is the copy. Only public addresses are connected to (checked on the resolved address, each
|
|
42
|
+
redirect hop included); images only (SVG served sandboxed, `nosniff`), size-
|
|
43
|
+
and time-capped. A source that gives no image is a `no-store` 404, so the
|
|
44
|
+
mark shows its letters and the next view asks again. Members-only apps need
|
|
45
|
+
sign-in; a stranger to a public app only reads what members kept.
|
|
46
|
+
|
|
36
47
|
## Schema sync
|
|
37
48
|
|
|
38
49
|
`plan()` diffs `db/schema` against the newest migration snapshot with
|
|
@@ -58,4 +69,6 @@ TEST_DATABASE_URL=postgres://you@127.0.0.1:5432/postgres pnpm test # with the
|
|
|
58
69
|
pnpm run build # dist/, what the template installs
|
|
59
70
|
```
|
|
60
71
|
|
|
61
|
-
|
|
72
|
+
Published to npm; the template installs it from there, so a change here
|
|
73
|
+
reaches new apps once it is published and the template's lockfile takes it.
|
|
74
|
+
To publish: bump `version`, then `pnpm publish` (`prepack` builds `dist`).
|
package/dist/client.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Entity } from "./schema.js";
|
|
|
4
4
|
export { EntityError, LIST_LIMIT, type ListOptions, type NewRow, type Sort, type Patch, type Row, type Where } from "./entities.js";
|
|
5
5
|
export type { FieldSource, FieldSources } from "./schema.js";
|
|
6
6
|
export { siteOf } from "./schema.js";
|
|
7
|
+
export { imageSrc } from "./images.js";
|
|
7
8
|
export { InvocationError, type InvocationStatus } from "@shubh90/app-runtime/invoke";
|
|
8
9
|
export declare function createApp<C extends Contracts = Record<never, never>>(): {
|
|
9
10
|
table: typeof tableClient;
|
package/dist/client.js
CHANGED
|
@@ -12,6 +12,7 @@ import { z } from "zod/v4";
|
|
|
12
12
|
import { ENTITIES_PATH, EntityError } from "./entities.js";
|
|
13
13
|
export { EntityError, LIST_LIMIT } from "./entities.js";
|
|
14
14
|
export { siteOf } from "./schema.js";
|
|
15
|
+
export { imageSrc } from "./images.js";
|
|
15
16
|
export { InvocationError } from "@shubh90/app-runtime/invoke";
|
|
16
17
|
export function createApp() {
|
|
17
18
|
return { table: tableClient, contracts: createInvoker() };
|
package/dist/images.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const IMAGE_PATH = "/api/light/image";
|
|
2
|
+
/**
|
|
3
|
+
* Where a row's photo or logo loads from: the app's own copy (kept from the
|
|
4
|
+
* first view on) for an address on the web, the value itself for one of the
|
|
5
|
+
* app's own files.
|
|
6
|
+
*/
|
|
7
|
+
export declare function imageSrc(src: string): string;
|
package/dist/images.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// What the browser and the image route agree on: where a row's photo or logo
|
|
2
|
+
// loads from. Nothing here may touch a database or the DOM.
|
|
3
|
+
import { webUrl } from "./schema.js";
|
|
4
|
+
export const IMAGE_PATH = "/api/light/image";
|
|
5
|
+
/**
|
|
6
|
+
* Where a row's photo or logo loads from: the app's own copy (kept from the
|
|
7
|
+
* first view on) for an address on the web, the value itself for one of the
|
|
8
|
+
* app's own files.
|
|
9
|
+
*/
|
|
10
|
+
export function imageSrc(src) {
|
|
11
|
+
return webUrl.safeParse(src).success ? `${IMAGE_PATH}?src=${encodeURIComponent(src)}` : src;
|
|
12
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { PgTable, type PgColumnBuilderBase, type PgTableWithColumns, type PgTableExtraConfigValue } from "drizzle-orm/pg-core";
|
|
2
2
|
import type { BuildColumns, BuildExtraConfigColumns } from "drizzle-orm";
|
|
3
3
|
import { z } from "zod/v4";
|
|
4
|
+
/** An address on the web: http or https. */
|
|
5
|
+
export declare const webUrl: z.ZodURL;
|
|
4
6
|
/** Where one value was read: the page, and the words on it that say so. */
|
|
5
7
|
export declare const fieldSource: z.ZodObject<{
|
|
6
8
|
url: z.ZodURL;
|
|
@@ -239,4 +241,86 @@ export declare const lightChanges: PgTableWithColumns<{
|
|
|
239
241
|
};
|
|
240
242
|
dialect: "pg";
|
|
241
243
|
}>;
|
|
244
|
+
/**
|
|
245
|
+
* The app's own copy of each photo or logo its rows point at, kept on the
|
|
246
|
+
* first view (the image route, `@trylight/app/server`), so a source that
|
|
247
|
+
* moves or shuts down later does not take the picture with it.
|
|
248
|
+
*/
|
|
249
|
+
export declare const lightImages: PgTableWithColumns<{
|
|
250
|
+
name: "light_images";
|
|
251
|
+
schema: undefined;
|
|
252
|
+
columns: {
|
|
253
|
+
source_url: import("drizzle-orm/pg-core").PgColumn<{
|
|
254
|
+
name: "source_url";
|
|
255
|
+
tableName: "light_images";
|
|
256
|
+
dataType: "string";
|
|
257
|
+
columnType: "PgText";
|
|
258
|
+
data: string;
|
|
259
|
+
driverParam: string;
|
|
260
|
+
notNull: true;
|
|
261
|
+
hasDefault: false;
|
|
262
|
+
isPrimaryKey: true;
|
|
263
|
+
isAutoincrement: false;
|
|
264
|
+
hasRuntimeDefault: false;
|
|
265
|
+
enumValues: [string, ...string[]];
|
|
266
|
+
baseColumn: never;
|
|
267
|
+
identity: undefined;
|
|
268
|
+
generated: undefined;
|
|
269
|
+
}, {}, {}>;
|
|
270
|
+
content_type: import("drizzle-orm/pg-core").PgColumn<{
|
|
271
|
+
name: "content_type";
|
|
272
|
+
tableName: "light_images";
|
|
273
|
+
dataType: "string";
|
|
274
|
+
columnType: "PgText";
|
|
275
|
+
data: string;
|
|
276
|
+
driverParam: string;
|
|
277
|
+
notNull: true;
|
|
278
|
+
hasDefault: false;
|
|
279
|
+
isPrimaryKey: false;
|
|
280
|
+
isAutoincrement: false;
|
|
281
|
+
hasRuntimeDefault: false;
|
|
282
|
+
enumValues: [string, ...string[]];
|
|
283
|
+
baseColumn: never;
|
|
284
|
+
identity: undefined;
|
|
285
|
+
generated: undefined;
|
|
286
|
+
}, {}, {}>;
|
|
287
|
+
bytes: import("drizzle-orm/pg-core").PgColumn<{
|
|
288
|
+
name: "bytes";
|
|
289
|
+
tableName: "light_images";
|
|
290
|
+
dataType: "custom";
|
|
291
|
+
columnType: "PgCustomColumn";
|
|
292
|
+
data: Uint8Array<ArrayBufferLike>;
|
|
293
|
+
driverParam: unknown;
|
|
294
|
+
notNull: true;
|
|
295
|
+
hasDefault: false;
|
|
296
|
+
isPrimaryKey: false;
|
|
297
|
+
isAutoincrement: false;
|
|
298
|
+
hasRuntimeDefault: false;
|
|
299
|
+
enumValues: undefined;
|
|
300
|
+
baseColumn: never;
|
|
301
|
+
identity: undefined;
|
|
302
|
+
generated: undefined;
|
|
303
|
+
}, {}, {
|
|
304
|
+
pgColumnBuilderBrand: "PgCustomColumnBuilderBrand";
|
|
305
|
+
}>;
|
|
306
|
+
fetched_at: import("drizzle-orm/pg-core").PgColumn<{
|
|
307
|
+
name: "fetched_at";
|
|
308
|
+
tableName: "light_images";
|
|
309
|
+
dataType: "string";
|
|
310
|
+
columnType: "PgTimestampString";
|
|
311
|
+
data: string;
|
|
312
|
+
driverParam: string;
|
|
313
|
+
notNull: true;
|
|
314
|
+
hasDefault: true;
|
|
315
|
+
isPrimaryKey: false;
|
|
316
|
+
isAutoincrement: false;
|
|
317
|
+
hasRuntimeDefault: false;
|
|
318
|
+
enumValues: undefined;
|
|
319
|
+
baseColumn: never;
|
|
320
|
+
identity: undefined;
|
|
321
|
+
generated: undefined;
|
|
322
|
+
}, {}, {}>;
|
|
323
|
+
};
|
|
324
|
+
dialect: "pg";
|
|
325
|
+
}>;
|
|
242
326
|
export {};
|
package/dist/schema.js
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// the app. Imported by drizzle-kit and the browser's types, so nothing here
|
|
4
4
|
// may touch a database or the DOM.
|
|
5
5
|
import { is } from "drizzle-orm";
|
|
6
|
-
import { PgTable, index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
6
|
+
import { PgTable, customType, index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
7
7
|
import { z } from "zod/v4";
|
|
8
|
+
/** An address on the web: http or https. */
|
|
9
|
+
export const webUrl = z.url({ protocol: /^https?$/ });
|
|
8
10
|
/** Where one value was read: the page, and the words on it that say so. */
|
|
9
11
|
export const fieldSource = z.object({
|
|
10
|
-
url:
|
|
12
|
+
url: webUrl,
|
|
11
13
|
evidence: z.string().min(1)
|
|
12
14
|
});
|
|
13
15
|
/** The site a source's url names, as people read it: "https://www.acme.com/about" is "acme.com". */
|
|
@@ -79,3 +81,15 @@ export const lightChanges = pgTable("light_changes", {
|
|
|
79
81
|
.notNull()
|
|
80
82
|
.defaultNow()
|
|
81
83
|
}, (t) => [index("light_changes_row_idx").on(t.table_name, t.row_id)]);
|
|
84
|
+
const bytea = customType({ dataType: () => "bytea" });
|
|
85
|
+
/**
|
|
86
|
+
* The app's own copy of each photo or logo its rows point at, kept on the
|
|
87
|
+
* first view (the image route, `@trylight/app/server`), so a source that
|
|
88
|
+
* moves or shuts down later does not take the picture with it.
|
|
89
|
+
*/
|
|
90
|
+
export const lightImages = pgTable("light_images", {
|
|
91
|
+
source_url: text().primaryKey(),
|
|
92
|
+
content_type: text().notNull(),
|
|
93
|
+
bytes: bytea().notNull(),
|
|
94
|
+
fetched_at: timestamp({ withTimezone: true, mode: "string" }).notNull().defaultNow()
|
|
95
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type LookupFunction } from "node:net";
|
|
2
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
3
|
+
import type { SessionState } from "./session.js";
|
|
4
|
+
type Db = PostgresJsDatabase<Record<string, unknown>>;
|
|
5
|
+
export declare const MAX_IMAGE_BYTES: number;
|
|
6
|
+
export type StoredImage = {
|
|
7
|
+
readonly contentType: string;
|
|
8
|
+
readonly bytes: Uint8Array;
|
|
9
|
+
};
|
|
10
|
+
export type ImageLookup = {
|
|
11
|
+
readonly ok: true;
|
|
12
|
+
readonly image: StoredImage;
|
|
13
|
+
} | {
|
|
14
|
+
readonly ok: false;
|
|
15
|
+
readonly reason: string;
|
|
16
|
+
};
|
|
17
|
+
export type ImageOptions = {
|
|
18
|
+
db: () => Db;
|
|
19
|
+
session: (request: Request) => Promise<SessionState>;
|
|
20
|
+
};
|
|
21
|
+
/** `/api/light/image?src=…`: a photo or logo a row points at, served from the app's own copy. */
|
|
22
|
+
export declare function createImageHandler(options: ImageOptions): (request: Request) => Promise<Response>;
|
|
23
|
+
/**
|
|
24
|
+
* An address on the public internet: not this machine, a private or carrier
|
|
25
|
+
* network, link-local (the cloud metadata address), multicast or reserved.
|
|
26
|
+
* An IPv4 address written as IPv6 is judged as itself.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isPublicAddress(address: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The name lookup of every connection the image fetch opens, redirects
|
|
31
|
+
* included: the address checked is the address connected to, so a public
|
|
32
|
+
* name that resolves (or is re-pointed between two asks) to this machine or
|
|
33
|
+
* a private network is never reached.
|
|
34
|
+
*/
|
|
35
|
+
export declare const publicOnlyLookup: LookupFunction;
|
|
36
|
+
/** The app's copy of the image at `source`, if a member's view has kept one. */
|
|
37
|
+
export declare function keptImage(db: Db, source: URL): Promise<ImageLookup>;
|
|
38
|
+
/** The app's copy of the image at `source`, fetched and kept on the first ask. */
|
|
39
|
+
export declare function storedImage(db: Db, source: URL): Promise<ImageLookup>;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// The app's copy of the photos and logos its rows point at. The first time a
|
|
2
|
+
// member's browser asks the image route for one it is fetched and kept in
|
|
3
|
+
// light_images; every later ask is answered from there, so a source that
|
|
4
|
+
// rate-limits, moves or shuts down after that does not take the picture with
|
|
5
|
+
// it. Ported from the v1 template (#1063).
|
|
6
|
+
import dns from "node:dns";
|
|
7
|
+
import { isIP } from "node:net";
|
|
8
|
+
import { eq } from "drizzle-orm";
|
|
9
|
+
import ipaddr from "ipaddr.js";
|
|
10
|
+
import { Agent } from "undici";
|
|
11
|
+
import { z } from "zod/v4";
|
|
12
|
+
import { lightImages, webUrl } from "../schema.js";
|
|
13
|
+
import { env } from "./env.js";
|
|
14
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
15
|
+
const MAX_REDIRECTS = 5;
|
|
16
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
17
|
+
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
18
|
+
const YEAR_SECONDS = 365 * 24 * 60 * 60;
|
|
19
|
+
// An SVG is a document if someone navigates to it; the route's sandbox
|
|
20
|
+
// header keeps it inert there, and in an <img> it never runs script.
|
|
21
|
+
const IMAGE_TYPES = new Set([
|
|
22
|
+
"image/png",
|
|
23
|
+
"image/jpeg",
|
|
24
|
+
"image/gif",
|
|
25
|
+
"image/webp",
|
|
26
|
+
"image/avif",
|
|
27
|
+
"image/svg+xml",
|
|
28
|
+
"image/x-icon",
|
|
29
|
+
"image/vnd.microsoft.icon"
|
|
30
|
+
]);
|
|
31
|
+
// The bytes are an image or they are nothing, and an SVG opened on its own stays inert.
|
|
32
|
+
const INERT = {
|
|
33
|
+
"Content-Security-Policy": "sandbox",
|
|
34
|
+
"X-Content-Type-Options": "nosniff"
|
|
35
|
+
};
|
|
36
|
+
// Node's fetch reports a DNS or connection failure as "fetch failed", with the
|
|
37
|
+
// reason (ENOTFOUND, ECONNREFUSED…) only on its cause.
|
|
38
|
+
const networkFailure = z.object({ cause: z.object({ code: z.string() }) });
|
|
39
|
+
/** `/api/light/image?src=…`: a photo or logo a row points at, served from the app's own copy. */
|
|
40
|
+
export function createImageHandler(options) {
|
|
41
|
+
return async (request) => {
|
|
42
|
+
const { user, mode } = await options.session(request);
|
|
43
|
+
if (user === null && mode === "members") {
|
|
44
|
+
return new Response("Sign in to use this app.", { status: 401 });
|
|
45
|
+
}
|
|
46
|
+
const src = webUrl.safeParse(new URL(request.url).searchParams.get("src"));
|
|
47
|
+
if (!src.success) {
|
|
48
|
+
return new Response("src must be an http(s) address.", { status: 400 });
|
|
49
|
+
}
|
|
50
|
+
const db = options.db();
|
|
51
|
+
const source = new URL(src.data);
|
|
52
|
+
// A stranger to a public app only reads the copy, so nobody signed out can
|
|
53
|
+
// make the app fetch or keep anything; a member's view fills it, as does
|
|
54
|
+
// every view where there is no sign-in to be a stranger to.
|
|
55
|
+
const stranger = user === null && env().configured;
|
|
56
|
+
const lookup = stranger ? keptImage : storedImage;
|
|
57
|
+
const found = await lookup(db, source);
|
|
58
|
+
if (!found.ok) {
|
|
59
|
+
// Nothing is kept for a source that did not answer with an image, so the
|
|
60
|
+
// next view asks it again; until then the row shows its letters.
|
|
61
|
+
return new Response(found.reason, {
|
|
62
|
+
status: 404,
|
|
63
|
+
headers: { ...INERT, "Cache-Control": "no-store" }
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// Cached by the CDN under the source address, which only someone who
|
|
67
|
+
// already knows it can ask for; the pictures are the public web's.
|
|
68
|
+
// A copy: the driver's buffer is typed over any ArrayBufferLike, a body needs one over ArrayBuffer.
|
|
69
|
+
return new Response(new Uint8Array(found.image.bytes), {
|
|
70
|
+
headers: {
|
|
71
|
+
...INERT,
|
|
72
|
+
"Content-Type": found.image.contentType,
|
|
73
|
+
"Cache-Control": `public, max-age=${YEAR_SECONDS}, immutable`
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* An address on the public internet: not this machine, a private or carrier
|
|
80
|
+
* network, link-local (the cloud metadata address), multicast or reserved.
|
|
81
|
+
* An IPv4 address written as IPv6 is judged as itself.
|
|
82
|
+
*/
|
|
83
|
+
export function isPublicAddress(address) {
|
|
84
|
+
return ipaddr.isValid(address) && ipaddr.process(address).range() === "unicast";
|
|
85
|
+
}
|
|
86
|
+
class NotPublicAddress extends Error {
|
|
87
|
+
address;
|
|
88
|
+
code = "ENOTPUBLIC";
|
|
89
|
+
constructor(address) {
|
|
90
|
+
super(`${address} is not a public address.`);
|
|
91
|
+
this.address = address;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The name lookup of every connection the image fetch opens, redirects
|
|
96
|
+
* included: the address checked is the address connected to, so a public
|
|
97
|
+
* name that resolves (or is re-pointed between two asks) to this machine or
|
|
98
|
+
* a private network is never reached.
|
|
99
|
+
*/
|
|
100
|
+
export const publicOnlyLookup = (hostname, options, callback) => {
|
|
101
|
+
dns.lookup(hostname, options, (error, address, family) => {
|
|
102
|
+
if (error !== null)
|
|
103
|
+
return callback(error, address, family);
|
|
104
|
+
const addresses = Array.isArray(address) ? address.map((found) => found.address) : [address];
|
|
105
|
+
const [refused = null] = addresses.filter((found) => !isPublicAddress(found));
|
|
106
|
+
if (refused !== null)
|
|
107
|
+
return callback(new NotPublicAddress(refused), address, family);
|
|
108
|
+
callback(null, address, family);
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
// Connects only to public addresses. A bare address in a URL skips the name
|
|
112
|
+
// lookup, so fetchImage checks that one itself.
|
|
113
|
+
const publicWeb = new Agent({ connect: { lookup: publicOnlyLookup } });
|
|
114
|
+
const refusedAddress = z.object({ cause: z.instanceof(NotPublicAddress) });
|
|
115
|
+
// A fragment never reaches the server, so two addresses that differ only
|
|
116
|
+
// there are one image.
|
|
117
|
+
function keyOf(source) {
|
|
118
|
+
const key = new URL(source);
|
|
119
|
+
key.hash = "";
|
|
120
|
+
return key;
|
|
121
|
+
}
|
|
122
|
+
/** The app's copy of the image at `source`, if a member's view has kept one. */
|
|
123
|
+
export async function keptImage(db, source) {
|
|
124
|
+
const [kept = null] = await db
|
|
125
|
+
.select({ contentType: lightImages.content_type, bytes: lightImages.bytes })
|
|
126
|
+
.from(lightImages)
|
|
127
|
+
.where(eq(lightImages.source_url, keyOf(source).href));
|
|
128
|
+
if (kept === null)
|
|
129
|
+
return { ok: false, reason: "No copy of this image is kept yet." };
|
|
130
|
+
return { ok: true, image: kept };
|
|
131
|
+
}
|
|
132
|
+
/** The app's copy of the image at `source`, fetched and kept on the first ask. */
|
|
133
|
+
export async function storedImage(db, source) {
|
|
134
|
+
const kept = await keptImage(db, source);
|
|
135
|
+
if (kept.ok)
|
|
136
|
+
return kept;
|
|
137
|
+
const key = keyOf(source);
|
|
138
|
+
const fetched = await fetchImage(key);
|
|
139
|
+
if (!fetched.ok)
|
|
140
|
+
return fetched;
|
|
141
|
+
await db
|
|
142
|
+
.insert(lightImages)
|
|
143
|
+
.values({ source_url: key.href, content_type: fetched.image.contentType, bytes: fetched.image.bytes })
|
|
144
|
+
.onConflictDoNothing();
|
|
145
|
+
return fetched;
|
|
146
|
+
}
|
|
147
|
+
function failureReason(error) {
|
|
148
|
+
const failure = networkFailure.safeParse(error);
|
|
149
|
+
return failure.success ? failure.data.cause.code : String(error);
|
|
150
|
+
}
|
|
151
|
+
async function fetchImage(source) {
|
|
152
|
+
const host = source.hostname.toLowerCase();
|
|
153
|
+
// Each hop is checked: a redirect must never be the way to make this app
|
|
154
|
+
// fetch from somewhere it would not fetch itself.
|
|
155
|
+
let url = source;
|
|
156
|
+
let response;
|
|
157
|
+
for (let hop = 0;; hop += 1) {
|
|
158
|
+
const at = url.hostname.toLowerCase();
|
|
159
|
+
const notPublic = (detail) => ({
|
|
160
|
+
ok: false,
|
|
161
|
+
reason: hop === 0
|
|
162
|
+
? `${at} is not a public web address${detail}.`
|
|
163
|
+
: `${host} redirected to ${at}, which is not a public web address${detail}.`
|
|
164
|
+
});
|
|
165
|
+
// An IPv6 host is bracketed in a URL.
|
|
166
|
+
const bare = at.replace(/^\[(.*)\]$/, "$1");
|
|
167
|
+
if (isIP(bare) !== 0 && !isPublicAddress(bare))
|
|
168
|
+
return notPublic("");
|
|
169
|
+
const init = {
|
|
170
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
171
|
+
redirect: "manual",
|
|
172
|
+
dispatcher: publicWeb
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
response = await fetch(url, init);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const refused = refusedAddress.safeParse(error);
|
|
179
|
+
if (refused.success)
|
|
180
|
+
return notPublic(` (it resolves to ${refused.data.cause.address})`);
|
|
181
|
+
return { ok: false, reason: `${at} could not be reached (${failureReason(error)}).` };
|
|
182
|
+
}
|
|
183
|
+
const location = response.headers.get("location");
|
|
184
|
+
if (location === null || !REDIRECT_STATUSES.has(response.status))
|
|
185
|
+
break;
|
|
186
|
+
if (response.body !== null)
|
|
187
|
+
await response.body.cancel();
|
|
188
|
+
if (hop === MAX_REDIRECTS)
|
|
189
|
+
return { ok: false, reason: `${host} redirected too many times.` };
|
|
190
|
+
url = new URL(location, url);
|
|
191
|
+
}
|
|
192
|
+
if (!response.ok)
|
|
193
|
+
return { ok: false, reason: `${host} answered HTTP ${response.status}.` };
|
|
194
|
+
const contentType = mediaType(response.headers.get("content-type"));
|
|
195
|
+
if (!IMAGE_TYPES.has(contentType)) {
|
|
196
|
+
return { ok: false, reason: `${host} answered with ${contentType || "no content type"}, not an image.` };
|
|
197
|
+
}
|
|
198
|
+
let bytes;
|
|
199
|
+
try {
|
|
200
|
+
bytes = await readCapped(response, MAX_IMAGE_BYTES);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
return { ok: false, reason: `${host} stopped answering before the image was read (${failureReason(error)}).` };
|
|
204
|
+
}
|
|
205
|
+
if (bytes === null)
|
|
206
|
+
return { ok: false, reason: `${host}'s image is over ${MAX_IMAGE_BYTES} bytes.` };
|
|
207
|
+
// An empty 200 is what a rate-limiter or a CDN edge answers with; kept, it
|
|
208
|
+
// would be served as the picture for a year.
|
|
209
|
+
if (bytes.byteLength === 0)
|
|
210
|
+
return { ok: false, reason: `${host} answered with an empty body.` };
|
|
211
|
+
return { ok: true, image: { contentType, bytes } };
|
|
212
|
+
}
|
|
213
|
+
/** "image/png; charset=binary" is "image/png"; a response with none has "". */
|
|
214
|
+
function mediaType(header) {
|
|
215
|
+
if (header === null)
|
|
216
|
+
return "";
|
|
217
|
+
return header.replace(/;.*$/, "").trim().toLowerCase();
|
|
218
|
+
}
|
|
219
|
+
// Read at most `limit` bytes, abandoning the response the moment it exceeds
|
|
220
|
+
// them: content-length is advisory, so the cap is applied while reading.
|
|
221
|
+
async function readCapped(response, limit) {
|
|
222
|
+
const stream = response.body;
|
|
223
|
+
if (stream === null)
|
|
224
|
+
return new Uint8Array(0);
|
|
225
|
+
const reader = stream.getReader();
|
|
226
|
+
const chunks = [];
|
|
227
|
+
let total = 0;
|
|
228
|
+
for (;;) {
|
|
229
|
+
const { done, value } = await reader.read();
|
|
230
|
+
if (done)
|
|
231
|
+
break;
|
|
232
|
+
total += value.byteLength;
|
|
233
|
+
if (total > limit) {
|
|
234
|
+
await reader.cancel();
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
chunks.push(value);
|
|
238
|
+
}
|
|
239
|
+
const out = new Uint8Array(total);
|
|
240
|
+
let at = 0;
|
|
241
|
+
for (const chunk of chunks) {
|
|
242
|
+
out.set(chunk, at);
|
|
243
|
+
at += chunk.byteLength;
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export declare function createLightServer<S extends Record<string, unknown>, C e
|
|
|
18
18
|
readonly db: PostgresJsDatabase<S>;
|
|
19
19
|
/** `/api/light/entities/*`: what `app.table(...)` calls. */
|
|
20
20
|
entities: (request: Request) => Promise<Response>;
|
|
21
|
+
/** `/api/light/image`: a row's photo or logo, from the app's own copy (kept on the first view). */
|
|
22
|
+
image: (request: Request) => Promise<Response>;
|
|
21
23
|
/** Server-side starts and reads of the app's contracts. */
|
|
22
24
|
invoke: import("@shubh90/app-runtime/invoke/server").MiiInvoke<C>;
|
|
23
25
|
/** `/api/mii/invoke/*`: the contract buttons, forwarded to Plaza as the person. */
|
package/dist/server/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The app's server half, bound once in `src/server/light.ts`: its database,
|
|
2
2
|
// its door (sign-in through Plaza), and the routes the SDK serves (entities,
|
|
3
|
-
// contracts, chat). Server-only.
|
|
3
|
+
// images, contracts, chat). Server-only.
|
|
4
4
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
5
5
|
import postgres from "postgres";
|
|
6
6
|
import { createMiiAuthCore } from "@shubh90/app-runtime/auth/core";
|
|
@@ -8,6 +8,7 @@ import { createMiiChatHandler } from "@shubh90/app-runtime/chat/server";
|
|
|
8
8
|
import { createMiiInvoke } from "@shubh90/app-runtime/invoke/server";
|
|
9
9
|
import { createEntitiesHandler } from "./entities.js";
|
|
10
10
|
import { env } from "./env.js";
|
|
11
|
+
import { createImageHandler } from "./images.js";
|
|
11
12
|
import { readSession, signIn } from "./session.js";
|
|
12
13
|
export { cookieAttributes, safeNext, SESSION_COOKIE } from "@shubh90/app-runtime/auth/core";
|
|
13
14
|
export { defineContracts, InvocationError } from "@shubh90/app-runtime/invoke/server";
|
|
@@ -42,6 +43,8 @@ export function createLightServer(options) {
|
|
|
42
43
|
},
|
|
43
44
|
/** `/api/light/entities/*`: what `app.table(...)` calls. */
|
|
44
45
|
entities: createEntitiesHandler({ schema: options.schema, db, session }),
|
|
46
|
+
/** `/api/light/image`: a row's photo or logo, from the app's own copy (kept on the first view). */
|
|
47
|
+
image: createImageHandler({ db, session }),
|
|
45
48
|
/** Server-side starts and reads of the app's contracts. */
|
|
46
49
|
invoke,
|
|
47
50
|
/** `/api/mii/invoke/*`: the contract buttons, forwarded to Plaza as the person. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trylight/app",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "The Light SDK every v2 org app depends on: entities (Drizzle tables with a typed client), sign-in, contracts and in-app chat.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -54,7 +54,9 @@
|
|
|
54
54
|
"@shubh90/app-runtime": "^0.10.3",
|
|
55
55
|
"drizzle-kit": "0.31.11",
|
|
56
56
|
"drizzle-zod": "^0.8.3",
|
|
57
|
-
"
|
|
57
|
+
"ipaddr.js": "^2.5.0",
|
|
58
|
+
"tsx": "^4.23.15",
|
|
59
|
+
"undici": "^7.30.0"
|
|
58
60
|
},
|
|
59
61
|
"devDependencies": {
|
|
60
62
|
"@eslint-community/eslint-plugin-eslint-comments": "^4.8.1",
|