Files
readest/apps/readest-app/src/libs/replicaSchemas.ts
T
Huang Xin 6bfeb295d2 feat(sync): add opds_catalog replica kind (plaintext fields) (#4087)
Wires OPDS catalogs through replica sync as a metadata-only kind.
Plaintext fields only in this PR — encrypted credentials (username,
password) ship in the follow-up alongside the SyncPassphrasePanel UI
and Tauri keychain backend.

- Migration 009 extends the kind allowlist with 'opds_catalog'.
- replicaSchemas adds opdsCatalogFieldsSchema (name, url, description,
  icon, customHeaders, autoDownload, disabled, addedAt) with a 50-row
  per-user cap.
- New opdsCatalogAdapter is metadata-only (no `binary` capability).
  Stable cross-device id from md5("opds:" + url.lower()) so two
  devices that import the same URL converge to one row instead of
  duplicating.
- New customOPDSStore (zustand) hydrates from SystemSettings,
  publishes upserts/deletes through the replica pipeline, preserves
  local-only username/password when overlaying remote updates, and
  strips tombstones at the persistence boundary so existing
  useSettingsStore readers (useOPDSSubscriptions, pseStream,
  app/opds/page.tsx) need no migration.
- replicaPullAndApply branches on adapter.binary so metadata-only
  kinds skip the bundleDir requirement and the manifest/binary path.
- CatalogManager rewires Add / Edit / Remove / Toggle / Add-popular
  through the new store.

Plan update bundled in: tenet 8 (scalar settings sync via a bundled
row; collections sync per-record), per-kind allowlist now includes a
`settings` singleton that will collapse PRs 5 + 6+ into one bundled
adapter, and PR 4 is split into 4a (already merged) / 4b (this) / 4c
(encrypted credentials + UX).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 05:33:18 +02:00

227 lines
6.2 KiB
TypeScript

import { z } from 'zod';
import type { ReplicaRow } from '@/types/replica';
import type { SyncErrorCode } from '@/libs/errors';
export const MAX_JSON_BYTES = 64 * 1024;
export const MAX_FIELD_COUNT = 64;
export const MAX_FILENAME_LEN = 255;
const fieldEnvelopeSchema = z.object({
v: z.unknown(),
t: z.string(),
s: z.string(),
});
const cipherEnvelopeSchema = z.object({
c: z.string(),
i: z.string(),
s: z.string(),
alg: z.string(),
h: z.string(),
});
const fieldEnvelopeWithCipher = z.union([fieldEnvelopeSchema, cipherEnvelopeSchema]);
const fieldsObjectSchema = z.record(z.string(), fieldEnvelopeWithCipher);
const dictionaryFieldsSchema = z
.object({
name: fieldEnvelopeSchema.optional(),
enabled: fieldEnvelopeSchema.optional(),
lang: fieldEnvelopeSchema.optional(),
})
.catchall(fieldEnvelopeWithCipher);
const fontFieldsSchema = z
.object({
name: fieldEnvelopeSchema.optional(),
family: fieldEnvelopeSchema.optional(),
style: fieldEnvelopeSchema.optional(),
weight: fieldEnvelopeSchema.optional(),
variable: fieldEnvelopeSchema.optional(),
byteSize: fieldEnvelopeSchema.optional(),
downloadedAt: fieldEnvelopeSchema.optional(),
})
.catchall(fieldEnvelopeWithCipher);
const textureFieldsSchema = z
.object({
name: fieldEnvelopeSchema.optional(),
byteSize: fieldEnvelopeSchema.optional(),
downloadedAt: fieldEnvelopeSchema.optional(),
})
.catchall(fieldEnvelopeWithCipher);
const opdsCatalogFieldsSchema = z
.object({
name: fieldEnvelopeSchema.optional(),
url: fieldEnvelopeSchema.optional(),
description: fieldEnvelopeSchema.optional(),
icon: fieldEnvelopeSchema.optional(),
customHeaders: fieldEnvelopeSchema.optional(),
autoDownload: fieldEnvelopeSchema.optional(),
disabled: fieldEnvelopeSchema.optional(),
addedAt: fieldEnvelopeSchema.optional(),
})
.catchall(fieldEnvelopeWithCipher);
interface KindSpec {
minSchemaVersion: number;
maxSchemaVersion: number;
maxRowsPerUser: number;
fields: z.ZodTypeAny;
binary: boolean;
}
export const KIND_ALLOWLIST: Record<string, KindSpec> = {
dictionary: {
minSchemaVersion: 1,
maxSchemaVersion: 1,
maxRowsPerUser: 200,
fields: dictionaryFieldsSchema,
binary: true,
},
font: {
minSchemaVersion: 1,
maxSchemaVersion: 1,
maxRowsPerUser: 500,
fields: fontFieldsSchema,
binary: true,
},
texture: {
minSchemaVersion: 1,
maxSchemaVersion: 1,
maxRowsPerUser: 200,
fields: textureFieldsSchema,
binary: true,
},
opds_catalog: {
minSchemaVersion: 1,
maxSchemaVersion: 1,
maxRowsPerUser: 50,
fields: opdsCatalogFieldsSchema,
binary: false,
},
};
export const isAllowedKind = (kind: string): boolean => Object.hasOwn(KIND_ALLOWLIST, kind);
export interface FilenameOk {
ok: true;
}
export interface FilenameError {
ok: false;
reason: string;
}
export const validateFilename = (name: string): FilenameOk | FilenameError => {
if (name.length === 0) return { ok: false, reason: 'empty' };
if (name.length > MAX_FILENAME_LEN) return { ok: false, reason: 'too long' };
if (name === '.' || name === '..') return { ok: false, reason: 'dot path' };
if (name.includes('/') || name.includes('\\')) return { ok: false, reason: 'path separator' };
if (name.includes('..')) return { ok: false, reason: 'path traversal' };
for (let i = 0; i < name.length; i++) {
const code = name.charCodeAt(i);
if (code < 0x20 || code === 0x7f) {
return { ok: false, reason: 'control character' };
}
}
return { ok: true };
};
export type ValidationResult =
| { ok: true }
| { ok: false; code: SyncErrorCode; message: string; cause?: unknown };
const measureJsonBytes = (value: unknown): number =>
new TextEncoder().encode(JSON.stringify(value)).length;
const manifestFileSchema = z.object({
filename: z.string(),
byteSize: z.number().int().nonnegative(),
partialMd5: z.string(),
mtime: z.number().optional(),
});
const manifestSchema = z.object({
files: z.array(manifestFileSchema),
schemaVersion: z.number().int(),
});
export const validateRow = (row: ReplicaRow): ValidationResult => {
if (!isAllowedKind(row.kind)) {
return { ok: false, code: 'UNKNOWN_KIND', message: `Unknown kind: ${row.kind}` };
}
const spec = KIND_ALLOWLIST[row.kind]!;
if (row.schema_version < spec.minSchemaVersion || row.schema_version > spec.maxSchemaVersion) {
return {
ok: false,
code: 'SCHEMA_TOO_NEW',
message: `schemaVersion ${row.schema_version} out of bounds [${spec.minSchemaVersion}, ${spec.maxSchemaVersion}] for kind ${row.kind}`,
};
}
const fieldKeys = Object.keys(row.fields_jsonb);
if (fieldKeys.length > MAX_FIELD_COUNT) {
return {
ok: false,
code: 'VALIDATION',
message: `field count ${fieldKeys.length} exceeds MAX_FIELD_COUNT=${MAX_FIELD_COUNT}`,
};
}
const fieldsBytes = measureJsonBytes(row.fields_jsonb);
if (fieldsBytes > MAX_JSON_BYTES) {
return {
ok: false,
code: 'VALIDATION',
message: `fields_jsonb size ${fieldsBytes}B exceeds MAX_JSON_BYTES=${MAX_JSON_BYTES}`,
};
}
const fieldsParse = fieldsObjectSchema.safeParse(row.fields_jsonb);
if (!fieldsParse.success) {
return {
ok: false,
code: 'VALIDATION',
message: 'malformed field envelope',
cause: fieldsParse.error,
};
}
const kindParse = spec.fields.safeParse(row.fields_jsonb);
if (!kindParse.success) {
return {
ok: false,
code: 'VALIDATION',
message: `kind=${row.kind} fields validation failed`,
cause: kindParse.error,
};
}
if (row.manifest_jsonb !== null) {
const manifestParse = manifestSchema.safeParse(row.manifest_jsonb);
if (!manifestParse.success) {
return {
ok: false,
code: 'VALIDATION',
message: 'malformed manifest_jsonb',
cause: manifestParse.error,
};
}
for (const file of row.manifest_jsonb.files) {
const fnCheck = validateFilename(file.filename);
if (!fnCheck.ok) {
return {
ok: false,
code: 'VALIDATION',
message: `manifest filename invalid: ${file.filename} (${fnCheck.reason})`,
};
}
}
}
return { ok: true };
};