Release 0.7.0

This commit is contained in:
hnzio committed 2026-10-25 18:40:01 +01:00
1 parent d586fbd8ce
commit 6d96900d81
27 files changed
+2060 -25

No files matched your search

+4
View File
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit/hooks';
import { deleteSession, getSessionUser, hasUsers, SESSION_COOKIE } from '#lib/server/auth.js';
import { startFeedScheduler } from '#lib/server/feeds.js';
const publicPaths = new Set([
'/',
@@ -14,9 +15,11 @@ const publicPaths = new Set([
]);
export const handle: Handle = async ({ event, resolve }) => {
startFeedScheduler();
const presentationPath = event.url.pathname.startsWith('/heinzlive/');
const sharePath = event.url.pathname.startsWith('/freigabe/');
const shareMediaPath = /^\/freigabe\/[^/]+\/media\/[^/]+$/.test(event.url.pathname);
const apiPath = event.url.pathname.startsWith('/api/v1/');
const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname);
const token = event.cookies.get(SESSION_COOKIE);
event.locals.user = getSessionUser(token);
@@ -26,6 +29,7 @@ export const handle: Handle = async ({ event, resolve }) => {
!presentationPath &&
!sharePath &&
!publicPaths.has(event.url.pathname) &&
!apiPath &&
!event.locals.user
) {
redirect(303, hasUsers() ? '/login' : '/setup');
+47
View File
@@ -0,0 +1,47 @@
import { json } from '@sveltejs/kit';
export async function apiBody(request: Request) {
const type = request.headers.get('content-type') ?? '';
if (!type.includes('application/json')) throw new Error('API_JSON_REQUIRED');
try {
return (await request.json()) as Record<string, unknown>;
} catch {
throw new Error('API_INVALID_JSON');
}
}
export function apiFailure(cause: unknown) {
const code = cause instanceof Error ? cause.message : 'API_ERROR';
const status =
code === 'API_UNAUTHORIZED'
? 401
: code === 'API_FORBIDDEN'
? 403
: code === 'NOT_FOUND'
? 404
: code === 'CONFLICT' || code === 'API_IDEMPOTENCY_CONFLICT'
? 409
: 400;
return json(
{
error: {
code,
message:
status === 401
? 'Authentifizierung erforderlich.'
: status === 403
? 'Diese Berechtigung fehlt.'
: status === 404
? 'Nicht gefunden.'
: 'Anfrage konnte nicht verarbeitet werden.'
}
},
{ status }
);
}
export function pageInput(url: URL) {
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') ?? 50) || 50));
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0) || 0);
return { limit, offset };
}
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
migrations: 17,
migrations: 18,
pieces: 6,
shows: 2,
usages: 5,
+134
View File
@@ -0,0 +1,134 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { closeDatabase, getDatabase } from './database.js';
import {
discardFeedEntries,
fetchDueFeeds,
fetchFeed,
importFeedEntries,
listFeedEntries,
saveFeedSource
} from './feeds.js';
import { getPiece } from './content.js';
vi.mock('node:dns/promises', () => ({
lookup: vi.fn(async () => [{ address: '93.184.216.34', family: 4 }])
}));
const validFeed = `<?xml version="1.0"?><rss version="2.0"><channel><title>Test</title><item>
<guid>entry-1</guid><title>Ein Beitrag</title><link>https://example.org/post</link>
<description><![CDATA[<p>Kurz <strong>und</strong> gut.</p><script>evil()</script>]]></description>
<pubDate>Sat, 24 Oct 2026 12:00:00 GMT</pubDate></item></channel></rss>`;
let directory: string;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-feed-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
});
afterEach(() => {
vi.unstubAllGlobals();
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
describe('RSS import inbox', () => {
it('keeps one entry across fetches and preserves a discard decision', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () => new Response(validFeed, { headers: { 'content-type': 'application/rss+xml' } })
)
);
const feedId = saveFeedSource(null, {
name: 'Feed',
url: 'https://example.org/feed.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
await fetchFeed(feedId);
const [entry] = listFeedEntries();
expect(entry.title).toBe('Ein Beitrag');
expect(discardFeedEntries([entry.id])).toBe(1);
await fetchFeed(feedId);
expect(listFeedEntries()).toHaveLength(0);
expect(listFeedEntries('discarded')).toHaveLength(1);
});
it('sanitizes foreign HTML and retains the original source on explicit import', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(validFeed))
);
const feedId = saveFeedSource(null, {
name: 'Feed',
url: 'https://example.org/feed.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: 'cat-jc'
});
await fetchFeed(feedId);
const [entry] = listFeedEntries();
const [pieceId] = importFeedEntries([entry.id], []);
const piece = getPiece(pieceId)!;
expect(piece.summary).toContain('Kurz und gut.');
expect(piece.summary).not.toContain('<script>');
expect(piece.sources).toEqual([
{ label: 'RSS: Originalbeitrag', url: 'https://example.org/post', timecodeSeconds: null }
]);
expect(piece.categoryIds).toEqual(['cat-jc']);
});
it('isolates a broken feed while fetching other due feeds', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async (url: URL) => new Response(url.href.includes('broken') ? '<not-feed />' : validFeed)
)
);
saveFeedSource(null, {
name: 'A broken',
url: 'https://example.org/broken.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
saveFeedSource(null, {
name: 'B valid',
url: 'https://example.org/valid.xml',
isEnabled: true,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
await fetchDueFeeds();
expect(listFeedEntries()).toHaveLength(1);
const errors = getDatabase()
.prepare("SELECT count(*) AS count FROM feed_sources WHERE last_error <> ''")
.get() as { count: number };
expect(errors.count).toBe(1);
});
it('blocks loopback targets before sending a request', async () => {
const request = vi.fn();
vi.stubGlobal('fetch', request);
const feedId = saveFeedSource(null, {
name: 'Local',
url: 'http://127.0.0.1/feed.xml',
isEnabled: false,
refreshIntervalMinutes: 60,
defaultCategoryId: null
});
await expect(fetchFeed(feedId)).rejects.toThrow('FEED_BLOCKED');
expect(request).not.toHaveBeenCalled();
});
});
+457
View File
@@ -0,0 +1,457 @@
import { createHash, randomUUID } from 'node:crypto';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import sanitizeHtml from 'sanitize-html';
import { XMLParser } from 'fast-xml-parser';
import { createPiece, getPiece, savePiece } from './content.js';
import { getDatabase } from './database.js';
const MAX_FEED_BYTES = 2 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 8_000;
const MAX_REDIRECTS = 3;
export type FeedSource = {
id: string;
name: string;
url: string;
isEnabled: boolean;
refreshIntervalMinutes: number;
defaultCategoryId: string | null;
lastFetchedAtUtc: string | null;
nextFetchAtUtc: string | null;
lastError: string;
};
export type FeedEntry = {
id: string;
feedId: string;
feedName: string;
title: string;
url: string;
summary: string;
publishedAtUtc: string | null;
status: 'new' | 'imported' | 'discarded';
pieceId: string | null;
defaultCategoryId: string | null;
};
function clean(value: unknown, maximum: number) {
return String(value ?? '')
.trim()
.slice(0, maximum);
}
function validHttpUrl(value: string) {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password;
} catch {
return false;
}
}
function privateIpv4(address: string) {
const p = address.split('.').map(Number);
return (
p[0] === 0 ||
p[0] === 10 ||
p[0] === 127 ||
(p[0] === 169 && p[1] === 254) ||
(p[0] === 172 && p[1] >= 16 && p[1] <= 31) ||
(p[0] === 192 && p[1] === 168) ||
p[0] >= 224
);
}
function privateAddress(address: string) {
if (isIP(address) === 4) return privateIpv4(address);
const value = address.toLowerCase().split('%')[0];
if (value === '::' || value === '::1' || value.startsWith('fc') || value.startsWith('fd'))
return true;
if (/^fe[89ab]/.test(value)) return true;
const mapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
return mapped ? privateIpv4(mapped) : false;
}
async function assertPublic(url: URL) {
if (!validHttpUrl(url.href) || (url.port && !['80', '443'].includes(url.port)))
throw new Error('FEED_BLOCKED');
const hostname = url.hostname.replace(/^\[|\]$/g, '');
if (hostname === 'localhost' || hostname.endsWith('.localhost')) throw new Error('FEED_BLOCKED');
const addresses = isIP(hostname)
? [{ address: hostname }]
: await lookup(hostname, { all: true, verbatim: true });
if (!addresses.length || addresses.some((entry) => privateAddress(entry.address)))
throw new Error('FEED_BLOCKED');
}
async function fetchXml(
input: string,
headers: Record<string, string>,
redirects = 0
): Promise<{ xml: string; etag: string; lastModified: string; unchanged: boolean }> {
const url = new URL(input);
await assertPublic(url);
const response = await fetch(url, {
headers: {
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml',
...headers
},
redirect: 'manual',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
if (redirects >= MAX_REDIRECTS) throw new Error('FEED_REDIRECT');
const location = response.headers.get('location');
if (!location) throw new Error('FEED_FETCH');
return fetchXml(new URL(location, url).href, headers, redirects + 1);
}
if (response.status === 304)
return {
xml: '',
etag: headers['If-None-Match'] ?? '',
lastModified: headers['If-Modified-Since'] ?? '',
unchanged: true
};
if (!response.ok) throw new Error(`FEED_HTTP_${response.status}`);
const declared = Number(response.headers.get('content-length') ?? 0);
if (declared > MAX_FEED_BYTES) throw new Error('FEED_TOO_LARGE');
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length > MAX_FEED_BYTES) throw new Error('FEED_TOO_LARGE');
return {
xml: buffer.toString('utf8'),
etag: response.headers.get('etag') ?? '',
lastModified: response.headers.get('last-modified') ?? '',
unchanged: false
};
}
function values<T>(value: T | T[] | undefined): T[] {
return value === undefined ? [] : Array.isArray(value) ? value : [value];
}
function textFrom(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') return String(value);
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
return textFrom(record['#text'] ?? record.__cdata ?? '');
}
return '';
}
function entryLink(value: unknown) {
for (const link of values(
value as Record<string, unknown> | Record<string, unknown>[] | string | undefined
)) {
if (typeof link === 'string') return link;
if (link && typeof link === 'object') {
const href = clean((link as Record<string, unknown>)['@_href'], 2_000);
const rel = clean((link as Record<string, unknown>)['@_rel'], 30);
if (href && (!rel || rel === 'alternate')) return href;
}
}
return '';
}
function parseDate(value: unknown) {
const raw = textFrom(value);
if (!raw) return null;
const date = new Date(raw);
return Number.isNaN(date.valueOf()) ? null : date.toISOString();
}
function parseFeed(xml: string) {
let document: Record<string, unknown>;
try {
document = new XMLParser({
ignoreAttributes: false,
processEntities: false,
trimValues: true
}).parse(xml) as Record<string, unknown>;
} catch {
throw new Error('INVALID_FEED');
}
const rssChannel = (document.rss as Record<string, unknown> | undefined)?.channel as
Record<string, unknown> | undefined;
const atom = document.feed as Record<string, unknown> | undefined;
if (!rssChannel && !atom) throw new Error('INVALID_FEED');
const rawEntries = values(
(rssChannel?.item ?? atom?.entry) as
Record<string, unknown> | Record<string, unknown>[] | undefined
);
return rawEntries.slice(0, 200).flatMap((entry) => {
const title = clean(textFrom(entry.title), 300);
const url = clean(entryLink(entry.link) || textFrom(entry.link), 2_000);
if (!title || !validHttpUrl(url)) return [];
const guid = clean(textFrom(entry.guid ?? entry.id), 2_000);
const externalId = guid || createHash('sha256').update(url).digest('hex');
const rawSummary = textFrom(entry.description ?? entry.summary ?? entry.content);
const summary = clean(
sanitizeHtml(rawSummary, { allowedTags: [], allowedAttributes: {} }),
5_000
);
return [
{
externalId,
title,
url,
summary,
publishedAtUtc: parseDate(entry.pubDate ?? entry.published ?? entry.updated)
}
];
});
}
export function listFeedSources(): FeedSource[] {
return (
getDatabase()
.prepare(
`SELECT id, name, url, is_enabled, refresh_interval_minutes,
default_category_id, last_fetched_at_utc, next_fetch_at_utc, last_error FROM feed_sources ORDER BY name`
)
.all() as Array<Record<string, unknown>>
).map((row) => ({
id: String(row.id),
name: String(row.name),
url: String(row.url),
isEnabled: row.is_enabled === 1,
refreshIntervalMinutes: Number(row.refresh_interval_minutes),
defaultCategoryId: row.default_category_id ? String(row.default_category_id) : null,
lastFetchedAtUtc: row.last_fetched_at_utc ? String(row.last_fetched_at_utc) : null,
nextFetchAtUtc: row.next_fetch_at_utc ? String(row.next_fetch_at_utc) : null,
lastError: String(row.last_error)
}));
}
export function saveFeedSource(
id: string | null,
input: {
name: string;
url: string;
isEnabled: boolean;
refreshIntervalMinutes: number;
defaultCategoryId: string | null;
}
) {
const name = clean(input.name, 120);
const url = clean(input.url, 2_000);
if (
!name ||
!validHttpUrl(url) ||
!Number.isInteger(input.refreshIntervalMinutes) ||
input.refreshIntervalMinutes < 15 ||
input.refreshIntervalMinutes > 10080
)
throw new Error('INVALID_FEED');
const database = getDatabase();
if (
input.defaultCategoryId &&
!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(input.defaultCategoryId)
)
throw new Error('INVALID_TAXONOMY');
const now = new Date().toISOString();
if (!id) {
id = randomUUID();
database
.prepare(
`INSERT INTO feed_sources (id, name, url, is_enabled, refresh_interval_minutes,
default_category_id, next_fetch_at_utc, created_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
name,
url,
input.isEnabled ? 1 : 0,
input.refreshIntervalMinutes,
input.defaultCategoryId,
input.isEnabled ? now : null,
now,
now
);
} else {
const result = database
.prepare(
`UPDATE feed_sources SET name = ?, url = ?, is_enabled = ?, refresh_interval_minutes = ?,
default_category_id = ?, next_fetch_at_utc = CASE WHEN ? = 1 THEN COALESCE(next_fetch_at_utc, ?) ELSE NULL END,
updated_at_utc = ? WHERE id = ?`
)
.run(
name,
url,
input.isEnabled ? 1 : 0,
input.refreshIntervalMinutes,
input.defaultCategoryId,
input.isEnabled ? 1 : 0,
now,
now,
id
);
if (!result.changes) throw new Error('NOT_FOUND');
}
return id;
}
export function deleteFeedSource(id: string) {
if (!getDatabase().prepare('DELETE FROM feed_sources WHERE id = ?').run(id).changes)
throw new Error('NOT_FOUND');
}
export function listFeedEntries(status: 'new' | 'imported' | 'discarded' = 'new'): FeedEntry[] {
return (
getDatabase()
.prepare(
`SELECT e.id, e.feed_id, f.name AS feed_name, e.title, e.url, e.summary,
e.published_at_utc, e.status, e.piece_id, f.default_category_id FROM feed_entries e
JOIN feed_sources f ON f.id = e.feed_id WHERE e.status = ? ORDER BY COALESCE(e.published_at_utc, e.first_seen_at_utc) DESC`
)
.all(status) as Array<Record<string, unknown>>
).map((row) => ({
id: String(row.id),
feedId: String(row.feed_id),
feedName: String(row.feed_name),
title: String(row.title),
url: String(row.url),
summary: String(row.summary),
publishedAtUtc: row.published_at_utc ? String(row.published_at_utc) : null,
status: row.status as FeedEntry['status'],
pieceId: row.piece_id ? String(row.piece_id) : null,
defaultCategoryId: row.default_category_id ? String(row.default_category_id) : null
}));
}
export async function fetchFeed(feedId: string) {
const database = getDatabase();
const feed = database.prepare('SELECT * FROM feed_sources WHERE id = ?').get(feedId) as
Record<string, unknown> | undefined;
if (!feed) throw new Error('NOT_FOUND');
const now = new Date();
try {
const response = await fetchXml(String(feed.url), {
...(feed.etag ? { 'If-None-Match': String(feed.etag) } : {}),
...(feed.last_modified ? { 'If-Modified-Since': String(feed.last_modified) } : {})
});
let inserted = 0;
if (!response.unchanged) {
const add =
database.prepare(`INSERT INTO feed_entries (id, feed_id, external_id, title, url, summary,
published_at_utc, first_seen_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(feed_id, external_id) DO UPDATE SET title = excluded.title, url = excluded.url,
summary = excluded.summary, published_at_utc = excluded.published_at_utc, updated_at_utc = excluded.updated_at_utc`);
for (const entry of parseFeed(response.xml)) {
const existed = database
.prepare('SELECT 1 FROM feed_entries WHERE feed_id = ? AND external_id = ?')
.get(feedId, entry.externalId);
add.run(
randomUUID(),
feedId,
entry.externalId,
entry.title,
entry.url,
entry.summary,
entry.publishedAtUtc,
now.toISOString(),
now.toISOString()
);
if (!existed) inserted += 1;
}
}
const next = new Date(
now.getTime() + Number(feed.refresh_interval_minutes) * 60_000
).toISOString();
database
.prepare(
`UPDATE feed_sources SET etag = ?, last_modified = ?, last_fetched_at_utc = ?,
next_fetch_at_utc = ?, last_error = '', updated_at_utc = ? WHERE id = ?`
)
.run(
response.etag,
response.lastModified,
now.toISOString(),
next,
now.toISOString(),
feedId
);
return { inserted, unchanged: response.unchanged };
} catch (cause) {
const message = cause instanceof Error ? cause.message.slice(0, 500) : 'FEED_FETCH';
const next = new Date(
now.getTime() + Number(feed.refresh_interval_minutes) * 60_000
).toISOString();
database
.prepare(
'UPDATE feed_sources SET last_fetched_at_utc = ?, next_fetch_at_utc = ?, last_error = ?, updated_at_utc = ? WHERE id = ?'
)
.run(now.toISOString(), next, message, now.toISOString(), feedId);
throw cause;
}
}
export async function fetchDueFeeds() {
const rows = getDatabase()
.prepare(
`SELECT id FROM feed_sources WHERE is_enabled = 1
AND (next_fetch_at_utc IS NULL OR next_fetch_at_utc <= ?) ORDER BY next_fetch_at_utc LIMIT 10`
)
.all(new Date().toISOString()) as { id: string }[];
for (const row of rows) await fetchFeed(row.id).catch(() => undefined);
}
export function importFeedEntries(entryIds: string[], categoryIds: string[]) {
const database = getDatabase();
const categories = [...new Set(categoryIds.filter(Boolean))];
if (categories.length > 20) throw new Error('INVALID_TAXONOMY');
for (const category of categories)
if (!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(category))
throw new Error('INVALID_TAXONOMY');
return database.transaction(() =>
entryIds.map((entryId) => {
const entry = database
.prepare(
`SELECT e.*, f.default_category_id FROM feed_entries e JOIN feed_sources f ON f.id = e.feed_id
WHERE e.id = ? AND e.status = 'new'`
)
.get(entryId) as Record<string, unknown> | undefined;
if (!entry) throw new Error('NOT_FOUND');
const pieceId = createPiece('blank');
const piece = getPiece(pieceId)!;
const assigned = categories.length
? categories
: entry.default_category_id
? [String(entry.default_category_id)]
: [];
savePiece(pieceId, piece.revision, {
...piece,
title: String(entry.title),
summary: String(entry.summary),
status: 'inbox',
categoryIds: assigned,
sources: [{ label: 'RSS: Originalbeitrag', url: String(entry.url), timecodeSeconds: null }]
});
database
.prepare(
"UPDATE feed_entries SET status = 'imported', piece_id = ?, updated_at_utc = ? WHERE id = ?"
)
.run(pieceId, new Date().toISOString(), entryId);
return pieceId;
})
)();
}
export function discardFeedEntries(entryIds: string[]) {
const database = getDatabase();
const update = database.prepare(
"UPDATE feed_entries SET status = 'discarded', updated_at_utc = ? WHERE id = ? AND status = 'new'"
);
const now = new Date().toISOString();
return database.transaction(() =>
entryIds.reduce((count, id) => count + update.run(now, id).changes, 0)
)();
}
let schedulerStarted = false;
export function startFeedScheduler() {
if (schedulerStarted || process.env.NODE_ENV === 'test') return;
schedulerStarted = true;
void fetchDueFeeds();
setInterval(() => void fetchDueFeeds(), 60_000).unref();
}
+97
View File
@@ -0,0 +1,97 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { AuthUser } from './auth.js';
import { closeDatabase, getDatabase } from './database.js';
import {
authenticateTechnical,
createTechnicalAccess,
idempotentResponse,
revokeTechnicalAccess
} from './technical-access.js';
let directory: string;
let actor: AuthUser;
beforeEach(() => {
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-api-test-'));
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
const now = new Date().toISOString();
getDatabase()
.prepare(
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc) VALUES ('test-user', 'test@example.org',
'test@example.org', 'Test', 'not-used', 'barkeeper', ?, ?, ?)`
)
.run(now, now, now);
actor = {
id: 'test-user',
email: 'test@example.org',
displayName: 'Test',
role: 'barkeeper',
lockedAtUtc: null
};
});
afterEach(() => {
closeDatabase();
delete process.env.SENDEWERK_DB_PATH;
delete process.env.SENDEWERK_UPLOAD_DIR;
rmSync(directory, { recursive: true, force: true });
});
function request(token: string, key?: string) {
return new Request('https://sendewerk.test/api/v1/pieces', {
headers: { Authorization: `Bearer ${token}`, ...(key ? { 'Idempotency-Key': key } : {}) }
});
}
describe('technical API access', () => {
it('enforces permissions and revocation without storing the raw token', () => {
const created = createTechnicalAccess(actor, 'Leser', 'read');
expect(authenticateTechnical(request(created.token))).toMatchObject({
name: 'Leser',
permissions: 'read'
});
expect(() => authenticateTechnical(request(created.token), true)).toThrow('API_FORBIDDEN');
expect(
getDatabase()
.prepare('SELECT token_hash FROM technical_accesses WHERE id = ?')
.get(created.id)
).not.toMatchObject({ token_hash: created.token });
revokeTechnicalAccess(actor, created.id);
expect(() => authenticateTechnical(request(created.token))).toThrow('API_UNAUTHORIZED');
});
it('replays an identical idempotent write and rejects a changed payload', () => {
const created = createTechnicalAccess(actor, 'Schreiber', 'read_write');
const technical = authenticateTechnical(request(created.token), true);
let calls = 0;
const first = idempotentResponse(
technical,
request(created.token, 'command-1'),
{ title: 'A' },
() => ({ status: 201, body: { calls: ++calls } })
);
const replay = idempotentResponse(
technical,
request(created.token, 'command-1'),
{ title: 'A' },
() => ({ status: 201, body: { calls: ++calls } })
);
expect(first.duplicate).toBe(false);
expect(replay).toMatchObject({ duplicate: true, body: { calls: 1 } });
expect(calls).toBe(1);
expect(() =>
idempotentResponse(technical, request(created.token, 'command-1'), { title: 'B' }, () => ({
status: 201,
body: {}
}))
).toThrow('API_IDEMPOTENCY_CONFLICT');
});
});
+151
View File
@@ -0,0 +1,151 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type { AuthUser } from './auth.js';
import { getDatabase } from './database.js';
export type TechnicalPermission = 'read' | 'read_write';
export type TechnicalActor = { id: string; name: string; permissions: TechnicalPermission };
function tokenHash(token: string) {
return createHash('sha256').update(token).digest('hex');
}
export function listTechnicalAccesses() {
return getDatabase()
.prepare(
`SELECT id, name, permissions, created_at_utc AS createdAtUtc,
last_used_at_utc AS lastUsedAtUtc, revoked_at_utc AS revokedAtUtc FROM technical_accesses ORDER BY name`
)
.all() as {
id: string;
name: string;
permissions: TechnicalPermission;
createdAtUtc: string;
lastUsedAtUtc: string | null;
revokedAtUtc: string | null;
}[];
}
export function createTechnicalAccess(actor: AuthUser, nameValue: string, permissions: string) {
const name = nameValue.trim();
if (!name || name.length > 120 || !['read', 'read_write'].includes(permissions))
throw new Error('INVALID_ACCESS');
const token = randomBytes(32).toString('base64url');
const id = randomUUID();
const now = new Date().toISOString();
getDatabase()
.prepare(
`INSERT INTO technical_accesses
(id, name, token_hash, permissions, created_by_user_id, created_at_utc) VALUES (?, ?, ?, ?, ?, ?)`
)
.run(id, name, tokenHash(token), permissions, actor.id, now);
auditTechnical(
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
'technical_access.create',
'technical_access',
id,
'user'
);
return { id, token };
}
export function revokeTechnicalAccess(actor: AuthUser, id: string) {
const result = getDatabase()
.prepare(
'UPDATE technical_accesses SET revoked_at_utc = ? WHERE id = ? AND revoked_at_utc IS NULL'
)
.run(new Date().toISOString(), id);
if (!result.changes) throw new Error('NOT_FOUND');
auditTechnical(
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
'technical_access.revoke',
'technical_access',
id,
'user'
);
}
export function authenticateTechnical(request: Request, write = false): TechnicalActor {
const authorization = request.headers.get('authorization') ?? '';
const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : '';
if (!token) throw new Error('API_UNAUTHORIZED');
const row = getDatabase()
.prepare(
`SELECT id, name, permissions FROM technical_accesses
WHERE token_hash = ? AND revoked_at_utc IS NULL`
)
.get(tokenHash(token)) as TechnicalActor | undefined;
if (!row) throw new Error('API_UNAUTHORIZED');
if (write && row.permissions !== 'read_write') throw new Error('API_FORBIDDEN');
getDatabase()
.prepare('UPDATE technical_accesses SET last_used_at_utc = ? WHERE id = ?')
.run(new Date().toISOString(), row.id);
return row;
}
export function auditTechnical(
actor: TechnicalActor,
action: string,
targetType: string,
targetId: string,
actorType: 'technical' | 'user' = 'technical'
) {
getDatabase()
.prepare(
`INSERT INTO audit_events (id, actor_type, actor_id, action, target_type, target_id, created_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
randomUUID(),
actorType,
actor.id,
action.slice(0, 120),
targetType.slice(0, 80),
targetId.slice(0, 200),
new Date().toISOString()
);
}
export function idempotentResponse(
actor: TechnicalActor,
request: Request,
body: unknown,
operation: () => { status: number; body: unknown }
) {
const key = request.headers.get('idempotency-key')?.trim() ?? '';
if (!key || key.length > 200) throw new Error('API_IDEMPOTENCY_REQUIRED');
const hash = createHash('sha256')
.update(`${request.method} ${new URL(request.url).pathname}\n${JSON.stringify(body)}`)
.digest('hex');
const database = getDatabase();
return database.transaction(() => {
const existing = database
.prepare(
'SELECT request_hash, status_code, response_json FROM api_idempotency WHERE access_id = ? AND request_key = ?'
)
.get(actor.id, key) as
{ request_hash: string; status_code: number; response_json: string } | undefined;
if (existing) {
if (existing.request_hash !== hash) throw new Error('API_IDEMPOTENCY_CONFLICT');
return {
status: existing.status_code,
body: JSON.parse(existing.response_json),
duplicate: true
};
}
const result = operation();
database
.prepare(
`INSERT INTO api_idempotency
(access_id, request_key, request_hash, status_code, response_json, created_at_utc) VALUES (?, ?, ?, ?, ?, ?)`
)
.run(
actor.id,
key,
hash,
result.status,
JSON.stringify(result.body),
new Date().toISOString()
);
return { ...result, duplicate: false };
})();
}
+105
View File
@@ -0,0 +1,105 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const id = { name: 'id', in: 'path', required: true, schema: { type: 'string' } };
const usageId = { name: 'usageId', in: 'path', required: true, schema: { type: 'string' } };
const idempotency = {
name: 'Idempotency-Key',
in: 'header',
required: true,
schema: { type: 'string' }
};
const ok = { '200': { description: 'Erfolgreich' } };
const jsonBody = (schema: Record<string, unknown>) => ({
required: true,
content: { 'application/json': { schema } }
});
export const GET: RequestHandler = () =>
json({
openapi: '3.1.0',
info: {
title: 'Sendewerk API',
version: '1.0.0',
description:
'Versionierte API für technische Redaktionszugänge. Schreibzugriffe benötigen einen Idempotency-Key.'
},
servers: [{ url: '/api/v1' }],
security: [{ bearerAuth: [] }],
components: {
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'Sendewerk technical token' }
},
schemas: {
PieceWrite: {
type: 'object',
properties: {
title: { type: 'string' },
summary: { type: 'string' },
status: { enum: ['inbox', 'preparing', 'ready'] },
durationSeconds: { type: 'integer', minimum: 0 }
}
},
ShowWrite: {
type: 'object',
required: ['title'],
properties: {
title: { type: 'string' },
startsAtUtc: { type: ['string', 'null'], format: 'date-time' },
templateId: { type: ['string', 'null'] }
}
}
}
},
paths: {
'/pieces': {
get: { summary: 'Pieces lesen', responses: ok },
post: {
summary: 'Piece anlegen',
parameters: [idempotency],
requestBody: jsonBody({ $ref: '#/components/schemas/PieceWrite' }),
responses: { '201': { description: 'Angelegt' } }
}
},
'/pieces/{id}': {
get: { summary: 'Piece lesen', parameters: [id], responses: ok },
patch: {
summary: 'Piece ändern',
parameters: [id],
requestBody: jsonBody({
allOf: [
{ $ref: '#/components/schemas/PieceWrite' },
{ type: 'object', required: ['revision'] }
]
}),
responses: ok
}
},
'/shows': {
get: { summary: 'Sendungen lesen', responses: ok },
post: {
summary: 'Sendung anlegen',
parameters: [idempotency],
requestBody: jsonBody({ $ref: '#/components/schemas/ShowWrite' }),
responses: { '201': { description: 'Angelegt' } }
}
},
'/shows/{id}': { get: { summary: 'Sendung lesen', parameters: [id], responses: ok } },
'/shows/{id}/usages': {
post: {
summary: 'Piece einplanen',
parameters: [id, idempotency],
requestBody: jsonBody({ type: 'object', required: ['revision', 'pieceId'] }),
responses: { '201': { description: 'Eingeplant' } }
}
},
'/shows/{id}/usages/{usageId}': {
patch: {
summary: 'Einplanung sortieren',
parameters: [id, usageId, idempotency],
requestBody: jsonBody({ type: 'object', required: ['revision', 'position'] }),
responses: ok
}
}
}
});
+69
View File
@@ -0,0 +1,69 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure, pageInput } from '#lib/server/api-http.js';
import {
createPiece,
getPiece,
listPieces,
savePiece,
type PieceStatus
} from '#lib/server/content.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, url }) => {
try {
authenticateTechnical(request);
const { limit, offset } = pageInput(url);
const status = url.searchParams.get('status') as PieceStatus | null;
const statuses = status ? [status] : (['inbox', 'preparing', 'ready'] as PieceStatus[]);
const all = listPieces({ statuses, query: url.searchParams.get('q') ?? '' });
return json({
data: all.slice(offset, offset + limit),
pagination: { limit, offset, total: all.length }
});
} catch (cause) {
return apiFailure(cause);
}
};
export const POST: RequestHandler = async ({ request }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
const id = createPiece('blank');
const piece = getPiece(id)!;
savePiece(id, piece.revision, {
...piece,
title: String(body.title ?? ''),
summary: String(body.summary ?? ''),
status: ['inbox', 'preparing', 'ready'].includes(String(body.status))
? (body.status as 'inbox' | 'preparing' | 'ready')
: 'preparing',
durationSeconds: Number(body.durationSeconds ?? 0),
sources: Array.isArray(body.sources)
? body.sources.map((source) => ({
label: String((source as Record<string, unknown>).label ?? ''),
url: String((source as Record<string, unknown>).url ?? ''),
timecodeSeconds:
(source as Record<string, unknown>).timecodeSeconds == null
? null
: Number((source as Record<string, unknown>).timecodeSeconds)
}))
: []
});
auditTechnical(actor, 'piece.create', 'piece', id);
return { status: 201, body: { data: getPiece(id) } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
+44
View File
@@ -0,0 +1,44 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure } from '#lib/server/api-http.js';
import { getPiece, savePiece } from '#lib/server/content.js';
import { auditTechnical, authenticateTechnical } from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, params }) => {
try {
authenticateTechnical(request);
const piece = getPiece(params.id);
if (!piece) throw new Error('NOT_FOUND');
return json({ data: piece });
} catch (cause) {
return apiFailure(cause);
}
};
export const PATCH: RequestHandler = async ({ request, params }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const piece = getPiece(params.id);
if (!piece) throw new Error('NOT_FOUND');
if (piece.status === 'archived') throw new Error('INVALID_STATUS');
if (Number(body.revision) !== piece.revision) throw new Error('CONFLICT');
const status =
body.status !== undefined
? (String(body.status) as 'inbox' | 'preparing' | 'ready')
: piece.status;
savePiece(params.id, piece.revision, {
...piece,
status,
...(body.title !== undefined ? { title: String(body.title) } : {}),
...(body.summary !== undefined ? { summary: String(body.summary) } : {}),
...(body.durationSeconds !== undefined
? { durationSeconds: Number(body.durationSeconds) }
: {})
});
auditTechnical(actor, 'piece.update', 'piece', params.id);
return json({ data: getPiece(params.id) });
} catch (cause) {
return apiFailure(cause);
}
};
+45
View File
@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure, pageInput } from '#lib/server/api-http.js';
import { createShow, getShow, listShows } from '#lib/server/shows.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, url }) => {
try {
authenticateTechnical(request);
const { limit, offset } = pageInput(url);
const all = listShows();
return json({
data: all.slice(offset, offset + limit),
pagination: { limit, offset, total: all.length }
});
} catch (cause) {
return apiFailure(cause);
}
};
export const POST: RequestHandler = async ({ request }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
const created = createShow({
title: String(body.title ?? ''),
startsAtUtc: body.startsAtUtc ? String(body.startsAtUtc) : null,
templateId: body.templateId ? String(body.templateId) : null
});
auditTechnical(actor, 'show.create', 'show', created.id);
return { status: 201, body: { data: getShow(created.id) } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
+16
View File
@@ -0,0 +1,16 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiFailure } from '#lib/server/api-http.js';
import { getShow } from '#lib/server/shows.js';
import { authenticateTechnical } from '#lib/server/technical-access.js';
export const GET: RequestHandler = ({ request, params }) => {
try {
authenticateTechnical(request);
const show = getShow(params.id);
if (!show) throw new Error('NOT_FOUND');
return json({ data: show });
} catch (cause) {
return apiFailure(cause);
}
};
@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure } from '#lib/server/api-http.js';
import { addPieceUsage, getShow } from '#lib/server/shows.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const POST: RequestHandler = async ({ request, params }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
const created = addPieceUsage(params.id, Number(body.revision), {
pieceId: String(body.pieceId ?? ''),
sectionId: body.sectionId ? String(body.sectionId) : null,
position: body.position == null ? undefined : Number(body.position)
});
auditTechnical(actor, 'usage.create', 'usage', created.id);
return { status: 201, body: { data: getShow(params.id), usageId: created.id } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
@@ -0,0 +1,30 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiBody, apiFailure } from '#lib/server/api-http.js';
import { getShow, movePieceUsage } from '#lib/server/shows.js';
import {
auditTechnical,
authenticateTechnical,
idempotentResponse
} from '#lib/server/technical-access.js';
export const PATCH: RequestHandler = async ({ request, params }) => {
try {
const actor = authenticateTechnical(request, true);
const body = await apiBody(request);
const result = idempotentResponse(actor, request, body, () => {
movePieceUsage(params.id, params.usageId, Number(body.revision), {
sectionId: body.sectionId ? String(body.sectionId) : null,
position: Number(body.position)
});
auditTechnical(actor, 'usage.move', 'usage', params.usageId);
return { status: 200, body: { data: getShow(params.id) } };
});
return json(result.body, {
status: result.status,
headers: { 'Idempotency-Replayed': result.duplicate ? 'true' : 'false' }
});
} catch (cause) {
return apiFailure(cause);
}
};
+29 -2
View File
@@ -1,11 +1,16 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { captureInbox, listPieces, setPieceDeleted } from '#lib/server/content.js';
import { captureInbox, getTaxonomy, listPieces, setPieceDeleted } from '#lib/server/content.js';
import { discardFeedEntries, importFeedEntries, listFeedEntries } from '#lib/server/feeds.js';
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { pieces: listPieces({ statuses: ['inbox'] }) };
return {
pieces: listPieces({ statuses: ['inbox'] }),
feedEntries: listFeedEntries(),
taxonomy: getTaxonomy()
};
};
export const actions = {
@@ -36,5 +41,27 @@ export const actions = {
} catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.' });
}
},
importFeeds: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const entryIds = data.getAll('entryIds').map(String);
if (!entryIds.length) return fail(400, { feedError: 'Mindestens einen Beitrag auswählen.' });
try {
const pieceIds = importFeedEntries(entryIds, data.getAll('categoryIds').map(String));
return {
feedSuccess: `${pieceIds.length} ${pieceIds.length === 1 ? 'Beitrag' : 'Beiträge'} in den Eingang übernommen.`
};
} catch {
return fail(400, { feedError: 'Die Auswahl konnte nicht übernommen werden.' });
}
},
discardFeeds: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
const entryIds = data.getAll('entryIds').map(String);
if (!entryIds.length) return fail(400, { feedError: 'Mindestens einen Beitrag auswählen.' });
const count = discardFeedEntries(entryIds);
return { feedSuccess: `${count} ${count === 1 ? 'Beitrag' : 'Beiträge'} verworfen.` };
}
} satisfies Actions;
+116
View File
@@ -1,4 +1,5 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- RSS links are validated external URLs. */
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types';
@@ -43,6 +44,65 @@
<button class="primary-button" type="submit">Im Eingang ablegen</button>
</form>
</section>
<section class="quiet-panel feed-inbox">
<div class="panel-heading">
<div>
<p class="eyebrow">RSS-Importeingang</p>
<h2>Beiträge sichten</h2>
</div>
<span>{data.feedEntries.length} neu</span>
</div>
<p>Beiträge bleiben hier getrennt, bis du sie auswählst und in den Fundus übernimmst.</p>
{#if form?.feedError}<div class="form-message error" role="alert">{form.feedError}</div>{/if}
{#if form?.feedSuccess}<div class="form-message success" role="status">
{form.feedSuccess}
</div>{/if}
{#if data.feedEntries.length}
<form method="POST" class="feed-selection">
<fieldset class="feed-categories">
<legend>Kategorien für die Auswahl <span>(Feed-Standard gilt ohne Auswahl)</span></legend>
{#each data.taxonomy.categories as category (category.id)}
<label
><input type="checkbox" name="categoryIds" value={category.id} />
{category.name}</label
>
{/each}
</fieldset>
<div class="feed-entry-list">
{#each data.feedEntries as entry (entry.id)}
<label class="feed-entry">
<input type="checkbox" name="entryIds" value={entry.id} />
<span
><small
>{entry.feedName}{entry.publishedAtUtc
? ` · ${new Date(entry.publishedAtUtc).toLocaleDateString('de-DE')}`
: ''}</small
><strong>{entry.title}</strong>{#if entry.summary}<span>{entry.summary}</span>{/if}
<a
href={entry.url}
target="_blank"
rel="noreferrer"
onclick={(event) => event.stopPropagation()}>Original öffnen</a
></span
>
</label>
{/each}
</div>
<div class="feed-actions">
<button class="primary-button" type="submit" formaction="?/importFeeds"
>Auswahl übernehmen</button
>
<button class="text-button danger-text" type="submit" formaction="?/discardFeeds"
>Auswahl verwerfen</button
>
</div>
</form>
{:else}
<p class="empty-copy">
Keine neuen Feed-Beiträge. Aktivierte Feeds werden automatisch abgerufen.
</p>
{/if}
</section>
{#if data.pieces.length}
<section class="inbox-list">
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
@@ -81,3 +141,59 @@
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p>
</section>{/if}
</div>
<style>
.feed-inbox {
display: grid;
gap: 1rem;
margin-block: 1.5rem;
}
.feed-selection,
.feed-entry-list {
display: grid;
gap: 0.75rem;
}
.feed-categories {
display: flex;
flex-wrap: wrap;
gap: 0.6rem 1rem;
border: 0;
padding: 0;
}
.feed-categories legend {
width: 100%;
font-weight: 700;
margin-bottom: 0.45rem;
}
.feed-categories legend span {
font-weight: 400;
color: var(--color-text-muted);
}
.feed-entry {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.8rem;
align-items: start;
padding: 0.9rem;
border: 1px solid var(--color-border);
border-radius: 0.7rem;
cursor: pointer;
}
.feed-entry > span {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.feed-entry small {
color: var(--color-text-muted);
}
.feed-entry a {
width: fit-content;
}
.feed-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
</style>
+83
View File
@@ -12,6 +12,12 @@ import { createShare, deleteShare, listShares, updateShare } from '#lib/server/s
import { listShows } from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
import { deleteFeedSource, fetchFeed, listFeedSources, saveFeedSource } from '#lib/server/feeds.js';
import {
createTechnicalAccess,
listTechnicalAccesses,
revokeTechnicalAccess
} from '#lib/server/technical-access.js';
import {
inviteUser,
isRole,
@@ -33,6 +39,8 @@ export const load: PageServerLoad = ({ locals }) => {
tools: listTools(true),
shows: listShows(),
shares: listShares(locals.user!),
feeds: listFeedSources(),
technicalAccesses: listTechnicalAccesses(),
mailTransport: getMailConfig().transport
};
};
@@ -59,6 +67,8 @@ function actionError(cause: unknown): string {
if (code === 'INVALID_EXPIRY') return 'Der Ablaufzeitpunkt muss in der Zukunft liegen.';
if (code === 'INVALID_SCOPE') return 'Der Informationsumfang ist ungültig.';
if (code === 'INVALID_PASSWORD_MODE') return 'Die Passwortoption ist ungültig.';
if (code === 'INVALID_FEED') return 'Name, URL und Abrufintervall bitte prüfen.';
if (code === 'INVALID_ACCESS') return 'Name und Berechtigung bitte prüfen.';
return 'Die Aktion konnte nicht ausgeführt werden.';
}
@@ -249,5 +259,78 @@ export const actions = {
} catch (cause) {
return fail(400, { intent: 'tool', error: actionError(cause) });
}
},
saveFeed: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
saveFeedSource(String(data.get('feedId') || '') || null, {
name: String(data.get('name') || ''),
url: String(data.get('url') || ''),
isEnabled: data.get('isEnabled') === '1',
refreshIntervalMinutes: Number(data.get('refreshIntervalMinutes')),
defaultCategoryId: String(data.get('defaultCategoryId') || '') || null
});
return { intent: 'feed', success: 'Feed gespeichert.' };
} catch (cause) {
return fail(400, { intent: 'feed', error: actionError(cause) });
}
},
deleteFeed: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteFeedSource(String(data.get('feedId') || ''));
return { intent: 'feed', success: 'Feed entfernt.' };
} catch (cause) {
return fail(400, { intent: 'feed', error: actionError(cause) });
}
},
fetchFeed: async ({ locals, request }) => {
requireBarkeeper(locals.user);
const data = await request.formData();
try {
const result = await fetchFeed(String(data.get('feedId') || ''));
return {
intent: 'feed',
success: result.unchanged
? 'Feed ist unverändert.'
: `${result.inserted} neue Beiträge abgerufen.`
};
} catch {
return fail(400, {
intent: 'feed',
error: 'Feed konnte nicht sicher abgerufen oder gelesen werden.'
});
}
},
createTechnicalAccess: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const created = createTechnicalAccess(
actor,
String(data.get('name') || ''),
String(data.get('permissions') || '')
);
return {
intent: 'technicalAccess',
success:
'Technischer Zugang angelegt. Token jetzt sicher hinterlegen; es wird nicht erneut angezeigt.',
technicalToken: created.token
};
} catch (cause) {
return fail(400, { intent: 'technicalAccess', error: actionError(cause) });
}
},
revokeTechnicalAccess: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
revokeTechnicalAccess(actor, String(data.get('accessId') || ''));
return { intent: 'technicalAccess', success: 'Technischer Zugang widerrufen.' };
} catch (cause) {
return fail(400, { intent: 'technicalAccess', error: actionError(cause) });
}
}
} satisfies Actions;
+235
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import AppIcon from '#lib/components/AppIcon.svelte';
import IconPicker from '#lib/components/IconPicker.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
@@ -32,6 +33,176 @@
{form.success}
</div>{/if}
<ShareManagement shows={data.shows} shares={data.shares} {form} />
<section class="quiet-panel integration-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Import</p>
<h2>RSS-Feeds</h2>
</div>
<span>{data.feeds.length} eingerichtet</span>
</div>
<p>
Aktive Feeds werden im gewählten Intervall abgerufen. Beiträge landen zuerst separat im
Eingang.
</p>
<form class="integration-form" method="POST" action="?/saveFeed">
<label>Name<input name="name" required maxlength="120" /></label>
<label
>Feed-URL<input
name="url"
type="url"
required
maxlength="2000"
placeholder="https://example.org/feed.xml"
/></label
>
<label
>Intervall in Minuten<input
name="refreshIntervalMinutes"
type="number"
min="15"
max="10080"
value="60"
required
/></label
>
<label
>Standardkategorie<select name="defaultCategoryId"
><option value="">Keine</option
>{#each data.taxonomy.categories as category (category.id)}<option value={category.id}
>{category.name}</option
>{/each}</select
></label
>
<label class="check-row"
><input type="checkbox" name="isEnabled" value="1" checked /> Automatisch abrufen</label
>
<button class="primary-button" type="submit">Feed anlegen</button>
</form>
<div class="integration-list">
{#each data.feeds as feed (feed.id)}
<details>
<summary
><span
><strong>{feed.name}</strong><small
>{feed.lastError
? `Fehler: ${feed.lastError}`
: feed.lastFetchedAtUtc
? `Zuletzt: ${new Date(feed.lastFetchedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht abgerufen'}</small
></span
><AppIcon name="pen" /></summary
>
<form class="integration-form" method="POST" action="?/saveFeed">
<input type="hidden" name="feedId" value={feed.id} />
<label>Name<input name="name" required maxlength="120" value={feed.name} /></label>
<label
>Feed-URL<input
name="url"
type="url"
required
maxlength="2000"
value={feed.url}
/></label
>
<label
>Intervall in Minuten<input
name="refreshIntervalMinutes"
type="number"
min="15"
max="10080"
value={feed.refreshIntervalMinutes}
required
/></label
>
<label
>Standardkategorie<select name="defaultCategoryId"
><option value="">Keine</option
>{#each data.taxonomy.categories as category (category.id)}<option
value={category.id}
selected={feed.defaultCategoryId === category.id}>{category.name}</option
>{/each}</select
></label
>
<label class="check-row"
><input type="checkbox" name="isEnabled" value="1" checked={feed.isEnabled} /> Automatisch
abrufen</label
>
<button class="secondary-button" type="submit">Speichern</button>
</form>
<div class="row-actions">
<form method="POST" action="?/fetchFeed">
<input type="hidden" name="feedId" value={feed.id} /><button
class="text-button"
type="submit">Jetzt abrufen</button
>
</form>
<form
method="POST"
action="?/deleteFeed"
onsubmit={(event) =>
!confirm('Feed und seinen Importeingang löschen?') && event.preventDefault()}
>
<input type="hidden" name="feedId" value={feed.id} /><button
class="text-button danger-text"
type="submit">Feed löschen</button
>
</form>
</div>
</details>
{/each}
</div>
</section>
<section class="quiet-panel integration-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Externe Assistenz</p>
<h2>Technische Zugänge</h2>
</div>
<a href={resolve('/api/v1/openapi.json')} target="_blank">OpenAPI</a>
</div>
<p>
Tokens gelten ausschließlich für die API. Schreibende Aufrufe werden protokolliert und müssen
idempotent sein.
</p>
{#if form?.technicalToken}<div class="token-reveal" role="status">
<strong>Token, einmalig sichtbar</strong><code>{form.technicalToken}</code>
</div>{/if}
<form class="integration-form" method="POST" action="?/createTechnicalAccess">
<label
>Name<input name="name" required maxlength="120" placeholder="Redaktionsassistenz" /></label
>
<label
>Berechtigung<select name="permissions"
><option value="read">Nur lesen</option><option value="read_write"
>Lesen und schreiben</option
></select
></label
>
<button class="primary-button" type="submit">Zugang anlegen</button>
</form>
<div class="integration-list">
{#each data.technicalAccesses as access (access.id)}
<div class="access-row">
<span
><strong>{access.name}</strong><small
>{access.permissions === 'read' ? 'Nur lesen' : 'Lesen und schreiben'} · {access.revokedAtUtc
? 'Widerrufen'
: access.lastUsedAtUtc
? `Zuletzt genutzt ${new Date(access.lastUsedAtUtc).toLocaleString('de-DE')}`
: 'Noch nicht genutzt'}</small
></span
>{#if !access.revokedAtUtc}<form method="POST" action="?/revokeTechnicalAccess">
<input type="hidden" name="accessId" value={access.id} /><button
class="secondary-button"
type="submit">Widerrufen</button
>
</form>{/if}
</div>
{/each}
</div>
</section>
<section class="quiet-panel account-panel">
<div class="panel-heading">
@@ -272,3 +443,67 @@
</section>
</div>
</div>
<style>
.integration-panel {
display: grid;
gap: 1rem;
margin-block: 1.5rem;
}
.integration-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.8rem;
align-items: end;
}
.integration-form label:not(.check-row) {
display: grid;
gap: 0.35rem;
}
.check-row {
display: flex;
gap: 0.5rem;
align-items: center;
min-height: 2.6rem;
}
.integration-list {
display: grid;
gap: 0.5rem;
}
.integration-list details,
.access-row {
border-top: 1px solid var(--color-border);
padding-top: 0.8rem;
}
.integration-list summary,
.access-row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.integration-list summary > span,
.access-row > span {
display: grid;
gap: 0.2rem;
}
.integration-list small {
color: var(--color-text-muted);
}
.row-actions {
display: flex;
gap: 1rem;
margin-top: 0.75rem;
}
.token-reveal {
display: grid;
gap: 0.4rem;
padding: 0.8rem;
border: 2px solid var(--color-accent);
border-radius: 0.7rem;
overflow-wrap: anywhere;
}
.token-reveal code {
user-select: all;
}
</style>