Skip to content

Commit 54f479a

Browse files
baozhoutaoclaude
authored andcommitted
fix(objectql): accept relative and inline URLs on url fields
The record-validator's url-type check required an absolute `scheme://` URL, so it rejected the root-relative value the platform's own storage service returns for an uploaded file. The console avatar uploader PUTs the image to storage and then writes `sys_user.image` (a Field.url) = `/api/v1/storage/files/<id>`; that failed `invalid_url` and, on the better-auth `update-user` path, broke the avatar-upload profile save. `URL_RE` now also accepts root-/protocol-relative refs (`/path`, `//host/path`) and the `data:` / `blob:` inline forms, in addition to `scheme://…`. A bare scheme-less string (e.g. "notaurl") is still rejected. Verified end-to-end in the running Console: avatar upload → display → replace → remove all succeed. Complements #3399 (which maps the validation error to a clean 4xx); this makes the legitimate relative URL pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d0fea33 commit 54f479a

3 files changed

Lines changed: 92 additions & 5 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): accept relative and inline URLs on `url` fields
6+
7+
The record-validator's `url`-type check required an absolute `scheme://` URL,
8+
so it rejected the **root-relative** value the platform's own storage service
9+
returns for an uploaded file. The console avatar uploader
10+
(`createObjectStackUploadAdapter`) PUTs the image to storage and then writes
11+
`sys_user.image` (a `Field.url`) = `/api/v1/storage/files/<id>`; that failed
12+
`invalid_url` and — on the better-auth `update-user` path — surfaced as a
13+
failed profile save (the "上传用户头像报错" avatar-upload bug).
14+
15+
`URL_RE` now also accepts root-/protocol-relative refs (`/path`, `//host/path`)
16+
and the `data:` / `blob:` inline forms, in addition to `scheme://…`. A bare
17+
scheme-less string with no leading `/` (e.g. `"notaurl"`) is still rejected.
18+
Verified end-to-end in the running Console: avatar upload → display → replace →
19+
remove all succeed.

packages/objectql/src/validation/record-validator.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,60 @@ describe('coerceBooleanFields — SQLite 0/1 → real booleans', () => {
288288
expect(coerceBooleanFields(schema, null as any)).toBe(null);
289289
});
290290
});
291+
292+
/**
293+
* `url` fields (e.g. `sys_user.image`, a Field.url) must accept relative and
294+
* authority-less URLs, not just `scheme://`.
295+
*
296+
* The load-bearing case is the root-relative form the platform's OWN storage
297+
* service returns for an uploaded file: the console avatar uploader writes
298+
* `sys_user.image = /api/v1/storage/files/<id>`. Before the fix that failed
299+
* `invalid_url` and, on the better-auth `update-user` path, surfaced as a raw
300+
* HTTP 500 — the exact avatar-upload bug users hit. `data:`/`blob:` inline
301+
* forms are accepted too.
302+
*/
303+
describe('validateRecord — url field accepts relative + inline URLs', () => {
304+
const schema = { fields: { image: { type: 'url', required: false } } };
305+
306+
it('accepts a root-relative storage URL (the real avatar-upload value)', () => {
307+
expect(() =>
308+
validateRecord(
309+
schema,
310+
{ image: '/api/v1/storage/files/cb02e85b-33f3-4bd1-88e4-b7b706ff856a' },
311+
'update',
312+
),
313+
).not.toThrow();
314+
});
315+
316+
it('accepts a protocol-relative URL', () => {
317+
expect(() =>
318+
validateRecord(schema, { image: '//cdn.example/a.png' }, 'update'),
319+
).not.toThrow();
320+
});
321+
322+
it('accepts a base64 data: URI', () => {
323+
expect(() =>
324+
validateRecord(
325+
schema,
326+
{ image: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB' },
327+
'update',
328+
),
329+
).not.toThrow();
330+
});
331+
332+
it('accepts a blob: object-URL', () => {
333+
expect(() =>
334+
validateRecord(schema, { image: 'blob:https://app.example/8f3c-1a2b' }, 'update'),
335+
).not.toThrow();
336+
});
337+
338+
it('still accepts a normal scheme:// URL', () => {
339+
expect(() =>
340+
validateRecord(schema, { image: 'https://cdn.example/a.png' }, 'update'),
341+
).not.toThrow();
342+
});
343+
344+
it('still rejects a bare non-URL string (no scheme, no leading slash)', () => {
345+
expect(() => validateRecord(schema, { image: 'notaurl' }, 'update')).toThrow(/valid URL/i);
346+
});
347+
});

packages/objectql/src/validation/record-validator.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,22 @@ const SKIP_FIELDS = new Set<string>([
4848
// backtracking (ReDoS) of the naive `[^\s@]+\.[^\s@]+` shape while still
4949
// requiring a local part, an '@', and a dotted domain.
5050
const EMAIL_RE = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
51-
// Permissive URL pattern: accept any scheme:// + non-empty body so that
52-
// non-HTTP URIs used by drivers (libsql://, postgres://, mysql://, file://, s3://, …)
53-
// pass field-level validation. Stricter per-field checks can be enforced
54-
// via custom validators where needed.
55-
const URL_RE = /^[a-z][a-z0-9+.\-]*:\/\/[^\s]+$/i;
51+
// Permissive URL pattern for `url` fields. Four accepted shapes:
52+
// 1. `scheme://…` — any scheme + non-empty body, so non-HTTP URIs used by
53+
// drivers (libsql://, postgres://, mysql://, file://, s3://, …) pass.
54+
// 2. root-relative / protocol-relative refs (`/path`, `//host/path`) — the
55+
// common same-origin asset form. This is what the platform's OWN storage
56+
// service returns for an uploaded file: the console avatar uploader
57+
// (@object-ui, createObjectStackUploadAdapter) PUTs the image to storage
58+
// and then writes `sys_user.image` (a Field.url) = `/api/v1/storage/files/
59+
// <id>`. Rejecting it made every avatar upload fail `invalid_url` and —
60+
// on the better-auth `update-user` path — surface as a raw HTTP 500.
61+
// 3. `data:` URIs — base64-embedded images (the default object-URL upload
62+
// client's inline form).
63+
// 4. `blob:` object-URLs.
64+
// A bare scheme-less string with no leading `/` (e.g. "notaurl") is still
65+
// rejected. Stricter per-field checks can be enforced via custom validators.
66+
const URL_RE = /^(?:[a-z][a-z0-9+.\-]*:\/\/[^\s]+|\/[^\s]*|data:[^\s]+|blob:[^\s]+)$/i;
5667
const PHONE_RE = /^[+()\-\s\d.]{5,}$/;
5768

5869
export interface FieldValidationError {

0 commit comments

Comments
 (0)