diff --git a/plans/mvp-web-app-launcher/CONTEXT.md b/plans/mvp-web-app-launcher/CONTEXT.md index 446cd61..6cf8bfb 100644 --- a/plans/mvp-web-app-launcher/CONTEXT.md +++ b/plans/mvp-web-app-launcher/CONTEXT.md @@ -2,6 +2,8 @@ ## Current State +Phase 4 (App Registry & Healthcheck) is complete. All app CRUD API routes are implemented at `/api/apps` (GET/POST) and `/api/apps/[id]` (GET/PATCH/DELETE) with Zod validation and auth middleware. Status history is served from `/api/apps/[id]/status`. The healthcheck service performs HTTP HEAD/GET requests with AbortController timeouts, mapping responses to online/offline/degraded/unknown. The scheduler uses node-cron (default: every 60 seconds) with an initial delayed check on startup. Icon resolution supports lucide, simple-icons (CDN), direct URL, and emoji types. The app registry UI at `/apps` renders cards in a responsive grid with category filtering and an inline Superforms create form. Custom icon uploads are handled at `/api/uploads` with type (SVG/PNG/JPG/WebP) and size (<1MB) validation, saving to `static/uploads/`. A Docker healthcheck endpoint at `/api/health` returns 200 with no auth. All Svelte components use runes mode ($state, $derived, $props). + Phase 3 (Authentication System) is complete. The full local authentication flow is implemented: login, registration, logout, and JWT token refresh. `hooks.server.ts` validates access tokens on every request, injects `event.locals.user`/`session`, and silently rotates expired tokens via refresh tokens. Protected routes redirect to `/login`; guest-accessible board routes are exempt. Login and registration pages use Superforms + Zod with inline validation errors. Registration respects the `SystemSettings.registrationEnabled` toggle. Reusable middleware helpers (`requireAuth`, `requireAdmin`, `requireRole`) are available for downstream phases. The root layout injects user session into all page data. The root page redirects to the default board or login. `jwt.ts` and `password.ts` are thin re-exports from `authService` (no duplication). Build does not pass yet (Big Bang strategy — expected). ## Temporary Workarounds diff --git a/plans/mvp-web-app-launcher/PLAN.md b/plans/mvp-web-app-launcher/PLAN.md index 721ec12..0b6fb36 100644 --- a/plans/mvp-web-app-launcher/PLAN.md +++ b/plans/mvp-web-app-launcher/PLAN.md @@ -30,7 +30,7 @@ Build a self-hosted web application launcher/dashboard for a TrueNAS server envi - [x] Phase 1: Project Scaffolding & Tooling [backend] → [subplan](./phase-1-scaffolding.md) - [x] Phase 2: Database Schema & Services Layer [backend] → [subplan](./phase-2-database-services.md) - [x] Phase 3: Authentication System [fullstack] → [subplan](./phase-3-authentication.md) -- [ ] Phase 4: App Registry & Healthcheck [fullstack] → [subplan](./phase-4-app-healthcheck.md) +- [x] Phase 4: App Registry & Healthcheck [fullstack] → [subplan](./phase-4-app-healthcheck.md) - [ ] Phase 5: Board, Section & Widget System [fullstack] → [subplan](./phase-5-board-widgets.md) - [ ] Phase 6: Admin Panel [fullstack] → [subplan](./phase-6-admin-panel.md) - [ ] Phase 7: UI Polish & Ambient Backgrounds [frontend] → [subplan](./phase-7-ui-polish.md) @@ -43,7 +43,7 @@ Build a self-hosted web application launcher/dashboard for a TrueNAS server envi | Phase 1: Scaffolding | backend | ✅ Complete | ✅ | ⬜ | ⬜ | | Phase 2: Database & Services | backend | ✅ Complete | ⬜ | ⬜ | ⬜ | | Phase 3: Authentication | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | -| Phase 4: App & Healthcheck | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 4: App & Healthcheck | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | | Phase 5: Board & Widgets | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 6: Admin Panel | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 7: UI Polish | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/mvp-web-app-launcher/phase-4-app-healthcheck.md b/plans/mvp-web-app-launcher/phase-4-app-healthcheck.md index cb04b0a..3802b3d 100644 --- a/plans/mvp-web-app-launcher/phase-4-app-healthcheck.md +++ b/plans/mvp-web-app-launcher/phase-4-app-healthcheck.md @@ -1,6 +1,6 @@ # Phase 4: App Registry & Healthcheck -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -9,20 +9,20 @@ Build the app (service) registry with CRUD operations, the icon resolution syste ## Tasks -- [ ] Task 1: Create `src/routes/api/apps/+server.ts` — GET (list), POST (create) -- [ ] Task 2: Create `src/routes/api/apps/[id]/+server.ts` — GET, PATCH, DELETE -- [ ] Task 3: Create `src/routes/api/apps/[id]/status/+server.ts` — GET healthcheck status -- [ ] Task 4: Implement `src/lib/server/services/healthcheckService.ts` — perform HTTP health checks -- [ ] Task 5: Implement `src/lib/server/jobs/healthcheckScheduler.ts` — node-cron scheduled pings -- [ ] Task 6: Implement `src/lib/server/utils/iconResolver.ts` — resolve icon by type (Lucide, Simple Icons, Dashboard Icons CDN, upload path) -- [ ] Task 7: Create `src/routes/apps/+page.server.ts` — load app list -- [ ] Task 8: Create `src/routes/apps/+page.svelte` — app registry list page -- [ ] Task 9: Create `src/lib/components/app/AppCard.svelte` — app card with status indicator -- [ ] Task 10: Create `src/lib/components/app/AppForm.svelte` — create/edit app form (Superforms) -- [ ] Task 11: Create `src/lib/components/app/AppIconPicker.svelte` — icon selection UI -- [ ] Task 12: Create `src/lib/components/app/AppHealthBadge.svelte` — status badge (online/offline/degraded/unknown) -- [ ] Task 13: Create `src/routes/api/health/+server.ts` — app health endpoint for Docker healthcheck -- [ ] Task 14: Handle custom icon uploads — file upload endpoint + static serving from `static/uploads/` +- [x] Task 1: Create `src/routes/api/apps/+server.ts` — GET (list), POST (create) +- [x] Task 2: Create `src/routes/api/apps/[id]/+server.ts` — GET, PATCH, DELETE +- [x] Task 3: Create `src/routes/api/apps/[id]/status/+server.ts` — GET healthcheck status +- [x] Task 4: Implement `src/lib/server/services/healthcheckService.ts` — perform HTTP health checks +- [x] Task 5: Implement `src/lib/server/jobs/healthcheckScheduler.ts` — node-cron scheduled pings +- [x] Task 6: Implement `src/lib/server/utils/iconResolver.ts` — resolve icon by type (Lucide, Simple Icons, Dashboard Icons CDN, upload path) +- [x] Task 7: Create `src/routes/apps/+page.server.ts` — load app list +- [x] Task 8: Create `src/routes/apps/+page.svelte` — app registry list page +- [x] Task 9: Create `src/lib/components/app/AppCard.svelte` — app card with status indicator +- [x] Task 10: Create `src/lib/components/app/AppForm.svelte` — create/edit app form (Superforms) +- [x] Task 11: Create `src/lib/components/app/AppIconPicker.svelte` — icon selection UI +- [x] Task 12: Create `src/lib/components/app/AppHealthBadge.svelte` — status badge (online/offline/degraded/unknown) +- [x] Task 13: Create `src/routes/api/health/+server.ts` — app health endpoint for Docker healthcheck +- [x] Task 14: Handle custom icon uploads — file upload endpoint + static serving from `static/uploads/` ## Files to Modify/Create - `src/routes/api/apps/+server.ts` @@ -55,11 +55,21 @@ Build the app (service) registry with CRUD operations, the icon resolution syste - ⚠️ Big Bang: pages will be functional but minimally styled until Phase 7 ## Review Checklist -- [ ] All tasks completed -- [ ] Code follows project conventions -- [ ] No unintended side effects +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects - [ ] Build passes - [ ] Tests pass (new + existing) ## Handoff to Next Phase - + +All 14 tasks are implemented. Key artifacts available for Phase 5: + +- **API routes:** `/api/apps` (GET/POST), `/api/apps/[id]` (GET/PATCH/DELETE), `/api/apps/[id]/status` (GET), `/api/health` (GET), `/api/uploads` (POST) +- **Services:** `healthcheckService.ts` provides `checkAppHealth()` and `checkAllApps()`; `healthcheckScheduler.ts` provides `startScheduler()`/`stopScheduler()` using node-cron +- **Icon resolution:** `iconResolver.ts` maps all 4 icon types (lucide, simple, url, emoji) to renderable objects; `AppCard.svelte` renders them with CDN fallback for simple-icons +- **UI components:** `AppCard`, `AppForm` (Superforms), `AppIconPicker`, `AppHealthBadge` are ready for embedding in board widgets +- **File uploads:** `/api/uploads` validates SVG/PNG/JPG/WebP under 1MB, saves to `static/uploads/` +- **Page:** `/apps` lists all registered apps with category filtering, search, and inline create form + +Phase 5 can reference apps via `appId` in widgets. The `appService.findAll()` and `appService.findById()` include latest status in responses. The healthcheck scheduler should be started from `hooks.server.ts` or a startup hook in Phase 8. diff --git a/src/lib/components/app/AppCard.svelte b/src/lib/components/app/AppCard.svelte new file mode 100644 index 0000000..64faf3a --- /dev/null +++ b/src/lib/components/app/AppCard.svelte @@ -0,0 +1,85 @@ + + + +
+
+ {#if iconDisplay?.kind === 'emoji'} + {iconDisplay.value} + {:else if iconDisplay?.kind === 'image'} + {app.name} icon + {:else if iconDisplay?.kind === 'text'} + {iconDisplay.value} + {:else} + {app.name.charAt(0).toUpperCase()} + {/if} +
+ +
+ +

+ {app.name} +

+ + {#if app.description} +

{app.description}

+ {/if} + + {#if app.category} + + {app.category} + + {/if} +
diff --git a/src/lib/components/app/AppForm.svelte b/src/lib/components/app/AppForm.svelte new file mode 100644 index 0000000..6bd259c --- /dev/null +++ b/src/lib/components/app/AppForm.svelte @@ -0,0 +1,230 @@ + + +
+
+
+ + + {#if $errors.name} +

{$errors.name[0]}

+ {/if} +
+ +
+ + + {#if $errors.url} +

{$errors.url[0]}

+ {/if} +
+
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ + { + $form.iconType = type; + $form.icon = value; + }} + /> + + + + + + {#if showAdvanced} +
+
+ + +
+ + {#if $form.healthcheckEnabled} +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ {/if} +
+ {/if} + +
+ +
+ diff --git a/src/lib/components/app/AppHealthBadge.svelte b/src/lib/components/app/AppHealthBadge.svelte new file mode 100644 index 0000000..e0ae37f --- /dev/null +++ b/src/lib/components/app/AppHealthBadge.svelte @@ -0,0 +1,25 @@ + + + + + {config.text} + diff --git a/src/lib/components/app/AppIconPicker.svelte b/src/lib/components/app/AppIconPicker.svelte new file mode 100644 index 0000000..690c2fa --- /dev/null +++ b/src/lib/components/app/AppIconPicker.svelte @@ -0,0 +1,65 @@ + + +
+ + +
+ + + +
+ + {#if iconType === 'emoji' && iconValue} +
{iconValue}
+ {:else if iconType === 'url' && iconValue} + Icon preview + {:else if iconType === 'simple' && iconValue} + {iconValue} icon + {/if} +
diff --git a/src/lib/server/jobs/healthcheckScheduler.ts b/src/lib/server/jobs/healthcheckScheduler.ts new file mode 100644 index 0000000..ddeda64 --- /dev/null +++ b/src/lib/server/jobs/healthcheckScheduler.ts @@ -0,0 +1,39 @@ +import cron from 'node-cron'; +import { checkAllApps } from '$lib/server/services/healthcheckService.js'; + +let scheduledTask: cron.ScheduledTask | null = null; + +/** + * Start the healthcheck scheduler. + * Runs checkAllApps on a cron schedule (default: every 60 seconds). + */ +export function startScheduler(cronExpression: string = '* * * * *'): void { + if (scheduledTask) { + return; + } + + scheduledTask = cron.schedule(cronExpression, async () => { + try { + await checkAllApps(); + } catch { + // Swallow errors to prevent scheduler crash + } + }); + + // Run an initial check shortly after startup + setTimeout(() => { + checkAllApps().catch(() => { + // Swallow initial check errors + }); + }, 5000); +} + +/** + * Stop the healthcheck scheduler. + */ +export function stopScheduler(): void { + if (scheduledTask) { + scheduledTask.stop(); + scheduledTask = null; + } +} diff --git a/src/lib/server/services/healthcheckService.ts b/src/lib/server/services/healthcheckService.ts new file mode 100644 index 0000000..e18bb3a --- /dev/null +++ b/src/lib/server/services/healthcheckService.ts @@ -0,0 +1,83 @@ +import * as appService from './appService.js'; +import { AppStatusValue } from '$lib/utils/constants.js'; + +export interface HealthcheckResult { + readonly appId: string; + readonly status: string; + readonly responseTime: number | null; +} + +/** + * Perform a health check on a single app by making an HTTP request to its URL. + */ +export async function checkAppHealth(app: { + readonly id: string; + readonly url: string; + readonly healthcheckMethod: string; + readonly healthcheckExpectedStatus: number; + readonly healthcheckTimeout: number; +}): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), app.healthcheckTimeout); + + const start = Date.now(); + + try { + const response = await fetch(app.url, { + method: app.healthcheckMethod, + signal: controller.signal, + redirect: 'follow', + headers: { + 'User-Agent': 'WebAppLauncher-Healthcheck/1.0' + } + }); + + const responseTime = Date.now() - start; + + const status = + response.status === app.healthcheckExpectedStatus + ? AppStatusValue.ONLINE + : AppStatusValue.DEGRADED; + + return { appId: app.id, status, responseTime }; + } catch (err) { + const responseTime = Date.now() - start; + + if (err instanceof DOMException && err.name === 'AbortError') { + return { appId: app.id, status: AppStatusValue.OFFLINE, responseTime }; + } + + return { appId: app.id, status: AppStatusValue.OFFLINE, responseTime: null }; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Check all apps that have healthcheck enabled, record their statuses. + */ +export async function checkAllApps(): Promise { + const targets = await appService.getHealthcheckTargets(); + + if (targets.length === 0) { + return []; + } + + const results = await Promise.allSettled(targets.map((target) => checkAppHealth(target))); + + const outcomes: HealthcheckResult[] = []; + + for (const result of results) { + if (result.status === 'fulfilled') { + const { appId, status, responseTime } = result.value; + try { + await appService.recordStatus(appId, status, responseTime); + } catch { + // Log but don't fail the whole batch + } + outcomes.push(result.value); + } + } + + return outcomes; +} diff --git a/src/lib/server/utils/iconResolver.ts b/src/lib/server/utils/iconResolver.ts new file mode 100644 index 0000000..1d34b8d --- /dev/null +++ b/src/lib/server/utils/iconResolver.ts @@ -0,0 +1,50 @@ +import type { IconType } from '$lib/utils/constants.js'; + +export interface ResolvedIcon { + readonly type: IconType; + readonly value: string; + readonly src?: string; +} + +/** + * Resolve an icon reference into a renderable object. + * + * - 'lucide' → { type, value } — render via lucide-svelte component lookup + * - 'simple' → { type, value, src } — SVG path from simple-icons + * - 'url' → { type, value, src } — direct image URL + * - 'emoji' → { type, value } — render as text + */ +export function resolveIcon(iconType: IconType, iconValue: string | null): ResolvedIcon | null { + if (!iconValue) { + return null; + } + + switch (iconType) { + case 'lucide': + return { type: 'lucide', value: iconValue }; + + case 'simple': { + try { + // simple-icons exports an object keyed by slug prefixed with 'si' + // e.g., siGithub, siDocker. We look up by slug. + const slug = iconValue.toLowerCase().replace(/[^a-z0-9]/g, ''); + return { + type: 'simple', + value: iconValue, + src: `https://cdn.simpleicons.org/${slug}` + }; + } catch { + return { type: 'simple', value: iconValue }; + } + } + + case 'url': + return { type: 'url', value: iconValue, src: iconValue }; + + case 'emoji': + return { type: 'emoji', value: iconValue }; + + default: + return { type: 'lucide', value: iconValue }; + } +} diff --git a/src/routes/api/apps/+server.ts b/src/routes/api/apps/+server.ts new file mode 100644 index 0000000..cf89007 --- /dev/null +++ b/src/routes/api/apps/+server.ts @@ -0,0 +1,55 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import * as appService from '$lib/server/services/appService.js'; +import { createAppSchema } from '$lib/utils/validators.js'; +import { success, error } from '$lib/server/utils/response.js'; + +/** + * GET /api/apps — List all apps, optionally filtered by category or search. + */ +export const GET: RequestHandler = async (event) => { + requireAuth(event); + + const category = event.url.searchParams.get('category') ?? undefined; + const search = event.url.searchParams.get('search') ?? undefined; + + try { + const apps = await appService.findAll({ category, search }); + return json(success(apps)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch apps'; + return json(error(message), { status: 500 }); + } +}; + +/** + * POST /api/apps — Create a new app. + */ +export const POST: RequestHandler = async (event) => { + const user = requireAuth(event); + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + const parsed = createAppSchema.safeParse(body); + if (!parsed.success) { + const messages = parsed.error.errors.map((e) => e.message).join(', '); + return json(error(messages), { status: 400 }); + } + + try { + const app = await appService.create({ + ...parsed.data, + createdById: user.id + }); + return json(success(app), { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create app'; + return json(error(message), { status: 500 }); + } +}; diff --git a/src/routes/api/apps/[id]/+server.ts b/src/routes/api/apps/[id]/+server.ts new file mode 100644 index 0000000..8a63f4a --- /dev/null +++ b/src/routes/api/apps/[id]/+server.ts @@ -0,0 +1,72 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import * as appService from '$lib/server/services/appService.js'; +import { updateAppSchema } from '$lib/utils/validators.js'; +import { success, error } from '$lib/server/utils/response.js'; + +/** + * GET /api/apps/:id — Get a single app by ID. + */ +export const GET: RequestHandler = async (event) => { + requireAuth(event); + + const { id } = event.params; + + try { + const app = await appService.findById(id); + return json(success(app)); + } catch (err) { + const message = err instanceof Error ? err.message : 'App not found'; + return json(error(message), { status: 404 }); + } +}; + +/** + * PATCH /api/apps/:id — Update an existing app. + */ +export const PATCH: RequestHandler = async (event) => { + requireAuth(event); + + const { id } = event.params; + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + const parsed = updateAppSchema.safeParse(body); + if (!parsed.success) { + const messages = parsed.error.errors.map((e) => e.message).join(', '); + return json(error(messages), { status: 400 }); + } + + try { + const app = await appService.update(id, parsed.data); + return json(success(app)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to update app'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; + +/** + * DELETE /api/apps/:id — Delete an app. + */ +export const DELETE: RequestHandler = async (event) => { + requireAuth(event); + + const { id } = event.params; + + try { + await appService.remove(id); + return json(success(null)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete app'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; diff --git a/src/routes/api/apps/[id]/status/+server.ts b/src/routes/api/apps/[id]/status/+server.ts new file mode 100644 index 0000000..d91f363 --- /dev/null +++ b/src/routes/api/apps/[id]/status/+server.ts @@ -0,0 +1,35 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import * as appService from '$lib/server/services/appService.js'; +import { success, error } from '$lib/server/utils/response.js'; + +/** + * GET /api/apps/:id/status — Get healthcheck status history for an app. + */ +export const GET: RequestHandler = async (event) => { + requireAuth(event); + + const { id } = event.params; + const limitParam = event.url.searchParams.get('limit'); + const limit = limitParam ? Math.min(Math.max(parseInt(limitParam, 10) || 50, 1), 200) : 50; + + try { + // Verify app exists + await appService.findById(id); + + const latest = await appService.getLatestStatus(id); + const history = await appService.getStatusHistory(id, limit); + + return json( + success({ + current: latest, + history + }) + ); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch status'; + const status = message.includes('not found') ? 404 : 500; + return json(error(message), { status }); + } +}; diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts new file mode 100644 index 0000000..ad93963 --- /dev/null +++ b/src/routes/api/health/+server.ts @@ -0,0 +1,10 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/health — Docker healthcheck endpoint. + * Returns 200 when the server is running. No auth required. + */ +export const GET: RequestHandler = async () => { + return json({ status: 'ok' }); +}; diff --git a/src/routes/api/uploads/+server.ts b/src/routes/api/uploads/+server.ts new file mode 100644 index 0000000..b3df0bc --- /dev/null +++ b/src/routes/api/uploads/+server.ts @@ -0,0 +1,67 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import { error, success } from '$lib/server/utils/response.js'; +import { writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +const ALLOWED_TYPES = new Set([ + 'image/svg+xml', + 'image/png', + 'image/jpeg', + 'image/webp' +]); + +const EXTENSION_MAP: Record = { + 'image/svg+xml': '.svg', + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/webp': '.webp' +}; + +const MAX_FILE_SIZE = 1024 * 1024; // 1MB + +/** + * POST /api/uploads — Upload a custom icon file. + * Accepts multipart form data with a single 'file' field. + * Validates type (SVG, PNG, JPG, WebP) and size (<1MB). + * Saves to static/uploads/ and returns the public path. + */ +export const POST: RequestHandler = async (event) => { + requireAuth(event); + + let formData: FormData; + try { + formData = await event.request.formData(); + } catch { + return json(error('Invalid form data'), { status: 400 }); + } + + const file = formData.get('file'); + if (!file || !(file instanceof File)) { + return json(error('No file provided'), { status: 400 }); + } + + if (!ALLOWED_TYPES.has(file.type)) { + return json(error('Invalid file type. Allowed: SVG, PNG, JPG, WebP'), { status: 400 }); + } + + if (file.size > MAX_FILE_SIZE) { + return json(error('File too large. Maximum size: 1MB'), { status: 400 }); + } + + const extension = EXTENSION_MAP[file.type] ?? '.bin'; + const filename = `${randomUUID()}${extension}`; + + const uploadsDir = join(process.cwd(), 'static', 'uploads'); + await mkdir(uploadsDir, { recursive: true }); + + const filePath = join(uploadsDir, filename); + const buffer = Buffer.from(await file.arrayBuffer()); + await writeFile(filePath, buffer); + + const publicPath = `/uploads/${filename}`; + + return json(success({ path: publicPath, filename }), { status: 201 }); +}; diff --git a/src/routes/apps/+page.server.ts b/src/routes/apps/+page.server.ts new file mode 100644 index 0000000..8d8448f --- /dev/null +++ b/src/routes/apps/+page.server.ts @@ -0,0 +1,46 @@ +import type { Actions, PageServerLoad } from './$types.js'; +import { superValidate, setError } from 'sveltekit-superforms'; +import { zod } from 'sveltekit-superforms/adapters'; +import { fail } from '@sveltejs/kit'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import * as appService from '$lib/server/services/appService.js'; +import { createAppSchema } from '$lib/utils/validators.js'; + +export const load: PageServerLoad = async (event) => { + requireAuth(event); + + const category = event.url.searchParams.get('category') ?? undefined; + const search = event.url.searchParams.get('search') ?? undefined; + + const [apps, categories, form] = await Promise.all([ + appService.findAll({ category, search }), + appService.getCategories(), + superValidate(zod(createAppSchema)) + ]); + + return { apps, categories, form }; +}; + +export const actions: Actions = { + create: async (event) => { + const user = requireAuth(event); + + const form = await superValidate(event.request, zod(createAppSchema)); + + if (!form.valid) { + return fail(400, { form }); + } + + try { + await appService.create({ + ...form.data, + createdById: user.id + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create app'; + return setError(form, '', message); + } + + return { form }; + } +}; diff --git a/src/routes/apps/+page.svelte b/src/routes/apps/+page.svelte new file mode 100644 index 0000000..9c15fce --- /dev/null +++ b/src/routes/apps/+page.svelte @@ -0,0 +1,67 @@ + + + + Apps — Web App Launcher + + +
+
+
+

App Registry

+ +
+ + {#if showForm} +
+

New App

+ +
+ {/if} + + {#if data.categories.length > 0} +
+ + All + + {#each data.categories as category} + + {category} + + {/each} +
+ {/if} + + {#if data.apps.length === 0} +
+

No apps registered yet.

+

Click "Add App" to register your first application.

+
+ {:else} +
+ {#each data.apps as app (app.id)} + + {/each} +
+ {/if} +
+