KnowMore v0.6-v0.7 - Dashboard, Immich Integration & Go Migration

Published on Mar 26, 2026

KnowMore v0.6-v0.7 - Dashboard, Immich Integration & Go Migration

KnowMore v0.6 through v0.7 covers four months of work across three major themes: a customizable dashboard system, full Immich photo integration with a Go server-side proxy, and a migration from dual JS+Go hooks to a single-language Go backend. Here’s what changed and why.

Table of Contents

Customizable Dashboard (v0.6.0)

The old dashboard was a static grid of hardcoded tiles. Parents wanted to see different information depending on whether they coached, had multiple children, or followed other teams in the club. v0.6.0 replaces the static layout with a drag-and-resize dashboard powered by GridStack.js.

Tile Architecture

The dashboard uses a three-layer component hierarchy:

AuthenticatedUserDashboard defines 13 base tile types plus dynamic per-followed-team tiles. Each tile definition carries its grid dimensions, minimum height, pinned flag, and a data-driven visibility condition:

const tiles = [
    { id: 'now-next', label: m.now_next(), w: 2, h: 2, minH: 1,
      visible: !!federation?.nextMatch },
    { id: 'standings', label: m.standings(), w: 1, h: 2, minH: 2,
      visible: hasFederationData() && hasStandingsData() },
    { id: 'gallery', label: m.gallery(), w: 1, h: 2, minH: 1,
      visible: !!team?.immich_album_id },
    // ...
];

Visibility has two layers: a data prerequisite (does federation data exist? does the team have an Immich album?) and a user preference override. The prerequisite always wins — if there’s no federation data, the standings tile can’t be shown regardless of user settings.

GridStack Integration

GridDashboard wraps GridStack.js with Svelte 5 reactivity. The grid initializes with 2 columns, 80px cell height, and 6px margins. An edit mode toggle switches between static (locked) and interactive (draggable) states:

function toggleEdit() {
    editing = !editing;
    grid.setStatic(!editing);
    // Inject drag handles into grid items
    for (const el of gridEl.querySelectorAll('.grid-stack-item')) {
        el.classList.toggle('editing', editing);
    }
}

Dual Layout Storage

Different devices need different layouts. A phone might stack everything in one column; a desktop might use two columns side by side. The dashboard stores separate layouts for mobile and desktop, auto-detected at a 768px viewport breakpoint:

interface DeviceLayout {
    columns: number;
    tiles: Array<{ id: string; x: number; y: number; w: number; h: number }>;
    visibility: Record<string, boolean>;
}

Layout state persists to the users.dashboard_layout JSON field in PocketBase, keyed by device mode. Save, cancel, and reset operations serialize the current GridStack state back to this structure.

Responsive Tile Content (v0.6.1)

A 2-column grid on desktop means tiles can be 400px wide or 200px wide depending on their column span. Tiles need to adapt their content to their actual rendered size, not the viewport.

2D Breakpoints via ResizeObserver

Each tile gets a TileSizeContext attached via Svelte 5’s @attach directive. A ResizeObserver reports the tile’s pixel dimensions, which are mapped to named breakpoints:

export type WidthBreakpoint = 'narrow' | 'normal' | 'wide';
export type HeightBreakpoint = 'short' | 'normal' | 'tall';

const SHORT_HEIGHT = 35;
const TALL_HEIGHT = 190;
const NARROW_WIDTH = 200;
const WIDE_WIDTH_MOBILE = 300;
const WIDE_WIDTH_DESKTOP = 400;

export function createSizeObserver(tileSize: TileSizeContext): Attachment {
    return (el) => {
        const ro = new ResizeObserver((entries) => {
            for (const entry of entries) {
                const w = entry.contentRect.width;
                const h = entry.contentRect.height;
                tileSize.width = w;
                tileSize.height = h;
                tileSize.w = computeW(w);
                tileSize.h = computeH(h);
            }
        });
        ro.observe(el);
        return () => ro.disconnect();
    };
}

Tiles then conditionally render content based on their breakpoints. The NowNext tile, for example, shows a compact single-column layout when narrow but expands to a two-column layout with response counts and upcoming schedule when wide:

{#if tileSize.w === 'wide'}
    <div class="grid grid-cols-2 gap-2">
        <MatchCard {match} />
        <UpcomingSchedule events={upcoming} />
    </div>
{:else}
    <MatchCard {match} compact />
{/if}

The PlayerCard switches between vertical (narrow) and horizontal (wide) layouts. The Standings tile adjusts its row count based on tile height. The ChatPreview shows 2-line message content only when wide.

Chat Improvements (v0.6.1)

Two chat features also landed in v0.6.1:

Seen-by read receipts — Each message shows checkmarks when read by other users, with a modal showing the full list of readers via animated avatar popovers.

Message search — A debounced search input highlights matching text in messages and provides scroll-to navigation between results.

Immich Photo Integration (v0.7.0)

KnowMore previously connected to Immich (a self-hosted Google Photos alternative) with client-side API keys. v0.7.0 replaces this with a full server-side proxy — the largest Go package in the project at 11 files.

Why a Server-Side Proxy

Client-side Immich integration had three problems:

  1. API keys in the browser — Each user’s Immich API key was stored in PocketBase and sent to the frontend. One XSS vulnerability would expose every user’s photo library.
  2. CORS — Immich doesn’t serve CORS headers for browser requests. The old workaround was a CORS proxy endpoint that was effectively an open relay.
  3. Caching — Blob URLs from fetch responses can’t be cached by service workers. Every thumbnail was re-downloaded on every page load.

The Go proxy solves all three: API keys stay server-side, requests go through PocketBase’s domain (no CORS), and responses are standard HTTP images that service workers cache natively.

Go Package Structure

pb/immich/
  immich.go      # Register() — routes + lifecycle hooks
  client.go      # HTTP client to Immich API
  proxy.go       # Binary streaming (thumbnails, video)
  assets.go      # Search, random, download-archive
  albums.go      # Album CRUD, sharing
  people.go      # Face recognition / people API
  tags.go        # Event tagging system
  setup.go       # Admin setup wizard
  status.go      # Connection status check
  admin.go       # Admin operations (user provisioning, repair)
  immich_test.go # Tests

Route Groups

The Register() function sets up routes in four groups with different auth requirements:

func Register(app *pocketbase.PocketBase) {
    app.OnServe().BindFunc(func(se *core.ServeEvent) error {
        requireAuth := apis.RequireAuth()

        // Media proxy — no PB auth required
        // Asset UUIDs are not guessable; search endpoint (which returns IDs) requires auth
        se.Router.GET("/api/immich/assets/{id}/thumbnail", handleThumbnail(se.App))
        se.Router.GET("/api/immich/assets/{id}/preview", handlePreview(se.App))
        se.Router.GET("/api/immich/assets/{id}/video", handleVideo(se.App))

        // Search & browse — auth required
        se.Router.POST("/api/immich/assets/search", handleSearchAssets(se.App)).Bind(requireAuth)
        se.Router.GET("/api/immich/albums", handleListAlbums(se.App)).Bind(requireAuth)

        // People / face recognition — auth required
        se.Router.GET("/api/immich/people", handleListPeople(se.App)).Bind(requireAuth)
        se.Router.GET("/api/immich/people/{id}/thumbnail", handlePersonThumbnail(se.App))

        // Tags — auth required, creation is backend-only
        se.Router.GET("/api/immich/tags", handleListTags(se.App)).Bind(requireAuth)
        se.Router.POST("/api/immich/tags/ensure-event", handleEnsureEventTag(se.App)).Bind(requireAuth)

        // Admin — 14 endpoints, role-checked in handler
        se.Router.POST("/api/immich/admin/setup", handleSetup(se.App)).Bind(requireAuth)
        // ...
        return se.Next()
    })

    // Auto-create Immich account for new users
    app.OnRecordAfterCreateSuccess("users").BindFunc(func(e *core.RecordEvent) error {
        go createImmichUserAccount(e.App, e.Record)
        return e.Next()
    })

    // Auto-create Immich tags for non-training events
    registerEventTagHooks(app)
}

Media proxy endpoints (thumbnails, previews, video) skip PocketBase authentication intentionally. The Immich API key stays server-side, and asset UUIDs are v4 UUIDs — not guessable. The search endpoint that returns those UUIDs still requires auth, so unauthenticated users can’t discover asset IDs.

Data Flow

Event Tags

Events can be linked to Immich photos through hierarchical tags. When a match or tournament is synced from the federation scraper (or created manually), a Go hook auto-creates an Immich tag with the structure knowmore/{team}/{event title} {date}:

knowmore/
  Ga-Junioren/
    FC Opponent - FC Club 2026-03-15
    FC Club - FC Rival 2026-03-22
  E-Junioren/
    Tournament Winterthur 2026-04-01

Photos uploaded to an event get tagged with its event tag. The gallery can then filter by tag to show only photos from a specific match — server-side, via the Immich API, so it works regardless of how many photos are loaded on the client.

ImmichManager and TeamAlbumStore

The frontend manages Immich data through per-album stores. Each team’s album gets its own reactive TeamAlbumStore with server-side filtering, paginated loading, and ownership of activity data (likes and comments):

export class TeamAlbumStore {
    readonly albumId: string;
    readonly teamId: string | null;
    readonly teamName: string | null;

    assets = $state<GalleryAsset[]>([]);
    total = $state(0);
    page = $state(0);
    loading = $state(false);
    activeFilters = $state<SearchFilters>({});

    // Activity thumbnail indicators — preloaded once per album visit
    activityMap = $state(new Map<string, ActivitySummary>());
    activitiesLoaded = $state(false);

    get hasMore(): boolean {
        return this.page === 0 || this.assets.length < this.total;
    }

    async loadNextPage(): Promise<void> {
        if (this.loading || (this.page > 0 && !this.hasMore)) return;
        this.loading = true;
        // POST /api/immich/assets/search with filters + pagination
        // ...
    }

    setFilters(filters: Partial<SearchFilters>): void {
        const newFilters: SearchFilters = { albumId: this.albumId, ...filters };
        const newKey = filtersKey(newFilters);
        if (newKey === this.currentFiltersKey && this.page > 0) return;
        this.activeFilters = newFilters;
        this.currentFiltersKey = newKey;
        this.assets = [];
        this.page = 0;
        this.total = 0;
        // Caller is responsible for calling loadNextPage() after setFilters()
    }

    // Team-specific event tags, resolved from the global tag tree
    get tags(): AssetTag[] { ... }
    isValidTag(tagId: string): boolean { ... }

    // Social operations — all update activityMap and return fresh activities
    async preloadActivities(userId: string): Promise<void> { ... }
    async toggleLike(assetId: string, userId: string, existing: Activity[]): Promise<Activity[]> { ... }
    async addComment(assetId: string, text: string): Promise<Activity[]> { ... }
    async removeComment(activityId: string, assetId: string): Promise<Activity[]> { ... }
}

The ImmichManager singleton (immichStore) holds a plain Map<string, TeamAlbumStore> for album stores — they persist in memory across navigation so switching pages doesn’t re-fetch. A separate SvelteMap<string, string[]> called teamPhotos preloads random photo URLs per team album for the dashboard gallery tiles. Tags are loaded once during init() alongside albums and people, so the tag tree is available immediately when the gallery opens.

JSVM to Go Migration (v0.7.0)

Before v0.7.0, KnowMore had a dual-language backend: Go packages for complex features (WebAuthn, notifications, federation) and JavaScript hooks via PocketBase’s JSVM for simpler lifecycle logic (team member changes, response validation, token management, user cleanup).

This split had real costs:

  • No type safety — JS hooks used PocketBase’s runtime type system, not Go’s compiler
  • Two debug paths — Go errors showed in Go logs; JS errors showed in JSVM console output with different formatting
  • JSVM overhead — The JavaScript VM loaded on every PocketBase start, even if most logic was in Go
  • Deployment complexity — Docker images had to COPY both the compiled Go binary and the pb_hooks/ directory

The Migration

All 7 JavaScript hook files were replaced by a Go hooks package with 6 files:

pb/hooks/
  hooks.go       # Register() — orchestrator
  responses.go   # Event response validation
  routes.go      # Custom API routes
  teams.go       # Team lifecycle (member add/remove)
  tokens.go      # Token management
  users.go       # User lifecycle hooks

The Register function delegates to four sub-registrations, following the same pattern as the other Go packages:

package hooks

import "github.com/pocketbase/pocketbase"

func Register(app *pocketbase.PocketBase) {
    registerResponseHooks(app)
    registerTeamHooks(app)
    registerRoutes(app)
    registerUserHooks(app)
}

Current main.go

With the migration complete, main.go registers five Go packages and zero JSVM hooks:

import (
    "github.com/keesfluitman/knowmore/pb/federation"
    "github.com/keesfluitman/knowmore/pb/hooks"
    "github.com/keesfluitman/knowmore/pb/immich"
    "github.com/keesfluitman/knowmore/pb/notifications"
    "github.com/keesfluitman/knowmore/pb/webauthn"
)

func main() {
    app := pocketbase.New()
    // ... migrations, static file serving, health routes ...

    webauthn.Register(app)
    notifications.Register(app)
    federation.Register(app)
    immich.Register(app)
    hooks.Register(app)

    app.Start()
}

Every Go package follows the same Register(app) convention — bind hooks, routes, and cron jobs to the PocketBase instance. The shared auth package provides role-based access control used across packages:

package auth

func RequireAdmin(e *core.RequestEvent) error {
    if e.Auth == nil {
        return e.UnauthorizedError("Authentication required", nil)
    }
    if e.Auth.IsSuperuser() {
        return nil
    }
    if !IsAdmin(e.App, e.Auth.Id) {
        return e.ForbiddenError("Admin access required", nil)
    }
    return nil
}

func IsAdmin(app core.App, userId string) bool {
    roles, err := app.FindRecordsByFilter("roles",
        "user = {:userId} && role = 'admin'",
        "", 1, 0,
        map[string]any{"userId": userId},
    )
    if err != nil {
        app.Logger().Error("[auth] Failed to check admin role", "userId", userId, "error", err)
        return false
    }
    return len(roles) > 0
}

Roles are stored in a dedicated roles collection (not a field on users) — RequireAdmin checks for the admin role, RequireAdminAccess checks for admin or co_admin, and RequireSuperuser checks PocketBase’s built-in superuser flag. All functions pass through the superuser bypass first.

Benefits

  • Single-language backend — one compile step, one log format, one debug path
  • Compile-time type safety — Go catches type errors that JS would only surface at runtime
  • Simpler Docker builds — no pb_hooks/ directory to COPY; hooks compile into the binary
  • Lower startup overhead — JSVM plugin removed entirely

Security Hardening (v0.7.0)

v0.7.0 fixed 15+ security issues found during the Immich integration work. The fixes fall into three categories.

Authentication Fixes

WebAuthn session collision — Discoverable (usernameless) login stored the WebAuthn session under a static "discoverable" key. If two users started passkey login simultaneously, their sessions would overwrite each other. Fixed by using unique per-session keys generated with security.RandomString().

WebAuthn fake challenge — When a user didn’t exist, the server returned a static fake challenge ("fakechallenge0000...") to avoid revealing whether the username was valid. The static value was fingerprintable. Replaced with a randomized challenge using security.RandomString().

PRNG tokens — Several JS hooks generated tokens with Math.random(), which is not cryptographically secure. Replaced with PocketBase’s security.RandomString() (backed by crypto/rand).

Proxy and Filter Fixes

SSRF — The CORS proxy used substring matching for domain validation (url.includes("immich")). An attacker could bypass this with a URL like https://immich.evil.com. Fixed with proper url.Parse() + hostname validation.

Filter injection — Several PocketBase queries used string interpolation for user input ("field = '" + value + "'") instead of parameterized filters. Fixed across all query sites with pb.filter('field = {:value}', { value }) and Go equivalents using {:param} placeholders.

Open proxy — The iCal import proxy fetched any URL without authentication or domain restrictions. Added auth requirement + strict domain allowlist for known calendar providers.

Federation and Notification Fixes

Webhook fail-closed — The federation webhook endpoint silently accepted all requests when the SCRAPER_API_KEY environment variable was unset. Changed to fail-closed: if the key is empty, all webhook requests are rejected.

Sync mutex — Federation sync could run from three sources simultaneously (daily cron, manual admin trigger, webhook). Without coordination, this caused duplicate events. Added a mutex so only one sync runs at a time.

PWA Caching Architecture (v0.7.0)

The move from blob URLs to standard HTTP image URLs unlocked proper service worker caching. The service worker uses Workbox with route-specific strategies:

// Immich thumbnails — immutable content, cache aggressively
registerRoute(
    ({ url }) => url.pathname.match(/\/api\/immich\/assets\/[^/]+\/thumbnail/),
    new CacheFirst({
        cacheName: 'immich-thumbnails',
        plugins: [
            new CacheableResponsePlugin({ statuses: [0, 200] }),
            new ExpirationPlugin({ maxEntries: 500, maxAgeSeconds: 365 * 24 * 60 * 60 })
        ]
    })
);

// Dynamic PocketBase data — network first, fall back to cache
registerRoute(
    ({ url }) => url.pathname.match(/\/api\/collections\/(events|teams|players|responses)\/records/),
    new NetworkFirst({
        cacheName: 'pb-api',
        networkTimeoutSeconds: 3,
        plugins: [
            new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 24 * 60 * 60 })
        ]
    })
);

