feat(app): register S3-compatible storage

- Adapt the contributed S3 client behind the neutral storage provider contract

- Resolve access credentials lazily for each storage operation

- Preserve namespace isolation, CORS setup, pagination, progress, and metadata fallbacks

Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
This commit is contained in:
Danila Poyarkov 2026-07-26 14:26:44 +03:00
parent a4a8eab46e
commit d315547f2b
17 changed files with 1111 additions and 0 deletions

View file

@ -36,6 +36,7 @@
"@unhead/vue": "^2.1.10",
"@vueuse/core": "^14.2.1",
"ai": "^6.0.174",
"aws4fetch": "^1.0.20",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"dedent": "^1.7.1",
@ -1498,6 +1499,8 @@
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"axe-core": ["axe-core@4.12.1", "", {}, "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA=="],
"babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="],

View file

@ -89,6 +89,7 @@
"@unhead/vue": "^2.1.10",
"@vueuse/core": "^14.2.1",
"ai": "^6.0.174",
"aws4fetch": "^1.0.20",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"dedent": "^1.7.1",

View file

@ -1,4 +1,8 @@
export { S3_STORAGE_PROVIDER, storageProviderRegistry } from './providers'
export { defineStorageProvider, StorageProviderRegistry } from './registry'
export { createS3StorageAdapter } from './s3/adapter'
export type { S3StorageAdapter } from './s3/adapter'
export type { S3CompatibleConfig, S3ConnectionResult } from './s3/types'
export type {
StorageAdapter,
StorageAdapterContext,

View file

@ -0,0 +1,28 @@
/** Fixed OpenPencil namespace inside a shared storage backend. */
export const STORAGE_NAMESPACE = 'open_pencil_storage'
export const STORAGE_NAMESPACE_MARKER = `${STORAGE_NAMESPACE}/.openpencil-namespace`
export const STORAGE_DOCUMENTS_PREFIX = `${STORAGE_NAMESPACE}/canvases/`
export function documentFigKey(documentId: string): string {
return `${STORAGE_DOCUMENTS_PREFIX}${documentId}.fig`
}
export function documentMetaKey(documentId: string): string {
return `${STORAGE_DOCUMENTS_PREFIX}${documentId}.meta.json`
}
export function documentThumbnailKey(documentId: string): string {
return `${STORAGE_DOCUMENTS_PREFIX}${documentId}.thumb.jpg`
}
export function documentIdFromFigKey(key: string): string | null {
if (!key.startsWith(STORAGE_DOCUMENTS_PREFIX) || !key.endsWith('.fig')) return null
const id = key.slice(STORAGE_DOCUMENTS_PREFIX.length, -'.fig'.length)
if (!id || id.includes('/')) return null
return id
}
export const NAMESPACE_MARKER_BODY = JSON.stringify({
app: 'open-pencil',
version: 1
})

View file

@ -0,0 +1,20 @@
import { defineStorageProvider, StorageProviderRegistry } from './registry'
import { createS3StorageAdapter } from './s3/adapter'
export const S3_STORAGE_PROVIDER = defineStorageProvider({
id: 's3-compatible',
label: 'S3 compatible',
description: 'AWS S3, Backblaze B2, Cloudflare R2, MinIO, and compatible storage',
preferenceFields: [
{ id: 'endpoint', label: 'Endpoint', kind: 'url', required: true },
{ id: 'bucket', label: 'Bucket', kind: 'text', required: true },
{ id: 'region', label: 'Region', kind: 'text' }
],
credentialFields: [
{ id: 'access-key-id', label: 'Access key ID', required: true },
{ id: 'secret-access-key', label: 'Secret access key', required: true }
],
createAdapter: createS3StorageAdapter
})
export const storageProviderRegistry = new StorageProviderRegistry([S3_STORAGE_PROVIDER])

View file

@ -0,0 +1,254 @@
import { isTauri } from '@/app/tauri/env'
import {
NAMESPACE_MARKER_BODY,
STORAGE_DOCUMENTS_PREFIX,
STORAGE_NAMESPACE,
STORAGE_NAMESPACE_MARKER,
documentFigKey,
documentIdFromFigKey,
documentMetaKey,
documentThumbnailKey
} from '../namespace'
import type {
StorageAdapter,
StorageDocument,
StorageDocumentMetadata,
StorageProviderRuntime
} from '../types'
import { S3HttpError, deleteObject, getObject, headObject, listObjects, putObject } from './client'
import {
CloudCorsError,
ensureWebCorsOnBucket,
formatBrowserCorsHelpMessage,
isLikelyCorsOrNetworkError
} from './cors'
import type { S3CompatibleConfig, S3ConnectionResult } from './types'
const ENDPOINT_FIELD = 'endpoint'
const BUCKET_FIELD = 'bucket'
const REGION_FIELD = 'region'
const ACCESS_KEY_FIELD = 'access-key-id'
const SECRET_KEY_FIELD = 'secret-access-key'
function requiredPreference(runtime: StorageProviderRuntime, field: string): string {
const value = runtime.preferences[field]?.trim()
if (!value) throw new Error(`S3 ${field} is required`)
return value
}
async function resolveConfig(runtime: StorageProviderRuntime): Promise<S3CompatibleConfig> {
const [accessKeyId, secretAccessKey] = await Promise.all([
runtime.resolveCredential(ACCESS_KEY_FIELD),
runtime.resolveCredential(SECRET_KEY_FIELD)
])
if (!accessKeyId || !secretAccessKey) throw new Error('S3 credentials are required')
const region = runtime.preferences[REGION_FIELD]?.trim()
return {
endpoint: requiredPreference(runtime, ENDPOINT_FIELD),
bucket: requiredPreference(runtime, BUCKET_FIELD),
accessKeyId,
secretAccessKey,
...(region ? { region } : {})
}
}
function parseMetadata(
bytes: Uint8Array | null,
fallback: StorageDocumentMetadata
): { metadata: StorageDocumentMetadata; authoritative: boolean } {
if (!bytes) return { metadata: fallback, authoritative: false }
try {
const parsed = JSON.parse(new TextDecoder().decode(bytes)) as Partial<StorageDocumentMetadata>
const name = typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name : null
const updatedAt =
typeof parsed.updatedAt === 'string' && parsed.updatedAt ? parsed.updatedAt : null
return {
metadata: {
name: name ?? fallback.name,
updatedAt: updatedAt ?? fallback.updatedAt
},
authoritative: name !== null && updatedAt !== null
}
} catch {
return { metadata: fallback, authoritative: false }
}
}
function connectionErrorMessage(error: unknown, isCors: boolean): string {
if (isCors) return formatBrowserCorsHelpMessage()
return error instanceof Error ? error.message : String(error)
}
async function ensureNamespace(config: S3CompatibleConfig): Promise<void> {
if (await headObject(config, STORAGE_NAMESPACE_MARKER)) return
try {
await putObject(config, STORAGE_NAMESPACE_MARKER, NAMESPACE_MARKER_BODY, 'application/json')
} catch (error) {
if (error instanceof S3HttpError && (error.status === 403 || error.status === 401)) {
throw new Error('Cannot write to this bucket. Check access permissions and bucket name.')
}
throw error
}
}
export interface S3StorageAdapter extends StorageAdapter {
testConnection(): Promise<S3ConnectionResult>
}
export function createS3StorageAdapter(runtime: StorageProviderRuntime): S3StorageAdapter {
return {
async testConnection() {
const config = await resolveConfig(runtime)
let cors = await ensureWebCorsOnBucket(config)
if (!cors.applied) console.warn('[Storage] Automatic PutBucketCors failed:', cors.error)
try {
await ensureNamespace(config)
await listObjects(config, STORAGE_DOCUMENTS_PREFIX)
} catch (error) {
const isCors =
error instanceof CloudCorsError || (!isTauri() && isLikelyCorsOrNetworkError(error))
return {
ok: false,
message: connectionErrorMessage(error, isCors),
corsApplied: cors.applied,
isCorsFailure: isCors,
corsError: cors.error
}
}
if (!cors.applied) cors = await ensureWebCorsOnBucket(config)
return {
ok: true,
message: cors.applied
? 'Connected. Bucket CORS was applied automatically for the web app.'
: 'Connected. Storage namespace is ready.',
corsApplied: cors.applied,
isCorsFailure: false,
corsError: null
}
},
async listDocuments() {
const config = await resolveConfig(runtime)
const objects = await listObjects(config, STORAGE_DOCUMENTS_PREFIX)
const entries = objects
.map((object) => {
const id = documentIdFromFigKey(object.key)
return id ? { id, lastModified: object.lastModified } : null
})
.filter((entry): entry is { id: string; lastModified: string | null } => entry !== null)
const documents = await Promise.all(
entries.map(async ({ id, lastModified }) => {
const fallback = {
name: id,
updatedAt: lastModified ?? new Date(0).toISOString()
}
const metadataBytes = await getObject(config, documentMetaKey(id)).catch(
(error: unknown) => {
console.warn('[Storage] Document metadata fetch failed:', id, error)
return null
}
)
const { metadata, authoritative } = parseMetadata(metadataBytes, fallback)
return {
id,
...metadata,
metadataAuthoritative: authoritative
} satisfies StorageDocument
})
)
return documents.sort((first, second) => second.updatedAt.localeCompare(first.updatedAt))
},
async getDocument(id, onProgress) {
const config = await resolveConfig(runtime)
const bytes = await getObject(
config,
documentFigKey(id),
onProgress
? (progress) =>
onProgress({
transferredBytes: progress.receivedBytes,
totalBytes: progress.totalBytes
})
: undefined
)
if (!bytes) throw new Error(`Document not found: ${id}`)
return bytes
},
async putDocument(id, bytes, metadata, onProgress) {
const config = await resolveConfig(runtime)
await putObject(
config,
documentFigKey(id),
bytes,
'application/octet-stream',
onProgress
? (progress) =>
onProgress({
transferredBytes: progress.sentBytes,
totalBytes: progress.totalBytes
})
: undefined
)
await putObject(
config,
documentMetaKey(id),
JSON.stringify({
name: metadata.name,
updatedAt: metadata.updatedAt || new Date().toISOString()
}),
'application/json'
)
},
async getDocumentMetadata(id) {
const config = await resolveConfig(runtime)
const bytes = await getObject(config, documentMetaKey(id))
if (!bytes) return null
const parsed = parseMetadata(bytes, {
name: id,
updatedAt: new Date(0).toISOString()
})
return parsed.authoritative ? parsed.metadata : null
},
async deleteDocument(id) {
const config = await resolveConfig(runtime)
const results = await Promise.allSettled([
deleteObject(config, documentFigKey(id)),
deleteObject(config, documentMetaKey(id)),
deleteObject(config, documentThumbnailKey(id))
])
const failure = results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected'
)
if (failure) throw failure.reason
},
async getUsage() {
const config = await resolveConfig(runtime)
const objects = await listObjects(config, `${STORAGE_NAMESPACE}/`)
return {
bytesUsed: objects.reduce((total, object) => total + (object.size ?? 0), 0),
objectCount: objects.length,
documentCount: objects.filter((object) => documentIdFromFigKey(object.key)).length
}
},
async putThumbnail(id, bytes) {
const config = await resolveConfig(runtime)
await putObject(config, documentThumbnailKey(id), bytes, 'image/jpeg')
},
async getThumbnail(id) {
const config = await resolveConfig(runtime)
return getObject(config, documentThumbnailKey(id))
}
}
}

