diff --git a/.changeset/url-field-accepts-relative-urls.md b/.changeset/url-field-accepts-relative-urls.md new file mode 100644 index 000000000..cd245003a --- /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 4abfa6764..1364f9cab 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 1333c4555..2c81c1ea1 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 {