// Stable reference data — serve stale, revalidate in background
registerRoute(
    ({ url }) => url.pathname.match(/\/api\/collections\/(users|users_public|clubs|clubs_public|app_info)\/records/),
    new StaleWhileRevalidate({
        cacheName: 'pb-static',
        plugins: [
            new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 7 * 24 * 60 * 60 })
        ]
    })
);
RouteStrategyCacheTTLMax
Immich thumbnailsCacheFirstimmich-thumbnails1 year500
Immich previewsCacheFirstimmich-previews1 year100
People thumbnailsCacheFirstimmich-people-thumbs24h100
Video streamingNetworkOnly———
Events/teams/players/responsesNetworkFirst (3s)pb-api24h100
Users/clubs/app_infoStaleWhileRevalidatepb-static7 days50

Three-Level Cache Versioning

Caches can go stale in ways that TTL alone can’t handle — schema changes, Immich server migrations, or a bad deploy that caches broken responses. KnowMore uses three levels of cache invalidation:

  1. TTL — Automatic expiration per the table above. No action needed.
  2. Admin remote clear — An admin bumps app_info.cache_version in PocketBase. The app detects the change and sends a CLEAR_CACHES message to the service worker.
  3. Deploy-time clear — The CACHE_VERSION constant in the service worker is bumped during deployment. On activation, the SW compares against its stored version and clears all runtime caches if stale.