View file

@ -0,0 +1,324 @@
import { AwsClient } from 'aws4fetch'
import { storageFetch } from '@/app/integrations/storage/s3/fetch'
import { inferS3Region } from '@/app/integrations/storage/s3/region'
import type { S3CompatibleConfig } from '@/app/integrations/storage/s3/types'
export function resolveS3Region(config: S3CompatibleConfig): string {
const explicit = config.region?.trim()
if (explicit) return explicit
return inferS3Region(config.endpoint)
}
export class S3HttpError extends Error {
readonly status: number
readonly code: string | null
constructor(status: number, message: string, code: string | null = null) {
super(message)
this.name = 'S3HttpError'
this.status = status
this.code = code
}
}
export function normalizeEndpoint(endpoint: string): string {
const trimmed = endpoint.trim().replace(/\/+$/, '')
if (!trimmed) throw new Error('S3 endpoint is required')
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed
return `https://${trimmed}`
}
/** Alias for CORS module import clarity. */
export const normalizeEndpointForCors = normalizeEndpoint
/** Path-style object URL: {endpoint}/{bucket}/{key} — works with B2, MinIO, R2, AWS. */
export function objectUrl(config: S3CompatibleConfig, key: string): string {
const base = normalizeEndpoint(config.endpoint)
const encodedKey = key
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/')
return `${base}/${encodeURIComponent(config.bucket)}/${encodedKey}`
}
export function createAwsClient(config: S3CompatibleConfig): AwsClient {
return new AwsClient({
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
region: resolveS3Region(config),
service: 's3'
})
}
async function readErrorBody(res: Response): Promise<{ message: string; code: string | null }> {
const text = await res.text().catch(() => '')
const codeMatch = text.match(/<Code>([^<]+)<\/Code>/i)
const messageMatch = text.match(/<Message>([^<]+)<\/Message>/i)
const code = codeMatch?.[1] ?? null
const message =
messageMatch?.[1] ??
(text.trim() ? text.trim().slice(0, 200) : `S3 request failed with status ${res.status}`)
return { message, code }
}
/**
* Known-length body types that UAs can send with Content-Length.
* Prefer these over Request-wrapped streams B2 rejects missing Content-Length (411)
* and some browsers hang on chunked S3 PUTs.
*/
function bodyByteLength(body: BodyInit | null | undefined): number | null {
if (body == null) return null
if (typeof body === 'string') return new TextEncoder().encode(body).byteLength
if (body instanceof ArrayBuffer) return body.byteLength
if (ArrayBuffer.isView(body)) return body.byteLength
if (typeof Blob !== 'undefined' && body instanceof Blob) return body.size
return null
}
export type UploadProgress = { sentBytes: number; totalBytes: number | null }
/**
* fetch() cannot observe upload progress replay the signed request over
* XMLHttpRequest when a progress callback is attached (uploads only).
*/
function xhrSend(
url: string,
method: string,
headers: Headers,
body: BodyInit | undefined,
onUploadProgress: (progress: UploadProgress) => void
): Promise<Response> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open(method, url)
headers.forEach((value, key) => {
// Forbidden request headers are set by the browser itself
if (/^(content-length|host)$/i.test(key)) return
xhr.setRequestHeader(key, value)
})
xhr.responseType = 'text'
xhr.upload.onprogress = (e) => {
onUploadProgress({ sentBytes: e.loaded, totalBytes: e.lengthComputable ? e.total : null })
}
xhr.onload = () => resolve(new Response(xhr.responseText, { status: xhr.status }))
// Same shape the fetch path throws so CORS/network detection keeps working
xhr.onerror = () => reject(new TypeError('Failed to fetch'))
xhr.send(body as XMLHttpRequestBodyInit)
})
}
export async function s3Request(
config: S3CompatibleConfig,
url: string,
init: RequestInit = {},
onUploadProgress?: (progress: UploadProgress) => void
): Promise<Response> {
const client = createAwsClient(config)
const length = bodyByteLength(init.body ?? null)
const headers = new Headers(init.headers)
// Never send cookies; avoids credentialed CORS mode.
// Set Content-Length when we know size. aws4fetch leaves it unsignable (correct for S3).
// Critical for Backblaze B2 large binary PUTs.
if (length != null && !headers.has('Content-Length')) {
headers.set('Content-Length', String(length))
}
// Sign with aws4fetch, then re-issue with url+init so the body keeps a known length.
// Passing only the signed Request object can drop Content-Length on some runtimes.
const signed = await client.sign(url, {
...init,
headers,
credentials: 'omit'
})
let res: Response
try {
if (onUploadProgress && typeof XMLHttpRequest !== 'undefined') {
res = await xhrSend(
signed.url,
signed.method,
signed.headers,
init.body ?? undefined,
onUploadProgress
)
} else {
res = await storageFetch(signed.url, {
method: signed.method,
headers: signed.headers,
body: init.body ?? undefined,
credentials: 'omit'
})
}
} catch (error) {
// Re-export as a typed error so UI can detect CORS/network blocks.
const { CloudCorsError, isLikelyCorsOrNetworkError, formatBrowserCorsHelpMessage } =
await import('@/app/integrations/storage/s3/cors')
if (isLikelyCorsOrNetworkError(error)) {
throw new CloudCorsError(formatBrowserCorsHelpMessage())
}
throw error
}
if (res.ok || res.status === 404) return res
const { message, code } = await readErrorBody(res)
throw new S3HttpError(res.status, message, code)
}
export async function headObject(config: S3CompatibleConfig, key: string): Promise<boolean> {
const res = await s3Request(config, objectUrl(config, key), { method: 'HEAD' })
if (res.status === 404) return false
return true
}
export async function putObject(
config: S3CompatibleConfig,
key: string,
body: Uint8Array | string,
contentType: string,
onUploadProgress?: (progress: UploadProgress) => void
): Promise<void> {
const bytes = typeof body === 'string' ? new TextEncoder().encode(body) : body
// Exact ArrayBuffer so fetch/UA can set Content-Length (required by B2 for large PUTs).
const payload = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer
const res = await s3Request(
config,
objectUrl(config, key),
{
method: 'PUT',
headers: {
'Content-Type': contentType
},
body: payload
},
onUploadProgress
)
if (!res.ok) {
throw new S3HttpError(res.status, `Failed to upload ${key}`)
}
}
export type DownloadProgress = { receivedBytes: number; totalBytes: number | null }
export async function getObject(
config: S3CompatibleConfig,
key: string,
onProgress?: (progress: DownloadProgress) => void
): Promise<Uint8Array | null> {
const res = await s3Request(config, objectUrl(config, key), { method: 'GET' })
if (res.status === 404) return null
if (!onProgress || !res.body) {
return new Uint8Array(await res.arrayBuffer())
}
// Stream so large figs can report download progress
const contentLength = Number(res.headers.get('content-length'))
const totalBytes = Number.isFinite(contentLength) && contentLength > 0 ? contentLength : null
const reader = res.body.getReader()
const chunks: Uint8Array[] = []
let receivedBytes = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
receivedBytes += value.byteLength
onProgress({ receivedBytes, totalBytes })
}
const out = new Uint8Array(receivedBytes)
let offset = 0
for (const chunk of chunks) {
out.set(chunk, offset)
offset += chunk.byteLength
}
return out
}
export async function deleteObject(config: S3CompatibleConfig, key: string): Promise<void> {
const res = await s3Request(config, objectUrl(config, key), { method: 'DELETE' })
if (!res.ok && res.status !== 404) {
throw new S3HttpError(res.status, `Failed to delete ${key}`)
}
}
export type ListedObject = {
key: string
lastModified: string | null
size: number | null
}
export type ListObjectsPage = {
objects: ListedObject[]
isTruncated: boolean
nextContinuationToken: string | null
}
/** Parse ListObjectsV2 XML into key entries. Pure for unit tests. */
export function parseListObjectsV2Xml(xml: string): ListedObject[] {
return parseListObjectsV2Page(xml).objects
}
/** Parse ListObjectsV2 XML including pagination fields. */
export function parseListObjectsV2Page(xml: string): ListObjectsPage {
const contents = [...xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/gi)]
const items: ListedObject[] = []
for (const match of contents) {
const block = match[1] ?? ''
const key = block.match(/<Key>([^<]*)<\/Key>/i)?.[1]
if (!key) continue
const lastModified = block.match(/<LastModified>([^<]*)<\/LastModified>/i)?.[1] ?? null
const sizeRaw = block.match(/<Size>([^<]*)<\/Size>/i)?.[1]
const size = sizeRaw != null && sizeRaw !== '' ? Number(sizeRaw) : null
items.push({
key: decodeXmlEntities(key),
lastModified,
size: Number.isFinite(size) ? size : null
})
}
const truncatedRaw = xml.match(/<IsTruncated>([^<]*)<\/IsTruncated>/i)?.[1]
const isTruncated = truncatedRaw?.trim().toLowerCase() === 'true'
const tokenRaw = xml.match(/<NextContinuationToken>([^<]*)<\/NextContinuationToken>/i)?.[1]
return {
objects: items,
isTruncated,
nextContinuationToken: tokenRaw ? decodeXmlEntities(tokenRaw) : null
}
}
function decodeXmlEntities(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
}
export async function listObjects(
config: S3CompatibleConfig,
prefix: string
): Promise<ListedObject[]> {
const base = normalizeEndpoint(config.endpoint)
const all: ListedObject[] = []
let continuationToken: string | null = null
for (let page = 0; page < 50; page++) {
const params = new URLSearchParams({
'list-type': '2',
prefix,
'max-keys': '1000'
})
if (continuationToken) params.set('continuation-token', continuationToken)
const url = `${base}/${encodeURIComponent(config.bucket)}?${params.toString()}`
const res = await s3Request(config, url, { method: 'GET' })
if (!res.ok) {
throw new S3HttpError(res.status, 'Failed to list objects')
}
const xml = await res.text()
const parsed = parseListObjectsV2Page(xml)
all.push(...parsed.objects)
if (!parsed.isTruncated || !parsed.nextContinuationToken) break
continuationToken = parsed.nextContinuationToken
}
return all
}

