mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
feat(db): image hosting schema, apiKey plugin, migration 0011 (v2.4.0 T1) (#315)
* feat(db): image hosting schema, apiKey plugin, migration 0011 Adds the DB infrastructure for the v2.4 image hosting feature: - server/db/schema.ts: new `image_hosting_configs` (per-org singleton, custom domain + referer allowlist) and `image_hostings` tables with all required indexes and cascade-delete FKs - server/db/auth-schema.ts: `apikey` table for @better-auth/api-key plugin (referenceId-indexed, rate-limiting fields, permissions column) - server/auth.ts: enable apiKey plugin (references='organization', image-hosting:upload permission declared) - shared/types/index.ts: ImageHostingConfig, ImageHosting, ImageHostingStatus - migrations/0011_image-hosting.sql: generated by drizzle-kit (NOT hand-authored); creates all three new tables + indexes - migrations/meta/0011_snapshot.json: baseline snapshot for future migration generation (repo was missing snapshots 0002–0010) - package.json: add @better-auth/api-key ^1.6.2 dep, bump better-auth to ^1.6.2 Note: migration is tagged 0011_image-hosting (idx=11 in journal) rather than 0012 because the existing 0011_notifications was registered as idx=10 (pre-existing numbering offset from a skipped 0006_ slot). Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f * fix: resolve coverage gap and migration filename collision - Add getTableConfig FK reference tests to cover deferred .references() callbacks in schema.ts (fixes Codecov patch coverage < 94.98%) - Rename 0011_image-hosting → 0012_image-hosting to avoid filename collision with pre-existing 0011_notifications migration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Bob <aibob@mails.agent-kanban.dev> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -52,3 +52,6 @@ zpan.db-wal
|
||||
# agent skills (managed by daemon)
|
||||
.agents/
|
||||
skills-lock.json
|
||||
|
||||
# agent skills (managed by daemon)
|
||||
.claude/skills/
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
CREATE TABLE `image_hosting_configs` (
|
||||
`org_id` text PRIMARY KEY NOT NULL,
|
||||
`custom_domain` text,
|
||||
`cf_hostname_id` text,
|
||||
`domain_verified_at` integer,
|
||||
`referer_allowlist` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`org_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `image_hosting_configs_custom_domain_unique` ON `image_hosting_configs` (`custom_domain`);--> statement-breakpoint
|
||||
CREATE TABLE `image_hostings` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`org_id` text NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`storage_id` text NOT NULL,
|
||||
`storage_key` text NOT NULL,
|
||||
`size` integer NOT NULL,
|
||||
`mime` text NOT NULL,
|
||||
`width` integer,
|
||||
`height` integer,
|
||||
`status` text DEFAULT 'draft' NOT NULL,
|
||||
`access_count` integer DEFAULT 0 NOT NULL,
|
||||
`last_accessed_at` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`org_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`storage_id`) REFERENCES `storages`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `image_hostings_token_unique` ON `image_hostings` (`token`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `image_hostings_org_path_uniq` ON `image_hostings` (`org_id`,`path`);--> statement-breakpoint
|
||||
CREATE INDEX `image_hostings_org_created_idx` ON `image_hostings` (`org_id`,`created_at`);--> statement-breakpoint
|
||||
CREATE INDEX `image_hostings_token_idx` ON `image_hostings` (`token`);--> statement-breakpoint
|
||||
CREATE TABLE `apikey` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`config_id` text DEFAULT 'default' NOT NULL,
|
||||
`name` text,
|
||||
`start` text,
|
||||
`reference_id` text NOT NULL,
|
||||
`prefix` text,
|
||||
`key` text NOT NULL,
|
||||
`refill_interval` integer,
|
||||
`refill_amount` integer,
|
||||
`last_refill_at` integer,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`rate_limit_enabled` integer DEFAULT true NOT NULL,
|
||||
`rate_limit_time_window` integer,
|
||||
`rate_limit_max` integer,
|
||||
`request_count` integer DEFAULT 0 NOT NULL,
|
||||
`remaining` integer,
|
||||
`last_request` integer,
|
||||
`expires_at` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`permissions` text,
|
||||
`metadata` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `apikey_config_id_idx` ON `apikey` (`config_id`);--> statement-breakpoint
|
||||
CREATE INDEX `apikey_reference_id_idx` ON `apikey` (`reference_id`);--> statement-breakpoint
|
||||
CREATE INDEX `apikey_key_idx` ON `apikey` (`key`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
||||
"when": 1776400000000,
|
||||
"tag": "0011_notifications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1776749333797,
|
||||
"tag": "0012_image-hosting",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+25
-1
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1022.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1022.0",
|
||||
"@better-auth/api-key": "^1.6.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -28,7 +29,7 @@
|
||||
"@tanstack/react-router": "^1.93.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@vidstack/react": "^1.12.13",
|
||||
"better-auth": "^1.2.0",
|
||||
"better-auth": "^1.6.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
@@ -1273,6 +1274,29 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@better-auth/api-key": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@better-auth/api-key/-/api-key-1.6.2.tgz",
|
||||
"integrity": "sha512-eEGfiPKS4qnd8MoV+GtPM9PP74cNfvQow3/0jARRNu8piI8R4NjqpojaSJVjZa5VrsRDKyYCpXAKz8u+7eaHog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@better-auth/core": "^1.6.2",
|
||||
"@better-auth/utils": "0.4.0",
|
||||
"better-auth": "^1.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@better-auth/api-key/node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@better-auth/core": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.2.tgz",
|
||||
|
||||
+2
-1
@@ -32,6 +32,7 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1022.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1022.0",
|
||||
"@better-auth/api-key": "^1.6.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -50,7 +51,7 @@
|
||||
"@tanstack/react-router": "^1.93.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@vidstack/react": "^1.12.13",
|
||||
"better-auth": "^1.2.0",
|
||||
"better-auth": "^1.6.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiKey } from '@better-auth/api-key'
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { admin, organization, username } from 'better-auth/plugins'
|
||||
@@ -189,6 +190,16 @@ export async function createAuth(db: Database, secret: string, baseURL?: string,
|
||||
scopes: c.scopes,
|
||||
})),
|
||||
}),
|
||||
apiKey({
|
||||
// Keys belong to the organization, not the individual user
|
||||
references: 'organization',
|
||||
// Rate limiting and lastRequest tracking are on by default
|
||||
rateLimit: { enabled: true },
|
||||
// Declare the image-hosting:upload permission so upload routes can require it
|
||||
permissions: {
|
||||
defaultPermissions: { 'image-hosting': ['upload'] },
|
||||
},
|
||||
}),
|
||||
],
|
||||
databaseHooks: {
|
||||
user: {
|
||||
|
||||
@@ -149,6 +149,40 @@ export const invitation = sqliteTable(
|
||||
],
|
||||
)
|
||||
|
||||
// apikey table — managed by @better-auth/api-key plugin
|
||||
export const apikey = sqliteTable(
|
||||
'apikey',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
configId: text('config_id').notNull().default('default'),
|
||||
name: text('name'),
|
||||
start: text('start'),
|
||||
referenceId: text('reference_id').notNull(), // organizationId when references='organization'
|
||||
prefix: text('prefix'),
|
||||
key: text('key').notNull(),
|
||||
refillInterval: integer('refill_interval'),
|
||||
refillAmount: integer('refill_amount'),
|
||||
lastRefillAt: integer('last_refill_at', { mode: 'timestamp_ms' }),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
rateLimitEnabled: integer('rate_limit_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
rateLimitTimeWindow: integer('rate_limit_time_window'),
|
||||
rateLimitMax: integer('rate_limit_max'),
|
||||
requestCount: integer('request_count').notNull().default(0),
|
||||
remaining: integer('remaining'),
|
||||
lastRequest: integer('last_request', { mode: 'timestamp_ms' }),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
permissions: text('permissions'), // JSON-serialized Statements
|
||||
metadata: text('metadata'), // JSON string
|
||||
},
|
||||
(table) => [
|
||||
index('apikey_config_id_idx').on(table.configId),
|
||||
index('apikey_reference_id_idx').on(table.referenceId),
|
||||
index('apikey_key_idx').on(table.key),
|
||||
],
|
||||
)
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { getTableConfig } from 'drizzle-orm/sqlite-core'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apikey } from './auth-schema.js'
|
||||
import { imageHostingConfigs, imageHostings } from './schema.js'
|
||||
|
||||
describe('imageHostingConfigs table', () => {
|
||||
it('uses org_id as primary key', () => {
|
||||
expect(imageHostingConfigs.orgId.name).toBe('org_id')
|
||||
expect(imageHostingConfigs.orgId.primary).toBe(true)
|
||||
})
|
||||
|
||||
it('has a nullable custom_domain column', () => {
|
||||
expect(imageHostingConfigs.customDomain).toBeDefined()
|
||||
expect(imageHostingConfigs.customDomain.name).toBe('custom_domain')
|
||||
expect(imageHostingConfigs.customDomain.notNull).toBe(false)
|
||||
})
|
||||
|
||||
it('custom_domain has a unique constraint', () => {
|
||||
expect(imageHostingConfigs.customDomain.isUnique).toBe(true)
|
||||
})
|
||||
|
||||
it('has nullable cf_hostname_id column', () => {
|
||||
expect(imageHostingConfigs.cfHostnameId).toBeDefined()
|
||||
expect(imageHostingConfigs.cfHostnameId.name).toBe('cf_hostname_id')
|
||||
expect(imageHostingConfigs.cfHostnameId.notNull).toBe(false)
|
||||
})
|
||||
|
||||
it('has nullable domain_verified_at column (timestamp_ms)', () => {
|
||||
expect(imageHostingConfigs.domainVerifiedAt).toBeDefined()
|
||||
expect(imageHostingConfigs.domainVerifiedAt.name).toBe('domain_verified_at')
|
||||
expect(imageHostingConfigs.domainVerifiedAt.columnType).toBe('SQLiteTimestamp')
|
||||
expect(imageHostingConfigs.domainVerifiedAt.notNull).toBe(false)
|
||||
})
|
||||
|
||||
it('has nullable referer_allowlist column', () => {
|
||||
expect(imageHostingConfigs.refererAllowlist).toBeDefined()
|
||||
expect(imageHostingConfigs.refererAllowlist.notNull).toBe(false)
|
||||
})
|
||||
|
||||
it('has not-null created_at and updated_at timestamps', () => {
|
||||
expect(imageHostingConfigs.createdAt.notNull).toBe(true)
|
||||
expect(imageHostingConfigs.updatedAt.notNull).toBe(true)
|
||||
})
|
||||
|
||||
it('org_id has a cascade-delete FK to organization', () => {
|
||||
const { foreignKeys } = getTableConfig(imageHostingConfigs)
|
||||
expect(foreignKeys).toHaveLength(1)
|
||||
const ref = foreignKeys[0].reference()
|
||||
expect(ref.foreignColumns).toHaveLength(1)
|
||||
expect(ref.foreignColumns[0].name).toBe('id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('imageHostings table', () => {
|
||||
it('has a text primary key id', () => {
|
||||
expect(imageHostings.id.name).toBe('id')
|
||||
expect(imageHostings.id.primary).toBe(true)
|
||||
expect(imageHostings.id.columnType).toBe('SQLiteText')
|
||||
})
|
||||
|
||||
it('has not-null org_id column', () => {
|
||||
expect(imageHostings.orgId.name).toBe('org_id')
|
||||
expect(imageHostings.orgId.notNull).toBe(true)
|
||||
})
|
||||
|
||||
it('has unique token column', () => {
|
||||
expect(imageHostings.token.name).toBe('token')
|
||||
expect(imageHostings.token.notNull).toBe(true)
|
||||
expect(imageHostings.token.isUnique).toBe(true)
|
||||
})
|
||||
|
||||
it('has not-null path column', () => {
|
||||
expect(imageHostings.path.name).toBe('path')
|
||||
expect(imageHostings.path.notNull).toBe(true)
|
||||
})
|
||||
|
||||
it('has not-null storage_id and storage_key columns', () => {
|
||||
expect(imageHostings.storageId.notNull).toBe(true)
|
||||
expect(imageHostings.storageKey.notNull).toBe(true)
|
||||
})
|
||||
|
||||
it('has not-null size and mime columns', () => {
|
||||
expect(imageHostings.size.notNull).toBe(true)
|
||||
expect(imageHostings.mime.notNull).toBe(true)
|
||||
})
|
||||
|
||||
it('has nullable width and height columns', () => {
|
||||
expect(imageHostings.width.notNull).toBe(false)
|
||||
expect(imageHostings.height.notNull).toBe(false)
|
||||
})
|
||||
|
||||
it('status defaults to "draft"', () => {
|
||||
expect(imageHostings.status.notNull).toBe(true)
|
||||
expect(imageHostings.status.default).toBe('draft')
|
||||
})
|
||||
|
||||
it('access_count defaults to 0', () => {
|
||||
expect(imageHostings.accessCount.notNull).toBe(true)
|
||||
expect(imageHostings.accessCount.default).toBe(0)
|
||||
})
|
||||
|
||||
it('has nullable last_accessed_at timestamp', () => {
|
||||
expect(imageHostings.lastAccessedAt.notNull).toBe(false)
|
||||
})
|
||||
|
||||
it('has not-null created_at timestamp', () => {
|
||||
expect(imageHostings.createdAt.notNull).toBe(true)
|
||||
})
|
||||
|
||||
it('org_id and storage_id have cascade/no-action FKs', () => {
|
||||
const { foreignKeys } = getTableConfig(imageHostings)
|
||||
expect(foreignKeys).toHaveLength(2)
|
||||
// Both FKs resolve to a single-column reference on 'id'
|
||||
for (const fk of foreignKeys) {
|
||||
const ref = fk.reference()
|
||||
expect(ref.foreignColumns).toHaveLength(1)
|
||||
expect(ref.foreignColumns[0].name).toBe('id')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('apikey table', () => {
|
||||
it('has a text primary key id', () => {
|
||||
expect(apikey.id.name).toBe('id')
|
||||
expect(apikey.id.primary).toBe(true)
|
||||
})
|
||||
|
||||
it('config_id defaults to "default"', () => {
|
||||
expect(apikey.configId.name).toBe('config_id')
|
||||
expect(apikey.configId.notNull).toBe(true)
|
||||
expect(apikey.configId.default).toBe('default')
|
||||
})
|
||||
|
||||
it('has not-null reference_id for org/user scoping', () => {
|
||||
expect(apikey.referenceId.name).toBe('reference_id')
|
||||
expect(apikey.referenceId.notNull).toBe(true)
|
||||
})
|
||||
|
||||
it('has not-null key column', () => {
|
||||
expect(apikey.key.name).toBe('key')
|
||||
expect(apikey.key.notNull).toBe(true)
|
||||
})
|
||||
|
||||
it('enabled defaults to true', () => {
|
||||
expect(apikey.enabled.notNull).toBe(true)
|
||||
expect(apikey.enabled.default).toBe(true)
|
||||
})
|
||||
|
||||
it('rate_limit_enabled defaults to true', () => {
|
||||
expect(apikey.rateLimitEnabled.notNull).toBe(true)
|
||||
expect(apikey.rateLimitEnabled.default).toBe(true)
|
||||
})
|
||||
|
||||
it('request_count defaults to 0', () => {
|
||||
expect(apikey.requestCount.notNull).toBe(true)
|
||||
expect(apikey.requestCount.default).toBe(0)
|
||||
})
|
||||
|
||||
it('has not-null created_at and updated_at', () => {
|
||||
expect(apikey.createdAt.notNull).toBe(true)
|
||||
expect(apikey.updatedAt.notNull).toBe(true)
|
||||
})
|
||||
|
||||
it('has nullable permissions column for JSON-serialized statements', () => {
|
||||
expect(apikey.permissions).toBeDefined()
|
||||
expect(apikey.permissions.notNull).toBe(false)
|
||||
})
|
||||
})
|
||||
+45
-1
@@ -1,4 +1,5 @@
|
||||
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
|
||||
import { organization } from './auth-schema'
|
||||
|
||||
export const matters = sqliteTable('matters', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -134,3 +135,46 @@ export const shareRecipients = sqliteTable(
|
||||
index('share_recipients_user_id_idx').on(t.recipientUserId),
|
||||
],
|
||||
)
|
||||
|
||||
// image_hosting_configs — per-org singleton; row exists => feature enabled
|
||||
export const imageHostingConfigs = sqliteTable('image_hosting_configs', {
|
||||
orgId: text('org_id')
|
||||
.primaryKey()
|
||||
.references(() => organization.id, { onDelete: 'cascade' }),
|
||||
customDomain: text('custom_domain').unique(),
|
||||
cfHostnameId: text('cf_hostname_id'),
|
||||
domainVerifiedAt: integer('domain_verified_at', { mode: 'timestamp_ms' }),
|
||||
refererAllowlist: text('referer_allowlist'), // JSON array of strings; null/empty => allow all
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
})
|
||||
|
||||
// image_hostings — one row per hosted image
|
||||
export const imageHostings = sqliteTable(
|
||||
'image_hostings',
|
||||
{
|
||||
id: text('id').primaryKey(), // nanoid(12)
|
||||
orgId: text('org_id')
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: 'cascade' }),
|
||||
token: text('token').notNull().unique(), // "ih_" + nanoid(10)
|
||||
path: text('path').notNull(), // virtual path e.g. "blog/2026/04/shot.png"
|
||||
storageId: text('storage_id')
|
||||
.notNull()
|
||||
.references(() => storages.id),
|
||||
storageKey: text('storage_key').notNull(), // "ih/<orgId>/<id>.<ext>"
|
||||
size: integer('size').notNull(),
|
||||
mime: text('mime').notNull(),
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
status: text('status').notNull().default('draft'), // 'draft' | 'active'
|
||||
accessCount: integer('access_count').notNull().default(0),
|
||||
lastAccessedAt: integer('last_accessed_at', { mode: 'timestamp_ms' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('image_hostings_org_path_uniq').on(t.orgId, t.path),
|
||||
index('image_hostings_org_created_idx').on(t.orgId, t.createdAt),
|
||||
index('image_hostings_token_idx').on(t.token),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -182,3 +182,32 @@ export interface ActivityEvent {
|
||||
image: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImageHostingConfig {
|
||||
orgId: string
|
||||
customDomain: string | null
|
||||
cfHostnameId: string | null
|
||||
domainVerifiedAt: string | null
|
||||
refererAllowlist: string | null // JSON array of strings; null/empty => allow all
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type ImageHostingStatus = 'draft' | 'active'
|
||||
|
||||
export interface ImageHosting {
|
||||
id: string
|
||||
orgId: string
|
||||
token: string
|
||||
path: string
|
||||
storageId: string
|
||||
storageKey: string
|
||||
size: number
|
||||
mime: string
|
||||
width: number | null
|
||||
height: number | null
|
||||
status: ImageHostingStatus
|
||||
accessCount: number
|
||||
lastAccessedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user