f1cfb61d13
Security hardening (CRITICAL/HIGH from production-readiness audit):
- Require strong JWT_SECRET + separate INTEGRATION_ENCRYPTION_KEY at boot;
refuse placeholder defaults. Integration key now derived via HKDF.
- SSRF guard (src/lib/server/utils/safeFetch.ts): DNS-resolves and rejects
RFC1918/loopback/link-local/IPv4-mapped IPv6/decimal-IP/cloud-metadata.
Manual redirect handling re-validates each 3xx Location hop. Applied to
healthcheck, RSS, calendar, metric, system-stats, camera, notifications,
discovery, apps/preview, and all integration clients.
- API tokens, session refresh tokens, invite tokens, password-reset tokens
switched from bcrypt to sha256 with @unique indexed lookup (O(1) instead
of O(N) bcrypt-compares; eliminates a trivial DoS).
- Refresh-token reuse detection via Session.previousTokenHash.
- Permission checks on App PATCH/DELETE and Widget/Section endpoints.
- /api/integrations/alerts now requires auth.
- SVG uploads sanitized through DOMPurify (svg profile, scheme allow-list).
- Custom CSS sanitizer + selector scoping (decodes CSS unicode escapes
before pattern match, drops forbidden at-rules incl. @import without
whitespace, strips dangerous url() args). Scoped to .custom-css-scope.
- Backup restore validates SQLite magic header, takes a safety snapshot,
uses atomic rename, re-applies pragmas.
- SQLite WAL + busy_timeout + foreign_keys + synchronous=NORMAL at startup.
- Healthcheck scheduler was dead code; wired in hooks.server.ts with
HMR-safe singleton, concurrency cap, overlap prevention, retention jobs
for AppClick/Notification/AuditLog. Composite indexes added on hot paths.
- Security headers (CSP, HSTS-on-https, X-Frame-Options, Permissions-Policy)
emitted on every response.
- Account-enumeration mitigation on login (dummy bcrypt on no-user/oauth
branches) + rate limiting on login/register/onboarding/refresh/invite/
password-reset.
- OAuth callback sanitizes IdP error_description before echoing.
New features:
- Custom +error.svelte pages (root + boards + admin) via shared
ErrorState component. Inverted hierarchy (status as label, title as hero).
- /forgot-password + /reset-password + admin-mediated /admin/password-resets
page. SHA256 tokens, 24h TTL, all sessions revoked on apply.
- /invite page for manual invite-token redemption.
- /api/metrics Prometheus exposition with optional METRICS_TOKEN bearer
auth. Counters for login/healthcheck/notification/integration; gauges
for users/boards/apps + per-status app counts.
- Webhook HMAC-SHA256 signing for HTTP notification channels (optional
shared secret + configurable signature header, default X-Signature-256).
- PATCH /api/users/me/password for self-service password change.
- Persistent uploads at /app/data/uploads with served-from-volume handler
at /uploads/[...path]. SVGs served with CSP: sandbox.
- /api/health does a DB ping; returns 503 on disconnect.
- Public /status filtered to guest-accessible-board apps when unauthenticated.
- Audit log coverage: LOGIN_SUCCESS/FAILED, LOGOUT, OAUTH_LOGIN,
OAUTH_USER_PROVISIONED, SESSION_REVOKED, API_TOKEN_*, INVITE_*,
APP_UPDATED, PASSWORD_CHANGED, PASSWORD_RESET_*.
Performance:
- Board page: removed double findAll() over-fetch; include links + appTags
in board query; widgets lazy-loaded via dynamic imports (marked,
DOMPurify, hls.js, integration renderers).
- uptimeService.getAllAppsUptime: single batched query instead of N+1.
- 30s in-memory user-locals cache; invalidated on user mutation.
- pruneOldStatuses: single window-function DELETE instead of N+1.
Code quality:
- Typed error classes (NotFoundError, PermissionError, RateLimitError,
IntegrationError) with toHttpError mapper.
- Locals.user shape exposes avatarUrl and narrows role via guard.
- App input types derived from Zod schemas via z.infer.
- 274 tests passing (up from 212); 62 new tests covering SSRF guard,
CSS sanitizer, SVG sanitizer, rate limiter.
CI / Docker / config:
- Test workflow adds build, docker-build, audit jobs. Release workflow
uses buildx multi-arch (amd64+arm64) with provenance + SBOM.
- Dockerfile uses tini, multi-stage prune, persistent uploads dir, single
prisma migrate deploy (no destructive db push fallback).
- docker-compose: JWT_SECRET + INTEGRATION_ENCRYPTION_KEY required at
startup, log rotation, resource limits.
- README documents breaking-change upgrade path.
Bug fixes from UI/UX review:
- ~55 missing i18n keys added to en/ru (auth flows, error pages, admin
nav, register invite banner, settings.card_style).
- Hardcoded English on login replaced with $t('auth.remember_me').
- Admin nav uses i18n keys; mobile horizontal-scroll layout.
- Page <title> tags standardized.
- Password-resets: separated error/info/success surfaces, ConfirmDialog
replaces window.confirm.
- Auth pages have matching lucide icon badges.
- Webhook secret has eye toggle and monospace input.
- text-green-500 → text-emerald-500 to match codebase convention.
Pre-existing CI lint failures cleaned up (31 errors → 0): each-key
attributes added, unused-svelte-ignore comments removed, two any casts
typed, dead skeleton components removed, /boards/[id]/edit redirect to
inline edit mode.
Tests: 274 / 274 passing
Type check: 0 errors / 0 warnings
Build: green
186 lines
4.6 KiB
TypeScript
186 lines
4.6 KiB
TypeScript
/**
|
|
* RSS/Atom feed service — fetches and parses RSS/Atom feeds.
|
|
* Uses lightweight XML parsing without heavy dependencies.
|
|
*/
|
|
import { safeFetch } from '$lib/server/utils/safeFetch.js';
|
|
|
|
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
const DEFAULT_MAX_ITEMS = 10;
|
|
|
|
interface CacheEntry {
|
|
readonly data: readonly FeedItem[];
|
|
readonly expiresAt: number;
|
|
}
|
|
|
|
export interface FeedItem {
|
|
readonly title: string;
|
|
readonly link: string;
|
|
readonly pubDate: string;
|
|
readonly summary: string;
|
|
}
|
|
|
|
const cache = new Map<string, CacheEntry>();
|
|
|
|
function getCached(key: string): readonly FeedItem[] | null {
|
|
const entry = cache.get(key);
|
|
if (!entry) return null;
|
|
if (Date.now() > entry.expiresAt) {
|
|
cache.delete(key);
|
|
return null;
|
|
}
|
|
return entry.data;
|
|
}
|
|
|
|
function setCache(key: string, data: readonly FeedItem[]): void {
|
|
cache.set(key, {
|
|
data,
|
|
expiresAt: Date.now() + CACHE_TTL_MS
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Extract text content between XML tags.
|
|
*/
|
|
function extractTag(xml: string, tag: string): string {
|
|
// Handle CDATA sections
|
|
const cdataPattern = new RegExp(
|
|
`<${tag}[^>]*>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>\\s*</${tag}>`,
|
|
'i'
|
|
);
|
|
const cdataMatch = xml.match(cdataPattern);
|
|
if (cdataMatch) return cdataMatch[1].trim();
|
|
|
|
// Handle regular content
|
|
const pattern = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
|
|
const match = xml.match(pattern);
|
|
if (match) return match[1].trim();
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Extract href from Atom link tag.
|
|
*/
|
|
function extractAtomLink(entryXml: string): string {
|
|
// Look for link with rel="alternate" or no rel
|
|
const altMatch = entryXml.match(/<link[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["']/i);
|
|
if (altMatch) return altMatch[1];
|
|
|
|
const hrefMatch = entryXml.match(/<link[^>]*href=["']([^"']+)["']/i);
|
|
if (hrefMatch) return hrefMatch[1];
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Parse RSS 2.0 feed XML.
|
|
*/
|
|
function parseRss(xml: string, maxItems: number): readonly FeedItem[] {
|
|
const items: FeedItem[] = [];
|
|
const itemRegex = /<item>([\s\S]*?)<\/item>/gi;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = itemRegex.exec(xml)) !== null && items.length < maxItems) {
|
|
const itemXml = match[1];
|
|
items.push({
|
|
title: extractTag(itemXml, 'title') || 'Untitled',
|
|
link: extractTag(itemXml, 'link') || '',
|
|
pubDate: extractTag(itemXml, 'pubDate') || '',
|
|
summary: extractTag(itemXml, 'description') || ''
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
/**
|
|
* Parse Atom feed XML.
|
|
*/
|
|
function parseAtom(xml: string, maxItems: number): readonly FeedItem[] {
|
|
const items: FeedItem[] = [];
|
|
const entryRegex = /<entry>([\s\S]*?)<\/entry>/gi;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = entryRegex.exec(xml)) !== null && items.length < maxItems) {
|
|
const entryXml = match[1];
|
|
items.push({
|
|
title: extractTag(entryXml, 'title') || 'Untitled',
|
|
link: extractAtomLink(entryXml) || '',
|
|
pubDate: extractTag(entryXml, 'published') || extractTag(entryXml, 'updated') || '',
|
|
summary: extractTag(entryXml, 'summary') || extractTag(entryXml, 'content') || ''
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
/**
|
|
* Strip HTML tags from a string (for summaries).
|
|
*/
|
|
function stripHtml(html: string): string {
|
|
return html
|
|
.replace(/<[^>]*>/g, '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Fetch and parse an RSS or Atom feed from a URL.
|
|
*/
|
|
export async function fetchFeed(feedUrl: string, maxItems?: number): Promise<readonly FeedItem[]> {
|
|
const limit = maxItems ?? DEFAULT_MAX_ITEMS;
|
|
const cacheKey = `${feedUrl}:${limit}`;
|
|
const cached = getCached(cacheKey);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await safeFetch(feedUrl, {
|
|
timeoutMs: FETCH_TIMEOUT_MS,
|
|
headers: {
|
|
'User-Agent': 'WebAppLauncher/1.0',
|
|
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Feed returned ${response.status}`);
|
|
}
|
|
|
|
const xml = await response.text();
|
|
|
|
// Detect feed type and parse
|
|
let items: readonly FeedItem[];
|
|
if (xml.includes('<feed') && xml.includes('xmlns="http://www.w3.org/2005/Atom"')) {
|
|
items = parseAtom(xml, limit);
|
|
} else {
|
|
items = parseRss(xml, limit);
|
|
}
|
|
|
|
// Strip HTML from summaries
|
|
const cleanItems = items.map((item) => ({
|
|
...item,
|
|
summary: stripHtml(item.summary).substring(0, 500)
|
|
}));
|
|
|
|
setCache(cacheKey, cleanItems);
|
|
return cleanItems;
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
throw new Error('Feed request timed out');
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear the RSS feed cache.
|
|
*/
|
|
export function clearCache(): void {
|
|
cache.clear();
|
|
}
|