View file

@ -0,0 +1,198 @@
import {
createAwsClient,
normalizeEndpointForCors,
S3HttpError
} from '@/app/integrations/storage/s3/client'
import { storageFetch } from '@/app/integrations/storage/s3/fetch'
import type { S3CompatibleConfig } from '@/app/integrations/storage/s3/types'
import { isTauri } from '@/app/tauri/env'
import { IS_BROWSER, WEB_APP_ORIGIN } from '@/constants'
/** Origins OpenPencil may run from when calling S3 from the browser. */
export const CLOUD_CORS_STATIC_ORIGINS = [
// Exact production origin — providers without partial-wildcard support
// (e.g. R2) need it verbatim even when CORS is configured from dev.
WEB_APP_ORIGIN,
// Wildcards: any openpencil.dev subdomain (staging, demo, …), Cloudflare
// Pages PR previews, and any local dev port. S3/B2 allow one '*' per origin.
// collectCloudCorsOrigins() also appends the current origin, so strict
// providers still get an exact match for wherever the app is running when
// CORS is applied.
'https://*.openpencil.dev',
'https://*.openpencil-app.pages.dev',
'http://localhost:*',
'http://127.0.0.1:*'
] as const
export function collectCloudCorsOrigins(extra?: string | null): string[] {
const set = new Set<string>(CLOUD_CORS_STATIC_ORIGINS)
if (extra?.trim()) set.add(extra.trim().replace(/\/+$/, ''))
if (IS_BROWSER && window.location.origin) {
set.add(window.location.origin)
}
return [...set].filter(Boolean).sort()
}
function escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
/** S3 PutBucketCors XML body (AWS + B2 S3-compatible). */
export function buildCorsConfigurationXml(origins: string[]): string {
const originTags = origins
.map((origin) => ` <AllowedOrigin>${escapeXml(origin)}</AllowedOrigin>`)
.join('\n')
return `<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration>
<CORSRule>
${originTags}
<AllowedMethod>GET</AllowedMethod>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>POST</AllowedMethod>
<AllowedMethod>DELETE</AllowedMethod>
<AllowedMethod>HEAD</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<ExposeHeader>ETag</ExposeHeader>
<ExposeHeader>x-amz-request-id</ExposeHeader>
<ExposeHeader>x-amz-id-2</ExposeHeader>
<ExposeHeader>x-amz-version-id</ExposeHeader>
<MaxAgeSeconds>3600</MaxAgeSeconds>
</CORSRule>
</CORSConfiguration>
`
}
/** AWS console / CLI JSON CORS document (copy-paste friendly). */
export function buildCorsConfigurationJson(origins: string[]): string {
return JSON.stringify(
[
{
AllowedHeaders: ['*'],
AllowedMethods: ['GET', 'PUT', 'POST', 'DELETE', 'HEAD'],
AllowedOrigins: origins,
ExposeHeaders: ['ETag', 'x-amz-request-id', 'x-amz-id-2', 'x-amz-version-id'],
MaxAgeSeconds: 3600
}
],
null,
2
)
}
export class CloudCorsError extends Error {
readonly kind = 'cors' as const
constructor(message: string) {
super(message)
this.name = 'CloudCorsError'
}
}
/** Best-effort detection of browser CORS / network blocks (preflight failures). */
export function isLikelyCorsOrNetworkError(error: unknown): boolean {
if (error instanceof CloudCorsError) return true
if (error instanceof TypeError) return true
if (!(error instanceof Error)) return false
const msg = error.message.toLowerCase()
return (
msg.includes('failed to fetch') ||
msg.includes('networkerror') ||
msg.includes('network request failed') ||
msg.includes('load failed') ||
msg.includes('cors') ||
msg.includes('access-control') ||
msg.includes('blocked by cors')
)
}
export function formatBrowserCorsHelpMessage(): string {
return (
'CORS issue: the browser blocked access to your bucket. ' +
'OpenPencil tried to set CORS automatically but could not from the web app ' +
'(the bucket must already allow this site, or use the desktop app once). ' +
'Click “Copy CORS JSON”, paste it into your bucket CORS settings, wait ~1 minute, then try again.'
)
}
/**
* Apply recommended CORS via S3 PutBucketCors same operation as AWS CLI put-bucket-cors.
* - Desktop: always works (no browser CORS on the request itself).
* - Web: only works if the bucket already allows this origin for PUT, or CORS was set externally.
*/
export async function putBucketCors(
config: S3CompatibleConfig,
origins: string[] = collectCloudCorsOrigins()
): Promise<void> {
const base = normalizeEndpointForCors(config.endpoint)
const url = `${base}/${encodeURIComponent(config.bucket)}?cors`
const body = buildCorsConfigurationXml(origins)
const client = createAwsClient(config)
let signed: Request
try {
signed = await client.sign(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/xml'
},
body,
credentials: 'omit'
})
} catch (error) {
throw error instanceof Error ? error : new Error(String(error))
}
let res: Response
try {
res = await storageFetch(signed)
} catch (error) {
if (isLikelyCorsOrNetworkError(error)) {
throw new CloudCorsError(
isTauri()
? 'Network error while applying bucket CORS.'
: 'Could not apply CORS from the browser (preflight blocked). Use Copy CORS JSON or the desktop app.'
)
}
throw error instanceof Error ? error : new Error(String(error))
}
if (!res.ok) {
const text = await res.text().catch(() => '')
if (res.status === 403 || res.status === 401) {
throw new S3HttpError(
res.status,
'Access key cannot update bucket CORS. Grant bucket write / PutBucketCors permission.'
)
}
throw new S3HttpError(
res.status,
text.trim().slice(0, 200) || `PutBucketCors failed with status ${res.status}`
)
}
}
export type EnsureCorsResult = {
applied: boolean
error: string | null
}
/**
* Always attempt PutBucketCors (automatic CORS setup for the web app origins).
* Returns whether it worked; never throws.
*/
export async function ensureWebCorsOnBucket(config: S3CompatibleConfig): Promise<EnsureCorsResult> {
const origins = collectCloudCorsOrigins()
try {
await putBucketCors(config, origins)
return { applied: true, error: null }
} catch (error) {
return {
applied: false,
error: error instanceof Error ? error.message : String(error)
}
}
}

