@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.
- package/dist/files/attach.js +25 -3
- package/dist/files/external-url.js +34 -0
- package/dist/files/options.d.ts +5 -0
- package/dist/files/present.js +23 -4
- package/dist/files/runtime.js +2 -0
- package/dist/files/trusted-hosts.js +14 -0
- package/dist/meta.js +1 -0
- package/dist/openapi/schemas.js +2 -2
- package/dist/project-config.js +3 -1
- package/package.json +4 -4
package/dist/files/attach.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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 };
|
package/dist/files/options.d.ts
CHANGED
|
@@ -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
|
package/dist/files/present.js
CHANGED
|
@@ -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
|
|
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
|
-
...
|
|
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
|
|
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) => {
|
package/dist/files/runtime.js
CHANGED
|
@@ -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
|
};
|
package/dist/openapi/schemas.js
CHANGED
|
@@ -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
|
|
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: {
|
package/dist/project-config.js
CHANGED
|
@@ -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.
|
|
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.
|
|
34
|
-
"@protobase/query": "0.15.
|
|
35
|
-
"@protobase/schema": "0.15.
|
|
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",
|