Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/url-field-accepts-relative-urls.md
Original file line number Diff line number Diff line change
@@ -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/<id>`; 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.
57 changes: 57 additions & 0 deletions packages/objectql/src/validation/record-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>`. 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);
});
});
21 changes: 16 additions & 5 deletions packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,22 @@ const SKIP_FIELDS = new Set<string>([
// 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/
// <id>`. 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 {
Expand Down