View file

@ -0,0 +1,49 @@
import { isTauri } from '@/app/tauri/env'
/** Avoid hung “Test connection” when CORS/network never resolves. */
const STORAGE_FETCH_TIMEOUT_MS = 20_000
function withTimeoutSignal(init?: RequestInit): {
signal: AbortSignal
cleanup: () => void
} {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), STORAGE_FETCH_TIMEOUT_MS)
const external = init?.signal
if (external) {
if (external.aborted) controller.abort()
else external.addEventListener('abort', () => controller.abort(), { once: true })
}
return {
signal: controller.signal,
cleanup: () => clearTimeout(timer)
}
}
/** Prefer Tauri HTTP bridge on desktop to avoid bucket CORS requirements. */
export async function storageFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const { signal, cleanup } = withTimeoutSignal(init)
try {
if (isTauri()) {
const { tauriFetch } = await import('@/app/tauri/http')
return await tauriFetch(input, { ...init, signal })
}
// Request bodies are owned by the Request; re-wrap so we can attach a timeout signal.
if (input instanceof Request) {
return await fetch(new Request(input, { signal }))
}
return await fetch(input, { ...init, signal })
} catch (error) {
if (signal.aborted) {
throw new Error(
'Storage request timed out. Check the endpoint URL, network, and bucket CORS settings.'
)
}
throw error
} finally {
cleanup()
}
}