const CACHE_VERSION = 2;

self.addEventListener('activate', (event) => {
    event.waitUntil((async () => {
        const db = await caches.open('knowmore-meta');
        const storedResp = await db.match('cache-version');
        const storedVersion = storedResp ? parseInt(await storedResp.text(), 10) : 0;

        if (storedVersion < CACHE_VERSION) {
            const allCaches = await caches.keys();
            const runtimeCaches = allCaches.filter(
                n => n.startsWith('immich-') || n.startsWith('pb-')
            );
            await Promise.all(runtimeCaches.map(n => caches.delete(n)));
            await db.put('cache-version', new Response(String(CACHE_VERSION)));
        }
    })());
});

The old gallery fetched images via the Immich API, converted responses to blob URLs, and displayed them in <img> tags. This leaked memory (blob URLs must be manually revoked), couldn’t be cached by service workers, and required complex cleanup logic.

The new gallery uses direct <img src> URLs pointing to the Go proxy:

<img
    src="/api/immich/assets/{asset.id}/thumbnail"
    loading="lazy"
    alt=""
/>

The browser handles caching natively (service worker intercepts the request), loading="lazy" defers off-screen images, and there’s nothing to clean up on unmount. All filters (person, tag, type, date range) go server-side to Immich — the client never filters a local array, so results are always accurate regardless of how many pages are loaded.

In v0.7.3 the gallery was split into two routes. /gallery shows a team album overview tile grid for clubs with multiple teams. /gallery/[albumId] is the full gallery for one team’s album, with the album ID in the path instead of as a query parameter. This made “switching albums” a navigation event rather than a filter state change, which eliminated a class of Svelte 5 reactive bugs where goto() was being called from inside $effect bodies.

What’s Next

The statistics page needs a rebuild — the current SQL views still reference patterns from before the team-owns-players relationship change. The federation calendar navigation is getting improvements so coaches can jump between rounds without returning to the standings page.


This is the seventh post in the KnowMore series. For the architecture overview, see Building KnowMore. For the real-time data system, see KnowMore Real-Time Data Loading. For the previous release, see KnowMore v0.4.1.