From add7ecc8510e57c43fdebc7db62b47500926d106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Tue, 21 Jul 2026 21:42:25 -0700 Subject: [PATCH] fix(objectql): accept relative and inline URLs on url fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/`; 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 --- .changeset/url-field-accepts-relative-urls.md | 19 +++++++ .../src/validation/record-validator.test.ts | 57 +++++++++++++++++++ .../src/validation/record-validator.ts | 21 +++++-- 3 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 .changeset/url-field-accepts-relative-urls.md diff --git a/.changeset/url-field-accepts-relative-urls.md b/.changeset/url-field-accepts-relative-urls.md new file mode 100644 index 0000000000..cd245003a2 --- /dev/null +++ b/.changeset/url-field-accepts-relative-urls.md @@ -0,0 +1,19 @@ +--- +"@objectstack/objectql": patch +--- + +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 +(`createObjectStackUploadAdapter`) PUTs the image to storage and then writes +`sys_user.image` (a `Field.url`) = `/api/v1/storage/files/`; that failed +`invalid_url` and — on the better-auth `update-user` path — surfaced as a +failed profile save (the "上传用户头像报错" avatar-upload bug). + +`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 with no leading `/` (e.g. `"notaurl"`) is still rejected. +Verified end-to-end in the running Console: avatar upload → display → replace → +remove all succeed. diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index 4abfa6764a..1364f9cabe 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -288,3 +288,60 @@ describe('coerceBooleanFields — SQLite 0/1 → real booleans', () => { expect(coerceBooleanFields(schema, null as any)).toBe(null); }); }); + +/** + * `url` fields (e.g. `sys_user.image`, a Field.url) must accept relative and + * authority-less URLs, not just `scheme://`. + * + * The load-bearing case is the root-relative form the platform's OWN storage + * service returns for an uploaded file: the console avatar uploader writes + * `sys_user.image = /api/v1/storage/files/`. Before the fix that failed + * `invalid_url` and, on the better-auth `update-user` path, surfaced as a raw + * HTTP 500 — the exact avatar-upload bug users hit. `data:`/`blob:` inline + * forms are accepted too. + */ +describe('validateRecord — url field accepts relative + inline URLs', () => { + const schema = { fields: { image: { type: 'url', required: false } } }; + + it('accepts a root-relative storage URL (the real avatar-upload value)', () => { + expect(() => + validateRecord( + schema, + { image: '/api/v1/storage/files/cb02e85b-33f3-4bd1-88e4-b7b706ff856a' }, + 'update', + ), + ).not.toThrow(); + }); + + it('accepts a protocol-relative URL', () => { + expect(() => + validateRecord(schema, { image: '//cdn.example/a.png' }, 'update'), + ).not.toThrow(); + }); + + it('accepts a base64 data: URI', () => { + expect(() => + validateRecord( + schema, + { image: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB' }, + 'update', + ), + ).not.toThrow(); + }); + + it('accepts a blob: object-URL', () => { + expect(() => + validateRecord(schema, { image: 'blob:https://app.example/8f3c-1a2b' }, 'update'), + ).not.toThrow(); + }); + + it('still accepts a normal scheme:// URL', () => { + expect(() => + validateRecord(schema, { image: 'https://cdn.example/a.png' }, 'update'), + ).not.toThrow(); + }); + + it('still rejects a bare non-URL string (no scheme, no leading slash)', () => { + expect(() => validateRecord(schema, { image: 'notaurl' }, 'update')).toThrow(/valid URL/i); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 1333c45557..2c81c1ea17 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -48,11 +48,22 @@ const SKIP_FIELDS = new Set([ // backtracking (ReDoS) of the naive `[^\s@]+\.[^\s@]+` shape while still // requiring a local part, an '@', and a dotted domain. const EMAIL_RE = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/; -// Permissive URL pattern: accept any scheme:// + non-empty body so that -// non-HTTP URIs used by drivers (libsql://, postgres://, mysql://, file://, s3://, …) -// pass field-level validation. Stricter per-field checks can be enforced -// via custom validators where needed. -const URL_RE = /^[a-z][a-z0-9+.\-]*:\/\/[^\s]+$/i; +// Permissive URL pattern for `url` fields. Four accepted shapes: +// 1. `scheme://…` — any scheme + non-empty body, so non-HTTP URIs used by +// drivers (libsql://, postgres://, mysql://, file://, s3://, …) pass. +// 2. root-relative / protocol-relative refs (`/path`, `//host/path`) — the +// common same-origin asset form. This is what the platform's OWN storage +// service returns for an uploaded file: the console avatar uploader +// (@object-ui, createObjectStackUploadAdapter) PUTs the image to storage +// and then writes `sys_user.image` (a Field.url) = `/api/v1/storage/files/ +// `. Rejecting it made every avatar upload fail `invalid_url` and — +// on the better-auth `update-user` path — surface as a raw HTTP 500. +// 3. `data:` URIs — base64-embedded images (the default object-URL upload +// client's inline form). +// 4. `blob:` object-URLs. +// A bare scheme-less string with no leading `/` (e.g. "notaurl") is still +// rejected. Stricter per-field checks can be enforced via custom validators. +const URL_RE = /^(?:[a-z][a-z0-9+.\-]*:\/\/[^\s]+|\/[^\s]*|data:[^\s]+|blob:[^\s]+)$/i; const PHONE_RE = /^[+()\-\s\d.]{5,}$/; export interface FieldValidationError {