View file

@ -0,0 +1,41 @@
/**
* Infer SigV4 signing region from a common S3-compatible endpoint host.
* Region is not shown in the UI most providers encode it in the URL.
*/
export function inferS3Region(endpoint: string, fallback = 'us-east-1'): string {
const trimmed = endpoint.trim()
if (!trimmed) return fallback
let host: string
try {
const withProto =
trimmed.startsWith('http://') || trimmed.startsWith('https://')
? trimmed
: `https://${trimmed}`
host = new URL(withProto).hostname.toLowerCase()
} catch {
return fallback
}
// Backblaze B2: s3.eu-central-003.backblazeb2.com
const b2 = host.match(/^s3\.([a-z0-9-]+)\.backblazeb2\.com$/)
if (b2?.[1]) return b2[1]
// AWS path-style: s3.eu-west-1.amazonaws.com / s3-eu-west-1.amazonaws.com
const awsPath = host.match(/^s3[.-]([a-z0-9-]+)\.amazonaws\.com$/)
if (awsPath?.[1] && awsPath[1] !== 'dualstack' && awsPath[1] !== 'control') {
return awsPath[1]
}
// AWS virtual-hosted: bucket.s3.eu-west-1.amazonaws.com
const awsVirtual = host.match(/\.s3[.-]([a-z0-9-]+)\.amazonaws\.com$/)
if (awsVirtual?.[1] && awsVirtual[1] !== 'dualstack') return awsVirtual[1]
// Cloudflare R2
if (host.endsWith('.r2.cloudflarestorage.com') || host === 'r2.cloudflarestorage.com') {
return 'auto'
}
// MinIO / custom: no region in host — SigV4 still wants a string.
return fallback
}

