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(
|
param(
|
||||||
[string]$EnvPath = (Join-Path $PSScriptRoot ".env")
|
[string]$EnvPath = (Join-Path $PSScriptRoot ".env")
|
||||||
)
|
)
|
||||||
|
|
||||||
if (-not (Test-Path $EnvPath)) {
|
function Parse-Env($path) {
|
||||||
Write-Error "Env file not found: ${EnvPath}"
|
$vals = @{}
|
||||||
exit 1
|
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'
|
||||||
$values = @{}
|
return $vals
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
Get-Content $path | ForEach-Object {
|
||||||
$values[$key] = $value
|
$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')
|
$values = Parse-Env $EnvPath
|
||||||
$missing = $required | Where-Object {
|
|
||||||
-not $values.ContainsKey($_) -or [string]::IsNullOrWhiteSpace($values[$_])
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($missing.Count -gt 0) {
|
$defineArgs = @()
|
||||||
Write-Error "Missing required keys in ${EnvPath}: $($missing -join ', ')"
|
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||||
exit 1
|
foreach ($k in $keysOfInterest) {
|
||||||
}
|
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
||||||
|
$defineArgs += "--dart-define=$k=$($values[$k])"
|
||||||
$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])"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
Push-Location (Join-Path $PSScriptRoot "sojorn_app")
|
||||||
try {
|
try {
|
||||||
flutter run @defineArgs @Args
|
Write-Host "Running: flutter run $($defineArgs -join ' ')" -ForegroundColor DarkGray
|
||||||
|
& flutter run @defineArgs @Args
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
Pop-Location
|
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(
|
param(
|
||||||
[string]$EnvPath = (Join-Path $PSScriptRoot ".env"),
|
[string]$EnvPath = (Join-Path $PSScriptRoot ".env"),
|
||||||
[int]$Port = 8001,
|
[int]$Port = 8001
|
||||||
[string]$Renderer = "auto", # Options: auto, canvaskit, html
|
|
||||||
[switch]$NoWasmDryRun
|
|
||||||
)
|
)
|
||||||
|
|
||||||
function Parse-Env($path) {
|
function Parse-Env($path) {
|
||||||
$vals = @{}
|
$vals = @{}
|
||||||
if (-not (Test-Path $path)) {
|
if (-not (Test-Path $path)) {
|
||||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
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'
|
$vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
|
||||||
return $vals
|
return $vals
|
||||||
}
|
}
|
||||||
|
|
@ -30,7 +29,6 @@ function Parse-Env($path) {
|
||||||
|
|
||||||
$values = Parse-Env $EnvPath
|
$values = Parse-Env $EnvPath
|
||||||
|
|
||||||
# Collect dart-defines we actually use on web.
|
|
||||||
$defineArgs = @()
|
$defineArgs = @()
|
||||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||||
foreach ($k in $keysOfInterest) {
|
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'])) {
|
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||||
$currentApi = 'https://api.sojorn.net/api/v1'
|
$currentApi = 'https://api.sojorn.net/api/v1'
|
||||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
$currentApi = $values['API_BASE_URL']
|
$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 "Launching Sojorn Web (Edge)..." -ForegroundColor Cyan
|
||||||
Write-Host "Port: $Port"
|
Write-Host "Port: $Port | API: $currentApi"
|
||||||
Write-Host "Renderer: $Renderer"
|
|
||||||
Write-Host "API: $currentApi"
|
|
||||||
|
|
||||||
Push-Location (Join-Path $PSScriptRoot -ChildPath "sojorn_app")
|
Push-Location (Join-Path $PSScriptRoot -ChildPath "sojorn_app")
|
||||||
try {
|
try {
|
||||||
$cmdArgs = @(
|
$cmdArgs = @('run', '-d', 'edge', '--web-hostname', 'localhost', '--web-port', "$Port")
|
||||||
'run',
|
|
||||||
'-d',
|
|
||||||
'edge',
|
|
||||||
'--web-hostname',
|
|
||||||
'localhost',
|
|
||||||
'--web-port',
|
|
||||||
"$Port"
|
|
||||||
)
|
|
||||||
$cmdArgs += $defineArgs
|
$cmdArgs += $defineArgs
|
||||||
|
|
||||||
if ($NoWasmDryRun) {
|
|
||||||
$cmdArgs += '--no-wasm-dry-run'
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray
|
Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray
|
||||||
& flutter $cmdArgs
|
& 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(
|
param(
|
||||||
[string]$EnvPath = (Join-Path $PSScriptRoot ".env"),
|
[string]$EnvPath = (Join-Path $PSScriptRoot ".env"),
|
||||||
[int]$Port = 8002,
|
[int]$Port = 8002
|
||||||
[string]$Renderer = "auto", # Options: auto, canvaskit, html
|
|
||||||
[switch]$NoWasmDryRun
|
|
||||||
)
|
)
|
||||||
|
|
||||||
function Parse-Env($path) {
|
function Parse-Env($path) {
|
||||||
$vals = @{}
|
$vals = @{}
|
||||||
if (-not (Test-Path $path)) {
|
if (-not (Test-Path $path)) {
|
||||||
Write-Host "No .env file found at ${path}. Using defaults." -ForegroundColor Yellow
|
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'
|
$vals['API_BASE_URL'] = 'https://api.sojorn.net/api/v1'
|
||||||
return $vals
|
return $vals
|
||||||
}
|
}
|
||||||
|
|
@ -30,7 +29,6 @@ function Parse-Env($path) {
|
||||||
|
|
||||||
$values = Parse-Env $EnvPath
|
$values = Parse-Env $EnvPath
|
||||||
|
|
||||||
# Collect dart-defines we actually use on web.
|
|
||||||
$defineArgs = @()
|
$defineArgs = @()
|
||||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||||
foreach ($k in $keysOfInterest) {
|
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'])) {
|
if (-not $values.ContainsKey('API_BASE_URL') -or [string]::IsNullOrWhiteSpace($values['API_BASE_URL'])) {
|
||||||
$currentApi = 'https://api.sojorn.net/api/v1'
|
$currentApi = 'https://api.sojorn.net/api/v1'
|
||||||
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
$defineArgs += "--dart-define=API_BASE_URL=$currentApi"
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
$currentApi = $values['API_BASE_URL']
|
$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 "Launching Sojorn Web (Chrome)..." -ForegroundColor Cyan
|
||||||
Write-Host "Port: $Port"
|
Write-Host "Port: $Port | API: $currentApi"
|
||||||
Write-Host "Renderer: $Renderer"
|
|
||||||
Write-Host "API: $currentApi"
|
|
||||||
|
|
||||||
Push-Location (Join-Path $PSScriptRoot -ChildPath "sojorn_app")
|
Push-Location (Join-Path $PSScriptRoot -ChildPath "sojorn_app")
|
||||||
try {
|
try {
|
||||||
$cmdArgs = @(
|
$cmdArgs = @('run', '-d', 'chrome', '--web-hostname', 'localhost', '--web-port', "$Port")
|
||||||
'run',
|
|
||||||
'-d',
|
|
||||||
'chrome',
|
|
||||||
'--web-hostname',
|
|
||||||
'localhost',
|
|
||||||
'--web-port',
|
|
||||||
"$Port"
|
|
||||||
)
|
|
||||||
$cmdArgs += $defineArgs
|
$cmdArgs += $defineArgs
|
||||||
|
|
||||||
if ($NoWasmDryRun) {
|
|
||||||
$cmdArgs += '--no-wasm-dry-run'
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray
|
Write-Host "Running: flutter $($cmdArgs -join ' ')" -ForegroundColor DarkGray
|
||||||
& flutter $cmdArgs
|
& flutter $cmdArgs
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,6 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
|
||||||
videoFile,
|
videoFile,
|
||||||
onProgress: (p) => state = state.copyWith(progress: 0.1 + (p * 0.4)),
|
onProgress: (p) => state = state.copyWith(progress: 0.1 + (p * 0.4)),
|
||||||
);
|
);
|
||||||
print('Video uploaded successfully: $videoUrl');
|
|
||||||
|
|
||||||
state = state.copyWith(progress: 0.5);
|
state = state.copyWith(progress: 0.5);
|
||||||
|
|
||||||
|
|
@ -99,9 +98,7 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
|
||||||
thumbnail,
|
thumbnail,
|
||||||
onProgress: (p) => state = state.copyWith(progress: 0.5 + (p * 0.3)),
|
onProgress: (p) => state = state.copyWith(progress: 0.5 + (p * 0.3)),
|
||||||
);
|
);
|
||||||
print('Thumbnail uploaded: $thumbnailUrl');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Thumbnail upload failed: $e');
|
|
||||||
// Continue without thumbnail - video is more important
|
// Continue without thumbnail - video is more important
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,6 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
||||||
if (!ReturnCode.isSuccess(returnCode)) {
|
if (!ReturnCode.isSuccess(returnCode)) {
|
||||||
final logs = await session.getAllLogsAsString();
|
final logs = await session.getAllLogsAsString();
|
||||||
// Print in chunks if it's too long for some logcats
|
// 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
|
// Extract the last error message from logs if possible
|
||||||
String errorDetail = "FFmpeg failed (Code: $returnCode)";
|
String errorDetail = "FFmpeg failed (Code: $returnCode)";
|
||||||
|
|
@ -114,7 +113,6 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Repair error details: $e'); // Log for debugging
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Repair Failed: $e')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Repair Failed: $e')));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Biometrics] Failed to check support: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,6 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||||
isLoadingDiscover = false;
|
isLoadingDiscover = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error loading discover data: $e');
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => isLoadingDiscover = false);
|
setState(() => isLoadingDiscover = false);
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +136,6 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||||
.toList();
|
.toList();
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error loading recent searches: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,7 +154,6 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||||
|
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error saving recent search: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,7 +165,6 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||||
recentSearches = [];
|
recentSearches = [];
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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});
|
await apiService.callGoApi('/users/block_by_handle', method: 'POST', body: {'handle': handle});
|
||||||
count++;
|
count++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to block $handle: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,6 @@ class _QuipRecorderScreenState extends State<QuipRecorderScreen>
|
||||||
|
|
||||||
if (mounted) setState(() => _isInitializing = false);
|
if (mounted) setState(() => _isInitializing = false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Camera init error: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,7 +153,6 @@ class _QuipRecorderScreenState extends State<QuipRecorderScreen>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Start Error: $e");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,7 +173,6 @@ class _QuipRecorderScreenState extends State<QuipRecorderScreen>
|
||||||
|
|
||||||
// NO Auto-Navigation. Just stop.
|
// NO Auto-Navigation. Just stop.
|
||||||
} catch (e) {
|
} 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 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) {
|
if (refresh && widget.initialPostId != null) {
|
||||||
print('[Quips] Handling initialPostId: ${widget.initialPostId}');
|
|
||||||
final existingIndex = items.indexWhere((q) => q.id == widget.initialPostId);
|
final existingIndex = items.indexWhere((q) => q.id == widget.initialPostId);
|
||||||
if (existingIndex != -1) {
|
if (existingIndex != -1) {
|
||||||
print('[Quips] Found initialPostId in feed at index $existingIndex, moving to top');
|
|
||||||
final initial = items.removeAt(existingIndex);
|
final initial = items.removeAt(existingIndex);
|
||||||
items.insert(0, initial);
|
items.insert(0, initial);
|
||||||
} else {
|
} else {
|
||||||
print('[Quips] initialPostId NOT in feed, fetching specifically...');
|
|
||||||
try {
|
try {
|
||||||
final postData = await api.callGoApi('/posts/${widget.initialPostId}', method: 'GET');
|
final postData = await api.callGoApi('/posts/${widget.initialPostId}', method: 'GET');
|
||||||
if (postData['post'] != null) {
|
if (postData['post'] != null) {
|
||||||
final quip = Quip.fromMap(postData['post'] as Map<String, dynamic>);
|
final quip = Quip.fromMap(postData['post'] as Map<String, dynamic>);
|
||||||
if (quip.videoUrl.isNotEmpty) {
|
if (quip.videoUrl.isNotEmpty) {
|
||||||
print('[Quips] Successfully fetched initial quip: ${quip.videoUrl}');
|
|
||||||
items.insert(0, quip);
|
items.insert(0, quip);
|
||||||
} else {
|
} else {
|
||||||
print('[Quips] Fetched post is not a video: ${quip.videoUrl}');
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print('[Quips] No post found for initialPostId: ${widget.initialPostId}');
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Initial quip fetch error: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
.toList();
|
.toList();
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading recent searches: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,7 +123,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
|
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error saving recent search: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,7 +134,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
recentSearches = [];
|
recentSearches = [];
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error clearing recent searches: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,15 +168,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print('[SearchScreen] Requesting search for: "$normalizedQuery"');
|
|
||||||
final apiService = ref.read(apiServiceProvider);
|
final apiService = ref.read(apiServiceProvider);
|
||||||
final searchResults = await apiService.search(normalizedQuery);
|
final searchResults = await apiService.search(normalizedQuery);
|
||||||
if (!mounted || requestId != _searchEpoch) {
|
if (!mounted || requestId != _searchEpoch) {
|
||||||
print('[SearchScreen] Request $requestId discarded (stale)');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[SearchScreen] Results received. Users: ${searchResults.users.length}, Tags: ${searchResults.tags.length}, Posts: ${searchResults.posts.length}');
|
|
||||||
|
|
||||||
if (searchResults.users.isNotEmpty) {
|
if (searchResults.users.isNotEmpty) {
|
||||||
await saveRecentSearch(RecentSearch(
|
await saveRecentSearch(RecentSearch(
|
||||||
|
|
@ -203,7 +197,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[SearchScreen] Search error: $e');
|
|
||||||
if (!mounted || requestId != _searchEpoch) return;
|
if (!mounted || requestId != _searchEpoch) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
||||||
// Also trigger immediate reload on conversation changes
|
// Also trigger immediate reload on conversation changes
|
||||||
_chatService.conversationListChanges.listen((_) {
|
_chatService.conversationListChanges.listen((_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
print('[UI] Conversation list changed in full screen - stream will update');
|
|
||||||
setState(() {}); // Force rebuild to get latest from stream
|
setState(() {}); // Force rebuild to get latest from stream
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -56,7 +55,6 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print('[UI] Initializing secure chat full screen...');
|
|
||||||
|
|
||||||
// Initialize chat service with key generation if needed
|
// Initialize chat service with key generation if needed
|
||||||
await _chatService.initialize(generateIfMissing: true);
|
await _chatService.initialize(generateIfMissing: true);
|
||||||
|
|
@ -64,9 +62,7 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
||||||
// Load conversations
|
// Load conversations
|
||||||
await _loadConversations();
|
await _loadConversations();
|
||||||
|
|
||||||
print('[UI] Secure chat full screen initialized successfully');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[UI] Failed to initialize secure chat: $e');
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
});
|
});
|
||||||
|
|
@ -80,7 +76,6 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
||||||
|
|
||||||
Future<void> _loadConversations() async {
|
Future<void> _loadConversations() async {
|
||||||
try {
|
try {
|
||||||
print('[UI] Loading conversations...');
|
|
||||||
final conversations = await _chatService.getConversations();
|
final conversations = await _chatService.getConversations();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -88,10 +83,8 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
||||||
_conversations = conversations;
|
_conversations = conversations;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
print('[UI] Loaded ${conversations.length} conversations');
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[UI] Failed to load conversations: $e');
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ class _SecureChatScreenState extends State<SecureChatScreen>
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
print('[DEBUG] SecureChatScreen initState - UPLOAD BUTTON SHOULD BE VISIBLE');
|
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_messageStream = _chatService.getMessagesStream(widget.conversation.id);
|
_messageStream = _chatService.getMessagesStream(widget.conversation.id);
|
||||||
NotificationService.instance.activeConversationId = widget.conversation.id;
|
NotificationService.instance.activeConversationId = widget.conversation.id;
|
||||||
|
|
|
||||||
|
|
@ -33,18 +33,14 @@ class AdIntegrationService {
|
||||||
return ad;
|
return ad;
|
||||||
}
|
}
|
||||||
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
|
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
|
||||||
debugPrint('AdIntegrationService: using cached ad for $categoryId');
|
|
||||||
return _currentAd;
|
return _currentAd;
|
||||||
}
|
}
|
||||||
debugPrint(
|
|
||||||
'AdIntegrationService: no sponsored post available for $categoryId');
|
'AdIntegrationService: no sponsored post available for $categoryId');
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
|
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
|
||||||
debugPrint('AdIntegrationService: fetch failed, using cached ad: $e');
|
|
||||||
return _currentAd;
|
return _currentAd;
|
||||||
}
|
}
|
||||||
debugPrint('AdIntegrationService: fetch failed, no fallback ad: $e');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +93,6 @@ extension ListAdExtension on List<Post> {
|
||||||
|
|
||||||
final activeAd = ad ?? fallbackAd;
|
final activeAd = ad ?? fallbackAd;
|
||||||
if (activeAd == null) {
|
if (activeAd == null) {
|
||||||
debugPrint('AdIntegrationService: no ad available to interleave');
|
|
||||||
return [...this];
|
return [...this];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -580,8 +580,6 @@ class ApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('[Post] Publishing: body=$body, video=$videoUrl, thumb=$thumbnailUrl');
|
|
||||||
print('[Post] Sanitized: video=$sanitizedVideoUrl, thumb=$sanitizedThumbnailUrl');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final data = await _callGoApi(
|
final data = await _callGoApi(
|
||||||
|
|
@ -974,9 +972,6 @@ class ApiService {
|
||||||
return SearchResults.fromJson(data);
|
return SearchResults.fromJson(data);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('[API] Search failed for query: "$query"');
|
|
||||||
print('Error: $e');
|
|
||||||
print('Stack: $stack');
|
|
||||||
}
|
}
|
||||||
// Return empty results on error
|
// Return empty results on error
|
||||||
return SearchResults(users: [], tags: [], posts: []);
|
return SearchResults(users: [], tags: [], posts: []);
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,6 @@ class ImageUploadService {
|
||||||
request.fields['fileName'] = videoFile.path.split('/').last;
|
request.fields['fileName'] = videoFile.path.split('/').last;
|
||||||
|
|
||||||
onProgress?.call(0.1);
|
onProgress?.call(0.1);
|
||||||
print('Starting streamed video upload for: ${videoFile.path} ($fileLength bytes)');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final streamedResponse = await request.send();
|
final streamedResponse = await request.send();
|
||||||
|
|
@ -112,7 +111,6 @@ class ImageUploadService {
|
||||||
onProgress?.call(1.0);
|
onProgress?.call(1.0);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
print('Upload error: ${response.body}');
|
|
||||||
final errorData = jsonDecode(response.body) as Map<String, dynamic>;
|
final errorData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
throw UploadException(errorData['message'] ?? 'Upload failed');
|
throw UploadException(errorData['message'] ?? 'Upload failed');
|
||||||
}
|
}
|
||||||
|
|
@ -180,7 +178,6 @@ class ImageUploadService {
|
||||||
|
|
||||||
onProgress?.call(0.2);
|
onProgress?.call(0.2);
|
||||||
|
|
||||||
print('Starting direct upload for: $fileName (${fileBytes.length} bytes)');
|
|
||||||
|
|
||||||
return _uploadBytes(
|
return _uploadBytes(
|
||||||
fileBytes: fileBytes,
|
fileBytes: fileBytes,
|
||||||
|
|
@ -224,7 +221,6 @@ class ImageUploadService {
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress?.call(0.2);
|
onProgress?.call(0.2);
|
||||||
print('Starting direct upload for: $safeName (${fileBytes.length} bytes)');
|
|
||||||
|
|
||||||
return _uploadBytes(
|
return _uploadBytes(
|
||||||
fileBytes: fileBytes,
|
fileBytes: fileBytes,
|
||||||
|
|
@ -469,14 +465,12 @@ class ImageUploadService {
|
||||||
|
|
||||||
onProgress?.call(0.3);
|
onProgress?.call(0.3);
|
||||||
|
|
||||||
print('Uploading image via R2 bridge...');
|
|
||||||
final streamedResponse = await request.send();
|
final streamedResponse = await request.send();
|
||||||
final response = await http.Response.fromStream(streamedResponse);
|
final response = await http.Response.fromStream(streamedResponse);
|
||||||
|
|
||||||
onProgress?.call(0.9);
|
onProgress?.call(0.9);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
print('Upload error: ${response.body}');
|
|
||||||
final errorData = jsonDecode(response.body) as Map<String, dynamic>;
|
final errorData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
final errorMsg = errorData['error'] ?? 'Unknown error';
|
final errorMsg = errorData['error'] ?? 'Unknown error';
|
||||||
throw UploadException('Upload failed: $errorMsg');
|
throw UploadException('Upload failed: $errorMsg');
|
||||||
|
|
@ -486,14 +480,11 @@ class ImageUploadService {
|
||||||
final signedUrl = responseData['signedUrl'] ?? responseData['signed_url'];
|
final signedUrl = responseData['signedUrl'] ?? responseData['signed_url'];
|
||||||
final publicUrl = (signedUrl ?? responseData['publicUrl']) as String;
|
final publicUrl = (signedUrl ?? responseData['publicUrl']) as String;
|
||||||
|
|
||||||
print('Upload successful! Public URL: $publicUrl');
|
|
||||||
onProgress?.call(1.0);
|
onProgress?.call(1.0);
|
||||||
|
|
||||||
// FORCE FIX: Ensure custom domain is used even if backend returns raw R2 URL
|
// FORCE FIX: Ensure custom domain is used even if backend returns raw R2 URL
|
||||||
return _fixR2Url(publicUrl);
|
return _fixR2Url(publicUrl);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
print('Upload Service Error: $e');
|
|
||||||
print('Stack trace: $stack');
|
|
||||||
throw UploadException(e.toString());
|
throw UploadException(e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ class LocalKeyBackupService {
|
||||||
bool includeMessages = true,
|
bool includeMessages = true,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
print('[BACKUP] Creating encrypted backup (keys: $includeKeys, msgs: $includeMessages)...');
|
|
||||||
|
|
||||||
// 1. Export keys (if requested)
|
// 1. Export keys (if requested)
|
||||||
Map<String, dynamic>? keyData;
|
Map<String, dynamic>? keyData;
|
||||||
|
|
@ -48,7 +47,6 @@ class LocalKeyBackupService {
|
||||||
// 1b. Export messages if requested
|
// 1b. Export messages if requested
|
||||||
List<Map<String, dynamic>>? messageData;
|
List<Map<String, dynamic>>? messageData;
|
||||||
if (includeMessages) {
|
if (includeMessages) {
|
||||||
print('[BACKUP] Exporting messages...');
|
|
||||||
final messages = await LocalMessageStore.instance.getAllMessageRecords();
|
final messages = await LocalMessageStore.instance.getAllMessageRecords();
|
||||||
messageData = messages.map((m) => {
|
messageData = messages.map((m) => {
|
||||||
'conversationId': m.conversationId,
|
'conversationId': m.conversationId,
|
||||||
|
|
@ -61,7 +59,6 @@ class LocalKeyBackupService {
|
||||||
'readAt': m.readAt?.toIso8601String(),
|
'readAt': m.readAt?.toIso8601String(),
|
||||||
'expiresAt': m.expiresAt?.toIso8601String(),
|
'expiresAt': m.expiresAt?.toIso8601String(),
|
||||||
}).toList();
|
}).toList();
|
||||||
print('[BACKUP] Exported ${messages.length} messages');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final payloadData = {
|
final payloadData = {
|
||||||
|
|
@ -107,11 +104,9 @@ class LocalKeyBackupService {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
print('[BACKUP] Backup created successfully (${secretBox.cipherText.length} bytes)');
|
|
||||||
return backup;
|
return backup;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[BACKUP] Failed to create backup: $e');
|
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +118,6 @@ class LocalKeyBackupService {
|
||||||
required SimpleE2EEService e2eeService,
|
required SimpleE2EEService e2eeService,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
print('[BACKUP] Restoring from backup...');
|
|
||||||
|
|
||||||
// 1. Validate backup format
|
// 1. Validate backup format
|
||||||
_validateBackupFormat(backup);
|
_validateBackupFormat(backup);
|
||||||
|
|
@ -157,7 +151,6 @@ class LocalKeyBackupService {
|
||||||
int restoredMessages = 0;
|
int restoredMessages = 0;
|
||||||
if (payloadData is Map && payloadData.containsKey('messages')) {
|
if (payloadData is Map && payloadData.containsKey('messages')) {
|
||||||
final messages = (payloadData['messages'] as List).cast<Map<String, dynamic>>();
|
final messages = (payloadData['messages'] as List).cast<Map<String, dynamic>>();
|
||||||
print('[BACKUP] Restoring ${messages.length} messages...');
|
|
||||||
|
|
||||||
for (final m in messages) {
|
for (final m in messages) {
|
||||||
await LocalMessageStore.instance.saveMessageRecord(LocalMessageRecord(
|
await LocalMessageStore.instance.saveMessageRecord(LocalMessageRecord(
|
||||||
|
|
@ -175,7 +168,6 @@ class LocalKeyBackupService {
|
||||||
restoredMessages = messages.length;
|
restoredMessages = messages.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[BACKUP] Backup restored successfully');
|
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
'restored_keys': keyData != null ? (keyData['keys']?.length ?? 0) : 0,
|
'restored_keys': keyData != null ? (keyData['keys']?.length ?? 0) : 0,
|
||||||
|
|
@ -184,7 +176,6 @@ class LocalKeyBackupService {
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[BACKUP] Failed to restore backup: $e');
|
|
||||||
if (e is ArgumentError && e.message.contains('MAC')) {
|
if (e is ArgumentError && e.message.contains('MAC')) {
|
||||||
throw Exception('Invalid password or corrupted backup file');
|
throw Exception('Invalid password or corrupted backup file');
|
||||||
}
|
}
|
||||||
|
|
@ -195,7 +186,6 @@ class LocalKeyBackupService {
|
||||||
/// Save backup to device file
|
/// Save backup to device file
|
||||||
static Future<String> saveBackupToDevice(Map<String, dynamic> backup) async {
|
static Future<String> saveBackupToDevice(Map<String, dynamic> backup) async {
|
||||||
try {
|
try {
|
||||||
print('[BACKUP] Saving backup to device...');
|
|
||||||
|
|
||||||
// Web implementation - download file
|
// Web implementation - download file
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
|
|
@ -215,7 +205,6 @@ class LocalKeyBackupService {
|
||||||
html.document.body?.children.remove(anchor);
|
html.document.body?.children.remove(anchor);
|
||||||
html.Url.revokeObjectUrl(url);
|
html.Url.revokeObjectUrl(url);
|
||||||
|
|
||||||
print('[BACKUP] Backup downloaded to browser: $fileName');
|
|
||||||
return fileName;
|
return fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,11 +234,9 @@ class LocalKeyBackupService {
|
||||||
final backupJson = const JsonEncoder.withIndent(' ').convert(backup);
|
final backupJson = const JsonEncoder.withIndent(' ').convert(backup);
|
||||||
await file.writeAsString(backupJson);
|
await file.writeAsString(backupJson);
|
||||||
|
|
||||||
print('[BACKUP] Backup saved to: ${file.path}');
|
|
||||||
return file.path;
|
return file.path;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[BACKUP] Failed to save backup: $e');
|
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -257,7 +244,6 @@ class LocalKeyBackupService {
|
||||||
/// Load backup from device file
|
/// Load backup from device file
|
||||||
static Future<Map<String, dynamic>> loadBackupFromDevice() async {
|
static Future<Map<String, dynamic>> loadBackupFromDevice() async {
|
||||||
try {
|
try {
|
||||||
print('[BACKUP] Loading backup from device...');
|
|
||||||
|
|
||||||
// Web implementation - file upload
|
// Web implementation - file upload
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
|
|
@ -308,7 +294,6 @@ class LocalKeyBackupService {
|
||||||
|
|
||||||
// Wait for file to be processed
|
// Wait for file to be processed
|
||||||
final backup = await completer.future;
|
final backup = await completer.future;
|
||||||
print('[BACKUP] Backup loaded from browser upload');
|
|
||||||
return backup;
|
return backup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,11 +321,9 @@ class LocalKeyBackupService {
|
||||||
final content = await file.readAsString();
|
final content = await file.readAsString();
|
||||||
final backup = jsonDecode(content) as Map<String, dynamic>;
|
final backup = jsonDecode(content) as Map<String, dynamic>;
|
||||||
|
|
||||||
print('[BACKUP] Backup loaded from: ${file.path}');
|
|
||||||
return backup;
|
return backup;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[BACKUP] Failed to load backup: $e');
|
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -407,7 +390,6 @@ class LocalKeyBackupService {
|
||||||
static Future<void> uploadToCloud({
|
static Future<void> uploadToCloud({
|
||||||
required Map<String, dynamic> backup,
|
required Map<String, dynamic> backup,
|
||||||
}) async {
|
}) async {
|
||||||
print('[BACKUP] Uploading to cloud...');
|
|
||||||
|
|
||||||
// Get device name
|
// Get device name
|
||||||
String deviceName = 'Unknown Device';
|
String deviceName = 'Unknown Device';
|
||||||
|
|
@ -432,7 +414,6 @@ class LocalKeyBackupService {
|
||||||
deviceName: deviceName,
|
deviceName: deviceName,
|
||||||
version: 1, // Currently hardcoded version
|
version: 1, // Currently hardcoded version
|
||||||
);
|
);
|
||||||
print('[BACKUP] Upload successful');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore from cloud backup
|
/// Restore from cloud backup
|
||||||
|
|
@ -441,7 +422,6 @@ class LocalKeyBackupService {
|
||||||
required SimpleE2EEService e2eeService,
|
required SimpleE2EEService e2eeService,
|
||||||
String? backupId,
|
String? backupId,
|
||||||
}) async {
|
}) async {
|
||||||
print('[BACKUP] Downloading from cloud...');
|
|
||||||
final backupData = await ApiService.instance.downloadBackup(backupId);
|
final backupData = await ApiService.instance.downloadBackup(backupId);
|
||||||
|
|
||||||
if (backupData == null) {
|
if (backupData == null) {
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,6 @@ class LocalMessageStore {
|
||||||
return _cachedKey!;
|
return _cachedKey!;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[LOCAL_STORE] Warning: Could not read encryption key: $e');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final bytes = _generateRandomBytes(32);
|
final bytes = _generateRandomBytes(32);
|
||||||
|
|
@ -91,7 +90,6 @@ class LocalMessageStore {
|
||||||
return _cachedKey!;
|
return _cachedKey!;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (attempt == _maxRetries - 1) {
|
if (attempt == _maxRetries - 1) {
|
||||||
print(
|
|
||||||
'[LOCAL_STORE] Failed to store encryption key after $_maxRetries attempts: $e');
|
'[LOCAL_STORE] Failed to store encryption key after $_maxRetries attempts: $e');
|
||||||
}
|
}
|
||||||
await Future.delayed(_retryDelay * (attempt + 1));
|
await Future.delayed(_retryDelay * (attempt + 1));
|
||||||
|
|
@ -189,7 +187,6 @@ class LocalMessageStore {
|
||||||
success = true;
|
success = true;
|
||||||
break;
|
break;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(
|
|
||||||
'[LOCAL_STORE] Save attempt ${attempt + 1}/$_maxRetries failed for $messageId: $e');
|
'[LOCAL_STORE] Save attempt ${attempt + 1}/$_maxRetries failed for $messageId: $e');
|
||||||
if (attempt < _maxRetries - 1) {
|
if (attempt < _maxRetries - 1) {
|
||||||
await Future.delayed(_retryDelay * (attempt + 1));
|
await Future.delayed(_retryDelay * (attempt + 1));
|
||||||
|
|
@ -243,7 +240,6 @@ class LocalMessageStore {
|
||||||
if (version >= 2 && storedHash != null) {
|
if (version >= 2 && storedHash != null) {
|
||||||
final computedHash = _computeHash(plaintext);
|
final computedHash = _computeHash(plaintext);
|
||||||
if (computedHash != storedHash) {
|
if (computedHash != storedHash) {
|
||||||
print('[LOCAL_STORE] Integrity check failed for $messageId');
|
|
||||||
return pending?.plaintext;
|
return pending?.plaintext;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -251,11 +247,9 @@ class LocalMessageStore {
|
||||||
return plaintext;
|
return plaintext;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.toString().contains('SecretBoxAuthenticationError')) {
|
if (e.toString().contains('SecretBoxAuthenticationError')) {
|
||||||
print('[LOCAL_STORE] MAC mismatch for $messageId - record is corrupt or key changed. Deleting.');
|
|
||||||
await _messageBox!.delete(messageId);
|
await _messageBox!.delete(messageId);
|
||||||
return pending?.plaintext;
|
return pending?.plaintext;
|
||||||
}
|
}
|
||||||
print(
|
|
||||||
'[LOCAL_STORE] Read attempt ${attempt + 1}/$_maxRetries failed for $messageId: $e');
|
'[LOCAL_STORE] Read attempt ${attempt + 1}/$_maxRetries failed for $messageId: $e');
|
||||||
if (attempt < _maxRetries - 1) {
|
if (attempt < _maxRetries - 1) {
|
||||||
await Future.delayed(_retryDelay * (attempt + 1));
|
await Future.delayed(_retryDelay * (attempt + 1));
|
||||||
|
|
@ -304,7 +298,6 @@ class LocalMessageStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[LOCAL_STORE] Batch load failed for conversation $conversationId: $e');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include any pending saves from WAL
|
// Include any pending saves from WAL
|
||||||
|
|
@ -360,7 +353,6 @@ class LocalMessageStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[LOCAL_STORE] Batch record load failed for $conversationId: $e');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include any pending saves from WAL
|
// Include any pending saves from WAL
|
||||||
|
|
@ -396,7 +388,6 @@ class LocalMessageStore {
|
||||||
results.addAll(messages);
|
results.addAll(messages);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[LOCAL_STORE] Failed to get all messages: $e');
|
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
@ -427,7 +418,6 @@ class LocalMessageStore {
|
||||||
try {
|
try {
|
||||||
return await _getConversationIndex(conversationId);
|
return await _getConversationIndex(conversationId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[LOCAL_STORE] Failed to get message IDs for $conversationId: $e');
|
|
||||||
return <String>[];
|
return <String>[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -463,7 +453,6 @@ class LocalMessageStore {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(
|
|
||||||
'[LOCAL_STORE] Delete attempt ${attempt + 1}/$_maxRetries failed for $messageId: $e');
|
'[LOCAL_STORE] Delete attempt ${attempt + 1}/$_maxRetries failed for $messageId: $e');
|
||||||
if (attempt < _maxRetries - 1) {
|
if (attempt < _maxRetries - 1) {
|
||||||
await Future.delayed(_retryDelay * (attempt + 1));
|
await Future.delayed(_retryDelay * (attempt + 1));
|
||||||
|
|
@ -508,7 +497,6 @@ class LocalMessageStore {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[LOCAL_STORE] Failed to delete conversation $conversationId: $e');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -531,7 +519,6 @@ class LocalMessageStore {
|
||||||
expiresAt: pending.expiresAt,
|
expiresAt: pending.expiresAt,
|
||||||
);
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
print('[LOCAL_STORE] Flushed pending write for ${entry.key}');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -620,7 +607,6 @@ class LocalMessageStore {
|
||||||
if (version >= 2 && storedHash != null) {
|
if (version >= 2 && storedHash != null) {
|
||||||
final computedHash = _computeHash(plaintext);
|
final computedHash = _computeHash(plaintext);
|
||||||
if (computedHash != storedHash) {
|
if (computedHash != storedHash) {
|
||||||
print('[LOCAL_STORE] Integrity check failed for $messageId');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -648,11 +634,9 @@ class LocalMessageStore {
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.toString().contains('SecretBoxAuthenticationError')) {
|
if (e.toString().contains('SecretBoxAuthenticationError')) {
|
||||||
print('[LOCAL_STORE] MAC mismatch for record $messageId - deleting corrupt record.');
|
|
||||||
_messageBox!.delete(messageId);
|
_messageBox!.delete(messageId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
print('[LOCAL_STORE] Failed to parse record $messageId: $e');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,22 +73,18 @@ class SecureChatService {
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
_wsChannel!.sink.add(jsonEncode(keyRecoveryEvent));
|
_wsChannel!.sink.add(jsonEncode(keyRecoveryEvent));
|
||||||
print('[WS] Key recovery event broadcasted for user: $userId');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[WS] Failed to broadcast key recovery: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force reset to fix 208-bit key bug
|
// Force reset to fix 208-bit key bug
|
||||||
Future<void> forceResetBrokenKeys() async {
|
Future<void> forceResetBrokenKeys() async {
|
||||||
print('[CHAT] Force resetting broken keys to fix 208-bit bug');
|
|
||||||
await _e2ee.forceResetBrokenKeys();
|
await _e2ee.forceResetBrokenKeys();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual key upload for testing
|
// Manual key upload for testing
|
||||||
Future<void> uploadKeysManually() async {
|
Future<void> uploadKeysManually() async {
|
||||||
print('[CHAT] Manual key upload requested');
|
|
||||||
await _e2ee.uploadKeysManually();
|
await _e2ee.uploadKeysManually();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +106,6 @@ class SecureChatService {
|
||||||
final wsUrl = Uri.parse(ApiConfig.baseUrl)
|
final wsUrl = Uri.parse(ApiConfig.baseUrl)
|
||||||
.replace(scheme: ApiConfig.baseUrl.startsWith('https') ? 'wss' : 'ws', path: '/ws', queryParameters: {'token': token});
|
.replace(scheme: ApiConfig.baseUrl.startsWith('https') ? 'wss' : 'ws', path: '/ws', queryParameters: {'token': token});
|
||||||
|
|
||||||
print('[WS] Connecting to $wsUrl');
|
|
||||||
_isReconnecting = true;
|
_isReconnecting = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -130,7 +125,6 @@ class SecureChatService {
|
||||||
return; // Silently ignore
|
return; // Silently ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[WS] Received: ${jsonEncode(data)}');
|
|
||||||
|
|
||||||
if (type == 'new_message') {
|
if (type == 'new_message') {
|
||||||
final payload = data['payload'];
|
final payload = data['payload'];
|
||||||
|
|
@ -143,7 +137,6 @@ class SecureChatService {
|
||||||
final messageId = payload['message_id'];
|
final messageId = payload['message_id'];
|
||||||
final conversationId = payload['conversation_id'];
|
final conversationId = payload['conversation_id'];
|
||||||
if (messageId != null && conversationId != null) {
|
if (messageId != null && conversationId != null) {
|
||||||
print('[WS] IMMEDIATE DELETE: $messageId');
|
|
||||||
_locallyDeletedMessageIds.add(messageId);
|
_locallyDeletedMessageIds.add(messageId);
|
||||||
unawaited(_localStore.deleteMessage(messageId));
|
unawaited(_localStore.deleteMessage(messageId));
|
||||||
_processedMessageIds[conversationId]?.remove(messageId);
|
_processedMessageIds[conversationId]?.remove(messageId);
|
||||||
|
|
@ -156,7 +149,6 @@ class SecureChatService {
|
||||||
final payload = data['payload'];
|
final payload = data['payload'];
|
||||||
final conversationId = payload['conversation_id'];
|
final conversationId = payload['conversation_id'];
|
||||||
if (conversationId != null) {
|
if (conversationId != null) {
|
||||||
print('[WS] CONVERSATION DELETED: $conversationId');
|
|
||||||
unawaited(_localStore.deleteConversation(conversationId));
|
unawaited(_localStore.deleteConversation(conversationId));
|
||||||
_processedMessageIds.remove(conversationId);
|
_processedMessageIds.remove(conversationId);
|
||||||
_localControllers[conversationId]?.close();
|
_localControllers[conversationId]?.close();
|
||||||
|
|
@ -169,7 +161,6 @@ class SecureChatService {
|
||||||
final userId = payload['user_id'];
|
final userId = payload['user_id'];
|
||||||
final currentUserId = _auth.currentUser?.id;
|
final currentUserId = _auth.currentUser?.id;
|
||||||
if (userId != null && currentUserId != null && userId == currentUserId) {
|
if (userId != null && currentUserId != null && userId == currentUserId) {
|
||||||
print('[WS] KEY RECOVERY EVENT RECEIVED - triggering local recovery');
|
|
||||||
unawaited(_e2ee.initiateKeyRecovery(currentUserId));
|
unawaited(_e2ee.initiateKeyRecovery(currentUserId));
|
||||||
}
|
}
|
||||||
} else if (data['type'] == 'pong') {
|
} else if (data['type'] == 'pong') {
|
||||||
|
|
@ -177,20 +168,16 @@ class SecureChatService {
|
||||||
_lastHeartbeat = DateTime.now();
|
_lastHeartbeat = DateTime.now();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[WS] Parse error: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, onError: (e) {
|
}, onError: (e) {
|
||||||
print('[WS] ERROR - IMMEDIATE RECONNECT: $e');
|
|
||||||
_cleanup();
|
_cleanup();
|
||||||
Future.delayed(const Duration(seconds: 1), connectRealtime);
|
Future.delayed(const Duration(seconds: 1), connectRealtime);
|
||||||
}, onDone: () {
|
}, onDone: () {
|
||||||
print('[WS] DISCONNECTED - IMMEDIATE RECONNECT');
|
|
||||||
_cleanup();
|
_cleanup();
|
||||||
Future.delayed(const Duration(seconds: 1), connectRealtime);
|
Future.delayed(const Duration(seconds: 1), connectRealtime);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[WS] Connection failed: $e');
|
|
||||||
_isReconnecting = false;
|
_isReconnecting = false;
|
||||||
Future.delayed(const Duration(seconds: 2), connectRealtime);
|
Future.delayed(const Duration(seconds: 2), connectRealtime);
|
||||||
}
|
}
|
||||||
|
|
@ -299,7 +286,6 @@ class SecureChatService {
|
||||||
unawaited(_emitLocal(conversationId));
|
unawaited(_emitLocal(conversationId));
|
||||||
return msg;
|
return msg;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[CHAT] Failed to send message: $e');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -341,11 +327,8 @@ class SecureChatService {
|
||||||
if (forEveryone) {
|
if (forEveryone) {
|
||||||
unawaited(_api.deleteMessage(messageId).then((success) {
|
unawaited(_api.deleteMessage(messageId).then((success) {
|
||||||
if (!success) {
|
if (!success) {
|
||||||
print('[CHAT] Server delete failed - message already removed locally');
|
|
||||||
}
|
}
|
||||||
}).catchError((e) {
|
}).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);
|
final messages = await _localStore.getMessagesForConversation(conversationId);
|
||||||
if (messages.isEmpty) {
|
if (messages.isEmpty) {
|
||||||
print('[CHAT] Conversation $conversationId is empty - deleting');
|
|
||||||
await deleteConversation(conversationId, fullDelete: true);
|
await deleteConversation(conversationId, fullDelete: true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[CHAT] Error checking empty conversation: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,7 +352,6 @@ class SecureChatService {
|
||||||
String conversationId, {
|
String conversationId, {
|
||||||
bool fullDelete = false,
|
bool fullDelete = false,
|
||||||
}) async {
|
}) async {
|
||||||
print('[CHAT] Deleting conversation: $conversationId (fullDelete: $fullDelete)');
|
|
||||||
|
|
||||||
// Clear local state IMMEDIATELY
|
// Clear local state IMMEDIATELY
|
||||||
_processedMessageIds.remove(conversationId);
|
_processedMessageIds.remove(conversationId);
|
||||||
|
|
@ -391,12 +371,9 @@ class SecureChatService {
|
||||||
if (fullDelete) {
|
if (fullDelete) {
|
||||||
unawaited(_api.deleteConversation(conversationId).then((success) {
|
unawaited(_api.deleteConversation(conversationId).then((success) {
|
||||||
if (success) {
|
if (success) {
|
||||||
print('[CHAT] Conversation permanently deleted from server');
|
|
||||||
} else {
|
} else {
|
||||||
print('[CHAT] Server delete failed - already removed locally');
|
|
||||||
}
|
}
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
print('[CHAT] Error deleting conversation from server: $e');
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return DeleteResult(success: true);
|
return DeleteResult(success: true);
|
||||||
|
|
@ -430,7 +407,6 @@ class SecureChatService {
|
||||||
final rows = await _api.getConversationMessages(conversationId);
|
final rows = await _api.getConversationMessages(conversationId);
|
||||||
await _ingestRemoteSnapshot(conversationId, rows);
|
await _ingestRemoteSnapshot(conversationId, rows);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[SYNC] Failed to sync $conversationId: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -440,7 +416,6 @@ class SecureChatService {
|
||||||
final rows = await _api.getConversationMessages(conversationId, limit: limit);
|
final rows = await _api.getConversationMessages(conversationId, limit: limit);
|
||||||
await _ingestRemoteSnapshot(conversationId, rows);
|
await _ingestRemoteSnapshot(conversationId, rows);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[SYNC] Failed to hydrate $conversationId: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -449,7 +424,6 @@ class SecureChatService {
|
||||||
await _e2ee.initialize();
|
await _e2ee.initialize();
|
||||||
|
|
||||||
if (!_e2ee.isReady) {
|
if (!_e2ee.isReady) {
|
||||||
print('[CHAT] Not ready to decrypt.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -511,7 +485,6 @@ class SecureChatService {
|
||||||
|
|
||||||
_processedMessageIds.putIfAbsent(conversationId, () => <String>{}).add(msg.id);
|
_processedMessageIds.putIfAbsent(conversationId, () => <String>{}).add(msg.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[DECRYPT] Failed for ${msg.id}: $e');
|
|
||||||
if (e.toString().contains('Invalid Key') || e.toString().contains('MAC')) {
|
if (e.toString().contains('Invalid Key') || e.toString().contains('MAC')) {
|
||||||
await _localStore.saveMessage(
|
await _localStore.saveMessage(
|
||||||
conversationId: conversationId,
|
conversationId: conversationId,
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,6 @@ class SimpleE2EEService {
|
||||||
// DO NOT add debug flags here - use resetAllKeys() method for intentional resets
|
// DO NOT add debug flags here - use resetAllKeys() method for intentional resets
|
||||||
|
|
||||||
Future<void> resetAllKeys() async {
|
Future<void> resetAllKeys() async {
|
||||||
print('[E2EE] RESETTING ALL KEYS - fixing MAC errors');
|
|
||||||
|
|
||||||
// Clear all storage
|
// Clear all storage
|
||||||
await _storage.deleteAll();
|
await _storage.deleteAll();
|
||||||
|
|
@ -91,12 +90,10 @@ class SimpleE2EEService {
|
||||||
// Generate fresh identity
|
// Generate fresh identity
|
||||||
await generateNewIdentity();
|
await generateNewIdentity();
|
||||||
|
|
||||||
print('[E2EE] Keys reset complete - fresh identity generated');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force reset to fix 208-bit key bug
|
// Force reset to fix 208-bit key bug
|
||||||
Future<void> forceResetBrokenKeys() async {
|
Future<void> forceResetBrokenKeys() async {
|
||||||
print('[E2EE] FORCE RESET - Clearing all broken 208-bit keys');
|
|
||||||
|
|
||||||
// Clear ALL storage completely
|
// Clear ALL storage completely
|
||||||
await _storage.deleteAll();
|
await _storage.deleteAll();
|
||||||
|
|
@ -112,7 +109,6 @@ class SimpleE2EEService {
|
||||||
// Clear session cache
|
// Clear session cache
|
||||||
_sessionCache.clear();
|
_sessionCache.clear();
|
||||||
|
|
||||||
print('[E2EE] All keys cleared - generating fresh 256-bit keys');
|
|
||||||
|
|
||||||
// Generate fresh identity with proper key lengths
|
// Generate fresh identity with proper key lengths
|
||||||
await generateNewIdentity();
|
await generateNewIdentity();
|
||||||
|
|
@ -120,20 +116,16 @@ class SimpleE2EEService {
|
||||||
// Verify the new keys are proper length
|
// Verify the new keys are proper length
|
||||||
if (_identityDhKeyPair != null) {
|
if (_identityDhKeyPair != null) {
|
||||||
final publicKey = await _identityDhKeyPair!.extractPublicKey();
|
final publicKey = await _identityDhKeyPair!.extractPublicKey();
|
||||||
print('[E2EE] New identity DH key: ${publicKey.bytes.length} bytes');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_identitySigningKeyPair != null) {
|
if (_identitySigningKeyPair != null) {
|
||||||
final publicKey = await _identitySigningKeyPair!.extractPublicKey();
|
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
|
// Manual key upload for testing
|
||||||
Future<void> uploadKeysManually() async {
|
Future<void> uploadKeysManually() async {
|
||||||
print('[E2EE] Manual key upload requested');
|
|
||||||
|
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
throw Exception('Keys not ready - generate keys first');
|
throw Exception('Keys not ready - generate keys first');
|
||||||
|
|
@ -154,7 +146,6 @@ class SimpleE2EEService {
|
||||||
}
|
}
|
||||||
|
|
||||||
await _publishKeys(spkSignature);
|
await _publishKeys(spkSignature);
|
||||||
print('[E2EE] Manual key upload completed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if keys exist on backend
|
// Check if keys exist on backend
|
||||||
|
|
@ -167,21 +158,17 @@ class SimpleE2EEService {
|
||||||
|
|
||||||
// If we get a successful response with key data, keys exist
|
// If we get a successful response with key data, keys exist
|
||||||
if (response.containsKey('identity_key')) {
|
if (response.containsKey('identity_key')) {
|
||||||
print('[E2EE] Keys found on backend');
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
print('[E2EE] Keys not found on backend');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[E2EE] Error checking backend keys: $e');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload existing keys to backend
|
// Upload existing keys to backend
|
||||||
Future<void> _uploadExistingKeys() async {
|
Future<void> _uploadExistingKeys() async {
|
||||||
print('[E2EE] Uploading existing keys to backend...');
|
|
||||||
|
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
throw Exception('Keys not ready for upload');
|
throw Exception('Keys not ready for upload');
|
||||||
|
|
@ -196,7 +183,6 @@ class SimpleE2EEService {
|
||||||
final spkSignature = signature.bytes;
|
final spkSignature = signature.bytes;
|
||||||
|
|
||||||
await _publishKeys(spkSignature);
|
await _publishKeys(spkSignature);
|
||||||
print('[E2EE] Existing keys uploaded to backend');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _doInitialize(String userId) async {
|
Future<void> _doInitialize(String userId) async {
|
||||||
|
|
@ -208,50 +194,41 @@ class SimpleE2EEService {
|
||||||
try {
|
try {
|
||||||
final loaded = await _loadKeysFromLocal(userId);
|
final loaded = await _loadKeysFromLocal(userId);
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
print('[E2EE] Keys loaded from local storage.');
|
|
||||||
// Test if keys are working by attempting a simple encrypt/decrypt
|
// Test if keys are working by attempting a simple encrypt/decrypt
|
||||||
if (await _testKeyCompatibility()) {
|
if (await _testKeyCompatibility()) {
|
||||||
// Check if keys exist on backend, upload if not
|
// Check if keys exist on backend, upload if not
|
||||||
if (await _checkKeysExistOnBackend()) {
|
if (await _checkKeysExistOnBackend()) {
|
||||||
final backendValid = await _validateBackendKeyBundle(userId);
|
final backendValid = await _validateBackendKeyBundle(userId);
|
||||||
if (!backendValid) {
|
if (!backendValid) {
|
||||||
print('[E2EE] Backend key bundle failed signature verification - reuploading');
|
|
||||||
await _uploadExistingKeys();
|
await _uploadExistingKeys();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
print('[E2EE] Keys exist on backend - ready');
|
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
print('[E2EE] Keys missing on backend - uploading now');
|
|
||||||
await _uploadExistingKeys();
|
await _uploadExistingKeys();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print('[E2EE] Keys failed compatibility test - initiating recovery');
|
|
||||||
await initiateKeyRecovery(userId);
|
await initiateKeyRecovery(userId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[E2EE] Error loading keys: $e. Regenerating.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Try Cloud Restore
|
// 2. Try Cloud Restore
|
||||||
final restored = await _restoreFromCloud(userId);
|
final restored = await _restoreFromCloud(userId);
|
||||||
if (restored) {
|
if (restored) {
|
||||||
print('[E2EE] Keys restored from cloud backup.');
|
|
||||||
// Test restored keys
|
// Test restored keys
|
||||||
if (await _testKeyCompatibility()) {
|
if (await _testKeyCompatibility()) {
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
print('[E2EE] Restored keys failed compatibility test - initiating recovery');
|
|
||||||
await initiateKeyRecovery(userId);
|
await initiateKeyRecovery(userId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Generate New
|
// 3. Generate New
|
||||||
print('[E2EE] No keys found. Generating new Identity.');
|
|
||||||
await generateNewIdentity();
|
await generateNewIdentity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -274,7 +251,6 @@ class SimpleE2EEService {
|
||||||
|
|
||||||
// Verify key length
|
// Verify key length
|
||||||
if (testKeyBytes.length != 32) {
|
if (testKeyBytes.length != 32) {
|
||||||
print('[E2EE] ERROR: Test key is ${testKeyBytes.length} bytes, expected 32');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,10 +266,8 @@ class SimpleE2EEService {
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = utf8.decode(decrypted) == testMessage;
|
final result = utf8.decode(decrypted) == testMessage;
|
||||||
print('[E2EE] Local encryption test: $result (key length: ${testKeyBytes.length} bytes)');
|
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[E2EE] Key compatibility test failed: $e');
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -337,14 +311,12 @@ class SimpleE2EEService {
|
||||||
|
|
||||||
return verified;
|
return verified;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[E2EE] Backend key bundle validation failed: $e');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smart key recovery that preserves messages when possible
|
// Smart key recovery that preserves messages when possible
|
||||||
Future<void> initiateKeyRecovery(String userId) async {
|
Future<void> initiateKeyRecovery(String userId) async {
|
||||||
print('[E2EE] Starting smart key recovery...');
|
|
||||||
|
|
||||||
// Try to preserve existing messages by backing up encrypted content
|
// Try to preserve existing messages by backing up encrypted content
|
||||||
final messageBackup = await _backupEncryptedMessages();
|
final messageBackup = await _backupEncryptedMessages();
|
||||||
|
|
@ -354,12 +326,10 @@ class SimpleE2EEService {
|
||||||
|
|
||||||
// Restore message backup with new keys if possible
|
// Restore message backup with new keys if possible
|
||||||
if (messageBackup > 0) {
|
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"
|
// Note: Messages encrypted with old keys will show as "encrypted with old keys"
|
||||||
// but new messages will work perfectly
|
// 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
|
// Backup encrypted messages to preserve them during key recovery
|
||||||
|
|
@ -367,10 +337,8 @@ class SimpleE2EEService {
|
||||||
try {
|
try {
|
||||||
// This would integrate with local message store to count/preserve messages
|
// This would integrate with local message store to count/preserve messages
|
||||||
// For now, just log that we're attempting preservation
|
// 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
|
return 0; // Return count of backed up messages
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[E2EE] Error backing up messages: $e');
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -379,7 +347,6 @@ class SimpleE2EEService {
|
||||||
final userId = _auth.currentUser?.id;
|
final userId = _auth.currentUser?.id;
|
||||||
if (userId == null) return;
|
if (userId == null) return;
|
||||||
|
|
||||||
print('[E2EE] Generating X3DH Key Bundle...');
|
|
||||||
|
|
||||||
// 1. Identity Key Pair (DH)
|
// 1. Identity Key Pair (DH)
|
||||||
_identityDhKeyPair = await _dhAlgo.newKeyPair();
|
_identityDhKeyPair = await _dhAlgo.newKeyPair();
|
||||||
|
|
@ -421,13 +388,11 @@ class SimpleE2EEService {
|
||||||
if (!_auth.isAuthenticated) throw Exception('Not authenticated');
|
if (!_auth.isAuthenticated) throw Exception('Not authenticated');
|
||||||
await initialize();
|
await initialize();
|
||||||
|
|
||||||
print('[ENCRYPT] Fetching key bundle for recipient: $recipientId');
|
|
||||||
|
|
||||||
// 1. Fetch Bundle
|
// 1. Fetch Bundle
|
||||||
final bundle = await ApiService(AuthService.instance).getKeyBundle(recipientId);
|
final bundle = await ApiService(AuthService.instance).getKeyBundle(recipientId);
|
||||||
|
|
||||||
// DEBUG: Validate Bundle
|
// DEBUG: Validate Bundle
|
||||||
print('[ENCRYPT] Bundle keys: ${bundle.keys.toList()}');
|
|
||||||
|
|
||||||
// Handle both formats:
|
// Handle both formats:
|
||||||
// Flat (from getKeyBundle normalization): { "identity_key_public": "...", "signed_prekey_public": "...", "signed_prekey_signature": "..." }
|
// 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'];
|
otkId = bundle['one_time_prekey_id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[ENCRYPT] IK: $ikField');
|
|
||||||
print('[ENCRYPT] SPK: $spkField');
|
|
||||||
|
|
||||||
if (ikField == null || ikField.isEmpty) {
|
if (ikField == null || ikField.isEmpty) {
|
||||||
throw Exception('Recipient identity_key not found in bundle. Structure: $bundle');
|
throw Exception('Recipient identity_key not found in bundle. Structure: $bundle');
|
||||||
|
|
@ -522,7 +485,6 @@ class SimpleE2EEService {
|
||||||
if (!isVerified) {
|
if (!isVerified) {
|
||||||
throw Exception('E2EE SECURITY ALERT: Recipient Signed PreKey signature verification failed!');
|
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 theirIk = SimplePublicKey(theirIkDhBytes, type: KeyPairType.x25519);
|
||||||
final theirSpk = SimplePublicKey(theirSpkBytes, type: KeyPairType.x25519);
|
final theirSpk = SimplePublicKey(theirSpkBytes, type: KeyPairType.x25519);
|
||||||
|
|
@ -626,9 +588,7 @@ class SimpleE2EEService {
|
||||||
final matchingOtk = _oneTimePreKeys![otkId];
|
final matchingOtk = _oneTimePreKeys![otkId];
|
||||||
final dh4 = await _dhAlgo.sharedSecretKey(keyPair: matchingOtk, remotePublicKey: senderEk);
|
final dh4 = await _dhAlgo.sharedSecretKey(keyPair: matchingOtk, remotePublicKey: senderEk);
|
||||||
dhBytes.addAll(await dh4.extractBytes());
|
dhBytes.addAll(await dh4.extractBytes());
|
||||||
print('[DECRYPT] Used OTK with key_id: $otkId');
|
|
||||||
} else {
|
} 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
|
// Decryption successful - plaintext not logged for security
|
||||||
return plaintext;
|
return plaintext;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[DECRYPT] Failed: $e');
|
|
||||||
if (e.toString().contains('MAC') || e.toString().contains('SecretBoxAuthenticationError')) {
|
if (e.toString().contains('MAC') || e.toString().contains('SecretBoxAuthenticationError')) {
|
||||||
// Automatic key recovery on MAC errors
|
// Automatic key recovery on MAC errors
|
||||||
_handleMacError();
|
_handleMacError();
|
||||||
|
|
@ -661,11 +620,9 @@ class SimpleE2EEService {
|
||||||
_macErrorCount++;
|
_macErrorCount++;
|
||||||
_lastMacErrorTime = DateTime.now();
|
_lastMacErrorTime = DateTime.now();
|
||||||
|
|
||||||
print('[E2EE] MAC error #$_macErrorCount detected');
|
|
||||||
|
|
||||||
// If we get multiple MAC errors in quick succession, trigger recovery
|
// If we get multiple MAC errors in quick succession, trigger recovery
|
||||||
if (_macErrorCount >= _maxMacErrors) {
|
if (_macErrorCount >= _maxMacErrors) {
|
||||||
print('[E2EE] Multiple MAC errors detected - triggering automatic key recovery');
|
|
||||||
_triggerAutomaticRecovery();
|
_triggerAutomaticRecovery();
|
||||||
_macErrorCount = 0; // Reset counter
|
_macErrorCount = 0; // Reset counter
|
||||||
}
|
}
|
||||||
|
|
@ -675,10 +632,8 @@ class SimpleE2EEService {
|
||||||
final userId = _auth.currentUser?.id;
|
final userId = _auth.currentUser?.id;
|
||||||
if (userId == null) return;
|
if (userId == null) return;
|
||||||
|
|
||||||
print('[E2EE] AUTOMATIC KEY RECOVERY TRIGGERED');
|
|
||||||
|
|
||||||
// Show user-friendly notification
|
// Show user-friendly notification
|
||||||
print('[E2EE] User notification: "Fixing encryption issues..."');
|
|
||||||
|
|
||||||
// Initiate smart recovery
|
// Initiate smart recovery
|
||||||
await initiateKeyRecovery(userId);
|
await initiateKeyRecovery(userId);
|
||||||
|
|
@ -686,27 +641,22 @@ class SimpleE2EEService {
|
||||||
// Broadcast key recovery event to all user's devices
|
// Broadcast key recovery event to all user's devices
|
||||||
_broadcastKeyRecovery(userId);
|
_broadcastKeyRecovery(userId);
|
||||||
|
|
||||||
print('[E2EE] Automatic recovery complete - new messages will work properly');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _broadcastKeyRecovery(String userId) {
|
void _broadcastKeyRecovery(String userId) {
|
||||||
// Broadcast key recovery event to all user's devices via WebSocket
|
// Broadcast key recovery event to all user's devices via WebSocket
|
||||||
_chatService?.broadcastKeyRecovery(userId);
|
_chatService?.broadcastKeyRecovery(userId);
|
||||||
print('[E2EE] Broadcasting key recovery to all devices for user: $userId');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete used OTK from server to prevent reuse
|
// Delete used OTK from server to prevent reuse
|
||||||
Future<void> _deleteUsedOTK(int keyId) async {
|
Future<void> _deleteUsedOTK(int keyId) async {
|
||||||
try {
|
try {
|
||||||
await _api.callGoApi('/keys/otk/$keyId', method: 'DELETE');
|
await _api.callGoApi('/keys/otk/$keyId', method: 'DELETE');
|
||||||
print('[E2EE] Deleted used OTK #$keyId from server');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final message = e.toString();
|
final message = e.toString();
|
||||||
if (message.contains('route not found') || message.contains('404')) {
|
if (message.contains('route not found') || message.contains('404')) {
|
||||||
print('[E2EE] OTK delete endpoint missing on backend; skipping cleanup for #$keyId');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
print('[E2EE] Error deleting OTK #$keyId: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -721,7 +671,6 @@ class SimpleE2EEService {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _publishKeys(List<int> spkSignature) async {
|
Future<void> _publishKeys(List<int> spkSignature) async {
|
||||||
print('[E2EE] Publishing key bundle to backend...');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final skPublic = await _identitySigningKeyPair!.extractPublicKey();
|
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
|
// Verify signature is not all zeros before upload
|
||||||
final allZeros = spkSignature.every((b) => b == 0);
|
final allZeros = spkSignature.every((b) => b == 0);
|
||||||
|
|
@ -757,9 +705,7 @@ class SimpleE2EEService {
|
||||||
oneTimePrekeys: otks,
|
oneTimePrekeys: otks,
|
||||||
);
|
);
|
||||||
|
|
||||||
print('[E2EE] Key bundle uploaded successfully to backend');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[E2EE] FAILED to upload key bundle: $e');
|
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -784,27 +730,21 @@ class SimpleE2EEService {
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString('e2ee_keys_$userId', data);
|
await prefs.setString('e2ee_keys_$userId', data);
|
||||||
print('[E2EE] Keys also saved to SharedPreferences (web fallback)');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _loadKeysFromLocal(String userId) async {
|
Future<bool> _loadKeysFromLocal(String userId) async {
|
||||||
print('[E2EE] Attempting to load keys for user: $userId');
|
|
||||||
|
|
||||||
// Try FlutterSecureStorage first
|
// Try FlutterSecureStorage first
|
||||||
var data = await _storage.read(key: 'e2ee_keys_$userId');
|
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
|
// Fallback to SharedPreferences on web if secure storage fails
|
||||||
if (data == null && kIsWeb) {
|
if (data == null && kIsWeb) {
|
||||||
print('[E2EE] Trying SharedPreferences fallback for web...');
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
data = prefs.getString('e2ee_keys_$userId');
|
data = prefs.getString('e2ee_keys_$userId');
|
||||||
print('[E2EE] SharedPreferences read result: ${data != null ? "found (${data.length} chars)" : "null"}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
print('[E2EE] No keys found in any storage');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -829,7 +769,6 @@ class SimpleE2EEService {
|
||||||
for (final otkSeed in map['otks']) {
|
for (final otkSeed in map['otks']) {
|
||||||
_oneTimePreKeys!.add(await _dhAlgo.newKeyPairFromSeed(base64Decode(otkSeed)));
|
_oneTimePreKeys!.add(await _dhAlgo.newKeyPairFromSeed(base64Decode(otkSeed)));
|
||||||
}
|
}
|
||||||
print('[E2EE] Loaded ${_oneTimePreKeys!.length} OTKs from local storage');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return isReady;
|
return isReady;
|
||||||
|
|
@ -914,7 +853,6 @@ class SimpleE2EEService {
|
||||||
|
|
||||||
return isReady;
|
return isReady;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Restore failed: $e');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -925,7 +863,6 @@ class SimpleE2EEService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print('[E2EE] Exporting all keys for backup...');
|
|
||||||
|
|
||||||
final identityDhPublic = await _identityDhKeyPair!.extractPublicKey();
|
final identityDhPublic = await _identityDhKeyPair!.extractPublicKey();
|
||||||
final identitySigningPublic = await _identitySigningKeyPair!.extractPublicKey();
|
final identitySigningPublic = await _identitySigningKeyPair!.extractPublicKey();
|
||||||
|
|
@ -968,18 +905,15 @@ class SimpleE2EEService {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
print('[E2EE] Keys exported successfully');
|
|
||||||
return exportData;
|
return exportData;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[E2EE] Failed to export keys: $e');
|
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> importAllKeys(Map<String, dynamic> backupData) async {
|
Future<void> importAllKeys(Map<String, dynamic> backupData) async {
|
||||||
try {
|
try {
|
||||||
print('[E2EE] Importing keys from backup...');
|
|
||||||
|
|
||||||
if (!backupData.containsKey('keys')) {
|
if (!backupData.containsKey('keys')) {
|
||||||
throw ArgumentError('Invalid backup format: missing keys');
|
throw ArgumentError('Invalid backup format: missing keys');
|
||||||
|
|
@ -989,18 +923,15 @@ class SimpleE2EEService {
|
||||||
|
|
||||||
// 1. Restore Identity Keys
|
// 1. Restore Identity Keys
|
||||||
if (keys.containsKey('identity_dh_private')) {
|
if (keys.containsKey('identity_dh_private')) {
|
||||||
print('[E2EE] Restoring Identity DH key...');
|
|
||||||
_identityDhKeyPair = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['identity_dh_private']));
|
_identityDhKeyPair = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['identity_dh_private']));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keys.containsKey('identity_signing_private')) {
|
if (keys.containsKey('identity_signing_private')) {
|
||||||
print('[E2EE] Restoring Identity Signing key...');
|
|
||||||
_identitySigningKeyPair = await _signingAlgo.newKeyPairFromSeed(base64Decode(keys['identity_signing_private']));
|
_identitySigningKeyPair = await _signingAlgo.newKeyPairFromSeed(base64Decode(keys['identity_signing_private']));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Restore Signed PreKey
|
// 2. Restore Signed PreKey
|
||||||
if (keys.containsKey('signed_prekey_private')) {
|
if (keys.containsKey('signed_prekey_private')) {
|
||||||
print('[E2EE] Restoring Signed PreKey...');
|
|
||||||
_signedPreKey = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['signed_prekey_private']));
|
_signedPreKey = await _dhAlgo.newKeyPairFromSeed(base64Decode(keys['signed_prekey_private']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1014,7 +945,6 @@ class SimpleE2EEService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_oneTimePreKeys = importedOTKs;
|
_oneTimePreKeys = importedOTKs;
|
||||||
print('[E2EE] Restored ${_oneTimePreKeys!.length} OTKs');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Set User Context from metadata
|
// 4. Set User Context from metadata
|
||||||
|
|
@ -1032,7 +962,6 @@ class SimpleE2EEService {
|
||||||
|
|
||||||
// 5. Persist and Synchronize
|
// 5. Persist and Synchronize
|
||||||
if (_initializedForUserId != null) {
|
if (_initializedForUserId != null) {
|
||||||
print('[E2EE] Persisting restored keys to local storage...');
|
|
||||||
await _saveKeysToLocal(_initializedForUserId!);
|
await _saveKeysToLocal(_initializedForUserId!);
|
||||||
|
|
||||||
// Republish to server to ensure backend is synchronized
|
// 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) {
|
} catch (e) {
|
||||||
print('[E2EE] CRITICAL: Failed to import keys: $e');
|
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,11 +104,9 @@ class SyncManager with WidgetsBindingObserver {
|
||||||
if (!_authService.isAuthenticated || _syncInProgress) return;
|
if (!_authService.isAuthenticated || _syncInProgress) return;
|
||||||
_syncInProgress = true;
|
_syncInProgress = true;
|
||||||
try {
|
try {
|
||||||
print('[SYNC] Triggered by: $reason');
|
|
||||||
|
|
||||||
await _e2ee.initialize();
|
await _e2ee.initialize();
|
||||||
if (!_e2ee.isReady) {
|
if (!_e2ee.isReady) {
|
||||||
print('[SYNC] Keys not ready, aborting sync.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,9 +114,7 @@ class SyncManager with WidgetsBindingObserver {
|
||||||
|
|
||||||
await _secureChatService.syncAllConversations(force: true);
|
await _secureChatService.syncAllConversations(force: true);
|
||||||
_lastSyncAt = DateTime.now();
|
_lastSyncAt = DateTime.now();
|
||||||
print('[SYNC] Sync complete.');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[SYNC] Global sync failed ($reason): $e');
|
|
||||||
} finally {
|
} finally {
|
||||||
_syncInProgress = false;
|
_syncInProgress = false;
|
||||||
}
|
}
|
||||||
|
|
@ -130,7 +126,6 @@ class SyncManager with WidgetsBindingObserver {
|
||||||
try {
|
try {
|
||||||
await _e2ee.initialize();
|
await _e2ee.initialize();
|
||||||
if (!_e2ee.isReady) {
|
if (!_e2ee.isReady) {
|
||||||
print('[SYNC] Identity not ready; skipping hydration.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,13 +134,11 @@ class SyncManager with WidgetsBindingObserver {
|
||||||
final isEmpty =
|
final isEmpty =
|
||||||
(await _localStore.getMessageIdsForConversation(conv.id)).isEmpty;
|
(await _localStore.getMessageIdsForConversation(conv.id)).isEmpty;
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
print('[SYNC] Hydrating empty conversation: ${conv.id}');
|
|
||||||
await _secureChatService.fetchAndDecryptHistory(conv.id, limit: 50);
|
await _secureChatService.fetchAndDecryptHistory(conv.id, limit: 50);
|
||||||
}
|
}
|
||||||
await _secureChatService.startLiveListener(conv.id);
|
await _secureChatService.startLiveListener(conv.id);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[SYNC] History load failed: $e');
|
|
||||||
} finally {
|
} finally {
|
||||||
_hydrating = false;
|
_hydrating = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,16 +41,13 @@ class VideoStitchingService {
|
||||||
if (ReturnCode.isSuccess(returnCode)) {
|
if (ReturnCode.isSuccess(returnCode)) {
|
||||||
return outputFile;
|
return outputFile;
|
||||||
} else {
|
} else {
|
||||||
print("Stitching failed with return code: $returnCode");
|
|
||||||
// Fallback: return the last segment or first one to at least save something?
|
// Fallback: return the last segment or first one to at least save something?
|
||||||
// For strict correctness, return null or throw.
|
// For strict correctness, return null or throw.
|
||||||
// Let's print logs.
|
// Let's print logs.
|
||||||
final logs = await session.getOutput();
|
final logs = await session.getOutput();
|
||||||
print("FFmpeg Logs: $logs");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Stitching Error: $e");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ class PostMedia extends StatelessWidget {
|
||||||
onTap: isVideo
|
onTap: isVideo
|
||||||
? () {
|
? () {
|
||||||
final url = '${AppRoutes.quips}?postId=${post!.id}';
|
final url = '${AppRoutes.quips}?postId=${post!.id}';
|
||||||
print('[PostMedia] Navigating to quips: $url');
|
|
||||||
context.go(url);
|
context.go(url);
|
||||||
}
|
}
|
||||||
: onTap,
|
: onTap,
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,6 @@ class _ReactionPickerState extends State<ReactionPicker> with SingleTickerProvid
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[REACTIONS] Error scanning assets: $e');
|
|
||||||
// Fallback
|
// Fallback
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@ class _VideoPlayerWidgetState extends State<VideoPlayerWidget> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error initializing video player: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@ class _VideoPlayerWithCommentsState extends State<VideoPlayerWithComments> {
|
||||||
|
|
||||||
setState(() {});
|
setState(() {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error initializing video: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue