@protobase/server 0.15.1 → 0.15.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.
@@ -1,4 +1,6 @@
1
1
  import { badRequest } from "../problem.js";
2
+ import { accepts } from "./media-types.js";
3
+ import { externalFile, parseExternalUrl } from "./external-url.js";
2
4
  import { baseOf, parseUri, variantPaths } from "./uri.js";
3
5
  import { tenantScope } from "../tenant.js";
4
6
  import { readTicket } from "./ticket.js";
@@ -11,10 +13,15 @@ const ticketScope = (access, field) => ({
11
13
  user: String(access.session.user.id)
12
14
  });
13
15
  const fileFields = (access) => Object.values(access.full.fields).filter((field) => field.file);
16
+ const externalRefusal = (field, url) => {
17
+ const type = externalFile(url).type ?? "application/octet-stream";
18
+ return accepts(field.file.accept, type) ? void 0 : `This field takes ${field.file.accept.join(", ")}; the URL's extension gives ${type}`;
19
+ };
14
20
  /**
15
21
  * File values of a validated create or update body, made ready to store: a ticket from `:upload` becomes its clean URI
16
22
  * plus the derived fields it carries, `null` clears the field and its derived fields, and the stored value itself is a
17
- * no-op. Anything else is refused, so a row can only point at a file its writer uploaded for that field.
23
+ * no-op. A field with `.externalUrls()` also stores an https URL as it is, its derived fields `null`. Anything else is
24
+ * refused, so a row can only point at a file its writer uploaded for that field, or at one elsewhere.
18
25
  */
19
26
  const attachFiles = async (access, values, current) => {
20
27
  const names = Object.keys(values).filter((name) => access.full.fields[name]?.file);
@@ -35,13 +42,28 @@ const attachFiles = async (access, values, current) => {
35
42
  delete result[name];
36
43
  continue;
37
44
  }
45
+ const external = field.file.external ? parseExternalUrl(String(value)) : void 0;
46
+ if (external) {
47
+ const refusal = externalRefusal(field, external);
48
+ if (refusal) {
49
+ errors.push({
50
+ field: name,
51
+ code: "invalid-value",
52
+ message: refusal
53
+ });
54
+ continue;
55
+ }
56
+ result[name] = value;
57
+ for (const target of derive) result[target] = null;
58
+ continue;
59
+ }
38
60
  const ticket = await readTicket(files.signer, String(value), ticketScope(access, name), now);
39
61
  if (!ticket.ok || ticket.ref.provider !== field.file.provider) {
40
- const expired = !ticket.ok && ticket.reason === "expired";
62
+ const message = !ticket.ok && ticket.reason === "expired" ? "The upload expired; upload the file again" : field.file.external ? "Neither a file uploaded for this field nor an https URL without credentials" : "Not a file uploaded for this field; upload it with :upload first";
41
63
  errors.push({
42
64
  field: name,
43
65
  code: "invalid-value",
44
- message: expired ? "The upload expired; upload the file again" : "Not a file uploaded for this field; upload it with :upload first"
66
+ message
45
67
  });
46
68
  continue;
47
69
  }
@@ -0,0 +1,34 @@
1
+ import { typeOfExtension } from "./media-types.js";
2
+ //#region src/files/external-url.ts
3
+ const urlText = /^[\x21-\x7e]+$/;
4
+ const scheme = "https://";
5
+ /**
6
+ * The URL an external file value names: an absolute `https:` URL with a host and no user name or password, written
7
+ * out without spaces or control characters, its host first after `https://`; `undefined` for anything else, a provider
8
+ * URI included.
9
+ */
10
+ const parseExternalUrl = (value) => {
11
+ if (!value.startsWith(scheme) || !urlText.test(value) || !URL.canParse(value)) return void 0;
12
+ const url = new URL(value);
13
+ if (url.username !== "" || url.password !== "" || !value.slice(8).toLowerCase().startsWith(url.host)) return void 0;
14
+ return url;
15
+ };
16
+ const lastSegment = (path) => path.slice(path.lastIndexOf("/") + 1);
17
+ const decoded = (segment) => {
18
+ try {
19
+ return decodeURIComponent(segment);
20
+ } catch (error) {
21
+ if (error instanceof URIError) return segment;
22
+ throw error;
23
+ }
24
+ };
25
+ /** An external file's name and type: its path's last segment, else its host, and the type of that segment's extension. */
26
+ const externalFile = (url) => {
27
+ const segment = decoded(lastSegment(url.pathname));
28
+ return {
29
+ name: segment || url.hostname,
30
+ type: typeOfExtension(segment)
31
+ };
32
+ };
33
+ //#endregion
34
+ export { externalFile, parseExternalUrl };
@@ -17,5 +17,10 @@ export type FilesOptions = {
17
17
  * config, so a built bundle carries it with its native add-on.
18
18
  */
19
19
  sharp?: Sharp;
20
+ /**
21
+ * Hosts whose images the admin app draws with the page's referrer, besides the app's own and those its providers link
22
+ * public files from: `['images.example.com']`. Images from any other host load with `referrerPolicy="no-referrer"`.
23
+ */
24
+ trustedHosts?: string[];
20
25
  };
21
26
  //#endregion
@@ -1,5 +1,6 @@
1
1
  import { downloadExpiry, signedDownloadUrl } from "./download-url.js";
2
2
  import { servedType } from "./media-types.js";
3
+ import { externalFile, parseExternalUrl } from "./external-url.js";
3
4
  import { parseUri, variantPaths } from "./uri.js";
4
5
  import { encodeKey } from "@protobase/schema/internal";
5
6
  //#region src/files/present.ts
@@ -18,20 +19,37 @@ const linker = async (files, access, row, field, ref, expires) => {
18
19
  expires
19
20
  });
