diff --git a/cmd/server/main.go b/cmd/server/main.go index 8d3a673..cd883f5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -125,7 +125,7 @@ func main() { AllowedOrigins: []string{"https://" + cfg.Host, "http://localhost:" + cfg.Port, "http://tauri.localhost", "https://tauri.localhost", "tauri://localhost"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, - ExposedHeaders: []string{"Link"}, + ExposedHeaders: []string{"Link", "X-Session-Token"}, AllowCredentials: true, MaxAge: 300, })) diff --git a/data/shared/mnemosyne.db-shm b/data/shared/mnemosyne.db-shm new file mode 100644 index 0000000..2ba6d8b Binary files /dev/null and b/data/shared/mnemosyne.db-shm differ diff --git a/data/shared/mnemosyne.db-wal b/data/shared/mnemosyne.db-wal new file mode 100644 index 0000000..43b72b6 Binary files /dev/null and b/data/shared/mnemosyne.db-wal differ diff --git a/docs/CapacitorAndroidPlan.md b/docs/CapacitorAndroidPlan.md new file mode 100644 index 0000000..f50a420 --- /dev/null +++ b/docs/CapacitorAndroidPlan.md @@ -0,0 +1,369 @@ +# Capacitor Android App — Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Wrap the existing dumpsterChat Vite/React PWA in a Capacitor shell and publish to Google Play. + +**Architecture:** Capacitor loads the Vite build output as local assets in an Android WebView. `@capacitor/core` bridges native APIs (push, status bar, etc.). No UI rewrite — the web app IS the app. + +**Tech Stack:** Vite, React 18, Capacitor 6, FCM (push), Gradle (Android build) + +--- + +### Phase 1: Capacitor Init + +#### Task 1: Add Capacitor dependencies + +**Objective:** Install Capacitor core + CLI in the web project. + +**Files:** +- Modify: `web/package.json` + +**Steps:** + +```bash +cd web +npm install @capacitor/core @capacitor/cli @capacitor/android +``` + +Then init Capacitor: + +```bash +npx cap init "Dumpster Chat" "coffee.dustin.dumpster" --web-dir dist +``` + +This creates `capacitor.config.ts` at the web root. + +**Verify:** `cat capacitor.config.ts` shows appId `coffee.dustin.dumpster`, webDir `dist`. + +--- + +#### Task 2: Configure capacitor.config.ts + +**Objective:** Set server URL for dev, configure Android-specific settings. + +**Files:** +- Modify: `web/capacitor.config.ts` + +**Content:** + +```ts +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'coffee.dustin.dumpster', + appName: 'Dumpster Chat', + webDir: 'dist', + server: { + // ponytail: no server.url — serve local assets. API calls go to absolute URL from api.ts. + androidScheme: 'https', // cookies work over https scheme in WebView + }, + plugins: { + PushNotifications: { + presentationOptions: ['badge', 'sound', 'alert'], + }, + }, +}; + +export default config; +``` + +**Key decisions:** +- `androidScheme: 'https'` makes `credentials: 'include'` cookies work in the WebView (http scheme blocks them). +- No `server.url` — local assets load from the APK, not from the web. Faster, works offline. + +--- + +#### Task 3: Add Android platform + +**Objective:** Generate the native Android project. + +**Files:** +- Create: `web/android/` (generated by Capacitor) + +**Steps:** + +```bash +cd web +npx cap add android +``` + +**Verify:** `ls web/android/app/src/main/AndroidManifest.xml` exists. + +--- + +### Phase 2: API Client Fix + +#### Task 4: Update API base URL for native + +**Objective:** When running in Capacitor, API calls need an absolute URL (no origin in a WebView). Keep relative paths for web/PWA. + +**Files:** +- Modify: `web/src/lib/api.ts` + +**Changes:** + +```ts +import { Capacitor } from '@capacitor/core'; + +// ponytail: single switch. native = absolute URL, web = relative (Caddy same-origin). +const API_BASE = Capacitor.isNativePlatform() + ? 'https://dumpster.dustin.coffee/api/v1' + : '/api/v1'; +``` + +The rest of the file stays unchanged. `Capacitor.isNativePlatform()` returns `false` in browsers and `true` in the Android WebView. + +**Skipped:** `@capacitor/http` plugin. Not needed — `androidScheme: 'https'` + absolute URL + `credentials: 'include'` works. Add the HTTP plugin only if cookies break. + +--- + +### Phase 3: Push Notifications (FCM) + +This is the only non-trivial part. VAPID web push does not work in Android WebViews. Need FCM. + +#### Task 5: Create Firebase project + +**Objective:** Set up FCM credentials for native push. + +**Steps (manual, one-time):** +1. Go to https://console.firebase.google.com +2. Create project (or use existing) named `dumpster-chat` +3. Add Android app with package name `coffee.dustin.dumpster` +4. Download `google-services.json` → place in `web/android/app/google-services.json` +5. In Firebase Console → Project Settings → Cloud Messaging → note the **Server Key** (legacy) or set up **Firebase Admin SDK** service account + +**Verify:** `google-services.json` exists in `web/android/app/`. + +--- + +#### Task 6: Add Capacitor Push Notifications plugin + +**Objective:** Register for FCM token on Android, send it to the server. + +**Files:** +- Modify: `web/package.json` (install plugin) +- Modify: `web/src/stores/push.ts` (add native branch) + +**Install:** +```bash +cd web +npm install @capacitor/push-notifications +``` + +**Modify `push.ts`** — add a native registration path alongside the existing web push: + +```ts +import { Capacitor } from '@capacitor/core'; + +// Existing web push subscribe stays as-is for PWA. +// Add native branch: +async function subscribeNative() { + const { PushNotifications } = await import('@capacitor/push-notifications'); + + const permStatus = await PushNotifications.requestPermissions(); + if (permStatus.receive !== 'granted') return; + + await PushNotifications.register(); + + // Server sends us the FCM token via this event + PushNotifications.addListener('registration', async (token) => { + await api.post('/push/subscribe', { + endpoint: 'fcm:' + token.value, // ponytail: prefix to distinguish from web push endpoints + keys: { p256dh: '', auth: '' }, // not used for FCM, but server expects the shape + }); + }); + + PushNotifications.addListener('pushNotificationReceived', (notification) => { + // Foreground notification — show in-app toast or badge + // ponytail: handled by existing in-app notification system + }); +} +``` + +Then in the existing `subscribe()` function, branch: + +```ts +if (Capacitor.isNativePlatform()) { + return subscribeNative(); +} +// ... existing web push logic +``` + +--- + +#### Task 7: Server-side FCM send support + +**Objective:** When a push subscription's endpoint starts with `fcm:`, send via FCM HTTP v1 API instead of VAPID. + +**Files:** +- Modify: `internal/push/handlers.go` + +**Changes:** +1. In `Subscribe()`: detect `fcm:` prefix on endpoint, store differently (or store as-is, the prefix distinguishes it). +2. In the send functions (`Send`, `SendToUser`): check if subscription endpoint starts with `fcm:` → use Firebase Admin SDK to send. + +**Install Go Firebase Admin:** +```bash +go get firebase.google.com/go/v4 +``` + +**Pattern:** +```go +// ponytail: one if/else in the send loop. endpoint prefix = routing key. +if strings.HasPrefix(sub.Endpoint, "fcm:") { + token := strings.TrimPrefix(sub.Endpoint, "fcm:") + msg := &messaging.Message{ + Token: token, + Notification: &messaging.Notification{ + Title: title, + Body: body, + }, + Data: map[string]string{"url": url}, + } + _, err = fcmClient.Send(ctx, msg) +} else { + // existing VAPID webpush send +} +``` + +**Config:** Add `FIREBASE_CREDENTIALS_FILE` env var (path to service account JSON) to the systemd unit / Docker compose. + +**Skipped:** Topic-based broadcast. Per-device tokens is fine for now. Add topics when channel count grows. + +--- + +### Phase 4: Gradle / Build Config + +#### Task 8: Configure Android build + +**Objective:** Set minimum SDK, app icon, theme. + +**Files:** +- Modify: `web/android/app/build.gradle` +- Modify: `web/android/app/src/main/res/values/strings.xml` + +**Changes in `build.gradle`:** +```gradle +minSdkVersion = 24 // ponytail: Android 7+ covers 99% of Play Store. lower = more compat bugs. +``` + +**App name in `strings.xml`:** +```xml +Dumpster Chat +``` + +**App icon:** Copy existing PWA icons into Android mipmap directories: +```bash +# Capacitor can sync icons automatically if placed at web/public/icon.png (1024x1024) +# or manually: web/android/app/src/main/res/mipmap-*/ +npx cap assets generate # if a 1024x1024 source icon exists +``` + +--- + +### Phase 5: Build & Publish + +#### Task 9: Sync and build debug APK + +**Objective:** Verify the app runs on a real device or emulator. + +**Steps:** +```bash +cd web +npm run build # builds Vite → dist/ +npx cap sync android # copies dist/ into android/assets, syncs plugins +cd android +./gradlew assembleDebug +``` + +**Output:** `web/android/app/build/outputs/apk/debug/app-debug.apk` + +**Verify:** Install on Android device: +```bash +adb install app-debug.apk +``` + +--- + +#### Task 10: Build signed release AAB for Play Store + +**Objective:** Create a signed Android App Bundle (.aab) for Google Play upload. + +**Steps:** +1. Generate keystore (one-time): +```bash +keytool -genkey -v -keystore dumpster-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias dumpster +``` +Store `dumpster-release.jks` securely. Back it up. Lose it = can't update the app. + +2. Add signing config to `web/android/app/build.gradle`: +```gradle +android { + signingConfigs { + release { + storeFile file('dumpster-release.jks') + storePassword System.getenv('KEYSTORE_PASSWORD') + keyAlias 'dumpster' + keyPassword System.getenv('KEY_PASSWORD') + } + } + buildTypes { + release { + signingConfig signingConfigs.release + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } +} +``` + +3. Build AAB: +```bash +cd web/android +KEYSTORE_PASSWORD=xxx KEY_PASSWORD=xxx ./gradlew bundleRelease +``` + +**Output:** `web/android/app/build/outputs/bundle/release/app-release.aab` + +4. Upload to Google Play Console → your new developer account → Create app → Upload AAB. + +--- + +#### Task 11: Clean up Tauri dependencies + +**Objective:** Remove unused Tauri packages (pivoted away from Tauri). + +**Files:** +- Modify: `web/package.json` + +**Steps:** +```bash +cd web +npm uninstall @tauri-apps/api @tauri-apps/cli +``` + +--- + +### Summary + +| Phase | What | Time estimate | +|-------|------|---------------| +| 1 | Capacitor init + Android platform | 10 min | +| 2 | API base URL native branch | 5 min | +| 3 | FCM push (plugin + server) | 1-2 hrs (incl. Firebase setup) | +| 4 | Gradle config / icons | 15 min | +| 5 | Build, test, publish | 30 min | + +**Total:** ~2-3 hours end-to-end. Phase 3 is the only real work. + +**Dependencies between tasks:** +- Tasks 1-3 sequential (Capacitor init) +- Task 4 independent of 5-7 +- Tasks 5-7 sequential (FCM chain) +- Task 8 depends on 1-3 +- Task 9 depends on all above +- Task 10 depends on 9 +- Task 11 independent, do anytime + +**After this plan:** Update the Makefile `build` target to include `npm run build && npx cap sync android` so deploys sync native assets too. diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index 614b680..cc2c314 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -331,6 +331,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { } h.setSessionCookie(w, token) + w.Header().Set("X-Session-Token", token) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"id": userID}) } @@ -383,6 +384,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { } h.setSessionCookie(w, token) + w.Header().Set("X-Session-Token", token) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"id": userID}) } diff --git a/internal/auth/webauthn.go b/internal/auth/webauthn.go index 0d832a0..f4e5aff 100644 --- a/internal/auth/webauthn.go +++ b/internal/auth/webauthn.go @@ -346,6 +346,7 @@ func (h *WebAuthnHandler) LoginFinish(w http.ResponseWriter, r *http.Request) { Secure: true, }) + w.Header().Set("X-Session-Token", token) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "authenticated"}) } diff --git a/internal/middleware/session.go b/internal/middleware/session.go index 6a55bb7..04bb0e3 100644 --- a/internal/middleware/session.go +++ b/internal/middleware/session.go @@ -18,13 +18,23 @@ type SessionStore interface { func Session(store SessionStore, cfg *config.Config) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var token string cookie, err := r.Cookie(cfg.Session.CookieName) - if err != nil { + if err == nil { + token = cookie.Value + } else { + authHeader := r.Header.Get("Authorization") + if len(authHeader) > 7 && authHeader[:7] == "Bearer " { + token = authHeader[7:] + } + } + + if token == "" { next.ServeHTTP(w, r) return } - userID, err := store.GetUserIDByToken(r.Context(), cookie.Value) + userID, err := store.GetUserIDByToken(r.Context(), token) if err != nil { next.ServeHTTP(w, r) return diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 410569d..c960d1f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -40,6 +40,19 @@ async function request(method: string, path: string, body?: unknown): Promise const ra = response.headers.get('Retry-After'); err.retryAfter = retryAfter ?? (ra ? parseInt(ra, 10) : undefined); } + + // Auto-logout on 401 for any non-login endpoint so expired sessions + // redirect to the login page instead of showing cryptic errors. + if (response.status === 401 && !path.startsWith('/auth/login') && !path.startsWith('/auth/register')) { + // Dynamically import to avoid circular deps + import('../stores/auth.ts').then(({ useAuthStore }) => { + const state = useAuthStore.getState(); + if (state.isAuthenticated) { + useAuthStore.setState({ user: null, isAuthenticated: false, error: null }); + } + }); + } + throw err; } diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 459d132..43d1dbf 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -151,12 +151,19 @@ export const useAuthStore = create((set) => ({ set({ user, isAuthenticated: true, isLoading: false }); autoSubscribePush(); } catch (error) { + // 401 from /auth/me simply means "no active session" — not a + // user-facing error. Only surface non-auth failures. + const isAuthError = + error instanceof Error && (error as import('../lib/api.ts').ApiError).status === 401; set({ user: null, isAuthenticated: false, isLoading: false, - error: - error instanceof Error ? error.message : "Failed to fetch user", + error: isAuthError + ? null + : error instanceof Error + ? error.message + : "Failed to fetch user", }); } },