added media fixes, search, push updates, pwa, metrics. preparing release 1.0

This commit is contained in:
vantablackTea
2026-09-27 23:26:15 +03:00
parent 855a62755c
commit 260e875984
59 changed files with 6823 additions and 113 deletions
+33
View File
@@ -0,0 +1,33 @@
# Changelog
## [1.0.0] - 2026-09-27
First production release. Self-hosted messenger for family and friends,
built from scratch.
### Added
- **Auth**: registration/login, JWT access tokens (15 min) + rotated refresh
tokens (30 days) in httpOnly cookies, argon2id password hashing,
session revocation, rate-limited auth endpoints.
- **Chat**: direct messages and groups (roles owner/member), read receipts,
typing indicators, presence, unread counters, keyset pagination.
- **Realtime**: in-process WebSocket hub delivering message.created, typing,
presence, read, reaction and membership events.
- **Media**: file upload/download with image thumbnails, S3-like storage
interface, local disk backend, path-traversal protection, 25 MiB limit.
- **Reactions**: emoji reactions on messages with live sync.
- **Search**: substring message search scoped to the user's conversations.
- **Web Push**: VAPID notifications to offline members (optional, needs keys).
- **PWA**: installable app, offline app-shell caching, Web Push service worker.
- **Frontend**: React + TypeScript + Tailwind, dark theme, responsive,
optimistic UI.
- **Observability**: Prometheus `/metrics` (HTTP, WS, runtime), VictoriaMetrics
+ Grafana dashboard in `deploy/`.
- **Hardening**: security headers (CSP, X-Frame-Options, nosniff), request
body limits, session cleanup, graceful shutdown.
- **Tooling**: `cmd/keygen` (VAPID keys), `cmd/seed` (demo data),
GitHub Actions CI with PostgreSQL service.
### Notes
- Video attachments are supported as files; inline playback is planned.
- E2EE is on the roadmap via libsignal; TLS in transit is the current baseline.
+5 -2
View File
@@ -16,11 +16,14 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api ./c
# --- Runtime stage --- # --- Runtime stage ---
FROM alpine:3.20 AS runtime FROM alpine:3.20 AS runtime
RUN adduser -D -u 10001 app RUN adduser -D -u 10001 app \
&& mkdir -p /data/media /app/web \
&& chown -R app:app /data /app
WORKDIR /app WORKDIR /app
COPY --from=api --chown=app:app /out/api /app/api COPY --from=api --chown=app:app /out/api /app/api
COPY --from=web --chown=app:app /build/web/dist /app/web COPY --from=web --chown=app:app /build/web/dist /app/web
USER app USER app
ENV MESSENGER_STATIC_DIR=/app/web ENV MESSENGER_STATIC_DIR=/app/web \
MESSENGER_MEDIA_DIR=/data/media
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/app/api"] ENTRYPOINT ["/app/api"]
+80 -57
View File
@@ -1,84 +1,91 @@
# Messenger # Messenger
Self-hosted messenger for family and friends. Built from scratch: Go backend, Self-hosted messenger for family and friends. Built from scratch: Go backend,
React PWA frontend, PostgreSQL, WebRTC calls. Deployed on a home-lab mini PC. React PWA frontend, PostgreSQL. Deployed on a home-lab mini PC.
> **Status:** Phase 1 — auth (JWT + refresh in httpOnly cookies, argon2id), > **Status: v1.0.0** — production-ready. Auth, direct chats and groups,
> contacts, direct-message chats, real-time delivery over WebSocket > real-time delivery, media, reactions, search, Web Push, PWA offline,
> (messages, typing, presence, read receipts). Backend is complete and tested; > metrics and hardening are all implemented and tested.
> the frontend still shows the Phase 0 status screen.
## Repository layout ## Repository layout
``` ```
server/ Go API server/ Go API
cmd/api/ entrypoint (wiring, migrations, graceful shutdown) cmd/api/ entrypoint (wiring, migrations, graceful shutdown)
internal/api/ HTTP handlers: auth, users, contacts, conversations, messages cmd/keygen/ generate VAPID keys for Web Push
cmd/seed/ create demo users and a family group
internal/api/ HTTP handlers: auth, users, contacts, conversations, messages, media, search, push
internal/auth/ argon2id hashing, JWT access tokens, refresh tokens internal/auth/ argon2id hashing, JWT access tokens, refresh tokens
internal/realtime/ WebSocket hub: events, presence, typing, read receipts internal/realtime/ WebSocket hub: events, presence, typing, read receipts
internal/media/ S3-like storage + image thumbnails
internal/store/ PostgreSQL access (pgx), embedded migrations internal/store/ PostgreSQL access (pgx), embedded migrations
internal/platform/ config, structured logging internal/platform/ config, logging, metrics, rate limiting, version
web/ React + TypeScript + Vite frontend (PWA-ready) web/ React + TypeScript + Vite PWA (offline, Web Push)
deploy/ docker-compose and operational config deploy/ docker-compose (api, postgres, victoriametrics, grafana)
docs/ architecture, ADR, OpenAPI docs/ architecture (mermaid), ADR
.github/ CI workflows .github/ CI workflows
``` ```
## API (Phase 1) ## API
All endpoints under `/api/v1`. Auth via httpOnly cookies (`messenger_access` All endpoints under `/api/v1`. Auth via httpOnly cookies (`messenger_access`
= JWT 15 min, `messenger_refresh` = opaque token 30 days, rotated on refresh). = JWT 15 min, `messenger_refresh` = opaque token 30 days, rotated on refresh).
| Method | Path | Description | | Method | Path | Description |
|--------|-----------------------------------|---------------------------------| |--------|------|-------------|
| POST | `/api/v1/auth/register` | Create account (sets cookies) | | POST | `/api/v1/auth/register` | Create account (sets cookies) |
| POST | `/api/v1/auth/login` | Sign in (sets cookies) | | POST | `/api/v1/auth/login` | Sign in (sets cookies) |
| POST | `/api/v1/auth/refresh` | Rotate refresh token | | POST | `/api/v1/auth/refresh` | Rotate refresh token |
| POST | `/api/v1/auth/logout` | Revoke session, clear cookies | | POST | `/api/v1/auth/logout` | Revoke session, clear cookies |
| GET | `/api/v1/users/me` | Current user | | GET | `/api/v1/users/me` | Current user |
| GET | `/api/v1/users?q=` | Search users by username/name | | GET | `/api/v1/users?q=` | Search users by username/name |
| GET | `/api/v1/contacts` | Contact list | | GET | `/api/v1/contacts` | Contact list |
| POST | `/api/v1/contacts` | `{user_id}` — add contact | | POST | `/api/v1/contacts` | `{user_id}` — add contact |
| DELETE | `/api/v1/contacts/{user_id}` | Remove contact | | DELETE | `/api/v1/contacts/{user_id}` | Remove contact |
| GET | `/api/v1/conversations` | List chats + unread counts | | GET | `/api/v1/conversations` | List chats + unread counts |
| POST | `/api/v1/conversations` | `{user_id}` — create/find DM | | POST | `/api/v1/conversations` | `{user_id}` DM or `{type:"group", name, member_ids}` |
| GET | `/api/v1/conversations/{id}/messages` | History, keyset `?before=` | | GET | `/api/v1/conversations/{id}/members` | List participants + roles |
| POST | `/api/v1/conversations/{id}/messages` | `{body, temp_id}` — send | | POST | `/api/v1/conversations/{id}/members` | `{user_id}` — add to group |
| GET | `/ws` | WebSocket (auth via cookie) | | DELETE | `/api/v1/conversations/{id}/members/{user_id}` | Remove/leave (owner or self) |
| GET | `/api/v1/conversations/{id}/messages` | History, keyset `?before=` |
| POST | `/api/v1/conversations/{id}/messages` | `{body, temp_id, media_id?}` — send |
| PUT | `/api/v1/conversations/{id}/messages/{msg_id}/reactions` | `{emoji}` — react |
| DELETE | `/api/v1/conversations/{id}/messages/{msg_id}/reactions/{emoji}` | Remove reaction |
| POST | `/api/v1/media` | Upload file (multipart `file`) |
| GET | `/api/v1/media/{id}` | Download file |
| GET | `/api/v1/media/{id}/thumb` | Image thumbnail |
| GET | `/api/v1/search/messages?q=` | Search user's messages |
| POST | `/api/v1/push/subscribe` | Register Web Push subscription |
| DELETE | `/api/v1/push/subscribe` | Unregister Web Push subscription |
| GET | `/ws` | WebSocket (auth via cookie) |
WebSocket event types: `message.created`, `typing`, `presence`, `read`. WebSocket event types: `message.created`, `typing`, `presence`, `read`,
`reaction.added/removed`, `member.added/removed`, `conversation.added/removed`.
## Prerequisites ## Prerequisites
- Go 1.27+ - Go 1.27+, Node.js 22+, PostgreSQL
- Node.js 22+
- Docker + Docker Compose (for deployment) - Docker + Docker Compose (for deployment)
## Local development ## Local development
Requires a local PostgreSQL (e.g. `sudo -u postgres psql -c "CREATE ROLE messenger LOGIN PASSWORD 'messenger';"` and
`sudo -u postgres psql -c "CREATE DATABASE messenger OWNER messenger;"`).
```bash ```bash
# Backend (migrations run automatically on start) # create the role once (as postgres superuser)
cd server sudo -u postgres psql -c "CREATE ROLE messenger LOGIN PASSWORD 'messenger';"
go run ./cmd/api # listen on :8080 sudo -u postgres psql -c "CREATE DATABASE messenger OWNER messenger;"
# Frontend (separate terminal) # backend (migrations run automatically on start)
cd server
go run ./cmd/api # :8080
# frontend (separate terminal)
cd web cd web
npm install npm install
npm run dev # Vite on :5173, proxies /api -> :8080 npm run dev # :5173, proxies /api and /ws -> :8080
``` ```
The frontend calls `/api/v1/ping` on load and renders the server response. Optional: demo data → `cd server && go run ./cmd/seed`
(users papa/mama/syn/doch, password `password123`, group «Семья»).
## Useful endpoints
| Endpoint | Purpose |
|--------------|----------------------------------|
| `/healthz` | Liveness probe |
| `/readyz` | Readiness probe |
| `/api/v1/ping` | Round-trip check (JSON) |
## Deployment (Docker Compose) ## Deployment (Docker Compose)
@@ -88,19 +95,35 @@ cd deploy
docker compose up -d --build docker compose up -d --build
``` ```
The API container is a single static binary that also serves the built Services: `api` (single static binary serving API + frontend), `postgres`,
frontend (`/app/web`). PostgreSQL runs alongside with a healthcheck. `victoriametrics` (metrics storage), `grafana` (dashboard «Messenger»
auto-provisioned at http://localhost:3000).
### Metrics
`GET /metrics` exposes Prometheus metrics: HTTP requests by status/path,
request latency, WebSocket connections, Go runtime. Scraped by VictoriaMetrics.
### Web Push (optional)
```bash
cd server && go run ./cmd/keygen # prints the three VAPID variables
```
Put them in `deploy/.env` (`MESSENGER_VAPID_*`) and in `web/.env`
(`VITE_VAPID_PUBLIC_KEY`). Without keys everything works except notifications.
## Architecture & decisions
See `docs/architecture.md` (component and sequence diagrams) and
`docs/adr/` for the rationale behind: monolith, in-memory WS hub, ILIKE
search, S3-like media storage, E2EE roadmap.
## Roadmap ## Roadmap
- **Phase 0** — skeleton, CI, Docker ✅ - **Phase 0-3** — skeleton → auth/chats → frontend → groups/reactions/media → search/push/PWA ✅
- **Phase 1** — auth (JWT + refresh in httpOnly cookie, argon2id), contacts, DMs, WebSocket delivery, receipts, typing ✅ (backend) - **Phase 4** — P2P WebRTC calls via our WebSocket signalling + coturn TURN fallback (planned, major feature)
- Phase 1.5 — frontend: auth screens, chat UI, realtime client (PWA) - **Phase 5** — metrics, docs, hardening ✅
- Phase 2 — groups, reactions, media upload + thumbnails (S3-like storage interface)
- Phase 3 — search (pg_trgm), Web Push (VAPID), PWA offline
- Phase 4 — P2P WebRTC calls via our WebSocket signalling + coturn TURN fallback
- Phase 5 — metrics (Prometheus + VictoriaMetrics + Grafana), docs, hardening
## License ## License
Proprietary for now; open-sourcing at v1.0. Proprietary for now; open-sourcing at a future major version.
+8
View File
@@ -4,6 +4,14 @@ MESSENGER_ENV=production
MESSENGER_ALLOWED_ORIGINS=https://msg.example.com MESSENGER_ALLOWED_ORIGINS=https://msg.example.com
MESSENGER_JWT_SECRET=generate-a-long-random-string MESSENGER_JWT_SECRET=generate-a-long-random-string
# Web Push (optional): generate via `cd server && go run ./cmd/keygen`
MESSENGER_VAPID_PUBLIC_KEY=
MESSENGER_VAPID_PRIVATE_KEY=
MESSENGER_VAPID_SUBJECT=mailto:you@example.com
POSTGRES_USER=messenger POSTGRES_USER=messenger
POSTGRES_PASSWORD=change-me POSTGRES_PASSWORD=change-me
POSTGRES_DB=messenger POSTGRES_DB=messenger
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
+34
View File
@@ -11,6 +11,12 @@ services:
MESSENGER_ALLOWED_ORIGINS: ${MESSENGER_ALLOWED_ORIGINS:-} MESSENGER_ALLOWED_ORIGINS: ${MESSENGER_ALLOWED_ORIGINS:-}
MESSENGER_DATABASE_URL: postgres://${POSTGRES_USER:-messenger}:${POSTGRES_PASSWORD:-messenger}@postgres:5432/${POSTGRES_DB:-messenger}?sslmode=disable MESSENGER_DATABASE_URL: postgres://${POSTGRES_USER:-messenger}:${POSTGRES_PASSWORD:-messenger}@postgres:5432/${POSTGRES_DB:-messenger}?sslmode=disable
MESSENGER_JWT_SECRET: ${MESSENGER_JWT_SECRET:?MESSENGER_JWT_SECRET must be set in .env} MESSENGER_JWT_SECRET: ${MESSENGER_JWT_SECRET:?MESSENGER_JWT_SECRET must be set in .env}
MESSENGER_MEDIA_DIR: /data/media
MESSENGER_VAPID_PUBLIC_KEY: ${MESSENGER_VAPID_PUBLIC_KEY:-}
MESSENGER_VAPID_PRIVATE_KEY: ${MESSENGER_VAPID_PRIVATE_KEY:-}
MESSENGER_VAPID_SUBJECT: ${MESSENGER_VAPID_SUBJECT:-}
volumes:
- mediadata:/data
ports: ports:
- "8080:8080" - "8080:8080"
depends_on: depends_on:
@@ -34,5 +40,33 @@ services:
timeout: 3s timeout: 3s
retries: 10 retries: 10
victoriametrics:
image: victoriametrics/victoria-metrics:latest
restart: unless-stopped
command:
- "--retentionPeriod=12"
volumes:
- vmdata:/victoria-metrics-data
ports:
- "127.0.0.1:8428:8428"
grafana:
image: grafana/grafana:latest
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- grafanadata:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning
ports:
- "127.0.0.1:3000:3000"
depends_on:
- victoriametrics
volumes: volumes:
pgdata: pgdata:
mediadata:
vmdata:
grafanadata:
@@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: messenger
orgId: 1
folder: Messenger
type: file
disableDeletion: false
updateIntervalSeconds: 30
options:
path: /etc/grafana/provisioning/dashboards
@@ -0,0 +1,130 @@
{
"annotations": { "list": [] },
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"panels": [
{
"datasource": { "type": "prometheus", "uid": "${DS_VICTORIAMETRICS}" },
"fieldConfig": {
"defaults": {
"custom": { "drawStyle": "line", "fillOpacity": 15, "lineWidth": 2, "showPoints": "never" },
"unit": "reqps"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"id": 1,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"expr": "sum(rate(messenger_http_requests_total{status=~\"2..\"}[5m]))",
"legendFormat": "2xx",
"refId": "A"
},
{
"expr": "sum(rate(messenger_http_requests_total{status=~\"4..\"}[5m]))",
"legendFormat": "4xx",
"refId": "B"
},
{
"expr": "sum(rate(messenger_http_requests_total{status=~\"5..\"}[5m]))",
"legendFormat": "5xx",
"refId": "C"
}
],
"title": "HTTP requests per second",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "${DS_VICTORIAMETRICS}" },
"fieldConfig": {
"defaults": {
"custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never" },
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"id": 2,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(messenger_http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p95",
"refId": "A"
},
{
"expr": "histogram_quantile(0.50, sum(rate(messenger_http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p50",
"refId": "B"
}
],
"title": "Request latency",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "${DS_VICTORIAMETRICS}" },
"fieldConfig": {
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 15, "lineWidth": 2, "showPoints": "never" } },
"overrides": []
},
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 8 },
"id": 3,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } },
"targets": [
{ "expr": "messenger_ws_connections", "legendFormat": "connections", "refId": "A" }
],
"title": "WebSocket connections",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "${DS_VICTORIAMETRICS}" },
"fieldConfig": {
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never" } },
"overrides": []
},
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 8 },
"id": 4,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{ "expr": "go_goroutines", "legendFormat": "goroutines", "refId": "A" },
{ "expr": "process_resident_memory_bytes / 1024 / 1024", "legendFormat": "RSS MiB", "refId": "B" }
],
"title": "Runtime",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "${DS_VICTORIAMETRICS}" },
"fieldConfig": {
"defaults": {
"custom": { "drawStyle": "line", "fillOpacity": 15, "lineWidth": 2, "showPoints": "never" },
"unit": "reqps"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 8 },
"id": 5,
"options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } },
"targets": [
{
"expr": "topk(8, sum(rate(messenger_http_requests_total[5m])) by (path))",
"legendFormat": "{{path}}",
"refId": "A"
}
],
"title": "Top endpoints",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["messenger"],
"templating": { "list": [] },
"time": { "from": "now-1h", "to": "now" },
"timepicker": {},
"timezone": "",
"title": "Messenger",
"uid": "messenger-main",
"version": 1
}
@@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: VictoriaMetrics
type: prometheus
access: proxy
url: http://victoriametrics:8428
isDefault: true
editable: false
+22
View File
@@ -0,0 +1,22 @@
# ADR 0001: Monolith over microservices
- Status: accepted
- Date: 2026-09
## Context
The messenger serves a single family (tens of users) on a home-lab mini PC
with ~8 GB RAM. A microservice split would add operational overhead
(networking, service discovery, per-service containers) with zero benefit at
this scale.
## Decision
Build a single Go process exposing REST, WebSocket, media serving and static
files, with PostgreSQL as the only external state. Internal packages
(`store`, `auth`, `realtime`, `api`, `media`) are cleanly separated with
explicit interfaces, so extraction later is mechanical.
## Consequences
- One container, trivial `docker compose up`.
- In-process WebSocket hub gives O(1) presence checks for push decisions.
- If the service ever needs horizontal scaling, `realtime` and `store`
already hide their implementations behind interfaces.
+21
View File
@@ -0,0 +1,21 @@
# ADR 0002: In-memory WebSocket hub
- Status: accepted
- Date: 2026-09
## Context
Realtime delivery (messages, typing, presence, read receipts) must reach
connected clients immediately. Options: external broker (Redis pub/sub,
NATS), Postgres LISTEN/NOTIFY, or an in-process hub.
## Decision
Use an in-process hub: `map[userID] -> connections` plus
`map[conversationID] -> subscribers`, guarded by a mutex. Membership and
read-state are resolved through a `MembershipProvider` interface implemented
by the store.
## Consequences
- Sub-millisecond fan-out, no extra moving parts.
- Presence checks (`IsOnline`) are lock-free reads used by the push notifier.
- Multi-node scaling would require a broker; the hub's publish methods are
the seam where that would plug in.
+20
View File
@@ -0,0 +1,20 @@
# ADR 0003: Search via ILIKE instead of pg_trgm
- Status: accepted
- Date: 2026-09
## Context
Message search must find substring matches across a user's conversations.
pg_trgm + GIN gives indexed trigram search, but the extension is not
installed on the target system and cannot be created by a non-superuser role
(and adding it to the Docker image is an OS-level dependency).
## Decision
Search with `body ILIKE '%term%'` restricted to the user's conversations,
ordered by message id descending with a limit.
## Consequences
- No extension or superuser requirement; works on any PostgreSQL install.
- Sequential scan is instant for thousands of messages (family scale).
- Scaling path documented: `CREATE EXTENSION pg_trgm` + GIN index with
`gin_trgm_ops`, keeping the same SQL semantics.
+22
View File
@@ -0,0 +1,22 @@
# ADR 0004: Media behind an S3-like interface
- Status: accepted
- Date: 2026-09
## Context
Attachments (images, files, videos) need storage, thumbnails for images, and
bounded upload size. Storage must survive container restarts and, ideally, be
swappable without touching API code.
## Decision
Define a `Storage` interface (`Save`/`Open`/`Delete` keyed by opaque keys) in
`internal/media`. Phase 2 ships `Local` — files on disk with atomic
temp-file+rename writes and path-traversal protection. API handlers depend
only on the interface.
## Consequences
- Metadata lives in Postgres (`media` table); bytes live in storage.
- Replacing disk with MinIO/S3 means a new `Storage` implementation, no
handler changes.
- Image thumbnails are generated with stdlib decoding + `x/image/draw`,
avoiding CGO and external binaries.
+21
View File
@@ -0,0 +1,21 @@
# ADR 0005: E2EE deferred, libsignal when implemented
- Status: accepted
- Date: 2026-09
## Context
A family messenger should eventually protect message content at rest and in
transit, ideally with end-to-end encryption. Rolling a custom crypto protocol
is unsafe and a red flag in review.
## Decision
- In transit: TLS everywhere (Caddy on the VPS / reverse proxy; homelab
ingress).
- At rest: Postgres and media disk live on trusted hardware.
- E2EE: deferred to a future major version, implemented with an established
library (libsignal) — never hand-written primitives.
## Consequences
- The wire protocol and data model are ready for E2EE (opaque message bodies,
device-agnostic session model).
- No false-security claims in the README.
+78
View File
@@ -0,0 +1,78 @@
# Architecture
Messenger is a self-hosted monolith: a single Go binary serves the REST API,
WebSocket hub, media files and the built frontend. PostgreSQL is the only
stateful dependency; attachments live on local disk behind an S3-like
interface.
## Component diagram
```mermaid
flowchart LR
subgraph Browser[Browser / PWA]
UI[React + Vite + Tailwind]
SW[Service Worker<br/>offline cache + Web Push]
end
subgraph Server[Go binary - single process]
HTTP[REST API /api/v1]
WS[WebSocket hub<br/>in-memory]
MEDIA[Media service<br/>thumbnails]
PUSH[Push notifier<br/>VAPID]
end
subgraph Data
PG[(PostgreSQL 17)]
DISK[(local disk<br/>S3-like Storage)]
end
UI -->|REST + WS| HTTP
UI --> WS
SW -->|Web Push| PUSH
HTTP --> PG
WS --> PG
PUSH --> PG
MEDIA --> DISK
HTTP --> MEDIA
WS -.->|presence check| PUSH
```
## Request flow for a message
```mermaid
sequenceDiagram
participant A as Alice (PWA)
participant API as API
participant PG as PostgreSQL
participant WS as WS hub
participant B as Bob (PWA)
A->>API: POST /conversations/{id}/messages
API->>PG: INSERT message
API->>WS: publish message.created
WS-->>A: message.created (echo)
WS-->>B: message.created
API->>PUSH: notify offline members
PUSH-->>B: Web Push (if offline)
API-->>A: 201 message
```
## Key decisions (see docs/adr)
| Area | Decision | Rationale |
|---|---|---|
| Shape | Monolith, one container | Family scale; trivial deploy; no fake microservices |
| Realtime | In-memory WS hub | Presence and delivery in one process; pg LISTEN/NOTIFY is the scaling path |
| Search | ILIKE substring | No extension dependency; instant at family scale; pg_trgm noted as upgrade |
| Media | S3-like Storage interface | Swap local disk for MinIO without touching handlers |
| Auth | JWT access + rotated refresh in httpOnly cookies | No token in JS; rotation limits replay |
| Crypto | TLS in transit; E2EE via libsignal on roadmap | Never hand-roll crypto |
## Deployment
docker-compose runs: api, postgres, victoriametrics, grafana.
See `deploy/` for compose + Grafana dashboard provisioning.
- **Metrics**: `GET /metrics` (Prometheus format), scraped by VictoriaMetrics
- **Grafana**: http://localhost:3000, dashboard «Messenger» auto-provisioned
- **Push**: optional; requires VAPID keys (`go run ./cmd/keygen`)
+26
View File
@@ -7,6 +7,7 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -17,6 +18,8 @@ import (
"messenger/internal/media" "messenger/internal/media"
"messenger/internal/platform/config" "messenger/internal/platform/config"
"messenger/internal/platform/logger" "messenger/internal/platform/logger"
"messenger/internal/platform/metrics"
"messenger/internal/platform/version"
"messenger/internal/realtime" "messenger/internal/realtime"
"messenger/internal/server" "messenger/internal/server"
"messenger/internal/store" "messenger/internal/store"
@@ -33,6 +36,9 @@ func main() {
os.Exit(1) os.Exit(1)
} }
log.Info("starting messenger", "version", version.Version)
metrics.Init(version.Version)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
@@ -59,6 +65,8 @@ func main() {
os.Exit(1) os.Exit(1)
} }
go cleanupLoop(ctx, st, log)
httpServer := server.New(cfg, log, st, tm, hub, mediaStore) httpServer := server.New(cfg, log, st, tm, hub, mediaStore)
// Run the server in a goroutine so that shutdown can be triggered // Run the server in a goroutine so that shutdown can be triggered
@@ -88,3 +96,21 @@ func main() {
} }
log.Info("server stopped cleanly") log.Info("server stopped cleanly")
} }
// cleanupLoop periodically purges expired sessions and revocations.
func cleanupLoop(ctx context.Context, st *store.Store, log *slog.Logger) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if n, err := st.CleanupSessions(ctx); err != nil {
log.Error("session cleanup failed", "error", err)
} else if n > 0 {
log.Info("cleaned up sessions", "count", n)
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
// Command keygen prints fresh VAPID keys for Web Push configuration.
package main
import (
"fmt"
"github.com/SherClockHolmes/webpush-go"
)
func main() {
private, public, err := webpush.GenerateVAPIDKeys()
if err != nil {
fmt.Println("failed to generate keys:", err)
return
}
fmt.Println("MESSENGER_VAPID_PUBLIC_KEY=" + public)
fmt.Println("MESSENGER_VAPID_PRIVATE_KEY=" + private)
fmt.Println("MESSENGER_VAPID_SUBJECT=mailto:you@example.com")
}
+118
View File
@@ -0,0 +1,118 @@
// Command seed creates demo users, a family group and a few messages.
// It is idempotent: existing usernames are left untouched.
package main
import (
"context"
"fmt"
"os"
"messenger/internal/auth"
"messenger/internal/platform/config"
"messenger/internal/platform/logger"
"messenger/internal/store"
"github.com/jackc/pgx/v5/pgxpool"
)
type seedUser struct {
username string
displayName string
password string
}
var demoUsers = []seedUser{
{"papa", "Папа", "password123"},
{"mama", "Мама", "password123"},
{"syn", "Сын", "password123"},
{"doch", "Дочь", "password123"},
}
func main() {
log := logger.New()
cfg, err := config.Load()
if err != nil {
log.Error("invalid configuration", "error", err)
os.Exit(1)
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
log.Error("failed to connect", "error", err)
os.Exit(1)
}
defer pool.Close()
st := store.New(pool)
if err := st.Migrate(ctx); err != nil {
log.Error("migration failed", "error", err)
os.Exit(1)
}
created, err := ensureUsers(ctx, st)
if err != nil {
log.Error("seed users failed", "error", err)
os.Exit(1)
}
fmt.Printf("users created: %d\n", created)
group, err := ensureGroup(ctx, st)
if err != nil {
log.Error("seed group failed", "error", err)
os.Exit(1)
}
fmt.Printf("group ready: %s (id=%d)\n", group.Name, group.ID)
}
func ensureUsers(ctx context.Context, st *store.Store) (int, error) {
created := 0
for _, u := range demoUsers {
if _, err := st.GetUserByUsername(ctx, u.username); err == nil {
continue
}
hash, err := auth.HashPassword(u.password)
if err != nil {
return created, err
}
if _, err := st.CreateUser(ctx, u.username, u.displayName, hash); err != nil {
return created, err
}
created++
}
return created, nil
}
func ensureGroup(ctx context.Context, st *store.Store) (*store.Conversation, error) {
users := make([]int64, 0, len(demoUsers))
var ownerID int64
for _, u := range demoUsers {
user, err := st.GetUserByUsername(ctx, u.username)
if err != nil {
return nil, fmt.Errorf("user %s missing: %w", u.username, err)
}
if ownerID == 0 {
ownerID = user.ID
}
users = append(users, user.ID)
}
// Reuse an existing group with the same name if present.
conversations, err := st.ListConversationsForUser(ctx, ownerID)
if err != nil {
return nil, err
}
for i := range conversations {
if conversations[i].Type == "group" && conversations[i].Name == "Семья" {
return &conversations[i].Conversation, nil
}
}
group, err := st.CreateGroup(ctx, ownerID, "Семья", users[1:])
if err != nil {
return nil, err
}
if _, err := st.InsertMessage(ctx, group.ID, ownerID, "Всем привет! Это наша семейная группа.", nil); err != nil {
return nil, err
}
return group, nil
}
+9
View File
@@ -3,19 +3,28 @@ module messenger
go 1.27 go 1.27
require ( require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/coder/websocket v1.8.15 github.com/coder/websocket v1.8.15
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
github.com/jackc/pgx/v5 v5.11.0 github.com/jackc/pgx/v5 v5.11.0
github.com/prometheus/client_golang v1.24.1
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
) )
require ( require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
golang.org/x/sync v0.23.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
) )
+93
View File
@@ -1,10 +1,20 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -15,23 +25,106 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+35 -15
View File
@@ -4,49 +4,66 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
"time"
"messenger/internal/auth" "messenger/internal/auth"
"messenger/internal/media" "messenger/internal/media"
"messenger/internal/platform/config" "messenger/internal/platform/config"
"messenger/internal/platform/ratelimit"
"messenger/internal/realtime" "messenger/internal/realtime"
"messenger/internal/store" "messenger/internal/store"
) )
type apiHandler struct { type apiHandler struct {
store *store.Store store *store.Store
tokens *auth.TokenManager tokens *auth.TokenManager
hub *realtime.Hub hub *realtime.Hub
log *slog.Logger log *slog.Logger
secure bool secure bool
media media.Storage media media.Storage
notifier *pushNotifier
} }
// NewHandler builds the /api/v1 route tree with all middleware applied. // NewHandler builds the /api/v1 route tree with all middleware applied.
// static, when non-nil, serves the built frontend for non-API paths. // static, when non-nil, serves the built frontend for non-API paths.
func NewHandler(st *store.Store, tm *auth.TokenManager, hub *realtime.Hub, log *slog.Logger, cfg *config.Config, ms media.Storage, static http.Handler) http.Handler { func NewHandler(st *store.Store, tm *auth.TokenManager, hub *realtime.Hub, log *slog.Logger, cfg *config.Config, ms media.Storage, static http.Handler) http.Handler {
h := &apiHandler{ h := &apiHandler{
store: st, store: st,
tokens: tm, tokens: tm,
hub: hub, hub: hub,
log: log, log: log,
secure: cfg.Environment == "production", secure: cfg.Environment == "production",
media: ms, media: ms,
notifier: newPushNotifier(st, hub, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject),
} }
mux := http.NewServeMux() mux := http.NewServeMux()
authHandler := requireAuth(tm, http.HandlerFunc(h.websocket)) authHandler := requireAuth(tm, http.HandlerFunc(h.websocket))
authLimiter := ratelimit.New(20, time.Minute)
limited := func(inner http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !authLimiter.Allow(r) {
w.Header().Set("Retry-After", "60")
writeError(w, http.StatusTooManyRequests, "too many requests")
return
}
inner(w, r)
}
}
mux.HandleFunc("GET /api/v1/ping", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /api/v1/ping", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"message": "pong"}) writeJSON(w, http.StatusOK, map[string]string{"message": "pong"})
}) })
mux.HandleFunc("POST /api/v1/auth/register", h.register) mux.HandleFunc("POST /api/v1/auth/register", limited(h.register))
mux.HandleFunc("POST /api/v1/auth/login", h.login) mux.HandleFunc("POST /api/v1/auth/login", limited(h.login))
mux.HandleFunc("POST /api/v1/auth/refresh", h.refresh) mux.HandleFunc("POST /api/v1/auth/refresh", limited(h.refresh))
mux.HandleFunc("POST /api/v1/auth/logout", h.logout) mux.HandleFunc("POST /api/v1/auth/logout", h.logout)
mux.Handle("GET /api/v1/users/me", requireAuth(tm, h.me)) mux.Handle("GET /api/v1/users/me", requireAuth(tm, h.me))
mux.Handle("GET /api/v1/users", requireAuth(tm, h.searchUsers)) mux.Handle("GET /api/v1/users", requireAuth(tm, h.searchUsers))
mux.Handle("GET /api/v1/search/messages", requireAuth(tm, h.searchMessages))
mux.Handle("GET /api/v1/contacts", requireAuth(tm, h.listContacts)) mux.Handle("GET /api/v1/contacts", requireAuth(tm, h.listContacts))
mux.Handle("POST /api/v1/contacts", requireAuth(tm, h.addContact)) mux.Handle("POST /api/v1/contacts", requireAuth(tm, h.addContact))
@@ -66,6 +83,9 @@ func NewHandler(st *store.Store, tm *auth.TokenManager, hub *realtime.Hub, log *
mux.Handle("GET /api/v1/media/{id}", requireAuth(tm, h.downloadMedia)) mux.Handle("GET /api/v1/media/{id}", requireAuth(tm, h.downloadMedia))
mux.Handle("GET /api/v1/media/{id}/thumb", requireAuth(tm, h.downloadThumb)) mux.Handle("GET /api/v1/media/{id}/thumb", requireAuth(tm, h.downloadThumb))
mux.Handle("POST /api/v1/push/subscribe", requireAuth(tm, h.subscribePush))
mux.Handle("DELETE /api/v1/push/subscribe", requireAuth(tm, h.unsubscribePush))
mux.Handle("GET /ws", authHandler) mux.Handle("GET /ws", authHandler)
if static != nil { if static != nil {
+2 -5
View File
@@ -1,7 +1,6 @@
package api package api
import ( import (
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"regexp" "regexp"
@@ -39,8 +38,7 @@ type credentialsRequest struct {
func (h *apiHandler) register(w http.ResponseWriter, r *http.Request) { func (h *apiHandler) register(w http.ResponseWriter, r *http.Request) {
var req credentialsRequest var req credentialsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if !decodeJSON(w, r, &req) {
writeError(w, http.StatusBadRequest, "invalid json")
return return
} }
if !usernameRe.MatchString(req.Username) { if !usernameRe.MatchString(req.Username) {
@@ -76,8 +74,7 @@ func (h *apiHandler) register(w http.ResponseWriter, r *http.Request) {
func (h *apiHandler) login(w http.ResponseWriter, r *http.Request) { func (h *apiHandler) login(w http.ResponseWriter, r *http.Request) {
var req credentialsRequest var req credentialsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if !decodeJSON(w, r, &req) {
writeError(w, http.StatusBadRequest, "invalid json")
return return
} }
user, err := h.store.GetUserByUsername(r.Context(), req.Username) user, err := h.store.GetUserByUsername(r.Context(), req.Username)
+4 -2
View File
@@ -1,7 +1,6 @@
package api package api
import ( import (
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
@@ -51,7 +50,10 @@ func (h *apiHandler) addContact(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 { if !decodeJSON(w, r, &req) {
return
}
if req.UserID <= 0 {
writeError(w, http.StatusBadRequest, "invalid user_id") writeError(w, http.StatusBadRequest, "invalid user_id")
return return
} }
+14 -6
View File
@@ -1,7 +1,6 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -105,8 +104,7 @@ type createConversationRequest struct {
func (h *apiHandler) createConversation(w http.ResponseWriter, r *http.Request) { func (h *apiHandler) createConversation(w http.ResponseWriter, r *http.Request) {
userID, _ := userIDFrom(r.Context()) userID, _ := userIDFrom(r.Context())
var req createConversationRequest var req createConversationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if !decodeJSON(w, r, &req) {
writeError(w, http.StatusBadRequest, "invalid json")
return return
} }
@@ -212,7 +210,10 @@ func (h *apiHandler) addMember(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 { if !decodeJSON(w, r, &req) {
return
}
if req.UserID <= 0 {
writeError(w, http.StatusBadRequest, "invalid user_id") writeError(w, http.StatusBadRequest, "invalid user_id")
return return
} }
@@ -358,8 +359,7 @@ func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) {
} }
var req sendMessageRequest var req sendMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if !decodeJSON(w, r, &req) {
writeError(w, http.StatusBadRequest, "invalid json")
return return
} }
if req.Body == "" && req.MediaID == nil { if req.Body == "" && req.MediaID == nil {
@@ -401,6 +401,14 @@ func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) {
} }
h.hub.PublishMessageCreated(convID, wsEvent) h.hub.PublishMessageCreated(convID, wsEvent)
if h.notifier != nil {
senderName := ""
if sender, err := h.store.GetUserByID(r.Context(), userID); err == nil {
senderName = sender.DisplayName
}
h.notifier.notifyMembers(r.Context(), convID, userID, msg.ID, senderName, msg.Body)
}
out := toMessageJSON(msg) out := toMessageJSON(msg)
if attached != nil { if attached != nil {
md := toMediaJSON(attached) md := toMediaJSON(attached)
+129
View File
@@ -0,0 +1,129 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/SherClockHolmes/webpush-go"
"messenger/internal/realtime"
"messenger/internal/store"
)
type pushRequest struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
}
func (h *apiHandler) subscribePush(w http.ResponseWriter, r *http.Request) {
userID, _ := userIDFrom(r.Context())
var req pushRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" {
writeError(w, http.StatusBadRequest, "endpoint, p256dh and auth are required")
return
}
_, err := h.store.CreatePushSubscription(r.Context(), store.PushSubscription{
UserID: userID,
Endpoint: req.Endpoint,
P256dh: req.Keys.P256dh,
Auth: req.Keys.Auth,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"})
}
func (h *apiHandler) unsubscribePush(w http.ResponseWriter, r *http.Request) {
userID, _ := userIDFrom(r.Context())
var req pushRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Endpoint == "" {
writeError(w, http.StatusBadRequest, "endpoint is required")
return
}
if err := h.store.DeletePushSubscription(r.Context(), userID, req.Endpoint); err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
type pushNotifier struct {
store *store.Store
hub *realtime.Hub
public string
private string
subject string
}
func newPushNotifier(st *store.Store, hub *realtime.Hub, public, private, subject string) *pushNotifier {
if public == "" || private == "" {
return nil
}
return &pushNotifier{store: st, hub: hub, public: public, private: private, subject: subject}
}
// notifyMembers pushes a message notification to offline members.
// Failed or stale endpoints are cleaned up asynchronously.
func (n *pushNotifier) notifyMembers(ctx context.Context, conversationID, senderID, messageID int64, senderName, body string) {
members, err := n.store.ListMembers(ctx, conversationID)
if err != nil {
return
}
title := senderName
text := strings.TrimSpace(body)
if text == "" {
text = "📎 Вложение"
}
if len(text) > 140 {
text = text[:140] + "…"
}
payload, _ := json.Marshal(map[string]any{
"title": title,
"body": text,
"conversation_id": conversationID,
"message_id": messageID,
})
for _, m := range members {
if m.User.ID == senderID || n.hub.IsOnline(m.User.ID) {
continue
}
subs, err := n.store.ListPushSubscriptionsForUser(ctx, m.User.ID)
if err != nil {
continue
}
for _, sub := range subs {
go n.send(ctx, m.User.ID, sub, payload)
}
}
}
func (n *pushNotifier) send(ctx context.Context, userID int64, sub store.PushSubscription, payload []byte) {
wp := &webpush.Subscription{Endpoint: sub.Endpoint, Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth}}
resp, err := webpush.SendNotificationWithContext(ctx, payload, wp, &webpush.Options{
VAPIDPublicKey: n.public,
VAPIDPrivateKey: n.private,
Subscriber: n.subject,
TTL: 24 * 60 * 60,
})
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
_ = n.store.DeletePushSubscription(context.Background(), userID, sub.Endpoint)
}
}
+1 -3
View File
@@ -1,7 +1,6 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv" "strconv"
) )
@@ -15,8 +14,7 @@ func (h *apiHandler) addReaction(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Emoji string `json:"emoji"` Emoji string `json:"emoji"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if !decodeJSON(w, r, &req) {
writeError(w, http.StatusBadRequest, "invalid json")
return return
} }
if len(req.Emoji) == 0 || len(req.Emoji) > 32 { if len(req.Emoji) == 0 || len(req.Emoji) > 32 {
+10
View File
@@ -31,3 +31,13 @@ func writeStoreError(w http.ResponseWriter, err error) {
writeError(w, http.StatusInternalServerError, "internal server error") writeError(w, http.StatusInternalServerError, "internal server error")
} }
} }
// decodeJSON parses the request body into dst, rejecting oversized payloads.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return false
}
return true
}
+67
View File
@@ -0,0 +1,67 @@
package api
import (
"net/http"
"strconv"
)
type searchHitJSON struct {
Message messageJSON `json:"message"`
Conversation conversationRef `json:"conversation"`
}
type conversationRef struct {
ID int64 `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
}
func (h *apiHandler) searchMessages(w http.ResponseWriter, r *http.Request) {
userID, _ := userIDFrom(r.Context())
query := r.URL.Query().Get("q")
if len(query) < 2 {
writeJSON(w, http.StatusOK, []searchHitJSON{})
return
}
limit := 50
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
limit = n
}
}
hits, err := h.store.SearchMessages(r.Context(), userID, query, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
out := make([]searchHitJSON, 0, len(hits))
mediaByID := make(map[int64]mediaJSON)
for _, hit := range hits {
if hit.MediaID != nil {
if _, ok := mediaByID[*hit.MediaID]; !ok {
if m, err := h.store.GetMediaByID(r.Context(), *hit.MediaID); err == nil {
mediaByID[*hit.MediaID] = toMediaJSON(m)
}
}
}
}
for _, hit := range hits {
msg := toMessageJSON(&hit.Message)
if hit.MediaID != nil {
if md, ok := mediaByID[*hit.MediaID]; ok {
msg.Media = &md
}
}
out = append(out, searchHitJSON{
Message: msg,
Conversation: conversationRef{
ID: hit.ConversationID,
Type: hit.ConversationType,
Name: hit.ConversationName,
},
})
}
writeJSON(w, http.StatusOK, out)
}
+15 -6
View File
@@ -26,18 +26,27 @@ type Config struct {
JWTSecret string JWTSecret string
// MediaDir is the local disk location for uploaded attachments. // MediaDir is the local disk location for uploaded attachments.
MediaDir string MediaDir string
// VAPIDPublicKey and VAPIDPrivateKey enable Web Push. When either is
// empty, push notifications are disabled.
VAPIDPublicKey string
VAPIDPrivateKey string
// VAPIDSubject is the contact address used in the VAPID JWT.
VAPIDSubject string
} }
// Load reads configuration from the environment, falling back to // Load reads configuration from the environment, falling back to
// development-friendly defaults. Unknown values are validated. // development-friendly defaults. Unknown values are validated.
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := &Config{ cfg := &Config{
HTTPAddr: fromEnv("MESSENGER_HTTP_ADDR", ":8080"), HTTPAddr: fromEnv("MESSENGER_HTTP_ADDR", ":8080"),
StaticDir: os.Getenv("MESSENGER_STATIC_DIR"), StaticDir: os.Getenv("MESSENGER_STATIC_DIR"),
Environment: fromEnv("MESSENGER_ENV", "development"), Environment: fromEnv("MESSENGER_ENV", "development"),
DatabaseURL: fromEnv("MESSENGER_DATABASE_URL", "postgres://messenger:messenger@localhost:5432/messenger?sslmode=disable"), DatabaseURL: fromEnv("MESSENGER_DATABASE_URL", "postgres://messenger:messenger@localhost:5432/messenger?sslmode=disable"),
JWTSecret: fromEnv("MESSENGER_JWT_SECRET", "dev-secret-change-me"), JWTSecret: fromEnv("MESSENGER_JWT_SECRET", "dev-secret-change-me"),
MediaDir: fromEnv("MESSENGER_MEDIA_DIR", "./media"), MediaDir: fromEnv("MESSENGER_MEDIA_DIR", "./media"),
VAPIDPublicKey: os.Getenv("MESSENGER_VAPID_PUBLIC_KEY"),
VAPIDPrivateKey: os.Getenv("MESSENGER_VAPID_PRIVATE_KEY"),
VAPIDSubject: os.Getenv("MESSENGER_VAPID_SUBJECT"),
} }
for _, origin := range strings.Split(fromEnv("MESSENGER_ALLOWED_ORIGINS", ""), ",") { for _, origin := range strings.Split(fromEnv("MESSENGER_ALLOWED_ORIGINS", ""), ",") {
+107
View File
@@ -0,0 +1,107 @@
// Package metrics exposes Prometheus instrumentation for the HTTP API
// and the realtime hub.
package metrics
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
requestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "messenger_http_requests_total",
Help: "HTTP requests handled, partitioned by method, normalized path and status.",
},
[]string{"method", "path", "status"},
)
requestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "messenger_http_request_duration_seconds",
Help: "HTTP request latency.",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "path"},
)
wsConnections = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "messenger_ws_connections",
Help: "Current number of open WebSocket connections.",
},
)
buildInfo = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "messenger_build_info",
Help: "Build information.",
},
[]string{"version"},
)
)
// Init registers all custom metrics. The default registry already carries
// the standard Go and process collectors.
func Init(version string) {
prometheus.MustRegister(requestsTotal, requestDuration, wsConnections, buildInfo)
buildInfo.WithLabelValues(version).Set(1)
}
// Handler returns the Prometheus scrape endpoint.
func Handler() http.Handler {
return promhttp.Handler()
}
// RecordWS increments or decrements the WebSocket connection gauge.
func RecordWS(delta float64) {
wsConnections.Add(delta)
}
// Middleware instruments every request with counters and a latency histogram.
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &recorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
path := NormalizePath(r.URL.Path)
requestsTotal.WithLabelValues(r.Method, path, strconv.Itoa(rec.status)).Inc()
requestDuration.WithLabelValues(r.Method, path).Observe(time.Since(start).Seconds())
})
}
type recorder struct {
http.ResponseWriter
status int
}
func (r *recorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
// NormalizePath replaces numeric path segments with a placeholder so that
// per-route cardinality stays bounded regardless of resource IDs.
func NormalizePath(p string) string {
parts := strings.Split(p, "/")
for i, part := range parts {
if part != "" && isNumeric(part) {
parts[i] = "{id}"
}
}
return strings.Join(parts, "/")
}
func isNumeric(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
@@ -0,0 +1,61 @@
// Package ratelimit provides a simple in-memory fixed-window limiter
// keyed by client IP, used to protect authentication endpoints.
package ratelimit
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
// Limiter is a fixed-window, per-IP request limiter.
type Limiter struct {
mu sync.Mutex
max int
window time.Duration
hits map[string]*hits
}
type hits struct {
windowStart time.Time
count int
}
// New returns a limiter allowing max requests per window per IP.
func New(max int, window time.Duration) *Limiter {
return &Limiter{max: max, window: window, hits: make(map[string]*hits)}
}
// Allow reports whether the request passes the limit, consuming one slot.
func (l *Limiter) Allow(r *http.Request) bool {
ip := ClientIP(r)
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
h := l.hits[ip]
if h == nil || now.Sub(h.windowStart) >= l.window {
h = &hits{windowStart: now}
l.hits[ip] = h
}
h.count++
return h.count <= l.max
}
// ClientIP extracts the remote address, preferring X-Forwarded-For when
// running behind a reverse proxy.
func ClientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.IndexByte(fwd, ','); i > 0 {
return strings.TrimSpace(fwd[:i])
}
return strings.TrimSpace(fwd)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
@@ -0,0 +1,66 @@
package ratelimit
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestLimiterAllowsUpToMax(t *testing.T) {
l := New(3, time.Minute)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "1.2.3.4:1234"
for i := 0; i < 3; i++ {
if !l.Allow(req) {
t.Fatalf("request %d rejected, want allowed", i+1)
}
}
if l.Allow(req) {
t.Fatal("4th request allowed, want rejected")
}
}
func TestLimiterSeparatesIPs(t *testing.T) {
l := New(2, time.Minute)
reqA := httptest.NewRequest(http.MethodGet, "/", nil)
reqA.RemoteAddr = "1.1.1.1:1"
reqB := httptest.NewRequest(http.MethodGet, "/", nil)
reqB.RemoteAddr = "2.2.2.2:2"
l.Allow(reqA)
l.Allow(reqA)
if l.Allow(reqA) {
t.Fatal("IP A over limit, want rejected")
}
if !l.Allow(reqB) {
t.Fatal("IP B should have its own budget")
}
}
func TestLimiterWindowResets(t *testing.T) {
l := New(1, 50*time.Millisecond)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "9.9.9.9:9"
if !l.Allow(req) {
t.Fatal("first request rejected")
}
if l.Allow(req) {
t.Fatal("second request within window allowed, want rejected")
}
time.Sleep(60 * time.Millisecond)
if !l.Allow(req) {
t.Fatal("request after window reset rejected")
}
}
func TestClientIPPrefersForwarded(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.5:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
if got := ClientIP(req); got != "203.0.113.9" {
t.Fatalf("ClientIP = %q, want 203.0.113.9", got)
}
}
@@ -0,0 +1,6 @@
// Package version holds the application version, single source of truth
// for release tooling and runtime metrics.
package version
// Version is the semantic version of the messenger server.
const Version = "1.0.0"
+11
View File
@@ -8,6 +8,8 @@ import (
"sync" "sync"
"github.com/coder/websocket" "github.com/coder/websocket"
"messenger/internal/platform/metrics"
) )
// MembershipProvider resolves conversation membership and read state. // MembershipProvider resolves conversation membership and read state.
@@ -145,6 +147,13 @@ func (h *Hub) PublishReactionChanged(conversationID, messageID, userID int64, em
h.sendToConversation(conversationID, envelope{Type: evtType, Data: mustMarshal(evt)}, 0) h.sendToConversation(conversationID, envelope{Type: evtType, Data: mustMarshal(evt)}, 0)
} }
// IsOnline reports whether a user has at least one active WebSocket connection.
func (h *Hub) IsOnline(userID int64) bool {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.conns[userID]) > 0
}
func (h *Hub) register(c *client, conversationIDs []int64) { func (h *Hub) register(c *client, conversationIDs []int64) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
@@ -158,6 +167,7 @@ func (h *Hub) register(c *client, conversationIDs []int64) {
} }
h.subs[cid][c.userID] = struct{}{} h.subs[cid][c.userID] = struct{}{}
} }
metrics.RecordWS(1)
} }
func (h *Hub) unregister(c *client, conversationIDs []int64) { func (h *Hub) unregister(c *client, conversationIDs []int64) {
@@ -177,6 +187,7 @@ func (h *Hub) unregister(c *client, conversationIDs []int64) {
} }
} }
} }
metrics.RecordWS(-1)
close(c.done) close(c.done)
} }
+13
View File
@@ -55,6 +55,19 @@ func recoverPanics(next http.Handler) http.Handler {
}) })
} }
// securityHeaders sets hardening headers on every response.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'")
next.ServeHTTP(w, r)
})
}
type statusRecorder struct { type statusRecorder struct {
http.ResponseWriter http.ResponseWriter
status int status int
+4 -1
View File
@@ -12,6 +12,7 @@ import (
"messenger/internal/auth" "messenger/internal/auth"
"messenger/internal/media" "messenger/internal/media"
"messenger/internal/platform/config" "messenger/internal/platform/config"
"messenger/internal/platform/metrics"
"messenger/internal/realtime" "messenger/internal/realtime"
"messenger/internal/store" "messenger/internal/store"
) )
@@ -28,6 +29,8 @@ func New(cfg *config.Config, log *slog.Logger, st *store.Store, tm *auth.TokenMa
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}) })
mux.Handle("GET /metrics", metrics.Handler())
var static http.Handler var static http.Handler
if cfg.StaticDir != "" { if cfg.StaticDir != "" {
static = spaHandler{staticDir: cfg.StaticDir, log: log} static = spaHandler{staticDir: cfg.StaticDir, log: log}
@@ -35,7 +38,7 @@ func New(cfg *config.Config, log *slog.Logger, st *store.Store, tm *auth.TokenMa
mux.Handle("/", api.NewHandler(st, tm, hub, log, cfg, ms, static)) mux.Handle("/", api.NewHandler(st, tm, hub, log, cfg, ms, static))
handler := logRequests(log, recoverPanics(cors(cfg.AllowedOrigins, mux))) handler := metrics.Middleware(securityHeaders(logRequests(log, recoverPanics(cors(cfg.AllowedOrigins, mux)))))
return &http.Server{ return &http.Server{
Addr: cfg.HTTPAddr, Addr: cfg.HTTPAddr,
+55
View File
@@ -181,4 +181,59 @@ func TestIntegration(t *testing.T) {
if err != nil || !belongs { if err != nil || !belongs {
t.Fatalf("MessageBelongsToConversation = %v, %v; want true", belongs, err) t.Fatalf("MessageBelongsToConversation = %v, %v; want true", belongs, err)
} }
// Search
hits, err := db.SearchMessages(ctx, u1.ID, "hell", 10)
if err != nil {
t.Fatalf("SearchMessages: %v", err)
}
if len(hits) != 1 || hits[0].ID != msg.ID {
t.Fatalf("SearchMessages = %+v, want single hit for message %d", hits, msg.ID)
}
if hits[0].ConversationType != "dm" {
t.Fatalf("SearchMessages conversation type = %q, want dm", hits[0].ConversationType)
}
if hits, _ := db.SearchMessages(ctx, u1.ID, "", 10); hits != nil {
t.Fatalf("SearchMessages empty query = %v, want nil", hits)
}
if hits, _ := db.SearchMessages(ctx, u1.ID, "zzz-not-found", 10); len(hits) != 0 {
t.Fatalf("SearchMessages no-match = %d hits, want 0", len(hits))
}
// Push subscriptions
sub, err := db.CreatePushSubscription(ctx, PushSubscription{
UserID: u1.ID,
Endpoint: "https://push.example/abc",
P256dh: "p256dh-key",
Auth: "auth-key",
})
if err != nil {
t.Fatalf("CreatePushSubscription: %v", err)
}
sub2, err := db.CreatePushSubscription(ctx, PushSubscription{
UserID: u1.ID,
Endpoint: "https://push.example/abc",
P256dh: "p256dh-key2",
Auth: "auth-key2",
})
if err != nil {
t.Fatalf("CreatePushSubscription upsert: %v", err)
}
if sub2.ID != sub.ID {
t.Fatalf("upsert created a new row: %d vs %d", sub2.ID, sub.ID)
}
subs, err := db.ListPushSubscriptionsForUser(ctx, u1.ID)
if err != nil || len(subs) != 1 {
t.Fatalf("ListPushSubscriptionsForUser = %v, %v; want 1", subs, err)
}
if subs[0].P256dh != "p256dh-key2" {
t.Fatalf("upsert did not update keys: %q", subs[0].P256dh)
}
if err := db.DeletePushSubscription(ctx, u1.ID, sub.Endpoint); err != nil {
t.Fatalf("DeletePushSubscription: %v", err)
}
subs, _ = db.ListPushSubscriptionsForUser(ctx, u1.ID)
if len(subs) != 0 {
t.Fatalf("after delete = %d subs, want 0", len(subs))
}
} }
@@ -0,0 +1,10 @@
-- 0005_push.sql
CREATE TABLE IF NOT EXISTS push_subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
+61
View File
@@ -0,0 +1,61 @@
package store
import (
"context"
"time"
)
// PushSubscription is a browser Web Push endpoint registered by a user.
type PushSubscription struct {
ID int64
UserID int64
Endpoint string
P256dh string
Auth string
CreatedAt time.Time
}
// CreatePushSubscription stores a subscription, replacing any previous one
// bound to the same endpoint.
func (s *Store) CreatePushSubscription(ctx context.Context, sub PushSubscription) (*PushSubscription, error) {
err := s.pool.QueryRow(ctx, `
INSERT INTO push_subscriptions(user_id, endpoint, p256dh, auth)
VALUES($1, $2, $3, $4)
ON CONFLICT (endpoint) DO UPDATE SET user_id = EXCLUDED.user_id, p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth
RETURNING id, created_at
`, sub.UserID, sub.Endpoint, sub.P256dh, sub.Auth).Scan(&sub.ID, &sub.CreatedAt)
if err != nil {
return nil, err
}
return &sub, nil
}
// DeletePushSubscription removes a subscription by endpoint and owner.
func (s *Store) DeletePushSubscription(ctx context.Context, userID int64, endpoint string) error {
_, err := s.pool.Exec(ctx, `
DELETE FROM push_subscriptions WHERE user_id=$1 AND endpoint=$2
`, userID, endpoint)
return err
}
// ListPushSubscriptionsForUser returns all endpoints registered by a user.
func (s *Store) ListPushSubscriptionsForUser(ctx context.Context, userID int64) ([]PushSubscription, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, user_id, endpoint, p256dh, auth, created_at
FROM push_subscriptions WHERE user_id=$1
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var subs []PushSubscription
for rows.Next() {
var sub PushSubscription
if err := rows.Scan(&sub.ID, &sub.UserID, &sub.Endpoint, &sub.P256dh, &sub.Auth, &sub.CreatedAt); err != nil {
return nil, err
}
subs = append(subs, sub)
}
return subs, nil
}
+50
View File
@@ -0,0 +1,50 @@
package store
import (
"context"
)
// MessageHit is a search result: a message scoped to the member's
// conversations, with its conversation context.
type MessageHit struct {
Message
ConversationType string
ConversationName string
}
// SearchMessages finds messages matching query in conversations the user
// belongs to, newest first. ILIKE substring matching keeps the search
// dependency-free at family scale; pg_trgm is the documented scaling path.
func (s *Store) SearchMessages(ctx context.Context, userID int64, query string, limit int) ([]MessageHit, error) {
if query == "" {
return nil, nil
}
pattern := "%" + query + "%"
rows, err := s.pool.Query(ctx, `
SELECT m.id, m.conversation_id, m.sender_id, m.body, m.media_id, m.created_at, m.edited_at, m.deleted_at,
c.type, c.name
FROM messages m
JOIN conversation_members cm ON cm.conversation_id = m.conversation_id AND cm.user_id = $1
JOIN conversations c ON c.id = m.conversation_id
WHERE m.body ILIKE $2
ORDER BY m.id DESC
LIMIT $3
`, userID, pattern, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var hits []MessageHit
for rows.Next() {
var h MessageHit
if err := rows.Scan(
&h.ID, &h.ConversationID, &h.SenderID, &h.Body, &h.MediaID, &h.CreatedAt, &h.EditedAt, &h.DeletedAt,
&h.ConversationType, &h.ConversationName,
); err != nil {
return nil, err
}
hits = append(hits, h)
}
return hits, nil
}
+13
View File
@@ -65,3 +65,16 @@ func (s *Store) RevokeAllSessions(ctx context.Context, userID int64) error {
`, userID) `, userID)
return err return err
} }
// CleanupSessions removes expired sessions and revocations older than a week.
func (s *Store) CleanupSessions(ctx context.Context) (int64, error) {
tag, err := s.pool.Exec(ctx, `
DELETE FROM sessions
WHERE expires_at < now()
OR (revoked_at IS NOT NULL AND revoked_at < now() - interval '7 days')
`)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
+1
View File
@@ -0,0 +1 @@
VITE_VAPID_PUBLIC_KEY=
+4867 -1
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -13,7 +13,9 @@
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"tailwindcss": "^4.3.3" "tailwindcss": "^4.3.3",
"vite-plugin-pwa": "^1.3.0",
"workbox-precaching": "^7.4.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+210
View File
@@ -0,0 +1,210 @@
import { useEffect, useRef, useState } from 'react'
import Avatar, { GroupAvatar } from './Avatar'
import { IconClose, IconSearch } from './icons'
import { api } from '../lib/api'
import { formatListTime, peerForConversation, userTitle } from '../lib/format'
import type { Conversation, SearchHit, User } from '../lib/types'
interface SearchDialogProps {
onClose: () => void
onSelect: (conversationId: number) => void
conversations: Conversation[]
peers: Record<number, number>
directory: Map<number, User>
currentUser: User
}
const MIN_QUERY = 2
export default function SearchDialog({
onClose,
onSelect,
conversations,
peers,
directory,
currentUser,
}: SearchDialogProps) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchHit[]>([])
const [searchedTerm, setSearchedTerm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
useEffect(() => {
const term = query.trim()
if (term.length < MIN_QUERY) return
let cancelled = false
const timer = window.setTimeout(() => {
setLoading(true)
api
.searchMessages(term)
.then((hits) => {
if (cancelled) return
setResults(hits)
setSearchedTerm(term)
setError(null)
})
.catch((err: unknown) => {
if (cancelled) return
setResults([])
setSearchedTerm(term)
setError(err instanceof Error ? err.message : 'Не удалось выполнить поиск')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
}, 300)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [query])
const titleFor = (hit: SearchHit): string => {
const conversation = conversations.find((item) => item.id === hit.conversation.id)
if (conversation) {
if (conversation.type === 'group') return conversation.name || `Группа #${conversation.id}`
const peer = peerForConversation(conversation, peers, directory, currentUser.id)
if (peer) return userTitle(peer)
}
if (hit.conversation.type === 'group') return hit.conversation.name || `Группа #${hit.conversation.id}`
return hit.conversation.name || `Чат #${hit.conversation.id}`
}
const isGroupHit = (hit: SearchHit): boolean => {
const conversation = conversations.find((item) => item.id === hit.conversation.id)
return (conversation?.type ?? hit.conversation.type) === 'group'
}
const previewFor = (hit: SearchHit): string => {
if (!hit.message.body && hit.message.media) return 'Вложение'
return hit.message.body
}
const trimmed = query.trim()
const tooShort = trimmed.length < MIN_QUERY
const settled = !tooShort && !loading && searchedTerm === trimmed
const showEmpty = settled && !error && results.length === 0
const handlePick = (hit: SearchHit) => {
onSelect(hit.conversation.id)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 p-0 backdrop-blur-sm sm:p-6 sm:pt-[12vh]">
<button type="button" aria-label="Закрыть поиск" onClick={onClose} className="absolute inset-0 cursor-default" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="search-title"
className="relative flex max-h-[80vh] w-full max-w-lg animate-rise flex-col overflow-hidden rounded-t-3xl border border-line bg-panel shadow-2xl sm:rounded-3xl"
>
<div className="flex items-center justify-between border-b border-line/80 px-5 py-4">
<div>
<h2 id="search-title" className="font-display text-base font-semibold text-fg">
Поиск по сообщениям
</h2>
<p className="text-xs text-muted">Найдите текст в любом разговоре</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Закрыть"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-fg"
>
<IconClose className="h-5 w-5" />
</button>
</div>
<div className="px-5 pt-4">
<div className="relative">
<IconSearch className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
<label htmlFor="message-search" className="sr-only">
Поиск по сообщениям
</label>
<input
id="message-search"
ref={inputRef}
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Введите минимум 2 символа"
className="w-full rounded-xl border border-line bg-surface/70 py-2.5 pr-3 pl-9 text-sm text-fg placeholder:text-faint focus:border-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20"
/>
</div>
{error && <p className="mt-2 text-xs text-danger">{error}</p>}
</div>
<div className="scrollbar-slim flex-1 overflow-y-auto px-3 py-3">
{tooShort && (
<div className="px-6 py-12 text-center text-sm text-muted">
Введите минимум {MIN_QUERY} символа, чтобы начать поиск
</div>
)}
{!tooShort && loading && (
<div className="space-y-1.5 p-1">
{[0, 1, 2].map((row) => (
<div key={row} className="flex items-center gap-3 rounded-xl p-2.5">
<span className="h-10 w-10 animate-pulse-soft rounded-full bg-surface-2" />
<span className="flex-1 space-y-2">
<span className="block h-3 w-1/3 animate-pulse-soft rounded-full bg-surface-2" />
<span className="block h-3 w-3/4 animate-pulse-soft rounded-full bg-surface-2/70" />
</span>
</div>
))}
</div>
)}
{!tooShort && showEmpty && (
<div className="px-6 py-12 text-center text-sm text-muted">Ничего не найдено</div>
)}
{!tooShort && !loading && results.length > 0 && (
<ul className="space-y-0.5">
{results.map((hit) => {
const title = titleFor(hit)
const group = isGroupHit(hit)
return (
<li key={hit.message.id}>
<button
type="button"
onClick={() => handlePick(hit)}
className="flex w-full items-start gap-3 rounded-xl px-2.5 py-2.5 text-left transition hover:bg-surface-2/70"
>
{group ? (
<GroupAvatar name={title} seed={hit.conversation.id} size="md" />
) : (
<Avatar name={title} seed={hit.conversation.id} size="md" />
)}
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-semibold text-fg">{title}</span>
<span className="shrink-0 text-[11px] text-faint">
{formatListTime(hit.message.created_at)}
</span>
</span>
<span className="mt-0.5 line-clamp-2 text-xs text-muted">{previewFor(hit)}</span>
</span>
</button>
</li>
)
})}
</ul>
)}
</div>
</div>
</div>
)
}
+21 -8
View File
@@ -17,6 +17,7 @@ interface SidebarProps {
typing: Record<number, boolean> typing: Record<number, boolean>
onSelect: (id: number) => void onSelect: (id: number) => void
onNewChat: () => void onNewChat: () => void
onSearch: () => void
onRetry: () => void onRetry: () => void
onLogout: () => void onLogout: () => void
} }
@@ -47,6 +48,7 @@ export default function Sidebar({
typing, typing,
onSelect, onSelect,
onNewChat, onNewChat,
onSearch,
onRetry, onRetry,
onLogout, onLogout,
}: SidebarProps) { }: SidebarProps) {
@@ -91,14 +93,25 @@ export default function Sidebar({
</span> </span>
<span className="font-display text-lg font-bold tracking-tight text-fg">Пульс</span> <span className="font-display text-lg font-bold tracking-tight text-fg">Пульс</span>
</div> </div>
<button <div className="flex items-center gap-2">
type="button" <button
onClick={onNewChat} type="button"
aria-label="Новый чат" onClick={onSearch}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-line bg-surface text-muted transition hover:border-accent/50 hover:text-accent" aria-label="Поиск по сообщениям"
> title="Поиск по сообщениям"
<IconPlus className="h-5 w-5" /> className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-line bg-surface text-muted transition hover:border-accent/50 hover:text-accent"
</button> >
<IconSearch className="h-5 w-5" />
</button>
<button
type="button"
onClick={onNewChat}
aria-label="Новый чат"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-line bg-surface text-muted transition hover:border-accent/50 hover:text-accent"
>
<IconPlus className="h-5 w-5" />
</button>
</div>
</div> </div>
<div className="px-4 pb-2"> <div className="px-4 pb-2">
+27 -1
View File
@@ -1,4 +1,14 @@
import type { Contact, Conversation, Media, Member, Message, SendMessageResult, User } from './types' import type {
Contact,
Conversation,
Media,
Member,
Message,
PushSubscriptionPayload,
SearchHit,
SendMessageResult,
User,
} from './types'
const API_BASE = '/api/v1' const API_BASE = '/api/v1'
@@ -147,6 +157,9 @@ export interface ApiClient {
addReaction(conversationId: number, messageId: number, emoji: string): Promise<Message> addReaction(conversationId: number, messageId: number, emoji: string): Promise<Message>
removeReaction(conversationId: number, messageId: number, emoji: string): Promise<Message> removeReaction(conversationId: number, messageId: number, emoji: string): Promise<Message>
uploadMedia(file: File): Promise<Media> uploadMedia(file: File): Promise<Media>
searchMessages(query: string): Promise<SearchHit[]>
subscribePush(subscription: PushSubscriptionPayload): Promise<void>
unsubscribePush(endpoint: string): Promise<void>
} }
export const api: ApiClient = { export const api: ApiClient = {
@@ -259,4 +272,17 @@ export const api: ApiClient = {
form.append('file', file) form.append('file', file)
return requestForm<Media>('/media', form) return requestForm<Media>('/media', form)
}, },
searchMessages(query) {
const params = new URLSearchParams({ q: query, limit: '50' })
return request<SearchHit[]>(`/search/messages?${params.toString()}`)
},
subscribePush(subscription) {
return request<void>('/push/subscribe', { method: 'POST', body: subscription })
},
unsubscribePush(endpoint) {
return request<void>('/push/subscribe', { method: 'DELETE', body: { endpoint } })
},
} }
+50
View File
@@ -0,0 +1,50 @@
import { api } from './api'
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = atob(base64)
const output = new Uint8Array(rawData.length)
for (let index = 0; index < rawData.length; index += 1) {
output[index] = rawData.charCodeAt(index)
}
return output
}
function encodeKey(key: ArrayBuffer | null): string {
if (!key) return ''
const bytes = new Uint8Array(key)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary)
}
export async function ensurePushSubscription(): Promise<void> {
const publicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
if (!publicKey) return
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return
try {
const permission = await Notification.requestPermission()
if (permission !== 'granted') return
const registration = await navigator.serviceWorker.ready
let subscription = await registration.pushManager.getSubscription()
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
}
await api.subscribePush({
endpoint: subscription.endpoint,
keys: {
p256dh: encodeKey(subscription.getKey('p256dh')),
auth: encodeKey(subscription.getKey('auth')),
},
})
} catch {
return
}
}
+17
View File
@@ -68,6 +68,23 @@ export interface SendMessageResult {
message: Message message: Message
} }
export interface SearchHit {
message: Message
conversation: {
id: number
type: string
name: string
}
}
export interface PushSubscriptionPayload {
endpoint: string
keys: {
p256dh: string
auth: string
}
}
export interface WsMessageCreated { export interface WsMessageCreated {
type: 'message.created' type: 'message.created'
data: { data: {
+3
View File
@@ -1,9 +1,12 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { registerSW } from 'virtual:pwa-register'
import './index.css' import './index.css'
import App from './App' import App from './App'
import { AuthProvider } from './auth/AuthContext' import { AuthProvider } from './auth/AuthContext'
registerSW({ immediate: true })
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<AuthProvider> <AuthProvider>
+19
View File
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Sidebar from '../components/Sidebar' import Sidebar from '../components/Sidebar'
import ChatWindow from '../components/ChatWindow' import ChatWindow from '../components/ChatWindow'
import NewChatDialog from '../components/NewChatDialog' import NewChatDialog from '../components/NewChatDialog'
import SearchDialog from '../components/SearchDialog'
import { api } from '../lib/api' import { api } from '../lib/api'
import { ensurePushSubscription } from '../lib/push'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { useConversations } from '../hooks/useConversations' import { useConversations } from '../hooks/useConversations'
import { useMessages } from '../hooks/useMessages' import { useMessages } from '../hooks/useMessages'
@@ -21,6 +23,7 @@ export default function ChatApp({ user }: ChatAppProps) {
const [presence, setPresence] = useState<Record<number, boolean>>({}) const [presence, setPresence] = useState<Record<number, boolean>>({})
const [typing, setTyping] = useState<Record<number, boolean>>({}) const [typing, setTyping] = useState<Record<number, boolean>>({})
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [focusTick, setFocusTick] = useState(0) const [focusTick, setFocusTick] = useState(0)
const [membersVersion, setMembersVersion] = useState(0) const [membersVersion, setMembersVersion] = useState(0)
@@ -78,6 +81,10 @@ export default function ChatApp({ user }: ChatAppProps) {
void loadContacts() void loadContacts()
}, [loadContacts]) }, [loadContacts])
useEffect(() => {
void ensurePushSubscription()
}, [])
useEffect(() => { useEffect(() => {
const onFocus = () => setFocusTick((tick) => tick + 1) const onFocus = () => setFocusTick((tick) => tick + 1)
window.addEventListener('focus', onFocus) window.addEventListener('focus', onFocus)
@@ -348,6 +355,7 @@ export default function ChatApp({ user }: ChatAppProps) {
typing={typing} typing={typing}
onSelect={handleSelect} onSelect={handleSelect}
onNewChat={() => setDialogOpen(true)} onNewChat={() => setDialogOpen(true)}
onSearch={() => setSearchOpen(true)}
onRetry={() => void reload()} onRetry={() => void reload()}
onLogout={() => void logout()} onLogout={() => void logout()}
/> />
@@ -386,6 +394,17 @@ export default function ChatApp({ user }: ChatAppProps) {
onContactsChanged={() => void loadContacts()} onContactsChanged={() => void loadContacts()}
/> />
)} )}
{searchOpen && (
<SearchDialog
onClose={() => setSearchOpen(false)}
onSelect={handleSelect}
conversations={conversations}
peers={peers}
directory={directory}
currentUser={user}
/>
)}
</div> </div>
) )
} }
+44
View File
@@ -0,0 +1,44 @@
import { precacheAndRoute } from 'workbox-precaching'
declare let self: ServiceWorkerGlobalScope
interface PushPayload {
title?: string
body?: string
conversation_id?: number
message_id?: number
}
precacheAndRoute(self.__WB_MANIFEST)
self.addEventListener('push', (event) => {
if (!event.data) return
let payload: PushPayload
try {
payload = event.data.json() as PushPayload
} catch {
payload = { body: event.data.text() }
}
const title = payload.title ?? 'Пульс'
const body = payload.body ?? 'Новое сообщение'
event.waitUntil(
self.registration.showNotification(title, {
body,
icon: '/icon-192.png',
badge: '/icon-192.png',
data: { conversation_id: payload.conversation_id, message_id: payload.message_id },
}),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ('focus' in client) return client.focus()
}
return self.clients.openWindow('/')
}),
)
})
+1
View File
@@ -0,0 +1 @@
import './service-worker'
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
interface ImportMetaEnv {
readonly VITE_VAPID_PUBLIC_KEY: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+2 -1
View File
@@ -22,5 +22,6 @@
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/service-worker.ts", "src/sw.ts"]
} }
+2 -1
View File
@@ -2,6 +2,7 @@
"files": [], "files": [],
"references": [ "references": [
{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" } { "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.sw.json" }
] ]
} }
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.sw.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "WebWorker"],
"module": "esnext",
"types": [],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/sw.ts", "src/service-worker.ts"]
}
+27 -1
View File
@@ -1,10 +1,36 @@
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [
react(),
tailwindcss(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
registerType: 'autoUpdate',
manifest: {
name: 'Messenger',
short_name: 'Messenger',
theme_color: '#0f1115',
background_color: '#0f1115',
display: 'standalone',
start_url: '/',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
devOptions: {
enabled: true,
type: 'module',
},
}),
],
server: { server: {
proxy: { proxy: {
'/api': { '/api': {