Compare commits

..

No commits in common. "main" and "issue-305-pond-archive" have entirely different histories.

101 changed files with 181 additions and 4405 deletions

View File

@ -345,16 +345,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts
- name: Reset login rate limit before admin-settings pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run admin-settings pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/admin-settings.spec.ts
- name: Reset login rate limit before admin-quotas pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
@ -375,18 +365,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts
- name: Reset login rate limit before invitations pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
# Invitations (issue #332) need the mail catcher like the auth pack:
# the invite link and the follow-up verification both travel by mail.
- name: Run invitations pack
run: |
E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \
pnpm --filter @dorfteich/web exec playwright test e2e/invitations.spec.ts
- name: Reset login rate limit before permission-matrix pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -1,26 +0,0 @@
-- Peer invitations (issue #332): a user invites an e-mail address; the token
-- allows exactly one registration even while registration is closed.
-- CreateTable
CREATE TABLE "invitations" (
"id" TEXT NOT NULL,
"inviter_id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"revoked_at" TIMESTAMP(3),
"accepted_at" TIMESTAMP(3),
"accepted_user_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "invitations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "invitations_token_hash_key" ON "invitations"("token_hash");
-- CreateIndex
CREATE INDEX "invitations_inviter_id_idx" ON "invitations"("inviter_id");
-- AddForeignKey
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_inviter_id_fkey" FOREIGN KEY ("inviter_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -68,34 +68,10 @@ model User {
notifications Notification[]
favorites PageFavorite[]
customFonts CustomFont[]
invitations Invitation[] @relation("InvitationsSent")
@@map("users")
}
/// Peer invitations (issue #332): a user invites an e-mail address; the
/// token allows exactly one registration even while registration is
/// closed. Only the SHA-256 hash of the token is stored (auth-tokens
/// pattern); revoked/accepted rows are kept so the settings UI can show
/// history. "Open" (pending, unexpired) rows count against the per-user
/// quota `invitations.maxOpenPerUser`.
model Invitation {
id String @id @default(uuid())
inviterId String @map("inviter_id")
email String
tokenHash String @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
revokedAt DateTime? @map("revoked_at")
acceptedAt DateTime? @map("accepted_at")
acceptedUserId String? @map("accepted_user_id")
createdAt DateTime @default(now()) @map("created_at")
inviter User @relation("InvitationsSent", fields: [inviterId], references: [id], onDelete: Cascade)
@@index([inviterId])
@@map("invitations")
}
/// Persistent audit trail (issue #86, security.md §Logging): auth events and
/// admin actions — grants, member roles, plugin installs, quota and settings
/// changes, setup steps, manual job triggers. Written by AuditService, which

View File

@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BackupModule } from '../backup/backup.module';
import { PondsModule } from '../ponds/ponds.module';
import { QuotasModule } from '../quotas/quotas.module';
import { SchedulerModule } from '../scheduler/scheduler.module';
import { SearchModule } from '../search/search.module';
@ -20,15 +19,7 @@ import { UserAdminController } from './user-admin.controller';
import { UserAdminService } from './user-admin.service';
@Module({
imports: [
QuotasModule,
UsersModule,
AuthModule,
SchedulerModule,
BackupModule,
SearchModule,
PondsModule,
],
imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule, BackupModule, SearchModule],
controllers: [
AdminSettingsController,
BackupAdminController,

View File

@ -12,11 +12,9 @@ import {
UseGuards,
} from '@nestjs/common';
import {
AdminCreateUserInput,
AdminUserListQuery,
AdminUserListView,
AdminUserView,
adminCreateUserSchema,
adminUserListQuerySchema,
setSiteAdminSchema,
setUserDisabledSchema,
@ -33,14 +31,6 @@ import { UserAdminService } from './user-admin.service';
export class UserAdminController {
constructor(private readonly users: UserAdminService) {}
@Post()
async create(
@Body(new ZodValidationPipe(adminCreateUserSchema)) input: AdminCreateUserInput,
@Req() request: AuthedRequest,
): Promise<AdminUserView> {
return this.users.createUser(request.user!, input);
}
@Get()
async list(
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,

View File

@ -69,64 +69,6 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
await app.close();
});
it('creates an account that can log in right away, with a personal pond (issue #331)', async () => {
const username = `ua-created-${suffix}`;
const res = await api()
.post('/api/v1/admin/users')
.set('Cookie', cookies.admin1!)
.send({
username,
email: `${username}@example.org`,
displayName: 'UA Created',
password,
locale: 'de',
})
.expect(201);
const created = res.body as { id: string; status: string };
ids.created = created.id;
// No verification hop: the admin vouched for the address.
expect(created.status).toBe('ACTIVE');
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200);
// The personal pond exists exactly like after self-registration.
expect(await prisma.pond.count({ where: { ownerId: created.id, type: 'PERSONAL' } })).toBe(1);
});
it('rejects duplicate usernames with a field-level conflict', async () => {
await api()
.post('/api/v1/admin/users')
.set('Cookie', cookies.admin1!)
.send({
username: `ua-created-${suffix}`,
email: `ua-created-other-${suffix}@example.org`,
displayName: 'UA Dup',
password,
locale: 'en',
})
.expect(409)
.expect((r) =>
expect((r.body as { details: Record<string, string[]> }).details.username).toEqual([
'validation.taken',
]),
);
});
it('refuses creation for non-admins', async () => {
await api()
.post('/api/v1/admin/users')
.set('Cookie', cookies.bob!)
.send({
username: `ua-sneak-${suffix}`,
email: `ua-sneak-${suffix}@example.org`,
displayName: 'UA Sneak',
password,
locale: 'en',
})
.expect(403);
});
it('lists and searches users (Site-Admin only)', async () => {
const res = await api()
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)

View File

@ -1,6 +1,5 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
AdminCreateUserInput,
AdminUserListQuery,
AdminUserListView,
AdminUserStatus,
@ -11,9 +10,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AuthService } from '../auth/auth.service';
import { AuditService } from '../audit/audit.service';
import { PondsService } from '../ponds/ponds.service';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { PseudonymizationService } from './pseudonymization.service';
/**
@ -30,33 +27,12 @@ export class UserAdminService {
private readonly prisma: PrismaService,
private readonly pseudonymizer: PseudonymizationService,
private readonly auth: AuthService,
private readonly users: UsersService,
private readonly ponds: PondsService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(UserAdminService.name);
}
/**
* Creates an account on behalf of a user (issue #331). The e-mail is
* marked verified immediately the admin vouches for the address and
* the personal pond is provisioned exactly like the verify-email path
* does, so the account is indistinguishable from a self-registered one.
*/
async createUser(actor: User, input: AdminCreateUserInput): Promise<AdminUserView> {
const user = await this.users.createUser(input);
const verified = await this.users.markEmailVerified(user.id);
await this.ponds.ensurePersonalPond(verified);
await this.audit.record({
action: 'user.created_by_admin',
actorId: actor.id,
targetType: 'user',
targetId: user.id,
});
return this.viewOf(verified, await this.pondCountOf(user.id));
}
async list(query: AdminUserListQuery): Promise<AdminUserListView> {
const q = query.q?.trim();
const where: Prisma.UserWhereInput = q

View File

@ -29,9 +29,6 @@ export const AUDIT_EVENTS = {
'file.integrity_failed': { severity: 'critical' },
'grant.created': { severity: 'notice' },
'grant.deleted': { severity: 'notice' },
'invitation.accepted': { severity: 'notice' },
'invitation.created': { severity: 'info' },
'invitation.revoked': { severity: 'info' },
'job.triggered': { severity: 'info' },
'member.added': { severity: 'notice' },
'member.removed': { severity: 'notice' },
@ -56,7 +53,6 @@ export const AUDIT_EVENTS = {
'setup.completed': { severity: 'info' },
'setup.preseeded': { severity: 'info' },
'setup.smtp_stored': { severity: 'info' },
'user.created_by_admin': { severity: 'notice' },
'user.deleted': { severity: 'notice' },
'user.disabled_set': { severity: 'notice' },
'user.pseudonymized': { severity: 'notice' },

View File

@ -3,7 +3,6 @@ import { APP_GUARD } from '@nestjs/core';
import { AppConfig } from '../config/app-config.service';
import { GrantsModule } from '../grants/grants.module';
import { InvitationsModule } from '../invitations/invitations.module';
import { MailModule } from '../mail/mail.module';
import { PondsModule } from '../ponds/ponds.module';
@ -19,7 +18,7 @@ import { ProxyIdentityService } from './proxy-identity.service';
import { SessionsModule } from './sessions.module';
@Module({
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule, InvitationsModule],
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule],
controllers: [AuthController, OidcController],
providers: [
AuthService,

View File

@ -9,7 +9,6 @@ import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service';
import { InvitationsService } from '../invitations/invitations.service';
import { MailService } from '../mail/mail.service';
import { PondsService } from '../ponds/ponds.service';
import { AuditService } from '../audit/audit.service';
@ -34,7 +33,6 @@ export class AuthService {
private readonly sessions: SessionsService,
private readonly mail: MailService,
private readonly ponds: PondsService,
private readonly invitations: InvitationsService,
private readonly rateLimits: RateLimitService,
private readonly audit: AuditService,
private readonly config: AppConfig,
@ -45,37 +43,10 @@ export class AuthService {
}
async signup(input: SignupInput): Promise<void> {
// An invitation token (issue #332) lets exactly one signup through a
// closed registration. Claimed atomically BEFORE the account exists;
// rolled back if the signup fails (duplicate username), so the invitee
// can retry with the same link.
const invitation = input.invitationToken
? await this.invitations.redeem(input.invitationToken)
: null;
if (input.invitationToken && !invitation) {
throw new BadRequestException({ code: 'token_invalid' });
}
if (!invitation && (await this.settings.get('auth.registrationMode')) === 'closed') {
if ((await this.settings.get('auth.registrationMode')) === 'closed') {
throw new ForbiddenException({ code: 'registration_closed' });
}
let user: User;
try {
user = await this.users.createUser(input);
} catch (error) {
if (invitation) await this.invitations.unredeem(invitation.id);
throw error;
}
if (invitation) {
await this.invitations.markAccepted(invitation.id, user.id);
await this.audit.record({
action: 'invitation.accepted',
actorId: user.id,
targetType: 'invitation',
targetId: invitation.id,
});
}
// The invite link proves nothing about the mailbox (it can be
// forwarded), so the usual verification mail still applies.
const user = await this.users.createUser(input);
await this.sendVerificationMail(user);
await this.audit.record({ action: 'auth.signup', actorId: user.id });
}

View File

@ -3,8 +3,6 @@ import {
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
Query,
Req,
@ -21,14 +19,11 @@ import {
LOGO_VARIANTS,
LogoVariant,
MAX_BRANDING_BYTES,
PondBranding,
} from '@dorfteich/shared';
import type { Response } from 'express';
import { SiteAdminGuard } from '../admin/site-admin.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';
function parseVariant(value: unknown): LogoVariant {
@ -57,19 +52,8 @@ export class BrandingController {
@Public()
@Get('logo')
async logo(
@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);
async logo(@Query('variant') variant: string | undefined, @Res() res: Response): Promise<void> {
const bytes = await this.branding.logoBytes(parseVariant(variant ?? 'light'));
// No shipped default: without a logo the app renders the instance NAME as
// text, so an empty answer here is the honest one.
if (!bytes) {
@ -85,21 +69,12 @@ export class BrandingController {
@Public()
@Get('favicon')
async favicon(
@Query('size') size: string | undefined,
@Query('pond') pondId: string | undefined,
@Res() res: Response,
): Promise<void> {
async favicon(@Query('size') size: string | undefined, @Res() res: Response): Promise<void> {
const wanted = Number(size ?? 32);
if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) {
throw new BadRequestException({ code: 'bad_request' });
}
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);
const { bytes, uploaded } = await this.branding.faviconBytes(wanted as FaviconSize);
res.setHeader('Content-Type', 'image/png');
// 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
@ -158,96 +133,3 @@ export class BrandingAdminController {
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));
}
}

View File

@ -1,13 +1,6 @@
import { Module } from '@nestjs/common';
import { PermissionsModule } from '../permissions/permissions.module';
import { QuotasModule } from '../quotas/quotas.module';
import {
BrandingAdminController,
BrandingController,
PondBrandingController,
} from './branding.controller';
import { BrandingAdminController, BrandingController } from './branding.controller';
import { BrandingStorageService } from './branding-storage.service';
import { BrandingService } from './branding.service';
@ -15,8 +8,7 @@ import { BrandingService } from './branding.service';
* the pond-level override (#307) can build on the same storage and the same
* resolution path instead of a parallel one. */
@Module({
imports: [PermissionsModule, QuotasModule],
controllers: [BrandingController, BrandingAdminController, PondBrandingController],
controllers: [BrandingController, BrandingAdminController],
providers: [BrandingService, BrandingStorageService],
exports: [BrandingService, BrandingStorageService],
})

View File

@ -2,16 +2,12 @@ import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import {
BrandingAsset,
BrandingView,
FAVICON_SIZES,
FaviconSize,
LOGO_VARIANTS,
LogoVariant,
PondBranding,
pondSettingsSchema,
MAX_BRANDING_BYTES,
MAX_LOGO_EDGE,
hasPngMagic,
@ -21,8 +17,6 @@ import {
import { User } from '@prisma/client';
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 { BrandingStorageService } from './branding-storage.service';
@ -47,8 +41,6 @@ export class BrandingService {
private readonly settings: InstanceSettingsService,
private readonly storage: BrandingStorageService,
private readonly audit: AuditService,
private readonly prisma: PrismaService,
private readonly quotas: QuotaService,
) {}
static logoKey(variant: LogoVariant): string {
@ -59,26 +51,6 @@ export class BrandingService {
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
* written. SVG gets its own message: an operator who tried one deserves to
@ -99,37 +71,11 @@ export class BrandingService {
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 {
return {
// Short digest: it only has to change when the bytes change, and it
// travels in every logo URL.
hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
byteSize: bytes.length,
...size,
};
}
@ -218,150 +164,6 @@ export class BrandingService {
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(
admin: User,
asset: 'logo' | 'logoDark' | 'favicon',

View File

@ -1,256 +0,0 @@
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();
});
});

View File

@ -1,50 +0,0 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common';
import {
CreateInvitationInput,
InvitationListView,
InvitationPreview,
InvitationView,
createInvitationSchema,
invitationPreviewSchema,
} from '@dorfteich/shared';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { InvitationsService } from './invitations.service';
/** Peer invitations (issue #332). */
@AuthenticatedOnly()
@Controller('invitations')
export class InvitationsController {
constructor(private readonly invitations: InvitationsService) {}
@Post()
async create(
@Body(new ZodValidationPipe(createInvitationSchema)) input: CreateInvitationInput,
@Req() request: AuthedRequest,
): Promise<InvitationView> {
return this.invitations.create(request.user!, input.email);
}
@Get()
async list(@Req() request: AuthedRequest): Promise<InvitationListView> {
return this.invitations.list(request.user!);
}
@Delete(':id')
@HttpCode(204)
async revoke(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.invitations.revoke(request.user!, id);
}
/** The signup screen's link check — POST keeps the token out of logs. */
@Public()
@Post('preview')
@HttpCode(200)
async preview(
@Body(new ZodValidationPipe(invitationPreviewSchema)) input: { token: string },
): Promise<InvitationPreview> {
return this.invitations.preview(input.token);
}
}

View File

@ -1,246 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { InvitationListView, InvitationPreview, InvitationView } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* Peer invitations end to end (issue #332): inviting mails a single-use
* link, open invitations are quota-bound per user, and a valid token lets
* exactly one signup through a closed registration. Settings written here
* are restored inside each test and the keys are deleted in afterAll
* (shared-DB rule).
*/
describe.skipIf(!hasTestDb)('invitations (e2e, issue #332)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'einladungen sind praktisch 1';
const ids: Record<string, string> = {};
const cookies: Record<string, string> = {};
const api = () => request(app.getHttpServer());
const settings = () => app.get(InstanceSettingsService);
async function makeUser(handle: string): Promise<void> {
const users = app.get(UsersService);
const username = `inv-${handle}-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `Inv ${handle}`,
password,
locale: 'en',
});
await users.markEmailVerified(user.id);
ids[handle] = user.id;
cookies[handle] = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
}
/** The raw token only travels in the mail — fish it out of the outbox. */
async function mailedTokenFor(email: string): Promise<string> {
const mail = await prisma.mailOutbox.findFirstOrThrow({
where: { toAddress: email },
orderBy: { createdAt: 'desc' },
});
const match = /invitation=([A-Za-z0-9_-]+)/.exec(mail.textBody);
expect(match).not.toBeNull();
return match![1]!;
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
await makeUser('alice');
await makeUser('quota');
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({
where: { key: { in: ['auth.registrationMode', 'invitations.maxOpenPerUser'] } },
});
const all = Object.values(ids);
await prisma.invitation.deleteMany({ where: { inviterId: { in: all } } });
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
await prisma.session.deleteMany({ where: { userId: { in: all } } });
await deletePondsWhere(prisma, { ownerId: { in: all } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
await prisma.user.deleteMany({ where: { id: { in: all } } });
await prisma.$disconnect();
await app.close();
});
it('invites, lists, and mails a single-use signup link', async () => {
const invitee = `guest-${suffix}@example.org`;
const res = await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.alice!)
.send({ email: invitee })
.expect(201);
const view = res.body as InvitationView;
expect(view.status).toBe('pending');
const list = (await api().get('/api/v1/invitations').set('Cookie', cookies.alice!).expect(200))
.body as InvitationListView;
expect(list.open).toBe(1);
expect(list.maxOpen).toBe(5);
expect(list.invitations.map((i) => i.id)).toContain(view.id);
// The mail exists and the public preview identifies the inviter.
const token = await mailedTokenFor(invitee);
const preview = (await api().post('/api/v1/invitations/preview').send({ token }).expect(200))
.body as InvitationPreview;
expect(preview.email).toBe(invitee);
expect(preview.inviterName).toBe('Inv alice');
});
it('a valid token passes a closed registration exactly once; a burned signup attempt does not consume it', async () => {
const invitee = `joiner-${suffix}@example.org`;
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.alice!)
.send({ email: invitee })
.expect(201);
const token = await mailedTokenFor(invitee);
await settings().set('auth.registrationMode', 'closed', ids.alice!);
try {
// Closed without a token: refused.
await api()
.post('/api/v1/auth/signup')
.send({
username: `inv-blocked-${suffix}`,
email: `inv-blocked-${suffix}@example.org`,
displayName: 'Blocked',
password,
locale: 'en',
})
.expect(403);
// A failing signup (taken username) must NOT burn the token.
await api()
.post('/api/v1/auth/signup')
.send({
username: `inv-alice-${suffix}`, // taken
email: invitee,
displayName: 'Joiner',
password,
locale: 'en',
invitationToken: token,
})
.expect(409);
// Same link, fresh username: through, despite closed mode.
const username = `inv-joiner-${suffix}`;
await api()
.post('/api/v1/auth/signup')
.send({
username,
email: invitee,
displayName: 'Joiner',
password,
locale: 'en',
invitationToken: token,
})
.expect(201);
const joiner = await prisma.user.findUniqueOrThrow({ where: { username } });
ids.joiner = joiner.id;
// The invitation is tied to the new account…
const accepted = await prisma.invitation.findFirstOrThrow({
where: { acceptedUserId: joiner.id },
});
expect(accepted.acceptedAt).not.toBeNull();
// …and the token is single-use.
await api()
.post('/api/v1/auth/signup')
.send({
username: `inv-replay-${suffix}`,
email: `inv-replay-${suffix}@example.org`,
displayName: 'Replay',
password,
locale: 'en',
invitationToken: token,
})
.expect(400);
} finally {
await settings().set('auth.registrationMode', 'open', ids.alice!);
}
});
it('enforces the open-invitations quota and frees it on revoke', async () => {
await settings().set('invitations.maxOpenPerUser', 2, ids.alice!);
try {
const first = (
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.quota!)
.send({ email: `q1-${suffix}@example.org` })
.expect(201)
).body as InvitationView;
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.quota!)
.send({ email: `q2-${suffix}@example.org` })
.expect(201);
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.quota!)
.send({ email: `q3-${suffix}@example.org` })
.expect(400)
.expect((r) => expect((r.body as { code: string }).code).toBe('invitation_quota_reached'));
// Revoking an open invitation frees the slot…
await api()
.delete(`/api/v1/invitations/${first.id}`)
.set('Cookie', cookies.quota!)
.expect(204);
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.quota!)
.send({ email: `q3-${suffix}@example.org` })
.expect(201);
// …and the revoked token is dead.
const revokedToken = await mailedTokenFor(`q1-${suffix}@example.org`);
await api().post('/api/v1/invitations/preview').send({ token: revokedToken }).expect(400);
} finally {
await settings().set('invitations.maxOpenPerUser', 5, ids.alice!);
}
});
it('quota 0 disables inviting entirely', async () => {
await settings().set('invitations.maxOpenPerUser', 0, ids.alice!);
try {
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.alice!)
.send({ email: `off-${suffix}@example.org` })
.expect(403)
.expect((r) => expect((r.body as { code: string }).code).toBe('invitations_disabled'));
} finally {
await settings().set('invitations.maxOpenPerUser', 5, ids.alice!);
}
});
it('requires a session for create/list/revoke but not for preview', async () => {
await api().post('/api/v1/invitations').send({ email: 'nope@example.org' }).expect(401);
await api().get('/api/v1/invitations').expect(401);
await api()
.post('/api/v1/invitations/preview')
.send({ token: 'x'.repeat(32) })
.expect(400);
});
});

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { MailModule } from '../mail/mail.module';
import { InvitationsController } from './invitations.controller';
import { InvitationsService } from './invitations.service';
@Module({
imports: [MailModule],
controllers: [InvitationsController],
providers: [InvitationsService],
exports: [InvitationsService],
})
export class InvitationsModule {}

View File

@ -1,199 +0,0 @@
import { createHash, randomBytes } from 'node:crypto';
import {
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
InvitationListView,
InvitationPreview,
InvitationStatus,
InvitationView,
} from '@dorfteich/shared';
import { Invitation, User } from '@prisma/client';
import { AuditService } from '../audit/audit.service';
import { AppConfig } from '../config/app-config.service';
import { MailService } from '../mail/mail.service';
import { PrismaService } from '../prisma/prisma.service';
import { RateLimitService } from '../rate-limit/rate-limit.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
export const INVITATION_TTL_SECONDS = 14 * 24 * 60 * 60;
// Anti-spam backstop besides the open-invitations quota: without it a
// revoke-and-recreate loop would allow unlimited mail volume while never
// exceeding the quota.
const CREATE_LIMIT = { limit: 20, windowSeconds: 24 * 60 * 60 };
/**
* Peer invitations (issue #332). A user invites an e-mail address; the
* mailed single-use token lets exactly one signup through even while
* registration is closed (auth.service). Open (pending, unexpired)
* invitations count against the per-user quota
* `invitations.maxOpenPerUser` 0 turns the feature off. Only the
* SHA-256 hash of the token is stored (auth-tokens pattern); revoked and
* accepted rows are kept so the settings UI can show history.
*/
@Injectable()
export class InvitationsService {
constructor(
private readonly prisma: PrismaService,
private readonly mail: MailService,
private readonly rateLimits: RateLimitService,
private readonly settings: InstanceSettingsService,
private readonly audit: AuditService,
private readonly config: AppConfig,
) {}
async create(user: User, email: string): Promise<InvitationView> {
const maxOpen = (await this.settings.get('invitations.maxOpenPerUser')) as number;
if (maxOpen === 0) throw new ForbiddenException({ code: 'invitations_disabled' });
if ((await this.openCount(user.id)) >= maxOpen) {
throw new BadRequestException({ code: 'invitation_quota_reached' });
}
const limit = await this.rateLimits.hit(
'invitation-create',
user.id,
CREATE_LIMIT.limit,
CREATE_LIMIT.windowSeconds,
);
if (!limit.allowed) {
throw new HttpException({ code: 'rate_limited' }, HttpStatus.TOO_MANY_REQUESTS);
}
const raw = randomBytes(32).toString('base64url');
const row = await this.prisma.invitation.create({
data: {
inviterId: user.id,
email: email.toLowerCase(),
tokenHash: hashToken(raw),
expiresAt: new Date(Date.now() + INVITATION_TTL_SECONDS * 1000),
},
});
// The invitee has no account and no locale yet — the instance default
// decides the mail language. The greeting falls back to the address.
await this.mail.enqueue(
row.email,
'invitation',
{
displayName: row.email,
inviterName: user.displayName,
link: `${this.config.env.APP_BASE_URL}/signup?invitation=${raw}`,
},
(await this.settings.get('instance.defaultLocale')) as 'de' | 'en',
);
await this.audit.record({
action: 'invitation.created',
actorId: user.id,
targetType: 'invitation',
targetId: row.id,
});
return this.viewOf(row);
}
async list(user: User): Promise<InvitationListView> {
const rows = await this.prisma.invitation.findMany({
where: { inviterId: user.id },
orderBy: { createdAt: 'desc' },
take: 100,
});
return {
invitations: rows.map((row) => this.viewOf(row)),
open: await this.openCount(user.id),
maxOpen: (await this.settings.get('invitations.maxOpenPerUser')) as number,
};
}
async revoke(user: User, id: string): Promise<void> {
const row = await this.prisma.invitation.findFirst({
where: { id, inviterId: user.id },
});
if (!row) throw new NotFoundException();
if (row.acceptedAt) throw new BadRequestException({ code: 'invitation_already_accepted' });
if (row.revokedAt) return; // idempotent
await this.prisma.invitation.update({ where: { id }, data: { revokedAt: new Date() } });
await this.audit.record({
action: 'invitation.revoked',
actorId: user.id,
targetType: 'invitation',
targetId: id,
});
}
/** What the signup screen may show for a link before it is used. */
async preview(raw: string): Promise<InvitationPreview> {
const row = await this.prisma.invitation.findUnique({
where: { tokenHash: hashToken(raw) },
include: { inviter: true },
});
if (!row || row.revokedAt || row.acceptedAt || row.expiresAt <= new Date()) {
throw new BadRequestException({ code: 'token_invalid' });
}
return { email: row.email, inviterName: row.inviter.displayName };
}
/**
* Atomically claims the token (only one signup can flip acceptedAt from
* null). Returns the row, or null for unknown/revoked/expired/used
* tokens. The caller un-redeems if the signup fails afterwards.
*/
async redeem(raw: string): Promise<Invitation | null> {
const result = await this.prisma.invitation.updateMany({
where: {
tokenHash: hashToken(raw),
revokedAt: null,
acceptedAt: null,
expiresAt: { gt: new Date() },
},
data: { acceptedAt: new Date() },
});
if (result.count === 0) return null;
return this.prisma.invitation.findUnique({ where: { tokenHash: hashToken(raw) } });
}
/** Ties the redeemed invitation to the account it created. */
async markAccepted(id: string, userId: string): Promise<void> {
await this.prisma.invitation.update({ where: { id }, data: { acceptedUserId: userId } });
}
/** Rolls a redeem back when the signup it gated failed (e.g. duplicate
* username) the invitee must be able to try again with the same link. */
async unredeem(id: string): Promise<void> {
await this.prisma.invitation.updateMany({
where: { id, acceptedUserId: null },
data: { acceptedAt: null },
});
}
private openCount(inviterId: string): Promise<number> {
return this.prisma.invitation.count({
where: { inviterId, revokedAt: null, acceptedAt: null, expiresAt: { gt: new Date() } },
});
}
private viewOf(row: Invitation): InvitationView {
return {
id: row.id,
email: row.email,
status: statusOf(row),
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt.toISOString(),
};
}
}
function statusOf(row: Invitation): InvitationStatus {
if (row.revokedAt) return 'revoked';
if (row.acceptedAt) return 'accepted';
if (row.expiresAt <= new Date()) return 'expired';
return 'pending';
}
function hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}

View File

@ -1,6 +1,6 @@
import { apiI18n } from '../i18n/api-i18n';
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest' | 'invitation';
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest';
export interface RenderedMail {
subject: string;
@ -15,15 +15,14 @@ export interface RenderedMail {
*/
export function renderMail(
template: MailTemplate,
// Extra keys (e.g. inviterName, #332) interpolate into the body text.
params: { displayName: string; link: string } & Record<string, string>,
params: { displayName: string; link: string },
locale: 'de' | 'en',
): RenderedMail {
const t = (key: string, options: Record<string, string> = {}): string =>
apiI18n.t(`mails:${key}`, { lng: locale, ...options });
const greeting = t('common.greeting', { displayName: params.displayName });
const body = t(`${template}.body`, params);
const body = t(`${template}.body`);
const action = t(`${template}.action`);
const expiry = t(`${template}.expiry`);
const ignore = t('common.ignoreHint');

View File

@ -14,7 +14,7 @@ export class MailService {
async enqueue(
to: string,
template: MailTemplate,
params: { displayName: string; link: string } & Record<string, string>,
params: { displayName: string; link: string },
locale: 'de' | 'en',
): Promise<void> {
const rendered = renderMail(template, params, locale);

View File

@ -26,56 +26,48 @@ describe('sort-key helpers (issue #45)', () => {
* pattern repeatedly drop the last page between the first two must never
* collide and never overflow the key length, because the caller rebalances
* when {@link nextKeyOrRebalance} returns null.
*
* Under parallel CI load the 10.000 iterations have repeatedly exceeded the
* default 5 s per-test timeout (runs 685, 699 same code passed on rerun),
* so this test carries its own budget.
*/
it(
'10.000 adversarial reorders never collide or overflow (rebalance verified)',
{ timeout: 30_000 },
() => {
// Start with five pages in a fixed order.
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
let rebalances = 0;
it('10.000 adversarial reorders never collide or overflow (rebalance verified)', () => {
// Start with five pages in a fixed order.
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
let rebalances = 0;
const rebalance = (): void => {
const keys = evenlySpacedKeys(order.length);
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
rebalances += 1;
};
const rebalance = (): void => {
const keys = evenlySpacedKeys(order.length);
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
rebalances += 1;
};
for (let i = 0; i < 10_000; i += 1) {
// Move the last page to sit between the first and second — the tightest
// possible gap, which is what grows key length fastest.
const moved = order[order.length - 1]!;
const rest = order.slice(0, -1);
const afterKey = rest[0]!.key;
const beforeKey = rest[1]!.key;
for (let i = 0; i < 10_000; i += 1) {
// Move the last page to sit between the first and second — the tightest
// possible gap, which is what grows key length fastest.
const moved = order[order.length - 1]!;
const rest = order.slice(0, -1);
const afterKey = rest[0]!.key;
const beforeKey = rest[1]!.key;
const key = nextKeyOrRebalance(afterKey, beforeKey);
if (key === null) {
// Rebalance keeps the CURRENT order, then retry the move once.
rebalance();
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
expect(k2).not.toBeNull();
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
} else {
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
}
// Invariants after every move: keys unique, bounded, and consistent with
// the intended array order.
const keys = order.map((p) => p.key);
expect(new Set(keys).size).toBe(keys.length);
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
for (let j = 1; j < keys.length; j += 1) {
expect(keys[j - 1]! < keys[j]!).toBe(true);
}
const key = nextKeyOrRebalance(afterKey, beforeKey);
if (key === null) {
// Rebalance keeps the CURRENT order, then retry the move once.
rebalance();
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
expect(k2).not.toBeNull();
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
} else {
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
}
// The adversarial pattern must have forced at least one rebalance.
expect(rebalances).toBeGreaterThan(0);
},
);
// Invariants after every move: keys unique, bounded, and consistent with
// the intended array order.
const keys = order.map((p) => p.key);
expect(new Set(keys).size).toBe(keys.length);
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
for (let j = 1; j < keys.length; j += 1) {
expect(keys[j - 1]! < keys[j]!).toBe(true);
}
}
// The adversarial pattern must have forced at least one rebalance.
expect(rebalances).toBeGreaterThan(0);
});
});

View File

@ -21,9 +21,6 @@ import { PrismaService } from '../prisma/prisma.service';
*/
export const INSTANCE_SETTINGS = {
'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
// Peer invitations (issue #332): max OPEN (pending, unexpired)
// invitations per user; 0 turns inviting off entirely.
'invitations.maxOpenPerUser': z.number().int().min(0).default(5),
'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'),
'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
// Branding assets (issue #306). Metadata only — the PNG bytes live under

View File

@ -317,42 +317,6 @@ describe.skipIf(!hasTestDb)('first-run setup wizard (fresh database, issue #80)'
expect(locked.body.code).toBe('setup_locked');
});
});
describe('env pre-seeding with invalid values (issue #325)', () => {
const dbName = `dorfteich_preseed_bad_${suffix}`;
let app: INestApplication;
const badEnv = {
SETUP_ADMIN_USERNAME: `preseed-bad-${suffix}`,
SETUP_ADMIN_EMAIL: `preseed-bad-${suffix}@example.org`,
SETUP_ADMIN_PASSWORD: 'short',
} as const;
beforeAll(async () => {
const url = await createFreshDatabase(dbName);
process.env.TEST_DATABASE_URL = url;
process.env.SECRETS_FILE = join(
mkdtempSync(join(tmpdir(), 'dorfteich-preseed-bad-')),
'secrets.env',
);
Object.assign(process.env, badEnv);
app = await createTestApp();
}, 60_000);
afterAll(async () => {
for (const key of Object.keys(badEnv)) delete process.env[key];
await app.close();
await dropDatabase(dbName);
});
it('fails the boot naming the SETUP_* variable, not a raw ZodError', async () => {
await expect(app.get(SetupService).preseedFromEnv()).rejects.toThrow(
/SETUP_ADMIN_PASSWORD must be at least 10 characters/,
);
// Fail-fast left nothing half-seeded: the wizard is still pending.
const status = await request(app.getHttpServer()).get('/api/v1/setup').expect(200);
expect(status.body.status).toBe('required');
});
});
});
interface FakeSmtpServer {

View File

@ -15,7 +15,6 @@ import {
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { ZodError } from 'zod';
import { SessionsService } from '../auth/sessions.service';
import { AppConfig } from '../config/app-config.service';
@ -71,23 +70,14 @@ export class SetupService implements OnModuleInit {
if (!(await this.state.isPending())) return;
// Fails the boot loudly on invalid values — a half-seeded instance
// would be much harder to diagnose than a startup error. Translated
// into operator terms first: the raw ZodError names schema fields and
// i18n keys, not the SETUP_* variable to fix (issue #325).
const parsed = setupAdminInputSchema.safeParse({
// would be much harder to diagnose than a startup error.
const input = setupAdminInputSchema.parse({
username: env.SETUP_ADMIN_USERNAME,
email: env.SETUP_ADMIN_EMAIL,
password: env.SETUP_ADMIN_PASSWORD,
displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME,
locale: env.SETUP_DEFAULT_LOCALE,
});
if (!parsed.success) {
throw new Error(
`Pre-seeding failed: ${describePreseedIssues(parsed.error)}. ` +
'Fix .env and recreate the api container.',
);
}
const input = parsed.data;
const admin = await this.createAdmin(input);
if (env.SETUP_INSTANCE_NAME) {
await this.settings.set('instance.name', env.SETUP_INSTANCE_NAME, admin.id);
@ -233,27 +223,3 @@ export class SetupService implements OnModuleInit {
return (await this.prisma.user.count({ where: { isSiteAdmin: true } })) > 0;
}
}
/** The env variable behind each schema field of the pre-seeded admin. */
const PRESEED_FIELD_TO_ENV: Record<string, string> = {
username: 'SETUP_ADMIN_USERNAME',
email: 'SETUP_ADMIN_EMAIL',
password: 'SETUP_ADMIN_PASSWORD',
displayName: 'SETUP_ADMIN_DISPLAY_NAME',
locale: 'SETUP_DEFAULT_LOCALE',
};
function describePreseedIssues(error: ZodError): string {
return error.issues
.map((issue) => {
const variable = PRESEED_FIELD_TO_ENV[String(issue.path[0])] ?? String(issue.path[0]);
if (issue.code === 'too_small' && issue.type === 'string') {
return `${variable} must be at least ${issue.minimum} characters`;
}
if (issue.code === 'invalid_string' && issue.validation === 'email') {
return `${variable} is not a valid e-mail address`;
}
return `${variable} is invalid (${issue.message})`;
})
.join('; ');
}

View File

@ -1,6 +1,5 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { BrandingModule } from '../branding/branding.module';
import { CommonModule } from '../common/common.module';
import { FilesModule } from '../files/files.module';
import { PagesModule } from '../pages/pages.module';
@ -19,7 +18,6 @@ const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60;
@Module({
imports: [
BrandingModule,
CommonModule,
PondsModule,
QuotasModule,

View File

@ -4,7 +4,6 @@ import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { BrandingService } from '../branding/branding.service';
import { ClockService } from '../common/clock.service';
import { SearchProvider } from '../search/search.provider';
import { PagesService } from '../pages/pages.service';
@ -33,7 +32,6 @@ export class TrashService {
private readonly settings: InstanceSettingsService,
private readonly quotas: QuotaService,
private readonly storage: FileStorageService,
private readonly branding: BrandingService,
private readonly clock: ClockService,
private readonly watches: WatchesService,
private readonly audit: AuditService,
@ -185,9 +183,6 @@ export class TrashService {
for (const attachment of attachments) {
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 = (
await this.prisma.page.findMany({ where: { pondId }, select: { id: true } })
).map((page) => page.id);

View File

@ -87,9 +87,6 @@ for (const scheme of SCHEMES) {
await page.emulateMedia({ colorScheme: scheme });
await page.goto('/settings');
await page.waitForLoadState('networkidle');
// Einladungs-Abschnitt (issue #332) gerendert — sonst liefe der Scan
// auch grün, wenn die Sektion gar nicht erscheint.
await page.locator('.invitations').waitFor();
await expectClean(page, `/settings (${scheme})`);
// Lizenzseite im selben Kontext (issue #304: sie trägt seit den
@ -126,11 +123,6 @@ for (const scheme of SCHEMES) {
// erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl.
await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
await expectClean(page, `/admin (${scheme})`);
// Anlage-Dialog (issue #331) im selben Kontext öffnen und mitscannen —
// wieder KEIN eigener Test (Rate-Limit-Lehre aus #301).
await page.locator('.user-manager__create').click();
await page.locator('.create-user-dialog').waitFor();
await expectClean(page, `/admin Anlage-Dialog (${scheme})`);
await context.close();
});
});

View File

@ -1,55 +0,0 @@
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/**
* The general admin settings card saves THROUGH THE FORM (issue #322).
*
* This must drive the UI, not the api: the bug it fences was invisible to
* every api-level test react-hook-form nested the dotted field names on
* input, the strict PATCH schema rejected the body, and the form looked
* fine while never saving. Verified end to end: success message, the value
* survives a full reload, the api returns it, and the TopBar picks it up
* without a reload (branding query invalidation).
*/
test('instance name changed in the general settings form persists', async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const before = (
(await (await admin.request.get('/api/v1/admin/settings')).json()) as Record<string, unknown>
)['instance.name'] as string;
const newName = `Renamed ${Date.now()}`;
const nameLabel = /^(Instance name|Name der Instanz)$/;
const page = await admin.newPage();
try {
await page.goto('/admin');
const generalCard = page
.locator('section.settings-section')
.filter({ has: page.getByLabel(nameLabel) });
await page.getByLabel(nameLabel).fill(newName);
await generalCard.getByRole('button', { name: /^(Save|Speichern)$/ }).click();
// Scoped to the card: the page has several forms with status regions.
await expect(generalCard.getByRole('status')).toHaveText(/^(Saved\.|Gespeichert\.)$/);
// The TopBar and the document title show the new name without a reload —
// the save invalidates the branding query both read from (issue #323).
await expect(page.locator('.topbar__brand')).toHaveText(newName);
await expect(page).toHaveTitle(new RegExp(`${newName}$`));
// The proof the form really persisted: the value survives a reload and
// the api returns it.
await page.reload();
await expect(page.getByLabel(nameLabel)).toHaveValue(newName);
const stored = (
(await (await admin.request.get('/api/v1/admin/settings')).json()) as Record<string, unknown>
)['instance.name'];
expect(stored).toBe(newName);
} finally {
await admin.request.patch('/api/v1/admin/settings', {
data: { 'instance.name': before },
});
await admin.close();
}
});

View File

@ -44,43 +44,3 @@ test('disabling a user in the admin UI blocks their login, enabling restores it'
await admin.close();
}
});
/**
* Direct account creation (issue #331): the dialog creates an active account
* the new user logs in immediately, no verification hop. The account stays
* in the e2e database; the unique name keeps reruns independent.
*/
test('creating a user in the admin UI yields an account that can log in at once', async ({
browser,
}) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const username = `created-${Date.now()}`;
const password = 'ein sicheres anfangspasswort';
const page = await admin.newPage();
await page.goto('/admin');
await page.locator('.user-manager__create').click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel(/username|benutzername/i).fill(username);
await dialog.getByLabel(/e-mail/i).fill(`${username}@example.org`);
await dialog.getByLabel(/display name|anzeigename/i).fill('Created via UI');
await dialog.getByLabel(/initial password|anfangspasswort/i).fill(password);
await dialog.getByRole('button', { name: /^create$|^anlegen$/i }).click();
await expect(dialog).toBeHidden();
// The list refetches; the fresh account is findable.
await page.locator('.user-manager__search').fill(username);
const row = page.locator(`.user-row[data-username="${username}"]`);
await expect(row).toBeVisible();
await expect(row.locator('.user-row__status')).toHaveText(/active|aktiv/i);
await admin.close();
// No verification mail hop: login works right away.
const ctx = await request.newContext({ baseURL: BASE_URL });
const res = await ctx.post('/api/v1/auth/login', {
data: { usernameOrEmail: username, password },
});
expect(res.status()).toBe(200);
await ctx.dispose();
});

View File

@ -60,155 +60,6 @@ test('typing persists across reload and undo/redo work', async ({ browser }) =>
await context.close();
});
test('gap cursor reaches positions before and after a lone table (issue #335)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Gapcursor ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
const status = page.locator('.editor-connection');
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
const content = page.locator('.ProseMirror');
await content.click();
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
await expect(content.locator('table')).toBeVisible();
// Inserting into the empty page replaces the placeholder paragraph — the
// table really is the only block, which is the situation of issue #335.
await expect(content.locator(':scope > p')).toHaveCount(0);
// Right after the insert the collab sync can still swallow a click's
// selection update; interact only against a settled editor (established
// pattern, see a11y.spec.ts). The typed markers below verify each click
// really placed the cursor where the locator points.
await page.waitForTimeout(500);
// Keyboard only: ArrowUp from the first cell lands on the gap cursor
// before the table; typing there materializes a paragraph.
await content.locator('th').first().click();
await page.keyboard.type('in');
await expect(content.locator('th').first()).toHaveText('in');
await page.keyboard.press('ArrowUp');
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
await page.keyboard.type('above');
await expect(content.locator(':scope > :first-child')).toHaveText('above');
// Same for the position after the table.
await content.locator('td').last().click();
await page.keyboard.type('z');
await expect(content.locator('td').last()).toHaveText('z');
await page.keyboard.press('ArrowDown');
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
await page.keyboard.type('below');
await expect(content.locator(':scope > :last-child')).toHaveText('below');
await context.close();
});
test('cells can be merged and split from the toolbar (issue #337)', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MergeSplit ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
const status = page.locator('.editor-connection');
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
const content = page.locator('.ProseMirror');
await content.click();
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
await expect(content.locator('table')).toBeVisible();
const mergeButton = page.getByRole('button', { name: /merge cells|zellen verbinden/i });
const splitButton = page.getByRole('button', { name: /split cell|zelle teilen/i });
await expect(mergeButton).toBeDisabled();
await expect(splitButton).toBeDisabled();
// Settle before clicking into cells — see the gap cursor test.
await page.waitForTimeout(500);
// Extending the selection across the cell border turns it into a cell
// selection (prosemirror-tables), which is what merge operates on.
// Shift+Click, not Shift+ArrowRight: a keypress fired in the same tick as
// the preceding click races the editor's post-click rendering and gets
// dropped — no human types that fast (works fine interactively).
await content.locator('td').first().click();
await content
.locator('td')
.nth(1)
.click({ modifiers: ['Shift'] });
await expect(content.locator('.selectedCell')).toHaveCount(2);
await expect(mergeButton).toBeEnabled();
await mergeButton.click();
await expect(content.locator('td[colspan="2"]')).toHaveCount(1);
// Splitting the merged cell restores the row's full cell count.
await content.locator('td[colspan="2"]').click();
await expect(splitButton).toBeEnabled();
await splitButton.click();
await expect(content.locator('td[colspan="2"]')).toHaveCount(0);
await expect(content.locator('tr').nth(1).locator('td')).toHaveCount(3);
await context.close();
});
test('Tab navigates table cells, extends the table, and never traps focus (issue #338)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E TableTab ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
const status = page.locator('.editor-connection');
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
const content = page.locator('.ProseMirror');
await content.click();
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
const rows = content.locator('tr');
await expect(rows).toHaveCount(3);
// Settle before clicking into cells — see the gap cursor test.
await page.waitForTimeout(500);
// Tab moves to the next cell, Shift+Tab back. Typed markers prove where
// the cursor really is (the typing assertions also settle the editor
// between keypresses — see the merge test on click/key races).
await content.locator('th').first().click();
await page.keyboard.type('one');
await expect(content.locator('th').first()).toHaveText('one');
await page.keyboard.press('Tab');
await page.keyboard.type('two');
await expect(content.locator('th').nth(1)).toHaveText('two');
await page.keyboard.press('Shift+Tab');
await page.keyboard.type('back');
await expect(content.locator('th').first()).toContainText('back');
// Tab in the last cell appends a row and moves into it (Word behavior).
const lastCell = rows.nth(2).locator('td').nth(2);
await lastCell.click();
await page.keyboard.type('z');
await expect(lastCell).toHaveText('z');
await page.keyboard.press('Tab');
await expect(rows).toHaveCount(4);
await page.keyboard.type('new');
await expect(rows.nth(3).locator('td').first()).toHaveText('new');
// No keyboard trap (WCAG 2.1.2): Escape works from EVERY cell (the gap
// cursor is only reachable per arrow key from edge cells) and places the
// cursor after the table; once outside, Tab leaves the editor entirely.
// The mechanism is announced via the editor's aria-describedby hint.
await page.keyboard.press('Escape');
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
await page.keyboard.press('Tab');
await expect(content).not.toBeFocused();
await context.close();
});
test('edit mode hides the sidebar; leaving edit mode restores it', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`);

View File

@ -1,93 +0,0 @@
import { expect, test } from '@playwright/test';
import { contextForUser, latestMailFor, tokenFromMail } from './helpers';
/**
* Peer invitations (issue #332), the full loop through the UI: a user
* invites an address, registration is closed, the invitee registers
* through the mailed link anyway, verifies, and the inviter sees the
* invitation accepted. Needs Mailpit like the auth pack.
*/
const MAILPIT_URL = process.env.E2E_MAILPIT_URL;
test.skip(!MAILPIT_URL, 'requires a Mailpit instance (E2E_MAILPIT_URL)');
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
test('invite -> closed registration -> signup through the link -> accepted', async ({
browser,
page,
}) => {
const stamp = Date.now().toString(36);
const invitee = `invited-${stamp}@dorfteich.test`;
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const inviter = await contextForUser(browser, BASE_URL, 'fixture-user');
await admin.request.patch('/api/v1/admin/settings', {
data: { 'auth.registrationMode': 'closed' },
});
try {
// Invite through the settings UI.
const settingsPage = await inviter.newPage();
await settingsPage.goto('/settings');
const section = settingsPage.locator('.invitations');
await section.getByLabel(/e-mail/i).fill(invitee);
await section.getByRole('button', { name: /^(invite|einladen)$/i }).click();
await expect(section.locator('.invitations__sent')).toHaveText(/sent|verschickt/i);
const row = section.locator(`.invitation-row[data-email="${invitee}"]`);
await expect(row.locator('.invitation-row__status')).toHaveText(/open|offen/i);
// Plain signup is closed…
await page.goto('/signup');
await expect(page.locator('.form-banner')).toHaveText(/closed|geschlossen/i);
// …but the mailed link opens the form, inviter banner and prefill included.
const mail = await latestMailFor(MAILPIT_URL!, invitee);
const invitationToken = /invitation=([A-Za-z0-9_-]+)/.exec(mail.text)?.[1];
expect(invitationToken).toBeTruthy();
await page.goto(`/signup?invitation=${invitationToken}`);
await expect(page.locator('.signup-invitation__banner')).toBeVisible();
await expect(page.getByLabel(/e-mail/i)).toHaveValue(invitee);
const username = `invited-${stamp}`;
await page.getByLabel(/username|benutzername/i).fill(username);
await page.getByLabel(/display name|anzeigename/i).fill('Invited Guest');
await page.getByLabel(/^password|^passwort/i).fill('ein einladungs passwort 1');
await page.getByRole('button', { name: /register|registrieren/i }).click();
await expect(page.getByRole('heading', { name: /inbox|postfach/i })).toBeVisible();
// The usual verification still applies (the link proves nothing about
// the mailbox). Two mails went to this address — poll for the second.
let verifyToken = '';
await expect(async () => {
const verifyMail = await latestMailFor(MAILPIT_URL!, invitee);
expect(verifyMail.text).toContain('/verify-email');
verifyToken = tokenFromMail(verifyMail.text);
}).toPass();
await page.goto(`/verify-email?token=${verifyToken}`);
await expect(page.getByRole('heading', { name: /confirmed|bestätigt/i })).toBeVisible();
// The inviter sees the acceptance; the used link is dead.
await settingsPage.reload();
await expect(
settingsPage
.locator(`.invitation-row[data-email="${invitee}"]`)
.locator('.invitation-row__status'),
).toHaveText(/accepted|angenommen/i);
await page.goto(`/signup?invitation=${invitationToken}`);
await expect(page.locator('.signup-invitation__invalid')).toBeVisible();
// Revoke flow through the UI: a second invitation dies by revoke.
const second = `revoked-${stamp}@dorfteich.test`;
await section.getByLabel(/e-mail/i).fill(second);
await section.getByRole('button', { name: /^(invite|einladen)$/i }).click();
const secondRow = section.locator(`.invitation-row[data-email="${second}"]`);
await expect(secondRow.locator('.invitation-row__status')).toHaveText(/open|offen/i);
await secondRow.getByRole('button', { name: /revoke|widerrufen/i }).click();
await expect(secondRow.locator('.invitation-row__status')).toHaveText(/revoked|widerrufen/i);
} finally {
await admin.request.patch('/api/v1/admin/settings', {
data: { 'auth.registrationMode': 'open' },
});
await admin.close();
await inviter.close();
}
});

View File

@ -108,102 +108,6 @@ test('a plain-text paste is not mangled into rich structure', async ({ browser }
await context.close();
});
test('a Markdown table pasted with code-editor styling HTML becomes a table (issue #339)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MD TablePaste ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditMode(page);
await page.locator('.ProseMirror').click();
// VS Code (copyWithSyntaxHighlighting) ships the plain text a second time
// as styled div/span HTML — exactly the flavor that used to shadow the
// Markdown conversion.
await page.evaluate(() => {
const el = document.querySelector('.ProseMirror');
const dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', '| A | B |\n| --- | --- |\n| 1 | 2 |');
dataTransfer.setData(
'text/html',
'<meta charset="utf-8"><div style="color:#d4d4d4;background-color:#1e1e1e;">' +
'<div><span style="color:#d4d4d4;">| A | B |</span></div>' +
'<div><span style="color:#d4d4d4;">| --- | --- |</span></div>' +
'<div><span style="color:#d4d4d4;">| 1 | 2 |</span></div></div>',
);
el!.dispatchEvent(
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
);
});
const content = page.locator('.ProseMirror');
await expect(content.locator('table')).toHaveCount(1);
await expect(content.locator('th').first()).toHaveText('A');
await expect(content.locator('td').first()).toHaveText('1');
await context.close();
});
test('a Markdown table pasted into a code block stays verbatim text (issue #339)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MD CodePaste ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditMode(page);
await page.locator('.ProseMirror').click();
await page.getByRole('button', { name: /code block|codeblock/i }).click();
await page.evaluate(() => {
const el = document.querySelector('.ProseMirror');
const dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', '| A | B |\n| --- | --- |\n| 1 | 2 |');
el!.dispatchEvent(
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
);
});
const content = page.locator('.ProseMirror');
await expect(content.locator('table')).toHaveCount(0);
await expect(content.locator('pre')).toContainText('| A | B |');
await context.close();
});
test('typing a Markdown table header plus separator creates a table (issue #339)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MD TableType ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditMode(page);
const content = page.locator('.ProseMirror');
await content.click();
await page.keyboard.type('| Name | Rolle |');
await page.keyboard.press('Enter');
await page.keyboard.type('| --- | --- |');
await expect(content).toContainText('| --- | --- |');
await page.keyboard.press('Enter');
await expect(content.locator('table')).toHaveCount(1);
await expect(content.locator('th').first()).toHaveText('Name');
await expect(content).not.toContainText('| --- | --- |');
// The cursor lands in the table; Tab from the last header cell appends the
// first body row (#338), so typing continues seamlessly.
await page.keyboard.type('x');
await expect(content.locator('th').first()).toContainText('x');
await context.close();
});
test('page menu downloads the page as Markdown matching its content', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${Date.now()}`);

View File

@ -28,9 +28,8 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
await expect(nav).toBeVisible();
const links = nav.locator('.settings-nav__link');
// Profile, password, sessions, watches, API tokens, feed tokens, data export.
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild),
// 10 seit #332 (Einladungen).
await expect(links).toHaveCount(10);
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild).
await expect(links).toHaveCount(9);
// Jump to the last section: it scrolls into view and becomes active.
const last = links.last();

View File

@ -34,7 +34,7 @@ test('mode marked: card, checkbox marking, and point-of-choice marking', async (
// select value — compliant choice clears it, violating choice brings it
// back, no save in between.
const regField = page.locator('label.field', {
has: page.locator('select[name="registrationMode"]'),
has: page.locator('select[name="auth.registrationMode"]'),
});
const regSelect = regField.locator('select');
await regSelect.selectOption('open');
@ -72,7 +72,7 @@ test('mode hidden: rows disappear, notes mark the hiding, a11y clean', async ({
// Value-listed control: the compliant registration mode keeps only its
// compliant choice (seed leaves it open = violating? then all options).
const regSelect = page.locator('select[name="registrationMode"]');
const regSelect = page.locator('select[name="auth.registrationMode"]');
const regField = page.locator('label.field', { has: regSelect });
const optionCount = await regSelect.locator('option').count();
const marked = await regField.locator('.vs-nfd-mark').count();

View File

@ -1,57 +1,50 @@
import { Link } from 'react-router-dom';
import { useCurrentPondRoute } from '../layout/use-pond-route';
import { logoUrl, usePondFavicon, useResolvedBranding } from './use-branding';
import { usePondId } from './use-pond-id';
import { logoUrl, useBranding } from './use-branding';
/**
* The identity at the top of the sidebar (issues #306/#307): the pond's own
* logo when it has one, else the instance's, else the instance name as text.
* The instance identity at the top of the sidebar (issue #306): the uploaded
* logo as a link home, or the instance name as text when nothing is uploaded.
*
* Its accessible name follows the LEVEL the logo came from a pond logo is
* named by the pond, an instance logo by the instance. For a screen reader
* 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.
* Its accessible name is the INSTANCE NAME, never "logo": for a screen reader
* this is the link home, and a link's name has to say where it goes. The
* images are therefore `alt=""` the link is already named.
*
* 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
* the correct logo is the one painted. A logo set belongs to ONE level and is
* never mixed across levels see `resolveBranding`.
* the correct logo is the one painted rather than the one that appears after a
* flash. Without a dark variant the light one carries both themes the
* 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 {
const { pondSlug } = useCurrentPondRoute();
const { pondId, pondName } = usePondId(pondSlug);
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;
const branding = useBranding();
if (!branding) return null;
const { logo, logoDark, instanceName } = branding;
return (
<Link to="/" className="brand-logo" aria-label={name}>
{resolved.logo || resolved.logoDark ? (
<Link to="/" className="brand-logo" aria-label={instanceName}>
{logo ? (
<>
{resolved.logo && (
<img
className={`brand-logo__img brand-logo__img--light${logoDark ? '' : ' brand-logo__img--both'}`}
src={logoUrl('light', logo.hash)}
width={logo.width}
height={logo.height}
alt=""
/>
{logoDark && (
<img
className={`brand-logo__img brand-logo__img--light${resolved.logoDark ? '' : ' brand-logo__img--both'}`}
src={logoUrl('light', resolved.logo.hash, logoPond)}
width={resolved.logo.width}
height={resolved.logo.height}
alt=""
/>
)}
{resolved.logoDark && (
<img
className={`brand-logo__img brand-logo__img--dark${resolved.logo ? '' : ' brand-logo__img--both'}`}
src={logoUrl('dark', resolved.logoDark.hash, logoPond)}
width={resolved.logoDark.width}
height={resolved.logoDark.height}
className="brand-logo__img brand-logo__img--dark"
src={logoUrl('dark', logoDark.hash)}
width={logoDark.width}
height={logoDark.height}
alt=""
/>
)}
</>
) : (
<span className="brand-logo__name">{name}</span>
<span className="brand-logo__name">{instanceName}</span>
)}
</Link>
);

View File

@ -1,6 +1,5 @@
import { BrandingView, PondBranding, ResolvedBranding, resolveBranding } from '@dorfteich/shared';
import { BrandingView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { apiGet } from '../lib/api';
@ -22,65 +21,9 @@ export function useBranding(): BrandingView | undefined {
}
/** URL of a logo variant, with the content hash so a replaced logo is never
* served from cache. `pondId` scopes it to a pond's own asset (issue #307);
* the route never falls back on its own the CALLER decided which level
* 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]);
* served from cache. */
export function logoUrl(variant: 'light' | 'dark', hash: string): string {
return `/api/v1/branding/logo?variant=${variant}&v=${hash}`;
}
export function useInvalidateBranding(): () => Promise<void> {

View File

@ -1,21 +0,0 @@
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 };
}

View File

@ -127,8 +127,6 @@ export function Toolbar({
canAddColumn: e.can().addColumnAfter(),
canDeleteColumn: e.can().deleteColumn(),
canDeleteTable: e.can().deleteTable(),
canMergeCells: e.can().mergeCells(),
canSplitCell: e.can().splitCell(),
canToggleHeaderRow: e.can().toggleHeaderRow(),
canUndo: e.can().undo(),
canRedo: e.can().redo(),
@ -272,10 +270,7 @@ export function Toolbar({
disabled={!state.canDeleteColumn}
onClick={() => editor.chain().focus().deleteColumn().run()}
>
{/* Axis stripes + the × delete marker (already established by
deleteTable's ): the earlier / double arrows read as
"resize/expand", not "delete" (issue #336). */}
×
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addRowBefore')}
@ -296,23 +291,7 @@ export function Toolbar({
disabled={!state.canDeleteRow}
onClick={() => editor.chain().focus().deleteRow().run()}
>
×
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.mergeCells')}
disabled={!state.canMergeCells}
onClick={() => editor.chain().focus().mergeCells().run()}
>
{/* Arrows collapsing onto / leaving a cell border: merge removes
the border between selected cells, split restores it. */}
|
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.splitCell')}
disabled={!state.canSplitCell}
onClick={() => editor.chain().focus().splitCell().run()}
>
|
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.toggleHeaderRow')}

View File

@ -1,8 +1,6 @@
import type { AnyExtension } from '@tiptap/core';
import { GapCursor } from './gap-cursor';
import { MarkdownClipboard } from './markdown-clipboard';
import { MarkdownTableInput } from './markdown-table-input';
import { Bold, CodeMark, Italic, LinkMark, Strikethrough } from './marks';
import { Image } from './nodes/image';
import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
@ -65,6 +63,4 @@ export const documentExtensions: AnyExtension[] = [
Strikethrough,
LinkMark,
MarkdownClipboard,
MarkdownTableInput,
GapCursor,
];

View File

@ -1,20 +0,0 @@
import { Extension } from '@tiptap/core';
import { gapCursor } from '@tiptap/pm/gapcursor';
/**
* Cursor position adjacent to block nodes that offer no text position of
* their own without it a table (or code block, image, ) as the page's
* first, last, or only block is unreachable from before/after, and no
* paragraph can be created there (issue #335). Wraps prosemirror-gapcursor,
* which also handles the arrow-key navigation into the gap positions; the
* bar itself is styled in `styles/base.css` (`.ProseMirror-gapcursor`)
* because the upstream package does not ship its stylesheet through this
* entry point.
*/
export const GapCursor = Extension.create({
name: 'gapCursor',
addProseMirrorPlugins() {
return [gapCursor()];
},
});

View File

@ -1,7 +1,6 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { htmlIsStyledPlainText, looksLikeMarkdown } from './markdown-clipboard';
import { looksLikeMarkdown } from './markdown-clipboard';
describe('looksLikeMarkdown (issue #30)', () => {
it('recognizes a heading + list document', () => {
@ -32,21 +31,3 @@ describe('looksLikeMarkdown (issue #30)', () => {
expect(looksLikeMarkdown(' \n ')).toBe(false);
});
});
describe('htmlIsStyledPlainText (issue #339)', () => {
it('recognizes VS-Code-style syntax-highlighting HTML as styled plain text', () => {
const vsCode =
'<meta charset="utf-8"><div style="color:#d4d4d4;background-color:#1e1e1e;">' +
'<div><span style="color:#d4d4d4;">| A | B |</span></div>' +
'<div><span>| --- | --- |</span></div></div>';
expect(htmlIsStyledPlainText(vsCode)).toBe(true);
});
it('keeps rich-text clipboard HTML on the HTML paste path', () => {
expect(htmlIsStyledPlainText('<table><tr><td>a</td></tr></table>')).toBe(false);
expect(htmlIsStyledPlainText('<p><strong>bold</strong> prose</p>')).toBe(false);
expect(htmlIsStyledPlainText('<ul><li>one</li></ul>')).toBe(false);
expect(htmlIsStyledPlainText('<p><a href="https://example.org">link</a></p>')).toBe(false);
expect(htmlIsStyledPlainText('<pre><code>x</code></pre>')).toBe(false);
});
});

View File

@ -26,25 +26,6 @@ export function looksLikeMarkdown(text: string): boolean {
return matches.length >= 2;
}
/** Elements whose presence means the clipboard HTML carries real structure
* or semantics that ProseMirror's HTML paste should interpret. */
const STRUCTURAL_HTML =
'table, ul, ol, li, h1, h2, h3, h4, h5, h6, blockquote, pre, code, a, img, b, strong, i, em, u, s';
/**
* Code editors (VS Code with copyWithSyntaxHighlighting, similar tools) put
* an HTML flavor on the clipboard that is nothing but the plain text wrapped
* in styled div/span containers. Treating that as "real HTML" made the paste
* ignore the Markdown heuristic below, so a Markdown table copied out of
* VS Code arrived as verbatim text while the same text from a plain editor
* converted fine (issue #339). Only HTML without any structural element is
* declared equivalent to the plain text anything from a rich-text source
* keeps going through ProseMirror's own HTML paste.
*/
export function htmlIsStyledPlainText(html: string): boolean {
return new DOMParser().parseFromString(html, 'text/html').querySelector(STRUCTURAL_HTML) === null;
}
/**
* Markdown on the clipboard, both ways (issue #30, ADR 0004/0009): copying
* puts Markdown on `text/plain` alongside the browser's own HTML (so
@ -84,11 +65,8 @@ export const MarkdownClipboard = Extension.create({
}
},
handlePaste(view, event) {
// Inside a code block pasted text is code, never a document —
// converting there would split the block around rich nodes.
if (view.state.selection.$from.parent.type.spec.code) return false;
const html = event.clipboardData?.getData('text/html');
if (html && html.trim() !== '' && !htmlIsStyledPlainText(html)) return false;
if (html && html.trim() !== '') return false;
const text = event.clipboardData?.getData('text/plain');
if (!text || !looksLikeMarkdown(text)) return false;

View File

@ -1,68 +0,0 @@
import { markdownToDoc } from '@dorfteich/shared';
import { Extension } from '@tiptap/core';
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
import { Plugin, Selection } from '@tiptap/pm/state';
/** A `| … |` pipe row — the same signal `looksLikeMarkdown` uses. */
const PIPE_ROW = /^\|.+\|\s*$/;
/** The GFM header separator (`| --- | :--- |`). Three dashes minimum keeps
* accidental short rows like `|-|` from ever triggering a conversion. */
const SEPARATOR_ROW = /^\|(?:\s*:?-{3,}:?\s*\|)+\s*$/;
/**
* Hand-typed Markdown tables (issue #339): pressing Enter at the end of a
* separator row whose previous sibling is a pipe row replaces the two
* paragraphs with a real table. TipTap input rules cannot express this
* they only see text inside a single textblock, and a table needs two.
* Conversion is refused inside existing tables (the schema would allow the
* nested table, the reader could not make sense of it). Body rows are then
* typed cell-wise Tab in the last cell appends a row (#338).
*/
export const MarkdownTableInput = Extension.create({
name: 'markdownTableInput',
addProseMirrorPlugins() {
return [
new Plugin({
props: {
handleKeyDown(view, event) {
if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey)
return false;
const { $from, empty } = view.state.selection;
if (!empty || $from.parent.type.name !== 'paragraph') return false;
if ($from.parentOffset !== $from.parent.content.size) return false;
if (!SEPARATOR_ROW.test($from.parent.textContent)) return false;
for (let depth = $from.depth - 1; depth > 0; depth -= 1) {
if ($from.node(depth).type.spec.tableRole) return false;
}
const container = $from.node($from.depth - 1);
const index = $from.index($from.depth - 1);
if (index === 0) return false;
const headerRow = container.child(index - 1);
if (headerRow.type.name !== 'paragraph' || !PIPE_ROW.test(headerRow.textContent))
return false;
let table: ProseMirrorNode;
try {
const parsed = markdownToDoc(`${headerRow.textContent}\n${$from.parent.textContent}`);
if (parsed.childCount !== 1 || parsed.firstChild?.type.name !== 'table') return false;
// Re-hydrated against the live schema — same identity dance as
// in markdown-clipboard.ts.
table = ProseMirrorNode.fromJSON(view.state.schema, parsed.firstChild.toJSON());
} catch {
return false;
}
const start = $from.before($from.depth) - headerRow.nodeSize;
const end = $from.after($from.depth);
const tr = view.state.tr.replaceWith(start, end, table);
tr.setSelection(Selection.near(tr.doc.resolve(start), 1));
view.dispatch(tr.scrollIntoView());
return true;
},
},
}),
];
},
});

View File

@ -1,6 +1,4 @@
import { Node } from '@tiptap/core';
import { GapCursor } from '@tiptap/pm/gapcursor';
import { Selection } from '@tiptap/pm/state';
import type { Node as PMNode, Schema } from 'prosemirror-model';
import {
addColumnAfter,
@ -10,9 +8,6 @@ import {
deleteColumn,
deleteRow,
deleteTable,
goToNextCell,
mergeCells,
splitCell,
tableEditing,
toggleHeaderRow,
} from 'prosemirror-tables';
@ -39,10 +34,6 @@ declare module '@tiptap/core' {
addRowAfter: () => ReturnType;
deleteRow: () => ReturnType;
deleteTable: () => ReturnType;
mergeCells: () => ReturnType;
splitCell: () => ReturnType;
goToNextCell: () => ReturnType;
goToPreviousCell: () => ReturnType;
toggleHeaderRow: () => ReturnType;
};
}
@ -74,37 +65,6 @@ export const Table = Node.create({
addProseMirrorPlugins() {
return [tableEditing()];
},
addKeyboardShortcuts() {
return {
// Word-style navigation (issue #338): Tab moves cell-wise and appends
// a new row from the last cell. Outside a table every branch returns
// false, so Tab keeps its browser default (focus moves on) and the
// editor is no keyboard trap — from inside a table the arrow keys
// lead out via the gap cursor (#335), then Tab leaves the editor.
Tab: () => {
if (this.editor.commands.goToNextCell()) return true;
if (!this.editor.can().addRowAfter()) return false;
return this.editor.chain().addRowAfter().goToNextCell().run();
},
'Shift-Tab': () => this.editor.commands.goToPreviousCell(),
// The documented exit (aria-describedby hint, #338): the gap cursor is
// only reachable per arrow key from the table's edge cells, so Escape
// is the exit that works from EVERY cell. Falls back to a gap cursor
// when no textblock follows the table (#335 guarantees the position).
Escape: () =>
this.editor.commands.command(({ state, dispatch }) => {
const { $head } = state.selection;
for (let depth = $head.depth; depth > 0; depth -= 1) {
if ($head.node(depth).type.spec.tableRole !== 'table') continue;
const $after = state.doc.resolve($head.after(depth));
const selection = Selection.findFrom($after, 1, true) ?? new GapCursor($after);
if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView());
return true;
}
return false;
}),
};
},
addCommands() {
return {
insertTable:
@ -141,22 +101,6 @@ export const Table = Node.create({
() =>
({ state, dispatch }) =>
deleteTable(state, dispatch),
mergeCells:
() =>
({ state, dispatch }) =>
mergeCells(state, dispatch),
splitCell:
() =>
({ state, dispatch }) =>
splitCell(state, dispatch),
goToNextCell:
() =>
({ state, dispatch }) =>
goToNextCell(1)(state, dispatch),
goToPreviousCell:
() =>
({ state, dispatch }) =>
goToNextCell(-1)(state, dispatch),
toggleHeaderRow:
() =>
({ state, dispatch }) =>

View File

@ -10,7 +10,6 @@ import deFiles from '@dorfteich/shared/i18n/de/files.json';
import deFont from '@dorfteich/shared/i18n/de/font.json';
import deGraph from '@dorfteich/shared/i18n/de/graph.json';
import deImport from '@dorfteich/shared/i18n/de/import.json';
import deInvitations from '@dorfteich/shared/i18n/de/invitations.json';
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
import deLinks from '@dorfteich/shared/i18n/de/links.json';
@ -40,7 +39,6 @@ import enFiles from '@dorfteich/shared/i18n/en/files.json';
import enFont from '@dorfteich/shared/i18n/en/font.json';
import enGraph from '@dorfteich/shared/i18n/en/graph.json';
import enImport from '@dorfteich/shared/i18n/en/import.json';
import enInvitations from '@dorfteich/shared/i18n/en/invitations.json';
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
import enLinks from '@dorfteich/shared/i18n/en/links.json';
@ -87,7 +85,6 @@ void i18n
font: enFont,
graph: enGraph,
import: enImport,
invitations: enInvitations,
labels: enLabels,
legal: enLegal,
links: enLinks,
@ -119,7 +116,6 @@ void i18n
font: deFont,
graph: deGraph,
import: deImport,
invitations: deInvitations,
labels: deLabels,
legal: deLegal,
links: deLinks,

View File

@ -1,33 +1,22 @@
import { useEffect } from 'react';
import { useBranding } from '../branding/use-branding';
const APP_NAME = 'Dorfteich';
/**
* Route-specific document title (issue #163, WCAG 2.4.2): joins the given
* parts with the instance name ("Page — Pond — My Wiki"). Empty/undefined
* parts with the app name ("Page — Pond — Dorfteich"). Empty/undefined
* parts are skipped, so callers can pass still-loading data directly.
* Falls back to the bare instance name on unmount.
*
* The trailing name is the OPERATOR'S instance name, not the product name
* (issue #323) same reasoning as the TopBar brand (issue #306). Until
* the branding query resolves (or when it cannot, e.g. maintenance mode)
* the shipped default keeps the title stable, so an untouched instance
* reads exactly as before.
* Falls back to the bare app name on unmount.
*/
export function useDocumentTitle(...parts: (string | null | undefined)[]): void {
const appName = useBranding()?.instanceName.trim() || APP_NAME;
const joined = [...parts.filter(Boolean), appName].join(' — ');
const joined = [...parts.filter(Boolean), APP_NAME].join(' — ');
useEffect(() => {
document.title = joined;
}, [joined]);
useEffect(
// On unmount only in effect: `joined` always changes with `appName`,
// so the title effect above re-runs right after this cleanup.
() => () => {
document.title = appName;
document.title = APP_NAME;
},
[appName],
[],
);
}

View File

@ -5,17 +5,10 @@ import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { BRANDING_KEY } from '../branding/use-branding';
import { Field, FormError, FormSuccess } from '../components/forms';
import { SettingsLayout } from '../components/SettingsLayout';
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
import { apiGet, apiPatch } from '../lib/api';
import {
GENERAL_FORM_FIELDS,
GeneralSettingsForm,
toFormValues,
toSettingsPatch,
} from './admin-settings-form';
import { BrandingManager } from './BrandingManager';
import { CustomFontManager } from './CustomFontManager';
import { PluginManager } from './PluginManager';
@ -25,7 +18,6 @@ import { UserManager } from './UserManager';
import { useDocumentTitle } from '../lib/use-document-title';
interface InstanceSettings {
'auth.registrationMode': 'open' | 'closed';
'invitations.maxOpenPerUser': number;
'instance.name': string;
'instance.defaultLocale': 'de' | 'en';
'quota.editorsPerPond': number;
@ -59,23 +51,15 @@ export function AdminSettingsPage(): React.JSX.Element {
queryFn: () => apiGet<InstanceSettings>('/admin/settings'),
});
// Dot-free field names with an explicit mapping to the dotted settings
// keys — see admin-settings-form.ts for why the names must not contain
// dots (issue #322).
const form = useForm<GeneralSettingsForm>({
values: settings.data ? toFormValues(settings.data) : undefined,
});
const form = useForm<InstanceSettings>({ values: settings.data });
const vsNfd = useVsNfdMarking();
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
setSaved(false);
try {
await apiPatch('/admin/settings', toSettingsPatch(input));
await apiPatch('/admin/settings', input);
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
// The TopBar takes the instance name from the public branding query;
// without this it keeps the old name until its staleTime runs out.
await queryClient.invalidateQueries({ queryKey: BRANDING_KEY });
setSaved(true);
} catch (err) {
setError(err);
@ -95,10 +79,6 @@ export function AdminSettingsPage(): React.JSX.Element {
<section className="settings-section">
<h2>{t('settings:admin.general')}</h2>
{(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) ||
vsNfd.hides(
'invitations.maxOpenPerUser',
settings.data['invitations.maxOpenPerUser'],
) ||
vsNfd.hides(
'classification.newPageDefault',
settings.data['classification.newPageDefault'],
@ -111,53 +91,37 @@ export function AdminSettingsPage(): React.JSX.Element {
<FormError error={error} />
<FormSuccess message={saved ? t('settings:admin.saved') : null} />
<Field label={t('settings:admin.instanceName')}>
<input type="text" {...form.register('instanceName')} />
<input type="text" {...form.register('instance.name')} />
</Field>
<Field label={t('settings:admin.defaultLocale')}>
<select {...form.register('defaultLocale')}>
<select {...form.register('instance.defaultLocale')}>
<option value="de">{t('settings:profile.locales.de')}</option>
<option value="en">{t('settings:profile.locales.en')}</option>
</select>
</Field>
<Field
label={t('settings:admin.registrationMode')}
marking={vsNfd.markingFor('auth.registrationMode', form.watch('registrationMode'))}
marking={vsNfd.markingFor(
'auth.registrationMode',
form.watch('auth.registrationMode'),
)}
>
<select {...form.register('registrationMode')}>
<select {...form.register('auth.registrationMode')}>
{!vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) && (
<option value="open">{t('settings:admin.registrationOpen')}</option>
)}
<option value="closed">{t('settings:admin.registrationClosed')}</option>
</select>
</Field>
{!vsNfd.hides(
'invitations.maxOpenPerUser',
settings.data['invitations.maxOpenPerUser'],
) && (
<Field
label={t('settings:admin.invitationsMaxOpen')}
hint={t('settings:admin.invitationsMaxOpenHelp')}
marking={vsNfd.markingFor(
'invitations.maxOpenPerUser',
form.watch('invitationsMaxOpenPerUser'),
)}
>
<input
type="number"
min={0}
{...form.register('invitationsMaxOpenPerUser', { valueAsNumber: true })}
/>
</Field>
)}
<Field
label={t('settings:admin.newPageClassification')}
hint={t('settings:admin.newPageClassificationHelp')}
marking={vsNfd.markingFor(
'classification.newPageDefault',
form.watch('newPageClassification'),
form.watch('classification.newPageDefault'),
)}
>
<select {...form.register('newPageClassification')}>
<select {...form.register('classification.newPageDefault')}>
{!vsNfd.hides(
'classification.newPageDefault',
settings.data['classification.newPageDefault'],
@ -172,9 +136,12 @@ export function AdminSettingsPage(): React.JSX.Element {
<Field
label={t('settings:admin.uploadPolicy')}
hint={t('settings:admin.uploadPolicyHelp')}
marking={vsNfd.markingFor('classification.uploadPolicy', form.watch('uploadPolicy'))}
marking={vsNfd.markingFor(
'classification.uploadPolicy',
form.watch('classification.uploadPolicy'),
)}
>
<select {...form.register('uploadPolicy')}>
<select {...form.register('classification.uploadPolicy')}>
{!vsNfd.hides(
'classification.uploadPolicy',
settings.data['classification.uploadPolicy'],
@ -193,18 +160,15 @@ export function AdminSettingsPage(): React.JSX.Element {
<form onSubmit={onSubmit} noValidate>
{(
[
'quotaEditorsPerPond',
'quotaReadersPerPond',
'quotaAdditionalPonds',
'quotaStorageBytes',
'quotaMaxFileBytes',
'quota.editorsPerPond',
'quota.readersPerPond',
'quota.additionalPonds',
'quota.storageBytes',
'quota.maxFileBytes',
] as const
).map((field) => (
<Field
key={field}
label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[GENERAL_FORM_FIELDS[field]]}`)}
>
<input type="number" min={0} {...form.register(field, { valueAsNumber: true })} />
).map((key) => (
<Field key={key} label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[key]}`)}>
<input type="number" min={0} {...form.register(key, { valueAsNumber: true })} />
</Field>
))}
<button type="submit" className="button" disabled={form.formState.isSubmitting}>

View File

@ -1,140 +0,0 @@
import type { InvitationListView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Field, FormError } from '../components/forms';
import { apiDelete, apiGet, apiPost } from '../lib/api';
const INVITATIONS_QUERY_KEY = ['users', 'me', 'invitations'] as const;
/**
* Peer invitations in the user settings (issue #332): invite an e-mail
* address, see your invitations with their status, revoke open ones. The
* quota line shows how many of the instance-wide per-user allowance are
* in use; with a quota of 0 the section explains that inviting is off.
*/
export function InvitationsSection(): React.JSX.Element {
const { t } = useTranslation('invitations');
const queryClient = useQueryClient();
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false);
const list = useQuery({
queryKey: INVITATIONS_QUERY_KEY,
queryFn: () => apiGet<InvitationListView>('/invitations'),
});
const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
setError(null);
setSent(false);
setBusy(true);
try {
await apiPost('/invitations', { email });
setEmail('');
setSent(true);
await queryClient.invalidateQueries({ queryKey: INVITATIONS_QUERY_KEY });
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
};
const revoke = async (id: string): Promise<void> => {
setError(null);
await apiDelete(`/invitations/${id}`);
await queryClient.invalidateQueries({ queryKey: INVITATIONS_QUERY_KEY });
};
const data = list.data;
const disabled = data?.maxOpen === 0;
return (
<section className="settings-section invitations">
<h2>{t('section.title')}</h2>
{disabled ? (
<p>{t('section.disabled')}</p>
) : (
<>
<p className="invitations__intro">{t('section.intro')}</p>
{data && (
<p className="invitations__quota">
{t('section.quota', { open: data.open, max: data.maxOpen })}
</p>
)}
<form onSubmit={(e) => void submit(e)} noValidate className="invitations__form">
<FormError error={error} />
{/* Scoped status region: a bare getByRole('status') must stay
unambiguous for other specs (lesson from #304/legal). */}
<p className="invitations__sent" role="status">
{sent ? t('form.sent') : ''}
</p>
<Field label={t('form.email')}>
<input
type="email"
value={email}
required
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<button type="submit" className="button" disabled={busy || email.length === 0}>
{t('form.submit')}
</button>
</form>
{data && data.invitations.length === 0 && <p>{t('section.empty')}</p>}
{data && data.invitations.length > 0 && (
<div
className="table-scroll"
// A scroll container is only operable by keyboard once it is
// focusable; role+name keep it from being an unlabelled stop.
tabIndex={0}
role="region"
aria-label={t('section.title')}
>
<table className="table invitations__table">
<thead>
<tr>
<th>{t('columns.email')}</th>
<th>{t('columns.status')}</th>
<th>{t('columns.created')}</th>
<th>{t('columns.expires')}</th>
<th>{t('columns.actions')}</th>
</tr>
</thead>
<tbody>
{data.invitations.map((invitation) => (
<tr
key={invitation.id}
className="invitation-row"
data-email={invitation.email}
>
<td>{invitation.email}</td>
<td className="invitation-row__status">{t(`status.${invitation.status}`)}</td>
<td>{new Date(invitation.createdAt).toLocaleDateString()}</td>
<td>{new Date(invitation.expiresAt).toLocaleDateString()}</td>
<td>
{invitation.status === 'pending' && (
<button
type="button"
className="linklike invitation-row__revoke"
onClick={() => void revoke(invitation.id)}
>
{t('actions.revoke')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</section>
);
}

View File

@ -227,9 +227,7 @@ function PageEditor({
attributes: {
role: canEdit ? 'textbox' : 'document',
'aria-label': t('contentLabel'),
...(canEdit
? { 'aria-multiline': 'true', 'aria-describedby': 'editor-keyboard-hint' }
: {}),
...(canEdit ? { 'aria-multiline': 'true' } : {}),
},
},
});
@ -363,13 +361,6 @@ function PageEditor({
/>
)}
<EditorContent editor={editor} className="editor-content" />
{/* Referenced via aria-describedby in edit mode: Tab is captured
inside tables (#338), so the way out must be discoverable. */}
{canEdit && (
<p id="editor-keyboard-hint" className="visually-hidden">
{t('keyboardHint')}
</p>
)}
{canEdit && <WikilinkAutocomplete editor={editor} />}
{canEdit && <MentionAutocomplete editor={editor} />}
</div>

View File

@ -21,7 +21,6 @@ import { StartPageSetting } from '../ponds/StartPageSetting';
import { SidebarViewSetting } from '../layout/SidebarViewSetting';
import { MemberManager } from '../members/MemberManager';
import { DeletePondSection } from '../ponds/DeletePondSection';
import { PondBrandingSection } from '../ponds/PondBrandingSection';
import { PondPluginSettings } from '../plugins/PondPluginSettings';
import { PondThemeSection } from '../theme/PondThemeSection';
@ -40,7 +39,6 @@ export function PondSettingsPage(): React.JSX.Element {
const { t: tMembers } = useTranslation('members');
const { t: tErrors } = useTranslation('errors');
const { t: tFiles } = useTranslation('files');
const { t: tBranding } = useTranslation('branding');
const { t: tExport } = useTranslation('export');
const { t: tComments } = useTranslation('comments');
const { t: tApiTokens } = useTranslation('apiTokens');
@ -116,8 +114,6 @@ export function PondSettingsPage(): React.JSX.Element {
pondSlug={pondSlug}
theme={pond.data.settings.theme}
/>
<h3>{tBranding('admin.title')}</h3>
<PondBrandingSection pondId={pond.data.id} />
</section>
)}
{canModify && <PondPluginSettings pondId={pond.data.id} />}

View File

@ -138,26 +138,21 @@ function QuotaRow({
<td>{t(`keys.${line.key}`)}</td>
<td>{line.instanceDefault}</td>
<td className="quota-row__override">
{/* Flex lives on the inner div: a td with display:flex stops behaving
like a table cell and its bottom border no longer meets the row's
(same fix as the user list's actions cell, #177). */}
<div className="quota-row__override-inner">
<input
type="number"
min={0}
className="quota-row__input"
value={draft}
onChange={(e) => onDraft(e.target.value)}
/>
<button type="button" className="linklike" onClick={onSet}>
{t('overrides.set')}
<input
type="number"
min={0}
className="quota-row__input"
value={draft}
onChange={(e) => onDraft(e.target.value)}
/>
<button type="button" className="linklike" onClick={onSet}>
{t('overrides.set')}
</button>
{line.override !== null && (
<button type="button" className="linklike" onClick={onClear}>
{t('overrides.clear')}
</button>
{line.override !== null && (
<button type="button" className="linklike" onClick={onClear}>
{t('overrides.clear')}
</button>
)}
</div>
)}
</td>
<td className="quota-row__effective">{line.effective}</td>
<td className="quota-row__usage">

View File

@ -17,7 +17,6 @@ import { SettingsLayout } from '../components/SettingsLayout';
import { useDataExport } from '../export/use-data-export';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
import { InvitationsSection } from './InvitationsSection';
import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
import { WatchesSection } from '../watches/WatchesSection';
@ -46,7 +45,6 @@ export function SettingsPage(): React.JSX.Element {
<PasswordSection />
<SessionsSection />
<WatchesSection />
<InvitationsSection />
<ApiTokensSection />
<FeedTokensSection />
<AppearanceSection />

View File

@ -1,18 +1,12 @@
import { zodResolver } from '@hookform/resolvers/zod';
import type { AdminCreateUserFormInput, AdminUserListView, AdminUserView } from '@dorfteich/shared';
import { adminCreateUserSchema } from '@dorfteich/shared';
import type { AdminUserListView, AdminUserView } from '@dorfteich/shared';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { MailCheck, ShieldMinus, ShieldPlus, Trash2, UserCheck, UserX } from 'lucide-react';
import { useEffect, useId, useRef, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/auth-context';
import { IconButton } from '../components/IconButton';
import { Field, FormError, applyFieldErrors } from '../components/forms';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable';
import { useModalFocus } from '../lib/use-modal-focus';
const PAGE_SIZE = 20;
@ -27,8 +21,6 @@ export function UserManager(): React.JSX.Element {
const { user: me } = useAuth();
const [q, setQ] = useState('');
const [page, setPage] = useState(1);
const [creating, setCreating] = useState(false);
const createButtonRef = useRef<HTMLButtonElement>(null);
const query = useQuery({
queryKey: ['admin', 'users', q, page],
@ -50,24 +42,6 @@ export function UserManager(): React.JSX.Element {
return (
<section className="settings-section user-manager">
<h2>{t('title')}</h2>
<button
type="button"
ref={createButtonRef}
className="button user-manager__create"
onClick={() => setCreating(true)}
>
{t('create.button')}
</button>
{creating && (
<CreateUserDialog
onClose={() => setCreating(false)}
onCreated={() => {
setCreating(false);
void query.refetch();
}}
returnFocusRef={createButtonRef}
/>
)}
<input
className="user-manager__search"
type="search"
@ -118,98 +92,6 @@ export function UserManager(): React.JSX.Element {
);
}
/**
* Direct account creation by a Site Admin (issue #331): same field rules as
* self-registration, but the account is active immediately no verification
* mail hop. Field names stay flat (no dots) react-hook-form treats dots as
* path separators (#322).
*/
function CreateUserDialog({
onClose,
onCreated,
returnFocusRef,
}: {
onClose: () => void;
onCreated: () => void;
returnFocusRef: React.RefObject<HTMLElement | null>;
}): React.JSX.Element {
const { t, i18n } = useTranslation('users');
const [error, setError] = useState<unknown>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
useDismissable(dialogRef, true, onClose);
useModalFocus(dialogRef, returnFocusRef);
const form = useForm<AdminCreateUserFormInput>({
resolver: zodResolver(adminCreateUserSchema) as Resolver<AdminCreateUserFormInput>,
defaultValues: { locale: i18n.language === 'de' ? 'de' : 'en' },
});
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
try {
await apiPost('/admin/users', input);
onCreated();
} catch (err) {
setError(err);
applyFieldErrors(err, (name, fieldError) =>
form.setError(name as keyof AdminCreateUserFormInput, fieldError),
);
}
});
return (
<div className="modal-overlay">
<div
className="modal create-user-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
ref={dialogRef}
>
<h2 className="modal__title" id={titleId}>
{t('create.title')}
</h2>
<p>{t('create.intro')}</p>
<form onSubmit={onSubmit} noValidate>
<FormError error={error} />
<Field label={t('create.username')} error={form.formState.errors.username?.message}>
<input type="text" autoComplete="off" {...form.register('username')} />
</Field>
<Field label={t('create.email')} error={form.formState.errors.email?.message}>
<input type="email" autoComplete="off" {...form.register('email')} />
</Field>
<Field label={t('create.displayName')} error={form.formState.errors.displayName?.message}>
<input type="text" autoComplete="off" {...form.register('displayName')} />
</Field>
<Field
label={t('create.password')}
hint={t('create.passwordHint')}
error={form.formState.errors.password?.message}
>
<input type="password" autoComplete="new-password" {...form.register('password')} />
</Field>
<Field label={t('create.locale')}>
<select {...form.register('locale')}>
<option value="de">{t('create.localeDe')}</option>
<option value="en">{t('create.localeEn')}</option>
</select>
</Field>
<div className="modal__actions">
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('create.submit')}
</button>
<button type="button" className="linklike" onClick={onClose}>
{t('create.cancel')}
</button>
</div>
</form>
</div>
</div>
);
}
function UserRow({
user,
isSelf,

View File

@ -1,55 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
GENERAL_FORM_FIELDS,
GeneralSettingsForm,
toFormValues,
toSettingsPatch,
} from './admin-settings-form';
describe('admin general settings form model (issue #322)', () => {
// The regression this file exists for: a dotted field name makes
// react-hook-form nest the typed value and the strict PATCH schema
// reject the body — the form then looks fine but never saves.
it('uses no dots in any form field name', () => {
for (const field of Object.keys(GENERAL_FORM_FIELDS)) {
expect(field).not.toContain('.');
}
});
it('round-trips settings through form values back to a flat patch', () => {
const settings = {
'instance.name': 'My Wiki',
'instance.defaultLocale': 'de',
'auth.registrationMode': 'closed',
'invitations.maxOpenPerUser': 5,
'classification.newPageDefault': 'unclassified',
'classification.uploadPolicy': 'warn',
'quota.editorsPerPond': 5,
'quota.readersPerPond': 50,
'quota.additionalPonds': 0,
'quota.storageBytes': 1024,
'quota.maxFileBytes': 25,
};
expect(toSettingsPatch(toFormValues(settings))).toEqual(settings);
});
it('patches only the settings this form edits, under their dotted keys', () => {
const input: GeneralSettingsForm = {
instanceName: 'Renamed',
defaultLocale: 'en',
registrationMode: 'open',
invitationsMaxOpenPerUser: 5,
newPageClassification: 'vs_nfd',
uploadPolicy: 'block',
quotaEditorsPerPond: 1,
quotaReadersPerPond: 2,
quotaAdditionalPonds: 3,
quotaStorageBytes: 4,
quotaMaxFileBytes: 5,
};
const patch = toSettingsPatch(input);
expect(patch['instance.name']).toBe('Renamed');
expect(Object.keys(patch).sort()).toEqual(Object.values(GENERAL_FORM_FIELDS).slice().sort());
});
});

View File

@ -1,62 +0,0 @@
/**
* Form model of the general + quota cards on the admin settings page.
*
* Field names MUST NOT contain dots: react-hook-form treats a dot in a
* field name as a nested-path separator. A field registered under its
* settings key ('instance.name') DISPLAYS fine RHF's getter falls back
* to the literal flat key but typing writes the value into a nested
* object ({ instance: { name } }), which the api's strict PATCH schema
* rejects, so nothing ever saved (issue #322). This mapping is the single
* place tying a dot-free field name to its dotted settings key; the
* converters below translate in both directions.
*/
export const GENERAL_FORM_FIELDS = {
instanceName: 'instance.name',
defaultLocale: 'instance.defaultLocale',
registrationMode: 'auth.registrationMode',
invitationsMaxOpenPerUser: 'invitations.maxOpenPerUser',
newPageClassification: 'classification.newPageDefault',
uploadPolicy: 'classification.uploadPolicy',
quotaEditorsPerPond: 'quota.editorsPerPond',
quotaReadersPerPond: 'quota.readersPerPond',
quotaAdditionalPonds: 'quota.additionalPonds',
quotaStorageBytes: 'quota.storageBytes',
quotaMaxFileBytes: 'quota.maxFileBytes',
} as const;
export type GeneralFormField = keyof typeof GENERAL_FORM_FIELDS;
export type GeneralFormSettingKey = (typeof GENERAL_FORM_FIELDS)[GeneralFormField];
export interface GeneralSettingsForm {
instanceName: string;
defaultLocale: 'de' | 'en';
registrationMode: 'open' | 'closed';
invitationsMaxOpenPerUser: number;
newPageClassification: 'unclassified' | 'vs_nfd';
uploadPolicy: 'warn' | 'block';
quotaEditorsPerPond: number;
quotaReadersPerPond: number;
quotaAdditionalPonds: number;
quotaStorageBytes: number;
quotaMaxFileBytes: number;
}
/** The settings this form reads and writes, keyed by their dotted names. */
export type GeneralFormSettings = Record<GeneralFormSettingKey, unknown>;
export function toFormValues(settings: GeneralFormSettings): GeneralSettingsForm {
return Object.fromEntries(
Object.entries(GENERAL_FORM_FIELDS).map(([field, key]) => [field, settings[key]]),
) as unknown as GeneralSettingsForm;
}
/** Flat dotted keys, exactly what PATCH /admin/settings expects. */
export function toSettingsPatch(input: GeneralSettingsForm): GeneralFormSettings {
return Object.fromEntries(
Object.entries(GENERAL_FORM_FIELDS).map(([field, key]) => [
key,
input[field as GeneralFormField],
]),
) as GeneralFormSettings;
}

View File

@ -1,10 +1,10 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { InvitationPreview, SignupFormInput, signupInputSchema } from '@dorfteich/shared';
import { SignupFormInput, signupInputSchema } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Link, useSearchParams } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { Field, FormError, applyFieldErrors } from '../../components/forms';
import { apiGet, apiPost } from '../../lib/api';
@ -21,19 +21,6 @@ export function SignupPage(): React.JSX.Element {
queryFn: () => apiGet<{ mode: 'open' | 'closed' }>('/auth/registration'),
});
// An invitation link (issue #332) carries ?invitation=<token>: a valid
// one lets this signup through even while registration is closed.
const [searchParams] = useSearchParams();
const invitationToken = searchParams.get('invitation');
const invitation = useQuery({
queryKey: ['invitation-preview', invitationToken],
queryFn: () => apiPost<InvitationPreview>('/invitations/preview', { token: invitationToken }),
enabled: Boolean(invitationToken),
retry: false,
staleTime: Infinity,
});
const invited = Boolean(invitationToken) && invitation.isSuccess;
type SignupFormValues = SignupFormInput;
// One confined cast: RHF cannot express Zod's input/output split
// (locale is optional on input, defaulted on output) without it.
@ -42,22 +29,10 @@ export function SignupPage(): React.JSX.Element {
defaultValues: { locale: i18n.language === 'de' ? 'de' : 'en' },
});
// Prefill the invited address; it stays editable (the mailbox is
// verified separately either way).
const setValue = form.setValue;
useEffect(() => {
if (invitation.data) setValue('email', invitation.data.email);
}, [invitation.data, setValue]);
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
try {
// Only a previewed-valid token rides along; a broken link falls
// back to a plain signup instead of failing the whole form.
await apiPost('/auth/signup', {
...input,
invitationToken: invited ? invitationToken : undefined,
});
await apiPost('/auth/signup', input);
setRegistered(input.email);
} catch (err) {
setError(err);
@ -67,25 +42,10 @@ export function SignupPage(): React.JSX.Element {
}
});
// With a pending invitation check, neither the closed screen nor the
// form should flash — hold the decision until the preview settles.
if (registration.data?.mode === 'closed' && invitationToken && invitation.isPending) {
if (registration.data?.mode === 'closed') {
return (
<div className="auth-card">
<h1>{t('auth:signup.title')}</h1>
</div>
);
}
if (registration.data?.mode === 'closed' && !invited) {
return (
<div className="auth-card">
<h1>{t('auth:signup.title')}</h1>
{invitationToken && invitation.isError && (
<p className="form-banner form-banner--error signup-invitation__invalid">
{t('invitations:signup.invalid')}
</p>
)}
<p className="form-banner">{t('auth:signup.closed')}</p>
<p className="auth-card__links">
<Link to="/login">{t('auth:signup.loginLink')}</Link>
@ -114,19 +74,6 @@ export function SignupPage(): React.JSX.Element {
<div className="auth-card">
<h1>{t('auth:signup.title')}</h1>
<form onSubmit={onSubmit} noValidate>
{invited && invitation.data && (
<p className="form-banner signup-invitation__banner">
{t('invitations:signup.banner', {
inviterName: invitation.data.inviterName,
email: invitation.data.email,
})}
</p>
)}
{invitationToken && invitation.isError && (
<p className="form-banner form-banner--error signup-invitation__invalid">
{t('invitations:signup.invalid')}
</p>
)}
<FormError error={error} />
<Field
label={t('auth:signup.username')}

View File

@ -1,234 +0,0 @@
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>
);
}

View File

@ -1732,42 +1732,6 @@ button {
outline: none;
}
/* Gap cursor (issue #335): the blinking bar prosemirror-gapcursor renders at
positions adjacent to block nodes without a text position of their own
(e.g. a table as the page's only block). The upstream package does not
ship its stylesheet through our import path, so these rules replace it. */
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
}
.ProseMirror-gapcursor::after {
content: '';
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid var(--color-text);
animation: dt-gapcursor-blink 1.1s steps(2, start) infinite;
}
@keyframes dt-gapcursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
@media (prefers-reduced-motion: reduce) {
.ProseMirror-gapcursor::after {
animation: none;
}
}
.editor-content h1,
.editor-content h2,
.editor-content h3,
@ -3084,7 +3048,7 @@ ul[data-type='task_list'] li p:last-of-type {
margin-bottom: var(--space-2);
}
.quota-row__override-inner {
.quota-row__override {
display: flex;
align-items: center;
gap: var(--space-2);

View File

@ -2,17 +2,14 @@
# next to docker-compose.yml and adjust the values.
# --- required ---------------------------------------------------------------
# PostgreSQL password for the `dorfteich` database user. URL-SAFE
# characters only (generate with `openssl rand -hex 24`): the compose file
# interpolates it into DATABASE_URL unescaped, so base64's `/`, `+`, `=`
# break the URL — db stays healthy while api/collab/backup restart-loop.
# PostgreSQL password for the `dorfteich` database user.
POSTGRES_PASSWORD=change-me
# ROOT key of the token key hierarchy (ADR 0020, issue #188): every token
# purpose (collaboration tokens, digest unsubscribe links) derives its own
# HKDF subkey from this value — nothing signs with it directly. The api and
# collab services share this one value; use a long random string
# (e.g. `openssl rand -hex 24`). Min length 16. Rotating it rotates all
# (e.g. `openssl rand -base64 32`). Min length 16. Rotating it rotates all
# derived keys at once and invalidates outstanding tokens.
COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string
@ -55,7 +52,7 @@ COMPOSE_PROJECT_NAME=dorfteich
# --- public URL + mail --------------------------------------------------------
# Public base URL of the stage (scheme + host). E-mail links and the CSRF
# origin check are derived from it — it must match what browsers use.
APP_BASE_URL=https://wiki.example.com
APP_BASE_URL=https://test.dorfteich.cloud
# SMTP relay for outgoing mail (verification, password reset). Optional:
# leave everything unset and configure the relay in the browser during the
@ -79,36 +76,10 @@ SMTP_FROM=Dorfteich <wiki@example.com>
#CADDY_HTTP_PORT=80
#CADDY_HTTPS_PORT=443
# --- external authentication (issues #214#216, ADR 0021) ---------------------
# Deploy-level on purpose — a Site Admin cannot change these. All unset =
# local username/password login only. Full reference:
# docs/architecture/security.md §External authentication.
# OIDC (Authorization Code + PKCE) is enabled iff ISSUER + CLIENT_ID are set;
# CLIENT_SECRET stays empty for a public client. Redirect URI to register at
# the IdP: $APP_BASE_URL/api/v1/auth/oidc/callback
#OIDC_ISSUER=https://idp.example.com/realms/example
#OIDC_CLIENT_ID=dorfteich-web
#OIDC_CLIENT_SECRET=
#OIDC_SCOPES=openid profile email
#OIDC_PROVIDER_LABEL=Single Sign-On
# Hard switch (#216): false turns EVERY local credential flow off (404) —
# sign-in only via OIDC or the trusted proxy. Complete the first-run setup
# BEFORE flipping it.
#AUTH_LOCAL_ENABLED=false
# Perimeter authentication (#215): identity from a proxy header, honoured
# only when the TCP peer is on the allowlist; the proxy MUST strip the
# header from incoming traffic. Unset = feature off, the header is inert.
#AUTH_PROXY_HEADER=X-Auth-User
#AUTH_PROXY_TRUSTED_PEERS=10.0.0.5
#AUTH_PROXY_MAP=username
#AUTH_PROXY_MODE=plain
#AUTH_PROXY_DN_ATTRIBUTE=CN
# --- backups (ADR 0015, issue #83) --------------------------------------------
# The backup sidecar dumps the database and archives the data volumes
# (uploads, plugins, custom fonts, branding) nightly onto the `backups`
# volume; restore via deploy/backup/restore.sh <backup-id>. All values
# optional.
# The backup sidecar dumps the database and archives the uploads/plugins
# volumes nightly onto the `backups` volume; restore via
# deploy/backup/restore.sh <backup-id>. All values optional.
# Daily run time HH:MM in TZ (default 03:00; set TZ for stage-local time,
# e.g. TZ=Europe/Berlin — unset means UTC).
#TZ=Europe/Berlin
@ -158,9 +129,6 @@ SMTP_FROM=Dorfteich <wiki@example.com>
# wizard. Automated deploys can skip it entirely by pre-seeding the Site
# Admin here; the wizard then completes and locks itself at first boot.
# All three SETUP_ADMIN_* values are required for pre-seeding to trigger.
# The wizard's validation applies: the password needs at least 10
# characters — a violation fails the boot with a message naming the
# variable (deliberate: no half-seeded instance).
#SETUP_ADMIN_USERNAME=admin
#SETUP_ADMIN_EMAIL=admin@example.com
#SETUP_ADMIN_PASSWORD=change-me-please

View File

@ -61,21 +61,6 @@ services:
# VS-NfD hardening-profile mode (issue #243, ADR 0027):
# off | marked | hidden | enforced. Empty = off — no marking anywhere.
VS_NFD_MODE: ${VS_NFD_MODE:-}
# External authentication (issues #214#216, ADR 0021) — deploy-level
# on purpose, out of a Site Admin's reach. All empty = local
# username/password login only. Reference: security.md §External
# authentication.
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_SCOPES: ${OIDC_SCOPES:-}
OIDC_PROVIDER_LABEL: ${OIDC_PROVIDER_LABEL:-}
AUTH_LOCAL_ENABLED: ${AUTH_LOCAL_ENABLED:-}
AUTH_PROXY_HEADER: ${AUTH_PROXY_HEADER:-}
AUTH_PROXY_TRUSTED_PEERS: ${AUTH_PROXY_TRUSTED_PEERS:-}
AUTH_PROXY_MAP: ${AUTH_PROXY_MAP:-}
AUTH_PROXY_MODE: ${AUTH_PROXY_MODE:-}
AUTH_PROXY_DN_ATTRIBUTE: ${AUTH_PROXY_DN_ATTRIBUTE:-}
# SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes
# the relay to the secret store on the `secrets` volume (issue #80);
# values set here in the stage .env always win over the store.

View File

@ -1,9 +1,6 @@
# Audit event catalogue
**Catalogue version 1.10 (2026-08-05; 1.10 adds `invitation.created`,
`invitation.revoked` and `invitation.accepted`, issue #332;
1.9 added `user.created_by_admin`,
issue #331; 1.8 added `pond.archived`,
**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
`font.deleted`, issue #303; 1.5 added `plugin.rejected`,
@ -80,14 +77,6 @@ failure), `warning` = feeds detection (suspicious or destructive),
| `auth.identity_linked` | OIDC identity linked to an existing account via the explicit link flow (issue #214) | notice | the linking user | — | `provider` |
| `auth.proxy_rejected` | Proxy-auth header received from a peer outside the allowlist — spoof attempt (issue #215) | warning | `null` (unauthenticated) | — | `peer`, `header` |
### Invitations (`invitation.*`, issue #332)
| Id | Trigger | Severity | Actor | Target | Fields |
| --------------------- | ---------------------------------------------------------------- | -------- | ----------------- | ------------ | ------ |
| `invitation.created` | User invites an e-mail address (mail with signup link sent) | info | the inviting user | `invitation` | — |
| `invitation.revoked` | Open invitation withdrawn by its creator | info | the inviting user | `invitation` | — |
| `invitation.accepted` | Signup completed through an invitation link (closed-mode bypass) | notice | the new user | `invitation` | — |
### Access & membership (`grant.*`, `member.*`)
| Id | Trigger | Severity | Actor | Target | Fields |
@ -102,7 +91,6 @@ failure), `warning` = feeds detection (suspicious or destructive),
| Id | Trigger | Severity | Actor | Target | Fields |
| -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------- |
| `user.created_by_admin` | Site Admin creates an account directly (#331) | notice | the admin | `user` | — |
| `user.disabled_set` | Site Admin disables/enables an account | notice | the admin | `user` | `disabled` (bool) |
| `user.site_admin_set` | Site-Admin privilege granted/revoked | notice | the admin | `user` | `isSiteAdmin` (bool) |
| `user.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — |

View File

@ -19,31 +19,22 @@ work, that is a bug (issue #88).
## Install
1. Create a directory and fetch the reference files from the repository
1. Create a directory and fetch the two reference files from the repository
(`deploy/compose/`): `docker-compose.yml`, `.env.example` — plus
`Caddyfile` if you want the `caddy` profile.
```sh
mkdir dorfteich && cd dorfteich
base=https://gitea.101010.cloud/stwaidele/dorfteich/raw/branch/main/deploy/compose
curl -fsSLO "$base/docker-compose.yml"
curl -fsSLO "$base/.env.example"
curl -fsSLO "$base/Caddyfile" # only for the caddy profile
# copy docker-compose.yml, .env.example (and Caddyfile) here
cp .env.example .env && chmod 600 .env
```
2. Edit `.env` — the minimum:
- `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings —
generate both with `openssl rand -hex 24`. Stick to URL-safe
characters for the database password (hex is): it is interpolated
into a connection URL, and a `/`, `+` or `=` from base64 output
breaks it in a confusing way (db healthy, everything else
restart-looping — see Troubleshooting).
- `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich` and `TAG`: pin
the latest release tag (semver, e.g. `v0.14.0`) — the
[release list](https://gitea.101010.cloud/stwaidele/dorfteich/releases)
is authoritative. Moving tags like `test`/`int` track our stages and
are not meant for third-party installs.
- `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings
(`openssl rand -base64 32`).
- `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich` and `TAG`:
releases are semver tags (`v1.2.3`); until the first public release,
`test` tracks the latest verified build.
- `APP_BASE_URL=https://wiki.example.com` — must be exactly what
browsers will use; e-mail links and the CSRF origin check derive
from it.
@ -85,18 +76,12 @@ and health endpoints with `503 setup_required` — that is not an error.
Unattended installs skip the wizard by pre-seeding: set the
`SETUP_ADMIN_*` variables in `.env` before the first start (see
`.env.example`). The same validation as in the wizard applies —
`SETUP_ADMIN_PASSWORD` needs **at least 10 characters** — and an invalid
value deliberately fails the boot with a message naming the variable
(a half-seeded instance would be harder to diagnose).
`.env.example`).
## Updating
```sh
# 1. take a backup first — the pre-update set is the guaranteed way back:
# Admin → System → "Back up now", or:
docker compose run --rm -e BACKUP_RUN_ONCE=1 backup
# 2. edit .env: TAG=v1.3.0
# edit .env: TAG=v1.3.0
docker compose pull && docker compose up -d
```
@ -104,17 +89,14 @@ Database migrations run automatically at api start. Release notes flag
releases with a `migration` label and any manual steps. **Downgrade
window: one minor release** — `TAG` back + `pull` + `up -d` is supported
one step back; further back, restore the backup taken before the update
instead. Rollback paths — including what to do when a migration itself
fails — are in
[`docs/operations/update-runbook.md`](../operations/update-runbook.md).
instead (the nightly sidecar gives you one at most 24 h old).
## Backups & restore
Enabled by default (ADR 0015): the `backup` sidecar dumps the database and
archives the data volumes (uploads, plugins, custom fonts, branding)
nightly at `BACKUP_TIME` onto the `backups` volume, prunes by
`BACKUP_RETENTION_DAYS`, writes `status.json`, and — with
`BACKUP_MAIL_TO` set — mails you on failure.
archives the uploads/plugins volumes nightly at `BACKUP_TIME` onto the
`backups` volume, prunes by `BACKUP_RETENTION_DAYS`, writes `status.json`,
and — with `BACKUP_MAIL_TO` set — mails you on failure.
- On-demand backup: `docker compose run --rm -e BACKUP_RUN_ONCE=1 backup`,
or the **Back up now** button under _Admin → System_.
@ -156,19 +138,6 @@ If the app itself is gone, use the operator path in
`docs/operations/restore-runbook.md` instead — it documents fetching a
bundle from Nextcloud by hand.
## External authentication (optional)
Local username/password accounts work out of the box — nothing to
configure. Deployments with an existing identity provider can add OpenID
Connect login (`OIDC_ISSUER` + `OIDC_CLIENT_ID` in `.env` enable it), let
an authenticating reverse proxy assert identities (`AUTH_PROXY_*`), and —
after the first-run setup is complete — turn local credentials off
entirely with `AUTH_LOCAL_ENABLED=false`. All of this is deploy-level by
design: a Site Admin cannot change it from the UI. The variables are
documented in `.env.example`; semantics and the trust model are in
[`docs/architecture/security.md`](../architecture/security.md)
(§External authentication).
## Public REST API
Scripts and integrations can talk to the instance through a
@ -192,15 +161,6 @@ and the OpenAPI document: [public-api.md](public-api.md).
mutations fail with 403 `csrf_origin_mismatch``APP_BASE_URL` does not
match the URL in the browser (scheme and host must be identical).
E-mail links point at the wrong host → same variable.
- api, collab **and** backup restart-looping while `db` is healthy →
`POSTGRES_PASSWORD` contains characters that break the connection URL
(base64's `/`, `+`, `=`); regenerate with `openssl rand -hex 24` and
recreate the stack. The db container looks fine because only its
clients build a URL from the password.
- api restart-looping right after the first start with a
`Pre-seeding failed` (or `validation.password.tooShort`) message →
`SETUP_ADMIN_PASSWORD` is shorter than 10 characters; fix `.env` and
recreate the api container.
- Wizard reappears after a restart → the database volume was not
persisted; never run without the `db-data` volume.
- `docker compose ps` shows `unhealthy` → that container's liveness check

View File

@ -29,7 +29,6 @@ Settings-Cache ist in-process (operations.md).
| Setting | Referenzwert | Default | Warum |
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. |
| `invitations.maxOpenPerUser` | `0` | `5` | **explizit setzen** — Einladungen (#332) öffnen einen kontrollierten Registrierungsweg an `auth.registrationMode=closed` vorbei; in der Referenzkonfiguration bleibt der Nutzerkreis allein Sache des Betreibers (Konten legt der Site-Admin an, #331). `0` schaltet das Einladen ab (403 `invitations_disabled`). |
| `api.enabled` | `false` | `false` | Public REST API ist ein zusätzlicher Egress-Kanal; ohne dokumentierten Bedarf bleibt er zu (404 auf allen `/api/public/v1`-Routen). |
| `mcp.enabled` | `false` | `false` | gleiches Argument für den MCP-Endpoint (`/api/mcp`); unabhängiger Schalter. |
| `feeds.enabled` | `false` | `true` | **explizit setzen** — Atom-Feeds liefern Inhalte an Reader außerhalb der Kontrolle der Instanz (Feed-Token umgehen die Session); Kopien in Feed-Readern sind nicht einholbar (Kopienliste, Sicherheitsdokumentation §5). Schaltet Routen UND Feed-Token-Verwaltung auf 404. |

View File

@ -20,8 +20,6 @@ import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
import { zipSync } from 'fflate';
import { thirdPartyNotices } from '../third-party-licenses.mjs';
const DRAWIO_VERSION = '30.3.6';
const DRAWIO_TARBALL = `https://github.com/jgraph/drawio/archive/refs/tags/v${DRAWIO_VERSION}.tar.gz`;
@ -29,16 +27,12 @@ const root = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
const vendor = join(root, 'vendor');
const webapp = join(vendor, `drawio-${DRAWIO_VERSION}`, 'src', 'main', 'webapp');
// Apache-2.0 requires a copy of the license with any redistribution (§4(a)),
// so the tarball's root LICENSE ships in the ZIP (issue #345).
const licenseFile = join(vendor, `drawio-${DRAWIO_VERSION}`, 'LICENSE');
// --- 1. Fetch + unpack the pinned draw.io release (cached in vendor/) -----
// In CI the vendor fetch is skipped (network + 60 MB — the fonts-build
// lesson): the controller bundle still builds, only the installable ZIP
// needs a dev machine (or a pre-populated vendor/ cache). The LICENSE guard
// also heals vendor/ caches unpacked before #345 added it.
if (!existsSync(webapp) || !existsSync(licenseFile)) {
// needs a dev machine (or a pre-populated vendor/ cache).
if (!existsSync(webapp)) {
if (process.env.CI) {
console.log('CI: skipping draw.io vendor fetch — bundling plugin.js only, no ZIP');
await bundleController();
@ -50,18 +44,9 @@ if (!existsSync(webapp) || !existsSync(licenseFile)) {
console.log(`fetching draw.io v${DRAWIO_VERSION}`);
execFileSync('curl', ['-sfL', '-o', tarball, DRAWIO_TARBALL], { stdio: 'inherit' });
}
execFileSync(
'tar',
[
'-xzf',
tarball,
'-C',
vendor,
`drawio-${DRAWIO_VERSION}/src/main/webapp`,
`drawio-${DRAWIO_VERSION}/LICENSE`,
],
{ stdio: 'inherit' },
);
execFileSync('tar', ['-xzf', tarball, '-C', vendor, `drawio-${DRAWIO_VERSION}/src/main/webapp`], {
stdio: 'inherit',
});
}
// --- 2. Select the runtime subset -----------------------------------------
@ -118,32 +103,20 @@ const drawioFiles = collect(webapp, '');
// --- 3. Bundle the plugin controller ---------------------------------------
async function bundleController() {
mkdirSync(join(root, 'dist'), { recursive: true });
const result = await build({
await build({
entryPoints: [join(root, 'src/plugin.ts')],
bundle: true,
format: 'esm',
outfile: join(root, 'dist/plugin.js'),
minify: true,
metafile: true,
});
return result.metafile;
}
const metafile = await bundleController();
await bundleController();
// --- 4. Pack the ZIP --------------------------------------------------------
const files = {
'manifest.json': readFileSync(join(root, 'manifest.json')),
'plugin.js': readFileSync(join(root, 'dist/plugin.js')),
'licenses/drawio-LICENSE.txt': readFileSync(licenseFile),
'licenses/THIRD-PARTY-NOTICES.txt': Buffer.from(
thirdPartyNotices(metafile, [
{
title: `draw.io ${DRAWIO_VERSION} (bundled webapp under assets/drawio/)`,
license: 'Apache-2.0',
note: `Source: ${DRAWIO_TARBALL} — full license text in licenses/drawio-LICENSE.txt.`,
},
]),
),
};
for (const name of readdirSync(join(root, 'i18n'))) {
files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name));

View File

@ -20,8 +20,6 @@ import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
import { zipSync } from 'fflate';
import { thirdPartyNotices } from '../third-party-licenses.mjs';
const root = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
const require = createRequire(import.meta.url);
@ -75,7 +73,7 @@ for (const sub of ASSET_SUBDIRS) {
// --- 2. Bundle the plugin controller (React + Excalidraw) ------------------
mkdirSync(join(root, 'dist'), { recursive: true });
const buildResult = await build({
await build({
entryPoints: [join(root, 'src/plugin.tsx')],
bundle: true,
format: 'esm',
@ -91,7 +89,6 @@ const buildResult = await build({
'process.env.NODE_ENV': '"production"',
'process.env.IS_PREACT': '"false"',
},
metafile: true,
});
// --- 3. Pack the ZIP --------------------------------------------------------
@ -106,29 +103,6 @@ for (const name of readdirSync(join(root, 'i18n'))) {
// and sit at the ZIP root so they resolve under EXCALIDRAW_ASSET_PATH.
Object.assign(files, assetFiles);
// License texts for the redistributed material (issue #345): bundled npm
// packages come from the metafile; the shipped fonts have no license files
// upstream at all, so the texts are curated in licenses/ (see FONT-NOTICES.md)
// — @excalidraw/excalidraw ships no LICENSE file either, hence the committed
// MIT text instead of the metafile fallback line.
for (const name of readdirSync(join(root, 'licenses'))) {
files[`licenses/${name}`] = readFileSync(join(root, 'licenses', name));
}
files['licenses/THIRD-PARTY-NOTICES.txt'] = Buffer.from(
thirdPartyNotices(buildResult.metafile, [
{
title: 'Excalidraw (bundled into plugin.js)',
license: 'MIT',
note: 'Full license text in licenses/excalidraw-MIT.txt.',
},
{
title: 'Fonts (shipped under fonts/)',
license: 'OFL-1.1 and MIT, per family',
note: 'Attribution table in licenses/FONT-NOTICES.md; per-font license texts alongside it.',
},
]),
);
const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`);
rmSync(target, { force: true });
writeFileSync(target, zipSync(files, { level: 6 }));

View File

@ -1,95 +0,0 @@
Copyright 2020 The Assistant Project Authors (https://github.com/hafontia/Assistant).
Copyright 2010 The Source Sans Pro Authors (https://github.com/adobe-fonts/source-sans-pro), with Reserved Font Name 'Source'.
Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -1,94 +0,0 @@
Copyright (c) 2019 - Present, Microsoft Corporation,
with Reserved Font Name Cascadia Code.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2018 Shannon Miwa
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,22 +0,0 @@
# Font notices
The Excalidraw plugin package ships the font files that Excalidraw's
prod build loads at runtime (`fonts/`). Neither the npm package nor the
Excalidraw repository ships license files next to the fonts, so the
attributions are collected here (issue #345); each referenced text in
this directory carries the font's own copyright statement.
| Font family | License | Text | Upstream |
| --------------- | ------- | ---------------------- | ------------------------------------------------------------------------------------------------- |
| Assistant | OFL-1.1 | `Assistant-OFL.txt` | https://github.com/hafontia/Assistant |
| Cascadia Code | OFL-1.1 | `CascadiaCode-OFL.txt` | https://github.com/microsoft/cascadia-code |
| Comic Shanns | MIT | `ComicShanns-MIT.txt` | https://github.com/shannpersand/comic-shanns |
| Excalifont | MIT | `excalidraw-MIT.txt` | Published as part of https://github.com/excalidraw/excalidraw (no separate font license upstream) |
| Liberation Sans | OFL-1.1 | `Liberation-OFL.txt` | https://github.com/liberationfonts/liberation-fonts |
| Lilita One | OFL-1.1 | `LilitaOne-OFL.txt` | https://fonts.google.com/specimen/Lilita+One |
| Nunito | OFL-1.1 | `Nunito-OFL.txt` | https://github.com/googlefonts/nunito |
| Virgil | OFL-1.1 | `Virgil-OFL.txt` | https://github.com/excalidraw/virgil |
| Xiaolai | OFL-1.1 | `Xiaolai-OFL.txt` | https://github.com/lxgw/kose-font |
The SIL Open Font License permits use, redistribution, and bundling
with software; it applies to the font files, not to this plugin's code.

View File

@ -1,102 +0,0 @@
Digitized data copyright (c) 2010 Google Corporation
with Reserved Font Arimo, Tinos and Cousine.
Copyright (c) 2012 Red Hat, Inc.
with Reserved Font Name Liberation.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
worldwide development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to provide
a free and open framework in which fonts may be shared and improved in
partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves.
The fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such.
This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components
as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting ? in part or in whole ?
any of the components of the Original Version, by changing formats or
by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer
or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this license, and must not be distributed
under any other license. The requirement for fonts to remain under
this license does not apply to any document created using the Font
Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.

View File

@ -1,94 +0,0 @@
Copyright (c) 2011 Juan Montoreano (juan@remolacha.biz),
with Reserved Font Name Lilita
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -1,93 +0,0 @@
Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -1,45 +0,0 @@
Copyright (c) 2021 - Present, Ellinor Rapp, with Reserved Font Name Virgil.
This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: [scripts.sil.org/OFL](https://scripts.sil.org/OFL).
# SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
## PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
## DEFINITIONS
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
## PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
1. Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
2. Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
3. No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
5. The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
## TERMINATION
This license becomes null and void if any of the above conditions are not met.
## DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -1,94 +0,0 @@
Copyright 2020-2024 LXGW (https://github.com/lxgw/kose-font)
Copyright 2014 Nozomi Seto (https://ja.osdn.net/projects/setofont/)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020 Excalidraw
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -9,27 +9,21 @@ import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
import { zipSync } from 'fflate';
import { thirdPartyNotices } from '../third-party-licenses.mjs';
const root = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
mkdirSync(join(root, 'dist'), { recursive: true });
const buildResult = await build({
await build({
entryPoints: [join(root, 'src/plugin.ts')],
bundle: true,
format: 'esm',
outfile: join(root, 'dist/plugin.js'),
minify: true,
metafile: true,
});
const files = {
'manifest.json': readFileSync(join(root, 'manifest.json')),
'plugin.js': readFileSync(join(root, 'dist/plugin.js')),
// mermaid and its transitive dependencies are bundled into plugin.js; their
// license texts ship with the package they belong to (issue #345).
'licenses/THIRD-PARTY-NOTICES.txt': Buffer.from(thirdPartyNotices(buildResult.metafile)),
};
for (const name of readdirSync(join(root, 'i18n'))) {
files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name));

View File

@ -1,82 +0,0 @@
// Third-party license notices for plugin ZIPs (issue #345). A plugin that
// redistributes third-party material must ship the license texts alongside it
// (Apache-2.0 §4(a), MIT's notice clause, OFL §2). The bundled-package list is
// derived from the esbuild metafile — the set of files that actually ended up
// in plugin.js — so the notices can never drift from the bundle the way a
// hand-maintained list would. Non-bundled material (vendored webapps, copied
// font assets) cannot appear in a metafile; callers pass those as `extras`.
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve, sep } from 'node:path';
const LICENSE_FILE_PATTERN = /^(licen[cs]e|copying|notice)(\.|$)/i;
/** Walk up from `file` to the nearest package.json that names a package. */
function packageRootOf(file) {
let dir = dirname(resolve(file));
while (dir !== dirname(dir)) {
const pj = join(dir, 'package.json');
if (existsSync(pj)) {
try {
const parsed = JSON.parse(readFileSync(pj, 'utf8'));
if (parsed.name) return { dir, pkg: parsed };
} catch {
// unreadable package.json (e.g. a fixture) — keep walking up
}
}
dir = dirname(dir);
}
return null;
}
function shippedLicenseText(dir) {
const names = readdirSync(dir).filter((name) => LICENSE_FILE_PATTERN.test(name));
return names
.sort()
.map((name) => readFileSync(join(dir, name), 'utf8').trim())
.join('\n\n');
}
/**
* All third-party npm packages whose files the metafile lists as bundle
* inputs, deduplicated by name@version. First-party `@dorfteich/*` packages
* are covered by the repository LICENSE and skipped.
*/
export function bundledPackages(metafile) {
const seen = new Map();
for (const input of Object.keys(metafile.inputs)) {
if (!input.split(sep).includes('node_modules') && !input.includes('/node_modules/')) continue;
const found = packageRootOf(input);
if (!found || found.pkg.name.startsWith('@dorfteich/')) continue;
const key = `${found.pkg.name}@${found.pkg.version}`;
if (!seen.has(key)) {
seen.set(key, {
name: found.pkg.name,
version: found.pkg.version,
license: typeof found.pkg.license === 'string' ? found.pkg.license : 'see license text',
text: shippedLicenseText(found.dir),
});
}
}
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Renders `licenses/THIRD-PARTY-NOTICES.txt` for a plugin ZIP: one section per
* bundled package (license expression + the license file it ships), then one
* per caller-supplied extra ({ title, license, note?, text? }).
*/
export function thirdPartyNotices(metafile, extras = []) {
const rule = '='.repeat(72);
const sections = [
'THIRD-PARTY NOTICES\n\nThis plugin package redistributes the third-party components listed\nbelow, each under its own license.\n',
];
for (const pkg of bundledPackages(metafile)) {
const body = pkg.text || `License: ${pkg.license} (no license file shipped in the npm package)`;
sections.push(`${rule}\n${pkg.name} ${pkg.version}${pkg.license}\n${rule}\n\n${body}\n`);
}
for (const extra of extras) {
const parts = [extra.note, extra.text].filter(Boolean).join('\n\n');
sections.push(`${rule}\n${extra.title}${extra.license}\n${rule}\n\n${parts}\n`);
}
return sections.join('\n');
}

View File

@ -37,13 +37,5 @@
"size": "Kantenlänge des Ausschnitts (px)",
"reset": "Ausschnitt zurücksetzen",
"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."
}
}

View File

@ -76,8 +76,6 @@
"addRowBefore": "Zeile davor einfügen",
"addRowAfter": "Zeile danach einfügen",
"deleteRow": "Zeile löschen",
"mergeCells": "Zellen verbinden",
"splitCell": "Zelle teilen",
"toggleHeaderRow": "Kopfzeile umschalten",
"deleteTable": "Tabelle löschen"
},
@ -194,6 +192,5 @@
"due": "Zieldatum",
"start": "Startdatum"
},
"contentLabel": "Seiteninhalt",
"keyboardHint": "In Tabellen wechselt die Tabulatortaste zur nächsten Zelle und legt in der letzten Zelle eine neue Zeile an; Umschalt+Tab geht zurück. Escape stellt den Cursor hinter die Tabelle; außerhalb von Tabellen verlässt die Tabulatortaste den Editor."
"contentLabel": "Seiteninhalt"
}

View File

@ -9,9 +9,6 @@
"rate_limited": "Zu viele Anfragen — bitte versuche es später erneut.",
"internal_error": "Interner Serverfehler.",
"registration_closed": "Die Registrierung ist auf dieser Instanz derzeit geschlossen.",
"invitations_disabled": "Einladungen sind auf dieser Instanz deaktiviert.",
"invitation_quota_reached": "Du hast die Höchstzahl offener Einladungen erreicht. Widerrufe eine offene Einladung oder warte, bis eine angenommen wurde oder abgelaufen ist.",
"invitation_already_accepted": "Diese Einladung wurde bereits angenommen und kann nicht mehr widerrufen werden.",
"token_invalid": "Dieser Link ist ungültig oder abgelaufen.",
"login_failed": "Benutzername/E-Mail oder Passwort ist falsch.",
"login_backoff": "Zu viele Fehlversuche — bitte warte ein paar Minuten.",

View File

@ -1,34 +0,0 @@
{
"section": {
"title": "Einladungen",
"intro": "Lade Personen per E-Mail ein. Der Link erlaubt genau eine Registrierung — auch wenn die Selbst-Registrierung geschlossen ist — und ist 14 Tage gültig.",
"quota": "{{open}} von {{max}} offenen Einladungen belegt.",
"disabled": "Einladungen sind auf dieser Instanz deaktiviert.",
"empty": "Noch keine Einladungen."
},
"form": {
"email": "E-Mail-Adresse",
"submit": "Einladen",
"sent": "Einladung verschickt."
},
"columns": {
"email": "E-Mail",
"status": "Status",
"created": "Eingeladen am",
"expires": "Gültig bis",
"actions": "Aktionen"
},
"status": {
"pending": "Offen",
"accepted": "Angenommen",
"revoked": "Widerrufen",
"expired": "Abgelaufen"
},
"actions": {
"revoke": "Widerrufen"
},
"signup": {
"banner": "{{inviterName}} lädt dich ein ({{email}}). Mit dieser Einladung kannst du dir jetzt ein Konto anlegen.",
"invalid": "Dieser Einladungslink ist ungültig, abgelaufen oder wurde bereits verwendet."
}
}

View File

@ -16,12 +16,6 @@
"action": "Neues Passwort setzen",
"expiry": "Der Link ist eine Stunde gültig. Dein aktuelles Passwort bleibt gültig, bis du ein neues gesetzt hast."
},
"invitation": {
"subject": "Du bist eingeladen: Dorfteich",
"body": "{{inviterName}} lädt dich zu einem Dorfteich ein — einem gemeinsamen Ort für Seiten und Notizen. Über diesen Link kannst du dir ein Konto anlegen:",
"action": "Einladung annehmen",
"expiry": "Der Link ist 14 Tage gültig und kann nur einmal verwendet werden."
},
"smtpTest": {
"subject": "SMTP-Testnachricht",
"body": "diese Testnachricht bestätigt, dass dein Dorfteich E-Mails über den konfigurierten SMTP-Server versenden kann. Deine Instanz erreichst du hier:",

View File

@ -32,20 +32,5 @@
},
"prev": "Zurück",
"next": "Weiter",
"page": "Seite {{page}}",
"create": {
"button": "Person anlegen",
"title": "Person anlegen",
"intro": "Das Konto ist sofort aktiv — es wird keine Bestätigungs-E-Mail verschickt. Teile das Passwort auf einem sicheren Weg mit.",
"username": "Benutzername",
"email": "E-Mail",
"displayName": "Anzeigename",
"password": "Anfangspasswort",
"passwordHint": "Mindestens 10 Zeichen. Die Person sollte es nach dem ersten Login ändern.",
"locale": "Sprache",
"localeDe": "Deutsch",
"localeEn": "Englisch",
"submit": "Anlegen",
"cancel": "Abbrechen"
}
"page": "Seite {{page}}"
}

View File

@ -37,13 +37,5 @@
"size": "Crop edge length (px)",
"reset": "Reset the crop",
"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."
}
}

View File

@ -76,8 +76,6 @@
"addRowBefore": "Add row before",
"addRowAfter": "Add row after",
"deleteRow": "Delete row",
"mergeCells": "Merge cells",
"splitCell": "Split cell",
"toggleHeaderRow": "Toggle header row",
"deleteTable": "Delete table"
},
@ -194,6 +192,5 @@
"due": "Due date",
"start": "Start date"
},
"contentLabel": "Page content",
"keyboardHint": "Inside tables, Tab moves to the next cell and creates a new row from the last cell; Shift+Tab moves back. Escape places the cursor after the table; outside tables, Tab leaves the editor."
"contentLabel": "Page content"
}

View File

@ -9,9 +9,6 @@
"rate_limited": "Too many requests — please try again later.",
"internal_error": "Internal server error.",
"registration_closed": "Registration is currently closed on this instance.",
"invitations_disabled": "Invitations are disabled on this instance.",
"invitation_quota_reached": "You have reached the maximum number of open invitations. Revoke an open invitation or wait until one is accepted or expires.",
"invitation_already_accepted": "This invitation has already been accepted and can no longer be revoked.",
"token_invalid": "This link is invalid or has expired.",
"login_failed": "Username/e-mail or password is incorrect.",
"login_backoff": "Too many failed attempts — please wait a few minutes.",

View File

@ -1,34 +0,0 @@
{
"section": {
"title": "Invitations",
"intro": "Invite people by e-mail. The link allows exactly one registration — even while self-registration is closed — and is valid for 14 days.",
"quota": "{{open}} of {{max}} open invitations used.",
"disabled": "Invitations are disabled on this instance.",
"empty": "No invitations yet."
},
"form": {
"email": "E-mail address",
"submit": "Invite",
"sent": "Invitation sent."
},
"columns": {
"email": "E-mail",
"status": "Status",
"created": "Invited on",
"expires": "Valid until",
"actions": "Actions"
},
"status": {
"pending": "Open",
"accepted": "Accepted",
"revoked": "Revoked",
"expired": "Expired"
},
"actions": {
"revoke": "Revoke"
},
"signup": {
"banner": "{{inviterName}} invites you ({{email}}). With this invitation you can create an account now.",
"invalid": "This invitation link is invalid, expired, or has already been used."
}
}

View File

@ -16,12 +16,6 @@
"action": "Set new password",
"expiry": "The link is valid for one hour. Your current password stays valid until you set a new one."
},
"invitation": {
"subject": "You are invited: Dorfteich",
"body": "{{inviterName}} invites you to a Dorfteich - a shared place for pages and notes. Use this link to create your account:",
"action": "Accept invitation",
"expiry": "The link is valid for 14 days and can only be used once."
},
"smtpTest": {
"subject": "SMTP test message",
"body": "this test message confirms that your Dorfteich can send e-mail through the configured SMTP server. You can reach your instance here:",

View File

@ -32,20 +32,5 @@
},
"prev": "Previous",
"next": "Next",
"page": "Page {{page}}",
"create": {
"button": "Create user",
"title": "Create user",
"intro": "The account is active immediately — no verification mail is sent. Share the password over a secure channel.",
"username": "Username",
"email": "E-mail",
"displayName": "Display name",
"password": "Initial password",
"passwordHint": "At least 10 characters. The user should change it after their first login.",
"locale": "Language",
"localeDe": "German",
"localeEn": "English",
"submit": "Create",
"cancel": "Cancel"
}
"page": "Page {{page}}"
}

View File

@ -1,7 +1,5 @@
import { z } from 'zod';
import { passwordSchema, usernameSchema } from './auth';
/**
* Site-Admin user management (issue #59): the list + actions an instance
* operator uses for support, abuse handling, and GDPR groundwork. Deleting a
@ -41,21 +39,5 @@ export type AdminUserListQuery = z.infer<typeof adminUserListQuerySchema>;
export const setUserDisabledSchema = z.object({ disabled: z.boolean() });
export const setSiteAdminSchema = z.object({ isSiteAdmin: z.boolean() });
/**
* Direct account creation by a Site Admin (issue #331). Same field rules as
* self-registration, but the account skips e-mail verification: the admin
* vouches for the address, so the user can log in right away.
*/
export const adminCreateUserSchema = z.object({
username: usernameSchema,
email: z.string().email('validation.email.invalid').max(254),
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80),
password: passwordSchema,
locale: z.enum(['de', 'en']).default('en'),
});
export type AdminCreateUserInput = z.infer<typeof adminCreateUserSchema>;
/** Form-side type: locale is optional before Zod applies its default. */
export type AdminCreateUserFormInput = z.input<typeof adminCreateUserSchema>;
/** The pseudonym a deleted user's authorship shows as. */
export const DELETED_USER_DISPLAY_NAME = 'Deleted user';

View File

@ -44,9 +44,6 @@ export const signupInputSchema = z.object({
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80),
password: passwordSchema,
locale: z.enum(['de', 'en']).default('en'),
/** Invitation token (issue #332): lets this one signup through even
* while registration is closed. */
invitationToken: z.string().min(16).max(256).optional(),
});
export type SignupInput = z.infer<typeof signupInputSchema>;
/** Form-side type: locale is optional before Zod applies its default. */

View File

@ -1,97 +0,0 @@
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);
});
});

View File

@ -81,10 +81,6 @@ export const brandingAssetSchema = z.object({
.regex(/^[a-f0-9]{16,64}$/),
width: 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>;
@ -100,54 +96,3 @@ export interface BrandingView {
* screen carries the branding. */
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,
};
}

View File

@ -25,26 +25,6 @@ describe('docToHtml (issue #24)', () => {
);
});
it('keeps the cell spans of merged cells (issue #337)', () => {
// Built directly: markdown cannot express merged cells.
const cell = (text: string, attrs: { colspan?: number; rowspan?: number } | null = null) =>
editorSchema.node('table_cell', attrs, [
editorSchema.node('paragraph', null, [editorSchema.text(text)]),
]);
const doc = editorSchema.node('doc', null, [
editorSchema.node('table', null, [
editorSchema.node('table_row', null, [
cell('wide', { colspan: 2 }),
cell('tall', { rowspan: 2 }),
]),
editorSchema.node('table_row', null, [cell('a'), cell('b')]),
]),
]);
expect(docToHtml(doc)).toBe(
'<table><tr><td colspan="2"><p>wide</p></td><td rowspan="2"><p>tall</p></td></tr><tr><td><p>a</p></td><td><p>b</p></td></tr></table>',
);
});
it('allowlists link protocols, neutralizing javascript: hrefs', () => {
const safe = markdownToDoc('[go](https://example.org)');
expect(docToHtml(safe)).toContain('href="https://example.org"');

View File

@ -111,12 +111,7 @@ function renderTable(node: Node): string {
out += '<tr>';
row.forEach((cell) => {
const tag = cell.type.name === 'table_header' ? 'th' : 'td';
// Merged cells (issue #337): without the span attributes the read-mode
// table silently loses the merge the editor shows.
const colspan = cell.attrs.colspan as number;
const rowspan = cell.attrs.rowspan as number;
const spans = `${colspan > 1 ? ` colspan="${colspan}"` : ''}${rowspan > 1 ? ` rowspan="${rowspan}"` : ''}`;
out += `<${tag}${spans}>${renderBlocks(cell)}</${tag}>`;
out += `<${tag}>${renderBlocks(cell)}</${tag}>`;
});
out += '</tr>';
});

View File

@ -148,21 +148,6 @@ describe('markdown round-trip (issue #24)', () => {
expect(doc.firstChild?.attrs.data).toEqual({});
});
it('pads a colspan cell so every row keeps the column count (issue #337)', () => {
// Built directly: markdown cannot express merged cells, so the export
// is lossy by format — but it must stay a well-formed GFM table.
const schema = markdownToDoc('x').type.schema;
const cell = (type: string, text: string, attrs: { colspan?: number } | null = null) =>
schema.node(type, attrs, [schema.node('paragraph', null, [schema.text(text)])]);
const doc = schema.node('doc', null, [
schema.node('table', null, [
schema.node('table_row', null, [cell('table_header', 'A'), cell('table_header', 'B')]),
schema.node('table_row', null, [cell('table_cell', 'wide', { colspan: 2 })]),
]),
]);
expect(docToMarkdown(doc)).toBe('| A | B |\n| --- | --- |\n| wide | |');
});
it('escalates the fence when the data contains backticks (issue #76)', () => {
const doc = markdownToDoc('```dorfteich-plugin p/t\n{"code":"x"}\n```');
const withTicks = doc.type.schema.nodes.plugin_block!.create({

View File

@ -510,10 +510,6 @@ function renderTable(state: MarkdownSerializerState, node: Node): void {
const cells: string[] = [];
row.forEach((cell) => {
cells.push(cell.textContent.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim());
// GFM has no cell spans: pad a colspan with empty cells so every row
// keeps the table's column count (rowspan stays lossy — the covered
// rows are simply shorter; issue #337).
for (let extra = 1; extra < (cell.attrs.colspan as number); extra += 1) cells.push('');
});
rows.push(cells);
});

View File

@ -18,7 +18,6 @@ export * from './fonts';
export * from './health';
export * from './home';
export * from './i18n-tools';
export * from './invitations';
export * from './labels';
export * from './legal';
export * from './links';

View File

@ -1,40 +0,0 @@
import { z } from 'zod';
/**
* Peer invitations (issue #332): a user invites an e-mail address; the
* mailed token allows exactly one registration even while registration
* is closed. A per-user quota on OPEN (pending, unexpired) invitations
* the instance setting `invitations.maxOpenPerUser`, default 5, 0 turns
* the feature off keeps the feature from becoming a spam channel.
*/
export type InvitationStatus = 'pending' | 'accepted' | 'revoked' | 'expired';
export interface InvitationView {
id: string;
email: string;
status: InvitationStatus;
createdAt: string;
expiresAt: string;
}
export interface InvitationListView {
invitations: InvitationView[];
/** Open (pending, unexpired) invitations counted against the quota. */
open: number;
/** The instance-wide per-user quota; 0 = inviting disabled. */
maxOpen: number;
}
export const createInvitationSchema = z.object({
email: z.string().email('validation.email.invalid').max(254),
});
export type CreateInvitationInput = z.infer<typeof createInvitationSchema>;
export const invitationPreviewSchema = z.object({ token: z.string().min(16).max(256) });
/** What the signup screen shows about an invitation link before use. */
export interface InvitationPreview {
email: string;
inviterName: string;
}

View File

@ -1,7 +1,5 @@
import { z } from 'zod';
import { pondBrandingSchema } from './branding';
import { COMMENT_POLICIES } from './comments';
/**
@ -68,12 +66,6 @@ export const pondSettingsSchema = z.object({
* 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. */
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. */
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
/** Per-pond opt-in to the public REST API (issue #104, default off):

Some files were not shown because too many files have changed in this diff Show More