20
21
  };
22
+ const placeholderOf = (access, row, field) => {
23
+ const target = field.file?.placeholder;
24
+ const placeholder = target !== void 0 && access.model.fields[target] ? row[target] : void 0;
25
+ return typeof placeholder === "string" ? placeholder : void 0;
26
+ };
27
+ const externalObject = (access, row, field, value, url) => {
28
+ const { name, type } = externalFile(url);
29
+ const placeholder = placeholderOf(access, row, field);
30
+ return {
31
+ uri: value,
32
+ name,
33
+ type: type ?? "application/octet-stream",
34
+ url: value,
35
+ ...placeholder !== void 0 && { placeholder }
36
+ };
37
+ };
21
38
  const fileObject = async (files, access, row, field, value, expires) => {
39
+ const external = field.file?.external ? parseExternalUrl(value) : void 0;
40
+ if (external) return externalObject(access, row, field, value, external);
22
41
  const ref = parseUri(value);
23
42
  if (!ref || !files.providers[ref.provider]) return {
24
43
  uri: value,
25
44
  missing: true
26
45
  };
27
- const target = field.file?.placeholder;
28
- const placeholder = target !== void 0 && access.model.fields[target] ? row[target] : void 0;
46
+ const placeholder = placeholderOf(access, row, field);
29
47
  const described = {
30
48
  uri: value,
31
49
  name: ref.name,
32
50
  type: servedType(ref.path),
33
51
  ...ref.size !== void 0 && { size: ref.size },
34
- ...typeof placeholder === "string" && { placeholder }
52
+ ...placeholder !== void 0 && { placeholder }
35
53
  };
36
54
  const link = await linker(files, access, row, field.name, ref, expires);
37
55
  if (!link) return described;
@@ -54,7 +72,8 @@ const fileObject = async (files, access, row, field, value, expires) => {
54
72
  /**
55
73
  * Records as responses carry them: each readable file field as `{ uri, name, type, size, url }`, with its `variants` and
56
74
  * `placeholder` when it has them. The URLs are minted here, after the ETag, for a caller who may already see the row:
57
- * private files get a signed link to this row's field, public ones their permanent address. Stored values, hooks and
75
+ * private files get a signed link to this row's field, public ones their permanent address, and an external URL of a
76
+ * field with `.externalUrls()` is its own `url`. Stored values, hooks and
58
77
  * ETags only ever see the URI.
59
78
  */
60
79
  const presentFiles = async (access, rows) => {
@@ -2,6 +2,7 @@ import { fileColumns, runCleanup } from "./cleanup-runner.js";
2
2
  import { storageCleanupSchedule } from "./cleanup-schedule.js";
3
3
  import { resolveProviders, retentionOf } from "./providers.js";
4
4
  import { createSigner } from "./signer.js";
5
+ import { trustedImageHosts } from "./trusted-hosts.js";
5
6
  //#region src/files/runtime.ts
6
7
  /** How long an upload ticket stays usable, so a form left open still saves. */
7
8
  const ticketSeconds = 86400;
@@ -26,6 +27,7 @@ const createFilesRuntime = ({ options = {}, env, models, systemPath }) => {
26
27
  retention: (provider) => retention(providers[provider]),
27
28
  path: `${systemPath}/files`,
28
29
  columns: fileColumns(models),
30
+ trustedHosts: trustedImageHosts(providers, options.trustedHosts),
29
31
  ...options.sharp && { sharp: options.sharp }
30
32
  };
31
33
  };
@@ -0,0 +1,14 @@
1
+ //#region src/files/trusted-hosts.ts
2
+ const hostPattern = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
3
+ const checkedHost = (host) => {
4
+ if (!hostPattern.test(host)) throw new Error(`files.trustedHosts: "${host}" is not a host name; write it like images.example.com, without https:// or a path`);
5
+ return host;
6
+ };
7
+ const providerHost = (provider) => provider.publicUrl !== void 0 && URL.canParse(provider.publicUrl) ? [new URL(provider.publicUrl).hostname] : [];
8
+ /**
9
+ * The hosts whose images the app draws with the page's referrer, besides its own: those its providers link public files
10
+ * from, and those the config adds. Every other image is drawn with `referrerPolicy="no-referrer"`.
11
+ */
12
+ const trustedImageHosts = (providers, configured = []) => [.../* @__PURE__ */ new Set([...Object.values(providers).flatMap(providerHost), ...configured.map(checkedHost)])].sort();
13
+ //#endregion
14
+ export { trustedImageHosts };
package/dist/meta.js CHANGED
@@ -79,6 +79,7 @@ const createMeta = (deps) => {
79
79
  ...deps.runtime && seesRuntime(session.user.roles) && { runtime: { url: deps.runtime } },
80
80
  ...deps.errorReporting && { errorReporting: deps.errorReporting },
81
81
  ...deps.mapStyle && { maps: { style: deps.mapStyle } },
82
+ ...deps.files && deps.files.trustedHosts.length > 0 && { files: { trustedHosts: deps.files.trustedHosts } },
82
83
  ...plugins.length > 0 && { plugins },
83
84
  ...sources.length > 0 && { sources }
84
85
  };
@@ -28,11 +28,11 @@ const hoist = (entry, schema) => {
28
28
  const fileSchema = {
29
29
  type: "object",
30
30
  required: ["uri"],
31
- description: "A stored file. `url` downloads it: a signed link valid one to two hours for private files, a permanent one for public files. A value that names no configured provider comes as `{ uri, missing: true }`.",
31
+ description: "A stored file. `url` downloads it: a signed link valid one to two hours for private files, a permanent one for public files, the URL itself for an external file. A value that names no configured provider comes as `{ uri, missing: true }`.",
32
32
  properties: {
33
33
  uri: {
34
34
  type: "string",
35
- description: "The stored value, `{provider}:{path}?name=…&size=…`; send it back unchanged to keep the file"
35
+ description: "The stored value, `{provider}:{path}?name=…&size=…` or an external https URL; send it back unchanged to keep the file"
36
36
  },
37
37
  name: { type: "string" },
38
38
  type: {
@@ -101,6 +101,7 @@ const mergedFiles = (named) => {
101
101
  label
102
102
  })));
103
103
  checkUnique("The file provider", providers);
104
+ const trustedHosts = [...new Set(all.flatMap(({ files }) => files.trustedHosts ?? []))];
104
105
  const { retention, secret, sharp } = lastSet(all.map(({ files }) => ({
105
106
  retention: files.retention,
106
107
  secret: files.secret,
@@ -110,7 +111,8 @@ const mergedFiles = (named) => {
110
111
  ...providers.length > 0 && { providers: Object.fromEntries(providers.map(({ name, value }) => [name, value])) },
111
112
  ...retention !== void 0 && { retention },
112
113
  ...secret !== void 0 && { secret },
113
- ...sharp !== void 0 && { sharp }
114
+ ...sharp !== void 0 && { sharp },
115
+ ...trustedHosts.length > 0 && { trustedHosts }
114
116
  };
115
117
  };
116
118
  const mergedJobs = (named) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@better-auth/passkey": "^1.7.7",
33
- "@protobase/layout": "0.15.1",
34
- "@protobase/query": "0.15.1",
35
- "@protobase/schema": "0.15.1",
33
+ "@protobase/layout": "0.15.2",
34
+ "@protobase/query": "0.15.2",
35
+ "@protobase/schema": "0.15.2",
36
36
  "@react-email/render": "^2.1.0",
37
37
  "@scalar/hono-api-reference": "^0.12.10",
38
38
  "@sentry/core": "^11.7.0",