Clean up: fix run scripts, remove 190+ debug print statements from 22 files, keep only FCM debugPrints for active notification work
This commit is contained in:
parent
b14e1fbfa3
commit
46566f394b
542
admin/docs/ADMIN_SYSTEM.md
Normal file
542
admin/docs/ADMIN_SYSTEM.md
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
# Sojorn Admin Panel — Comprehensive System Documentation
|
||||
|
||||
> Last updated: February 6, 2026
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Architecture](#architecture)
|
||||
3. [Authentication & Security](#authentication--security)
|
||||
4. [Server Deployment](#server-deployment)
|
||||
5. [Frontend (Next.js)](#frontend-nextjs)
|
||||
6. [Backend API Routes](#backend-api-routes)
|
||||
7. [Database Schema](#database-schema)
|
||||
8. [Feature Reference](#feature-reference)
|
||||
9. [Environment Variables](#environment-variables)
|
||||
10. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Sojorn Admin Panel is an internal tool for platform administrators to manage users, moderate content, review appeals, configure the feed algorithm, and monitor system health. It is a standalone Next.js 14 application that communicates with the existing Sojorn Go backend via a dedicated set of admin API endpoints.
|
||||
|
||||
**Key characteristics:**
|
||||
- Separate frontend deployment from the main Flutter app
|
||||
- Role-based access — only users with `role = 'admin'` in the `profiles` table can log in
|
||||
- All admin actions are logged to the `audit_log` table
|
||||
- Invisible Cloudflare Turnstile bot protection on login
|
||||
- JWT authentication with 24-hour token expiry
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐ HTTPS ┌──────────────────────┐
|
||||
│ Browser │ ◄────────────► │ Nginx │
|
||||
│ admin.sojorn.net │ │ (reverse proxy) │
|
||||
└─────────────────────┘ └──────┬───────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ port 3002 │ port 8080 │
|
||||
▼ ▼ │
|
||||
┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ Next.js 14 │ │ Go Backend │ │
|
||||
│ (sojorn-admin) │ │ (sojorn-api) │ │
|
||||
│ SSR + Static │ │ Gin framework │ │
|
||||
└─────────────────┘ └────────┬────────┘ │
|
||||
│ │
|
||||
┌────────▼────────┐ │
|
||||
│ PostgreSQL │ │
|
||||
│ sojorn database │ │
|
||||
└─────────────────┘ │
|
||||
│
|
||||
┌─────────────────┐ │
|
||||
│ Cloudflare R2 │───────┘
|
||||
│ (media storage) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| Frontend | Next.js 14, TypeScript, TailwindCSS, Recharts, Lucide icons |
|
||||
| Backend | Go 1.21+, Gin framework, pgx (PostgreSQL driver) |
|
||||
| Database | PostgreSQL 15+ |
|
||||
| Process management | systemd |
|
||||
| Reverse proxy | Nginx |
|
||||
| Bot protection | Cloudflare Turnstile (invisible mode) |
|
||||
| Auth | JWT (HS256), bcrypt password hashing |
|
||||
|
||||
---
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
### Login Flow
|
||||
|
||||
1. User enters email + password on `/login`
|
||||
2. Cloudflare Turnstile invisible widget generates a token in the background
|
||||
3. Frontend sends `POST /api/v1/admin/login` with `{ email, password, turnstile_token }`
|
||||
4. Backend verifies Turnstile token with Cloudflare API
|
||||
5. Backend checks `users` table for valid credentials (bcrypt)
|
||||
6. Backend verifies account status is `active`
|
||||
7. Backend checks `profiles.role = 'admin'`
|
||||
8. Returns JWT (`access_token`) with 24-hour expiry + user profile data
|
||||
9. Token stored in `localStorage` as `admin_token`
|
||||
|
||||
### Middleware Chain (Protected Routes)
|
||||
|
||||
All `/api/v1/admin/*` routes (except `/login`) pass through:
|
||||
|
||||
1. **AuthMiddleware** — Validates JWT, extracts `user_id` from `sub` claim, sets it in Gin context
|
||||
2. **AdminMiddleware** — Queries `profiles.role` for the authenticated user, rejects non-admin users with 403
|
||||
|
||||
### Security Measures
|
||||
|
||||
- **Invisible Turnstile** — Blocks automated login attacks without user friction
|
||||
- **Graceful degradation** — If `TURNSTILE_SECRET` is empty, verification is skipped (dev mode)
|
||||
- **bcrypt** — Passwords hashed with bcrypt (default cost)
|
||||
- **JWT expiry** — Admin tokens expire after 24 hours (vs 7 days for regular users)
|
||||
- **Auto-logout** — Frontend redirects to `/login` on any 401 response
|
||||
- **Audit logging** — Status changes, post deletions, and moderation actions are logged
|
||||
|
||||
### Granting Admin Access
|
||||
|
||||
```sql
|
||||
UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';
|
||||
```
|
||||
|
||||
Valid roles: `user`, `moderator`, `admin`
|
||||
|
||||
---
|
||||
|
||||
## Server Deployment
|
||||
|
||||
### Services
|
||||
|
||||
| Service | systemd unit | Port | Binary/Entry |
|
||||
|---------|-------------|------|-------------|
|
||||
| Go API | `sojorn-api` | 8080 | `/opt/sojorn/bin/api` |
|
||||
| Admin Frontend | `sojorn-admin` | 3002 | `node .../next start --port 3002` |
|
||||
|
||||
### File Locations on Server
|
||||
|
||||
```
|
||||
/opt/sojorn/
|
||||
├── .env # Shared environment variables
|
||||
├── bin/
|
||||
│ └── api # Compiled Go binary
|
||||
├── go-backend/ # Go source code
|
||||
│ ├── cmd/api/main.go
|
||||
│ ├── internal/
|
||||
│ │ ├── handlers/admin_handler.go
|
||||
│ │ ├── middleware/admin.go
|
||||
│ │ └── ...
|
||||
│ └── .env # Symlink or copy of /opt/sojorn/.env
|
||||
├── admin/ # Next.js admin frontend
|
||||
│ ├── .env.local # Frontend env vars
|
||||
│ ├── .next/ # Build output
|
||||
│ ├── node_modules/
|
||||
│ ├── src/
|
||||
│ │ ├── app/ # Page routes
|
||||
│ │ └── lib/ # API client, auth context
|
||||
│ └── package.json
|
||||
└── firebase-service-account.json
|
||||
```
|
||||
|
||||
### systemd Service Files
|
||||
|
||||
**`/etc/systemd/system/sojorn-admin.service`**:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Sojorn Admin Panel
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=patrick
|
||||
Group=patrick
|
||||
WorkingDirectory=/opt/sojorn/admin
|
||||
ExecStart=/usr/bin/node /opt/sojorn/admin/node_modules/next/dist/bin/next start --port 3002
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
Environment=NODE_ENV=production
|
||||
Environment=NEXT_PUBLIC_API_URL=https://api.sojorn.net
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Nginx Configuration
|
||||
|
||||
**`/etc/nginx/sites-available/sojorn-admin`**:
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.sojorn.net;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Operations
|
||||
|
||||
```bash
|
||||
# Rebuild and deploy Go backend
|
||||
cd /opt/sojorn/go-backend
|
||||
go build -ldflags='-s -w' -o /opt/sojorn/bin/api ./cmd/api/main.go
|
||||
sudo systemctl restart sojorn-api
|
||||
|
||||
# Rebuild and deploy admin frontend
|
||||
cd /opt/sojorn/admin
|
||||
npx next build
|
||||
sudo systemctl restart sojorn-admin
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u sojorn-admin -f
|
||||
sudo journalctl -u sojorn-api -f
|
||||
|
||||
# Check service status
|
||||
sudo systemctl status sojorn-admin
|
||||
sudo systemctl status sojorn-api
|
||||
|
||||
# SSL certificate (after DNS A record is pointed)
|
||||
sudo certbot --nginx -d admin.sojorn.net
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend (Next.js)
|
||||
|
||||
### Pages
|
||||
|
||||
| Route | File | Description |
|
||||
|-------|------|-------------|
|
||||
| `/` | `app/page.tsx` | Dashboard with stats cards and growth charts |
|
||||
| `/login` | `app/login/page.tsx` | Admin login with invisible Turnstile |
|
||||
| `/users` | `app/users/page.tsx` | User list with search, filter by status/role |
|
||||
| `/users/[id]` | `app/users/[id]/page.tsx` | User detail — profile, stats, admin actions |
|
||||
| `/posts` | `app/posts/page.tsx` | Post list with search, filter by status |
|
||||
| `/posts/[id]` | `app/posts/[id]/page.tsx` | Post detail — content, media, moderation flags, admin actions |
|
||||
| `/moderation` | `app/moderation/page.tsx` | Moderation queue — AI-flagged content pending review |
|
||||
| `/appeals` | `app/appeals/page.tsx` | User appeals against violations |
|
||||
| `/reports` | `app/reports/page.tsx` | User-submitted reports |
|
||||
| `/algorithm` | `app/algorithm/page.tsx` | Feed algorithm & moderation threshold tuning |
|
||||
| `/categories` | `app/categories/page.tsx` | Category management — create, edit, toggle sensitive |
|
||||
| `/system` | `app/system/page.tsx` | System health, DB stats, audit log |
|
||||
| `/settings` | `app/settings/page.tsx` | Admin session info, API URL override |
|
||||
|
||||
### Key Libraries
|
||||
|
||||
| Library | Source |
|
||||
|---------|--------|
|
||||
| `api.ts` | `src/lib/api.ts` — Singleton API client with JWT token management |
|
||||
| `auth.tsx` | `src/lib/auth.tsx` — React context provider for auth state |
|
||||
|
||||
### API Client (`src/lib/api.ts`)
|
||||
|
||||
The `ApiClient` class provides typed methods for every admin API endpoint. Key behaviors:
|
||||
|
||||
- **Token management** — Stored in `localStorage` as `admin_token`, attached as `Bearer` header
|
||||
- **Auto-logout** — Any 401 response clears token and redirects to `/login`
|
||||
- **Base URL** — Configurable via `NEXT_PUBLIC_API_URL` environment variable
|
||||
- **Error handling** — Throws `Error` with server-provided error message
|
||||
|
||||
### Auth Context (`src/lib/auth.tsx`)
|
||||
|
||||
- Wraps app in `AuthProvider`
|
||||
- On mount, validates existing token by calling `getDashboardStats()`
|
||||
- Provides `login()`, `logout()`, `isAuthenticated`, `isLoading`, `user`
|
||||
|
||||
---
|
||||
|
||||
## Backend API Routes
|
||||
|
||||
All routes prefixed with `/api/v1/admin/`.
|
||||
|
||||
### Authentication (no middleware)
|
||||
|
||||
| Method | Path | Handler | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `POST` | `/login` | `AdminLogin` | Email/password login with Turnstile verification |
|
||||
|
||||
### Dashboard (requires auth + admin)
|
||||
|
||||
| Method | Path | Handler | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `GET` | `/dashboard` | `GetDashboardStats` | User/post/moderation/appeal/report counts |
|
||||
| `GET` | `/growth?days=30` | `GetGrowthStats` | Daily user & post creation counts for charts |
|
||||
|
||||
### User Management
|
||||
|
||||
| Method | Path | Handler | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `GET` | `/users?limit=50&offset=0&search=&status=&role=` | `ListUsers` | Paginated user list with filters |
|
||||
| `GET` | `/users/:id` | `GetUser` | Full user profile with follower/post/violation counts |
|
||||
| `PATCH` | `/users/:id/status` | `UpdateUserStatus` | Set status: `active`, `suspended`, `banned`, `deactivated` |
|
||||
| `PATCH` | `/users/:id/role` | `UpdateUserRole` | Set role: `user`, `moderator`, `admin` |
|
||||
| `PATCH` | `/users/:id/verification` | `UpdateUserVerification` | Toggle `is_official` and `is_verified` flags |
|
||||
| `POST` | `/users/:id/reset-strikes` | `ResetUserStrikes` | Reset violation strike counter to 0 |
|
||||
|
||||
### Post Management
|
||||
|
||||
| Method | Path | Handler | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `GET` | `/posts?limit=50&offset=0&search=&status=&author_id=` | `ListPosts` | Paginated post list with filters |
|
||||
| `GET` | `/posts/:id` | `GetPost` | Full post detail with moderation flags |
|
||||
| `PATCH` | `/posts/:id/status` | `UpdatePostStatus` | Set status: `active`, `flagged`, `removed` |
|
||||
| `DELETE` | `/posts/:id` | `DeletePost` | Soft-delete (sets `deleted_at` + status `removed`) |
|
||||
|
||||
### Moderation Queue
|
||||
|
||||
| Method | Path | Handler | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `GET` | `/moderation?limit=50&offset=0&status=pending` | `GetModerationQueue` | AI-flagged content awaiting review |
|
||||
| `PATCH` | `/moderation/:id/review` | `ReviewModerationFlag` | Actions: `approve`, `dismiss`, `remove_content`, `ban_user` |
|
||||
|
||||
### Appeals
|
||||
|
||||
| Method | Path | Handler | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `GET` | `/appeals?limit=50&offset=0&status=pending` | `ListAppeals` | User appeals with violation details |
|
||||
| `PATCH` | `/appeals/:id/review` | `ReviewAppeal` | Decision: `approved` or `rejected`, optional content restore |
|
||||
|
||||
### Reports
|
||||
|
||||
| Method | Path | Handler | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `GET` | `/reports?limit=50&offset=0&status=pending` | `ListReports` | User-submitted reports |
|
||||
| `PATCH` | `/reports/:id` | `UpdateReportStatus` | Set status: `reviewed`, `dismissed`, `actioned` |
|
||||
|
||||
### Algorithm & Feed Config
|
||||
|
||||
| Method | Path | Handler | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `GET` | `/algorithm` | `GetAlgorithmConfig` | All key-value config pairs |
|
||||
| `PUT` | `/algorithm` | `UpdateAlgorithmConfig` | Upsert a config `{ key, value }` |
|
||||
|
||||
### Categories
|
||||
|
||||
| Method | Path | Handler | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `GET` | `/categories` | `ListCategories` | All content categories |
|
||||
| `POST` | `/categories` | `CreateCategory` | Create `{ slug, name, description?, is_sensitive? }` |
|
||||
| `PATCH` | `/categories/:id` | `UpdateCategory` | Update name, description, or sensitive flag |
|
||||
|
||||
### System
|
||||
|
||||
| Method | Path | Handler | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `GET` | `/health` | `GetSystemHealth` | DB ping, latency, connection pool stats, DB size |
|
||||
| `GET` | `/audit-log?limit=50&offset=0` | `GetAuditLog` | Admin action history with actor handles |
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Tables Created by Admin Migration
|
||||
|
||||
**`algorithm_config`** — Key-value store for feed and moderation tuning:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `key` | `TEXT PRIMARY KEY` | Config identifier |
|
||||
| `value` | `TEXT NOT NULL` | Config value |
|
||||
| `description` | `TEXT` | Human-readable description |
|
||||
| `updated_at` | `TIMESTAMPTZ` | Last modification time |
|
||||
|
||||
Default seed values:
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `feed_recency_weight` | `0.4` | Weight for post recency in feed ranking |
|
||||
| `feed_engagement_weight` | `0.3` | Weight for engagement metrics |
|
||||
| `feed_harmony_weight` | `0.2` | Weight for author harmony/trust score |
|
||||
| `feed_diversity_weight` | `0.1` | Weight for content diversity |
|
||||
| `moderation_auto_flag_threshold` | `0.7` | AI score threshold for auto-flagging |
|
||||
| `moderation_auto_remove_threshold` | `0.95` | AI score threshold for auto-removal |
|
||||
| `moderation_greed_keyword_threshold` | `0.7` | Spam/greed detection threshold |
|
||||
| `feed_max_posts_per_author` | `3` | Max posts from same author per feed page |
|
||||
| `feed_boost_mutual_follow` | `1.5` | Boost multiplier for mutual follows |
|
||||
| `feed_beacon_boost` | `1.2` | Boost multiplier for beacon posts |
|
||||
|
||||
**`audit_log`** — Admin action history:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | `UUID PRIMARY KEY` | Unique entry ID |
|
||||
| `actor_id` | `UUID` | Admin who performed the action |
|
||||
| `action` | `TEXT NOT NULL` | Action type (e.g., `post_status_change`, `admin_delete_post`) |
|
||||
| `target_type` | `TEXT NOT NULL` | Entity type: `user`, `post`, `comment`, `appeal`, `report`, `config` |
|
||||
| `target_id` | `UUID` | ID of the affected entity |
|
||||
| `details` | `TEXT` | JSON string with action-specific metadata |
|
||||
| `created_at` | `TIMESTAMPTZ` | Timestamp |
|
||||
|
||||
### Columns Ensured by Migration
|
||||
|
||||
The migration ensures these columns exist (added if missing):
|
||||
|
||||
| Table | Column | Type | Default |
|
||||
|-------|--------|------|---------|
|
||||
| `profiles` | `role` | `TEXT` | `'user'` |
|
||||
| `profiles` | `is_verified` | `BOOLEAN` | `FALSE` |
|
||||
| `profiles` | `is_private` | `BOOLEAN` | `FALSE` |
|
||||
| `users` | `status` | `TEXT` | `'active'` |
|
||||
| `users` | `last_login` | `TIMESTAMPTZ` | `NULL` |
|
||||
|
||||
### Pre-existing Tables Used by Admin
|
||||
|
||||
| Table | Admin Usage |
|
||||
|-------|-------------|
|
||||
| `users` | Login validation, status management, growth stats |
|
||||
| `profiles` | Role checks, user details, verification flags |
|
||||
| `posts` | Content listing, status changes, deletion |
|
||||
| `comments` | Moderation flag targets, appeal content restoration |
|
||||
| `moderation_flags` | Moderation queue — AI-generated flags with scores |
|
||||
| `user_violations` | Violation records linked to moderation flags |
|
||||
| `user_appeals` | Appeal records linked to violations |
|
||||
| `user_status_history` | Log of admin-initiated status changes |
|
||||
| `reports` | User-submitted reports of other users/content |
|
||||
| `categories` | Content categories managed by admins |
|
||||
| `follows` | Follower/following counts on user detail page |
|
||||
|
||||
---
|
||||
|
||||
## Feature Reference
|
||||
|
||||
### Dashboard
|
||||
|
||||
Displays real-time aggregate stats:
|
||||
- **Users**: total, active, suspended, banned, new today
|
||||
- **Posts**: total, active, flagged, removed, new today
|
||||
- **Moderation**: pending flags, reviewed flags
|
||||
- **Appeals**: pending, approved, rejected
|
||||
- **Reports**: pending count
|
||||
- **Growth charts**: Daily user & post registrations (configurable 7/30/90 day window)
|
||||
|
||||
### Moderation Workflow
|
||||
|
||||
1. **AI flagging** — Posts/comments are automatically analyzed by the `ModerationService` using OpenAI + Google Vision
|
||||
2. **Three Poisons Score** — Content is scored on Hate, Greed, Delusion dimensions
|
||||
3. **Auto-flag** — Content exceeding `moderation_auto_flag_threshold` is flagged for review
|
||||
4. **Admin review** — Admin sees flagged content in the moderation queue with scores
|
||||
5. **Actions available**:
|
||||
- **Approve** — Content is fine, dismiss the flag
|
||||
- **Dismiss** — Same as approve (flag was a false positive)
|
||||
- **Remove content** — Soft-delete the post/comment
|
||||
- **Ban user** — Ban the author and action the flag
|
||||
|
||||
### Appeal Workflow
|
||||
|
||||
1. User receives a violation (triggered by moderation)
|
||||
2. User submits an appeal with reason and context
|
||||
3. Appeal appears in admin panel with violation details, AI scores, and original content
|
||||
4. Admin reviews and decides:
|
||||
- **Approve** — Optionally restore the removed content
|
||||
- **Reject** — Violation stands, include written reasoning (min 5 chars)
|
||||
|
||||
### User Management Actions
|
||||
|
||||
- **Change status**: `active` ↔ `suspended` ↔ `banned` ↔ `deactivated` (requires reason)
|
||||
- **Change role**: `user` ↔ `moderator` ↔ `admin`
|
||||
- **Toggle verification**: `is_official` and `is_verified` badges
|
||||
- **Reset strikes**: Clear the violation counter
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Frontend (`/opt/sojorn/admin/.env.local`)
|
||||
|
||||
| Variable | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `NEXT_PUBLIC_API_URL` | `https://api.sojorn.net` | Go backend base URL |
|
||||
| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | `0x4AAAAAAC...` | Cloudflare Turnstile site key (invisible mode) |
|
||||
|
||||
### Backend (`/opt/sojorn/.env`)
|
||||
|
||||
The admin system uses these existing environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `JWT_SECRET` | Secret for signing/verifying JWT tokens |
|
||||
| `TURNSTILE_SECRET` | Cloudflare Turnstile server-side verification key |
|
||||
| `PORT` | API server port (default: `8080`) |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Admin login returns "Admin access required"
|
||||
|
||||
The user's profile doesn't have `role = 'admin'`. Fix:
|
||||
```sql
|
||||
UPDATE profiles SET role = 'admin' WHERE handle = 'your_handle';
|
||||
```
|
||||
|
||||
### Admin login returns "Invalid credentials"
|
||||
|
||||
- Verify the email matches what's in the `users` table
|
||||
- Password is validated against `encrypted_password` via bcrypt
|
||||
- Check the user's `status` is `active` (not `pending`, `suspended`, or `banned`)
|
||||
|
||||
### Admin login returns "Security verification failed"
|
||||
|
||||
Cloudflare Turnstile rejected the request. Possible causes:
|
||||
- `TURNSTILE_SECRET` in backend `.env` doesn't match the site key in frontend `.env.local`
|
||||
- The Turnstile widget hostname doesn't include `admin.sojorn.net` in Cloudflare dashboard
|
||||
- Bot or automated request without a valid Turnstile token
|
||||
|
||||
### sojorn-admin service won't start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
sudo journalctl -u sojorn-admin -n 50 --no-pager
|
||||
|
||||
# Check if port 3002 is in use
|
||||
ss -tlnp | grep 3002
|
||||
|
||||
# If port is taken, find and kill the process
|
||||
sudo fuser -k 3002/tcp
|
||||
sudo systemctl restart sojorn-admin
|
||||
```
|
||||
|
||||
### sojorn-api panics on startup
|
||||
|
||||
Check for duplicate route registrations in `cmd/api/main.go`. The Go backend will panic if two routes resolve to the same path (e.g., legacy routes conflicting with admin group routes).
|
||||
|
||||
### Frontend shows "Loading..." indefinitely
|
||||
|
||||
- Check browser console for network errors
|
||||
- Verify `NEXT_PUBLIC_API_URL` in `.env.local` is correct and reachable
|
||||
- Ensure the Go API is running: `sudo systemctl status sojorn-api`
|
||||
- Check CORS — the backend must allow the admin domain in `CORS_ORIGINS`
|
||||
|
||||
### Database migration errors
|
||||
|
||||
Run the migration manually:
|
||||
```bash
|
||||
export PGPASSWORD=your_db_password
|
||||
psql -U postgres -h localhost -d sojorn -f /opt/sojorn/go-backend/internal/database/migrations/20260206000001_admin_panel_tables.up.sql
|
||||
```
|
||||
|
||||
### PM2 conflicts
|
||||
|
||||
Port 3001 is used by another PM2-managed site on this server. The admin panel uses port **3002** to avoid conflicts. Do not change it to 3001.
|
||||
45
admin/fix_service.sh
Normal file
45
admin/fix_service.sh
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
#!/bin/bash
|
||||
# Fix and restart the sojorn-admin service
|
||||
# Run as: sudo bash /opt/sojorn/admin/fix_service.sh
|
||||
|
||||
# 1. Remove old service completely
|
||||
systemctl stop sojorn-admin 2>/dev/null
|
||||
systemctl disable sojorn-admin 2>/dev/null
|
||||
|
||||
# 2. Kill ANY process on port 3001
|
||||
fuser -k 3001/tcp 2>/dev/null
|
||||
sleep 2
|
||||
# Double check
|
||||
fuser -k 3001/tcp 2>/dev/null
|
||||
sleep 1
|
||||
|
||||
# 3. Write fresh service file with Restart=on-failure
|
||||
cat > /etc/systemd/system/sojorn-admin.service <<'SVCEOF'
|
||||
[Unit]
|
||||
Description=Sojorn Admin Panel
|
||||
After=network.target sojorn-api.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=patrick
|
||||
Group=patrick
|
||||
WorkingDirectory=/opt/sojorn/admin
|
||||
ExecStart=/usr/bin/node /opt/sojorn/admin/node_modules/next/dist/bin/next start --port 3001
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=3
|
||||
Environment=NODE_ENV=production
|
||||
Environment=NEXT_PUBLIC_API_URL=https://api.sojorn.net
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SVCEOF
|
||||
|
||||
# 4. Reload and start
|
||||
systemctl daemon-reload
|
||||
systemctl enable sojorn-admin
|
||||
systemctl start sojorn-admin
|
||||
|
||||
sleep 4
|
||||
systemctl status sojorn-admin --no-pager
|
||||
28
admin/setup_nginx.sh
Normal file
28
admin/setup_nginx.sh
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/bin/bash
|
||||
cat > /tmp/sojorn-admin.conf << 'EOF'
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.sojorn.net;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo cp /tmp/sojorn-admin.conf /etc/nginx/sites-available/sojorn-admin
|
||||
sudo ln -sf /etc/nginx/sites-available/sojorn-admin /etc/nginx/sites-enabled/sojorn-admin
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
echo "--- Nginx status ---"
|
||||
sudo systemctl status nginx --no-pager | head -5
|
||||
echo "--- Testing certbot ---"
|
||||
sudo certbot --nginx -d admin.sojorn.net --non-interactive --agree-tos --redirect -m patrick@mp.ls
|
||||
echo "--- Done ---"
|
||||
78
admin/setup_port3002.sh
Normal file
78
admin/setup_port3002.sh
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Setting up Sojorn Admin on port 3002 ==="
|
||||
|
||||
# Stop old service if running
|
||||
systemctl stop sojorn-admin 2>/dev/null || true
|
||||
systemctl disable sojorn-admin 2>/dev/null || true
|
||||
|
||||
# Write service file for port 3002
|
||||
cat > /etc/systemd/system/sojorn-admin.service <<'SVCEOF'
|
||||
[Unit]
|
||||
Description=Sojorn Admin Panel
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=patrick
|
||||
Group=patrick
|
||||
WorkingDirectory=/opt/sojorn/admin
|
||||
ExecStart=/usr/bin/node /opt/sojorn/admin/node_modules/next/dist/bin/next start --port 3002
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
Environment=NODE_ENV=production
|
||||
Environment=NEXT_PUBLIC_API_URL=https://api.sojorn.net
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SVCEOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable sojorn-admin
|
||||
systemctl start sojorn-admin
|
||||
|
||||
echo "Waiting 5s for startup..."
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "=== Service status ==="
|
||||
systemctl status sojorn-admin --no-pager
|
||||
|
||||
echo ""
|
||||
echo "=== Port check ==="
|
||||
ss -tlnp | grep 3002
|
||||
|
||||
echo ""
|
||||
echo "=== Setting up Nginx ==="
|
||||
|
||||
cat > /etc/nginx/sites-available/sojorn-admin <<'NGXEOF'
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.sojorn.net;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
NGXEOF
|
||||
|
||||
if [ ! -L /etc/nginx/sites-enabled/sojorn-admin ]; then
|
||||
ln -s /etc/nginx/sites-available/sojorn-admin /etc/nginx/sites-enabled/
|
||||
fi
|
||||
|
||||
nginx -t && systemctl reload nginx
|
||||
echo "Nginx configured for admin.sojorn.net -> port 3002"
|
||||
|
||||
echo ""
|
||||
echo "=== DONE ==="
|
||||
104
admin/setup_server.sh
Normal file
104
admin/setup_server.sh
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Cleaning up all node processes on port 3001 ==="
|
||||
|
||||
# Stop and fully remove any existing service
|
||||
systemctl stop sojorn-admin 2>/dev/null || true
|
||||
systemctl disable sojorn-admin 2>/dev/null || true
|
||||
|
||||
# Kill ALL node processes related to the admin panel
|
||||
pkill -9 -f "next start --port 3001" 2>/dev/null || true
|
||||
pkill -9 -f "dist/server/entry.mjs" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# Double-kill anything left on port 3001
|
||||
fuser -k 3001/tcp 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# Triple check
|
||||
STILL_RUNNING=$(fuser 3001/tcp 2>/dev/null || true)
|
||||
if [ -n "$STILL_RUNNING" ]; then
|
||||
echo "Force killing PIDs: $STILL_RUNNING"
|
||||
kill -9 $STILL_RUNNING 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
echo "Port 3001 status:"
|
||||
ss -tlnp | grep 3001 || echo "PORT IS FREE"
|
||||
|
||||
echo ""
|
||||
echo "=== Writing systemd service ==="
|
||||
|
||||
cat > /etc/systemd/system/sojorn-admin.service <<'SVCEOF'
|
||||
[Unit]
|
||||
Description=Sojorn Admin Panel
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=patrick
|
||||
Group=patrick
|
||||
WorkingDirectory=/opt/sojorn/admin
|
||||
ExecStart=/usr/bin/node /opt/sojorn/admin/node_modules/next/dist/bin/next start --port 3001
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
Environment=NODE_ENV=production
|
||||
Environment=NEXT_PUBLIC_API_URL=https://api.sojorn.net
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SVCEOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable sojorn-admin
|
||||
systemctl start sojorn-admin
|
||||
|
||||
echo "Waiting 5s for startup..."
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "=== Service status ==="
|
||||
systemctl status sojorn-admin --no-pager
|
||||
|
||||
echo ""
|
||||
echo "=== Port check ==="
|
||||
ss -tlnp | grep 3001
|
||||
|
||||
echo ""
|
||||
echo "=== Setting up Nginx ==="
|
||||
|
||||
cat > /etc/nginx/sites-available/sojorn-admin <<'NGXEOF'
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.sojorn.net;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
NGXEOF
|
||||
|
||||
if [ ! -L /etc/nginx/sites-enabled/sojorn-admin ]; then
|
||||
ln -s /etc/nginx/sites-available/sojorn-admin /etc/nginx/sites-enabled/
|
||||
fi
|
||||
|
||||
nginx -t && systemctl reload nginx
|
||||
echo "Nginx configured and reloaded"
|
||||
|
||||
echo ""
|
||||
echo "=== Checking Go API service ==="
|
||||
systemctl status sojorn-api --no-pager || true
|
||||
|
||||
echo ""
|
||||
echo "=== DONE ==="
|
||||
16
admin/sojorn-admin.nginx
Normal file
16
admin/sojorn-admin.nginx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name admin.sojorn.net;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
14
go-backend/scripts/add_jailed_status.sql
Normal file
14
go-backend/scripts/add_jailed_status.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-- Check if posts.status is an enum or text and add 'jailed' if needed
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Check if post_status enum exists
|
||||
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'post_status') THEN
|
||||
-- Add 'jailed' to post_status enum
|
||||
BEGIN
|
||||
ALTER TYPE post_status ADD VALUE IF NOT EXISTS 'jailed';
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- If posts.status is just text, no enum changes needed - 'jailed' will just work
|
||||
82
run_dev.ps1
82
run_dev.ps1
|
|
@ -1,59 +1,55 @@
|
|||
# run_dev.ps1 — Run Sojorn on the default connected device (mobile/emulator)
|
||||
# Usage: .\run_dev.ps1 [-EnvPath .env]
|
||||
param(
|
||||
[string]$EnvPath = (Join-Path $PSScriptRoot ".env")
|
||||
)
|
||||
|
||||
if (-not (Test-Path $EnvPath)) {
|
||||
Write-Error "Env file not found: ${EnvPath}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$values = @{}
|
||||
Get-Content $EnvPath | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if ($line.Length -eq 0) { return }
|
||||
if ($line.StartsWith('#')) { return }
|
||||
|
||||
$parts = $line -split '=', 2
|
||||
if ($parts.Count -lt 2) { return }
|
||||
|
||||
$key = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
|
||||
if ($value.StartsWith('"') -and $value.EndsWith('"')) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
function Parse-Env($path) {
|
||||
$vals = @{}
|
||||
if (-not (Test-Path $path)) {
|
||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
||||
$vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
|
||||
return $vals
|
||||
}
|
||||
|
||||
$values[$key] = $value
|
||||
Get-Content $path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if ($line.Length -eq 0 -or $line.StartsWith('#')) { return }
|
||||
$parts = $line -split '=', 2
|
||||
if ($parts.Count -lt 2) { return }
|
||||
$key = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
if ($value.StartsWith('"') -and $value.EndsWith('"')) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
$vals[$key] = $value
|
||||
}
|
||||
return $vals
|
||||
}
|
||||
|
||||
$required = @('API_BASE_URL')
|
||||
$missing = $required | Where-Object {
|
||||
-not $values.ContainsKey($_) -or [string]::IsNullOrWhiteSpace($values[$_])
|
||||
}
|
||||
$values = Parse-Env $EnvPath
|
||||
|
||||
if ($missing.Count -gt 0) {
|
||||
Write-Error "Missing required keys in ${EnvPath}: $($missing -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$defineArgs = @(
|
||||
"--dart-define=API_BASE_URL=$($values['API_BASE_URL'])"
|
||||
)
|
||||
|
||||
$optionalDefines = @(
|
||||
'FIREBASE_WEB_VAPID_KEY',
|
||||
'TURNSTILE_SITE_KEY'
|
||||
)
|
||||
|
||||
foreach ($opt in $optionalDefines) {
|
||||
if ($values.ContainsKey($opt) -and -not [string]::IsNullOrWhiteSpace($values[$opt])) {
|
||||
$defineArgs += "--dart-define=$opt=$($values[$opt])"
|
||||
$defineArgs = @()
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||
foreach ($k in $keysOfInterest) {
|
||||
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
||||
$defineArgs += "--dart-define=$k=$($values[$k])"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||
$currentApi = 'https://api.sojorn.net/api/v1'
|
||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||
} else {
|
||||
$currentApi = $values['API_BASE_URL']
|
||||
}
|
||||
|
||||
Write-Host "Launching Sojorn (device)..." -ForegroundColor Cyan
|
||||
Write-Host "API: $currentApi"
|
||||
|
||||
Push-Location (Join-Path $PSScriptRoot "sojorn_app")
|
||||
try {
|
||||
flutter run @defineArgs @Args
|
||||
Write-Host "Running: flutter run $($defineArgs -join ' ')" -ForegroundColor DarkGray
|
||||
& flutter run @defineArgs @Args
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
|
|
|
|||
42
run_web.ps1
42
run_web.ps1
|
|
@ -1,15 +1,14 @@
|
|||
# run_web.ps1 — Run Sojorn in Edge browser
|
||||
# Usage: .\run_web.ps1 [-Port 8001] [-EnvPath .env]
|
||||
param(
|
||||
[string]$EnvPath = (Join-Path $PSScriptRoot ".env"),
|
||||
[int]$Port = 8001,
|
||||
[string]$Renderer = "auto", # Options: auto, canvaskit, html
|
||||
[switch]$NoWasmDryRun
|
||||
[int]$Port = 8001
|
||||
)
|
||||
|
||||
function Parse-Env($path) {
|
||||
$vals = @{}
|
||||
if (-not (Test-Path $path)) {
|
||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
||||
# Set default API_BASE_URL since no .env exists
|
||||
$vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
|
||||
return $vals
|
||||
}
|
||||
|
|
@ -30,7 +29,6 @@ function Parse-Env($path) {
|
|||
|
||||
$values = Parse-Env $EnvPath
|
||||
|
||||
# Collect dart-defines we actually use on web.
|
||||
$defineArgs = @()
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||
foreach ($k in $keysOfInterest) {
|
||||
|
|
@ -39,47 +37,21 @@ foreach ($k in $keysOfInterest) {
|
|||
}
|
||||
}
|
||||
|
||||
# Ensure API_BASE_URL is set
|
||||
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||
$currentApi = 'https://api.sojorn.net/api/v1'
|
||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$currentApi = $values['API_BASE_URL']
|
||||
# Always ensure we're using the HTTPS endpoint
|
||||
if ($currentApi.StartsWith('http://api.sojorn.net:8080')) {
|
||||
$currentApi = $currentApi.Replace('http://api.sojorn.net:8080', 'https://api.sojorn.net')
|
||||
$defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') }
|
||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||
}
|
||||
elseif ($currentApi.StartsWith('http://localhost:')) {
|
||||
# For local development, keep localhost but warn
|
||||
Write-Host "Using local API: $currentApi" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Launching Sojorn Web..." -ForegroundColor Cyan
|
||||
Write-Host "Port: $Port"
|
||||
Write-Host "Renderer: $Renderer"
|
||||
Write-Host "API: $currentApi"
|
||||
Write-Host "Launching Sojorn Web (Edge)..." -ForegroundColor Cyan
|
||||
Write-Host "Port: $Port | API: $currentApi"
|
||||
|
||||
Push-Location (Join-Path $PSScriptRoot -ChildPath "sojorn_app")
|
||||
try {
|
||||
$cmdArgs = @(
|
||||
'run',
|
||||
'-d',
|
||||
'edge',
|
||||
'--web-hostname',
|
||||
'localhost',
|
||||
'--web-port',
|
||||
"$Port"
|
||||
)
|
||||
$cmdArgs = @('run', '-d', 'edge', '--web-hostname', 'localhost', '--web-port', "$Port")
|
||||
$cmdArgs += $defineArgs
|
||||
|
||||
if ($NoWasmDryRun) {
|
||||
$cmdArgs += '--no-wasm-dry-run'
|
||||
}
|
||||
|
||||
Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray
|
||||
& flutter $cmdArgs
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
# run_web_chrome.ps1 — Run Sojorn in Chrome browser
|
||||
# Usage: .\run_web_chrome.ps1 [-Port 8002] [-EnvPath .env]
|
||||
param(
|
||||
[string]$EnvPath = (Join-Path $PSScriptRoot ".env"),
|
||||
[int]$Port = 8002,
|
||||
[string]$Renderer = "auto", # Options: auto, canvaskit, html
|
||||
[switch]$NoWasmDryRun
|
||||
[int]$Port = 8002
|
||||
)
|
||||
|
||||
function Parse-Env($path) {
|
||||
$vals = @{}
|
||||
if (-not (Test-Path $path)) {
|
||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
||||
# Set default API_BASE_URL since no .env exists
|
||||
$vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
|
||||
return $vals
|
||||
}
|
||||
|
|
@ -30,7 +29,6 @@ function Parse-Env($path) {
|
|||
|
||||
$values = Parse-Env $EnvPath
|
||||
|
||||
# Collect dart-defines we actually use on web.
|
||||
$defineArgs = @()
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||
foreach ($k in $keysOfInterest) {
|
||||
|
|
@ -39,47 +37,21 @@ foreach ($k in $keysOfInterest) {
|
|||
}
|
||||
}
|
||||
|
||||
# Ensure API_BASE_URL is set
|
||||
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||
$currentApi = 'https://api.sojorn.net/api/v1'
|
||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$currentApi = $values['API_BASE_URL']
|
||||
# Always ensure we're using the HTTPS endpoint
|
||||
if ($currentApi.StartsWith('http://api.sojorn.net:8080')) {
|
||||
$currentApi = $currentApi.Replace('http://api.sojorn.net:8080', 'https://api.sojorn.net')
|
||||
$defineArgs = $defineArgs | Where-Object { -not ($_ -like '--dart-define=API_BASE_URL=*') }
|
||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||
}
|
||||
elseif ($currentApi.StartsWith('http://localhost:')) {
|
||||
# For local development, keep localhost but warn
|
||||
Write-Host "Using local API: $currentApi" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Launching Sojorn Web (Chrome)..." -ForegroundColor Cyan
|
||||
Write-Host "Port: $Port"
|
||||
Write-Host "Renderer: $Renderer"
|
||||
Write-Host "API: $currentApi"
|
||||
Write-Host "Port: $Port | API: $currentApi"
|
||||
|
||||
Push-Location (Join-Path $PSScriptRoot -ChildPath "sojorn_app")
|
||||
try {
|
||||
$cmdArgs = @(
|
||||
'run',
|
||||
'-d',
|
||||
'chrome',
|
||||
'--web-hostname',
|
||||
'localhost',
|
||||
'--web-port',
|
||||
"$Port"
|
||||
)
|
||||
$cmdArgs = @('run', '-d', 'chrome', '--web-hostname', 'localhost', '--web-port', "$Port")
|
||||
$cmdArgs += $defineArgs
|
||||
|
||||
if ($NoWasmDryRun) {
|
||||
$cmdArgs += '--no-wasm-dry-run'
|
||||
}
|
||||
|
||||
Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray
|
||||
& flutter $cmdArgs
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
|
|||
videoFile,
|
||||
onProgress: (p) => state = state.copyWith(progress: 0.1 + (p * 0.4)),
|
||||
);
|
||||
print('Video uploaded successfully: $videoUrl');
|
||||
|
||||
state = state.copyWith(progress: 0.5);
|
||||
|
||||
|
|
@ -99,9 +98,7 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
|
|||
thumbnail,
|
||||
onProgress: (p) => state = state.copyWith(progress: 0.5 + (p * 0.3)),
|
||||
);
|
||||
print('Thumbnail uploaded: $thumbnailUrl');
|
||||
} catch (e) {
|
||||
print('Thumbnail upload failed: $e');
|
||||
// Continue without thumbnail - video is more important
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
|||
if (!ReturnCode.isSuccess(returnCode)) {
|
||||
final logs = await session.getAllLogsAsString();
|
||||
// Print in chunks if it's too long for some logcats
|
||||
debugPrint("FFmpeg Full Log:\n$logs");
|
||||
|
||||
// Extract the last error message from logs if possible
|
||||
String errorDetail = "FFmpeg failed (Code: $returnCode)";
|
||||
|
|
@ -114,7 +113,6 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
|||
}
|
||||
|
||||
} catch (e) {
|
||||
print('Repair error details: $e'); // Log for debugging
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Repair Failed: $e')));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[Biometrics] Failed to check support: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -120,7 +120,6 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||
isLoadingDiscover = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Error loading discover data: $e');
|
||||
if (mounted) {
|
||||
setState(() => isLoadingDiscover = false);
|
||||
}
|
||||
|
|
@ -137,7 +136,6 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||
.toList();
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Error loading recent searches: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +154,6 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||
|
||||
if (mounted) setState(() {});
|
||||
} catch (e) {
|
||||
debugPrint('Error saving recent search: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -168,7 +165,6 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||
recentSearches = [];
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Error clearing recent searches: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -130,7 +130,6 @@ class _BlockedUsersScreenState extends ConsumerState<BlockedUsersScreen> {
|
|||
await apiService.callGoApi('/users/block_by_handle', method: 'POST', body: {'handle': handle});
|
||||
count++;
|
||||
} catch (e) {
|
||||
debugPrint('Failed to block $handle: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ class _QuipRecorderScreenState extends State<QuipRecorderScreen>
|
|||
|
||||
if (mounted) setState(() => _isInitializing = false);
|
||||
} catch (e) {
|
||||
debugPrint('Camera init error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +153,6 @@ class _QuipRecorderScreenState extends State<QuipRecorderScreen>
|
|||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint("Start Error: $e");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +173,6 @@ class _QuipRecorderScreenState extends State<QuipRecorderScreen>
|
|||
|
||||
// NO Auto-Navigation. Just stop.
|
||||
} catch (e) {
|
||||
debugPrint("Stop Error: $e");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -382,4 +379,4 @@ class _QuipRecorderScreenState extends State<QuipRecorderScreen>
|
|||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -249,29 +249,22 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
|||
// If we have an initialPostId, ensure it's at the top
|
||||
// If we have an initialPostId, ensure it's at the top
|
||||
if (refresh && widget.initialPostId != null) {
|
||||
print('[Quips] Handling initialPostId: ${widget.initialPostId}');
|
||||
final existingIndex = items.indexWhere((q) => q.id == widget.initialPostId);
|
||||
if (existingIndex != -1) {
|
||||
print('[Quips] Found initialPostId in feed at index $existingIndex, moving to top');
|
||||
final initial = items.removeAt(existingIndex);
|
||||
items.insert(0, initial);
|
||||
} else {
|
||||
print('[Quips] initialPostId NOT in feed, fetching specifically...');
|
||||
try {
|
||||
final postData = await api.callGoApi('/posts/${widget.initialPostId}', method: 'GET');
|
||||
if (postData['post'] != null) {
|
||||
final quip = Quip.fromMap(postData['post'] as Map<String, dynamic>);
|
||||
if (quip.videoUrl.isNotEmpty) {
|
||||
print('[Quips] Successfully fetched initial quip: ${quip.videoUrl}');
|
||||
items.insert(0, quip);
|
||||
} else {
|
||||
print('[Quips] Fetched post is not a video: ${quip.videoUrl}');
|
||||
}
|
||||
} else {
|
||||
print('[Quips] No post found for initialPostId: ${widget.initialPostId}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('Initial quip fetch error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
.toList();
|
||||
});
|
||||
} catch (e) {
|
||||
print('Error loading recent searches: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +123,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
|
||||
if (mounted) setState(() {});
|
||||
} catch (e) {
|
||||
print('Error saving recent search: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +134,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
recentSearches = [];
|
||||
});
|
||||
} catch (e) {
|
||||
print('Error clearing recent searches: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -171,15 +168,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
});
|
||||
|
||||
try {
|
||||
print('[SearchScreen] Requesting search for: "$normalizedQuery"');
|
||||
final apiService = ref.read(apiServiceProvider);
|
||||
final searchResults = await apiService.search(normalizedQuery);
|
||||
if (!mounted || requestId != _searchEpoch) {
|
||||
print('[SearchScreen] Request $requestId discarded (stale)');
|
||||
return;
|
||||
}
|
||||
|
||||
print('[SearchScreen] Results received. Users: ${searchResults.users.length}, Tags: ${searchResults.tags.length}, Posts: ${searchResults.posts.length}');
|
||||
|
||||
if (searchResults.users.isNotEmpty) {
|
||||
await saveRecentSearch(RecentSearch(
|
||||
|
|
@ -203,7 +197,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||
isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
print('[SearchScreen] Search error: $e');
|
||||
if (!mounted || requestId != _searchEpoch) return;
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
|||
// Also trigger immediate reload on conversation changes
|
||||
_chatService.conversationListChanges.listen((_) {
|
||||
if (mounted) {
|
||||
print('[UI] Conversation list changed in full screen - stream will update');
|
||||
setState(() {}); // Force rebuild to get latest from stream
|
||||
}
|
||||
});
|
||||
|
|
@ -56,7 +55,6 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
|||
});
|
||||
|
||||
try {
|
||||
print('[UI] Initializing secure chat full screen...');
|
||||
|
||||
// Initialize chat service with key generation if needed
|
||||
await _chatService.initialize(generateIfMissing: true);
|
||||
|
|
@ -64,9 +62,7 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
|||
// Load conversations
|
||||
await _loadConversations();
|
||||
|
||||
print('[UI] Secure chat full screen initialized successfully');
|
||||
} catch (e) {
|
||||
print('[UI] Failed to initialize secure chat: $e');
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
});
|
||||
|
|
@ -80,7 +76,6 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
|||
|
||||
Future<void> _loadConversations() async {
|
||||
try {
|
||||
print('[UI] Loading conversations...');
|
||||
final conversations = await _chatService.getConversations();
|
||||
|
||||
if (mounted) {
|
||||
|
|
@ -88,10 +83,8 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
|||
_conversations = conversations;
|
||||
_error = null;
|
||||
});
|
||||
print('[UI] Loaded ${conversations.length} conversations');
|
||||
}
|
||||
} catch (e) {
|
||||
print('[UI] Failed to load conversations: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ class _SecureChatScreenState extends State<SecureChatScreen>
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print('[DEBUG] SecureChatScreen initState - UPLOAD BUTTON SHOULD BE VISIBLE');
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_messageStream = _chatService.getMessagesStream(widget.conversation.id);
|
||||
NotificationService.instance.activeConversationId = widget.conversation.id;
|
||||
|
|
|
|||
|
|
@ -33,18 +33,14 @@ class AdIntegrationService {
|
|||
return ad;
|
||||
}
|
||||
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
|
||||
debugPrint('AdIntegrationService: using cached ad for $categoryId');
|
||||
return _currentAd;
|
||||
}
|
||||
debugPrint(
|
||||
'AdIntegrationService: no sponsored post available for $categoryId');
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
|
||||
debugPrint('AdIntegrationService: fetch failed, using cached ad: $e');
|
||||
return _currentAd;
|
||||
}
|
||||
debugPrint('AdIntegrationService: fetch failed, no fallback ad: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -97,7 +93,6 @@ extension ListAdExtension on List<Post> {
|
|||
|
||||
final activeAd = ad ?? fallbackAd;
|
||||
if (activeAd == null) {
|
||||
debugPrint('AdIntegrationService: no ad available to interleave');
|
||||
return [...this];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -580,8 +580,6 @@ class ApiService {
|
|||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('[Post] Publishing: body=$body, video=$videoUrl, thumb=$thumbnailUrl');
|
||||
print('[Post] Sanitized: video=$sanitizedVideoUrl, thumb=$sanitizedThumbnailUrl');
|
||||
}
|
||||
|
||||
final data = await _callGoApi(
|
||||
|
|
@ -974,9 +972,6 @@ class ApiService {
|
|||
return SearchResults.fromJson(data);
|
||||
} catch (e, stack) {
|
||||
if (kDebugMode) {
|
||||
print('[API] Search failed for query: "$query"');
|
||||
print('Error: $e');
|
||||
print('Stack: $stack');
|
||||
}
|
||||
// Return empty results on error
|
||||
return SearchResults(users: [], tags: [], posts: []);
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ class ImageUploadService {
|
|||
request.fields['fileName'] = videoFile.path.split('/').last;
|
||||
|
||||
onProgress?.call(0.1);
|
||||
print('Starting streamed video upload for: ${videoFile.path} ($fileLength bytes)');
|
||||
|
||||
try {
|
||||
final streamedResponse = await request.send();
|
||||
|
|
@ -112,7 +111,6 @@ class ImageUploadService {
|
|||
onProgress?.call(1.0);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
print('Upload error: ${response.body}');
|
||||
final errorData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
throw UploadException(errorData['message'] ?? 'Upload failed');
|
||||
}
|
||||
|
|
@ -180,7 +178,6 @@ class ImageUploadService {
|
|||
|
||||
onProgress?.call(0.2);
|
||||
|
||||
print('Starting direct upload for: $fileName (${fileBytes.length} bytes)');
|
||||
|
||||
return _uploadBytes(
|
||||
fileBytes: fileBytes,
|
||||
|
|
@ -224,7 +221,6 @@ class ImageUploadService {
|
|||
}
|
||||
|
||||
onProgress?.call(0.2);
|
||||
print('Starting direct upload for: $safeName (${fileBytes.length} bytes)');
|
||||
|
||||
return _uploadBytes(
|
||||
fileBytes: fileBytes,
|
||||
|
|
@ -469,14 +465,12 @@ class ImageUploadService {
|
|||
|
||||
onProgress?.call(0.3);
|
||||
|
||||
print('Uploading image via R2 bridge...');
|
||||
final streamedResponse = await request.send();
|
||||
final response = await http.Response.fromStream(streamedResponse);
|
||||
|
||||
onProgress?.call(0.9);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
print('Upload error: ${response.body}');
|
||||
final errorData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final errorMsg = errorData['error'] ?? 'Unknown error';
|
||||
throw UploadException('Upload failed: $errorMsg');
|
||||
|
|
@ -486,14 +480,11 @@ class ImageUploadService {
|
|||
final signedUrl = responseData['signedUrl'] ?? responseData['signed_url'];
|
||||
final publicUrl = (signedUrl ?? responseData['publicUrl']) as String;
|
||||
|
||||
print('Upload successful! Public URL: $publicUrl');
|
||||
onProgress?.call(1.0);
|
||||
|
||||
// FORCE FIX: Ensure custom domain is used even if backend returns raw R2 URL
|
||||
return _fixR2Url(publicUrl);
|
||||
} catch (e, stack) {
|
||||
print('Upload Service Error: $e');
|
||||
print('Stack trace: $stack');
|
||||
throw UploadException(e.toString());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ class LocalKeyBackupService {
|
|||
bool includeMessages = true,
|
||||
}) async {
|
||||
try {
|
||||
print('[BACKUP] Creating encrypted backup (keys: $includeKeys, msgs: $includeMessages)...');
|
||||
|
||||
// 1. Export keys (if requested)
|
||||
Map<String, dynamic>? keyData;
|
||||
|
|
@ -48,7 +47,6 @@ class LocalKeyBackupService {
|
|||
// 1b. Export messages if requested
|
||||
List<Map<String, dynamic>>? messageData;
|
||||
if (includeMessages) {
|
||||
print('[BACKUP] Exporting messages...');
|
||||
final messages = await LocalMessageStore.instance.getAllMessageRecords();
|
||||
messageData = messages.map((m) => {
|
||||
'conversationId': m.conversationId,
|
||||
|
|
@ -61,7 +59,6 @@ class LocalKeyBackupService {
|
|||
'readAt': m.readAt?.toIso8601String(),
|
||||
'expiresAt': m.expiresAt?.toIso8601String(),
|
||||
}).toList();
|
||||
print('[BACKUP] Exported ${messages.length} messages');
|
||||
}
|
||||
|
||||
final payloadData = {
|
||||
|
|
@ -107,11 +104,9 @@ class LocalKeyBackupService {
|
|||
},
|
||||
};
|
||||
|
||||
print('[BACKUP] Backup created successfully (${secretBox.cipherText.length} bytes)');
|
||||
return backup;
|
||||
|
||||
} catch (e) {
|
||||
print('[BACKUP] Failed to create backup: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
|
@ -123,7 +118,6 @@ class LocalKeyBackupService {
|
|||
required SimpleE2EEService e2eeService,
|
||||
}) async {
|
||||
try {
|
||||
print('[BACKUP] Restoring from backup...');
|
||||
|
||||
// 1. Validate backup format
|
||||
_validateBackupFormat(backup);
|
||||
|
|
@ -157,7 +151,6 @@ class LocalKeyBackupService {
|
|||
int restoredMessages = 0;
|
||||
if (payloadData is Map && payloadData.containsKey('messages')) {
|
||||
final messages = (payloadData['messages'] as List).cast<Map<String, dynamic>>();
|
||||
print('[BACKUP] Restoring ${messages.length} messages...');
|
||||
|
||||
for (final m in messages) {
|
||||
await LocalMessageStore.instance.saveMessageRecord(LocalMessageRecord(
|
||||
|
|
@ -175,7 +168,6 @@ class LocalKeyBackupService {
|
|||
restoredMessages = messages.length;
|
||||
}
|
||||
|
||||
print('[BACKUP] Backup restored successfully');
|
||||
return {
|
||||
'success': true,
|
||||
'restored_keys': keyData != null ? (keyData['keys']?.length ?? 0) : 0,
|
||||
|
|
@ -184,7 +176,6 @@ class LocalKeyBackupService {
|
|||
};
|
||||
|
||||
} catch (e) {
|
||||
print('[BACKUP] Failed to restore backup: $e');
|
||||
if (e is ArgumentError && e.message.contains('MAC')) {
|
||||
throw Exception('Invalid password or corrupted backup file');
|
||||
}
|
||||
|
|
@ -195,7 +186,6 @@ class LocalKeyBackupService {
|
|||
/// Save backup to device file
|
||||
static Future<String> saveBackupToDevice(Map<String, dynamic> backup) async {
|
||||
try {
|
||||
print('[BACKUP] Saving backup to device...');
|
||||
|
||||
// Web implementation - download file
|
||||
if (kIsWeb) {
|
||||
|
|
@ -215,7 +205,6 @@ class LocalKeyBackupService {
|
|||
html.document.body?.children.remove(anchor);
|
||||
html.Url.revokeObjectUrl(url);
|
||||
|
||||
print('[BACKUP] Backup downloaded to browser: $fileName');
|
||||
return fileName;
|
||||
}
|
||||
|
||||
|
|
@ -245,11 +234,9 @@ class LocalKeyBackupService {
|
|||
final backupJson = const JsonEncoder.withIndent(' ').convert(backup);
|
||||
await file.writeAsString(backupJson);
|
||||
|
||||
print('[BACKUP] Backup saved to: ${file.path}');
|
||||
return file.path;
|
||||
|
||||
} catch (e) {
|
||||
print('[BACKUP] Failed to save backup: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
|
@ -257,7 +244,6 @@ class LocalKeyBackupService {
|
|||
/// Load backup from device file
|
||||
static Future<Map<String, dynamic>> loadBackupFromDevice() async {
|
||||
try {
|
||||
print('[BACKUP] Loading backup from device...');
|
||||
|
||||
// Web implementation - file upload
|
||||
if (kIsWeb) {
|
||||
|
|
@ -308,7 +294,6 @@ class LocalKeyBackupService {
|
|||
|
||||
// Wait for file to be processed
|
||||
final backup = await completer.future;
|
||||
print('[BACKUP] Backup loaded from browser upload');
|
||||
return backup;
|
||||
}
|
||||
|
||||
|
|
@ -336,11 +321,9 @@ class LocalKeyBackupService {
|
|||
final content = await file.readAsString();
|
||||
final backup = jsonDecode(content) as Map<String, dynamic>;
|
||||
|
||||
print('[BACKUP] Backup loaded from: ${file.path}');
|
||||
return backup;
|
||||
|
||||
} catch (e) {
|
||||
print('[BACKUP] Failed to load backup: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
|
@ -407,7 +390,6 @@ class LocalKeyBackupService {
|
|||
static Future<void> uploadToCloud({
|
||||
required Map<String, dynamic> backup,
|
||||
}) async {
|
||||
print('[BACKUP] Uploading to cloud...');
|
||||
|
||||
// Get device name
|
||||
String deviceName = 'Unknown Device';
|
||||
|
|
@ -432,7 +414,6 @@ class LocalKeyBackupService {
|
|||
deviceName: deviceName,
|
||||
version: 1, // Currently hardcoded version
|
||||
);
|
||||
print('[BACKUP] Upload successful');
|
||||
}
|
||||
|
||||
/// Restore from cloud backup
|
||||
|
|
@ -441,7 +422,6 @@ class LocalKeyBackupService {
|
|||
required SimpleE2EEService e2eeService,
|
||||
String? backupId,
|
||||
}) async {
|
||||
print('[BACKUP] Downloading from cloud...');
|
||||
final backupData = await ApiService.instance.downloadBackup(backupId);
|
||||
|
||||
if (backupData == null) {
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ class LocalMessageStore {
|
|||
return _cachedKey!;
|
||||
}
|
||||
} catch (e) {
|
||||
print('[LOCAL_STORE] Warning: Could not read encryption key: $e');
|
||||
}
|
||||
|
||||
final bytes = _generateRandomBytes(32);
|
||||
|
|
@ -91,7 +90,6 @@ class LocalMessageStore {
|
|||
return _cachedKey!;
|
||||
} catch (e) {
|
||||
if (attempt == _maxRetries - 1) {
|
||||
print(
|
||||
'[LOCAL_STORE] Failed to store encryption key after $_maxRetries attempts: $e');
|
||||
}
|
||||
await Future.delayed(_retryDelay * (attempt + 1));
|
||||
|
|
@ -189,7 +187,6 @@ class LocalMessageStore {
|
|||
success = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
print(
|
||||
'[LOCAL_STORE] Save attempt ${attempt + 1}/$_maxRetries failed for $messageId: $e');
|
||||
if (attempt < _maxRetries - 1) {
|
||||
await Future.delayed(_retryDelay * (attempt + 1));
|
||||
|
|
@ -243,7 +240,6 @@ class LocalMessageStore {
|
|||
if (version >= 2 && storedHash != null) {
|
||||
final computedHash = _computeHash(plaintext);
|
||||
if (computedHash != storedHash) {
|
||||
print('[LOCAL_STORE] Integrity check failed for $messageId');
|
||||
return pending?.plaintext;
|
||||
}
|
||||
}
|
||||
|
|
@ -251,11 +247,9 @@ class LocalMessageStore {
|
|||
return plaintext;
|
||||
} catch (e) {
|
||||
if (e.toString().contains('SecretBoxAuthenticationError')) {
|
||||
print('[LOCAL_STORE] MAC mismatch for $messageId - record is corrupt or key changed. Deleting.');
|
||||
await _messageBox!.delete(messageId);
|
||||
return pending?.plaintext;
|
||||
}
|
||||
print(
|
||||
'[LOCAL_STORE] Read attempt ${attempt + 1}/$_maxRetries failed for $messageId: $e');
|
||||
if (attempt < _maxRetries - 1) {
|
||||
await Future.delayed(_retryDelay * (attempt + 1));
|
||||
|
|
@ -304,7 +298,6 @@ class LocalMessageStore {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[LOCAL_STORE] Batch load failed for conversation $conversationId: $e');
|
||||
}
|
||||
|
||||
// Include any pending saves from WAL
|
||||
|
|
@ -360,7 +353,6 @@ class LocalMessageStore {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[LOCAL_STORE] Batch record load failed for $conversationId: $e');
|
||||
}
|
||||
|
||||
// Include any pending saves from WAL
|
||||
|
|
@ -396,7 +388,6 @@ class LocalMessageStore {
|
|||
results.addAll(messages);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[LOCAL_STORE] Failed to get all messages: $e');
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
|
@ -427,7 +418,6 @@ class LocalMessageStore {
|
|||
try {
|
||||
return await _getConversationIndex(conversationId);
|
||||
} catch (e) {
|
||||
print('[LOCAL_STORE] Failed to get message IDs for $conversationId: $e');
|
||||
return <String>[];
|
||||
}
|
||||
}
|
||||
|
|
@ -463,7 +453,6 @@ class LocalMessageStore {
|
|||
|
||||
return true;
|
||||
} catch (e) {
|
||||
print(
|
||||
'[LOCAL_STORE] Delete attempt ${attempt + 1}/$_maxRetries failed for $messageId: $e');
|
||||
if (attempt < _maxRetries - 1) {
|
||||
await Future.delayed(_retryDelay * (attempt + 1));
|
||||
|
|
@ -508,7 +497,6 @@ class LocalMessageStore {
|
|||
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('[LOCAL_STORE] Failed to delete conversation $conversationId: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -531,7 +519,6 @@ class LocalMessageStore {
|
|||
expiresAt: pending.expiresAt,
|
||||
);
|
||||
if (success) {
|
||||
print('[LOCAL_STORE] Flushed pending write for ${entry.key}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -620,7 +607,6 @@ class LocalMessageStore {
|
|||
if (version >= 2 && storedHash != null) {
|
||||
final computedHash = _computeHash(plaintext);
|
||||
if (computedHash != storedHash) {
|
||||
print('[LOCAL_STORE] Integrity check failed for $messageId');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -648,11 +634,9 @@ class LocalMessageStore {
|
|||
);
|
||||
} catch (e) {
|
||||
if (e.toString().contains('SecretBoxAuthenticationError')) {
|
||||
print('[LOCAL_STORE] MAC mismatch for record $messageId - deleting corrupt record.');
|
||||
_messageBox!.delete(messageId);
|
||||
return null;
|
||||
}
|
||||
print('[LOCAL_STORE] Failed to parse record $messageId: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,22 +73,18 @@ class SecureChatService {
|
|||
};
|
||||
try {
|
||||
_wsChannel!.sink.add(jsonEncode(keyRecoveryEvent));
|
||||
print('[WS] Key recovery event broadcasted for user: $userId');
|
||||
} catch (e) {
|
||||
print('[WS] Failed to broadcast key recovery: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force reset to fix 208-bit key bug
|
||||
Future<void> forceResetBrokenKeys() async {
|
||||
print('[CHAT] Force resetting broken keys to fix 208-bit bug');
|
||||
await _e2ee.forceResetBrokenKeys();
|
||||
}
|
||||
|
||||
// Manual key upload for testing
|
||||
Future<void> uploadKeysManually() async {
|
||||
print('[CHAT] Manual key upload requested');
|
||||
await _e2ee.uploadKeysManually();
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +106,6 @@ class SecureChatService {
|
|||
final wsUrl = Uri.parse(ApiConfig.baseUrl)
|
||||
.replace(scheme: ApiConfig.baseUrl.startsWith('https') ? 'wss' : 'ws', path: '/ws', queryParameters: {'token': token});
|
||||
|
||||
print('[WS] Connecting to $wsUrl');
|
||||
_isReconnecting = true;
|
||||
|
||||
try {
|
||||
|
|
@ -130,7 +125,6 @@ class SecureChatService {
|
|||
return; // Silently ignore
|
||||
}
|
||||
|
||||
print('[WS] Received: ${jsonEncode(data)}');
|
||||
|
||||
if (type == 'new_message') {
|
||||
final payload = data['payload'];
|
||||
|
|
@ -143,7 +137,6 @@ class SecureChatService {
|
|||
final messageId = payload['message_id'];
|
||||
final conversationId = payload['conversation_id'];
|
||||
if (messageId != null && conversationId != null) {
|
||||
print('[WS] IMMEDIATE DELETE: $messageId');
|
||||
_locallyDeletedMessageIds.add(messageId);
|
||||
unawaited(_localStore.deleteMessage(messageId));
|
||||
_processedMessageIds[conversationId]?.remove(messageId);
|
||||
|
|
@ -156,7 +149,6 @@ class SecureChatService {
|
|||
final payload = data['payload'];
|
||||
final conversationId = payload['conversation_id'];
|
||||
if (conversationId != null) {
|
||||
print('[WS] CONVERSATION DELETED: $conversationId');
|
||||
unawaited(_localStore.deleteConversation(conversationId));
|
||||
_processedMessageIds.remove(conversationId);
|
||||
_localControllers[conversationId]?.close();
|
||||
|
|
@ -169,7 +161,6 @@ class SecureChatService {
|
|||
final userId = payload['user_id'];
|
||||
final currentUserId = _auth.currentUser?.id;
|
||||
if (userId != null && currentUserId != null && userId == currentUserId) {
|
||||
print('[WS] KEY RECOVERY EVENT RECEIVED - triggering local recovery');
|
||||
unawaited(_e2ee.initiateKeyRecovery(currentUserId));
|
||||
}
|
||||
} else if (data['type'] == 'pong') {
|
||||
|
|
@ -177,20 +168,16 @@ class SecureChatService {
|
|||
_lastHeartbeat = DateTime.now();
|
||||
}
|
||||
} catch (e) {
|
||||
print('[WS] Parse error: $e');
|
||||
}
|
||||
}
|
||||
}, onError: (e) {
|
||||
print('[WS] ERROR - IMMEDIATE RECONNECT: $e');
|
||||
_cleanup();
|
||||
Future.delayed(const Duration(seconds: 1), connectRealtime);
|
||||
}, onDone: () {
|
||||
print('[WS] DISCONNECTED - IMMEDIATE RECONNECT');
|
||||
_cleanup();
|
||||
Future.delayed(const Duration(seconds: 1), connectRealtime);
|
||||
});
|
||||
} catch (e) {
|
||||
print('[WS] Connection failed: $e');
|
||||
_isReconnecting = false;
|
||||
Future.delayed(const Duration(seconds: 2), connectRealtime);
|
||||
}
|
||||
|
|
@ -299,7 +286,6 @@ class SecureChatService {
|
|||
unawaited(_emitLocal(conversationId));
|
||||
return msg;
|
||||
} catch (e) {
|
||||
print('[CHAT] Failed to send message: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -341,11 +327,8 @@ class SecureChatService {
|
|||
if (forEveryone) {
|
||||
unawaited(_api.deleteMessage(messageId).then((success) {
|
||||
if (!success) {
|
||||
print('[CHAT] Server delete failed - message already removed locally');
|
||||
}
|
||||
}).catchError((e) {
|
||||
print('[CHAT] Error deleting message from server: $e');
|
||||
print('[CHAT] Server delete error: $e');
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -359,11 +342,9 @@ class SecureChatService {
|
|||
|
||||
final messages = await _localStore.getMessagesForConversation(conversationId);
|
||||
if (messages.isEmpty) {
|
||||
print('[CHAT] Conversation $conversationId is empty - deleting');
|
||||
await deleteConversation(conversationId, fullDelete: true);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[CHAT] Error checking empty conversation: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -371,7 +352,6 @@ class SecureChatService {
|
|||
String conversationId, {
|
||||
bool fullDelete = false,
|
||||
}) async {
|
||||
print('[CHAT] Deleting conversation: $conversationId (fullDelete: $fullDelete)');
|
||||
|
||||
// Clear local state IMMEDIATELY
|
||||
_processedMessageIds.remove(conversationId);
|
||||
|
|
@ -391,12 +371,9 @@ class SecureChatService {
|
|||
if (fullDelete) {
|
||||
unawaited(_api.deleteConversation(conversationId).then((success) {
|
||||
if (success) {
|
||||
print('[CHAT] Conversation permanently deleted from server');
|
||||
} else {
|
||||
print('[CHAT] Server delete failed - already removed locally');
|
||||
}
|
||||
}).catchError((e) {
|
||||
print('[CHAT] Error deleting conversation from server: $e');
|
||||
}));
|
||||
}
|
||||
return DeleteResult(success: true);
|
||||
|
|
@ -430,7 +407,6 @@ class SecureChatService {
|
|||
final rows = await _api.getConversationMessages(conversationId);
|
||||
await _ingestRemoteSnapshot(conversationId, rows);
|
||||
} catch (e) {
|
||||
print('[SYNC] Failed to sync $conversationId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -440,7 +416,6 @@ class SecureChatService {
|
|||
final rows = await _api.getConversationMessages(conversationId, limit: limit);
|
||||
await _ingestRemoteSnapshot(conversationId, rows);
|
||||
} catch (e) {
|
||||
print('[SYNC] Failed to hydrate $conversationId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -449,7 +424,6 @@ class SecureChatService {
|
|||
await _e2ee.initialize();
|
||||
|
||||
if (!_e2ee.isReady) {
|
||||
print('[CHAT] Not ready to decrypt.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -511,7 +485,6 @@ class SecureChatService {
|
|||
|
||||
_processedMessageIds.putIfAbsent(conversationId, () => <String>{}).add(msg.id);
|
||||
} catch (e) {
|
||||
print('[DECRYPT] Failed for ${msg.id}: $e');
|
||||
if (e.toString().contains('Invalid Key') || e.toString().contains('MAC')) {
|
||||
await _localStore.saveMessage(
|
||||
conversationId: conversationId,
|
||||
|
|
|
|||
|
|
@ -77,7 +77,6 @@ class SimpleE2EEService {
|
|||
// DO NOT add debug flags here - use resetAllKeys() method for intentional resets
|
||||
|
||||
Future<void> resetAllKeys() async {
|
||||
print('[E2EE] RESETTING ALL KEYS - fixing MAC errors');
|
||||
|
||||
// Clear all storage
|
||||
await _storage.deleteAll();
|
||||
|
|
@ -91,12 +90,10 @@ class SimpleE2EEService {
|
|||
// Generate fresh identity
|
||||
await generateNewIdentity();
|
||||
|
||||
print('[E2EE] Keys reset complete - fresh identity generated');
|
||||
}
|
||||
|
||||
// Force reset to fix 208-bit key bug
|
||||
Future<void> forceResetBrokenKeys() async {
|
||||
print('[E2EE] FORCE RESET - Clearing all broken 208-bit keys');
|
||||
|
||||
// Clear ALL storage completely
|
||||
await _storage.deleteAll();
|
||||
|
|
@ -112,7 +109,6 @@ class SimpleE2EEService {
|
|||
// Clear session cache
|
||||
_sessionCache.clear();
|
||||
|
||||
print('[E2EE] All keys cleared - generating fresh 256-bit keys');
|
||||
|
||||
// Generate fresh identity with proper key lengths
|
||||
await generateNewIdentity();
|
||||
|
|
@ -120,20 +116,16 @@ class SimpleE2EEService {
|
|||
// Verify the new keys are proper length
|
||||
if (_identityDhKeyPair != null) {
|
||||
final publicKey = await _identityDhKeyPair!.extractPublicKey();
|
||||
print('[E2EE] New identity DH key: ${publicKey.bytes.length} bytes');
|
||||
}
|
||||
|
||||
if (_identitySigningKeyPair != null) {
|
||||
final publicKey = await _identitySigningKeyPair!.extractPublicKey();
|
||||
print('[E2EE] New identity signing key: ${publicKey.bytes.length} bytes');
|
||||
}
|
||||
|
||||
print('[E2EE] FORCE RESET complete - all keys now 256-bit');
|
||||
}
|
||||
|
||||
// Manual key upload for testing
|
||||
Future<void> uploadKeysManually() async {
|
||||
print('[E2EE] Manual key upload requested');
|
||||
|
||||
if (!isReady) {
|
||||
throw Exception('Keys not ready - generate keys first');
|
||||
|
|
@ -154,7 +146,6 @@ class SimpleE2EEService {
|
|||
}
|
||||
|
||||
await _publishKeys(spkSignature);
|
||||
print('[E2EE] Manual key upload completed');
|
||||
}
|
||||
|
||||
// Check if keys exist on backend
|
||||
|
|
@ -167,21 +158,17 @@ class SimpleE2EEService {
|
|||
|
||||
// If we get a successful response with key data, keys exist
|
||||
if (response.containsKey('identity_key')) {
|
||||
print('[E2EE] Keys found on backend');
|
||||
return true;
|
||||
} else {
|
||||
print('[E2EE] Keys not found on backend');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
print('[E2EE] Error checking backend keys: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload existing keys to backend
|
||||
Future<void> _uploadExistingKeys() async {
|
||||
print('[E2EE] Uploading existing keys to backend...');
|
||||
|
||||
if (!isReady) {
|
||||
throw Exception('Keys not ready for upload');
|
||||
|
|
@ -196,7 +183,6 @@ class SimpleE2EEService {
|
|||
final spkSignature = signature.bytes;
|
||||
|
||||
await _publishKeys(spkSignature);
|
||||
print('[E2EE] Existing keys uploaded to backend');
|
||||
}
|
||||
|
||||
Future<void> _doInitialize(String userId) async {
|
||||
|
|
@ -208,50 +194,41 @@ class SimpleE2EEService {
|
|||
try {
|
||||
final loaded = await _loadKeysFromLocal(userId);
|
||||
if (loaded) {
|
||||
print('[E2EE] Keys loaded from local storage.');
|
||||
// Test if keys are working by attempting a simple encrypt/decrypt
|
||||
if (await _testKeyCompatibility()) {
|
||||
// Check if keys exist on backend, upload if not
|
||||
if (await _checkKeysExistOnBackend()) {
|
||||
final backendValid = await _validateBackendKeyBundle(userId);
|
||||
if (!backendValid) {
|
||||
print('[E2EE] Backend key bundle failed signature verification - reuploading');
|
||||
await _uploadExistingKeys();
|
||||
return;
|
||||
}
|
||||
print('[E2EE] Keys exist on backend - ready');
|
||||
return;
|
||||
} else {
|
||||
print('[E2EE] Keys missing on backend - uploading now');
|
||||
await _uploadExistingKeys();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
print('[E2EE] Keys failed compatibility test - initiating recovery');
|
||||
await initiateKeyRecovery(userId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[E2EE] Error loading keys: $e. Regenerating.');
|
||||
}
|
||||
|
||||
// 2. Try Cloud Restore
|
||||
final restored = await _restoreFromCloud(userId);
|
||||
if (restored) {
|
||||
print('[E2EE] Keys restored from cloud backup.');
|
||||
// Test restored keys
|
||||
if (await _testKeyCompatibility()) {
|
||||
return;
|
||||
} else {
|
||||
print('[E2EE] Restored keys failed compatibility test - initiating recovery');
|
||||
await initiateKeyRecovery(userId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Generate New
|
||||
print('[E2EE] No keys found. Generating new Identity.');
|
||||
await generateNewIdentity();
|
||||
}
|
||||
|
||||
|
|
@ -274,7 +251,6 @@ class SimpleE2EEService {
|
|||
|
||||
// Verify key length
|
||||
if (testKeyBytes.length != 32) {
|
||||
print('[E2EE] ERROR: Test key is ${testKeyBytes.length} bytes, expected 32');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -290,10 +266,8 @@ class SimpleE2EEService {
|
|||
);
|
||||
|
||||
final result = utf8.decode(decrypted) == testMessage;
|
||||
print('[E2EE] Local encryption test: $result (key length: ${testKeyBytes.length} bytes)');
|
||||
return result;
|
||||
} catch (e) {
|
||||
print('[E2EE] Key compatibility test failed: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -337,14 +311,12 @@ class SimpleE2EEService {
|
|||
|
||||
return verified;
|
||||
} catch (e) {
|
||||
print('[E2EE] Backend key bundle validation failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Smart key recovery that preserves messages when possible
|
||||
Future<void> initiateKeyRecovery(String userId) async {
|
||||
print('[E2EE] Starting smart key recovery...');
|
||||
|
||||
// Try to preserve existing messages by backing up encrypted content
|
||||
final messageBackup = await _backupEncryptedMessages();
|
||||
|
|
@ -354,12 +326,10 @@ class SimpleE2EEService {
|
|||
|
||||
// Restore message backup with new keys if possible
|
||||
if (messageBackup > 0) {
|
||||
print('[E2EE] Attempting to preserve $messageBackup messages with new keys');
|
||||
// Note: Messages encrypted with old keys will show as "encrypted with old keys"
|
||||
// but new messages will work perfectly
|
||||
}
|
||||
|
||||
print('[E2EE] Key recovery complete - new messages will encrypt/decrypt properly');
|
||||
}
|
||||
|
||||
// Backup encrypted messages to preserve them during key recovery
|
||||
|
|
@ -367,10 +337,8 @@ class SimpleE2EEService {
|
|||
try {
|
||||
// This would integrate with local message store to count/preserve messages
|
||||
// For now, just log that we're attempting preservation
|
||||
print('[E2EE] Backing up encrypted messages for preservation...');
|
||||
return 0; // Return count of backed up messages
|
||||
} catch (e) {
|
||||
print('[E2EE] Error backing up messages: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -379,7 +347,6 @@ class SimpleE2EEService {
|
|||
final userId = _auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
|
||||
print('[E2EE] Generating X3DH Key Bundle...');
|
||||
|
||||
// 1. Identity Key Pair (DH)
|
||||
_identityDhKeyPair = await _dhAlgo.newKeyPair();
|
||||
|
|
@ -421,13 +388,11 @@ class SimpleE2EEService {
|
|||
if (!_auth.isAuthenticated) throw Exception('Not authenticated');
|
||||
await initialize();
|
||||
|
||||
print('[ENCRYPT] Fetching key bundle for recipient: $recipientId');
|
||||
|
||||
// 1. Fetch Bundle
|
||||
final bundle = await ApiService(AuthService.instance).getKeyBundle(recipientId);
|
||||
|
||||
// DEBUG: Validate Bundle
|
||||
print('[ENCRYPT] Bundle keys: ${bundle.keys.toList()}');
|
||||
|
||||
// Handle both formats:
|
||||
// Flat (from getKeyBundle normalization): { "identity_key_public": "...", "signed_prekey_public": "...", "signed_prekey_signature": "..." }
|
||||
|
|
@ -468,8 +433,6 @@ class SimpleE2EEService {
|
|||
otkId = bundle['one_time_prekey_id'];
|
||||
}
|
||||
|
||||
print('[ENCRYPT] IK: $ikField');
|
||||
print('[ENCRYPT] SPK: $spkField');
|
||||
|
||||
if (ikField == null || ikField.isEmpty) {
|
||||
throw Exception('Recipient identity_key not found in bundle. Structure: $bundle');
|
||||
|
|
@ -522,7 +485,6 @@ class SimpleE2EEService {
|
|||
if (!isVerified) {
|
||||
throw Exception('E2EE SECURITY ALERT: Recipient Signed PreKey signature verification failed!');
|
||||
}
|
||||
print('[E2EE] SPK signature verified successfully.');
|
||||
|
||||
final theirIk = SimplePublicKey(theirIkDhBytes, type: KeyPairType.x25519);
|
||||
final theirSpk = SimplePublicKey(theirSpkBytes, type: KeyPairType.x25519);
|
||||
|
|
@ -626,9 +588,7 @@ class SimpleE2EEService {
|
|||
final matchingOtk = _oneTimePreKeys![otkId];
|
||||
final dh4 = await _dhAlgo.sharedSecretKey(keyPair: matchingOtk, remotePublicKey: senderEk);
|
||||
dhBytes.addAll(await dh4.extractBytes());
|
||||
print('[DECRYPT] Used OTK with key_id: $otkId');
|
||||
} else {
|
||||
print('[DECRYPT] WARNING: OTK key_id $otkId out of range (have ${_oneTimePreKeys!.length} OTKs)');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -639,7 +599,6 @@ class SimpleE2EEService {
|
|||
// Decryption successful - plaintext not logged for security
|
||||
return plaintext;
|
||||
} catch (e) {
|
||||
print('[DECRYPT] Failed: $e');
|
||||
if (e.toString().contains('MAC') || e.toString().contains('SecretBoxAuthenticationError')) {
|
||||
// Automatic key recovery on MAC errors
|
||||
_handleMacError();
|
||||
|
|
@ -661,11 +620,9 @@ class SimpleE2EEService {
|
|||
_macErrorCount++;
|
||||
_lastMacErrorTime = DateTime.now();
|
||||
|
||||
print('[E2EE] MAC error #$_macErrorCount detected');
|
||||
|
||||
// If we get multiple MAC errors in quick succession, trigger recovery
|
||||
if (_macErrorCount >= _maxMacErrors) {
|
||||
print('[E2EE] Multiple MAC errors detected - triggering automatic key recovery');
|
||||
_triggerAutomaticRecovery();
|
||||
_macErrorCount = 0; // Reset counter
|
||||
}
|
||||
|
|
@ -675,10 +632,8 @@ class SimpleE2EEService {
|
|||
final userId = _auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
|
||||
print('[E2EE] AUTOMATIC KEY RECOVERY TRIGGERED');
|
||||
|
||||
// Show user-friendly notification
|
||||
print('[E2EE] User notification: "Fixing encryption issues..."');
|
||||
|
||||
// Initiate smart recovery
|
||||
await initiateKeyRecovery(userId);
|
||||
|
|
@ -686,27 +641,22 @@ class SimpleE2EEService {
|
|||
// Broadcast key recovery event to all user's devices
|
||||
_broadcastKeyRecovery(userId);
|
||||
|
||||
print('[E2EE] Automatic recovery complete - new messages will work properly');
|
||||
}
|
||||
|
||||
void _broadcastKeyRecovery(String userId) {
|
||||
// Broadcast key recovery event to all user's devices via WebSocket
|
||||
_chatService?.broadcastKeyRecovery(userId);
|
||||
print('[E2EE] Broadcasting key recovery to all devices for user: $userId');
|
||||
}
|
||||
|
||||
// Delete used OTK from server to prevent reuse
|
||||
Future<void> _deleteUsedOTK(int keyId) async {
|
||||
try {
|
||||
await _api.callGoApi('/keys/otk/$keyId', method: 'DELETE');
|
||||
print('[E2EE] Deleted used OTK #$keyId from server');
|
||||
} catch (e) {
|
||||
final message = e.toString();
|
||||
if (message.contains('route not found') || message.contains('404')) {
|
||||
print('[E2EE] OTK delete endpoint missing on backend; skipping cleanup for #$keyId');
|
||||
return;
|
||||
}
|
||||
print('[E2EE] Error deleting OTK #$keyId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -721,7 +671,6 @@ class SimpleE2EEService {
|
|||
}
|
||||
|
||||
Future<void> _publishKeys(List<int> spkSignature) async {
|
||||
print('[E2EE] Publishing key bundle to backend...');
|
||||
|
||||
try {
|
||||
final skPublic = await _identitySigningKeyPair!.extractPublicKey();
|
||||
|
|
@ -740,7 +689,6 @@ class SimpleE2EEService {
|
|||
});
|
||||
}
|
||||
|
||||
print('[E2EE] Uploading ${otks.length} OTKs to backend');
|
||||
|
||||
// Verify signature is not all zeros before upload
|
||||
final allZeros = spkSignature.every((b) => b == 0);
|
||||
|
|
@ -757,9 +705,7 @@ class SimpleE2EEService {
|
|||
oneTimePrekeys: otks,
|
||||
);
|
||||
|
||||
print('[E2EE] Key bundle uploaded successfully to backend');
|
||||
} catch (e) {
|
||||
print('[E2EE] FAILED to upload key bundle: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
|
@ -784,27 +730,21 @@ class SimpleE2EEService {
|
|||
if (kIsWeb) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('e2ee_keys_$userId', data);
|
||||
print('[E2EE] Keys also saved to SharedPreferences (web fallback)');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _loadKeysFromLocal(String userId) async {
|
||||
print('[E2EE] Attempting to load keys for user: $userId');
|
||||
|
||||
// Try FlutterSecureStorage first
|
||||
var data = await _storage.read(key: 'e2ee_keys_$userId');
|
||||
print('[E2EE] FlutterSecureStorage read result: ${data != null ? "found (${data.length} chars)" : "null"}');
|
||||
|
||||
// Fallback to SharedPreferences on web if secure storage fails
|
||||
if (data == null && kIsWeb) {
|
||||
print('[E2EE] Trying SharedPreferences fallback for web...');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
data = prefs.getString('e2ee_keys_$userId');
|
||||
print('[E2EE] SharedPreferences read result: ${data != null ? "found (${data.length} chars)" : "null"}');
|
||||
}
|
||||
|
||||
if (data == null) {
|
||||
print('[E2EE] No keys found in any storage');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -829,7 +769,6 @@ class SimpleE2EEService {
|
|||
for (final otkSeed in map['otks']) {
|
||||
_oneTimePreKeys!.add(await _dhAlgo.newKeyPairFromSeed(base64Decode(otkSeed)));
|
||||
}
|
||||
print('[E2EE] Loaded ${_oneTimePreKeys!.length} OTKs from local storage');
|
||||
}
|
||||
|
||||
return isReady;
|
||||
|
|
@ -914,7 +853,6 @@ class SimpleE2EEService {
|
|||
|
||||
return isReady;
|
||||
} catch (e) {
|
||||
print('Restore failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -925,7 +863,6 @@ class SimpleE2EEService {
|
|||
}
|
||||
|
||||
try {
|
||||
print('[E2EE] Exporting all keys for backup...');
|
||||
|
||||
final identityDhPublic = await _identityDhKeyPair!.extractPublicKey();
|
||||
final identitySigningPublic = await _identitySigningKeyPair!.extractPublicKey();
|
||||
|
|
@ -968,18 +905,15 @@ class SimpleE2EEService {
|
|||
},
|
||||
};
|
||||
|
||||
print('[E2EE] Keys exported successfully');
|
||||
return exportData;
|
||||
|
||||
} catch (e) {
|
||||
print('[E2EE] Failed to export keys: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> importAllKeys(Map<String, dynamic> backupData) async {
|
||||
try {
|
||||
print('[E2EE] Importing keys from backup...');
|
||||
|
||||
if (!backupData.containsKey('keys')) {
|
||||
throw ArgumentError('Invalid backup format: missing keys');
|
||||
|
|
@ -989,18 +923,15 @@ class SimpleE2EEService {
|
|||
|
||||
// 1. Restore Identity Keys
|
||||
if (keys.containsKey('identity_dh_private')) {
|
||||
print('[E2EE] Restoring Identity DH key...');
|
||||
_identityDhKeyPair = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['identity_dh_private']));
|
||||
}
|
||||
|
||||
if (keys.containsKey('identity_signing_private')) {
|
||||
print('[E2EE] Restoring Identity Signing key...');
|
||||
_identitySigningKeyPair = await _signingAlgo.newKeyPairFromSeed(base64Decode(keys['identity_signing_private']));
|
||||
}
|
||||
|
||||
// 2. Restore Signed PreKey
|
||||
if (keys.containsKey('signed_prekey_private')) {
|
||||
print('[E2EE] Restoring Signed PreKey...');
|
||||
_signedPreKey = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['signed_prekey_private']));
|
||||
}
|
||||
|
||||
|
|
@ -1014,7 +945,6 @@ class SimpleE2EEService {
|
|||
}
|
||||
}
|
||||
_oneTimePreKeys = importedOTKs;
|
||||
print('[E2EE] Restored ${_oneTimePreKeys!.length} OTKs');
|
||||
}
|
||||
|
||||
// 4. Set User Context from metadata
|
||||
|
|
@ -1032,7 +962,6 @@ class SimpleE2EEService {
|
|||
|
||||
// 5. Persist and Synchronize
|
||||
if (_initializedForUserId != null) {
|
||||
print('[E2EE] Persisting restored keys to local storage...');
|
||||
await _saveKeysToLocal(_initializedForUserId!);
|
||||
|
||||
// Republish to server to ensure backend is synchronized
|
||||
|
|
@ -1047,10 +976,8 @@ class SimpleE2EEService {
|
|||
}
|
||||
}
|
||||
|
||||
print('[E2EE] Backup restoration complete. Old messages can now be decrypted.');
|
||||
|
||||
} catch (e) {
|
||||
print('[E2EE] CRITICAL: Failed to import keys: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,11 +104,9 @@ class SyncManager with WidgetsBindingObserver {
|
|||
if (!_authService.isAuthenticated || _syncInProgress) return;
|
||||
_syncInProgress = true;
|
||||
try {
|
||||
print('[SYNC] Triggered by: $reason');
|
||||
|
||||
await _e2ee.initialize();
|
||||
if (!_e2ee.isReady) {
|
||||
print('[SYNC] Keys not ready, aborting sync.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -116,9 +114,7 @@ class SyncManager with WidgetsBindingObserver {
|
|||
|
||||
await _secureChatService.syncAllConversations(force: true);
|
||||
_lastSyncAt = DateTime.now();
|
||||
print('[SYNC] Sync complete.');
|
||||
} catch (e) {
|
||||
print('[SYNC] Global sync failed ($reason): $e');
|
||||
} finally {
|
||||
_syncInProgress = false;
|
||||
}
|
||||
|
|
@ -130,7 +126,6 @@ class SyncManager with WidgetsBindingObserver {
|
|||
try {
|
||||
await _e2ee.initialize();
|
||||
if (!_e2ee.isReady) {
|
||||
print('[SYNC] Identity not ready; skipping hydration.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -139,13 +134,11 @@ class SyncManager with WidgetsBindingObserver {
|
|||
final isEmpty =
|
||||
(await _localStore.getMessageIdsForConversation(conv.id)).isEmpty;
|
||||
if (isEmpty) {
|
||||
print('[SYNC] Hydrating empty conversation: ${conv.id}');
|
||||
await _secureChatService.fetchAndDecryptHistory(conv.id, limit: 50);
|
||||
}
|
||||
await _secureChatService.startLiveListener(conv.id);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[SYNC] History load failed: $e');
|
||||
} finally {
|
||||
_hydrating = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,16 +41,13 @@ class VideoStitchingService {
|
|||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
return outputFile;
|
||||
} else {
|
||||
print("Stitching failed with return code: $returnCode");
|
||||
// Fallback: return the last segment or first one to at least save something?
|
||||
// For strict correctness, return null or throw.
|
||||
// Let's print logs.
|
||||
final logs = await session.getOutput();
|
||||
print("FFmpeg Logs: $logs");
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
print("Stitching Error: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ class PostMedia extends StatelessWidget {
|
|||
onTap: isVideo
|
||||
? () {
|
||||
final url = '${AppRoutes.quips}?postId=${post!.id}';
|
||||
print('[PostMedia] Navigating to quips: $url');
|
||||
context.go(url);
|
||||
}
|
||||
: onTap,
|
||||
|
|
|
|||
|
|
@ -133,7 +133,6 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
|||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('[REACTIONS] Error scanning assets: $e');
|
||||
// Fallback
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ class _VideoPlayerWidgetState extends State<VideoPlayerWidget> {
|
|||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error initializing video player: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ class _VideoPlayerWithCommentsState extends State<VideoPlayerWithComments> {
|
|||
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
print('Error initializing video: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue