added media fixes, search, push updates, pwa, metrics. preparing release 1.0
This commit is contained in:
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
||||||
+9
-1
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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", ""), ",") {
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_VAPID_PUBLIC_KEY=
|
||||||
Generated
+4867
-1
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -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 |
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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 } })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('/')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import './service-worker'
|
||||||
Vendored
+10
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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': {
|
||||||
|
|||||||
Reference in New Issue
Block a user