Compare commits
3 Commits
4b9018c033
...
90748b743e
| Author | SHA1 | Date | |
|---|---|---|---|
| 90748b743e | |||
| 3310ae3926 | |||
| 8752cf0c5a |
@ -40,6 +40,7 @@ export const AUDIT_EVENTS = {
|
|||||||
'plugin.mode_set': { severity: 'notice' },
|
'plugin.mode_set': { severity: 'notice' },
|
||||||
'plugin.pond_toggled': { severity: 'info' },
|
'plugin.pond_toggled': { severity: 'info' },
|
||||||
'plugin.uninstalled': { severity: 'notice' },
|
'plugin.uninstalled': { severity: 'notice' },
|
||||||
|
'pond.archived': { severity: 'notice' },
|
||||||
'pond.purged': { severity: 'notice' },
|
'pond.purged': { severity: 'notice' },
|
||||||
'quota.override_cleared': { severity: 'notice' },
|
'quota.override_cleared': { severity: 'notice' },
|
||||||
'quota.override_set': { severity: 'notice' },
|
'quota.override_set': { severity: 'notice' },
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
@ -19,11 +21,14 @@ import {
|
|||||||
LOGO_VARIANTS,
|
LOGO_VARIANTS,
|
||||||
LogoVariant,
|
LogoVariant,
|
||||||
MAX_BRANDING_BYTES,
|
MAX_BRANDING_BYTES,
|
||||||
|
PondBranding,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||||
import { AuthedRequest, Public } from '../auth/auth.guard';
|
import { AuthedRequest, Public } from '../auth/auth.guard';
|
||||||
|
import { RequiresPondRole } from '../permissions/permission.decorators';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { BrandingService } from './branding.service';
|
import { BrandingService } from './branding.service';
|
||||||
|
|
||||||
function parseVariant(value: unknown): LogoVariant {
|
function parseVariant(value: unknown): LogoVariant {
|
||||||
@ -52,8 +57,19 @@ export class BrandingController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get('logo')
|
@Get('logo')
|
||||||
async logo(@Query('variant') variant: string | undefined, @Res() res: Response): Promise<void> {
|
async logo(
|
||||||
const bytes = await this.branding.logoBytes(parseVariant(variant ?? 'light'));
|
@Query('variant') variant: string | undefined,
|
||||||
|
@Query('pond') pondId: string | undefined,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const wanted = parseVariant(variant ?? 'light');
|
||||||
|
// A pond scope serves the pond's own bytes and nothing else: the caller
|
||||||
|
// already resolved WHICH level applies (`resolveBranding`), so silently
|
||||||
|
// falling back here would mix variants across levels — exactly what #307
|
||||||
|
// forbids.
|
||||||
|
const bytes = pondId
|
||||||
|
? await this.branding.pondLogoBytes(pondId, wanted)
|
||||||
|
: await this.branding.logoBytes(wanted);
|
||||||
// No shipped default: without a logo the app renders the instance NAME as
|
// No shipped default: without a logo the app renders the instance NAME as
|
||||||
// text, so an empty answer here is the honest one.
|
// text, so an empty answer here is the honest one.
|
||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
@ -69,12 +85,21 @@ export class BrandingController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get('favicon')
|
@Get('favicon')
|
||||||
async favicon(@Query('size') size: string | undefined, @Res() res: Response): Promise<void> {
|
async favicon(
|
||||||
|
@Query('size') size: string | undefined,
|
||||||
|
@Query('pond') pondId: string | undefined,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
const wanted = Number(size ?? 32);
|
const wanted = Number(size ?? 32);
|
||||||
if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) {
|
if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) {
|
||||||
throw new BadRequestException({ code: 'bad_request' });
|
throw new BadRequestException({ code: 'bad_request' });
|
||||||
}
|
}
|
||||||
const { bytes, uploaded } = await this.branding.faviconBytes(wanted as FaviconSize);
|
const pondBytes = pondId
|
||||||
|
? await this.branding.pondFaviconBytes(pondId, wanted as FaviconSize)
|
||||||
|
: null;
|
||||||
|
const { bytes, uploaded } = pondBytes
|
||||||
|
? { bytes: pondBytes, uploaded: true }
|
||||||
|
: await this.branding.faviconBytes(wanted as FaviconSize);
|
||||||
res.setHeader('Content-Type', 'image/png');
|
res.setHeader('Content-Type', 'image/png');
|
||||||
// The `<link rel="icon">` href is a constant in index.html, so this URL
|
// The `<link rel="icon">` href is a constant in index.html, so this URL
|
||||||
// cannot carry a hash — revalidation is the only way a replaced favicon
|
// cannot carry a hash — revalidation is the only way a replaced favicon
|
||||||
@ -133,3 +158,96 @@ export class BrandingAdminController {
|
|||||||
return this.branding.clearFavicon(request.user!);
|
return this.branding.clearFavicon(request.user!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pond-level branding (issue #307). The uploader here is an ordinary Pond
|
||||||
|
* Admin rather than the operator, so the security rules of #306 are not
|
||||||
|
* relaxed by a single line: SVG refused, magic bytes checked server-side,
|
||||||
|
* size caps enforced, content type pinned on serving, no image parsing.
|
||||||
|
*
|
||||||
|
* 404/403 policy: a user who cannot see the pond gets 404 from the pond-role
|
||||||
|
* guard, one who can see but not administer it gets 403.
|
||||||
|
*/
|
||||||
|
@Controller('ponds/:pondId/branding')
|
||||||
|
export class PondBrandingController {
|
||||||
|
constructor(
|
||||||
|
private readonly branding: BrandingService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** The pond row the quota is charged to. */
|
||||||
|
private async pondOf(pondId: string): Promise<{ id: string; ownerId: string }> {
|
||||||
|
const pond = await this.prisma.pond.findUnique({
|
||||||
|
where: { id: pondId },
|
||||||
|
select: { id: true, ownerId: true },
|
||||||
|
});
|
||||||
|
if (!pond) throw new NotFoundException();
|
||||||
|
return pond;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequiresPondRole('reader', { idParam: 'pondId' })
|
||||||
|
view(@Param('pondId') pondId: string): Promise<PondBranding> {
|
||||||
|
return this.branding.pondBranding(pondId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logo')
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
||||||
|
async setLogo(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Query('variant') variant: string | undefined,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const file = files?.find((entry) => entry.fieldname === 'file');
|
||||||
|
if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
|
||||||
|
return this.branding.setPondLogo(
|
||||||
|
request.user!,
|
||||||
|
await this.pondOf(pondId),
|
||||||
|
parseVariant(variant ?? 'light'),
|
||||||
|
file.buffer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('logo')
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
async clearLogo(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Query('variant') variant: string | undefined,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
return this.branding.clearPondLogo(
|
||||||
|
request.user!,
|
||||||
|
await this.pondOf(pondId),
|
||||||
|
parseVariant(variant ?? 'light'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('favicon')
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
||||||
|
async setFavicon(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer]));
|
||||||
|
const collected = {} as Record<FaviconSize, Buffer>;
|
||||||
|
for (const size of FAVICON_SIZES) {
|
||||||
|
const bytes = byField.get(`png-${size}`);
|
||||||
|
if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' });
|
||||||
|
collected[size] = bytes;
|
||||||
|
}
|
||||||
|
return this.branding.setPondFavicon(request.user!, await this.pondOf(pondId), collected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('favicon')
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
async clearFavicon(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
return this.branding.clearPondFavicon(request.user!, await this.pondOf(pondId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { BrandingAdminController, BrandingController } from './branding.controller';
|
import { PermissionsModule } from '../permissions/permissions.module';
|
||||||
|
import { QuotasModule } from '../quotas/quotas.module';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BrandingAdminController,
|
||||||
|
BrandingController,
|
||||||
|
PondBrandingController,
|
||||||
|
} from './branding.controller';
|
||||||
import { BrandingStorageService } from './branding-storage.service';
|
import { BrandingStorageService } from './branding-storage.service';
|
||||||
import { BrandingService } from './branding.service';
|
import { BrandingService } from './branding.service';
|
||||||
|
|
||||||
@ -8,7 +15,8 @@ import { BrandingService } from './branding.service';
|
|||||||
* the pond-level override (#307) can build on the same storage and the same
|
* the pond-level override (#307) can build on the same storage and the same
|
||||||
* resolution path instead of a parallel one. */
|
* resolution path instead of a parallel one. */
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [BrandingController, BrandingAdminController],
|
imports: [PermissionsModule, QuotasModule],
|
||||||
|
controllers: [BrandingController, BrandingAdminController, PondBrandingController],
|
||||||
providers: [BrandingService, BrandingStorageService],
|
providers: [BrandingService, BrandingStorageService],
|
||||||
exports: [BrandingService, BrandingStorageService],
|
exports: [BrandingService, BrandingStorageService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,12 +2,16 @@ import { createHash } from 'node:crypto';
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
BrandingAsset,
|
BrandingAsset,
|
||||||
BrandingView,
|
BrandingView,
|
||||||
|
FAVICON_SIZES,
|
||||||
FaviconSize,
|
FaviconSize,
|
||||||
|
LOGO_VARIANTS,
|
||||||
LogoVariant,
|
LogoVariant,
|
||||||
|
PondBranding,
|
||||||
|
pondSettingsSchema,
|
||||||
MAX_BRANDING_BYTES,
|
MAX_BRANDING_BYTES,
|
||||||
MAX_LOGO_EDGE,
|
MAX_LOGO_EDGE,
|
||||||
hasPngMagic,
|
hasPngMagic,
|
||||||
@ -17,6 +21,8 @@ import {
|
|||||||
import { User } from '@prisma/client';
|
import { User } from '@prisma/client';
|
||||||
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { QuotaService } from '../quotas/quota.service';
|
||||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
import { BrandingStorageService } from './branding-storage.service';
|
import { BrandingStorageService } from './branding-storage.service';
|
||||||
|
|
||||||
@ -41,6 +47,8 @@ export class BrandingService {
|
|||||||
private readonly settings: InstanceSettingsService,
|
private readonly settings: InstanceSettingsService,
|
||||||
private readonly storage: BrandingStorageService,
|
private readonly storage: BrandingStorageService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly quotas: QuotaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
static logoKey(variant: LogoVariant): string {
|
static logoKey(variant: LogoVariant): string {
|
||||||
@ -51,6 +59,26 @@ export class BrandingService {
|
|||||||
return `instance-favicon-${size}`;
|
return `instance-favicon-${size}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pond assets share the directory and the naming rules (issue #307); the
|
||||||
|
* pond id keeps them apart and makes purge a prefix delete. */
|
||||||
|
static pondLogoKey(pondId: string, variant: LogoVariant): string {
|
||||||
|
return `pond-${pondId}-logo-${variant}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static pondFaviconKey(pondId: string, size: FaviconSize): string {
|
||||||
|
return `pond-${pondId}-favicon-${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every branding file a pond can own — the purge deletes exactly this set
|
||||||
|
* (issue #307). The purge standard is absolute: after it, nothing
|
||||||
|
* referencing the pond survives, rows or files. */
|
||||||
|
static pondKeys(pondId: string): string[] {
|
||||||
|
return [
|
||||||
|
...LOGO_VARIANTS.map((variant) => BrandingService.pondLogoKey(pondId, variant)),
|
||||||
|
...FAVICON_SIZES.map((size) => BrandingService.pondFaviconKey(pondId, size)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rejects anything that is not a PNG within the caps, before a byte is
|
* Rejects anything that is not a PNG within the caps, before a byte is
|
||||||
* written. SVG gets its own message: an operator who tried one deserves to
|
* written. SVG gets its own message: an operator who tried one deserves to
|
||||||
@ -71,11 +99,37 @@ export class BrandingService {
|
|||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserve the pond's storage for a branding asset, releasing what the asset
|
||||||
|
* it replaces occupied. Doing it in that order means replacing a logo with
|
||||||
|
* one of the same size costs nothing — otherwise every re-upload would eat
|
||||||
|
* the quota again, which is how "a pond admin fills the disk with logos"
|
||||||
|
* happens.
|
||||||
|
*/
|
||||||
|
private async chargeQuota(
|
||||||
|
pond: { id: string; ownerId: string },
|
||||||
|
bytes: number,
|
||||||
|
previous: BrandingAsset | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
|
||||||
|
try {
|
||||||
|
await this.quotas.checkAndConsume(pond.id, pond.ownerId, bytes);
|
||||||
|
} catch (error) {
|
||||||
|
// Put the released reservation back: a refused upload must not leave
|
||||||
|
// the pond with MORE room than before.
|
||||||
|
if (previous?.byteSize) {
|
||||||
|
await this.quotas.checkAndConsume(pond.id, pond.ownerId, previous.byteSize);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private assetOf(bytes: Buffer, size: { width: number; height: number }): BrandingAsset {
|
private assetOf(bytes: Buffer, size: { width: number; height: number }): BrandingAsset {
|
||||||
return {
|
return {
|
||||||
// Short digest: it only has to change when the bytes change, and it
|
// Short digest: it only has to change when the bytes change, and it
|
||||||
// travels in every logo URL.
|
// travels in every logo URL.
|
||||||
hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
|
hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
|
||||||
|
byteSize: bytes.length,
|
||||||
...size,
|
...size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -164,6 +218,150 @@ export class BrandingService {
|
|||||||
return { bytes, uploaded: false };
|
return { bytes, uploaded: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The pond's own branding, defaulted — one place reads the settings blob. */
|
||||||
|
async pondBranding(pondId: string): Promise<PondBranding> {
|
||||||
|
const pond = await this.prisma.pond.findUnique({
|
||||||
|
where: { id: pondId },
|
||||||
|
select: { settings: true },
|
||||||
|
});
|
||||||
|
if (!pond) throw new NotFoundException();
|
||||||
|
return pondSettingsSchema.parse(pond.settings ?? {}).branding;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writePondBranding(
|
||||||
|
actor: User,
|
||||||
|
pondId: string,
|
||||||
|
next: PondBranding,
|
||||||
|
asset: 'logo' | 'logoDark' | 'favicon',
|
||||||
|
change: 'set' | 'cleared',
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const pond = await this.prisma.pond.findUniqueOrThrow({
|
||||||
|
where: { id: pondId },
|
||||||
|
select: { settings: true },
|
||||||
|
});
|
||||||
|
const settings = pondSettingsSchema.parse(pond.settings ?? {});
|
||||||
|
await this.prisma.pond.update({
|
||||||
|
where: { id: pondId },
|
||||||
|
data: { settings: { ...settings, branding: next } as object },
|
||||||
|
});
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'branding.changed',
|
||||||
|
actorId: actor.id,
|
||||||
|
targetType: 'pond',
|
||||||
|
targetId: pondId,
|
||||||
|
details: { scope: 'pond', pondId, asset, change },
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pond logo, charged to the pond's storage quota (issue #307).
|
||||||
|
*
|
||||||
|
* Without the charge, branding would be a way around the quota — and
|
||||||
|
* replacing a logo repeatedly would let a pond admin consume disk with no
|
||||||
|
* ceiling. Charged BEFORE the write, like attachments, so a race never
|
||||||
|
* leaves bytes on the volume without a reservation; the bytes a replaced
|
||||||
|
* asset frees are released first, so re-uploading the same logo is free
|
||||||
|
* rather than cumulative.
|
||||||
|
*/
|
||||||
|
async setPondLogo(
|
||||||
|
actor: User,
|
||||||
|
pond: { id: string; ownerId: string },
|
||||||
|
variant: LogoVariant,
|
||||||
|
bytes: Buffer,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE);
|
||||||
|
const current = await this.pondBranding(pond.id);
|
||||||
|
const previous = variant === 'dark' ? current.logoDark : current.logo;
|
||||||
|
await this.chargeQuota(pond, bytes.length, previous);
|
||||||
|
await this.storage.save(BrandingService.pondLogoKey(pond.id, variant), bytes);
|
||||||
|
const asset = this.assetOf(bytes, size);
|
||||||
|
return this.writePondBranding(
|
||||||
|
actor,
|
||||||
|
pond.id,
|
||||||
|
variant === 'dark' ? { ...current, logoDark: asset } : { ...current, logo: asset },
|
||||||
|
variant === 'dark' ? 'logoDark' : 'logo',
|
||||||
|
'set',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearPondLogo(
|
||||||
|
actor: User,
|
||||||
|
pond: { id: string; ownerId: string },
|
||||||
|
variant: LogoVariant,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const current = await this.pondBranding(pond.id);
|
||||||
|
const previous = variant === 'dark' ? current.logoDark : current.logo;
|
||||||
|
await this.storage.remove(BrandingService.pondLogoKey(pond.id, variant));
|
||||||
|
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
|
||||||
|
return this.writePondBranding(
|
||||||
|
actor,
|
||||||
|
pond.id,
|
||||||
|
variant === 'dark' ? { ...current, logoDark: null } : { ...current, logo: null },
|
||||||
|
variant === 'dark' ? 'logoDark' : 'logo',
|
||||||
|
'cleared',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPondFavicon(
|
||||||
|
actor: User,
|
||||||
|
pond: { id: string; ownerId: string },
|
||||||
|
files: Record<FaviconSize, Buffer>,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const checked = Object.entries(files).map(([declared, bytes]) => {
|
||||||
|
const size = this.assertUsablePng(bytes, 512);
|
||||||
|
const expected = Number(declared);
|
||||||
|
if (size.width !== expected || size.height !== expected) {
|
||||||
|
throw new BadRequestException({ code: 'branding_favicon_not_square' });
|
||||||
|
}
|
||||||
|
return { expected: expected as FaviconSize, bytes, size };
|
||||||
|
});
|
||||||
|
const current = await this.pondBranding(pond.id);
|
||||||
|
const total = checked.reduce((sum, entry) => sum + entry.bytes.length, 0);
|
||||||
|
await this.chargeQuota(pond, total, current.favicon);
|
||||||
|
for (const entry of checked) {
|
||||||
|
await this.storage.save(BrandingService.pondFaviconKey(pond.id, entry.expected), entry.bytes);
|
||||||
|
}
|
||||||
|
const small = checked.find((entry) => entry.expected === 32)!;
|
||||||
|
// The pair is charged together, so the stored size is the pair's — that
|
||||||
|
// is what a later release has to give back.
|
||||||
|
const asset = { ...this.assetOf(small.bytes, small.size), byteSize: total };
|
||||||
|
return this.writePondBranding(actor, pond.id, { ...current, favicon: asset }, 'favicon', 'set');
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearPondFavicon(
|
||||||
|
actor: User,
|
||||||
|
pond: { id: string; ownerId: string },
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const current = await this.pondBranding(pond.id);
|
||||||
|
for (const size of FAVICON_SIZES) {
|
||||||
|
await this.storage.remove(BrandingService.pondFaviconKey(pond.id, size));
|
||||||
|
}
|
||||||
|
if (current.favicon?.byteSize) await this.quotas.release(pond.id, current.favicon.byteSize);
|
||||||
|
return this.writePondBranding(
|
||||||
|
actor,
|
||||||
|
pond.id,
|
||||||
|
{ ...current, favicon: null },
|
||||||
|
'favicon',
|
||||||
|
'cleared',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bytes for a pond asset — null when the pond has none at that slot, which
|
||||||
|
* is what makes the caller fall back to the instance level. */
|
||||||
|
pondLogoBytes(pondId: string, variant: LogoVariant): Promise<Buffer | null> {
|
||||||
|
return this.storage.read(BrandingService.pondLogoKey(pondId, variant));
|
||||||
|
}
|
||||||
|
|
||||||
|
pondFaviconBytes(pondId: string, size: FaviconSize): Promise<Buffer | null> {
|
||||||
|
return this.storage.read(BrandingService.pondFaviconKey(pondId, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes every branding file of a pond (issue #307's purge obligation). */
|
||||||
|
async removePondAssets(pondId: string): Promise<void> {
|
||||||
|
for (const key of BrandingService.pondKeys(pondId)) await this.storage.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
private record(
|
private record(
|
||||||
admin: User,
|
admin: User,
|
||||||
asset: 'logo' | 'logoDark' | 'favicon',
|
asset: 'logo' | 'logoDark' | 'favicon',
|
||||||
|
|||||||
256
apps/api/src/branding/pond-branding.e2e.db.test.ts
Normal file
256
apps/api/src/branding/pond-branding.e2e.db.test.ts
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { deflateSync } from 'node:zlib';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import {
|
||||||
|
createTestPrisma,
|
||||||
|
deletePondsWhere,
|
||||||
|
grantOwnerAdmin,
|
||||||
|
hasTestDb,
|
||||||
|
uniqueSuffix,
|
||||||
|
} from '../testing/test-db';
|
||||||
|
import { TrashService } from '../trash/trash.service';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
import { BrandingService } from './branding.service';
|
||||||
|
import { BrandingStorageService } from './branding-storage.service';
|
||||||
|
|
||||||
|
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
return c >>> 0;
|
||||||
|
});
|
||||||
|
function crc32(buf: Buffer): number {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
function chunk(type: string, data: Buffer): Buffer {
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(data.length);
|
||||||
|
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||||
|
const crc = Buffer.alloc(4);
|
||||||
|
crc.writeUInt32BE(crc32(body));
|
||||||
|
return Buffer.concat([length, body, crc]);
|
||||||
|
}
|
||||||
|
/** A real PNG — the api reads the IHDR, so the header has to be genuine. */
|
||||||
|
function png(size: number): Buffer {
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(size, 0);
|
||||||
|
ihdr.writeUInt32BE(size, 4);
|
||||||
|
ihdr[8] = 8;
|
||||||
|
ihdr[9] = 6;
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
chunk('IHDR', ihdr),
|
||||||
|
chunk('IDAT', deflateSync(Buffer.alloc(size * (size * 4 + 1)))),
|
||||||
|
chunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!hasTestDb)('pond branding (e2e, issue #307)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let storage: BrandingStorageService;
|
||||||
|
let brandingDir: string;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'teichmarke mit eigenem logo 1';
|
||||||
|
const owner = { username: `pb-${suffix}` };
|
||||||
|
const member = { username: `pbm-${suffix}` };
|
||||||
|
let ownerCookie: string;
|
||||||
|
let memberCookie: string;
|
||||||
|
let pondId: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-pondbranding-'));
|
||||||
|
process.env.BRANDING_DIR = brandingDir;
|
||||||
|
app = await createTestApp();
|
||||||
|
storage = app.get(BrandingStorageService);
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const tokens = app.get(AuthTokensService);
|
||||||
|
// Verification through the endpoint, not `markEmailVerified`: only this
|
||||||
|
// path creates the personal pond these tests brand.
|
||||||
|
const verify = async (userId: string): Promise<void> => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/verify-email')
|
||||||
|
.send({ token: await tokens.issue(userId, 'EMAIL_VERIFICATION', 600) })
|
||||||
|
.expect(204);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ownerUser = await users.createUser({
|
||||||
|
username: owner.username,
|
||||||
|
email: `${owner.username}@example.org`,
|
||||||
|
displayName: `Pond Branding Owner ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await verify(ownerUser.id);
|
||||||
|
const memberUser = await users.createUser({
|
||||||
|
username: member.username,
|
||||||
|
email: `${member.username}@example.org`,
|
||||||
|
displayName: `Pond Branding Member ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await verify(memberUser.id);
|
||||||
|
|
||||||
|
const login = async (username: string): Promise<string> =>
|
||||||
|
sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
ownerCookie = await login(owner.username);
|
||||||
|
memberCookie = await login(member.username);
|
||||||
|
|
||||||
|
pondId = (
|
||||||
|
await prisma.pond.findFirstOrThrow({ where: { ownerId: ownerUser.id, type: 'PERSONAL' } })
|
||||||
|
).id;
|
||||||
|
// A reader on the same pond: may see it, may not administer it. Through
|
||||||
|
// the API, not a raw row — the permission cache would not see the row
|
||||||
|
// (the documented rule for grants in tests).
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/grants`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({
|
||||||
|
subjectType: 'user',
|
||||||
|
subjectId: memberUser.id,
|
||||||
|
role: 'reader',
|
||||||
|
scopeType: 'pond',
|
||||||
|
effect: 'allow',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.roleGrant.deleteMany({
|
||||||
|
where: { pond: { owner: { username: { contains: suffix } } } },
|
||||||
|
});
|
||||||
|
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
await rm(brandingDir, { recursive: true, force: true });
|
||||||
|
delete process.env.BRANDING_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a pond logo, reports it, and serves it under the pond scope', async () => {
|
||||||
|
const view = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', png(64), 'logo.png')
|
||||||
|
.expect(201);
|
||||||
|
expect(view.body.logo).toMatchObject({ width: 64, height: 64 });
|
||||||
|
|
||||||
|
const served = await api()
|
||||||
|
.get(`/api/v1/branding/logo?variant=light&pond=${pondId}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(served.headers['content-type']).toContain('image/png');
|
||||||
|
|
||||||
|
// Without the pond scope the instance level answers — 404 here, since no
|
||||||
|
// instance logo is set. The two levels never leak into each other.
|
||||||
|
await api().get('/api/v1/branding/logo?variant=light').expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charges the pond quota and gives the bytes back when the logo is replaced', async () => {
|
||||||
|
const usageOf = async (): Promise<number> =>
|
||||||
|
Number(
|
||||||
|
(
|
||||||
|
await prisma.pondUsage.findUnique({
|
||||||
|
where: { pondId },
|
||||||
|
select: { storageBytesUsed: true },
|
||||||
|
})
|
||||||
|
)?.storageBytesUsed ?? 0,
|
||||||
|
);
|
||||||
|
const before = await usageOf();
|
||||||
|
|
||||||
|
const big = png(120);
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', big, 'logo.png')
|
||||||
|
.expect(201);
|
||||||
|
const afterUpload = await usageOf();
|
||||||
|
expect(afterUpload).toBe(before + big.length);
|
||||||
|
|
||||||
|
// Replacing releases the old reservation first — otherwise re-uploading
|
||||||
|
// the same logo would eat the quota again and again.
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', big, 'logo.png')
|
||||||
|
.expect(201);
|
||||||
|
expect(await usageOf()).toBe(afterUpload);
|
||||||
|
|
||||||
|
await api()
|
||||||
|
.delete(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(await usageOf()).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses SVG at the pond level too — the rules do not relax for a pond admin', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', Buffer.from('<svg xmlns="x"><script/></svg>'), 'x.png')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('branding_svg_rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a member read the pond branding but not change it', async () => {
|
||||||
|
await api().get(`/api/v1/ponds/${pondId}/branding`).set('Cookie', memberCookie).expect(200);
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
|
||||||
|
.set('Cookie', memberCookie)
|
||||||
|
.attach('file', png(32), 'x.png')
|
||||||
|
.expect(403);
|
||||||
|
await api()
|
||||||
|
.delete(`/api/v1/ponds/${pondId}/branding/favicon`)
|
||||||
|
.set('Cookie', memberCookie)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('purging the pond removes its branding files', async () => {
|
||||||
|
// A pond of its own, so the purge does not take the shared fixture with it.
|
||||||
|
const ownerRow = await prisma.user.findFirstOrThrow({ where: { username: owner.username } });
|
||||||
|
const created = await prisma.pond.create({
|
||||||
|
data: {
|
||||||
|
name: `Purge Branding ${suffix}`,
|
||||||
|
slug: `purge-branding-${suffix}`,
|
||||||
|
type: 'SHARED',
|
||||||
|
ownerId: ownerRow.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Raw grant row, before this pond's first permission query — the
|
||||||
|
// documented exception to "grants through the API".
|
||||||
|
await grantOwnerAdmin(prisma, created.id, ownerRow.id);
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/ponds/${created.id}/branding/logo?variant=light`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', png(48), 'logo.png')
|
||||||
|
.expect(201);
|
||||||
|
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).not.toBeNull();
|
||||||
|
|
||||||
|
await prisma.pond.update({ where: { id: created.id }, data: { deletedAt: new Date() } });
|
||||||
|
const trash = app.get(TrashService);
|
||||||
|
await trash.purgePondNow(ownerRow, created.id);
|
||||||
|
|
||||||
|
// The purge standard is absolute: after it nothing referencing the pond
|
||||||
|
// survives — rows OR files.
|
||||||
|
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,10 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||||
import { ConversionJobView, PageExportInput, pageExportInputSchema } from '@dorfteich/shared';
|
import {
|
||||||
|
ConversionJobView,
|
||||||
|
PageExportInput,
|
||||||
|
PondArchivePreview,
|
||||||
|
pageExportInputSchema,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { AuthedRequest } from '../auth/auth.guard';
|
import { AuthedRequest } from '../auth/auth.guard';
|
||||||
@ -7,7 +12,10 @@ import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
|||||||
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
|
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
|
||||||
import { readActorOf } from '../read-trail/read-actor';
|
import { readActorOf } from '../read-trail/read-actor';
|
||||||
|
|
||||||
|
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||||
|
|
||||||
import { ExportService } from './export.service';
|
import { ExportService } from './export.service';
|
||||||
|
import { PondArchiveService } from './pond-archive.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export endpoints (ADR 0009, issue #65): a whole pond as a ZIP of Markdown and
|
* Export endpoints (ADR 0009, issue #65): a whole pond as a ZIP of Markdown and
|
||||||
@ -16,7 +24,41 @@ import { ExportService } from './export.service';
|
|||||||
*/
|
*/
|
||||||
@Controller()
|
@Controller()
|
||||||
export class ExportController {
|
export class ExportController {
|
||||||
constructor(private readonly exports: ExportService) {}
|
constructor(
|
||||||
|
private readonly exports: ExportService,
|
||||||
|
private readonly archives: PondArchiveService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of the pond this requester's archive would contain (issue #305).
|
||||||
|
* Asked before the download so the UI can name the number of omitted pages:
|
||||||
|
* an archive silently missing content is worse than no archive, because it
|
||||||
|
* ends the search.
|
||||||
|
*/
|
||||||
|
@Get('ponds/:pondId/archive/preview')
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
archivePreview(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<PondArchivePreview> {
|
||||||
|
return this.archives.preview(request.user!, pondId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full archive: every readable page, EVERY attachment, and a versioned
|
||||||
|
* manifest with settings, labels, comments and the hierarchy (issue #305).
|
||||||
|
* Pond-Admin, because it is the deletion flow's last resort — a reader who
|
||||||
|
* wants their own copy has the Markdown export.
|
||||||
|
*/
|
||||||
|
@Get('ponds/:pondId/archive')
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
async pondArchive(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@Res() response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.archives.stream(request.user!, pondId, response, readActorOf(request), false);
|
||||||
|
}
|
||||||
|
|
||||||
/** Streamed ZIP of the pond's readable pages as Markdown (+ `media/`). The
|
/** Streamed ZIP of the pond's readable pages as Markdown (+ `media/`). The
|
||||||
* `reader` role is "may see the pond"; the service filters to readable pages,
|
* `reader` role is "may see the pond"; the service filters to readable pages,
|
||||||
@ -48,3 +90,34 @@ export class ExportController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Site Admin's archive from the purge dialog (issue #305, #193).
|
||||||
|
*
|
||||||
|
* Separate controller because it must NOT carry `@RequiresPondRole`: a Site
|
||||||
|
* Admin purging a trashed pond is usually not a member of it, and the last
|
||||||
|
* archive before an irreversible purge must not depend on that. It is
|
||||||
|
* therefore complete by construction — the read filter is skipped.
|
||||||
|
*/
|
||||||
|
@Controller('admin/trash')
|
||||||
|
@UseGuards(SiteAdminGuard)
|
||||||
|
export class PondArchiveAdminController {
|
||||||
|
constructor(private readonly archives: PondArchiveService) {}
|
||||||
|
|
||||||
|
@Get('ponds/:pondId/archive/preview')
|
||||||
|
archivePreview(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<PondArchivePreview> {
|
||||||
|
return this.archives.preview(request.user!, pondId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('ponds/:pondId/archive')
|
||||||
|
async archive(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@Res() response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.archives.stream(request.user!, pondId, response, readActorOf(request), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { ConversionWorker } from './conversion-worker.service';
|
|||||||
import { DATA_EXPORT_PROCESSOR } from './data-export.constants';
|
import { DATA_EXPORT_PROCESSOR } from './data-export.constants';
|
||||||
import { DataExportController } from './data-export.controller';
|
import { DataExportController } from './data-export.controller';
|
||||||
import { DataExportService } from './data-export.service';
|
import { DataExportService } from './data-export.service';
|
||||||
import { ExportController } from './export.controller';
|
import { ExportController, PondArchiveAdminController } from './export.controller';
|
||||||
import { ExportService } from './export.service';
|
import { ExportService } from './export.service';
|
||||||
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
|
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
|
||||||
import { IMPORT_PROCESSOR } from './import.constants';
|
import { IMPORT_PROCESSOR } from './import.constants';
|
||||||
@ -23,6 +23,7 @@ import { ImportController } from './import.controller';
|
|||||||
import { ImportService } from './import.service';
|
import { ImportService } from './import.service';
|
||||||
import { JobsController } from './jobs.controller';
|
import { JobsController } from './jobs.controller';
|
||||||
import { PandocConverter, PandocServerConverter } from './pandoc.converter';
|
import { PandocConverter, PandocServerConverter } from './pandoc.converter';
|
||||||
|
import { PondArchiveService } from './pond-archive.service';
|
||||||
|
|
||||||
/** How often expired data-export payloads are purged (#68). Hourly is ample:
|
/** How often expired data-export payloads are purged (#68). Hourly is ample:
|
||||||
* the link's own expiry check already stops downloads the moment it lapses. */
|
* the link's own expiry check already stops downloads the moment it lapses. */
|
||||||
@ -48,12 +49,19 @@ const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60;
|
|||||||
SchedulerModule,
|
SchedulerModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
],
|
],
|
||||||
controllers: [JobsController, ImportController, ExportController, DataExportController],
|
controllers: [
|
||||||
|
JobsController,
|
||||||
|
ImportController,
|
||||||
|
ExportController,
|
||||||
|
PondArchiveAdminController,
|
||||||
|
DataExportController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ConversionJobService,
|
ConversionJobService,
|
||||||
ConversionWorker,
|
ConversionWorker,
|
||||||
ImportService,
|
ImportService,
|
||||||
ExportService,
|
ExportService,
|
||||||
|
PondArchiveService,
|
||||||
DataExportService,
|
DataExportService,
|
||||||
// The worker resolves the import pipeline through this token (never the
|
// The worker resolves the import pipeline through this token (never the
|
||||||
// class), so its file does not import the import service's (avoids a cycle).
|
// class), so its file does not import the import service's (avoids a cycle).
|
||||||
|
|||||||
250
apps/api/src/import-export/pond-archive.e2e.db.test.ts
Normal file
250
apps/api/src/import-export/pond-archive.e2e.db.test.ts
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PondArchiveManifest } from '@dorfteich/shared';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { unzipSync } from 'fflate';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
const PNG_BASE64 =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
function entries(buffer: Buffer): Record<string, Uint8Array> {
|
||||||
|
return unzipSync(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** supertest parses text by default — a ZIP has to be collected as bytes. */
|
||||||
|
function asBinary(req: request.Test): request.Test {
|
||||||
|
return req.parse((res, cb) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestOf(buffer: Buffer): PondArchiveManifest {
|
||||||
|
const raw = entries(buffer)['manifest.json'];
|
||||||
|
return JSON.parse(Buffer.from(raw!).toString('utf8')) as PondArchiveManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full pond archive (issue #305). What separates it from the Markdown
|
||||||
|
* export is exactly what is asserted here: EVERY attachment travels, not only
|
||||||
|
* the embedded ones, and the manifest carries what Markdown cannot — settings,
|
||||||
|
* labels, comments and the hierarchy.
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('pond archive (e2e, issue #305)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let files: FilesService;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'archiviere den ganzen teich 1';
|
||||||
|
const owner = { username: `arch-${suffix}` };
|
||||||
|
const admin = { username: `archadm-${suffix}` };
|
||||||
|
let ownerId: string;
|
||||||
|
let ownerCookie: string;
|
||||||
|
let adminCookie: string;
|
||||||
|
let pondId: string;
|
||||||
|
let parentPageId: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
app = await createTestApp();
|
||||||
|
files = app.get(FilesService);
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const tokens = app.get(AuthTokensService);
|
||||||
|
|
||||||
|
const ownerUser = await users.createUser({
|
||||||
|
username: owner.username,
|
||||||
|
email: `${owner.username}@example.org`,
|
||||||
|
displayName: `Archive Owner ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
ownerId = ownerUser.id;
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/verify-email')
|
||||||
|
.send({ token: await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600) })
|
||||||
|
.expect(204);
|
||||||
|
ownerCookie = sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: owner.username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
|
||||||
|
const adminUser = await users.createUser({
|
||||||
|
username: admin.username,
|
||||||
|
email: `${admin.username}@example.org`,
|
||||||
|
displayName: `Archive Admin ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(adminUser.id);
|
||||||
|
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
|
||||||
|
adminCookie = sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: admin.username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
|
||||||
|
pondId = (await prisma.pond.findFirstOrThrow({ where: { ownerId, type: 'PERSONAL' } })).id;
|
||||||
|
|
||||||
|
// A parent and a child page, so the hierarchy has something to state.
|
||||||
|
const parent = await prisma.page.create({
|
||||||
|
data: {
|
||||||
|
pondId,
|
||||||
|
title: 'Archive Parent',
|
||||||
|
slug: 'archive-parent',
|
||||||
|
ydocState: new Uint8Array(),
|
||||||
|
sortKey: 'a',
|
||||||
|
createdBy: ownerId,
|
||||||
|
contentCache: {
|
||||||
|
create: { plainText: 'Parent body', markdown: 'Parent body', html: '', outline: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
parentPageId = parent.id;
|
||||||
|
await prisma.page.create({
|
||||||
|
data: {
|
||||||
|
pondId,
|
||||||
|
parentId: parent.id,
|
||||||
|
title: 'Archive Child',
|
||||||
|
slug: 'archive-child',
|
||||||
|
ydocState: new Uint8Array(),
|
||||||
|
sortKey: 'b',
|
||||||
|
createdBy: ownerId,
|
||||||
|
contentCache: {
|
||||||
|
create: { plainText: 'Child body', markdown: 'Child body', html: '', outline: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = await prisma.label.create({
|
||||||
|
data: { pondId, name: `Archive Label ${suffix}`, color: '#2f6f4f' },
|
||||||
|
});
|
||||||
|
await prisma.pageLabel.create({ data: { pageId: parent.id, labelId: label.id } });
|
||||||
|
await prisma.comment.create({
|
||||||
|
data: { pageId: parent.id, authorId: ownerId, body: 'A remark worth keeping.' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const where = { pond: { owner: { username: { contains: suffix } } } };
|
||||||
|
await prisma.comment.deleteMany({ where: { page: where } });
|
||||||
|
await prisma.attachment.deleteMany({ where });
|
||||||
|
await prisma.pageLabel.deleteMany({ where: { page: where } });
|
||||||
|
await prisma.label.deleteMany({ where });
|
||||||
|
await prisma.page.deleteMany({ where });
|
||||||
|
await prisma.roleGrant.deleteMany({ where });
|
||||||
|
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains EVERY attachment, not only the embedded ones', async () => {
|
||||||
|
// The gap this whole issue exists for: an attachment nobody embedded
|
||||||
|
// would vanish unnoticed with the Markdown export.
|
||||||
|
const orphan = await files.upload({ id: ownerId } as never, pondId, {
|
||||||
|
buffer: Buffer.from(PNG_BASE64, 'base64'),
|
||||||
|
size: 70,
|
||||||
|
originalname: 'never-embedded.png',
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(res.headers['content-type']).toContain('application/zip');
|
||||||
|
|
||||||
|
const names = Object.keys(entries(res.body as Buffer));
|
||||||
|
expect(names).toContain('manifest.json');
|
||||||
|
expect(names).toContain('README.txt');
|
||||||
|
expect(names).toContain('pages/archive-parent.md');
|
||||||
|
expect(names).toContain('pages/archive-child.md');
|
||||||
|
expect(names.some((name) => name.startsWith(`media/${orphan.id}.`))).toBe(true);
|
||||||
|
|
||||||
|
const manifest = manifestOf(res.body as Buffer);
|
||||||
|
expect(manifest.attachments.map((a) => a.id)).toContain(orphan.id);
|
||||||
|
// The Markdown export would have shipped no media at all here.
|
||||||
|
expect(manifest.attachments.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('states the hierarchy, labels, comments and settings in the manifest', async () => {
|
||||||
|
const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
const manifest = manifestOf(res.body as Buffer);
|
||||||
|
|
||||||
|
expect(manifest.kind).toBe('dorfteich-pond-archive');
|
||||||
|
expect(manifest.formatVersion).toBe(1);
|
||||||
|
expect(manifest.complete).toBe(true);
|
||||||
|
expect(manifest.omittedPages).toBe(0);
|
||||||
|
|
||||||
|
const child = manifest.pages.find((page) => page.slug === 'archive-child');
|
||||||
|
// The hierarchy is exactly what a folder of Markdown cannot express.
|
||||||
|
expect(child?.parentId).toBe(parentPageId);
|
||||||
|
|
||||||
|
expect(manifest.labels.some((label) => label.name.includes(suffix))).toBe(true);
|
||||||
|
expect(manifest.comments.map((comment) => comment.body)).toContain('A remark worth keeping.');
|
||||||
|
// A display name, not an account id — the archive outlives the account.
|
||||||
|
expect(manifest.comments[0]?.author).toContain('Archive Owner');
|
||||||
|
// Pond settings ride along; fonts are always present through the schema
|
||||||
|
// defaults, so their presence proves the settings object is real.
|
||||||
|
expect(manifest.pond.settings).toHaveProperty('fonts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells a requester before the download how much they would get', async () => {
|
||||||
|
const preview = await api()
|
||||||
|
.get(`/api/v1/ponds/${pondId}/archive/preview`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(preview.body.omittedPages).toBe(0);
|
||||||
|
expect(preview.body.includedPages).toBe(preview.body.totalPages);
|
||||||
|
expect(preview.body.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('audits the download with counts and completeness', async () => {
|
||||||
|
await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
const entry = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'pond.archived', targetId: pondId },
|
||||||
|
orderBy: { at: 'desc' },
|
||||||
|
});
|
||||||
|
expect(entry).not.toBeNull();
|
||||||
|
expect(entry!.details).toMatchObject({ complete: true, omittedPages: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the Site Admin a complete archive without pond membership', async () => {
|
||||||
|
// The purge dialog's archive must not depend on which ponds the operator
|
||||||
|
// happens to be a member of — this admin is a member of none.
|
||||||
|
const preview = await api()
|
||||||
|
.get(`/api/v1/admin/trash/ponds/${pondId}/archive/preview`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(preview.body.complete).toBe(true);
|
||||||
|
expect(preview.body.omittedPages).toBe(0);
|
||||||
|
|
||||||
|
const res = await asBinary(api().get(`/api/v1/admin/trash/ponds/${pondId}/archive`))
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(manifestOf(res.body as Buffer).pages.length).toBe(preview.body.totalPages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the admin archive away from an ordinary pond admin', async () => {
|
||||||
|
await api()
|
||||||
|
.get(`/api/v1/admin/trash/ponds/${pondId}/archive`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
395
apps/api/src/import-export/pond-archive.service.ts
Normal file
395
apps/api/src/import-export/pond-archive.service.ts
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
PageClassification,
|
||||||
|
PondArchiveManifest,
|
||||||
|
PondArchivePreview,
|
||||||
|
POND_ARCHIVE_FORMAT_VERSION,
|
||||||
|
classificationMarking,
|
||||||
|
highestClassification,
|
||||||
|
pondSettingsSchema,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
|
import { User } from '@prisma/client';
|
||||||
|
import archiver from 'archiver';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { FileStorageService } from '../files/file-storage.service';
|
||||||
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
|
||||||
|
|
||||||
|
import { markClassifiedMarkdown } from './classified-markdown';
|
||||||
|
import { imageExtension, markdownForZip } from './export-markdown';
|
||||||
|
|
||||||
|
/** The plain-text note that travels inside the ZIP. The manifest says the
|
||||||
|
* same thing machine-readably, but a person unpacking a folder of Markdown
|
||||||
|
* a year from now reads the file lying next to it — and must not believe
|
||||||
|
* they are holding a one-click restore. */
|
||||||
|
const README = `Dorfteich pond archive (format version ${POND_ARCHIVE_FORMAT_VERSION})
|
||||||
|
|
||||||
|
This is a PRESERVATION archive, not a backup you can re-import: Dorfteich has
|
||||||
|
no importer for it yet. Everything needed to write one later is here and
|
||||||
|
documented — see manifest.json and docs/architecture/pond-archive-format.md in
|
||||||
|
the Dorfteich repository.
|
||||||
|
|
||||||
|
manifest.json pond settings, labels, page hierarchy, comments, attachment
|
||||||
|
metadata, and the classification of every file
|
||||||
|
pages/ one Markdown file per page
|
||||||
|
media/ EVERY attachment of the pond, not only the embedded ones
|
||||||
|
|
||||||
|
If manifest.json states "complete": false, the archive was produced by someone
|
||||||
|
who could not read every page of the pond; "omittedPages" says how many are
|
||||||
|
missing.
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full pond archive offered before a pond is deleted (issue #305).
|
||||||
|
*
|
||||||
|
* Distinct from the Markdown export (`exportPond`, #65) on purpose: that one
|
||||||
|
* ships the pages plus the images they embed, which as a LAST resort is not
|
||||||
|
* enough — an attachment nobody embedded would vanish unnoticed. This one adds
|
||||||
|
* every attachment and a machine-readable sidecar of the things Markdown
|
||||||
|
* cannot carry: settings, labels, comments and the page hierarchy.
|
||||||
|
*
|
||||||
|
* Re-import is deliberately out of scope. The archive is a preservation
|
||||||
|
* format: complete, versioned and documented, so an importer can be written
|
||||||
|
* later without guesswork.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PondArchiveService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionService,
|
||||||
|
private readonly storage: FileStorageService,
|
||||||
|
private readonly readTrail: ReadTrailService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
|
this.logger.setContext(PondArchiveService.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages in the pond and how many of them this requester may read.
|
||||||
|
*
|
||||||
|
* The UI states the difference BEFORE the download: an archive silently
|
||||||
|
* missing content is worse than no archive, because it ends the search.
|
||||||
|
* A site admin archiving from the purge dialog reads everything, so their
|
||||||
|
* preview says nothing is omitted.
|
||||||
|
*/
|
||||||
|
async preview(user: User, pondId: string, unfiltered: boolean): Promise<PondArchivePreview> {
|
||||||
|
const pond = await this.loadPond(pondId);
|
||||||
|
const pages = await this.readablePages(user, pond.id, unfiltered);
|
||||||
|
const total = await this.prisma.page.count({ where: { pondId: pond.id, deletedAt: null } });
|
||||||
|
return {
|
||||||
|
totalPages: total,
|
||||||
|
includedPages: pages.length,
|
||||||
|
omittedPages: total - pages.length,
|
||||||
|
// "Complete" is a statement about the RESULT, not about the route: a
|
||||||
|
// pond admin who may read every page gets a complete archive too. Only
|
||||||
|
// an archive that actually leaves pages out is incomplete.
|
||||||
|
complete: total === pages.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pond, or 404 — the caller's permission is checked by the route. */
|
||||||
|
private async loadPond(pondId: string): Promise<{ id: string; slug: string; name: string }> {
|
||||||
|
// Deliberately including trashed ponds: the purge dialog archives a pond
|
||||||
|
// that is already in the trash, which is the last moment it exists.
|
||||||
|
const pond = await this.prisma.pond.findUnique({
|
||||||
|
where: { id: pondId },
|
||||||
|
select: { id: true, slug: true, name: true },
|
||||||
|
});
|
||||||
|
if (!pond) throw new NotFoundException();
|
||||||
|
return pond;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readablePages(
|
||||||
|
user: User,
|
||||||
|
pondId: string,
|
||||||
|
unfiltered: boolean,
|
||||||
|
): Promise<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
parentId: string | null;
|
||||||
|
sortKey: string;
|
||||||
|
classification: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
labels: { labelId: string }[];
|
||||||
|
contentCache: { markdown: string } | null;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
const pages = await this.prisma.page.findMany({
|
||||||
|
where: { pondId, deletedAt: null },
|
||||||
|
orderBy: { title: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
title: true,
|
||||||
|
parentId: true,
|
||||||
|
sortKey: true,
|
||||||
|
classification: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
labels: { select: { labelId: true } },
|
||||||
|
contentCache: { select: { markdown: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (unfiltered) return pages;
|
||||||
|
const readable = await this.permissions.filterPages(
|
||||||
|
user,
|
||||||
|
pondId,
|
||||||
|
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
|
||||||
|
'read',
|
||||||
|
);
|
||||||
|
return pages.filter((page) => readable.has(page.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream the archive.
|
||||||
|
*
|
||||||
|
* `unfiltered` is the Site-Admin path from the purge dialog: it skips the
|
||||||
|
* read filter, because the last archive before an irreversible purge must
|
||||||
|
* not depend on which pages the operator happens to be a member of.
|
||||||
|
* Whether the RESULT is complete is a separate question, answered by
|
||||||
|
* comparing what went in with what exists.
|
||||||
|
*/
|
||||||
|
async stream(
|
||||||
|
user: User,
|
||||||
|
pondId: string,
|
||||||
|
res: Response,
|
||||||
|
read: ReadActor,
|
||||||
|
unfiltered: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const pond = await this.loadPond(pondId);
|
||||||
|
const pages = await this.readablePages(user, pond.id, unfiltered);
|
||||||
|
const totalPages = await this.prisma.page.count({
|
||||||
|
where: { pondId: pond.id, deletedAt: null },
|
||||||
|
});
|
||||||
|
const complete = totalPages === pages.length;
|
||||||
|
|
||||||
|
// Read trail (ADR 0023, issue #222's property): one `export` event per
|
||||||
|
// classified page BEFORE any classified byte enters the stream, so a
|
||||||
|
// failed write aborts the download with the evidence intact. The added
|
||||||
|
// attachments carry their page's classification and are covered by the
|
||||||
|
// same events — they never travel without their page.
|
||||||
|
for (const page of pages) {
|
||||||
|
if (page.classification !== 'VS_NFD') continue;
|
||||||
|
await this.readTrail.record({
|
||||||
|
...read,
|
||||||
|
pageId: page.id,
|
||||||
|
pondId: pond.id,
|
||||||
|
channel: 'export',
|
||||||
|
details: { format: 'pond_archive' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [settingsRow, labels, comments, attachmentRows] = await Promise.all([
|
||||||
|
this.prisma.pond.findUniqueOrThrow({
|
||||||
|
where: { id: pond.id },
|
||||||
|
select: { name: true, slug: true, type: true, settings: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.label.findMany({
|
||||||
|
where: { pondId: pond.id },
|
||||||
|
select: { id: true, name: true, color: true, parentId: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
}),
|
||||||
|
this.prisma.comment.findMany({
|
||||||
|
where: { page: { pondId: pond.id, deletedAt: null } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
pageId: true,
|
||||||
|
parentId: true,
|
||||||
|
body: true,
|
||||||
|
createdAt: true,
|
||||||
|
editedAt: true,
|
||||||
|
resolvedAt: true,
|
||||||
|
author: { select: { displayName: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// EVERY attachment of the pond (issue #305) — not only the embedded
|
||||||
|
// ones the Markdown export ships.
|
||||||
|
this.prisma.attachment.findMany({
|
||||||
|
where: { pondId: pond.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
pageId: true,
|
||||||
|
fileName: true,
|
||||||
|
mimeType: true,
|
||||||
|
sizeBytes: true,
|
||||||
|
sha256: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const includedPageIds = new Set(pages.map((page) => page.id));
|
||||||
|
// An attachment of a page the requester cannot read stays out — the same
|
||||||
|
// rule the pages follow. Pond-level attachments (no page) are included:
|
||||||
|
// nothing narrower than the pond governs them.
|
||||||
|
const visibleAttachments = attachmentRows.filter(
|
||||||
|
(row) => !row.pageId || includedPageIds.has(row.pageId),
|
||||||
|
);
|
||||||
|
const onDisk = await Promise.all(
|
||||||
|
visibleAttachments.map((row) => this.storage.exists(pond.id, row.id)),
|
||||||
|
);
|
||||||
|
const attachments = visibleAttachments.filter((_, index) => onDisk[index]);
|
||||||
|
|
||||||
|
const classificationByPage = new Map(
|
||||||
|
pages.map((page) => [page.id, page.classification.toLowerCase() as PageClassification]),
|
||||||
|
);
|
||||||
|
const mediaName = new Map(
|
||||||
|
attachments.map((row) => [row.id, `${row.id}.${imageExtension(row.mimeType)}`]),
|
||||||
|
);
|
||||||
|
const readableSlugs = new Set(pages.map((page) => page.slug));
|
||||||
|
|
||||||
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||||
|
res.set('Content-Type', 'application/zip');
|
||||||
|
res.set('Content-Disposition', `attachment; filename="${pond.slug}-archive.zip"`);
|
||||||
|
res.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
archive.on('error', (error) => {
|
||||||
|
this.logger.error({ pondId: pond.id, err: error.message }, 'pond archive failed');
|
||||||
|
res.destroy(error);
|
||||||
|
});
|
||||||
|
archive.pipe(res);
|
||||||
|
|
||||||
|
const files: { path: string; classification: PageClassification }[] = [];
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const level = classificationByPage.get(page.id) ?? 'unclassified';
|
||||||
|
const markdown = markClassifiedMarkdown(
|
||||||
|
markdownForZip(page.contentCache?.markdown ?? '', readableSlugs, mediaName),
|
||||||
|
level,
|
||||||
|
);
|
||||||
|
const path = `pages/${page.slug}.md`;
|
||||||
|
archive.append(markdown, { name: path });
|
||||||
|
files.push({ path, classification: level });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of attachments) {
|
||||||
|
// An attachment inherits its page's level (fail-closed, ADR 0022); one
|
||||||
|
// that belongs to no page inherits the pond's highest, because nothing
|
||||||
|
// narrower governs it.
|
||||||
|
const level = row.pageId
|
||||||
|
? (classificationByPage.get(row.pageId) ?? 'unclassified')
|
||||||
|
: highestClassification([...classificationByPage.values()]);
|
||||||
|
const path = `media/${mediaName.get(row.id)!}`;
|
||||||
|
files.push({ path, classification: level });
|
||||||
|
// Companion marking (issue #212): binaries cannot carry it themselves,
|
||||||
|
// and the sibling file survives unpacking where a manifest may not.
|
||||||
|
const marking = classificationMarking(level);
|
||||||
|
if (marking) {
|
||||||
|
archive.append(`${marking}\n`, { name: `${path}.classification.txt` });
|
||||||
|
files.push({ path: `${path}.classification.txt`, classification: level });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest: PondArchiveManifest = {
|
||||||
|
kind: 'dorfteich-pond-archive',
|
||||||
|
formatVersion: POND_ARCHIVE_FORMAT_VERSION,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
complete,
|
||||||
|
omittedPages: totalPages - pages.length,
|
||||||
|
classification: highestClassification(files.map((file) => file.classification)),
|
||||||
|
pond: {
|
||||||
|
name: settingsRow.name,
|
||||||
|
slug: settingsRow.slug,
|
||||||
|
type: settingsRow.type,
|
||||||
|
createdAt: settingsRow.createdAt.toISOString(),
|
||||||
|
// The EFFECTIVE settings, defaults filled in — a preservation format
|
||||||
|
// must not require its reader to know Dorfteich's defaults, and the
|
||||||
|
// stored row only holds what was explicitly set.
|
||||||
|
settings: pondSettingsSchema.parse(settingsRow.settings ?? {}) as unknown as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
labels: labels.map((label) => ({
|
||||||
|
id: label.id,
|
||||||
|
name: label.name,
|
||||||
|
color: label.color,
|
||||||
|
parentId: label.parentId,
|
||||||
|
})),
|
||||||
|
pages: pages.map((page) => ({
|
||||||
|
id: page.id,
|
||||||
|
slug: page.slug,
|
||||||
|
title: page.title,
|
||||||
|
parentId: page.parentId,
|
||||||
|
sortKey: page.sortKey,
|
||||||
|
classification: page.classification.toLowerCase() as PageClassification,
|
||||||
|
labelIds: page.labels.map((label) => label.labelId),
|
||||||
|
createdAt: page.createdAt.toISOString(),
|
||||||
|
updatedAt: page.updatedAt.toISOString(),
|
||||||
|
file: `pages/${page.slug}.md`,
|
||||||
|
})),
|
||||||
|
// Comments of included pages only — a comment is content of its page.
|
||||||
|
comments: comments
|
||||||
|
.filter((comment) => includedPageIds.has(comment.pageId))
|
||||||
|
.map((comment) => ({
|
||||||
|
id: comment.id,
|
||||||
|
pageId: comment.pageId,
|
||||||
|
parentId: comment.parentId,
|
||||||
|
body: comment.body,
|
||||||
|
// The display name, not the account: the archive is a document, and
|
||||||
|
// it should stay readable after the account is gone.
|
||||||
|
author: comment.author?.displayName ?? null,
|
||||||
|
createdAt: comment.createdAt.toISOString(),
|
||||||
|
editedAt: comment.editedAt?.toISOString() ?? null,
|
||||||
|
resolvedAt: comment.resolvedAt?.toISOString() ?? null,
|
||||||
|
})),
|
||||||
|
attachments: attachments.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
pageId: row.pageId,
|
||||||
|
fileName: row.fileName,
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
sizeBytes: row.sizeBytes,
|
||||||
|
sha256: row.sha256,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
file: `media/${mediaName.get(row.id)!}`,
|
||||||
|
})),
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
|
||||||
|
archive.append(README, { name: 'README.txt' });
|
||||||
|
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
|
||||||
|
|
||||||
|
for (const row of attachments) {
|
||||||
|
const stream = this.storage.createReadStream(pond.id, row.id);
|
||||||
|
stream.on('error', (error) =>
|
||||||
|
this.logger.warn(
|
||||||
|
{ pondId: pond.id, fileId: row.id, err: error.message },
|
||||||
|
'pond archive: media read failed',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
archive.append(stream, { name: `media/${mediaName.get(row.id)!}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audited: a whole pond leaving the instance in one file, usually right
|
||||||
|
// before it is deleted, is exactly the event an operator wants to find
|
||||||
|
// later. Recorded before finalize so the trail exists even if the
|
||||||
|
// download is aborted mid-stream.
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'pond.archived',
|
||||||
|
actorId: user.id,
|
||||||
|
targetType: 'pond',
|
||||||
|
targetId: pond.id,
|
||||||
|
details: {
|
||||||
|
pages: pages.length,
|
||||||
|
attachments: attachments.length,
|
||||||
|
omittedPages: totalPages - pages.length,
|
||||||
|
complete,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await archive.finalize();
|
||||||
|
this.logger.info(
|
||||||
|
{ pondId: pond.id, pages: pages.length, attachments: attachments.length, complete },
|
||||||
|
'pond archive streamed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { Module, OnModuleInit } from '@nestjs/common';
|
import { Module, OnModuleInit } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BrandingModule } from '../branding/branding.module';
|
||||||
import { CommonModule } from '../common/common.module';
|
import { CommonModule } from '../common/common.module';
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { PagesModule } from '../pages/pages.module';
|
import { PagesModule } from '../pages/pages.module';
|
||||||
@ -18,6 +19,7 @@ const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60;
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
BrandingModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
PondsModule,
|
PondsModule,
|
||||||
QuotasModule,
|
QuotasModule,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { User } from '@prisma/client';
|
|||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { BrandingService } from '../branding/branding.service';
|
||||||
import { ClockService } from '../common/clock.service';
|
import { ClockService } from '../common/clock.service';
|
||||||
import { SearchProvider } from '../search/search.provider';
|
import { SearchProvider } from '../search/search.provider';
|
||||||
import { PagesService } from '../pages/pages.service';
|
import { PagesService } from '../pages/pages.service';
|
||||||
@ -32,6 +33,7 @@ export class TrashService {
|
|||||||
private readonly settings: InstanceSettingsService,
|
private readonly settings: InstanceSettingsService,
|
||||||
private readonly quotas: QuotaService,
|
private readonly quotas: QuotaService,
|
||||||
private readonly storage: FileStorageService,
|
private readonly storage: FileStorageService,
|
||||||
|
private readonly branding: BrandingService,
|
||||||
private readonly clock: ClockService,
|
private readonly clock: ClockService,
|
||||||
private readonly watches: WatchesService,
|
private readonly watches: WatchesService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
@ -183,6 +185,9 @@ export class TrashService {
|
|||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
await this.storage.delete(pondId, attachment.id);
|
await this.storage.delete(pondId, attachment.id);
|
||||||
}
|
}
|
||||||
|
// The pond's branding files (issue #307). The purge standard is absolute:
|
||||||
|
// after it, nothing referencing the pond survives — rows OR files.
|
||||||
|
await this.branding.removePondAssets(pondId);
|
||||||
const pageIds = (
|
const pageIds = (
|
||||||
await this.prisma.page.findMany({ where: { pondId }, select: { id: true } })
|
await this.prisma.page.findMany({ where: { pondId }, select: { id: true } })
|
||||||
).map((page) => page.id);
|
).map((page) => page.id);
|
||||||
|
|||||||
@ -96,6 +96,15 @@ for (const scheme of SCHEMES) {
|
|||||||
await page.goto('/fonts');
|
await page.goto('/fonts');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
await expectClean(page, `/fonts (${scheme})`);
|
await expectClean(page, `/fonts (${scheme})`);
|
||||||
|
|
||||||
|
// Teich-Einstellungen im selben Kontext (fixture-user besitzt den
|
||||||
|
// Fixture-Teich): dort sitzt seit issue #305 das Archiv-Angebot in der
|
||||||
|
// Löschzone. Wieder KEIN eigener Test — zusätzliche Logins kippen die
|
||||||
|
// CI zwei Packs später am Rate-Limit (Lehre aus #301).
|
||||||
|
await page.goto('/p/content-fixtures/settings');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.locator('.pond-archive__download').waitFor();
|
||||||
|
await expectClean(page, `Teich-Einstellungen (${scheme})`);
|
||||||
await context.close();
|
await context.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,23 @@
|
|||||||
|
# The SPA shell's `lang` attribute, negotiated from the request (issue #179,
|
||||||
|
# WCAG 3.1.1). `apps/web/index.html` is a static file with a hard `lang="en"`;
|
||||||
|
# the app corrects it at runtime (#163), but a crawler or a no-JS visit of an
|
||||||
|
# SPA route — which nginx answers with index.html — would see `en` forever,
|
||||||
|
# even for German content.
|
||||||
|
#
|
||||||
|
# Only the FIRST tag of Accept-Language decides, which is what "the browser's
|
||||||
|
# preferred language" means and mirrors #163's semantics. `de-CH` counts as
|
||||||
|
# German; `en-US,de` does not, because that visitor asked for English first.
|
||||||
|
#
|
||||||
|
# Known limit, documented rather than worked around: nginx does not know
|
||||||
|
# `instance.defaultLocale` from the database, so a visitor with no (or an
|
||||||
|
# unlisted) Accept-Language gets `en` even on a German instance. For PUBLIC
|
||||||
|
# content that is not the authoritative rendering anyway — the api's server
|
||||||
|
# shell (`/api/v1/public/...`) renders those with the instance locale.
|
||||||
|
map $http_accept_language $spa_lang {
|
||||||
|
default en;
|
||||||
|
~*^de de;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA serving: static assets with long-lived caching, everything else
|
# SPA serving: static assets with long-lived caching, everything else
|
||||||
# falls back to index.html (client-side routing).
|
# falls back to index.html (client-side routing).
|
||||||
server {
|
server {
|
||||||
@ -34,6 +54,17 @@ server {
|
|||||||
# zero-third-party-request guarantee (security.md) is unaffected.
|
# zero-third-party-request guarantee (security.md) is unaffected.
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
# The shell's language (issue #179). Only the html document is
|
||||||
|
# rewritten, and only its first match — `<html lang="en">` is the
|
||||||
|
# first and only occurrence in index.html. Everything else this
|
||||||
|
# location serves passes through untouched.
|
||||||
|
sub_filter_types text/html;
|
||||||
|
sub_filter_once on;
|
||||||
|
sub_filter 'lang="en"' 'lang="$spa_lang"';
|
||||||
|
# The response now depends on a request header, so shared caches must
|
||||||
|
# not serve one language's copy to the other. This location is
|
||||||
|
# `no-cache` anyway; the header states the dependency correctly.
|
||||||
|
add_header Vary "Accept-Language" always;
|
||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,50 +1,57 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { logoUrl, useBranding } from './use-branding';
|
import { useCurrentPondRoute } from '../layout/use-pond-route';
|
||||||
|
import { logoUrl, usePondFavicon, useResolvedBranding } from './use-branding';
|
||||||
|
import { usePondId } from './use-pond-id';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The instance identity at the top of the sidebar (issue #306): the uploaded
|
* The identity at the top of the sidebar (issues #306/#307): the pond's own
|
||||||
* logo as a link home, or the instance name as text when nothing is uploaded.
|
* logo when it has one, else the instance's, else the instance name as text.
|
||||||
*
|
*
|
||||||
* Its accessible name is the INSTANCE NAME, never "logo": for a screen reader
|
* Its accessible name follows the LEVEL the logo came from — a pond logo is
|
||||||
* this is the link home, and a link's name has to say where it goes. The
|
* named by the pond, an instance logo by the instance. For a screen reader
|
||||||
* images are therefore `alt=""` — the link is already named.
|
* this is the link home, and a link's name has to say where it goes; keeping
|
||||||
|
* the instance name on a pond logo would announce the wrong destination.
|
||||||
*
|
*
|
||||||
* Both variants are rendered and one is hidden by CSS (`:root[data-theme]`),
|
* Both variants are rendered and one is hidden by CSS (`:root[data-theme]`),
|
||||||
* not by JavaScript: `theme-init.js` resolves the theme before first paint, so
|
* not by JavaScript: `theme-init.js` resolves the theme before first paint, so
|
||||||
* the correct logo is the one painted rather than the one that appears after a
|
* the correct logo is the one painted. A logo set belongs to ONE level and is
|
||||||
* flash. Without a dark variant the light one carries both themes — the
|
* never mixed across levels — see `resolveBranding`.
|
||||||
* operator's own asset, shown unchanged, rather than a substitute they did
|
|
||||||
* not choose (the rule #307 extends to ponds).
|
|
||||||
*/
|
*/
|
||||||
export function BrandLogo(): React.JSX.Element | null {
|
export function BrandLogo(): React.JSX.Element | null {
|
||||||
const branding = useBranding();
|
const { pondSlug } = useCurrentPondRoute();
|
||||||
if (!branding) return null;
|
const { pondId, pondName } = usePondId(pondSlug);
|
||||||
const { logo, logoDark, instanceName } = branding;
|
const { resolved, instanceName, pondId: logoPond } = useResolvedBranding(pondId);
|
||||||
|
usePondFavicon(pondId, resolved.faviconLevel === 'pond');
|
||||||
|
|
||||||
|
const name = resolved.logoLevel === 'pond' ? (pondName ?? instanceName) : instanceName;
|
||||||
|
if (!instanceName && resolved.logoLevel === 'none') return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to="/" className="brand-logo" aria-label={instanceName}>
|
<Link to="/" className="brand-logo" aria-label={name}>
|
||||||
{logo ? (
|
{resolved.logo || resolved.logoDark ? (
|
||||||
<>
|
<>
|
||||||
|
{resolved.logo && (
|
||||||
<img
|
<img
|
||||||
className={`brand-logo__img brand-logo__img--light${logoDark ? '' : ' brand-logo__img--both'}`}
|
className={`brand-logo__img brand-logo__img--light${resolved.logoDark ? '' : ' brand-logo__img--both'}`}
|
||||||
src={logoUrl('light', logo.hash)}
|
src={logoUrl('light', resolved.logo.hash, logoPond)}
|
||||||
width={logo.width}
|
width={resolved.logo.width}
|
||||||
height={logo.height}
|
height={resolved.logo.height}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
{logoDark && (
|
)}
|
||||||
|
{resolved.logoDark && (
|
||||||
<img
|
<img
|
||||||
className="brand-logo__img brand-logo__img--dark"
|
className={`brand-logo__img brand-logo__img--dark${resolved.logo ? '' : ' brand-logo__img--both'}`}
|
||||||
src={logoUrl('dark', logoDark.hash)}
|
src={logoUrl('dark', resolved.logoDark.hash, logoPond)}
|
||||||
width={logoDark.width}
|
width={resolved.logoDark.width}
|
||||||
height={logoDark.height}
|
height={resolved.logoDark.height}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="brand-logo__name">{instanceName}</span>
|
<span className="brand-logo__name">{name}</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { BrandingView } from '@dorfteich/shared';
|
import { BrandingView, PondBranding, ResolvedBranding, resolveBranding } from '@dorfteich/shared';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
|
|
||||||
@ -21,9 +22,65 @@ export function useBranding(): BrandingView | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** URL of a logo variant, with the content hash so a replaced logo is never
|
/** URL of a logo variant, with the content hash so a replaced logo is never
|
||||||
* served from cache. */
|
* served from cache. `pondId` scopes it to a pond's own asset (issue #307);
|
||||||
export function logoUrl(variant: 'light' | 'dark', hash: string): string {
|
* the route never falls back on its own — the CALLER decided which level
|
||||||
return `/api/v1/branding/logo?variant=${variant}&v=${hash}`;
|
* applies, and a silent fallback here would mix variants across levels. */
|
||||||
|
export function logoUrl(variant: 'light' | 'dark', hash: string, pondId?: string): string {
|
||||||
|
const pond = pondId ? `&pond=${pondId}` : '';
|
||||||
|
return `/api/v1/branding/logo?variant=${variant}&v=${hash}${pond}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branding in force here: the pond's own, else the instance's, else the
|
||||||
|
* default (issue #307). One helper, in shared, so api and web cannot drift.
|
||||||
|
*/
|
||||||
|
export function useResolvedBranding(pondId?: string): {
|
||||||
|
resolved: ResolvedBranding;
|
||||||
|
instanceName: string;
|
||||||
|
/** Which level the logo came from — the asset URLs need the pond scope
|
||||||
|
* exactly when the pond supplied it. */
|
||||||
|
pondId?: string;
|
||||||
|
} {
|
||||||
|
const instance = useBranding();
|
||||||
|
const pond = useQuery({
|
||||||
|
queryKey: ['pond', pondId, 'branding'],
|
||||||
|
queryFn: () => apiGet<PondBranding>(`/ponds/${pondId!}/branding`),
|
||||||
|
enabled: Boolean(pondId),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
const base = instance ?? { logo: null, logoDark: null, favicon: null, instanceName: '' };
|
||||||
|
const resolved = resolveBranding(base, pond.data ?? null);
|
||||||
|
return {
|
||||||
|
resolved,
|
||||||
|
instanceName: base.instanceName,
|
||||||
|
pondId: resolved.logoLevel === 'pond' ? pondId : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points the document's icon at the pond's favicon while a pond is open, and
|
||||||
|
* back at the instance's on leaving (issue #307).
|
||||||
|
*
|
||||||
|
* Accepted and worth stating: the swap necessarily happens AFTER first paint,
|
||||||
|
* so opening a pond link directly shows the instance favicon briefly before it
|
||||||
|
* changes. Avoiding that would mean server-rendering index.html, which is
|
||||||
|
* #179's territory and deliberately out of scope here. In a pinned tab — where
|
||||||
|
* telling ponds apart matters most — the tab is already open, so the swap is
|
||||||
|
* the normal case rather than the exception.
|
||||||
|
*
|
||||||
|
* Driven by the RESOLVED pond, never by the raw route parameter: an unreadable
|
||||||
|
* or non-existent pond slug must not leave a stale icon in the tab.
|
||||||
|
*/
|
||||||
|
export function usePondFavicon(pondId: string | undefined, hasPondFavicon: boolean): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||||
|
if (!link) return undefined;
|
||||||
|
const instanceHref = '/api/v1/branding/favicon';
|
||||||
|
link.href = pondId && hasPondFavicon ? `${instanceHref}?pond=${pondId}` : instanceHref;
|
||||||
|
return () => {
|
||||||
|
link.href = instanceHref;
|
||||||
|
};
|
||||||
|
}, [pondId, hasPondFavicon]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInvalidateBranding(): () => Promise<void> {
|
export function useInvalidateBranding(): () => Promise<void> {
|
||||||
|
|||||||
21
apps/web/src/branding/use-pond-id.ts
Normal file
21
apps/web/src/branding/use-pond-id.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type { PondView } from '@dorfteich/shared';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { apiGet } from '../lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current pond's id and name from its slug (issue #307).
|
||||||
|
*
|
||||||
|
* Shares the sidebar's query key, so the pond is fetched once. Returns
|
||||||
|
* nothing for an unreadable or unknown slug — which is exactly why the
|
||||||
|
* favicon swap is driven by this and not by the raw route parameter: a bad
|
||||||
|
* slug must not leave a stale icon in the tab.
|
||||||
|
*/
|
||||||
|
export function usePondId(pondSlug: string | null): { pondId?: string; pondName?: string } {
|
||||||
|
const pond = useQuery({
|
||||||
|
queryKey: ['pond', pondSlug],
|
||||||
|
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug!}`),
|
||||||
|
enabled: Boolean(pondSlug),
|
||||||
|
});
|
||||||
|
return { pondId: pond.data?.id, pondName: pond.data?.name };
|
||||||
|
}
|
||||||
@ -21,6 +21,7 @@ import { StartPageSetting } from '../ponds/StartPageSetting';
|
|||||||
import { SidebarViewSetting } from '../layout/SidebarViewSetting';
|
import { SidebarViewSetting } from '../layout/SidebarViewSetting';
|
||||||
import { MemberManager } from '../members/MemberManager';
|
import { MemberManager } from '../members/MemberManager';
|
||||||
import { DeletePondSection } from '../ponds/DeletePondSection';
|
import { DeletePondSection } from '../ponds/DeletePondSection';
|
||||||
|
import { PondBrandingSection } from '../ponds/PondBrandingSection';
|
||||||
import { PondPluginSettings } from '../plugins/PondPluginSettings';
|
import { PondPluginSettings } from '../plugins/PondPluginSettings';
|
||||||
import { PondThemeSection } from '../theme/PondThemeSection';
|
import { PondThemeSection } from '../theme/PondThemeSection';
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
const { t: tMembers } = useTranslation('members');
|
const { t: tMembers } = useTranslation('members');
|
||||||
const { t: tErrors } = useTranslation('errors');
|
const { t: tErrors } = useTranslation('errors');
|
||||||
const { t: tFiles } = useTranslation('files');
|
const { t: tFiles } = useTranslation('files');
|
||||||
|
const { t: tBranding } = useTranslation('branding');
|
||||||
const { t: tExport } = useTranslation('export');
|
const { t: tExport } = useTranslation('export');
|
||||||
const { t: tComments } = useTranslation('comments');
|
const { t: tComments } = useTranslation('comments');
|
||||||
const { t: tApiTokens } = useTranslation('apiTokens');
|
const { t: tApiTokens } = useTranslation('apiTokens');
|
||||||
@ -114,6 +116,8 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
pondSlug={pondSlug}
|
pondSlug={pondSlug}
|
||||||
theme={pond.data.settings.theme}
|
theme={pond.data.settings.theme}
|
||||||
/>
|
/>
|
||||||
|
<h3>{tBranding('admin.title')}</h3>
|
||||||
|
<PondBrandingSection pondId={pond.data.id} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{canModify && <PondPluginSettings pondId={pond.data.id} />}
|
{canModify && <PondPluginSettings pondId={pond.data.id} />}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
|
|
||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
import { apiDelete } from '../lib/api';
|
import { apiDelete } from '../lib/api';
|
||||||
|
import { PondArchiveOffer } from './PondArchiveOffer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pond's danger zone: move a shared pond to the site-level trash
|
* The pond's danger zone: move a shared pond to the site-level trash
|
||||||
@ -45,6 +46,7 @@ export function DeletePondSection({
|
|||||||
<section className="pond-delete">
|
<section className="pond-delete">
|
||||||
<h2>{t('pond.delete.title')}</h2>
|
<h2>{t('pond.delete.title')}</h2>
|
||||||
<p className="pond-delete__hint">{t('pond.delete.hint')}</p>
|
<p className="pond-delete__hint">{t('pond.delete.hint')}</p>
|
||||||
|
<PondArchiveOffer pondId={pondId} />
|
||||||
<form onSubmit={(e) => void remove(e)}>
|
<form onSubmit={(e) => void remove(e)}>
|
||||||
<FormError error={error} />
|
<FormError error={error} />
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
63
apps/web/src/ponds/PondArchiveOffer.tsx
Normal file
63
apps/web/src/ponds/PondArchiveOffer.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { PondArchivePreview } from '@dorfteich/shared';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { apiGet } from '../lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last full archive, offered INSIDE the deletion flow (issue #305).
|
||||||
|
*
|
||||||
|
* The gap this closes is not a missing prompt — the type-to-confirm field
|
||||||
|
* next to it is stricter than a dialog. It is that the person who deletes the
|
||||||
|
* pond loses access the moment they do: the pond disappears from their view,
|
||||||
|
* only a Site Admin can bring it back, and the export is no longer reachable
|
||||||
|
* for them. So the offer has to be here, before the button, not afterwards.
|
||||||
|
*
|
||||||
|
* Not downloading is allowed. A pond full of test pages should not require a
|
||||||
|
* download, and the server cannot tell whether a file actually arrived — so
|
||||||
|
* the finality is stated in text instead of enforced.
|
||||||
|
*/
|
||||||
|
export function PondArchiveOffer({ pondId }: { pondId: string }): React.JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [started, setStarted] = useState(false);
|
||||||
|
|
||||||
|
const preview = useQuery({
|
||||||
|
queryKey: ['pond', pondId, 'archive-preview'],
|
||||||
|
queryFn: () => apiGet<PondArchivePreview>(`/ponds/${pondId}/archive/preview`),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pond-archive">
|
||||||
|
<p>{t('pond.archive.intro')}</p>
|
||||||
|
{/* An archive silently missing content is worse than no archive: it ends
|
||||||
|
the search. So the omission is named BEFORE the download, with its
|
||||||
|
number, not implied afterwards. */}
|
||||||
|
{preview.data && !preview.data.complete && (
|
||||||
|
<p className="pond-archive__warning">
|
||||||
|
<span aria-hidden="true">⚠ </span>
|
||||||
|
{t('pond.archive.omitted', { count: preview.data.omittedPages })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p>{t('pond.archive.noImport')}</p>
|
||||||
|
{/*
|
||||||
|
A plain link, not fetch-into-a-blob: the api streams the ZIP, and
|
||||||
|
buffering a whole pond in the tab to show a progress bar would trade
|
||||||
|
memory for cosmetics. The browser's own download UI reports progress
|
||||||
|
and completion; what it cannot say is that the archive is being BUILT,
|
||||||
|
so the live region below says that.
|
||||||
|
*/}
|
||||||
|
<a
|
||||||
|
className="button pond-archive__download"
|
||||||
|
href={`/api/v1/ponds/${pondId}/archive`}
|
||||||
|
onClick={() => setStarted(true)}
|
||||||
|
>
|
||||||
|
{t('pond.archive.download')}
|
||||||
|
</a>
|
||||||
|
<p role="status" className="pond-archive__status">
|
||||||
|
{started ? t('pond.archive.started') : ''}
|
||||||
|
</p>
|
||||||
|
<p className="pond-archive__finality">{t('pond.archive.finality')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
apps/web/src/ponds/PondBrandingSection.tsx
Normal file
234
apps/web/src/ponds/PondBrandingSection.tsx
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import { LogoVariant, MAX_LOGO_EDGE, PondBranding } from '@dorfteich/shared';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { CropField } from '../branding/CropField';
|
||||||
|
import { canvasToPngFile, drawCrop } from '../branding/crop';
|
||||||
|
import { logoUrl } from '../branding/use-branding';
|
||||||
|
import { FormError, FormSuccess } from '../components/forms';
|
||||||
|
import { apiDelete, apiGet, apiPostForm } from '../lib/api';
|
||||||
|
|
||||||
|
const FAVICON_SIZES = [32, 180] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pond's own logo and favicon (issue #307).
|
||||||
|
*
|
||||||
|
* Reuses the instance screen's crop control rather than growing a second,
|
||||||
|
* drag-only one: the uploader here is an ordinary Pond Admin, and the
|
||||||
|
* keyboard operability is not theirs to lose.
|
||||||
|
*/
|
||||||
|
export function PondBrandingSection({ pondId }: { pondId: string }): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('branding');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const branding = useQuery({
|
||||||
|
queryKey: ['pond', pondId, 'branding'],
|
||||||
|
queryFn: () => apiGet<PondBranding>(`/ponds/${pondId}/branding`),
|
||||||
|
});
|
||||||
|
const view = branding.data;
|
||||||
|
|
||||||
|
const refresh = async (): Promise<void> => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['pond', pondId, 'branding'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="branding pond-branding">
|
||||||
|
<p>{t('pond.intro')}</p>
|
||||||
|
<p>{t('pond.quotaNote')}</p>
|
||||||
|
|
||||||
|
<PondLogoSlot
|
||||||
|
variant="light"
|
||||||
|
pondId={pondId}
|
||||||
|
asset={view?.logo ?? null}
|
||||||
|
onChanged={refresh}
|
||||||
|
/>
|
||||||
|
<PondLogoSlot
|
||||||
|
variant="dark"
|
||||||
|
pondId={pondId}
|
||||||
|
asset={view?.logoDark ?? null}
|
||||||
|
onChanged={refresh}
|
||||||
|
/>
|
||||||
|
{view?.logo && !view.logoDark && (
|
||||||
|
// Advisory, exactly as on the instance screen: a pond that uploads
|
||||||
|
// only a light logo shows THAT logo in dark mode — it does not borrow
|
||||||
|
// the instance's dark one. Nothing is blocked.
|
||||||
|
<p className="branding__warning" role="note">
|
||||||
|
<span aria-hidden="true">⚠ </span>
|
||||||
|
{t('pond.darkMissing')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PondFaviconSlot pondId={pondId} present={Boolean(view?.favicon)} onChanged={refresh} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PondLogoSlot({
|
||||||
|
pondId,
|
||||||
|
variant,
|
||||||
|
asset,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
pondId: string;
|
||||||
|
variant: LogoVariant;
|
||||||
|
asset: { hash: string; width: number; height: number } | null;
|
||||||
|
onChanged: () => Promise<void>;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('branding');
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const upload = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) throw new Error('no image');
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', await canvasToPngFile(canvas, `logo-${variant}.png`));
|
||||||
|
return apiPostForm<PondBranding>(`/ponds/${pondId}/branding/logo?variant=${variant}`, form);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
setDone(true);
|
||||||
|
await onChanged();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reset = useMutation({
|
||||||
|
mutationFn: () => apiDelete(`/ponds/${pondId}/branding/logo?variant=${variant}`),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setDone(false);
|
||||||
|
await onChanged();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="branding__slot" data-pond-logo-variant={variant}>
|
||||||
|
<h3>{t(`admin.logo.${variant}`)}</h3>
|
||||||
|
<FormError error={upload.error ?? reset.error} />
|
||||||
|
<FormSuccess message={done ? t('admin.logo.saved') : null} />
|
||||||
|
{asset ? (
|
||||||
|
<div className="branding__current">
|
||||||
|
<img
|
||||||
|
src={logoUrl(variant, asset.hash, pondId)}
|
||||||
|
alt={t('admin.logo.currentAlt')}
|
||||||
|
className={`branding__preview branding__preview--${variant}`}
|
||||||
|
/>
|
||||||
|
<p>{t('admin.logo.current', { width: asset.width, height: asset.height })}</p>
|
||||||
|
{/* A real, labelled control — not an empty file field standing in
|
||||||
|
for "remove". */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--outline"
|
||||||
|
onClick={() => reset.mutate()}
|
||||||
|
disabled={reset.isPending}
|
||||||
|
>
|
||||||
|
{t('pond.reset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>{t('pond.inherited')}</p>
|
||||||
|
)}
|
||||||
|
<CropField
|
||||||
|
idPrefix={`pond-logo-${variant}`}
|
||||||
|
square={false}
|
||||||
|
maxEdge={MAX_LOGO_EDGE}
|
||||||
|
onChange={(canvas) => {
|
||||||
|
canvasRef.current = canvas;
|
||||||
|
setDone(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button"
|
||||||
|
onClick={() => upload.mutate()}
|
||||||
|
disabled={upload.isPending}
|
||||||
|
>
|
||||||
|
{t('admin.logo.submit')}
|
||||||
|
</button>
|
||||||
|
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PondFaviconSlot({
|
||||||
|
pondId,
|
||||||
|
present,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
pondId: string;
|
||||||
|
present: boolean;
|
||||||
|
onChanged: () => Promise<void>;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('branding');
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const upload = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const source = canvasRef.current;
|
||||||
|
if (!source) throw new Error('no image');
|
||||||
|
const form = new FormData();
|
||||||
|
for (const size of FAVICON_SIZES) {
|
||||||
|
const scratch = document.createElement('canvas');
|
||||||
|
drawCrop(
|
||||||
|
source,
|
||||||
|
{ x: 0, y: 0, width: source.width, height: source.height },
|
||||||
|
{ width: size, height: size },
|
||||||
|
scratch,
|
||||||
|
);
|
||||||
|
form.append(`png-${size}`, await canvasToPngFile(scratch, `favicon-${size}.png`));
|
||||||
|
}
|
||||||
|
return apiPostForm<PondBranding>(`/ponds/${pondId}/branding/favicon`, form);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
setDone(true);
|
||||||
|
await onChanged();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reset = useMutation({
|
||||||
|
mutationFn: () => apiDelete(`/ponds/${pondId}/branding/favicon`),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setDone(false);
|
||||||
|
await onChanged();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="branding__slot" data-branding-slot="pond-favicon">
|
||||||
|
<h3>{t('admin.favicon.title')}</h3>
|
||||||
|
<p>{t('pond.faviconHint')}</p>
|
||||||
|
<FormError error={upload.error ?? reset.error} />
|
||||||
|
<FormSuccess message={done ? t('admin.favicon.saved') : null} />
|
||||||
|
<p>{present ? t('admin.favicon.present') : t('pond.inherited')}</p>
|
||||||
|
{present && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--outline"
|
||||||
|
onClick={() => reset.mutate()}
|
||||||
|
disabled={reset.isPending}
|
||||||
|
>
|
||||||
|
{t('pond.reset')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<CropField
|
||||||
|
idPrefix="pond-favicon"
|
||||||
|
square
|
||||||
|
maxEdge={180}
|
||||||
|
onChange={(canvas) => {
|
||||||
|
canvasRef.current = canvas;
|
||||||
|
setDone(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button"
|
||||||
|
onClick={() => upload.mutate()}
|
||||||
|
disabled={upload.isPending}
|
||||||
|
>
|
||||||
|
{t('admin.favicon.submit')}
|
||||||
|
</button>
|
||||||
|
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4281,3 +4281,29 @@ ul[data-type='task_list'] li p:last-of-type {
|
|||||||
8px -8px,
|
8px -8px,
|
||||||
-8px 0;
|
-8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Full-archive offer inside the pond deletion flow (issue #305). Plain
|
||||||
|
column: the section must reflow at 320px without rules of its own (#301). */
|
||||||
|
.pond-archive {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pond-archive__warning {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pond-archive__download {
|
||||||
|
display: inline-block;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pond-archive__finality {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
# Audit event catalogue
|
# Audit event catalogue
|
||||||
|
|
||||||
**Catalogue version 1.7 (2026-08-01; 1.7 adds `branding.changed`,
|
**Catalogue version 1.8 (2026-08-01; 1.8 adds `pond.archived`,
|
||||||
|
issue #305; 1.7 added `branding.changed`,
|
||||||
issue #306; 1.6 added `font.uploaded` and
|
issue #306; 1.6 added `font.uploaded` and
|
||||||
`font.deleted`, issue #303; 1.5 added `plugin.rejected`,
|
`font.deleted`, issue #303; 1.5 added `plugin.rejected`,
|
||||||
issue #232; 1.4 added `auth.proxy_rejected`, issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added
|
issue #232; 1.4 added `auth.proxy_rejected`, issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added
|
||||||
@ -113,6 +114,7 @@ failure), `warning` = feeds detection (suspicious or destructive),
|
|||||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||||
| ----------------------- | -------------------------------------------------------------------- | -------- | ---------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | -------------------------------------------------------------------- | -------- | ---------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `file.integrity_failed` | Attachment download hash mismatch — fail-closed (issue #199) | critical | `null` (any downloader; detection) | `attachment` | `pondId`, `expected` (stored sha256), `actual` (computed sha256) |
|
| `file.integrity_failed` | Attachment download hash mismatch — fail-closed (issue #199) | critical | `null` (any downloader; detection) | `attachment` | `pondId`, `expected` (stored sha256), `actual` (computed sha256) |
|
||||||
|
| `pond.archived` | Full pond archive downloaded before deletion or purge (#305) | notice | the pond admin or Site Admin | `pond` | `pages`, `attachments`, `omittedPages`, `complete` |
|
||||||
| `pond.purged` | Pond irreversibly destroyed (manual or trash retention, issue #193) | notice | admin, `null` when retention-run | `pond` | `trigger` (`manual` \| `retention`) plus per-object-type deletion counts (e.g. `pages`, `attachments`, … — informational, keys may grow) |
|
| `pond.purged` | Pond irreversibly destroyed (manual or trash retention, issue #193) | notice | admin, `null` when retention-run | `pond` | `trigger` (`manual` \| `retention`) plus per-object-type deletion counts (e.g. `pages`, `attachments`, … — informational, keys may grow) |
|
||||||
| `audit.pruned` | Audit retention deleted rows past the period (issue #196) | info | `null` (system) | — | `count`, `cutoff` (ISO), `retentionDays` |
|
| `audit.pruned` | Audit retention deleted rows past the period (issue #196) | info | `null` (system) | — | `count`, `cutoff` (ISO), `retentionDays` |
|
||||||
| `read_trail.pruned` | Read-trail retention removed events past its own period (issue #224) | info | `null` (system) | — | `count`, `cutoff` (ISO), `retentionDays` |
|
| `read_trail.pruned` | Read-trail retention removed events past its own period (issue #224) | info | `null` (system) | — | `count`, `cutoff` (ISO), `retentionDays` |
|
||||||
|
|||||||
118
docs/architecture/pond-archive-format.md
Normal file
118
docs/architecture/pond-archive-format.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# Pond archive format (issue #305)
|
||||||
|
|
||||||
|
**Format version 1.**
|
||||||
|
|
||||||
|
The archive a pond admin downloads before deleting a pond, and the one a Site
|
||||||
|
Admin downloads before purging one. It is a **preservation format, not a
|
||||||
|
backup**: Dorfteich has no importer for it, deliberately. What this document
|
||||||
|
buys is that one can be written later without guesswork.
|
||||||
|
|
||||||
|
Do not confuse it with two neighbours:
|
||||||
|
|
||||||
|
| | contains | purpose |
|
||||||
|
| ------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------- |
|
||||||
|
| Markdown export (`GET /ponds/:id/export/markdown`, #65) | readable pages + the images they embed | everyday "give me my text" |
|
||||||
|
| **Pond archive** (this document) | readable pages + **all** attachments + settings, labels, comments, hierarchy | last resort before deletion |
|
||||||
|
| Restore set (ADR 0015) | the whole instance, database and data directories | operational recovery |
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
README.txt plain-text version of this warning, for whoever unpacks it
|
||||||
|
manifest.json everything Markdown cannot carry (see below)
|
||||||
|
pages/<slug>.md one file per page, Markdown, wikilinks rewritten to
|
||||||
|
relative links, media references rewritten to media/…
|
||||||
|
media/<id>.<ext> EVERY attachment of the pond — including ones no page
|
||||||
|
embeds, which is the whole point of this archive
|
||||||
|
media/<id>.<ext>.classification.txt
|
||||||
|
companion marking for a classified attachment (#212): the
|
||||||
|
binary cannot carry it, and this file survives copying
|
||||||
|
```
|
||||||
|
|
||||||
|
## `manifest.json`
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"kind": "dorfteich-pond-archive",
|
||||||
|
"formatVersion": 1,
|
||||||
|
"exportedAt": "2026-08-01T18:00:00.000Z",
|
||||||
|
|
||||||
|
// False when the exporter could not read every page. Stated in the archive
|
||||||
|
// itself so a later reader is never misled about what they hold.
|
||||||
|
"complete": true,
|
||||||
|
"omittedPages": 0,
|
||||||
|
|
||||||
|
// Highest classification contained (ADR 0022), stated once.
|
||||||
|
"classification": "unclassified",
|
||||||
|
|
||||||
|
"pond": { "name": "…", "slug": "…", "type": "SHARED", "createdAt": "…", "settings": { … } },
|
||||||
|
"labels": [{ "id": "…", "name": "…", "color": "…", "parentId": null }],
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"id": "…", "slug": "…", "title": "…",
|
||||||
|
"parentId": null, // the hierarchy Markdown cannot express
|
||||||
|
"sortKey": "…", // sibling order (ADR 0012's fractional key)
|
||||||
|
"classification": "unclassified",
|
||||||
|
"labelIds": ["…"],
|
||||||
|
"createdAt": "…", "updatedAt": "…",
|
||||||
|
"file": "pages/<slug>.md"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"id": "…", "pageId": "…", "parentId": null, "body": "…",
|
||||||
|
"author": "Display Name", // NOT the account id — see below
|
||||||
|
"createdAt": "…", "editedAt": null, "resolvedAt": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"id": "…", "pageId": "…" | null, "fileName": "…", "mimeType": "…",
|
||||||
|
"sizeBytes": 1234,
|
||||||
|
"sha256": "…", // #199, so a reader can verify the bytes
|
||||||
|
"createdAt": "…", "file": "media/<id>.<ext>"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"files": [{ "path": "…", "classification": "unclassified" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decisions a reader should know about
|
||||||
|
|
||||||
|
- **`formatVersion` is a contract.** A reader that does not recognise the
|
||||||
|
version should refuse rather than guess. Additive fields do not bump it;
|
||||||
|
a change in meaning does.
|
||||||
|
- **Comments name a display name, not an account.** The archive is a document
|
||||||
|
that outlives the instance; an account id would be an unresolvable reference
|
||||||
|
the moment the account is gone.
|
||||||
|
- **`files` is the #210 property, kept.** Every file with its level, so the
|
||||||
|
bulk-egress channel stays machine-checkable after the ZIP is unpacked and
|
||||||
|
copied onward.
|
||||||
|
- **An attachment with no page inherits the pond's highest classification.**
|
||||||
|
Nothing narrower governs it, and fail-closed is the rule (ADR 0022).
|
||||||
|
- **Incomplete archives are labelled, not refused.** A pond admin who cannot
|
||||||
|
read every page still gets what they may read — with `complete: false` and
|
||||||
|
the count of what is missing, in the manifest and in the UI before the
|
||||||
|
download.
|
||||||
|
|
||||||
|
## Read trail
|
||||||
|
|
||||||
|
The archive is a bulk-egress channel. One `export` read event is written per
|
||||||
|
classified page **before any classified byte enters the stream** (ADR 0023), so
|
||||||
|
a failed write aborts the download with the evidence intact. Attachments never
|
||||||
|
travel without their page, so they are covered by the same events.
|
||||||
|
|
||||||
|
The download itself is audited as `pond.archived` (catalogue v1.7) with the
|
||||||
|
page and attachment counts, the number of omitted pages, and whether the
|
||||||
|
archive was complete.
|
||||||
|
|
||||||
|
## What is NOT in it
|
||||||
|
|
||||||
|
- Page history and Yjs update logs. The Markdown is the current state.
|
||||||
|
- Permissions and memberships: they name accounts of _this_ instance, which an
|
||||||
|
archive read elsewhere cannot resolve.
|
||||||
|
- Anything from the trash: trashed pages are not exported.
|
||||||
|
|
||||||
|
An importer will therefore recreate a pond's content, structure and
|
||||||
|
discussion — not its history or its access rules. That is a deliberate scope,
|
||||||
|
not an oversight.
|
||||||
@ -37,5 +37,13 @@
|
|||||||
"size": "Kantenlänge des Ausschnitts (px)",
|
"size": "Kantenlänge des Ausschnitts (px)",
|
||||||
"reset": "Ausschnitt zurücksetzen",
|
"reset": "Ausschnitt zurücksetzen",
|
||||||
"result": "Ergebnis: {{width}} × {{height}} px (Ausgangsbild {{sourceWidth}} × {{sourceHeight}} px)."
|
"result": "Ergebnis: {{width}} × {{height}} px (Ausgangsbild {{sourceWidth}} × {{sourceHeight}} px)."
|
||||||
|
},
|
||||||
|
"pond": {
|
||||||
|
"intro": "Dieser Teich kann ein eigenes Logo und ein eigenes Favicon führen. Beides überschreibt das der Instanz — nur für diesen Teich.",
|
||||||
|
"quotaNote": "Die Dateien zählen auf das Speicher-Kontingent dieses Teichs, wie Anhänge auch.",
|
||||||
|
"inherited": "Nichts hinterlegt — es gilt die Einstellung der Instanz.",
|
||||||
|
"reset": "Auf die Instanz-Einstellung zurücksetzen",
|
||||||
|
"darkMissing": "Für den Dunkelmodus ist kein eigenes Teich-Logo hinterlegt. Dann wird dort das helle Logo dieses Teichs verwendet — NICHT das dunkle Logo der Instanz. Ein Logo-Satz gehört zu einer Ebene und wird nie ebenenübergreifend gemischt. Das ist ein Hinweis, keine Sperre.",
|
||||||
|
"faviconHint": "Das Favicon wird beim Betreten des Teichs im Tab gesetzt und beim Verlassen wieder zurückgestellt. Beim direkten Öffnen eines Teich-Links erscheint kurz das Instanz-Favicon, bevor es wechselt."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,6 +92,15 @@
|
|||||||
"confirmLabel": "Zur Bestätigung den Teichnamen eintippen: {{name}}",
|
"confirmLabel": "Zur Bestätigung den Teichnamen eintippen: {{name}}",
|
||||||
"submit": "Teich löschen",
|
"submit": "Teich löschen",
|
||||||
"deleted": "Teich gelöscht."
|
"deleted": "Teich gelöscht."
|
||||||
|
},
|
||||||
|
"archive": {
|
||||||
|
"intro": "Bevor du löschst: Lade hier ein Vollarchiv dieses Teichs herunter. Es enthält alle Seiten als Markdown, ALLE Dateianhänge — auch die, die auf keiner Seite eingebunden sind — sowie Einstellungen, Labels, Kommentare und die Seitenstruktur in einer manifest.json.",
|
||||||
|
"omitted_one": "Achtung: Eine Seite dieses Teichs kannst du nicht lesen und fehlt deshalb im Archiv.",
|
||||||
|
"omitted_other": "Achtung: {{count}} Seiten dieses Teichs kannst du nicht lesen und fehlen deshalb im Archiv.",
|
||||||
|
"noImport": "Das Archiv ist ein Bewahrungsformat: Es lässt sich derzeit nicht wieder einspielen. Das Format ist dokumentiert, damit ein Importer später geschrieben werden kann — verlasse dich also nicht darauf, den Teich damit auf Knopfdruck zurückzuholen.",
|
||||||
|
"download": "Vollarchiv herunterladen",
|
||||||
|
"started": "Das Archiv wird erstellt; der Download startet, sobald es fertig ist.",
|
||||||
|
"finality": "Du musst nicht herunterladen. Ohne Archiv gilt aber: Nach Ablauf der Aufbewahrungsfrist im Papierkorb wird der Teich endgültig entfernt — danach bleibt nichts von ihm übrig, weder Seiten noch Dateien."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settingsNav": {
|
"settingsNav": {
|
||||||
|
|||||||
@ -37,5 +37,13 @@
|
|||||||
"size": "Crop edge length (px)",
|
"size": "Crop edge length (px)",
|
||||||
"reset": "Reset the crop",
|
"reset": "Reset the crop",
|
||||||
"result": "Result: {{width}} × {{height}} px (source image {{sourceWidth}} × {{sourceHeight}} px)."
|
"result": "Result: {{width}} × {{height}} px (source image {{sourceWidth}} × {{sourceHeight}} px)."
|
||||||
|
},
|
||||||
|
"pond": {
|
||||||
|
"intro": "This pond can carry its own logo and favicon. Both override the instance's — for this pond only.",
|
||||||
|
"quotaNote": "The files count against this pond's storage quota, just like attachments.",
|
||||||
|
"inherited": "Nothing set — the instance's setting applies.",
|
||||||
|
"reset": "Reset to the instance setting",
|
||||||
|
"darkMissing": "No separate dark-mode logo is set for this pond. The pond's light logo is then used there — NOT the instance's dark one. A logo set belongs to one level and is never mixed across levels. This is advice, not a block.",
|
||||||
|
"faviconHint": "The favicon is applied to the tab when the pond is entered and restored on leaving. Opening a pond link directly shows the instance favicon briefly before it changes."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,6 +92,15 @@
|
|||||||
"confirmLabel": "Type the pond name to confirm: {{name}}",
|
"confirmLabel": "Type the pond name to confirm: {{name}}",
|
||||||
"submit": "Delete pond",
|
"submit": "Delete pond",
|
||||||
"deleted": "Pond deleted."
|
"deleted": "Pond deleted."
|
||||||
|
},
|
||||||
|
"archive": {
|
||||||
|
"intro": "Before you delete: download a full archive of this pond here. It contains every page as Markdown, ALL attachments — including those no page embeds — plus settings, labels, comments and the page structure in a manifest.json.",
|
||||||
|
"omitted_one": "Careful: there is one page in this pond you cannot read, so it is missing from the archive.",
|
||||||
|
"omitted_other": "Careful: there are {{count}} pages in this pond you cannot read, so they are missing from the archive.",
|
||||||
|
"noImport": "The archive is a preservation format: it cannot currently be imported back. The format is documented so an importer can be written later — so do not rely on it to bring the pond back at the push of a button.",
|
||||||
|
"download": "Download the full archive",
|
||||||
|
"started": "The archive is being built; the download starts as soon as it is ready.",
|
||||||
|
"finality": "You do not have to download it. But without an archive: once the trash retention period is over the pond is removed for good — after that nothing of it remains, neither pages nor files."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settingsNav": {
|
"settingsNav": {
|
||||||
|
|||||||
97
packages/shared/src/branding.test.ts
Normal file
97
packages/shared/src/branding.test.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { pngDimensions, hasPngMagic, looksLikeSvg, resolveBranding } from './branding';
|
||||||
|
|
||||||
|
const asset = (hash: string) => ({ hash, width: 10, height: 10 });
|
||||||
|
const NONE = { logo: null, logoDark: null, favicon: null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolution order (issues #306/#307) lives in one function, so this is
|
||||||
|
* where the decision that most easily gets "fixed" by accident is pinned:
|
||||||
|
* a logo set belongs to ONE level and variants are never mixed across levels.
|
||||||
|
*/
|
||||||
|
describe('resolveBranding', () => {
|
||||||
|
it('prefers the pond over the instance', () => {
|
||||||
|
const resolved = resolveBranding(
|
||||||
|
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: null, favicon: null },
|
||||||
|
{ ...NONE, logo: asset('bbbbbbbbbbbbbbbb') },
|
||||||
|
);
|
||||||
|
expect(resolved.logo?.hash).toBe('bbbbbbbbbbbbbbbb');
|
||||||
|
expect(resolved.logoLevel).toBe('pond');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a pond on ITS OWN light logo in dark mode, never the instance dark one', () => {
|
||||||
|
// Decided 2026-08-01: a logo silently swapping to a different image when
|
||||||
|
// the viewer switches theme is a change nobody ordered. A design that
|
||||||
|
// looks wrong is more honest than one that is quietly substituted.
|
||||||
|
const resolved = resolveBranding(
|
||||||
|
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: asset('cccccccccccccccc'), favicon: null },
|
||||||
|
{ ...NONE, logo: asset('bbbbbbbbbbbbbbbb') },
|
||||||
|
);
|
||||||
|
expect(resolved.logo?.hash).toBe('bbbbbbbbbbbbbbbb');
|
||||||
|
expect(resolved.logoDark).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inherits BOTH instance variants when the pond has no logo at all', () => {
|
||||||
|
const resolved = resolveBranding(
|
||||||
|
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: asset('cccccccccccccccc'), favicon: null },
|
||||||
|
NONE,
|
||||||
|
);
|
||||||
|
expect(resolved.logo?.hash).toBe('aaaaaaaaaaaaaaaa');
|
||||||
|
expect(resolved.logoDark?.hash).toBe('cccccccccccccccc');
|
||||||
|
expect(resolved.logoLevel).toBe('instance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a pond with only a DARK logo as a set of its own too', () => {
|
||||||
|
const resolved = resolveBranding(
|
||||||
|
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: null, favicon: null },
|
||||||
|
{ ...NONE, logoDark: asset('dddddddddddddddd') },
|
||||||
|
);
|
||||||
|
expect(resolved.logo).toBeNull();
|
||||||
|
expect(resolved.logoDark?.hash).toBe('dddddddddddddddd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to nothing, which is the render-the-name case', () => {
|
||||||
|
expect(resolveBranding(NONE, NONE).logoLevel).toBe('none');
|
||||||
|
expect(resolveBranding(NONE, null).logo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the favicon per level, down to the shipped default', () => {
|
||||||
|
expect(resolveBranding(NONE, { ...NONE, favicon: asset('e'.repeat(16)) }).faviconLevel).toBe(
|
||||||
|
'pond',
|
||||||
|
);
|
||||||
|
expect(resolveBranding({ ...NONE, favicon: asset('f'.repeat(16)) }, NONE).faviconLevel).toBe(
|
||||||
|
'instance',
|
||||||
|
);
|
||||||
|
expect(resolveBranding(NONE, NONE).faviconLevel).toBe('default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('image checks (no decoding)', () => {
|
||||||
|
const png = (width: number, height: number): Uint8Array => {
|
||||||
|
const buf = Buffer.alloc(33);
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0);
|
||||||
|
buf.write('IHDR', 12, 'latin1');
|
||||||
|
buf.writeUInt32BE(width, 16);
|
||||||
|
buf.writeUInt32BE(height, 20);
|
||||||
|
return buf;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('reads the dimensions out of the IHDR', () => {
|
||||||
|
expect(pngDimensions(png(512, 128))).toEqual({ width: 512, height: 128 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses anything that is not a PNG with an IHDR first', () => {
|
||||||
|
expect(hasPngMagic(Buffer.from('GIF89a'))).toBe(false);
|
||||||
|
expect(pngDimensions(Buffer.from('GIF89a'))).toBeNull();
|
||||||
|
// Truncated: a reader that trusted the signature alone would run off the
|
||||||
|
// end here.
|
||||||
|
expect(pngDimensions(Buffer.from([0x89, 0x50, 0x4e, 0x47]))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises SVG so the rejection can say why', () => {
|
||||||
|
expect(looksLikeSvg(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg">'))).toBe(true);
|
||||||
|
expect(looksLikeSvg(Buffer.from('<?xml version="1.0"?>\n<svg>'))).toBe(true);
|
||||||
|
expect(looksLikeSvg(png(1, 1))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -81,6 +81,10 @@ export const brandingAssetSchema = z.object({
|
|||||||
.regex(/^[a-f0-9]{16,64}$/),
|
.regex(/^[a-f0-9]{16,64}$/),
|
||||||
width: z.number().int().min(1),
|
width: z.number().int().min(1),
|
||||||
height: z.number().int().min(1),
|
height: z.number().int().min(1),
|
||||||
|
/** Stored bytes. Needed so a pond's quota can be released exactly when the
|
||||||
|
* asset is replaced or removed (issue #307) — optional because instance
|
||||||
|
* assets predating it are not charged to anything. */
|
||||||
|
byteSize: z.number().int().min(0).optional(),
|
||||||
});
|
});
|
||||||
export type BrandingAsset = z.infer<typeof brandingAssetSchema>;
|
export type BrandingAsset = z.infer<typeof brandingAssetSchema>;
|
||||||
|
|
||||||
@ -96,3 +100,54 @@ export interface BrandingView {
|
|||||||
* screen carries the branding. */
|
* screen carries the branding. */
|
||||||
instanceName: string;
|
instanceName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A pond's own branding (issue #307), stored in its settings. Null per slot
|
||||||
|
* means "not set at this level". */
|
||||||
|
export const pondBrandingSchema = z.object({
|
||||||
|
logo: brandingAssetSchema.nullable().default(null),
|
||||||
|
logoDark: brandingAssetSchema.nullable().default(null),
|
||||||
|
favicon: brandingAssetSchema.nullable().default(null),
|
||||||
|
});
|
||||||
|
export type PondBranding = z.infer<typeof pondBrandingSchema>;
|
||||||
|
|
||||||
|
/** What a pond page should actually show. */
|
||||||
|
export interface ResolvedBranding {
|
||||||
|
logo: BrandingAsset | null;
|
||||||
|
logoDark: BrandingAsset | null;
|
||||||
|
favicon: BrandingAsset | null;
|
||||||
|
/** Which level the LOGO came from — the link's accessible name follows it:
|
||||||
|
* a pond logo is named by the pond, an instance logo by the instance. */
|
||||||
|
logoLevel: 'pond' | 'instance' | 'none';
|
||||||
|
faviconLevel: 'pond' | 'instance' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single place that decides which asset applies (issues #306/#307):
|
||||||
|
* the pond's own, else the instance's, else the shipped default (favicon) or
|
||||||
|
* the instance name as text (logo).
|
||||||
|
*
|
||||||
|
* **A logo set belongs to one level — variants are NEVER mixed across
|
||||||
|
* levels.** A pond that uploaded only a light logo shows THAT logo in dark
|
||||||
|
* mode; it does not fall back to the instance's dark variant. Decided
|
||||||
|
* 2026-08-01: a logo silently swapping to a different image when the viewer
|
||||||
|
* switches theme is a change nobody ordered, and a design that looks wrong is
|
||||||
|
* more honest than one that is quietly substituted — the pond admin can see
|
||||||
|
* it and fix it. Only a pond with NO logo at all inherits the instance's set,
|
||||||
|
* again as a set.
|
||||||
|
*/
|
||||||
|
export function resolveBranding(
|
||||||
|
instance: Pick<BrandingView, 'logo' | 'logoDark' | 'favicon'>,
|
||||||
|
pond?: PondBranding | null,
|
||||||
|
): ResolvedBranding {
|
||||||
|
const pondHasLogo = Boolean(pond && (pond.logo || pond.logoDark));
|
||||||
|
const logoLevel = pondHasLogo ? 'pond' : instance.logo || instance.logoDark ? 'instance' : 'none';
|
||||||
|
const source = pondHasLogo ? pond! : instance;
|
||||||
|
const faviconLevel = pond?.favicon ? 'pond' : instance.favicon ? 'instance' : 'default';
|
||||||
|
return {
|
||||||
|
logo: logoLevel === 'none' ? null : source.logo,
|
||||||
|
logoDark: logoLevel === 'none' ? null : source.logoDark,
|
||||||
|
favicon: pond?.favicon ?? instance.favicon ?? null,
|
||||||
|
logoLevel,
|
||||||
|
faviconLevel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export * from './search';
|
|||||||
export * from './secret-store';
|
export * from './secret-store';
|
||||||
export * from './setup';
|
export * from './setup';
|
||||||
export * from './system';
|
export * from './system';
|
||||||
|
export * from './pond-archive';
|
||||||
export * from './ponds';
|
export * from './ponds';
|
||||||
export * from './public-api';
|
export * from './public-api';
|
||||||
export * from './quotas';
|
export * from './quotas';
|
||||||
|
|||||||
102
packages/shared/src/pond-archive.ts
Normal file
102
packages/shared/src/pond-archive.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import type { PageClassification } from './pages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full pond archive (issue #305): the last download offered before a pond
|
||||||
|
* is trashed, and before a Site Admin purges one for good.
|
||||||
|
*
|
||||||
|
* A PRESERVATION format, not a backup — there is no importer, on purpose.
|
||||||
|
* What that buys is a documented, versioned description of everything the
|
||||||
|
* Markdown alone cannot carry, so an importer can be written later without
|
||||||
|
* guesswork. The format is documented in
|
||||||
|
* `docs/architecture/pond-archive-format.md`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Bumped whenever the manifest's shape changes in a way a reader must know
|
||||||
|
* about. A reader that does not recognise the version should refuse rather
|
||||||
|
* than guess. */
|
||||||
|
export const POND_ARCHIVE_FORMAT_VERSION = 1;
|
||||||
|
|
||||||
|
export interface PondArchiveLabel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
parentId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PondArchivePage {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
/** The hierarchy Markdown cannot express — null for a top-level page. */
|
||||||
|
parentId: string | null;
|
||||||
|
/** Sibling order, as stored (ADR 0012's fractional key). */
|
||||||
|
sortKey: string;
|
||||||
|
classification: PageClassification;
|
||||||
|
labelIds: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
/** Path of the page's Markdown inside the archive. */
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PondArchiveComment {
|
||||||
|
id: string;
|
||||||
|
pageId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
body: string;
|
||||||
|
/** Display name at export time; the account may be gone by the time anyone
|
||||||
|
* reads this, and the archive should stay readable. */
|
||||||
|
author: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
editedAt: string | null;
|
||||||
|
resolvedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PondArchiveAttachment {
|
||||||
|
id: string;
|
||||||
|
/** Null for an attachment that belongs to the pond rather than a page. */
|
||||||
|
pageId: string | null;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
/** SHA-256 of the stored bytes (issue #199), so a reader can verify them. */
|
||||||
|
sha256: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PondArchiveManifest {
|
||||||
|
kind: 'dorfteich-pond-archive';
|
||||||
|
formatVersion: number;
|
||||||
|
exportedAt: string;
|
||||||
|
/** False when the exporter could not read every page — see `omittedPages`.
|
||||||
|
* Stated in the archive itself so a later reader is never misled about
|
||||||
|
* what they are holding. */
|
||||||
|
complete: boolean;
|
||||||
|
omittedPages: number;
|
||||||
|
/** Highest classification contained (ADR 0022), stated once. */
|
||||||
|
classification: PageClassification;
|
||||||
|
pond: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
type: string;
|
||||||
|
createdAt: string;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
labels: PondArchiveLabel[];
|
||||||
|
pages: PondArchivePage[];
|
||||||
|
comments: PondArchiveComment[];
|
||||||
|
attachments: PondArchiveAttachment[];
|
||||||
|
/** Every file in the archive with its level — the #210 manifest property,
|
||||||
|
* kept so the bulk-egress channel stays machine-checkable after unpacking. */
|
||||||
|
files: { path: string; classification: PageClassification }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the UI asks for before offering the download, so it can say how much
|
||||||
|
* of the pond the requester would actually get. */
|
||||||
|
export interface PondArchivePreview {
|
||||||
|
totalPages: number;
|
||||||
|
includedPages: number;
|
||||||
|
omittedPages: number;
|
||||||
|
complete: boolean;
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { pondBrandingSchema } from './branding';
|
||||||
|
|
||||||
import { COMMENT_POLICIES } from './comments';
|
import { COMMENT_POLICIES } from './comments';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -66,6 +68,12 @@ export const pondSettingsSchema = z.object({
|
|||||||
* as an id, not a slug, so renaming or moving the page does not break it;
|
* as an id, not a slug, so renaming or moving the page does not break it;
|
||||||
* a dangling id (page trashed) falls back rather than erroring. */
|
* a dangling id (page trashed) falls back rather than erroring. */
|
||||||
startPageId: z.string().uuid().nullable().default(null),
|
startPageId: z.string().uuid().nullable().default(null),
|
||||||
|
/** The pond's own logo and favicon (issue #307), overriding the instance's.
|
||||||
|
* Metadata only — the PNG bytes live under `BRANDING_DIR`, like the
|
||||||
|
* instance's. Written through the branding endpoints, never through
|
||||||
|
* `PATCH /ponds/:id`: it describes bytes on disk, and hand-writing it would
|
||||||
|
* claim an asset that is not there. */
|
||||||
|
branding: pondBrandingSchema.default({ logo: null, logoDark: null, favicon: null }),
|
||||||
/** Who may write comments (issue #91): every reader, or editors only. */
|
/** Who may write comments (issue #91): every reader, or editors only. */
|
||||||
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
|
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
|
||||||
/** Per-pond opt-in to the public REST API (issue #104, default off):
|
/** Per-pond opt-in to the public REST API (issue #104, default off):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user