View file

@ -0,0 +1,15 @@
export type S3CompatibleConfig = {
endpoint: string
bucket: string
accessKeyId: string
secretAccessKey: string
region?: string
}
export type S3ConnectionResult = {
ok: boolean
message: string
corsApplied: boolean
isCorsFailure: boolean
corsError: string | null
}

View file

@ -16,6 +16,7 @@ export type StorageDocumentMetadata = {
export type StorageDocument = StorageDocumentMetadata & {
id: string
thumbnailUrl?: string | null
metadataAuthoritative?: boolean
}
export type StorageUsage = {
@ -43,6 +44,7 @@ export interface StorageAdapter {
onProgress?: (progress: StorageTransferProgress) => void
): Promise<void>
deleteDocument(id: string): Promise<void>
getDocumentMetadata?(id: string): Promise<StorageDocumentMetadata | null>
getUsage(): Promise<StorageUsage>
getThumbnail?(id: string): Promise<Uint8Array | null>
putThumbnail?(id: string, bytes: Uint8Array): Promise<void>

View file

@ -0,0 +1,57 @@
import { describe, expect, test } from 'bun:test'
import {
buildCorsConfigurationJson,
buildCorsConfigurationXml,
collectCloudCorsOrigins,
isLikelyCorsOrNetworkError
} from '@/app/integrations/storage/s3/cors'
import { WEB_APP_ORIGIN } from '@/constants'
describe('cloud S3 CORS helpers', () => {
test('builds XML with required methods and wildcard headers', () => {
const xml = buildCorsConfigurationXml(['https://app.openpencil.dev', 'http://localhost:1420'])
expect(xml).toContain('<AllowedOrigin>https://app.openpencil.dev</AllowedOrigin>')
expect(xml).toContain('<AllowedOrigin>http://localhost:1420</AllowedOrigin>')
expect(xml).toContain('<AllowedMethod>GET</AllowedMethod>')
expect(xml).toContain('<AllowedMethod>PUT</AllowedMethod>')
expect(xml).toContain('<AllowedMethod>HEAD</AllowedMethod>')
expect(xml).toContain('<AllowedMethod>DELETE</AllowedMethod>')
expect(xml).toContain('<AllowedHeader>*</AllowedHeader>')
expect(xml).toContain('<ExposeHeader>ETag</ExposeHeader>')
})
test('escapes XML special characters in origins', () => {
const xml = buildCorsConfigurationXml(['https://example.com/a&b'])
expect(xml).toContain('https://example.com/a&amp;b')
})
test('builds AWS console JSON', () => {
const json = JSON.parse(buildCorsConfigurationJson(['https://app.openpencil.dev'])) as Array<{
AllowedOrigins: string[]
AllowedMethods: string[]
AllowedHeaders: string[]
}>
expect(json).toHaveLength(1)
expect(json[0]?.AllowedOrigins).toContain('https://app.openpencil.dev')
expect(json[0]?.AllowedMethods).toEqual(
expect.arrayContaining(['GET', 'PUT', 'POST', 'DELETE', 'HEAD'])
)
expect(json[0]?.AllowedHeaders).toEqual(['*'])
})
test('collects static web and localhost origins', () => {
const origins = collectCloudCorsOrigins()
expect(origins).toContain(WEB_APP_ORIGIN)
expect(origins).toContain('https://*.openpencil.dev')
expect(origins).toContain('https://*.openpencil-app.pages.dev')
expect(origins).toContain('http://localhost:*')
expect(origins).toContain('http://127.0.0.1:*')
})
test('detects typical browser CORS/network failures', () => {
expect(isLikelyCorsOrNetworkError(new TypeError('Failed to fetch'))).toBe(true)
expect(isLikelyCorsOrNetworkError(new Error('blocked by CORS policy'))).toBe(true)
expect(isLikelyCorsOrNetworkError(new Error('Access key invalid'))).toBe(false)
})
})

View file

@ -0,0 +1,44 @@
import { describe, expect, test } from 'bun:test'
import { documentIdFromFigKey } from '@/app/integrations/storage/namespace'
import { parseListObjectsV2Xml } from '@/app/integrations/storage/s3/client'
describe('parseListObjectsV2Xml', () => {
test('extracts keys and ignores objects outside canvas fig pattern when filtered', () => {
const xml = `<?xml version="1.0"?>
<ListBucketResult>
<Contents>
<Key>open_pencil_storage/canvases/a1.fig</Key>
<LastModified>2026-01-02T03:04:05.000Z</LastModified>
<Size>12</Size>
</Contents>
<Contents>
<Key>open_pencil_storage/canvases/a1.meta.json</Key>
<LastModified>2026-01-02T03:04:06.000Z</LastModified>
<Size>40</Size>
</Contents>
<Contents>
<Key>other-app/file.bin</Key>
<LastModified>2026-01-01T00:00:00.000Z</LastModified>
<Size>1</Size>
</Contents>
</ListBucketResult>`
const listed = parseListObjectsV2Xml(xml)
expect(listed).toHaveLength(3)
expect(listed[0]?.key).toBe('open_pencil_storage/canvases/a1.fig')
expect(listed[0]?.lastModified).toBe('2026-01-02T03:04:05.000Z')
expect(listed[0]?.size).toBe(12)
const canvasIds = listed
.map((object) => documentIdFromFigKey(object.key))
.filter((id): id is string => id != null)
expect(canvasIds).toEqual(['a1'])
})
test('decodes basic XML entities in keys', () => {
const xml = `<ListBucketResult><Contents><Key>open_pencil_storage/canvases/a&amp;b.fig</Key><Size>1</Size></Contents></ListBucketResult>`
const listed = parseListObjectsV2Xml(xml)
expect(listed[0]?.key).toBe('open_pencil_storage/canvases/a&b.fig')
})
})

View file

@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test'
import {
STORAGE_DOCUMENTS_PREFIX,
STORAGE_NAMESPACE,
STORAGE_NAMESPACE_MARKER,
documentFigKey,
documentIdFromFigKey,
documentMetaKey,
documentThumbnailKey
} from '@/app/integrations/storage/namespace'
describe('storage namespace', () => {
test('uses the fixed open_pencil_storage prefix', () => {
expect(STORAGE_NAMESPACE).toBe('open_pencil_storage')
expect(STORAGE_NAMESPACE_MARKER.startsWith(`${STORAGE_NAMESPACE}/`)).toBe(true)
expect(STORAGE_DOCUMENTS_PREFIX).toBe('open_pencil_storage/canvases/')
})
test('builds document object keys inside the namespace', () => {
const id = 'abc-123'
expect(documentFigKey(id)).toBe('open_pencil_storage/canvases/abc-123.fig')
expect(documentMetaKey(id)).toBe('open_pencil_storage/canvases/abc-123.meta.json')
expect(documentThumbnailKey(id)).toBe('open_pencil_storage/canvases/abc-123.thumb.jpg')
})
test('parses document IDs from fig keys and ignores foreign keys', () => {
expect(documentIdFromFigKey('open_pencil_storage/canvases/uuid-1.fig')).toBe('uuid-1')
expect(documentIdFromFigKey('other_prefix/canvases/uuid-1.fig')).toBeNull()
expect(documentIdFromFigKey('open_pencil_storage/canvases/nested/uuid-1.fig')).toBeNull()
expect(documentIdFromFigKey('open_pencil_storage/canvases/uuid-1.meta.json')).toBeNull()
})
})

View file

@ -0,0 +1,23 @@
import { describe, expect, test } from 'bun:test'
import { inferS3Region } from '@/app/integrations/storage/s3/region'
describe('inferS3Region', () => {
test('parses Backblaze B2 endpoints', () => {
expect(inferS3Region('https://s3.eu-central-003.backblazeb2.com')).toBe('eu-central-003')
expect(inferS3Region('s3.us-west-004.backblazeb2.com')).toBe('us-west-004')
})
test('parses AWS path-style endpoints', () => {
expect(inferS3Region('https://s3.eu-west-1.amazonaws.com')).toBe('eu-west-1')
expect(inferS3Region('https://s3-us-east-1.amazonaws.com')).toBe('us-east-1')
})
test('uses auto for Cloudflare R2', () => {
expect(inferS3Region('https://abc123.r2.cloudflarestorage.com')).toBe('auto')
})
test('falls back for custom MinIO hosts', () => {
expect(inferS3Region('https://minio.example.com')).toBe('us-east-1')
})
})

View file

@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'
import {
defineStorageProvider,
storageProviderRegistry,
StorageProviderRegistry,
type StorageAdapter,
type StorageProviderRuntime
@ -50,6 +51,20 @@ function testProvider() {
}
describe('storage provider registry', () => {
test('registers S3 preferences separately from credential fields', () => {
const provider = storageProviderRegistry.get('s3-compatible')
expect(provider.preferenceFields.map((field) => field.id)).toEqual([
'endpoint',
'bucket',
'region'
])
expect(provider.credentialFields.map((field) => field.id)).toEqual([
'access-key-id',
'secret-access-key'
])
})
test('lists provider schemas without resolving credentials', () => {
let resolutionCount = 0
const credentials: CredentialResolver = {