Initialize Ulti Backend project with Docker setup, environment configuration, and core services. Added .dockerignore, .env.example, Dockerfile, and docker-compose files for PostgreSQL, KeyDB, RustFS, Authentik, Nextcloud, Jitsi, and Immich. Implemented main application structure in Go with API handlers and environment variable expansion. Included README for project overview and setup instructions.
This commit is contained in:
commit
d86f5f6c17
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
.git
|
||||
.env
|
||||
project-plan
|
||||
README.md
|
||||
.gitignore
|
||||
.dockerignore
|
||||
.idea
|
||||
.vscode
|
||||
deploy
|
||||
182
.env.example
Normal file
182
.env.example
Normal file
@ -0,0 +1,182 @@
|
||||
# =============================================================================
|
||||
# Ulti Backend — Configuration
|
||||
# =============================================================================
|
||||
# Secrets : definir UNE FOIS dans la section ci-dessous.
|
||||
# Le reste du fichier utilise {{NOM_VARIABLE}} — expansion au lancement
|
||||
# (docker compose, go run, migrate) via cmd/envexpand → .env.resolved
|
||||
#
|
||||
# Chaque brique peut etre en mode "local" (stack Docker) ou "external".
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Secrets — source unique (rotation ici uniquement)
|
||||
# -----------------------------------------------------------------------------
|
||||
POSTGRES_PASSWORD=changeme
|
||||
RUSTFS_ACCESS_KEY=ultiadmin
|
||||
RUSTFS_SECRET_KEY=changeme123
|
||||
AUTHENTIK_SECRET_KEY=changeme-generate-a-long-random-string
|
||||
ULTID_OIDC_CLIENT_SECRET=changeme
|
||||
MAIL_CREDENTIAL_KEYS=v1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
|
||||
MAIL_ACTIVE_CREDENTIAL_KEY_ID=v1
|
||||
MAIL_WEBHOOK_SHARED_SECRET=changeme-webhook-signing-secret
|
||||
NC_ADMIN_PASSWORD=changeme
|
||||
NC_OIDC_CLIENT_SECRET=changeme
|
||||
JITSI_APP_SECRET=changeme-jwt-secret
|
||||
JITSI_INTERNAL_AUTH_PASSWORD=changeme
|
||||
KEYDB_PASSWORD=
|
||||
MEILISEARCH_API_KEY=changeme
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# General
|
||||
# -----------------------------------------------------------------------------
|
||||
DOMAIN=localhost
|
||||
ULTID_PORT=8080
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# PostgreSQL
|
||||
# Mode local : deploye dans la stack Docker (deploy/docker-compose.yml)
|
||||
# Mode externe : pointer vers une instance existante
|
||||
# -----------------------------------------------------------------------------
|
||||
POSTGRES_USER=ulti
|
||||
POSTGRES_DB=ultidb
|
||||
POSTGRES_MULTIPLE_DATABASES=ultidb,authentik,nextcloud,immich
|
||||
|
||||
# Connection utilisee par ultid (changer host pour instance externe)
|
||||
ULTID_DB_URL=postgres://{{POSTGRES_USER}}:{{POSTGRES_PASSWORD}}@postgres:5432/{{POSTGRES_DB}}?sslmode=disable
|
||||
# Exemple externe :
|
||||
# ULTID_DB_URL=postgres://user:{{POSTGRES_PASSWORD}}@db.example.com:5432/ultidb?sslmode=require
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# KeyDB (Redis-compatible)
|
||||
# Mode local : deploye dans la stack
|
||||
# Mode externe : pointer vers un Redis/KeyDB/Valkey existant
|
||||
# -----------------------------------------------------------------------------
|
||||
ULTID_KEYDB_URL=keydb:6379
|
||||
ULTID_KEYDB_PASSWORD={{KEYDB_PASSWORD}}
|
||||
ULTID_KEYDB_DB=0
|
||||
# Exemple externe : ULTID_KEYDB_URL=redis.example.com:6379
|
||||
|
||||
KEYDB_HOST=keydb
|
||||
KEYDB_PORT=6379
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Object Storage (S3-compatible : RustFS, MinIO, AWS S3, Backblaze, etc.)
|
||||
# Mode local : RustFS deploye dans la stack
|
||||
# Mode externe : n'importe quel endpoint S3-compatible
|
||||
# -----------------------------------------------------------------------------
|
||||
ULTID_RUSTFS_ENDPOINT=rustfs:9000
|
||||
ULTID_RUSTFS_ACCESS_KEY={{RUSTFS_ACCESS_KEY}}
|
||||
ULTID_RUSTFS_SECRET_KEY={{RUSTFS_SECRET_KEY}}
|
||||
ULTID_RUSTFS_USE_SSL=false
|
||||
ULTID_RUSTFS_REGION=us-east-1
|
||||
# Exemple AWS S3 :
|
||||
# ULTID_RUSTFS_ENDPOINT=s3.amazonaws.com
|
||||
# ULTID_RUSTFS_USE_SSL=true
|
||||
# ULTID_RUSTFS_REGION=eu-west-1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Auth / OIDC (Authentik, Keycloak, ou tout provider OIDC)
|
||||
# Mode local : Authentik deploye dans la stack
|
||||
# Mode externe : n'importe quel provider OIDC existant
|
||||
# -----------------------------------------------------------------------------
|
||||
ULTID_OIDC_ISSUER=http://authentik-server:9000/application/o/ulti/
|
||||
ULTID_OIDC_CLIENT_ID=ulti-backend
|
||||
# ULTID_OIDC_CLIENT_SECRET — defini dans la section Secrets
|
||||
# Exemple Keycloak externe :
|
||||
# ULTID_OIDC_ISSUER=https://auth.example.com/realms/ulti
|
||||
# ULTID_OIDC_CLIENT_ID=ulti-backend
|
||||
|
||||
# Config du container Authentik local (ignoree si provider externe)
|
||||
# AUTHENTIK_SECRET_KEY — defini dans la section Secrets
|
||||
AUTHENTIK_POSTGRESQL__HOST=postgres
|
||||
AUTHENTIK_POSTGRESQL__USER={{POSTGRES_USER}}
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD={{POSTGRES_PASSWORD}}
|
||||
AUTHENTIK_POSTGRESQL__NAME=authentik
|
||||
AUTHENTIK_REDIS__HOST=keydb
|
||||
AUTHENTIK_WEB__PATH=/auth/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Nextcloud (Drive / Calendar / Contacts)
|
||||
# Mode local : Nextcloud FPM deploye dans la stack
|
||||
# Mode externe : instance Nextcloud existante
|
||||
# -----------------------------------------------------------------------------
|
||||
# URL interne (ultid → nginx Nextcloud, racine WebDAV)
|
||||
NEXTCLOUD_URL=http://nextcloud:80
|
||||
# URL publique UI (edge nginx /cloud/)
|
||||
NC_PUBLIC_URL=http://{{DOMAIN}}/cloud
|
||||
NC_OVERWRITE_PROTOCOL=http
|
||||
NC_ADMIN_USER=admin
|
||||
# NC_ADMIN_PASSWORD — defini dans la section Secrets
|
||||
# Exemple externe :
|
||||
# NEXTCLOUD_URL=https://cloud.example.com
|
||||
# NC_ADMIN_USER=ulti-service
|
||||
# Desactiver (pas de Drive/Cal/Contacts) : NEXTCLOUD_ENABLED=false
|
||||
|
||||
NEXTCLOUD_ENABLED=true
|
||||
# Ce flag pilote aussi le lancement Docker via ./deploy/compose-up.sh
|
||||
|
||||
NC_OIDC_CLIENT_ID=ulti-nextcloud
|
||||
# NC_OIDC_CLIENT_SECRET — defini dans la section Secrets
|
||||
NC_OIDC_DISCOVERY_URL=http://authentik-server:9000/application/o/nextcloud/.well-known/openid-configuration
|
||||
|
||||
NC_S3_BUCKET=nextcloud
|
||||
NC_S3_HOST=rustfs
|
||||
NC_S3_PORT=9000
|
||||
NC_S3_KEY={{RUSTFS_ACCESS_KEY}}
|
||||
NC_S3_SECRET={{RUSTFS_SECRET_KEY}}
|
||||
NC_S3_SSL=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Jitsi Meet (Visioconference)
|
||||
# Mode local : Jitsi deploye dans la stack
|
||||
# Mode externe : instance Jitsi existante avec JWT configure
|
||||
# -----------------------------------------------------------------------------
|
||||
JITSI_ENABLED=true
|
||||
# Ce flag pilote aussi le lancement Docker via ./deploy/compose-up.sh
|
||||
JITSI_DOMAIN=meet.jitsi
|
||||
JITSI_APP_ID=ulti
|
||||
# JITSI_APP_SECRET — defini dans la section Secrets
|
||||
JITSI_PUBLIC_URL=https://{{DOMAIN}}/meet
|
||||
|
||||
JICOFO_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
|
||||
JVB_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
|
||||
JVB_STUN_SERVERS=stun.l.google.com:19302
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Immich (Photos)
|
||||
# Mode local : Immich deploye dans la stack
|
||||
# Mode externe : instance Immich existante
|
||||
# -----------------------------------------------------------------------------
|
||||
IMMICH_ENABLED=true
|
||||
# Ce flag pilote aussi le lancement Docker via ./deploy/compose-up.sh
|
||||
# Immich utilise son propre Postgres (pgvecto.rs) via immich-postgres
|
||||
IMMICH_API_URL=http://immich-server:2283/api
|
||||
|
||||
IMMICH_DB_NAME=immich
|
||||
IMMICH_UPLOAD_LOCATION=/upload
|
||||
IMMICH_ML_URL=http://immich-ml:3003
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Mail (Ultimail) — toujours gere par ultid
|
||||
# -----------------------------------------------------------------------------
|
||||
MAIL_ATTACHMENTS_BUCKET=mail-attachments
|
||||
MAIL_SYNC_INTERVAL=2m
|
||||
MAIL_OUTBOX_INTERVAL=10s
|
||||
# Credentials IMAP/SMTP chiffrés AES-GCM (format keyring: key_id:base64key,key_id2:base64key2)
|
||||
# Rotation: ajouter nouvelle clé dans MAIL_CREDENTIAL_KEYS puis basculer MAIL_ACTIVE_CREDENTIAL_KEY_ID.
|
||||
# Les anciennes clés restent présentes temporairement pour déchiffrement.
|
||||
# Runtime secrets possibles via *_FILE (ex: MAIL_CREDENTIAL_KEYS_FILE=/run/secrets/mail_keys)
|
||||
|
||||
# Politique rotation secrets (RFC3339 + durée)
|
||||
SECRET_ROTATION_MAX_AGE=2160h
|
||||
ULTID_OIDC_CLIENT_SECRET_ROTATED_AT=2026-01-01T00:00:00Z
|
||||
MAIL_CREDENTIAL_KEY_ROTATED_AT=2026-01-01T00:00:00Z
|
||||
MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT=2026-01-01T00:00:00Z
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Recherche
|
||||
# -----------------------------------------------------------------------------
|
||||
SEARCH_ENGINE=postgres
|
||||
# SEARCH_ENGINE=meilisearch
|
||||
# MEILISEARCH_URL=http://meilisearch:7700
|
||||
# MEILISEARCH_API_KEY={{MEILISEARCH_API_KEY}}
|
||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
# Environment
|
||||
.env
|
||||
.env.resolved
|
||||
|
||||
# Go binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/ultid
|
||||
|
||||
# Test & coverage
|
||||
*.test
|
||||
*.out
|
||||
coverage.html
|
||||
|
||||
# Vendor (optional)
|
||||
vendor/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker data (local volumes)
|
||||
docker-data/
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@ -0,0 +1,21 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /ultid ./cmd/ultid
|
||||
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& addgroup -S ulti && adduser -S ulti -G ulti
|
||||
|
||||
COPY --from=builder /ultid /usr/local/bin/ultid
|
||||
|
||||
USER ulti
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["ultid"]
|
||||
137
README.md
Normal file
137
README.md
Normal file
@ -0,0 +1,137 @@
|
||||
# Ulti Backend
|
||||
|
||||
Backend monolithe Go orchestrant la Ulti Suite — alternative souveraine à Google Suite.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ nginx (reverse proxy unique, port 80) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ultid (Go monolithe) │
|
||||
│ ├─ /api/v1/mail — Ultimail (IMAP/SMTP custom) │
|
||||
│ ├─ /api/v1/drive — Ultidrive (→ Nextcloud WebDAV) │
|
||||
│ ├─ /api/v1/calendar — Agenda (→ Nextcloud CalDAV) │
|
||||
│ ├─ /api/v1/contacts — Contacts (→ Nextcloud CardDAV) │
|
||||
│ ├─ /api/v1/meet — Ultimeet (→ Jitsi JWT) │
|
||||
│ ├─ /api/v1/photos — Ultiphotos (→ Immich API) │
|
||||
│ ├─ /api/v1/admin — Administration │
|
||||
│ ├─ /api/v1/search — Recherche transversale │
|
||||
│ └─ /ws — WebSocket realtime │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Services tiers (Docker) │
|
||||
│ ├─ PostgreSQL 16 — DB partagée │
|
||||
│ ├─ KeyDB — Cache/sessions │
|
||||
│ ├─ RustFS — Object storage S3 │
|
||||
│ ├─ Authentik — Auth OIDC/SAML │
|
||||
│ ├─ Nextcloud (nginx+FPM) — Drive/Cal/Contacts (/cloud) │
|
||||
│ ├─ Jitsi — Visioconférence │
|
||||
│ └─ Immich — Photos/ML │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Copy environment file
|
||||
cp .env.example .env
|
||||
# Edit secrets once at the top of .env (POSTGRES_PASSWORD, RUSTFS_SECRET_KEY, etc.)
|
||||
# Other variables use {{VAR}} placeholders expanded at launch.
|
||||
# Toggle modules with flags:
|
||||
# NEXTCLOUD_ENABLED=true|false
|
||||
# JITSI_ENABLED=true|false
|
||||
# IMMICH_ENABLED=true|false
|
||||
|
||||
# 2. Start stack (core + modules enabled by flags)
|
||||
./deploy/compose-up.sh up -d
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run locally (needs PG, KeyDB, RustFS running; loads .env with {{VAR}} expansion)
|
||||
go run ./cmd/ultid
|
||||
|
||||
# Build
|
||||
go build -o ultid ./cmd/ultid
|
||||
|
||||
# Expand .env for external tools (docker, migrate)
|
||||
go run ./cmd/envexpand -in .env -out .env.resolved
|
||||
source <(grep -v '^#' .env.resolved | sed 's/^/export /')
|
||||
|
||||
# Run migrations (use expanded ULTID_DB_URL; host may need localhost instead of postgres)
|
||||
migrate -path migrations -database "$ULTID_DB_URL" up
|
||||
```
|
||||
|
||||
### Reverse proxy (nginx)
|
||||
|
||||
Un seul **nginx** expose l’entrée HTTP (`:80`) et route :
|
||||
|
||||
| Chemin | Service |
|
||||
|--------|---------|
|
||||
| `/api/*` | ultid |
|
||||
| `/ws` | ultid (WebSocket) |
|
||||
| `/auth/*` | Authentik |
|
||||
| `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) |
|
||||
| `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) |
|
||||
|
||||
Nextcloud : FPM + nginx dédié ; ultid appelle `NEXTCLOUD_URL` en interne (`http://nextcloud:80`).
|
||||
Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx).
|
||||
|
||||
### Centralized secrets
|
||||
|
||||
Set passwords and keys **once** in the `Secrets` section at the top of `.env`. Derived values reference them with `{{POSTGRES_PASSWORD}}`, `{{RUSTFS_SECRET_KEY}}`, etc. Expansion runs via:
|
||||
|
||||
- `./deploy/compose-up.sh` — writes `.env.resolved` for Docker Compose
|
||||
- `go run ./cmd/envexpand -in .env -out .env.resolved` — manual export for migrate/scripts
|
||||
- `go run ./cmd/ultid` — expands `.env` in-process before reading config
|
||||
|
||||
Runtime secret files are also supported with `*_FILE` variables (example: `ULTID_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret`).
|
||||
|
||||
Mail credentials are encrypted at rest with AES-GCM using `MAIL_CREDENTIAL_KEYS` (`key_id:base64key,...`) and `MAIL_ACTIVE_CREDENTIAL_KEY_ID`.
|
||||
Secret rotation policy is enforced through:
|
||||
|
||||
- `SECRET_ROTATION_MAX_AGE`
|
||||
- `ULTID_OIDC_CLIENT_SECRET_ROTATED_AT`
|
||||
- `MAIL_CREDENTIAL_KEY_ROTATED_AT`
|
||||
- `MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT`
|
||||
|
||||
## Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| Backend | Go 1.23+ (chi, pgx, go-imap, go-smtp) |
|
||||
| Database | PostgreSQL 16 |
|
||||
| Cache | KeyDB (Redis-compatible, multi-threaded) |
|
||||
| Object Storage | RustFS (S3-compatible, Apache 2.0) |
|
||||
| Auth | Authentik (OIDC/SAML) |
|
||||
| Files/Cal/Contacts | Nextcloud headless (WebDAV/CalDAV/CardDAV) |
|
||||
| Video | Jitsi Meet (JWT auth) |
|
||||
| Photos | Immich (ML classification) |
|
||||
| Reverse Proxy | nginx (TLS à ajouter via certbot ou autre) |
|
||||
| Search | PostgreSQL tsvector + GIN |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── cmd/ultid/ — Entry point
|
||||
├── internal/
|
||||
│ ├── api/ — HTTP handlers (mail, admin, drive, calendar, contacts, meet, photos)
|
||||
│ ├── mail/ — IMAP sync, SMTP send, rules engine, webhooks
|
||||
│ ├── nextcloud/ — WebDAV/CalDAV/CardDAV client
|
||||
│ ├── meet/ — Jitsi JWT generation
|
||||
│ ├── photos/ — Immich API client
|
||||
│ ├── auth/ — OIDC verification
|
||||
│ ├── search/ — Full-text search
|
||||
│ ├── realtime/ — WebSocket hub
|
||||
│ └── config/ — Environment config
|
||||
├── migrations/ — SQL migrations
|
||||
├── deploy/ — Docker Compose configs
|
||||
│ ├── docker-compose.yml — Core stack
|
||||
│ ├── nginx/
|
||||
│ ├── nextcloud/
|
||||
│ ├── jitsi/
|
||||
│ └── immich/
|
||||
├── Dockerfile — Multi-stage build
|
||||
└── .env.example
|
||||
```
|
||||
27
cmd/envexpand/main.go
Normal file
27
cmd/envexpand/main.go
Normal file
@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/envexpand"
|
||||
)
|
||||
|
||||
func main() {
|
||||
in := flag.String("in", ".env", "input .env file")
|
||||
out := flag.String("out", "", "output file (default: stdout)")
|
||||
flag.Parse()
|
||||
|
||||
if err := run(*in, *out); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "envexpand: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(inPath, outPath string) error {
|
||||
if outPath == "" {
|
||||
return envexpand.RenderToWriter(inPath, os.Stdout)
|
||||
}
|
||||
return envexpand.Render(inPath, outPath)
|
||||
}
|
||||
225
cmd/ultid/main.go
Normal file
225
cmd/ultid/main.go
Normal file
@ -0,0 +1,225 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/admin"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/calendar"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/contacts"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/drive"
|
||||
mailapi "github.com/ultisuite/ulti-backend/internal/api/mail"
|
||||
meetapi "github.com/ultisuite/ulti-backend/internal/api/meet"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
photosapi "github.com/ultisuite/ulti-backend/internal/api/photos"
|
||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||
"github.com/ultisuite/ulti-backend/internal/config"
|
||||
"github.com/ultisuite/ulti-backend/internal/envexpand"
|
||||
mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
||||
imapsync "github.com/ultisuite/ulti-backend/internal/mail/imap"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/smtp"
|
||||
"github.com/ultisuite/ulti-backend/internal/meet"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
"github.com/ultisuite/ulti-backend/internal/photos"
|
||||
"github.com/ultisuite/ulti-backend/internal/realtime"
|
||||
"github.com/ultisuite/ulti-backend/internal/search"
|
||||
"github.com/ultisuite/ulti-backend/internal/securityaudit"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
loadDotEnv()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
slog.Error("failed to load config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
slog.Error("failed to connect to database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: cfg.KeyDBAddr})
|
||||
defer rdb.Close()
|
||||
|
||||
_, err = minio.New(cfg.RustFSEndpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.RustFSAccessKey, cfg.RustFSSecretKey, ""),
|
||||
Secure: cfg.RustFSUseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to create RustFS client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
verifier, err := auth.NewVerifier(ctx, cfg.OIDCIssuer, cfg.OIDCClientID)
|
||||
if err != nil {
|
||||
slog.Warn("OIDC verifier not available (Authentik may not be running)", "error", err)
|
||||
}
|
||||
if cfg.IsProduction() {
|
||||
if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" {
|
||||
slog.Error("missing required OIDC configuration in production",
|
||||
"ULTID_OIDC_ISSUER_set", cfg.OIDCIssuer != "",
|
||||
"ULTID_OIDC_CLIENT_ID_set", cfg.OIDCClientID != "")
|
||||
os.Exit(1)
|
||||
}
|
||||
if verifier == nil {
|
||||
slog.Error("OIDC verifier initialization failed in production")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := cfg.ValidateSecretRotation(); err != nil {
|
||||
slog.Error("secret rotation policy check failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else if err := cfg.ValidateSecretRotation(); err != nil {
|
||||
slog.Warn("secret rotation policy warning", "error", err)
|
||||
}
|
||||
|
||||
credentialManager, err := mailcredentials.NewManager(cfg.MailCredentialKeys, cfg.MailActiveCredentialKeyID)
|
||||
if err != nil {
|
||||
slog.Error("mail credential encryption not configured", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
auditLogger := securityaudit.NewLogger(pool)
|
||||
|
||||
// Nextcloud client (nil if disabled)
|
||||
var ncClient *nextcloud.Client
|
||||
if cfg.NextcloudEnabled {
|
||||
ncClient = nextcloud.NewClient(cfg.NextcloudURL, cfg.NCAdminUser, cfg.NCAdminPass)
|
||||
slog.Info("nextcloud enabled", "url", cfg.NextcloudURL)
|
||||
}
|
||||
|
||||
// Meet config (nil if disabled)
|
||||
var meetCfg *meet.Config
|
||||
if cfg.JitsiEnabled {
|
||||
meetCfg = meet.NewConfig(cfg.JitsiAppID, cfg.JitsiAppSecret, cfg.Domain)
|
||||
slog.Info("jitsi meet enabled", "domain", cfg.JitsiDomain)
|
||||
}
|
||||
|
||||
// Photos client (nil if disabled)
|
||||
var photosClient *photos.Client
|
||||
if cfg.ImmichEnabled {
|
||||
photosClient = photos.NewClient(cfg.ImmichAPIURL)
|
||||
slog.Info("immich photos enabled", "url", cfg.ImmichAPIURL)
|
||||
}
|
||||
|
||||
// WebSocket hub
|
||||
hub := realtime.NewHub()
|
||||
|
||||
// Start background workers
|
||||
go imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager).Start(ctx)
|
||||
|
||||
sender := smtp.NewSender(pool, credentialManager)
|
||||
go smtp.NewOutboxProcessor(pool, sender, cfg.MailOutboxInterval).Start(ctx)
|
||||
|
||||
// Router
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type", apiresponse.TraceIDHeader},
|
||||
ExposedHeaders: []string{apiresponse.TraceIDHeader},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
r.Use(middleware.TraceID)
|
||||
r.Use(middleware.Logging)
|
||||
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := pool.Ping(r.Context()); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Write([]byte("db unhealthy"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
r.Get("/ws", hub.HandleWS)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Auth(verifier, auditLogger))
|
||||
|
||||
r.Mount("/api/v1/mail", mailapi.NewHandler(pool, auditLogger, credentialManager).Routes())
|
||||
r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes())
|
||||
r.Get("/api/v1/search", search.NewHandler(pool).Search)
|
||||
|
||||
if ncClient != nil {
|
||||
r.Mount("/api/v1/drive", drive.NewHandler(ncClient).Routes())
|
||||
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient).Routes())
|
||||
r.Mount("/api/v1/contacts", contacts.NewHandler(ncClient).Routes())
|
||||
}
|
||||
if meetCfg != nil {
|
||||
r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes())
|
||||
}
|
||||
if photosClient != nil {
|
||||
r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient).Routes())
|
||||
}
|
||||
})
|
||||
|
||||
_ = rdb
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
slog.Info("server starting", "port", cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case <-quit:
|
||||
case err := <-errCh:
|
||||
slog.Error("server error", "error", err)
|
||||
}
|
||||
|
||||
slog.Info("shutting down server")
|
||||
cancel()
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("server forced shutdown", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("server stopped")
|
||||
}
|
||||
|
||||
func loadDotEnv() {
|
||||
for _, path := range []string{".env", "../.env"} {
|
||||
if err := envexpand.ApplyFile(path); err == nil {
|
||||
slog.Debug("loaded env file", "path", path)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
53
deploy/compose-up.sh
Executable file
53
deploy/compose-up.sh
Executable file
@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# Expand {{VAR}} placeholders in .env, then start Docker Compose.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
echo "Missing .env — run: cp .env.example .env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
go run ./cmd/envexpand -in .env -out .env.resolved
|
||||
else
|
||||
if [[ ! -x ./ultid-envexpand ]] && [[ ! -f ./bin/envexpand ]]; then
|
||||
echo "Go not found and envexpand binary missing. Install Go or build: go build -o bin/envexpand ./cmd/envexpand" >&2
|
||||
exit 1
|
||||
fi
|
||||
./bin/envexpand -in .env -out .env.resolved 2>/dev/null || ./ultid-envexpand -in .env -out .env.resolved
|
||||
fi
|
||||
|
||||
to_bool() {
|
||||
local value="${1:-}"
|
||||
value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$value" in
|
||||
1|true|yes|on) echo "true" ;;
|
||||
*) echo "false" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
set -a
|
||||
source .env.resolved
|
||||
set +a
|
||||
|
||||
compose_files=(
|
||||
"-f" "deploy/docker-compose.yml"
|
||||
)
|
||||
|
||||
if [[ "$(to_bool "${NEXTCLOUD_ENABLED:-false}")" == "true" ]]; then
|
||||
compose_files+=("-f" "deploy/nextcloud/docker-compose.nextcloud.yml")
|
||||
fi
|
||||
|
||||
if [[ "$(to_bool "${JITSI_ENABLED:-false}")" == "true" ]]; then
|
||||
compose_files+=("-f" "deploy/jitsi/docker-compose.jitsi.yml")
|
||||
fi
|
||||
|
||||
if [[ "$(to_bool "${IMMICH_ENABLED:-false}")" == "true" ]]; then
|
||||
compose_files+=("-f" "deploy/immich/docker-compose.immich.yml")
|
||||
fi
|
||||
|
||||
exec docker compose --env-file .env.resolved "${compose_files[@]}" "$@"
|
||||
136
deploy/docker-compose.yml
Normal file
136
deploy/docker-compose.yml
Normal file
@ -0,0 +1,136 @@
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro
|
||||
environment:
|
||||
DOMAIN: ${DOMAIN:-localhost}
|
||||
env_file: ../.env.resolved
|
||||
networks:
|
||||
- ulti-net
|
||||
depends_on:
|
||||
ultid:
|
||||
condition: service_started
|
||||
authentik-server:
|
||||
condition: service_started
|
||||
|
||||
ultid:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file: ../.env.resolved
|
||||
networks:
|
||||
- ulti-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
keydb:
|
||||
condition: service_healthy
|
||||
rustfs:
|
||||
condition: service_started
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
env_file: ../.env.resolved
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro
|
||||
networks:
|
||||
- ulti-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
keydb:
|
||||
image: eqalpha/keydb:latest
|
||||
restart: unless-stopped
|
||||
command: keydb-server --appendonly yes
|
||||
volumes:
|
||||
- keydb_data:/data
|
||||
networks:
|
||||
- ulti-net
|
||||
healthcheck:
|
||||
test: ["CMD", "keydb-cli", "-h", "127.0.0.1", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
rustfs:
|
||||
image: rustfs/rustfs:latest
|
||||
restart: unless-stopped
|
||||
command: server /data
|
||||
ports:
|
||||
- "9002:9000"
|
||||
- "9003:9001"
|
||||
environment:
|
||||
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
|
||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
|
||||
volumes:
|
||||
- rustfs_data:/data
|
||||
networks:
|
||||
- ulti-net
|
||||
|
||||
authentik-server:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||
AUTHENTIK_POSTGRESQL__HOST: ${AUTHENTIK_POSTGRESQL__HOST}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_POSTGRESQL__USER}
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRESQL__PASSWORD}
|
||||
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME}
|
||||
AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST}
|
||||
AUTHENTIK_WEB__PATH: /auth/
|
||||
env_file: ../.env.resolved
|
||||
networks:
|
||||
- ulti-net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
keydb:
|
||||
condition: service_healthy
|
||||
|
||||
authentik-worker:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||
AUTHENTIK_POSTGRESQL__HOST: ${AUTHENTIK_POSTGRESQL__HOST}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_POSTGRESQL__USER}
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRESQL__PASSWORD}
|
||||
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRESQL__NAME}
|
||||
AUTHENTIK_REDIS__HOST: ${AUTHENTIK_REDIS__HOST}
|
||||
AUTHENTIK_WEB__PATH: /auth/
|
||||
env_file: ../.env.resolved
|
||||
networks:
|
||||
- ulti-net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
keydb:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
ulti-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
keydb_data:
|
||||
rustfs_data:
|
||||
53
deploy/immich/docker-compose.immich.yml
Normal file
53
deploy/immich/docker-compose.immich.yml
Normal file
@ -0,0 +1,53 @@
|
||||
services:
|
||||
immich-postgres:
|
||||
image: tensorchord/pgvecto-rs:pg16-v0.3.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${IMMICH_DB_NAME:-immich}
|
||||
volumes:
|
||||
- immich_postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- ulti-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
immich-server:
|
||||
image: ghcr.io/immich-app/immich-server:v1.120.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DB_HOSTNAME=immich-postgres
|
||||
- DB_USERNAME=${POSTGRES_USER}
|
||||
- DB_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- DB_DATABASE_NAME=${IMMICH_DB_NAME:-immich}
|
||||
- REDIS_HOSTNAME=keydb
|
||||
- REDIS_PORT=6379
|
||||
- IMMICH_MACHINE_LEARNING_URL=http://immich-ml:3003
|
||||
- UPLOAD_LOCATION=/upload
|
||||
volumes:
|
||||
- immich_upload:/upload
|
||||
networks:
|
||||
- ulti-net
|
||||
depends_on:
|
||||
immich-postgres:
|
||||
condition: service_healthy
|
||||
keydb:
|
||||
condition: service_started
|
||||
|
||||
immich-ml:
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v1.120.0
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- immich_model_cache:/cache
|
||||
networks:
|
||||
- ulti-net
|
||||
|
||||
volumes:
|
||||
immich_upload:
|
||||
immich_model_cache:
|
||||
immich_postgres_data:
|
||||
8
deploy/init-db.sh
Normal file
8
deploy/init-db.sh
Normal file
@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
CREATE DATABASE authentik;
|
||||
CREATE DATABASE nextcloud;
|
||||
CREATE DATABASE immich;
|
||||
EOSQL
|
||||
71
deploy/jitsi/docker-compose.jitsi.yml
Normal file
71
deploy/jitsi/docker-compose.jitsi.yml
Normal file
@ -0,0 +1,71 @@
|
||||
x-jitsi-env: &jitsi-env
|
||||
JWT_APP_ID: ${JITSI_APP_ID:-ulti}
|
||||
JWT_APP_SECRET: ${JITSI_APP_SECRET:-changeme-jwt-secret}
|
||||
JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD:-changeme}
|
||||
JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD:-changeme}
|
||||
TZ: Europe/Paris
|
||||
|
||||
services:
|
||||
jitsi-web:
|
||||
image: jitsi/web:stable-9823
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *jitsi-env
|
||||
ENABLE_AUTH: "1"
|
||||
AUTH_TYPE: jwt
|
||||
JWT_ACCEPTED_ISSUERS: ulti
|
||||
JWT_ACCEPTED_AUDIENCES: ulti
|
||||
PUBLIC_URL: https://${DOMAIN:-localhost}/meet
|
||||
XMPP_DOMAIN: meet.jitsi
|
||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||
XMPP_BOSH_URL_BASE: http://jitsi-prosody:5280
|
||||
networks:
|
||||
- ulti-net
|
||||
depends_on:
|
||||
jitsi-prosody:
|
||||
condition: service_started
|
||||
|
||||
jitsi-prosody:
|
||||
image: jitsi/prosody:stable-9823
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *jitsi-env
|
||||
ENABLE_AUTH: "1"
|
||||
AUTH_TYPE: jwt
|
||||
JWT_ACCEPTED_ISSUERS: ulti
|
||||
JWT_ACCEPTED_AUDIENCES: ulti
|
||||
XMPP_DOMAIN: meet.jitsi
|
||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||
networks:
|
||||
- ulti-net
|
||||
|
||||
jitsi-jicofo:
|
||||
image: jitsi/jicofo:stable-9823
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *jitsi-env
|
||||
XMPP_DOMAIN: meet.jitsi
|
||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||
networks:
|
||||
- ulti-net
|
||||
depends_on:
|
||||
- jitsi-prosody
|
||||
|
||||
jitsi-jvb:
|
||||
image: jitsi/jvb:stable-9823
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "10000:10000/udp"
|
||||
environment:
|
||||
<<: *jitsi-env
|
||||
XMPP_DOMAIN: meet.jitsi
|
||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||
JVB_PORT: "10000"
|
||||
JVB_STUN_SERVERS: stun.l.google.com:19302
|
||||
PUBLIC_URL: https://${DOMAIN:-localhost}/meet
|
||||
networks:
|
||||
- ulti-net
|
||||
depends_on:
|
||||
- jitsi-prosody
|
||||
73
deploy/nextcloud/docker-compose.nextcloud.yml
Normal file
73
deploy/nextcloud/docker-compose.nextcloud.yml
Normal file
@ -0,0 +1,73 @@
|
||||
services:
|
||||
nextcloud-fpm:
|
||||
image: nextcloud:30-fpm-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
keydb:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_DB=nextcloud
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- REDIS_HOST=keydb
|
||||
- REDIS_HOST_PORT=6379
|
||||
- NEXTCLOUD_ADMIN_USER=${NC_ADMIN_USER:-admin}
|
||||
- NEXTCLOUD_ADMIN_PASSWORD=${NC_ADMIN_PASSWORD:-changeme}
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=${DOMAIN:-localhost}
|
||||
- OBJECTSTORE_S3_BUCKET=nextcloud
|
||||
- OBJECTSTORE_S3_HOST=rustfs
|
||||
- OBJECTSTORE_S3_PORT=9000
|
||||
- OBJECTSTORE_S3_KEY=${RUSTFS_ACCESS_KEY}
|
||||
- OBJECTSTORE_S3_SECRET=${RUSTFS_SECRET_KEY}
|
||||
- OBJECTSTORE_S3_SSL=false
|
||||
- OBJECTSTORE_S3_USEPATH_STYLE=true
|
||||
- OVERWRITEHOST=${DOMAIN:-localhost}
|
||||
- OVERWRITEPROTOCOL=${NC_OVERWRITE_PROTOCOL:-http}
|
||||
- OVERWRITEWEBROOT=/cloud
|
||||
- OVERWRITECLIURL=${NC_PUBLIC_URL:-http://localhost/cloud}
|
||||
- TRUSTED_PROXIES=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
||||
volumes:
|
||||
- nextcloud_data:/var/www/html
|
||||
- ./nextcloud/init.sh:/docker-entrypoint-hooks.d/post-installation/init.sh:ro
|
||||
networks:
|
||||
- ulti-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "php -r 'echo 1;' || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
nextcloud:
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
nextcloud-fpm:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./nextcloud/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- nextcloud_data:/var/www/html:ro
|
||||
networks:
|
||||
- ulti-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
nextcloud-cron:
|
||||
image: nextcloud:30-fpm-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- nextcloud-fpm
|
||||
entrypoint: /cron.sh
|
||||
volumes:
|
||||
- nextcloud_data:/var/www/html
|
||||
networks:
|
||||
- ulti-net
|
||||
|
||||
volumes:
|
||||
nextcloud_data:
|
||||
46
deploy/nextcloud/init.sh
Executable file
46
deploy/nextcloud/init.sh
Executable file
@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
OCC="php /var/www/html/occ"
|
||||
|
||||
# Wait for database
|
||||
sleep 5
|
||||
|
||||
# Disable unnecessary apps
|
||||
$OCC app:disable dashboard || true
|
||||
$OCC app:disable weather_status || true
|
||||
$OCC app:disable firstrunwizard || true
|
||||
$OCC app:disable recommendations || true
|
||||
$OCC app:disable survey_client || true
|
||||
|
||||
# Enable needed apps
|
||||
$OCC app:enable files || true
|
||||
$OCC app:enable calendar || true
|
||||
$OCC app:enable contacts || true
|
||||
$OCC app:enable groupfolders || true
|
||||
$OCC app:enable user_oidc || true
|
||||
|
||||
# Configure OIDC (Authentik)
|
||||
$OCC config:app:set user_oidc --value="1" allow_multiple_user_backends
|
||||
$OCC user_oidc:provider Authentik \
|
||||
--clientid="${OIDC_CLIENT_ID:-ulti-nextcloud}" \
|
||||
--clientsecret="${OIDC_CLIENT_SECRET:-changeme}" \
|
||||
--discoveryuri="${OIDC_DISCOVERY_URL:-http://authentik-server:9000/application/o/nextcloud/.well-known/openid-configuration}" \
|
||||
--unique-uid=1 \
|
||||
--check-bearer=1 \
|
||||
--mapping-uid=preferred_username \
|
||||
--mapping-email=email \
|
||||
--mapping-displayname=name || true
|
||||
|
||||
# Performance tuning
|
||||
$OCC config:system:set memcache.local --value='\OC\Memcache\APCu'
|
||||
$OCC config:system:set memcache.distributed --value='\OC\Memcache\Redis'
|
||||
$OCC config:system:set memcache.locking --value='\OC\Memcache\Redis'
|
||||
$OCC config:system:set redis host --value='keydb'
|
||||
$OCC config:system:set redis port --value='6379' --type=integer
|
||||
$OCC config:system:set default_phone_region --value='FR'
|
||||
|
||||
# Disable theming/UI since we use headless
|
||||
$OCC config:system:set skeletondirectory --value=''
|
||||
|
||||
echo "Nextcloud initialization complete."
|
||||
39
deploy/nextcloud/nginx.conf
Normal file
39
deploy/nextcloud/nginx.conf
Normal file
@ -0,0 +1,39 @@
|
||||
upstream php-handler {
|
||||
server nextcloud-fpm:9000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /var/www/html;
|
||||
client_max_body_size 10G;
|
||||
fastcgi_buffers 64 4K;
|
||||
|
||||
location / {
|
||||
rewrite ^ /index.php;
|
||||
}
|
||||
|
||||
location ~ ^/(?:build|tests|config|lib|3rdparty|templates|data)(?:$|/) {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ~ ^/(?:\.|autotest|occ|issue|indie|db_|console) {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ~ \.php(?:$|/) {
|
||||
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
|
||||
set $path_info $fastcgi_path_info;
|
||||
fastcgi_param PATH_INFO $path_info;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
fastcgi_pass php-handler;
|
||||
fastcgi_intercept_errors on;
|
||||
}
|
||||
|
||||
location ~ \.(?:css|js|svg|gif|png|jpg|ico|wasm|tflite|map|ogg|flac)$ {
|
||||
try_files $uri /index.php$request_uri;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
69
deploy/nginx/default.conf.template
Normal file
69
deploy/nginx/default.conf.template
Normal file
@ -0,0 +1,69 @@
|
||||
# Edge reverse proxy — single entry point (replaces Caddy).
|
||||
# Optional upstreams use Docker DNS resolver so nginx starts even if a module is disabled.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${DOMAIN};
|
||||
|
||||
client_max_body_size 10G;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://ultid:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://ultid:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /auth/ {
|
||||
proxy_pass http://authentik-server:9000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /meet/ {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $jitsi_upstream jitsi-web;
|
||||
proxy_pass http://$jitsi_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /cloud/ {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $nc_upstream nextcloud;
|
||||
proxy_pass http://$nc_upstream/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /cloud {
|
||||
return 301 /cloud/;
|
||||
}
|
||||
|
||||
location / {
|
||||
default_type text/plain;
|
||||
return 200 "Ulti Suite\n";
|
||||
}
|
||||
}
|
||||
40
go.mod
Normal file
40
go.mod
Normal file
@ -0,0 +1,40 @@
|
||||
module github.com/ultisuite/ulti-backend
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.7
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.1
|
||||
github.com/minio/minio-go/v7 v7.0.80
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-message v0.18.1 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
golang.org/x/crypto v0.28.0 // indirect
|
||||
golang.org/x/net v0.30.0 // indirect
|
||||
golang.org/x/oauth2 v0.24.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/text v0.19.0 // indirect
|
||||
)
|
||||
114
go.sum
Normal file
114
go.sum
Normal file
@ -0,0 +1,114 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.7 h1:lNznYWa5uhMrngnSYEklzCeye4DBq9TEJ+pr0K593+8=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.7/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
|
||||
github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
|
||||
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
|
||||
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
|
||||
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/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.80 h1:2mdUHXEykRdY/BigLt3Iuu1otL0JTogT0Nmltg0wujk=
|
||||
github.com/minio/minio-go/v7 v7.0.80/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0=
|
||||
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/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
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.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
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/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.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
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.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
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.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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/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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
152
internal/api/admin/handlers.go
Normal file
152
internal/api/admin/handlers.go
Normal file
@ -0,0 +1,152 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||
"github.com/ultisuite/ulti-backend/internal/securityaudit"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandler(db *pgxpool.Pool, audit *securityaudit.Logger) *Handler {
|
||||
return &Handler{
|
||||
svc: NewService(db, audit),
|
||||
logger: slog.Default().With("component", "admin-api"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequireRole(permission.RoleAdmin))
|
||||
|
||||
r.Get("/users", h.ListUsers)
|
||||
r.Get("/users/{userID}", h.GetUser)
|
||||
r.Put("/users/{userID}/quota", h.SetQuota)
|
||||
r.Delete("/users/{userID}", h.DeleteUser)
|
||||
|
||||
r.Get("/audit", h.ListAuditLogs)
|
||||
r.Get("/stats", h.GetStats)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ListUsers(r.Context(), params)
|
||||
if err != nil {
|
||||
h.logger.Error("list users", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userID")
|
||||
if verr := validateUserID(userID); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.svc.GetUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("get user", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (h *Handler) SetQuota(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userID")
|
||||
if verr := validateUserID(userID); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
var req setQuotaRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxQuotaRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateSetQuota(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.SetQuota(r.Context(), claims.Sub, userID, req.MaxStorageBytes); err != nil {
|
||||
h.logger.Error("set quota", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userID")
|
||||
if verr := validateUserID(userID); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
if err := h.svc.DeleteUser(r.Context(), claims.Sub, userID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("delete user", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ListAuditLogs(w http.ResponseWriter, r *http.Request) {
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ListAuditLogs(r.Context(), params)
|
||||
if err != nil {
|
||||
h.logger.Error("list audit logs", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.svc.GetStats(r.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("get stats", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
224
internal/api/admin/service.go
Normal file
224
internal/api/admin/service.go
Normal file
@ -0,0 +1,224 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/securityaudit"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Service struct {
|
||||
db *pgxpool.Pool
|
||||
audit *securityaudit.Logger
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewService(db *pgxpool.Pool, audit *securityaudit.Logger) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
audit: audit,
|
||||
logger: slog.Default().With("component", "admin-service"),
|
||||
}
|
||||
}
|
||||
|
||||
type UsersList struct {
|
||||
Users []map[string]any `json:"users"`
|
||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) ListUsers(ctx context.Context, params query.ListParams) (UsersList, error) {
|
||||
var total int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&total); err != nil {
|
||||
return UsersList{}, err
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, external_id, email, name, created_at FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, params.Limit(), params.Offset())
|
||||
if err != nil {
|
||||
return UsersList{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
var id, extID, email, name string
|
||||
var createdAt time.Time
|
||||
if err := rows.Scan(&id, &extID, &email, &name, &createdAt); err != nil {
|
||||
return UsersList{}, err
|
||||
}
|
||||
users = append(users, map[string]any{
|
||||
"id": id, "external_id": extID, "email": email, "name": name, "created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return UsersList{}, err
|
||||
}
|
||||
|
||||
return UsersList{
|
||||
Users: users,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetUser(ctx context.Context, userID string) (map[string]any, error) {
|
||||
var id, extID, email, name string
|
||||
var createdAt time.Time
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, external_id, email, name, created_at FROM users WHERE id = $1
|
||||
`, userID).Scan(&id, &extID, &email, &name, &createdAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var mailCount int64
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
SELECT COALESCE(COUNT(*), 0) FROM messages m
|
||||
JOIN mail_accounts ma ON m.account_id = ma.id WHERE ma.user_id = $1
|
||||
`, userID).Scan(&mailCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var maxStorage int64
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
SELECT COALESCE((preferences->>'max_storage')::bigint, 5368709120)
|
||||
FROM settings WHERE user_id = $1
|
||||
`, userID).Scan(&maxStorage); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"id": id, "external_id": extID, "email": email, "name": name,
|
||||
"created_at": createdAt,
|
||||
"quota": map[string]any{
|
||||
"mail_count": mailCount,
|
||||
"storage_used_bytes": int64(0),
|
||||
"max_storage_bytes": maxStorage,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetQuota(ctx context.Context, actorSub, userID string, maxStorageBytes int64) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO settings (user_id, preferences)
|
||||
VALUES ($1, jsonb_build_object('max_storage', $2::text))
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET preferences = settings.preferences || jsonb_build_object('max_storage', $2::text),
|
||||
updated_at = NOW()
|
||||
`, userID, maxStorageBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logAudit(ctx, actorSub, "set_quota", map[string]any{
|
||||
"target_user": userID, "max_storage_bytes": maxStorageBytes,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteUser(ctx context.Context, actorSub, userID string) error {
|
||||
result, err := s.db.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
s.logAudit(ctx, actorSub, "delete_user", map[string]any{"target_user": userID})
|
||||
return nil
|
||||
}
|
||||
|
||||
type AuditLogsList struct {
|
||||
Logs []map[string]any `json:"logs"`
|
||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) ListAuditLogs(ctx context.Context, params query.ListParams) (AuditLogsList, error) {
|
||||
var total int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM audit_logs`).Scan(&total); err != nil {
|
||||
return AuditLogsList{}, err
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, actor, action, details, created_at
|
||||
FROM audit_logs ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, params.Limit(), params.Offset())
|
||||
if err != nil {
|
||||
return AuditLogsList{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
logs := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
var id, actor, action string
|
||||
var details []byte
|
||||
var createdAt time.Time
|
||||
if err := rows.Scan(&id, &actor, &action, &details, &createdAt); err != nil {
|
||||
return AuditLogsList{}, err
|
||||
}
|
||||
logs = append(logs, map[string]any{
|
||||
"id": id, "actor": actor, "action": action,
|
||||
"details": json.RawMessage(details), "created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return AuditLogsList{}, err
|
||||
}
|
||||
|
||||
return AuditLogsList{
|
||||
Logs: logs,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetStats(ctx context.Context) (map[string]any, error) {
|
||||
stats := map[string]any{}
|
||||
|
||||
var userCount, mailCount, accountCount int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM messages`).Scan(&mailCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM mail_accounts`).Scan(&accountCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats["total_users"] = userCount
|
||||
stats["total_messages"] = mailCount
|
||||
stats["total_accounts"] = accountCount
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Service) logAudit(ctx context.Context, actor, action string, details map[string]any) {
|
||||
if s.audit == nil {
|
||||
return
|
||||
}
|
||||
if action == "delete_user" {
|
||||
s.audit.Log(ctx, actor, securityaudit.ActionCriticalDeletion, map[string]any{
|
||||
"target": "user",
|
||||
"target_user": details["target_user"],
|
||||
"admin_action": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
s.audit.Log(ctx, actor, securityaudit.ActionAdminAction, map[string]any{
|
||||
"action": action,
|
||||
"details": details,
|
||||
})
|
||||
}
|
||||
31
internal/api/admin/validate.go
Normal file
31
internal/api/admin/validate.go
Normal file
@ -0,0 +1,31 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
)
|
||||
|
||||
const maxQuotaRequestBody = 4 << 10
|
||||
|
||||
type setQuotaRequest struct {
|
||||
MaxStorageBytes int64 `json:"max_storage_bytes"`
|
||||
}
|
||||
|
||||
func validateSetQuota(req *setQuotaRequest) *apivalidate.ValidationError {
|
||||
if req.MaxStorageBytes < 0 {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "max_storage_bytes", Message: "must be non-negative",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUserID(userID string) *apivalidate.ValidationError {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "userID", Message: "required",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
17
internal/api/apiresponse/codes.go
Normal file
17
internal/api/apiresponse/codes.go
Normal file
@ -0,0 +1,17 @@
|
||||
package apiresponse
|
||||
|
||||
// Standard API error codes.
|
||||
const (
|
||||
CodeAuthUnavailable = "auth.unavailable"
|
||||
CodeAuthMissingAuthorization = "auth.missing_authorization"
|
||||
CodeAuthInvalidAuthorization = "auth.invalid_authorization"
|
||||
CodeAuthInvalidToken = "auth.invalid_token"
|
||||
CodeAuthUnauthorized = "auth.unauthorized"
|
||||
CodeAuthForbidden = "auth.forbidden"
|
||||
|
||||
CodeInvalidQueryParam = "invalid_query_param"
|
||||
CodeInvalidRequest = "invalid_request_body"
|
||||
CodeNotFound = "not_found"
|
||||
CodeInternal = "internal_error"
|
||||
CodePayloadTooLarge = "request_body_too_large"
|
||||
)
|
||||
27
internal/api/apiresponse/errors.go
Normal file
27
internal/api/apiresponse/errors.go
Normal file
@ -0,0 +1,27 @@
|
||||
package apiresponse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ErrorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details any `json:"details,omitempty"`
|
||||
TraceID string `json:"trace_id"`
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, r *http.Request, status int, code, message string, details any) {
|
||||
traceID := TraceIDFromContext(r.Context())
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
_ = json.NewEncoder(w).Encode(ErrorBody{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
TraceID: traceID,
|
||||
})
|
||||
}
|
||||
43
internal/api/apiresponse/errors_test.go
Normal file
43
internal/api/apiresponse/errors_test.go
Normal file
@ -0,0 +1,43 @@
|
||||
package apiresponse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteError(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req = req.WithContext(WithTraceID(req.Context(), "trace-123"))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
WriteError(rec, req, http.StatusUnauthorized, CodeAuthInvalidToken, "invalid token", map[string]string{
|
||||
"reason": "expired",
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
|
||||
t.Fatalf("content-type = %q", ct)
|
||||
}
|
||||
|
||||
var body ErrorBody
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body.Code != CodeAuthInvalidToken {
|
||||
t.Fatalf("code = %q", body.Code)
|
||||
}
|
||||
if body.Message != "invalid token" {
|
||||
t.Fatalf("message = %q", body.Message)
|
||||
}
|
||||
if body.TraceID != "trace-123" {
|
||||
t.Fatalf("trace_id = %q", body.TraceID)
|
||||
}
|
||||
details, ok := body.Details.(map[string]any)
|
||||
if !ok || details["reason"] != "expired" {
|
||||
t.Fatalf("details = %#v", body.Details)
|
||||
}
|
||||
}
|
||||
12
internal/api/apiresponse/response.go
Normal file
12
internal/api/apiresponse/response.go
Normal file
@ -0,0 +1,12 @@
|
||||
package apiresponse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
26
internal/api/apiresponse/trace.go
Normal file
26
internal/api/apiresponse/trace.go
Normal file
@ -0,0 +1,26 @@
|
||||
package apiresponse
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const TraceIDHeader = "X-Trace-Id"
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const traceIDKey ctxKey = iota
|
||||
|
||||
func WithTraceID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, traceIDKey, id)
|
||||
}
|
||||
|
||||
func TraceIDFromContext(ctx context.Context) string {
|
||||
id, _ := ctx.Value(traceIDKey).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
func GenerateTraceID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
26
internal/api/apiresponse/trace_test.go
Normal file
26
internal/api/apiresponse/trace_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
package apiresponse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTraceIDContext(t *testing.T) {
|
||||
ctx := WithTraceID(context.Background(), "abc")
|
||||
if got := TraceIDFromContext(ctx); got != "abc" {
|
||||
t.Fatalf("TraceIDFromContext() = %q, want abc", got)
|
||||
}
|
||||
if got := TraceIDFromContext(context.Background()); got != "" {
|
||||
t.Fatalf("TraceIDFromContext(empty) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTraceID(t *testing.T) {
|
||||
id := GenerateTraceID()
|
||||
if id == "" {
|
||||
t.Fatal("GenerateTraceID returned empty string")
|
||||
}
|
||||
if id == GenerateTraceID() {
|
||||
t.Fatal("GenerateTraceID returned duplicate values")
|
||||
}
|
||||
}
|
||||
74
internal/api/apivalidate/apivalidate.go
Normal file
74
internal/api/apivalidate/apivalidate.go
Normal file
@ -0,0 +1,74 @@
|
||||
package apivalidate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
)
|
||||
|
||||
// FieldDetail identifies a single invalid request field.
|
||||
type FieldDetail struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ValidationError reports invalid request bodies using field-level details.
|
||||
type ValidationError struct {
|
||||
Details []FieldDetail
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return "invalid request body"
|
||||
}
|
||||
|
||||
// NewValidationError builds a validation error from field details.
|
||||
func NewValidationError(details ...FieldDetail) *ValidationError {
|
||||
return &ValidationError{Details: details}
|
||||
}
|
||||
|
||||
// DecodeJSON reads and decodes a JSON body with a size cap.
|
||||
func DecodeJSON(w http.ResponseWriter, r *http.Request, limit int64, dest any) error {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
if err := json.NewDecoder(r.Body).Decode(dest); err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
apiresponse.WriteError(w, r, http.StatusRequestEntityTooLarge, apiresponse.CodePayloadTooLarge, "request body too large", nil)
|
||||
return err
|
||||
}
|
||||
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", nil)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteValidationError writes a standardized validation error response.
|
||||
func WriteValidationError(w http.ResponseWriter, r *http.Request, err *ValidationError) {
|
||||
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", err.Details)
|
||||
}
|
||||
|
||||
// WriteQueryError maps query validation errors to API responses.
|
||||
func WriteQueryError(w http.ResponseWriter, r *http.Request, err error) bool {
|
||||
var qerr *query.ValidationError
|
||||
if errors.As(err, &qerr) {
|
||||
apiresponse.WriteError(w, r, http.StatusBadRequest, qerr.Code, qerr.Message, qerr.Details)
|
||||
return true
|
||||
}
|
||||
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidQueryParam, "invalid query parameters", nil)
|
||||
return true
|
||||
}
|
||||
|
||||
// WriteNotFound writes a standardized not-found response.
|
||||
func WriteNotFound(w http.ResponseWriter, r *http.Request, message string) {
|
||||
if message == "" {
|
||||
message = "not found"
|
||||
}
|
||||
apiresponse.WriteError(w, r, http.StatusNotFound, apiresponse.CodeNotFound, message, nil)
|
||||
}
|
||||
|
||||
// WriteInternal writes a standardized internal error response.
|
||||
func WriteInternal(w http.ResponseWriter, r *http.Request) {
|
||||
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "internal error", nil)
|
||||
}
|
||||
46
internal/api/apivalidate/apivalidate_test.go
Normal file
46
internal/api/apivalidate/apivalidate_test.go
Normal file
@ -0,0 +1,46 @@
|
||||
package apivalidate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
)
|
||||
|
||||
func TestDecodeJSONInvalidBody(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString("not-json"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
err := DecodeJSON(rec, req, 1024, &struct{}{})
|
||||
if err == nil {
|
||||
t.Fatal("expected decode error")
|
||||
}
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
|
||||
var body apiresponse.ErrorBody
|
||||
_ = json.NewDecoder(rec.Body).Decode(&body)
|
||||
if body.Code != apiresponse.CodeInvalidRequest {
|
||||
t.Fatalf("code = %q", body.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteQueryError(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/?page=0", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
_, err := query.ParseListRequest(req)
|
||||
if err == nil {
|
||||
t.Fatal("expected query error")
|
||||
}
|
||||
WriteQueryError(rec, req, err)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
102
internal/api/calendar/handlers.go
Normal file
102
internal/api/calendar/handlers.go
Normal file
@ -0,0 +1,102 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandler(nc *nextcloud.Client) *Handler {
|
||||
return &Handler{
|
||||
svc: NewService(nc),
|
||||
logger: slog.Default().With("component", "calendar-api"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
read := middleware.RequirePermission(permission.ResourceCalendar, permission.LevelRead)
|
||||
write := middleware.RequirePermission(permission.ResourceCalendar, permission.LevelWrite)
|
||||
|
||||
r.With(read).Get("/", h.ListCalendars)
|
||||
r.With(read).Get("/{calID}/events", h.ListEvents)
|
||||
r.With(write).Post("/{calID}/events", h.CreateEvent)
|
||||
r.With(write).Delete("/events/*", h.DeleteEvent)
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *Handler) ListCalendars(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
cals, err := h.svc.ListCalendars(r.Context(), claims.Sub)
|
||||
if err != nil {
|
||||
h.logger.Error("list calendars", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"calendars": cals})
|
||||
}
|
||||
|
||||
func (h *Handler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ListEvents(r.Context(), claims.Sub, chi.URLParam(r, "calID"), params)
|
||||
if err != nil {
|
||||
h.logger.Error("list events", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
var event nextcloud.Event
|
||||
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &event); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateCreateEvent(&event); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.CreateEvent(r.Context(), claims.Sub, chi.URLParam(r, "calID"), &event); err != nil {
|
||||
h.logger.Error("create event", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
eventPath := chi.URLParam(r, "*")
|
||||
if verr := validateDeletePath(eventPath); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteEvent(r.Context(), claims.Sub, eventPath); err != nil {
|
||||
h.logger.Error("delete event", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
78
internal/api/calendar/service.go
Normal file
78
internal/api/calendar/service.go
Normal file
@ -0,0 +1,78 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
nc *nextcloud.Client
|
||||
}
|
||||
|
||||
func NewService(nc *nextcloud.Client) *Service {
|
||||
return &Service{nc: nc}
|
||||
}
|
||||
|
||||
func calendarPath(userID, calID string) string {
|
||||
return "/remote.php/dav/calendars/" + userID + "/" + calID + "/"
|
||||
}
|
||||
|
||||
func (s *Service) ListCalendars(ctx context.Context, userID string) ([]nextcloud.Calendar, error) {
|
||||
return s.nc.ListCalendars(ctx, userID)
|
||||
}
|
||||
|
||||
type EventsList struct {
|
||||
Events []nextcloud.Event `json:"events"`
|
||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) ListEvents(ctx context.Context, userID, calID string, params query.ListParams) (EventsList, error) {
|
||||
from := time.Now().AddDate(0, -1, 0)
|
||||
to := time.Now().AddDate(0, 1, 0)
|
||||
if params.From != nil {
|
||||
from = *params.From
|
||||
}
|
||||
if params.To != nil {
|
||||
to = *params.To
|
||||
}
|
||||
|
||||
events, err := s.nc.ListEvents(ctx, userID, calendarPath(userID, calID), from, to)
|
||||
if err != nil {
|
||||
return EventsList{}, err
|
||||
}
|
||||
filtered := filterEvents(events, params.Q)
|
||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||
return EventsList{
|
||||
Events: page,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateEvent(ctx context.Context, userID, calID string, event *nextcloud.Event) error {
|
||||
return s.nc.CreateEvent(ctx, userID, calendarPath(userID, calID), event)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteEvent(ctx context.Context, userID, eventPath string) error {
|
||||
return s.nc.DeleteEvent(ctx, userID, eventPath)
|
||||
}
|
||||
|
||||
func filterEvents(events []nextcloud.Event, q string) []nextcloud.Event {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
if q == "" {
|
||||
return events
|
||||
}
|
||||
out := make([]nextcloud.Event, 0, len(events))
|
||||
for _, e := range events {
|
||||
if strings.Contains(strings.ToLower(e.Summary), q) ||
|
||||
strings.Contains(strings.ToLower(e.Description), q) ||
|
||||
strings.Contains(strings.ToLower(e.Location), q) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
36
internal/api/calendar/validate.go
Normal file
36
internal/api/calendar/validate.go
Normal file
@ -0,0 +1,36 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
)
|
||||
|
||||
const maxRequestBody = 256 << 10
|
||||
|
||||
func validateCreateEvent(event *nextcloud.Event) *apivalidate.ValidationError {
|
||||
var details []apivalidate.FieldDetail
|
||||
if strings.TrimSpace(event.Summary) == "" {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "summary", Message: "required"})
|
||||
}
|
||||
if strings.TrimSpace(event.Start) == "" {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "start", Message: "required"})
|
||||
}
|
||||
if strings.TrimSpace(event.End) == "" {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "end", Message: "required"})
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
func validateDeletePath(path string) *apivalidate.ValidationError {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "path", Message: "required",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
126
internal/api/contacts/handlers.go
Normal file
126
internal/api/contacts/handlers.go
Normal file
@ -0,0 +1,126 @@
|
||||
package contacts
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandler(nc *nextcloud.Client) *Handler {
|
||||
return &Handler{
|
||||
svc: NewService(nc),
|
||||
logger: slog.Default().With("component", "contacts-api"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
read := middleware.RequirePermission(permission.ResourceContacts, permission.LevelRead)
|
||||
write := middleware.RequirePermission(permission.ResourceContacts, permission.LevelWrite)
|
||||
|
||||
r.With(read).Get("/books", h.ListAddressBooks)
|
||||
r.With(read).Get("/books/{bookID}", h.ListContacts)
|
||||
r.With(read).Get("/search", h.SearchContacts)
|
||||
r.With(write).Post("/books/{bookID}", h.CreateContact)
|
||||
r.With(write).Delete("/*", h.DeleteContact)
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *Handler) ListAddressBooks(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
books, err := h.svc.ListAddressBooks(r.Context(), claims.Sub)
|
||||
if err != nil {
|
||||
h.logger.Error("list address books", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"address_books": books})
|
||||
}
|
||||
|
||||
func (h *Handler) ListContacts(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ListContacts(r.Context(), claims.Sub, chi.URLParam(r, "bookID"), params)
|
||||
if err != nil {
|
||||
h.logger.Error("list contacts", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) SearchContacts(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
bookID := r.URL.Query().Get("book_id")
|
||||
if bookID == "" {
|
||||
bookID = "contacts"
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
|
||||
result, err := h.svc.SearchContacts(r.Context(), claims.Sub, bookID, q, params)
|
||||
if err != nil {
|
||||
h.logger.Error("search contacts", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateContact(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
var contact nextcloud.Contact
|
||||
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &contact); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateCreateContact(&contact); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.CreateContact(r.Context(), claims.Sub, chi.URLParam(r, "bookID"), &contact); err != nil {
|
||||
h.logger.Error("create contact", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteContact(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
contactPath := chi.URLParam(r, "*")
|
||||
if verr := validateDeletePath(contactPath); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteContact(r.Context(), claims.Sub, contactPath); err != nil {
|
||||
h.logger.Error("delete contact", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
85
internal/api/contacts/service.go
Normal file
85
internal/api/contacts/service.go
Normal file
@ -0,0 +1,85 @@
|
||||
package contacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
nc *nextcloud.Client
|
||||
}
|
||||
|
||||
func NewService(nc *nextcloud.Client) *Service {
|
||||
return &Service{nc: nc}
|
||||
}
|
||||
|
||||
func bookPath(userID, bookID string) string {
|
||||
return "/remote.php/dav/addressbooks/users/" + userID + "/" + bookID + "/"
|
||||
}
|
||||
|
||||
func (s *Service) ListAddressBooks(ctx context.Context, userID string) ([]nextcloud.AddressBook, error) {
|
||||
return s.nc.ListAddressBooks(ctx, userID)
|
||||
}
|
||||
|
||||
type ContactsList struct {
|
||||
Contacts []nextcloud.Contact `json:"contacts"`
|
||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) ListContacts(ctx context.Context, userID, bookID string, params query.ListParams) (ContactsList, error) {
|
||||
contacts, err := s.nc.ListContacts(ctx, userID, bookPath(userID, bookID))
|
||||
if err != nil {
|
||||
return ContactsList{}, err
|
||||
}
|
||||
filtered := filterContacts(contacts, params.Q)
|
||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||
return ContactsList{
|
||||
Contacts: page,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SearchContacts(ctx context.Context, userID, bookID, q string, params query.ListParams) (ContactsList, error) {
|
||||
searchQ := strings.TrimSpace(q)
|
||||
if searchQ == "" {
|
||||
searchQ = strings.TrimSpace(params.Q)
|
||||
}
|
||||
contacts, err := s.nc.SearchContacts(ctx, userID, bookPath(userID, bookID), searchQ)
|
||||
if err != nil {
|
||||
return ContactsList{}, err
|
||||
}
|
||||
page, total := paginate.Slice(contacts, params.Offset(), params.Limit())
|
||||
return ContactsList{
|
||||
Contacts: page,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateContact(ctx context.Context, userID, bookID string, contact *nextcloud.Contact) error {
|
||||
return s.nc.CreateContact(ctx, userID, bookPath(userID, bookID), contact)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteContact(ctx context.Context, userID, contactPath string) error {
|
||||
return s.nc.DeleteContact(ctx, userID, contactPath)
|
||||
}
|
||||
|
||||
func filterContacts(contacts []nextcloud.Contact, q string) []nextcloud.Contact {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
if q == "" {
|
||||
return contacts
|
||||
}
|
||||
out := make([]nextcloud.Contact, 0, len(contacts))
|
||||
for _, c := range contacts {
|
||||
if strings.Contains(strings.ToLower(c.FullName), q) ||
|
||||
strings.Contains(strings.ToLower(c.Email), q) ||
|
||||
strings.Contains(strings.ToLower(c.Phone), q) ||
|
||||
strings.Contains(strings.ToLower(c.Org), q) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
28
internal/api/contacts/validate.go
Normal file
28
internal/api/contacts/validate.go
Normal file
@ -0,0 +1,28 @@
|
||||
package contacts
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
)
|
||||
|
||||
const maxRequestBody = 64 << 10
|
||||
|
||||
func validateCreateContact(contact *nextcloud.Contact) *apivalidate.ValidationError {
|
||||
if strings.TrimSpace(contact.FullName) == "" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "full_name", Message: "required",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeletePath(path string) *apivalidate.ValidationError {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "path", Message: "required",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
16
internal/api/contacts/validate_test.go
Normal file
16
internal/api/contacts/validate_test.go
Normal file
@ -0,0 +1,16 @@
|
||||
package contacts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
)
|
||||
|
||||
func TestValidateCreateContact(t *testing.T) {
|
||||
if validateCreateContact(&nextcloud.Contact{FullName: "Ada"}) != nil {
|
||||
t.Fatal("expected valid contact")
|
||||
}
|
||||
if validateCreateContact(&nextcloud.Contact{}) == nil {
|
||||
t.Fatal("expected missing full_name error")
|
||||
}
|
||||
}
|
||||
171
internal/api/drive/handlers.go
Normal file
171
internal/api/drive/handlers.go
Normal file
@ -0,0 +1,171 @@
|
||||
package drive
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandler(nc *nextcloud.Client) *Handler {
|
||||
return &Handler{
|
||||
svc: NewService(nc),
|
||||
logger: slog.Default().With("component", "drive-api"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
read := middleware.RequirePermission(permission.ResourceDrive, permission.LevelRead)
|
||||
write := middleware.RequirePermission(permission.ResourceDrive, permission.LevelWrite)
|
||||
admin := middleware.RequirePermission(permission.ResourceDrive, permission.LevelAdmin)
|
||||
|
||||
r.With(read).Get("/files/*", h.ListFiles)
|
||||
r.With(read).Get("/download/*", h.Download)
|
||||
r.With(write).Post("/files/*", h.Upload)
|
||||
r.With(write).Delete("/files/*", h.DeleteFile)
|
||||
r.With(write).Post("/folders/*", h.CreateFolder)
|
||||
r.With(write).Post("/move", h.Move)
|
||||
r.With(admin).Post("/shares", h.CreateShare)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *Handler) ListFiles(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
path := chi.URLParam(r, "*")
|
||||
result, err := h.svc.ListFiles(r.Context(), claims.Sub, path, params)
|
||||
if err != nil {
|
||||
h.logger.Error("list files", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
path := chi.URLParam(r, "*")
|
||||
if verr := validatePath(path); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Upload(r.Context(), claims.Sub, path, r.Body, r.Header.Get("Content-Type")); err != nil {
|
||||
h.logger.Error("upload", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"status": "uploaded", "path": path})
|
||||
}
|
||||
|
||||
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
path := chi.URLParam(r, "*")
|
||||
if verr := validatePath(path); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
body, contentType, err := h.svc.Download(r.Context(), claims.Sub, path)
|
||||
if err != nil {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
io.Copy(w, body)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteFile(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
path := chi.URLParam(r, "*")
|
||||
if verr := validatePath(path); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Delete(r.Context(), claims.Sub, path); err != nil {
|
||||
h.logger.Error("delete file", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateFolder(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
path := chi.URLParam(r, "*")
|
||||
if verr := validatePath(path); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.CreateFolder(r.Context(), claims.Sub, path); err != nil {
|
||||
h.logger.Error("create folder", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
var req moveRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateMoveRequest(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Move(r.Context(), claims.Sub, req.Source, req.Destination); err != nil {
|
||||
h.logger.Error("move", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
var req createShareRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateCreateShareRequest(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
share, err := h.svc.CreateShare(r.Context(), claims.Sub, req.Path, req.ShareType, req.Permissions)
|
||||
if err != nil {
|
||||
h.logger.Error("create share", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusCreated, share)
|
||||
}
|
||||
79
internal/api/drive/service.go
Normal file
79
internal/api/drive/service.go
Normal file
@ -0,0 +1,79 @@
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
nc *nextcloud.Client
|
||||
}
|
||||
|
||||
func NewService(nc *nextcloud.Client) *Service {
|
||||
return &Service{nc: nc}
|
||||
}
|
||||
|
||||
type FilesList struct {
|
||||
Files []nextcloud.FileInfo `json:"files"`
|
||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) ListFiles(ctx context.Context, userID, path string, params query.ListParams) (FilesList, error) {
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
files, err := s.nc.ListFiles(ctx, userID, path)
|
||||
if err != nil {
|
||||
return FilesList{}, err
|
||||
}
|
||||
filtered := filterFiles(files, params.Q)
|
||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||
return FilesList{
|
||||
Files: page,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Upload(ctx context.Context, userID, path string, body io.Reader, contentType string) error {
|
||||
return s.nc.Upload(ctx, userID, path, body, contentType)
|
||||
}
|
||||
|
||||
func (s *Service) Download(ctx context.Context, userID, path string) (io.ReadCloser, string, error) {
|
||||
return s.nc.Download(ctx, userID, path)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, userID, path string) error {
|
||||
return s.nc.Delete(ctx, userID, path)
|
||||
}
|
||||
|
||||
func (s *Service) CreateFolder(ctx context.Context, userID, path string) error {
|
||||
return s.nc.CreateFolder(ctx, userID, path)
|
||||
}
|
||||
|
||||
func (s *Service) Move(ctx context.Context, userID, source, destination string) error {
|
||||
return s.nc.Move(ctx, userID, source, destination)
|
||||
}
|
||||
|
||||
func (s *Service) CreateShare(ctx context.Context, userID, path string, shareType, permissions int) (*nextcloud.ShareInfo, error) {
|
||||
return s.nc.CreateShare(ctx, userID, path, shareType, permissions)
|
||||
}
|
||||
|
||||
func filterFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
if q == "" {
|
||||
return files
|
||||
}
|
||||
out := make([]nextcloud.FileInfo, 0, len(files))
|
||||
for _, f := range files {
|
||||
if strings.Contains(strings.ToLower(f.Name), q) ||
|
||||
strings.Contains(strings.ToLower(f.Path), q) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
52
internal/api/drive/validate.go
Normal file
52
internal/api/drive/validate.go
Normal file
@ -0,0 +1,52 @@
|
||||
package drive
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
)
|
||||
|
||||
const maxJSONRequestBody = 32 << 10
|
||||
|
||||
type moveRequest struct {
|
||||
Source string `json:"source"`
|
||||
Destination string `json:"destination"`
|
||||
}
|
||||
|
||||
func validateMoveRequest(req *moveRequest) *apivalidate.ValidationError {
|
||||
var details []apivalidate.FieldDetail
|
||||
if strings.TrimSpace(req.Source) == "" {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "source", Message: "required"})
|
||||
}
|
||||
if strings.TrimSpace(req.Destination) == "" {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "destination", Message: "required"})
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
type createShareRequest struct {
|
||||
Path string `json:"path"`
|
||||
ShareType int `json:"share_type"`
|
||||
Permissions int `json:"permissions"`
|
||||
}
|
||||
|
||||
func validateCreateShareRequest(req *createShareRequest) *apivalidate.ValidationError {
|
||||
if strings.TrimSpace(req.Path) == "" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "path", Message: "required",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePath(path string) *apivalidate.ValidationError {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "path", Message: "required",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
454
internal/api/mail/handlers.go
Normal file
454
internal/api/mail/handlers.go
Normal file
@ -0,0 +1,454 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
||||
"github.com/ultisuite/ulti-backend/internal/securityaudit"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandler(db *pgxpool.Pool, audit *securityaudit.Logger, credentialManager *credentials.Manager) *Handler {
|
||||
return &Handler{
|
||||
svc: NewService(db, audit, credentialManager),
|
||||
logger: slog.Default().With("component", "mail-api"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Get("/accounts", h.ListAccounts)
|
||||
r.Post("/accounts", h.CreateAccount)
|
||||
r.Get("/accounts/{accountID}", h.GetAccount)
|
||||
r.Delete("/accounts/{accountID}", h.DeleteAccount)
|
||||
|
||||
r.Get("/messages", h.ListMessages)
|
||||
r.Get("/messages/{messageID}", h.GetMessage)
|
||||
r.Put("/messages/{messageID}/labels", h.UpdateLabels)
|
||||
r.Put("/messages/{messageID}/flags", h.UpdateFlags)
|
||||
r.Delete("/messages/{messageID}", h.DeleteMessage)
|
||||
|
||||
r.Get("/threads/{threadID}", h.GetThread)
|
||||
|
||||
r.Post("/send", h.SendMessage)
|
||||
|
||||
r.Get("/rules", h.ListRules)
|
||||
r.Post("/rules", h.CreateRule)
|
||||
r.Put("/rules/{ruleID}", h.UpdateRule)
|
||||
r.Delete("/rules/{ruleID}", h.DeleteRule)
|
||||
|
||||
r.Get("/webhooks", h.ListWebhooks)
|
||||
r.Post("/webhooks", h.CreateWebhook)
|
||||
r.Delete("/webhooks/{webhookID}", h.DeleteWebhook)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *Handler) ListAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ListAccounts(r.Context(), claims.Sub, params)
|
||||
if err != nil {
|
||||
h.logger.Error("list accounts", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
var req createAccountRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxAccountRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateCreateAccount(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := h.svc.CreateAccount(r.Context(), claims.Sub, &req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrCredentialsUnavailable) {
|
||||
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "credentials encryption unavailable", nil)
|
||||
return
|
||||
}
|
||||
h.logger.Error("create account", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id})
|
||||
}
|
||||
|
||||
func (h *Handler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
account, err := h.svc.GetAccount(r.Context(), claims.Sub, chi.URLParam(r, "accountID"))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("get account", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, account)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
if err := h.svc.DeleteAccount(r.Context(), claims.Sub, chi.URLParam(r, "accountID")); err != nil {
|
||||
if errors.Is(err, ErrUserNotProvisioned) {
|
||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "user not provisioned", nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("delete account", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
filter := MessageListFilter{
|
||||
Folder: r.URL.Query().Get("folder"),
|
||||
AccountID: r.URL.Query().Get("account_id"),
|
||||
}
|
||||
|
||||
result, err := h.svc.ListMessages(r.Context(), claims.Sub, filter, params)
|
||||
if err != nil {
|
||||
h.logger.Error("list messages", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
msg, err := h.svc.GetMessage(r.Context(), claims.Sub, chi.URLParam(r, "messageID"))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("get message", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, msg)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateLabels(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub)
|
||||
if err != nil {
|
||||
h.writeUserResolveError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req updateLabelsRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxFlagsLabelsBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateUpdateLabels(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.UpdateLabels(r.Context(), userID, chi.URLParam(r, "messageID"), req.Labels); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("update labels", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateFlags(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub)
|
||||
if err != nil {
|
||||
h.writeUserResolveError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req updateFlagsRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxFlagsLabelsBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateUpdateFlags(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.UpdateFlags(r.Context(), userID, chi.URLParam(r, "messageID"), req.Flags); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("update flags", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub)
|
||||
if err != nil {
|
||||
h.writeUserResolveError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteMessage(r.Context(), claims.Sub, userID, chi.URLParam(r, "messageID")); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("delete message", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) GetThread(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
result, err := h.svc.GetThread(r.Context(), claims.Sub, chi.URLParam(r, "threadID"))
|
||||
if err != nil {
|
||||
h.logger.Error("get thread", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub)
|
||||
if err != nil {
|
||||
h.writeUserResolveError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req sendMessageRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxSendRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateSendMessage(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
id, status, err := h.svc.SendMessage(r.Context(), userID, &req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAccountNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "account not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("send message", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusAccepted, map[string]string{"id": id, "status": status})
|
||||
}
|
||||
|
||||
func (h *Handler) ListRules(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ListRules(r.Context(), claims.Sub, params)
|
||||
if err != nil {
|
||||
h.logger.Error("list rules", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub)
|
||||
if err != nil {
|
||||
h.writeUserResolveError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req createRuleRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxRulesRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateCreateRule(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := h.svc.CreateRule(r.Context(), userID, &req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAccountNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "account not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("create rule", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateRule(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub)
|
||||
if err != nil {
|
||||
h.writeUserResolveError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req updateRuleRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxRulesRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if verr := validateUpdateRule(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.UpdateRule(r.Context(), userID, chi.URLParam(r, "ruleID"), &req); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("update rule", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub)
|
||||
if err != nil {
|
||||
h.writeUserResolveError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteRule(r.Context(), claims.Sub, userID, chi.URLParam(r, "ruleID")); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("delete rule", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ListWebhooks(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ListWebhooks(r.Context(), claims.Sub, params)
|
||||
if err != nil {
|
||||
h.logger.Error("list webhooks", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
var req createWebhookRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxWebhookRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
method, verr := validateCreateWebhook(&req)
|
||||
if verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := h.svc.CreateWebhook(r.Context(), claims.Sub, &req, method)
|
||||
if err != nil {
|
||||
h.logger.Error("create webhook", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
userID, err := h.svc.ResolveUserID(r.Context(), claims.Sub)
|
||||
if err != nil {
|
||||
h.writeUserResolveError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteWebhook(r.Context(), claims.Sub, userID, chi.URLParam(r, "webhookID")); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("delete webhook", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) writeUserResolveError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if errors.Is(err, ErrUserNotProvisioned) {
|
||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "user not provisioned", nil)
|
||||
return
|
||||
}
|
||||
h.logger.Error("resolve user id", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
}
|
||||
572
internal/api/mail/service.go
Normal file
572
internal/api/mail/service.go
Normal file
@ -0,0 +1,572 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
||||
"github.com/ultisuite/ulti-backend/internal/securityaudit"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrUserNotProvisioned = errors.New("user not provisioned")
|
||||
ErrAccountNotFound = errors.New("account not found")
|
||||
ErrCredentialsUnavailable = errors.New("credentials encryption unavailable")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *pgxpool.Pool
|
||||
credentials *credentials.Manager
|
||||
audit *securityaudit.Logger
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewService(db *pgxpool.Pool, audit *securityaudit.Logger, credentialManager *credentials.Manager) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
credentials: credentialManager,
|
||||
audit: audit,
|
||||
logger: slog.Default().With("component", "mail-service"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ResolveUserID(ctx context.Context, externalID string) (string, error) {
|
||||
var userID string
|
||||
err := s.db.QueryRow(ctx, `SELECT id FROM users WHERE external_id = $1`, externalID).Scan(&userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrUserNotProvisioned
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
type AccountsList struct {
|
||||
Accounts []map[string]any `json:"accounts"`
|
||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) ListAccounts(ctx context.Context, externalID string, params query.ListParams) (AccountsList, error) {
|
||||
var total int64
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM mail_accounts
|
||||
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
|
||||
`, externalID).Scan(&total)
|
||||
if err != nil {
|
||||
return AccountsList{}, err
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, name, email, provider, imap_host, smtp_host, is_active, last_sync_at, created_at
|
||||
FROM mail_accounts WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, externalID, params.Limit(), params.Offset())
|
||||
if err != nil {
|
||||
return AccountsList{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
accounts := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
var id, name, email, provider, imapHost, smtpHost string
|
||||
var isActive bool
|
||||
var lastSync, createdAt any
|
||||
if err := rows.Scan(&id, &name, &email, &provider, &imapHost, &smtpHost, &isActive, &lastSync, &createdAt); err != nil {
|
||||
return AccountsList{}, err
|
||||
}
|
||||
accounts = append(accounts, map[string]any{
|
||||
"id": id, "name": name, "email": email, "provider": provider,
|
||||
"imap_host": imapHost, "smtp_host": smtpHost, "is_active": isActive,
|
||||
"last_sync_at": lastSync, "created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return AccountsList{}, err
|
||||
}
|
||||
|
||||
return AccountsList{
|
||||
Accounts: accounts,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateAccount(ctx context.Context, externalID string, req *createAccountRequest) (string, error) {
|
||||
if s.credentials == nil {
|
||||
return "", ErrCredentialsUnavailable
|
||||
}
|
||||
creds, err := s.credentials.Encrypt(req.Username, req.Password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var id string
|
||||
err = s.db.QueryRow(ctx, `
|
||||
INSERT INTO mail_accounts (user_id, name, email, provider, imap_host, imap_port, imap_tls, smtp_host, smtp_port, smtp_tls, credentials)
|
||||
VALUES ((SELECT id FROM users WHERE external_id = $1), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id
|
||||
`, externalID, req.Name, req.Email, req.Provider, req.IMAPHost, req.IMAPPort, req.IMAPTLS, req.SMTPHost, req.SMTPPort, req.SMTPTLS, creds).Scan(&id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetAccount(ctx context.Context, externalID, accountID string) (map[string]any, error) {
|
||||
var id, name, email, provider string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, name, email, provider FROM mail_accounts
|
||||
WHERE id = $1 AND user_id = (SELECT id FROM users WHERE external_id = $2)
|
||||
`, accountID, externalID).Scan(&id, &name, &email, &provider)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"id": id, "name": name, "email": email, "provider": provider}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAccount(ctx context.Context, externalID, accountID string) error {
|
||||
userID, err := s.ResolveUserID(ctx, externalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := s.db.Exec(ctx, `DELETE FROM mail_accounts WHERE id = $1 AND user_id = $2`, accountID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if s.audit != nil {
|
||||
s.audit.Log(ctx, externalID, securityaudit.ActionCriticalDeletion, map[string]any{
|
||||
"target": "mail_account", "account_id": accountID,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type MessageListFilter struct {
|
||||
Folder string
|
||||
AccountID string
|
||||
}
|
||||
|
||||
type MessagesList struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
Page int `json:"page"`
|
||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) ListMessages(ctx context.Context, externalID string, filter MessageListFilter, params query.ListParams) (MessagesList, error) {
|
||||
baseQuery := `
|
||||
FROM messages m
|
||||
JOIN mail_accounts ma ON m.account_id = ma.id
|
||||
WHERE ma.user_id = (SELECT id FROM users WHERE external_id = $1)
|
||||
`
|
||||
args := []any{externalID}
|
||||
argIdx := 2
|
||||
|
||||
if filter.AccountID != "" {
|
||||
baseQuery += fmt.Sprintf(" AND m.account_id = $%d", argIdx)
|
||||
args = append(args, filter.AccountID)
|
||||
argIdx++
|
||||
}
|
||||
if filter.Folder != "" {
|
||||
baseQuery += fmt.Sprintf(" AND m.folder_id = (SELECT id FROM mail_folders WHERE name = $%d AND account_id = m.account_id LIMIT 1)", argIdx)
|
||||
args = append(args, filter.Folder)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
var total int64
|
||||
countQuery := "SELECT COUNT(*) " + baseQuery
|
||||
if err := s.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return MessagesList{}, err
|
||||
}
|
||||
|
||||
listQuery := `
|
||||
SELECT m.id, m.subject, m.from_addr, m.to_addrs, m.date, m.snippet, m.flags, m.labels, m.has_attachments
|
||||
` + baseQuery + fmt.Sprintf(" ORDER BY m.date DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
|
||||
args = append(args, params.Limit(), params.Offset())
|
||||
|
||||
rows, err := s.db.Query(ctx, listQuery, args...)
|
||||
if err != nil {
|
||||
return MessagesList{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
messages := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
var id, subject, snippet string
|
||||
var fromAddr, toAddrs []byte
|
||||
var date any
|
||||
var flags, labels []string
|
||||
var hasAttachments bool
|
||||
if err := rows.Scan(&id, &subject, &fromAddr, &toAddrs, &date, &snippet, &flags, &labels, &hasAttachments); err != nil {
|
||||
return MessagesList{}, err
|
||||
}
|
||||
messages = append(messages, map[string]any{
|
||||
"id": id, "subject": subject, "from": json.RawMessage(fromAddr),
|
||||
"to": json.RawMessage(toAddrs), "date": date, "snippet": snippet,
|
||||
"flags": flags, "labels": labels, "has_attachments": hasAttachments,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return MessagesList{}, err
|
||||
}
|
||||
|
||||
return MessagesList{
|
||||
Messages: messages,
|
||||
Page: params.Page,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetMessage(ctx context.Context, externalID, messageID string) (map[string]any, error) {
|
||||
var msg struct {
|
||||
ID string
|
||||
Subject string
|
||||
From []byte
|
||||
To []byte
|
||||
Cc []byte
|
||||
Date any
|
||||
Text string
|
||||
HTML string
|
||||
Flags []string
|
||||
Labels []string
|
||||
}
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT m.id, m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.date, m.body_text, m.body_html, m.flags, m.labels
|
||||
FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id
|
||||
WHERE m.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
|
||||
`, messageID, externalID).Scan(&msg.ID, &msg.Subject, &msg.From, &msg.To, &msg.Cc, &msg.Date, &msg.Text, &msg.HTML, &msg.Flags, &msg.Labels)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"id": msg.ID, "subject": msg.Subject, "from": json.RawMessage(msg.From),
|
||||
"to": json.RawMessage(msg.To), "cc": json.RawMessage(msg.Cc),
|
||||
"date": msg.Date, "body_text": msg.Text, "body_html": msg.HTML,
|
||||
"flags": msg.Flags, "labels": msg.Labels,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateLabels(ctx context.Context, userID, messageID string, labels []string) error {
|
||||
result, err := s.db.Exec(ctx, `
|
||||
UPDATE messages
|
||||
SET labels = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
AND account_id IN (SELECT id FROM mail_accounts WHERE user_id = $3)
|
||||
`, labels, messageID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateFlags(ctx context.Context, userID, messageID string, flags []string) error {
|
||||
result, err := s.db.Exec(ctx, `
|
||||
UPDATE messages
|
||||
SET flags = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
AND account_id IN (SELECT id FROM mail_accounts WHERE user_id = $3)
|
||||
`, flags, messageID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteMessage(ctx context.Context, externalID, userID, messageID string) error {
|
||||
result, err := s.db.Exec(ctx, `
|
||||
DELETE FROM messages
|
||||
WHERE id = $1
|
||||
AND account_id IN (SELECT id FROM mail_accounts WHERE user_id = $2)
|
||||
`, messageID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if s.audit != nil {
|
||||
s.audit.Log(ctx, externalID, securityaudit.ActionCriticalDeletion, map[string]any{
|
||||
"target": "message", "message_id": messageID,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetThread(ctx context.Context, externalID, threadID string) (map[string]any, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT m.id, m.subject, m.from_addr, m.date, m.snippet, m.flags
|
||||
FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id
|
||||
WHERE m.thread_id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
|
||||
ORDER BY m.date ASC
|
||||
`, threadID, externalID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
messages := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
var id, subject, snippet string
|
||||
var from []byte
|
||||
var date any
|
||||
var flags []string
|
||||
if err := rows.Scan(&id, &subject, &from, &date, &snippet, &flags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = append(messages, map[string]any{
|
||||
"id": id, "subject": subject, "from": json.RawMessage(from),
|
||||
"date": date, "snippet": snippet, "flags": flags,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"thread_id": threadID, "messages": messages}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SendMessage(ctx context.Context, userID string, req *sendMessageRequest) (id, status string, err error) {
|
||||
toJSON, _ := json.Marshal(req.To)
|
||||
ccJSON, _ := json.Marshal(req.Cc)
|
||||
bccJSON, _ := json.Marshal(req.Bcc)
|
||||
|
||||
status = "queued"
|
||||
if req.ScheduleAt != nil {
|
||||
status = "scheduled"
|
||||
}
|
||||
|
||||
err = s.db.QueryRow(ctx, `
|
||||
INSERT INTO outbox (user_id, account_id, to_addrs, cc_addrs, bcc_addrs, subject, body_text, body_html, in_reply_to, status, scheduled_at)
|
||||
SELECT $1, ma.id, $3, $4, $5, $6, $7, $8, $9, $10, $11
|
||||
FROM mail_accounts ma
|
||||
WHERE ma.id = $2 AND ma.user_id = $1
|
||||
RETURNING id
|
||||
`, userID, req.AccountID, toJSON, ccJSON, bccJSON, req.Subject, req.BodyText, req.BodyHTML, req.InReplyTo, status, req.ScheduleAt).Scan(&id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", ErrAccountNotFound
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
return id, status, nil
|
||||
}
|
||||
|
||||
type RulesList struct {
|
||||
Rules []map[string]any `json:"rules"`
|
||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) ListRules(ctx context.Context, externalID string, params query.ListParams) (RulesList, error) {
|
||||
var total int64
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM mail_rules WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
|
||||
`, externalID).Scan(&total)
|
||||
if err != nil {
|
||||
return RulesList{}, err
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, name, priority, conditions, actions, is_active, match_count
|
||||
FROM mail_rules WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
|
||||
ORDER BY priority ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, externalID, params.Limit(), params.Offset())
|
||||
if err != nil {
|
||||
return RulesList{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
rules := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
var id, name string
|
||||
var priority int
|
||||
var conditions, actions []byte
|
||||
var isActive bool
|
||||
var matchCount int64
|
||||
if err := rows.Scan(&id, &name, &priority, &conditions, &actions, &isActive, &matchCount); err != nil {
|
||||
return RulesList{}, err
|
||||
}
|
||||
rules = append(rules, map[string]any{
|
||||
"id": id, "name": name, "priority": priority,
|
||||
"conditions": json.RawMessage(conditions), "actions": json.RawMessage(actions),
|
||||
"is_active": isActive, "match_count": matchCount,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return RulesList{}, err
|
||||
}
|
||||
|
||||
return RulesList{
|
||||
Rules: rules,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateRule(ctx context.Context, userID string, req *createRuleRequest) (string, error) {
|
||||
condJSON, _ := json.Marshal(req.Conditions)
|
||||
actJSON, _ := json.Marshal(req.Actions)
|
||||
|
||||
if req.AccountID != "" {
|
||||
var exists bool
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM mail_accounts WHERE id = $1 AND user_id = $2)
|
||||
`, req.AccountID, userID).Scan(&exists)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return "", ErrAccountNotFound
|
||||
}
|
||||
}
|
||||
|
||||
var id string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO mail_rules (user_id, account_id, name, priority, conditions, actions)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
`, userID, nilIfEmpty(req.AccountID), req.Name, req.Priority, condJSON, actJSON).Scan(&id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateRule(ctx context.Context, userID, ruleID string, req *updateRuleRequest) error {
|
||||
condJSON, _ := json.Marshal(req.Conditions)
|
||||
actJSON, _ := json.Marshal(req.Actions)
|
||||
|
||||
result, err := s.db.Exec(ctx, `
|
||||
UPDATE mail_rules SET name=$1, priority=$2, is_active=$3, conditions=$4, actions=$5, updated_at=NOW()
|
||||
WHERE id=$6 AND user_id=$7
|
||||
`, req.Name, req.Priority, req.IsActive, condJSON, actJSON, ruleID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteRule(ctx context.Context, externalID, userID, ruleID string) error {
|
||||
result, err := s.db.Exec(ctx, `DELETE FROM mail_rules WHERE id = $1 AND user_id = $2`, ruleID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if s.audit != nil {
|
||||
s.audit.Log(ctx, externalID, securityaudit.ActionCriticalDeletion, map[string]any{
|
||||
"target": "mail_rule", "rule_id": ruleID,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WebhooksList struct {
|
||||
Webhooks []map[string]any `json:"webhooks"`
|
||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) ListWebhooks(ctx context.Context, externalID string, params query.ListParams) (WebhooksList, error) {
|
||||
var total int64
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM webhook_templates
|
||||
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
|
||||
`, externalID).Scan(&total)
|
||||
if err != nil {
|
||||
return WebhooksList{}, err
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, name, url, method, is_active FROM webhook_templates
|
||||
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, externalID, params.Limit(), params.Offset())
|
||||
if err != nil {
|
||||
return WebhooksList{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
webhooks := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
var id, name, url, method string
|
||||
var isActive bool
|
||||
if err := rows.Scan(&id, &name, &url, &method, &isActive); err != nil {
|
||||
return WebhooksList{}, err
|
||||
}
|
||||
webhooks = append(webhooks, map[string]any{
|
||||
"id": id, "name": name, "url": url, "method": method, "is_active": isActive,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return WebhooksList{}, err
|
||||
}
|
||||
|
||||
return WebhooksList{
|
||||
Webhooks: webhooks,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateWebhook(ctx context.Context, externalID string, req *createWebhookRequest, method string) (string, error) {
|
||||
headersJSON, _ := json.Marshal(req.Headers)
|
||||
|
||||
var id string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO webhook_templates (user_id, name, url, method, headers, body_template)
|
||||
VALUES ((SELECT id FROM users WHERE external_id = $1), $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
`, externalID, req.Name, req.URL, method, headersJSON, req.BodyTemplate).Scan(&id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteWebhook(ctx context.Context, externalID, userID, webhookID string) error {
|
||||
result, err := s.db.Exec(ctx, `DELETE FROM webhook_templates WHERE id = $1 AND user_id = $2`, webhookID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if s.audit != nil {
|
||||
s.audit.Log(ctx, externalID, securityaudit.ActionCriticalDeletion, map[string]any{
|
||||
"target": "webhook_template", "webhook_id": webhookID,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
487
internal/api/mail/validate.go
Normal file
487
internal/api/mail/validate.go
Normal file
@ -0,0 +1,487 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"mime"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
)
|
||||
|
||||
const (
|
||||
maxAccountRequestBody = 32 << 10 // 32 KiB
|
||||
maxSendRequestBody = 5 << 20 // 5 MiB
|
||||
maxWebhookRequestBody = 128 << 10 // 128 KiB
|
||||
maxRulesRequestBody = 256 << 10 // 256 KiB
|
||||
maxFlagsLabelsBody = 32 << 10 // 32 KiB
|
||||
|
||||
maxWebhookBodyTemplate = 64 << 10 // 64 KiB
|
||||
maxWebhookHeaders = 20
|
||||
maxHeaderNameLen = 256
|
||||
maxHeaderValueLen = 8192
|
||||
|
||||
maxSubjectLen = 998
|
||||
maxBodyField = 4 << 20 // 4 MiB per body field
|
||||
maxEmailLen = 320
|
||||
maxHostLen = 253
|
||||
maxAccountName = 128
|
||||
maxUsernameLen = 256
|
||||
maxPasswordLen = 256
|
||||
maxWebhookName = 128
|
||||
maxRuleName = 128
|
||||
)
|
||||
|
||||
var allowedWebhookMethods = map[string]struct{}{
|
||||
"POST": {},
|
||||
"PUT": {},
|
||||
"PATCH": {},
|
||||
}
|
||||
|
||||
func containsNewline(s string) bool {
|
||||
return strings.ContainsAny(s, "\r\n")
|
||||
}
|
||||
|
||||
func validateEmailField(field, addr string) *apivalidate.FieldDetail {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "required"}
|
||||
}
|
||||
if len(addr) > maxEmailLen {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "too long"}
|
||||
}
|
||||
if containsNewline(addr) {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "invalid"}
|
||||
}
|
||||
parsed, err := mail.ParseAddress(addr)
|
||||
if err != nil || parsed.Address == "" {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "invalid"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHostField(field, host string) *apivalidate.FieldDetail {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "required"}
|
||||
}
|
||||
if len(host) > maxHostLen {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "too long"}
|
||||
}
|
||||
if containsNewline(host) || strings.Contains(host, " ") {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "invalid"}
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
inner := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
||||
if ip := net.ParseIP(inner); ip != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if !isDNSHostname(host) {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "invalid"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDNSHostname(host string) bool {
|
||||
if strings.HasSuffix(host, ".") {
|
||||
host = strings.TrimSuffix(host, ".")
|
||||
}
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
labels := strings.Split(host, ".")
|
||||
for _, label := range labels {
|
||||
if len(label) == 0 || len(label) > 63 {
|
||||
return false
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, r := range label {
|
||||
if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validatePortField(field string, port int) *apivalidate.FieldDetail {
|
||||
if port < 1 || port > 65535 {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "must be between 1 and 65535"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCredentialField(field, value string, maxLen int) *apivalidate.FieldDetail {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "required"}
|
||||
}
|
||||
if len(value) > maxLen {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "too long"}
|
||||
}
|
||||
if containsNewline(value) {
|
||||
return &apivalidate.FieldDetail{Field: field, Message: "invalid"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type createAccountRequest struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Provider string `json:"provider"`
|
||||
IMAPHost string `json:"imap_host"`
|
||||
IMAPPort int `json:"imap_port"`
|
||||
IMAPTLS bool `json:"imap_tls"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
SMTPTLS bool `json:"smtp_tls"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func validateCreateAccount(req *createAccountRequest) *apivalidate.ValidationError {
|
||||
var details []apivalidate.FieldDetail
|
||||
if req.Name != "" && len(req.Name) > maxAccountName {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"})
|
||||
}
|
||||
if d := validateEmailField("email", req.Email); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validateHostField("imap_host", req.IMAPHost); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validateHostField("smtp_host", req.SMTPHost); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validatePortField("imap_port", req.IMAPPort); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validatePortField("smtp_port", req.SMTPPort); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validateCredentialField("username", req.Username, maxUsernameLen); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if d := validateCredentialField("password", req.Password, maxPasswordLen); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
type sendMessageRequest struct {
|
||||
AccountID string `json:"account_id"`
|
||||
To []string `json:"to"`
|
||||
Cc []string `json:"cc"`
|
||||
Bcc []string `json:"bcc"`
|
||||
Subject string `json:"subject"`
|
||||
BodyText string `json:"body_text"`
|
||||
BodyHTML string `json:"body_html"`
|
||||
InReplyTo string `json:"in_reply_to"`
|
||||
ScheduleAt *string `json:"schedule_at"`
|
||||
}
|
||||
|
||||
func validateSendMessage(req *sendMessageRequest) *apivalidate.ValidationError {
|
||||
var details []apivalidate.FieldDetail
|
||||
if strings.TrimSpace(req.AccountID) == "" {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "account_id", Message: "required"})
|
||||
}
|
||||
recipients := append(append([]string{}, req.To...), append(req.Cc, req.Bcc...)...)
|
||||
if len(recipients) == 0 {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "to", Message: "at least one recipient required"})
|
||||
}
|
||||
for i, addr := range req.To {
|
||||
if d := validateRecipient(addr); d != nil {
|
||||
d.Field = "to[" + strconv.Itoa(i) + "]"
|
||||
details = append(details, *d)
|
||||
}
|
||||
}
|
||||
for i, addr := range req.Cc {
|
||||
if d := validateRecipient(addr); d != nil {
|
||||
d.Field = "cc[" + strconv.Itoa(i) + "]"
|
||||
details = append(details, *d)
|
||||
}
|
||||
}
|
||||
for i, addr := range req.Bcc {
|
||||
if d := validateRecipient(addr); d != nil {
|
||||
d.Field = "bcc[" + strconv.Itoa(i) + "]"
|
||||
details = append(details, *d)
|
||||
}
|
||||
}
|
||||
if len(req.Subject) > maxSubjectLen {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "subject", Message: "too long"})
|
||||
}
|
||||
if len(req.BodyText) > maxBodyField {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "body_text", Message: "too long"})
|
||||
}
|
||||
if len(req.BodyHTML) > maxBodyField {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "body_html", Message: "too long"})
|
||||
}
|
||||
if req.InReplyTo != "" && len(req.InReplyTo) > 998 {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "in_reply_to", Message: "too long"})
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
func validateRecipient(addr string) *apivalidate.FieldDetail {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return &apivalidate.FieldDetail{Field: "email", Message: "required"}
|
||||
}
|
||||
if len(addr) > maxEmailLen || containsNewline(addr) {
|
||||
return &apivalidate.FieldDetail{Field: "email", Message: "invalid"}
|
||||
}
|
||||
parsed, err := mail.ParseAddress(addr)
|
||||
if err != nil || parsed.Address == "" {
|
||||
return &apivalidate.FieldDetail{Field: "email", Message: "invalid"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type updateLabelsRequest struct {
|
||||
Labels []string `json:"labels"`
|
||||
}
|
||||
|
||||
func validateUpdateLabels(req *updateLabelsRequest) *apivalidate.ValidationError {
|
||||
if req.Labels == nil {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "labels", Message: "required",
|
||||
})
|
||||
}
|
||||
for i, label := range req.Labels {
|
||||
if containsNewline(label) {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "labels[" + strconv.Itoa(i) + "]", Message: "invalid",
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type updateFlagsRequest struct {
|
||||
Flags []string `json:"flags"`
|
||||
}
|
||||
|
||||
func validateUpdateFlags(req *updateFlagsRequest) *apivalidate.ValidationError {
|
||||
if req.Flags == nil {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "flags", Message: "required",
|
||||
})
|
||||
}
|
||||
for i, flag := range req.Flags {
|
||||
if strings.TrimSpace(flag) == "" || containsNewline(flag) {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "flags[" + strconv.Itoa(i) + "]", Message: "invalid",
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type createRuleRequest struct {
|
||||
Name string `json:"name"`
|
||||
AccountID string `json:"account_id"`
|
||||
Priority int `json:"priority"`
|
||||
Conditions any `json:"conditions"`
|
||||
Actions any `json:"actions"`
|
||||
}
|
||||
|
||||
func validateCreateRule(req *createRuleRequest) *apivalidate.ValidationError {
|
||||
var details []apivalidate.FieldDetail
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "required"})
|
||||
} else if len(req.Name) > maxRuleName {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"})
|
||||
}
|
||||
if req.Conditions == nil {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "conditions", Message: "required"})
|
||||
}
|
||||
if req.Actions == nil {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "actions", Message: "required"})
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
type updateRuleRequest struct {
|
||||
Name string `json:"name"`
|
||||
Priority int `json:"priority"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Conditions any `json:"conditions"`
|
||||
Actions any `json:"actions"`
|
||||
}
|
||||
|
||||
func validateUpdateRule(req *updateRuleRequest) *apivalidate.ValidationError {
|
||||
var details []apivalidate.FieldDetail
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "required"})
|
||||
} else if len(req.Name) > maxRuleName {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"})
|
||||
}
|
||||
if req.Conditions == nil {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "conditions", Message: "required"})
|
||||
}
|
||||
if req.Actions == nil {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "actions", Message: "required"})
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return apivalidate.NewValidationError(details...)
|
||||
}
|
||||
|
||||
type createWebhookRequest struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
BodyTemplate string `json:"body_template"`
|
||||
}
|
||||
|
||||
func validateWebhookURL(raw string) *apivalidate.FieldDetail {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return &apivalidate.FieldDetail{Field: "url", Message: "required"}
|
||||
}
|
||||
if len(raw) > 2048 {
|
||||
return &apivalidate.FieldDetail{Field: "url", Message: "too long"}
|
||||
}
|
||||
if containsNewline(raw) {
|
||||
return &apivalidate.FieldDetail{Field: "url", Message: "invalid"}
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return &apivalidate.FieldDetail{Field: "url", Message: "invalid"}
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return &apivalidate.FieldDetail{Field: "url", Message: "invalid scheme"}
|
||||
}
|
||||
if u.Host == "" {
|
||||
return &apivalidate.FieldDetail{Field: "url", Message: "invalid"}
|
||||
}
|
||||
if u.User != nil {
|
||||
return &apivalidate.FieldDetail{Field: "url", Message: "must not contain credentials"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWebhookMethod(method string) (string, *apivalidate.FieldDetail) {
|
||||
method = strings.TrimSpace(strings.ToUpper(method))
|
||||
if method == "" {
|
||||
return "", &apivalidate.FieldDetail{Field: "method", Message: "required"}
|
||||
}
|
||||
if _, ok := allowedWebhookMethods[method]; !ok {
|
||||
return "", &apivalidate.FieldDetail{Field: "method", Message: "invalid"}
|
||||
}
|
||||
return method, nil
|
||||
}
|
||||
|
||||
func isValidHeaderToken(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c < 0x21 || c == 0x7f || c == '(' || c == ')' || c == '<' || c == '>' ||
|
||||
c == '@' || c == ',' || c == ';' || c == ':' || c == '\\' || c == '"' ||
|
||||
c == '/' || c == '[' || c == ']' || c == '?' || c == '=' || c == '{' ||
|
||||
c == '}' || c == ' ' || c == '\t' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateWebhookHeaders(headers map[string]string) *apivalidate.ValidationError {
|
||||
if len(headers) > maxWebhookHeaders {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "headers", Message: "too many entries",
|
||||
})
|
||||
}
|
||||
for name, value := range headers {
|
||||
if len(name) > maxHeaderNameLen {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "headers", Message: "header name too long",
|
||||
})
|
||||
}
|
||||
if len(value) > maxHeaderValueLen {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "headers", Message: "header value too long",
|
||||
})
|
||||
}
|
||||
if containsNewline(name) || containsNewline(value) {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "headers", Message: "invalid header",
|
||||
})
|
||||
}
|
||||
if !isValidHeaderToken(name) {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "headers", Message: "invalid header name",
|
||||
})
|
||||
}
|
||||
if strings.EqualFold(name, "Content-Type") {
|
||||
if _, _, err := mime.ParseMediaType(value); err != nil {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "headers", Message: "invalid content-type",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWebhookBodyTemplate(body string) *apivalidate.FieldDetail {
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
if len(body) > maxWebhookBodyTemplate {
|
||||
return &apivalidate.FieldDetail{Field: "body_template", Message: "too large"}
|
||||
}
|
||||
if !json.Valid([]byte(body)) {
|
||||
return &apivalidate.FieldDetail{Field: "body_template", Message: "invalid json"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCreateWebhook(req *createWebhookRequest) (string, *apivalidate.ValidationError) {
|
||||
var details []apivalidate.FieldDetail
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "required"})
|
||||
} else if len(req.Name) > maxWebhookName {
|
||||
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"})
|
||||
}
|
||||
if d := validateWebhookURL(req.URL); d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
method, d := validateWebhookMethod(req.Method)
|
||||
if d != nil {
|
||||
details = append(details, *d)
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return "", apivalidate.NewValidationError(details...)
|
||||
}
|
||||
if verr := validateWebhookHeaders(req.Headers); verr != nil {
|
||||
return "", verr
|
||||
}
|
||||
if d := validateWebhookBodyTemplate(req.BodyTemplate); d != nil {
|
||||
return "", apivalidate.NewValidationError(*d)
|
||||
}
|
||||
return method, nil
|
||||
}
|
||||
86
internal/api/meet/handlers.go
Normal file
86
internal/api/meet/handlers.go
Normal file
@ -0,0 +1,86 @@
|
||||
package meet
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandler(meetCfg *meetpkg.Config) *Handler {
|
||||
return &Handler{
|
||||
svc: NewService(meetCfg),
|
||||
logger: slog.Default().With("component", "meet-api"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/rooms", h.CreateRoom)
|
||||
r.Post("/rooms/{roomID}/token", h.GetToken)
|
||||
return r
|
||||
}
|
||||
|
||||
func meetUser(claims *auth.Claims) *meetpkg.UserInfo {
|
||||
return &meetpkg.UserInfo{
|
||||
ID: claims.Sub,
|
||||
Name: claims.Name,
|
||||
Email: claims.Email,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
var req createRoomRequest
|
||||
if r.ContentLength != 0 {
|
||||
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if verr := validateCreateRoom(&req); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
user := meetUser(claims)
|
||||
user.IsMod = true
|
||||
token, err := h.svc.CreateRoom(req.Name, user)
|
||||
if err != nil {
|
||||
h.logger.Error("create room token", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusCreated, token)
|
||||
}
|
||||
|
||||
func (h *Handler) GetToken(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
roomID := chi.URLParam(r, "roomID")
|
||||
if verr := validateRoomID(roomID); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
user := meetUser(claims)
|
||||
user.IsMod = false
|
||||
|
||||
token, err := h.svc.GetToken(roomID, user)
|
||||
if err != nil {
|
||||
h.logger.Error("get room token", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, token)
|
||||
}
|
||||
30
internal/api/meet/service.go
Normal file
30
internal/api/meet/service.go
Normal file
@ -0,0 +1,30 @@
|
||||
package meet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg *meetpkg.Config
|
||||
}
|
||||
|
||||
func NewService(cfg *meetpkg.Config) *Service {
|
||||
return &Service{cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *Service) CreateRoom(name string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
|
||||
roomID := uuid.New().String()[:8]
|
||||
if strings.TrimSpace(name) != "" {
|
||||
roomID = strings.TrimSpace(name)
|
||||
}
|
||||
return s.cfg.GenerateToken(roomID, user, 24*time.Hour)
|
||||
}
|
||||
|
||||
func (s *Service) GetToken(roomID string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
|
||||
return s.cfg.GenerateToken(roomID, user, 4*time.Hour)
|
||||
}
|
||||
41
internal/api/meet/validate.go
Normal file
41
internal/api/meet/validate.go
Normal file
@ -0,0 +1,41 @@
|
||||
package meet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
)
|
||||
|
||||
const maxRequestBody = 8 << 10
|
||||
|
||||
type createRoomRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func validateCreateRoom(req *createRoomRequest) *apivalidate.ValidationError {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
if utf8.RuneCountInString(name) > 128 {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "name", Message: "too long",
|
||||
})
|
||||
}
|
||||
if strings.ContainsAny(name, "/\r\n") {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "name", Message: "invalid characters",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRoomID(roomID string) *apivalidate.ValidationError {
|
||||
if strings.TrimSpace(roomID) == "" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "room_id", Message: "required",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
88
internal/api/middleware/auth.go
Normal file
88
internal/api/middleware/auth.go
Normal file
@ -0,0 +1,88 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||
"github.com/ultisuite/ulti-backend/internal/securityaudit"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const claimsKey ctxKey = "claims"
|
||||
|
||||
func Auth(verifier *auth.Verifier, audit *securityaudit.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if verifier == nil {
|
||||
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeAuthUnavailable, "authentication unavailable", nil)
|
||||
if audit != nil {
|
||||
audit.Log(r.Context(), "system", securityaudit.ActionTokenRejected, map[string]any{
|
||||
"reason": "verifier_unavailable",
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
header := r.Header.Get("Authorization")
|
||||
if header == "" {
|
||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthMissingAuthorization, "missing authorization header", nil)
|
||||
if audit != nil {
|
||||
audit.Log(r.Context(), "anonymous", securityaudit.ActionTokenRejected, map[string]any{
|
||||
"reason": "missing_authorization_header",
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
token, found := strings.CutPrefix(header, "Bearer ")
|
||||
if !found {
|
||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthInvalidAuthorization, "invalid authorization header", nil)
|
||||
if audit != nil {
|
||||
audit.Log(r.Context(), "anonymous", securityaudit.ActionTokenRejected, map[string]any{
|
||||
"reason": "invalid_authorization_header",
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := verifier.Verify(r.Context(), token)
|
||||
if err != nil {
|
||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthInvalidToken, "invalid token", nil)
|
||||
if audit != nil {
|
||||
audit.Log(r.Context(), "anonymous", securityaudit.ActionTokenRejected, map[string]any{
|
||||
"reason": "token_verification_failed",
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if audit != nil {
|
||||
audit.Log(r.Context(), claims.Sub, securityaudit.ActionLogin, map[string]any{
|
||||
"email": claims.Email,
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
})
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), claimsKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ClaimsFromContext(ctx context.Context) *auth.Claims {
|
||||
claims, _ := ctx.Value(claimsKey).(*auth.Claims)
|
||||
return claims
|
||||
}
|
||||
33
internal/api/middleware/logging.go
Normal file
33
internal/api/middleware/logging.go
Normal file
@ -0,0 +1,33 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.status = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func Logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
wrapped := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
slog.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", wrapped.status,
|
||||
"duration", time.Since(start),
|
||||
)
|
||||
})
|
||||
}
|
||||
42
internal/api/middleware/rbac.go
Normal file
42
internal/api/middleware/rbac.go
Normal file
@ -0,0 +1,42 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||
)
|
||||
|
||||
func RequireRole(roles ...permission.Role) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims := ClaimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
|
||||
return
|
||||
}
|
||||
if !permission.HasAnyRole(claims.Groups, roles...) {
|
||||
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "forbidden", nil)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequirePermission(resource permission.Resource, level permission.Level) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims := ClaimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
|
||||
return
|
||||
}
|
||||
if !permission.HasPermission(claims.Groups, resource, level) {
|
||||
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "forbidden", nil)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
20
internal/api/middleware/trace.go
Normal file
20
internal/api/middleware/trace.go
Normal file
@ -0,0 +1,20 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
)
|
||||
|
||||
func TraceID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.Header.Get(apiresponse.TraceIDHeader)
|
||||
if id == "" {
|
||||
id = apiresponse.GenerateTraceID()
|
||||
}
|
||||
|
||||
w.Header().Set(apiresponse.TraceIDHeader, id)
|
||||
ctx := apiresponse.WithTraceID(r.Context(), id)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
48
internal/api/middleware/trace_test.go
Normal file
48
internal/api/middleware/trace_test.go
Normal file
@ -0,0 +1,48 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
)
|
||||
|
||||
func TestTraceIDMiddlewareUsesRequestHeader(t *testing.T) {
|
||||
var gotTraceID string
|
||||
handler := TraceID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotTraceID = apiresponse.TraceIDFromContext(r.Context())
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set(apiresponse.TraceIDHeader, "client-trace")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if gotTraceID != "client-trace" {
|
||||
t.Fatalf("context trace_id = %q, want client-trace", gotTraceID)
|
||||
}
|
||||
if rec.Header().Get(apiresponse.TraceIDHeader) != "client-trace" {
|
||||
t.Fatalf("response header = %q, want client-trace", rec.Header().Get(apiresponse.TraceIDHeader))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceIDMiddlewareGeneratesWhenMissing(t *testing.T) {
|
||||
var gotTraceID string
|
||||
handler := TraceID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotTraceID = apiresponse.TraceIDFromContext(r.Context())
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if gotTraceID == "" {
|
||||
t.Fatal("expected generated trace id in context")
|
||||
}
|
||||
if rec.Header().Get(apiresponse.TraceIDHeader) != gotTraceID {
|
||||
t.Fatalf("response header = %q, context = %q", rec.Header().Get(apiresponse.TraceIDHeader), gotTraceID)
|
||||
}
|
||||
}
|
||||
14
internal/api/paginate/paginate.go
Normal file
14
internal/api/paginate/paginate.go
Normal file
@ -0,0 +1,14 @@
|
||||
package paginate
|
||||
|
||||
// Slice returns a page of items and the total count before paging.
|
||||
func Slice[T any](items []T, offset, limit int) ([]T, int64) {
|
||||
total := int64(len(items))
|
||||
if offset >= len(items) {
|
||||
return []T{}, total
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
return items[offset:end], total
|
||||
}
|
||||
22
internal/api/paginate/paginate_test.go
Normal file
22
internal/api/paginate/paginate_test.go
Normal file
@ -0,0 +1,22 @@
|
||||
package paginate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSlice(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5}
|
||||
|
||||
page, total := Slice(items, 0, 2)
|
||||
if total != 5 || len(page) != 2 || page[0] != 1 || page[1] != 2 {
|
||||
t.Fatalf("first page = %#v total=%d", page, total)
|
||||
}
|
||||
|
||||
page, total = Slice(items, 4, 10)
|
||||
if total != 5 || len(page) != 1 || page[0] != 5 {
|
||||
t.Fatalf("last page = %#v total=%d", page, total)
|
||||
}
|
||||
|
||||
page, total = Slice(items, 10, 5)
|
||||
if total != 5 || len(page) != 0 {
|
||||
t.Fatalf("past end = %#v total=%d", page, total)
|
||||
}
|
||||
}
|
||||
122
internal/api/photos/handlers.go
Normal file
122
internal/api/photos/handlers.go
Normal file
@ -0,0 +1,122 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
photospkg "github.com/ultisuite/ulti-backend/internal/photos"
|
||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandler(client *photospkg.Client) *Handler {
|
||||
return &Handler{
|
||||
svc: NewService(client),
|
||||
logger: slog.Default().With("component", "photos-api"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
read := middleware.RequirePermission(permission.ResourcePhotos, permission.LevelRead)
|
||||
write := middleware.RequirePermission(permission.ResourcePhotos, permission.LevelWrite)
|
||||
|
||||
r.With(read).Get("/assets", h.ListAssets)
|
||||
r.With(read).Get("/assets/{assetID}/thumbnail", h.Thumbnail)
|
||||
r.With(read).Get("/albums", h.ListAlbums)
|
||||
r.With(write).Post("/assets", h.Upload)
|
||||
r.With(write).Delete("/assets/{assetID}", h.DeleteAsset)
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *Handler) ListAssets(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ListAssets(r.Context(), claims.Sub, params)
|
||||
if err != nil {
|
||||
h.logger.Error("list assets", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
id, err := h.svc.UploadAsset(r.Context(), claims.Sub, r.Body, r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
h.logger.Error("upload asset", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"id": id})
|
||||
}
|
||||
|
||||
func (h *Handler) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
assetID := chi.URLParam(r, "assetID")
|
||||
if verr := validateAssetID(assetID); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
body, ct, err := h.svc.GetAssetThumbnail(r.Context(), claims.Sub, assetID)
|
||||
if err != nil {
|
||||
apivalidate.WriteNotFound(w, r, "not found")
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
w.Header().Set("Content-Type", ct)
|
||||
io.Copy(w, body)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteAsset(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
assetID := chi.URLParam(r, "assetID")
|
||||
if verr := validateAssetID(assetID); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteAsset(r.Context(), claims.Sub, assetID); err != nil {
|
||||
h.logger.Error("delete asset", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ListAlbums(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ListAlbums(r.Context(), claims.Sub, params)
|
||||
if err != nil {
|
||||
h.logger.Error("list albums", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
99
internal/api/photos/service.go
Normal file
99
internal/api/photos/service.go
Normal file
@ -0,0 +1,99 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
photospkg "github.com/ultisuite/ulti-backend/internal/photos"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
client *photospkg.Client
|
||||
}
|
||||
|
||||
func NewService(client *photospkg.Client) *Service {
|
||||
return &Service{client: client}
|
||||
}
|
||||
|
||||
type AssetsList struct {
|
||||
Assets []photospkg.Asset `json:"assets"`
|
||||
Page int `json:"page"`
|
||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) ListAssets(ctx context.Context, apiKey string, params query.ListParams) (AssetsList, error) {
|
||||
assets, err := s.client.GetAssets(ctx, apiKey, params.Page, params.PageSize)
|
||||
if err != nil {
|
||||
return AssetsList{}, err
|
||||
}
|
||||
filtered := filterAssets(assets, params.Q)
|
||||
total := int64(len(filtered))
|
||||
page, _ := paginate.Slice(filtered, 0, len(filtered))
|
||||
return AssetsList{
|
||||
Assets: page,
|
||||
Page: params.Page,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type AlbumsList struct {
|
||||
Albums []photospkg.Album `json:"albums"`
|
||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) ListAlbums(ctx context.Context, apiKey string, params query.ListParams) (AlbumsList, error) {
|
||||
albums, err := s.client.GetAlbums(ctx, apiKey)
|
||||
if err != nil {
|
||||
return AlbumsList{}, err
|
||||
}
|
||||
filtered := filterAlbums(albums, params.Q)
|
||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||
return AlbumsList{
|
||||
Albums: page,
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UploadAsset(ctx context.Context, apiKey string, body io.Reader, contentType string) (string, error) {
|
||||
return s.client.UploadAsset(ctx, apiKey, body, contentType)
|
||||
}
|
||||
|
||||
func (s *Service) GetAssetThumbnail(ctx context.Context, apiKey, assetID string) (io.ReadCloser, string, error) {
|
||||
return s.client.GetAssetThumbnail(ctx, apiKey, assetID)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAsset(ctx context.Context, apiKey, assetID string) error {
|
||||
return s.client.DeleteAsset(ctx, apiKey, assetID)
|
||||
}
|
||||
|
||||
func filterAssets(assets []photospkg.Asset, q string) []photospkg.Asset {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
if q == "" {
|
||||
return assets
|
||||
}
|
||||
out := make([]photospkg.Asset, 0, len(assets))
|
||||
for _, a := range assets {
|
||||
if strings.Contains(strings.ToLower(a.OriginalName), q) ||
|
||||
strings.Contains(strings.ToLower(a.MimeType), q) {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterAlbums(albums []photospkg.Album, q string) []photospkg.Album {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
if q == "" {
|
||||
return albums
|
||||
}
|
||||
out := make([]photospkg.Album, 0, len(albums))
|
||||
for _, a := range albums {
|
||||
if strings.Contains(strings.ToLower(a.Name), q) {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
16
internal/api/photos/validate.go
Normal file
16
internal/api/photos/validate.go
Normal file
@ -0,0 +1,16 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
)
|
||||
|
||||
func validateAssetID(assetID string) *apivalidate.ValidationError {
|
||||
if strings.TrimSpace(assetID) == "" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "asset_id", Message: "required",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
198
internal/api/query/query.go
Normal file
198
internal/api/query/query.go
Normal file
@ -0,0 +1,198 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultPage = 1
|
||||
DefaultPageSize = 50
|
||||
MaxPageSize = 200
|
||||
|
||||
dateLayout = "2006-01-02"
|
||||
)
|
||||
|
||||
// ListParams holds normalized list query parameters.
|
||||
type ListParams struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Q string
|
||||
Sort string
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
}
|
||||
|
||||
// Offset returns the SQL/list offset derived from page and page_size.
|
||||
func (p ListParams) Offset() int {
|
||||
return (p.Page - 1) * p.PageSize
|
||||
}
|
||||
|
||||
// Limit returns the page size as a fetch limit.
|
||||
func (p ListParams) Limit() int {
|
||||
return p.PageSize
|
||||
}
|
||||
|
||||
// PaginationMeta describes list pagination in API responses.
|
||||
type PaginationMeta struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
}
|
||||
|
||||
// Meta builds response pagination metadata for the current params.
|
||||
func (p ListParams) Meta(total *int64) PaginationMeta {
|
||||
return PaginationMeta{
|
||||
Page: p.Page,
|
||||
PageSize: p.PageSize,
|
||||
Total: total,
|
||||
}
|
||||
}
|
||||
|
||||
// FieldDetail identifies a single invalid query parameter.
|
||||
type FieldDetail struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ValidationError reports invalid query parameters using the standard API error shape.
|
||||
type ValidationError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details []FieldDetail `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
if len(e.Details) == 0 {
|
||||
return e.Message
|
||||
}
|
||||
parts := make([]string, len(e.Details))
|
||||
for i, d := range e.Details {
|
||||
parts[i] = fmt.Sprintf("%s: %s", d.Field, d.Message)
|
||||
}
|
||||
return e.Message + ": " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// ParseDate parses a YYYY-MM-DD date in UTC at midnight.
|
||||
func ParseDate(raw string) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, fmt.Errorf("empty date")
|
||||
}
|
||||
t, err := time.ParseInLocation(dateLayout, raw, time.UTC)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("expected YYYY-MM-DD")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ParseList parses list query parameters from url.Values.
|
||||
func ParseList(values url.Values) (ListParams, error) {
|
||||
var params ListParams
|
||||
var details []FieldDetail
|
||||
|
||||
page, pageErr := parsePage(values.Get("page"))
|
||||
if pageErr != nil {
|
||||
details = append(details, FieldDetail{Field: "page", Message: pageErr.Error()})
|
||||
} else {
|
||||
params.Page = page
|
||||
}
|
||||
|
||||
pageSize, sizeErr := parsePageSize(values.Get("page_size"))
|
||||
if sizeErr != nil {
|
||||
details = append(details, FieldDetail{Field: "page_size", Message: sizeErr.Error()})
|
||||
} else {
|
||||
params.PageSize = pageSize
|
||||
}
|
||||
|
||||
params.Q = strings.TrimSpace(values.Get("q"))
|
||||
params.Sort = strings.TrimSpace(values.Get("sort"))
|
||||
|
||||
fromRaw := strings.TrimSpace(values.Get("from"))
|
||||
toRaw := strings.TrimSpace(values.Get("to"))
|
||||
|
||||
if fromRaw != "" {
|
||||
from, err := ParseDate(fromRaw)
|
||||
if err != nil {
|
||||
details = append(details, FieldDetail{Field: "from", Message: err.Error()})
|
||||
} else {
|
||||
params.From = &from
|
||||
}
|
||||
}
|
||||
|
||||
if toRaw != "" {
|
||||
to, err := ParseDate(toRaw)
|
||||
if err != nil {
|
||||
details = append(details, FieldDetail{Field: "to", Message: err.Error()})
|
||||
} else {
|
||||
end := to.Add(24*time.Hour - time.Nanosecond)
|
||||
params.To = &end
|
||||
}
|
||||
}
|
||||
|
||||
if params.From != nil && params.To != nil && params.From.After(*params.To) {
|
||||
details = append(details, FieldDetail{
|
||||
Field: "from",
|
||||
Message: "must be on or before to",
|
||||
})
|
||||
}
|
||||
|
||||
if len(details) > 0 {
|
||||
return ListParams{}, &ValidationError{
|
||||
Code: "invalid_query_param",
|
||||
Message: "invalid query parameters",
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// ParseListRequest parses list query parameters from an HTTP request.
|
||||
func ParseListRequest(r *http.Request) (ListParams, error) {
|
||||
if r == nil {
|
||||
return ListParams{}, &ValidationError{
|
||||
Code: "invalid_query_param",
|
||||
Message: "invalid query parameters",
|
||||
Details: []FieldDetail{{Field: "request", Message: "missing request"}},
|
||||
}
|
||||
}
|
||||
return ParseList(r.URL.Query())
|
||||
}
|
||||
|
||||
func parsePage(raw string) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return DefaultPage, nil
|
||||
}
|
||||
page, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("must be a positive integer")
|
||||
}
|
||||
if page < 1 {
|
||||
return 0, fmt.Errorf("must be at least 1")
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func parsePageSize(raw string) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return DefaultPageSize, nil
|
||||
}
|
||||
size, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("must be a positive integer")
|
||||
}
|
||||
if size < 1 {
|
||||
return 0, fmt.Errorf("must be at least 1")
|
||||
}
|
||||
if size > MaxPageSize {
|
||||
return 0, fmt.Errorf("must be at most %d", MaxPageSize)
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
213
internal/api/query/query_test.go
Normal file
213
internal/api/query/query_test.go
Normal file
@ -0,0 +1,213 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseList_defaults(t *testing.T) {
|
||||
params, err := ParseList(url.Values{})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseList() error = %v", err)
|
||||
}
|
||||
if params.Page != DefaultPage {
|
||||
t.Fatalf("Page = %d, want %d", params.Page, DefaultPage)
|
||||
}
|
||||
if params.PageSize != DefaultPageSize {
|
||||
t.Fatalf("PageSize = %d, want %d", params.PageSize, DefaultPageSize)
|
||||
}
|
||||
if params.Q != "" || params.Sort != "" {
|
||||
t.Fatalf("Q/Sort should be empty, got q=%q sort=%q", params.Q, params.Sort)
|
||||
}
|
||||
if params.From != nil || params.To != nil {
|
||||
t.Fatal("From/To should be nil")
|
||||
}
|
||||
if params.Offset() != 0 {
|
||||
t.Fatalf("Offset() = %d, want 0", params.Offset())
|
||||
}
|
||||
if params.Limit() != DefaultPageSize {
|
||||
t.Fatalf("Limit() = %d, want %d", params.Limit(), DefaultPageSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseList_valid(t *testing.T) {
|
||||
values := url.Values{
|
||||
"page": {"3"},
|
||||
"page_size": {"25"},
|
||||
"q": {" hello "},
|
||||
"sort": {"-created_at"},
|
||||
"from": {"2026-01-01"},
|
||||
"to": {"2026-01-31"},
|
||||
}
|
||||
|
||||
params, err := ParseList(values)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseList() error = %v", err)
|
||||
}
|
||||
if params.Page != 3 || params.PageSize != 25 {
|
||||
t.Fatalf("page/page_size = %d/%d", params.Page, params.PageSize)
|
||||
}
|
||||
if params.Q != "hello" || params.Sort != "-created_at" {
|
||||
t.Fatalf("q/sort = %q/%q", params.Q, params.Sort)
|
||||
}
|
||||
if params.Offset() != 50 {
|
||||
t.Fatalf("Offset() = %d, want 50", params.Offset())
|
||||
}
|
||||
if params.From == nil || params.To == nil {
|
||||
t.Fatal("expected From/To to be set")
|
||||
}
|
||||
if params.From.Format(dateLayout) != "2026-01-01" {
|
||||
t.Fatalf("From = %s", params.From.Format(time.RFC3339))
|
||||
}
|
||||
if params.To.Format(dateLayout) != "2026-01-31" {
|
||||
t.Fatalf("To day = %s", params.To.Format(dateLayout))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseList_invalidPage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"zero", "0"},
|
||||
{"negative", "-1"},
|
||||
{"non_numeric", "abc"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ParseList(url.Values{"page": {tt.value}})
|
||||
assertValidationError(t, err, "page")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseList_invalidPageSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"zero", "0"},
|
||||
{"negative", "-5"},
|
||||
{"too_large", "201"},
|
||||
{"non_numeric", "large"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ParseList(url.Values{"page_size": {tt.value}})
|
||||
assertValidationError(t, err, "page_size")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseList_invalidDates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values url.Values
|
||||
field string
|
||||
}{
|
||||
{
|
||||
name: "invalid from format",
|
||||
values: url.Values{"from": {"01-01-2026"}},
|
||||
field: "from",
|
||||
},
|
||||
{
|
||||
name: "invalid to format",
|
||||
values: url.Values{"to": {"2026/01/31"}},
|
||||
field: "to",
|
||||
},
|
||||
{
|
||||
name: "from after to",
|
||||
values: url.Values{"from": {"2026-02-01"}, "to": {"2026-01-01"}},
|
||||
field: "from",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ParseList(tt.values)
|
||||
assertValidationError(t, err, tt.field)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseList_multipleErrors(t *testing.T) {
|
||||
_, err := ParseList(url.Values{
|
||||
"page": {"0"},
|
||||
"page_size": {"500"},
|
||||
"from": {"bad-date"},
|
||||
})
|
||||
var verr *ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected ValidationError, got %T", err)
|
||||
}
|
||||
if len(verr.Details) < 3 {
|
||||
t.Fatalf("Details len = %d, want >= 3", len(verr.Details))
|
||||
}
|
||||
if verr.Code != "invalid_query_param" {
|
||||
t.Fatalf("Code = %q", verr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDate(t *testing.T) {
|
||||
got, err := ParseDate("2026-05-22")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDate() error = %v", err)
|
||||
}
|
||||
if got.Location() != time.UTC {
|
||||
t.Fatalf("location = %v, want UTC", got.Location())
|
||||
}
|
||||
if got.Format(dateLayout) != "2026-05-22" {
|
||||
t.Fatalf("date = %s", got.Format(dateLayout))
|
||||
}
|
||||
|
||||
if _, err := ParseDate("22-05-2026"); err == nil {
|
||||
t.Fatal("expected error for invalid format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeta(t *testing.T) {
|
||||
params := ListParams{Page: 2, PageSize: 10}
|
||||
total := int64(42)
|
||||
meta := params.Meta(&total)
|
||||
|
||||
if meta.Page != 2 || meta.PageSize != 10 {
|
||||
t.Fatalf("meta page/page_size = %d/%d", meta.Page, meta.PageSize)
|
||||
}
|
||||
if meta.Total == nil || *meta.Total != 42 {
|
||||
t.Fatalf("meta total = %v", meta.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListRequest(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "/items?page=2&page_size=10&q=test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
params, err := ParseListRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseListRequest() error = %v", err)
|
||||
}
|
||||
if params.Page != 2 || params.PageSize != 10 || params.Q != "test" {
|
||||
t.Fatalf("params = %+v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func assertValidationError(t *testing.T, err error, field string) {
|
||||
t.Helper()
|
||||
var verr *ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
for _, detail := range verr.Details {
|
||||
if detail.Field == field {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected detail for field %q, got %+v", field, verr.Details)
|
||||
}
|
||||
52
internal/auth/oidc.go
Normal file
52
internal/auth/oidc.go
Normal file
@ -0,0 +1,52 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
Sub string
|
||||
Email string
|
||||
Name string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
type Verifier struct {
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
func NewVerifier(ctx context.Context, issuerURL, clientID string) (*Verifier, error) {
|
||||
provider, err := oidc.NewProvider(ctx, issuerURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
|
||||
return &Verifier{verifier: verifier}, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) Verify(ctx context.Context, rawToken string) (*Claims, error) {
|
||||
token, err := v.verifier.Verify(ctx, rawToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
if err := token.Claims(&claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Claims{
|
||||
Sub: claims.Sub,
|
||||
Email: claims.Email,
|
||||
Name: claims.Name,
|
||||
Groups: claims.Groups,
|
||||
}, nil
|
||||
}
|
||||
232
internal/config/config.go
Normal file
232
internal/config/config.go
Normal file
@ -0,0 +1,232 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/secrets"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
Domain string
|
||||
AppEnv string
|
||||
|
||||
// PostgreSQL
|
||||
DatabaseURL string
|
||||
|
||||
// KeyDB
|
||||
KeyDBAddr string
|
||||
KeyDBPassword string
|
||||
KeyDBDB int
|
||||
|
||||
// Object Storage (S3-compatible)
|
||||
RustFSEndpoint string
|
||||
RustFSAccessKey string
|
||||
RustFSSecretKey string
|
||||
RustFSUseSSL bool
|
||||
RustFSRegion string
|
||||
|
||||
// OIDC
|
||||
OIDCIssuer string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
|
||||
// Nextcloud
|
||||
NextcloudEnabled bool
|
||||
NextcloudURL string
|
||||
NCAdminUser string
|
||||
NCAdminPass string
|
||||
|
||||
// Jitsi
|
||||
JitsiEnabled bool
|
||||
JitsiDomain string
|
||||
JitsiAppID string
|
||||
JitsiAppSecret string
|
||||
JitsiPublicURL string
|
||||
|
||||
// Immich
|
||||
ImmichEnabled bool
|
||||
ImmichAPIURL string
|
||||
|
||||
// Mail
|
||||
MailAttachmentsBucket string
|
||||
MailSyncInterval time.Duration
|
||||
MailOutboxInterval time.Duration
|
||||
MailCredentialKeys string
|
||||
MailActiveCredentialKeyID string
|
||||
MailWebhookSharedSecret string
|
||||
|
||||
// Secret rotation policy
|
||||
SecretRotationMaxAge time.Duration
|
||||
OIDCSecretRotatedAt time.Time
|
||||
SMTPCredentialKeyRotatedAt time.Time
|
||||
WebhookSharedSecretRotatedAt time.Time
|
||||
|
||||
// Search
|
||||
SearchEngine string
|
||||
MeilisearchURL string
|
||||
MeilisearchKey string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
port := 8080
|
||||
if v := os.Getenv("ULTID_PORT"); v != "" {
|
||||
if p, err := strconv.Atoi(v); err == nil {
|
||||
port = p
|
||||
}
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
Domain: envOrDefault("DOMAIN", "localhost"),
|
||||
AppEnv: strings.ToLower(envOrDefault("ULTID_ENV", envOrDefault("APP_ENV", "development"))),
|
||||
|
||||
DatabaseURL: envOrDefault("ULTID_DB_URL", "postgres://ulti:changeme@localhost:5432/ultidb?sslmode=disable"),
|
||||
KeyDBAddr: envOrDefault("ULTID_KEYDB_URL", "localhost:6379"),
|
||||
KeyDBPassword: secrets.Env("ULTID_KEYDB_PASSWORD"),
|
||||
KeyDBDB: envInt("ULTID_KEYDB_DB", 0),
|
||||
|
||||
RustFSEndpoint: envOrDefault("ULTID_RUSTFS_ENDPOINT", "localhost:9000"),
|
||||
RustFSAccessKey: secrets.Env("ULTID_RUSTFS_ACCESS_KEY"),
|
||||
RustFSSecretKey: secrets.Env("ULTID_RUSTFS_SECRET_KEY"),
|
||||
RustFSUseSSL: envBool("ULTID_RUSTFS_USE_SSL", false),
|
||||
RustFSRegion: envOrDefault("ULTID_RUSTFS_REGION", "us-east-1"),
|
||||
|
||||
OIDCIssuer: os.Getenv("ULTID_OIDC_ISSUER"),
|
||||
OIDCClientID: os.Getenv("ULTID_OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: secrets.Env("ULTID_OIDC_CLIENT_SECRET"),
|
||||
|
||||
NextcloudEnabled: envBool("NEXTCLOUD_ENABLED", true),
|
||||
NextcloudURL: envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"),
|
||||
NCAdminUser: envOrDefault("NC_ADMIN_USER", "admin"),
|
||||
NCAdminPass: envOrDefaultSecret("NC_ADMIN_PASSWORD", "changeme"),
|
||||
|
||||
JitsiEnabled: envBool("JITSI_ENABLED", true),
|
||||
JitsiDomain: envOrDefault("JITSI_DOMAIN", "meet.jitsi"),
|
||||
JitsiAppID: envOrDefault("JITSI_APP_ID", "ulti"),
|
||||
JitsiAppSecret: envOrDefaultSecret("JITSI_APP_SECRET", "changeme-jwt-secret"),
|
||||
JitsiPublicURL: envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"),
|
||||
|
||||
ImmichEnabled: envBool("IMMICH_ENABLED", true),
|
||||
ImmichAPIURL: envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"),
|
||||
|
||||
MailAttachmentsBucket: envOrDefault("MAIL_ATTACHMENTS_BUCKET", "mail-attachments"),
|
||||
MailSyncInterval: envDuration("MAIL_SYNC_INTERVAL", 2*time.Minute),
|
||||
MailOutboxInterval: envDuration("MAIL_OUTBOX_INTERVAL", 10*time.Second),
|
||||
MailCredentialKeys: secrets.Env("MAIL_CREDENTIAL_KEYS"),
|
||||
MailActiveCredentialKeyID: envOrDefault("MAIL_ACTIVE_CREDENTIAL_KEY_ID", ""),
|
||||
MailWebhookSharedSecret: secrets.Env("MAIL_WEBHOOK_SHARED_SECRET"),
|
||||
|
||||
SecretRotationMaxAge: envDuration("SECRET_ROTATION_MAX_AGE", 90*24*time.Hour),
|
||||
OIDCSecretRotatedAt: envTime("ULTID_OIDC_CLIENT_SECRET_ROTATED_AT"),
|
||||
SMTPCredentialKeyRotatedAt: envTime("MAIL_CREDENTIAL_KEY_ROTATED_AT"),
|
||||
WebhookSharedSecretRotatedAt: envTime("MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT"),
|
||||
|
||||
SearchEngine: envOrDefault("SEARCH_ENGINE", "postgres"),
|
||||
MeilisearchURL: os.Getenv("MEILISEARCH_URL"),
|
||||
MeilisearchKey: secrets.Env("MEILISEARCH_API_KEY"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Config) IsProduction() bool {
|
||||
return c != nil && c.AppEnv == "production"
|
||||
}
|
||||
|
||||
func (c *Config) ValidateSecretRotation() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
maxAge := c.SecretRotationMaxAge
|
||||
if maxAge <= 0 {
|
||||
maxAge = 90 * 24 * time.Hour
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
checks := []struct {
|
||||
name string
|
||||
rotatedAt time.Time
|
||||
}{
|
||||
{name: "ULTID_OIDC_CLIENT_SECRET", rotatedAt: c.OIDCSecretRotatedAt},
|
||||
{name: "MAIL_CREDENTIAL_KEY", rotatedAt: c.SMTPCredentialKeyRotatedAt},
|
||||
{name: "MAIL_WEBHOOK_SHARED_SECRET", rotatedAt: c.WebhookSharedSecretRotatedAt},
|
||||
}
|
||||
|
||||
var stale []string
|
||||
for _, check := range checks {
|
||||
if check.rotatedAt.IsZero() || now.Sub(check.rotatedAt) > maxAge {
|
||||
stale = append(stale, check.name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(stale) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errors.New("stale or missing rotation metadata for: " + strings.Join(stale, ", "))
|
||||
}
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envOrDefaultSecret(key, fallback string) string {
|
||||
if v := secrets.Env(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func envDuration(key string, fallback time.Duration) time.Duration {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func envTime(key string) time.Time {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
ts, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return ts
|
||||
}
|
||||
245
internal/envexpand/expand.go
Normal file
245
internal/envexpand/expand.go
Normal file
@ -0,0 +1,245 @@
|
||||
package envexpand
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var placeholderRE = regexp.MustCompile(`\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}`)
|
||||
|
||||
const maxIterations = 32
|
||||
|
||||
// ExpandString replaces {{NAME}} placeholders using vars.
|
||||
func ExpandString(s string, vars map[string]string) string {
|
||||
return placeholderRE.ReplaceAllStringFunc(s, func(match string) string {
|
||||
name := match[2 : len(match)-2]
|
||||
if v, ok := vars[name]; ok {
|
||||
return v
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
// ExpandMap resolves {{VAR}} references until stable or maxIterations is reached.
|
||||
func ExpandMap(vars map[string]string) (map[string]string, error) {
|
||||
out := make(map[string]string, len(vars))
|
||||
for k, v := range vars {
|
||||
out[k] = v
|
||||
}
|
||||
|
||||
for i := 0; i < maxIterations; i++ {
|
||||
changed := false
|
||||
for k, v := range out {
|
||||
next := ExpandString(v, out)
|
||||
if next != v {
|
||||
changed = true
|
||||
out[k] = next
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
if unresolved := findUnresolved(out); len(unresolved) > 0 {
|
||||
return nil, fmt.Errorf("envexpand: unresolved placeholders: %s", strings.Join(unresolved, ", "))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("envexpand: expansion did not converge after %d iterations", maxIterations)
|
||||
}
|
||||
|
||||
func findUnresolved(vars map[string]string) []string {
|
||||
seen := make(map[string]bool)
|
||||
for _, v := range vars {
|
||||
for _, name := range placeholderRE.FindAllStringSubmatch(v, -1) {
|
||||
seen[name[1]] = true
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(seen))
|
||||
for n := range seen {
|
||||
names = append(names, "{{"+n+"}}")
|
||||
}
|
||||
for i := 0; i < len(names); i++ {
|
||||
for j := i + 1; j < len(names); j++ {
|
||||
if names[j] < names[i] {
|
||||
names[i], names[j] = names[j], names[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ParseLines reads KEY=VALUE pairs from dotenv-style content (no expansion).
|
||||
func ParseLines(lines []string) (map[string]string, error) {
|
||||
vars := make(map[string]string)
|
||||
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("envexpand: invalid line %d: missing '='", i+1)
|
||||
}
|
||||
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("envexpand: invalid line %d: empty key", i+1)
|
||||
}
|
||||
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 2 {
|
||||
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
|
||||
vars[key] = value
|
||||
}
|
||||
|
||||
return vars, nil
|
||||
}
|
||||
|
||||
// LoadFile parses a .env file without expanding placeholders.
|
||||
func LoadFile(path string) (map[string]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
||||
return ParseLines(lines)
|
||||
}
|
||||
|
||||
// LoadExpandFile parses and expands a .env file.
|
||||
func LoadExpandFile(path string) (map[string]string, error) {
|
||||
vars, err := LoadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ExpandMap(vars)
|
||||
}
|
||||
|
||||
// WriteFile writes KEY=VALUE lines (no quoting; values are used as-is).
|
||||
func WriteFile(path string, vars map[string]string) error {
|
||||
var keys []string
|
||||
for k := range vars {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// Stable output: sort keys
|
||||
for i := 0; i < len(keys); i++ {
|
||||
for j := i + 1; j < len(keys); j++ {
|
||||
if keys[j] < keys[i] {
|
||||
keys[i], keys[j] = keys[j], keys[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, k := range keys {
|
||||
b.WriteString(k)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(vars[k])
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
|
||||
return os.WriteFile(path, []byte(b.String()), 0o600)
|
||||
}
|
||||
|
||||
// ApplyFile loads and expands a .env file, then sets variables in the process
|
||||
// environment. Existing environment variables are not overwritten.
|
||||
func ApplyFile(path string) error {
|
||||
vars, err := LoadExpandFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ApplyMap(vars, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyMap sets variables in the process environment.
|
||||
// When override is false, existing environment variables are kept.
|
||||
func ApplyMap(vars map[string]string, override bool) {
|
||||
for k, v := range vars {
|
||||
if !override {
|
||||
if _, exists := os.LookupEnv(k); exists {
|
||||
continue
|
||||
}
|
||||
}
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Render reads an input .env, expands placeholders, and writes the result.
|
||||
func Render(inPath, outPath string) error {
|
||||
vars, err := LoadExpandFile(inPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteFile(outPath, vars)
|
||||
}
|
||||
|
||||
// RenderToWriter expands and writes dotenv format to w.
|
||||
func RenderToWriter(inPath string, w interface{ Write([]byte) (int, error) }) error {
|
||||
vars, err := LoadExpandFile(inPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var keys []string
|
||||
for k := range vars {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
for i := 0; i < len(keys); i++ {
|
||||
for j := i + 1; j < len(keys); j++ {
|
||||
if keys[j] < keys[i] {
|
||||
keys[i], keys[j] = keys[j], keys[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range keys {
|
||||
if _, err := fmt.Fprintf(w, "%s=%s\n", k, vars[k]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseReader is a helper for tests and stdin.
|
||||
func ParseReader(r interface {
|
||||
ReadString(byte) (string, error)
|
||||
}) (map[string]string, error) {
|
||||
var lines []string
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
lines = append(lines, strings.TrimSuffix(line, "\n"))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ParseLines(lines)
|
||||
}
|
||||
|
||||
// ParseFileLines loads via bufio for large files (alias to LoadFile).
|
||||
func ParseFile(path string) (map[string]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lines []string
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
lines = append(lines, sc.Text())
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseLines(lines)
|
||||
}
|
||||
71
internal/envexpand/expand_test.go
Normal file
71
internal/envexpand/expand_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package envexpand
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExpandMap(t *testing.T) {
|
||||
vars := map[string]string{
|
||||
"POSTGRES_PASSWORD": "s3cret",
|
||||
"POSTGRES_USER": "ulti",
|
||||
"POSTGRES_DB": "ultidb",
|
||||
"ULTID_DB_URL": "postgres://{{POSTGRES_USER}}:{{POSTGRES_PASSWORD}}@postgres:5432/{{POSTGRES_DB}}?sslmode=disable",
|
||||
"RUSTFS_SECRET_KEY": "key123",
|
||||
"NC_S3_SECRET": "{{RUSTFS_SECRET_KEY}}",
|
||||
}
|
||||
|
||||
out, err := ExpandMap(vars)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := "postgres://ulti:s3cret@postgres:5432/ultidb?sslmode=disable"
|
||||
if out["ULTID_DB_URL"] != want {
|
||||
t.Fatalf("ULTID_DB_URL = %q, want %q", out["ULTID_DB_URL"], want)
|
||||
}
|
||||
if out["NC_S3_SECRET"] != "key123" {
|
||||
t.Fatalf("NC_S3_SECRET = %q", out["NC_S3_SECRET"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandMap_nested(t *testing.T) {
|
||||
vars := map[string]string{
|
||||
"A": "1",
|
||||
"B": "{{A}}2",
|
||||
"C": "x{{B}}y",
|
||||
}
|
||||
|
||||
out, err := ExpandMap(vars)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["C"] != "x12y" {
|
||||
t.Fatalf("C = %q", out["C"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandMap_cycle(t *testing.T) {
|
||||
vars := map[string]string{
|
||||
"A": "{{B}}",
|
||||
"B": "{{A}}",
|
||||
}
|
||||
|
||||
_, err := ExpandMap(vars)
|
||||
if err == nil {
|
||||
t.Fatal("expected cycle error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLines(t *testing.T) {
|
||||
lines := []string{
|
||||
"# comment",
|
||||
"",
|
||||
`FOO=bar`,
|
||||
`QUOTED="hello"`,
|
||||
}
|
||||
vars, err := ParseLines(lines)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if vars["FOO"] != "bar" || vars["QUOTED"] != "hello" {
|
||||
t.Fatalf("vars = %#v", vars)
|
||||
}
|
||||
}
|
||||
160
internal/mail/credentials/manager.go
Normal file
160
internal/mail/credentials/manager.go
Normal file
@ -0,0 +1,160 @@
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const prefix = "UMC1|"
|
||||
|
||||
type Manager struct {
|
||||
activeKeyID string
|
||||
keys map[string][]byte
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func NewManager(keysSpec, activeKeyID string) (*Manager, error) {
|
||||
keys, err := parseKeys(keysSpec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil, errors.New("mail credential keys are required")
|
||||
}
|
||||
|
||||
if activeKeyID == "" {
|
||||
for keyID := range keys {
|
||||
activeKeyID = keyID
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, ok := keys[activeKeyID]; !ok {
|
||||
return nil, fmt.Errorf("active credential key id %q not found", activeKeyID)
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
activeKeyID: activeKeyID,
|
||||
keys: keys,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Encrypt(username, password string) ([]byte, error) {
|
||||
key := m.keys[m.activeKeyID]
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new gcm: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("nonce: %w", err)
|
||||
}
|
||||
|
||||
rawPayload, err := json.Marshal(payload{Username: username, Password: password})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nil, nonce, rawPayload, nil)
|
||||
|
||||
serialized := strings.Join([]string{
|
||||
prefix[:len(prefix)-1],
|
||||
m.activeKeyID,
|
||||
base64.StdEncoding.EncodeToString(nonce),
|
||||
base64.StdEncoding.EncodeToString(ciphertext),
|
||||
}, "|")
|
||||
return []byte(serialized), nil
|
||||
}
|
||||
|
||||
func (m *Manager) Decrypt(blob []byte) (string, string, error) {
|
||||
parts := strings.Split(string(blob), "|")
|
||||
if len(parts) != 4 || parts[0] != strings.TrimSuffix(prefix, "|") {
|
||||
return "", "", errors.New("credentials payload is not encrypted with supported format")
|
||||
}
|
||||
|
||||
keyID := parts[1]
|
||||
key, ok := m.keys[keyID]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unknown credential key id %q", keyID)
|
||||
}
|
||||
|
||||
nonce, err := base64.StdEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("decode nonce: %w", err)
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(parts[3])
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("decode ciphertext: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("new cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("new gcm: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("decrypt: %w", err)
|
||||
}
|
||||
|
||||
var p payload
|
||||
if err := json.Unmarshal(plaintext, &p); err != nil {
|
||||
return "", "", fmt.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
if p.Username == "" || p.Password == "" {
|
||||
return "", "", errors.New("decrypted credentials are incomplete")
|
||||
}
|
||||
return p.Username, p.Password, nil
|
||||
}
|
||||
|
||||
func IsEncrypted(blob []byte) bool {
|
||||
return strings.HasPrefix(string(blob), prefix)
|
||||
}
|
||||
|
||||
func parseKeys(spec string) (map[string][]byte, error) {
|
||||
entries := strings.Split(spec, ",")
|
||||
keys := make(map[string][]byte, len(entries))
|
||||
for _, entry := range entries {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
pair := strings.SplitN(entry, ":", 2)
|
||||
if len(pair) != 2 {
|
||||
return nil, fmt.Errorf("invalid key entry %q", entry)
|
||||
}
|
||||
keyID := strings.TrimSpace(pair[0])
|
||||
if keyID == "" {
|
||||
return nil, errors.New("key id cannot be empty")
|
||||
}
|
||||
rawKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(pair[1]))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode key %s: %w", keyID, err)
|
||||
}
|
||||
if l := len(rawKey); l != 16 && l != 24 && l != 32 {
|
||||
return nil, fmt.Errorf("key %s must be AES-128/192/256", keyID)
|
||||
}
|
||||
keys[keyID] = rawKey
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
30
internal/mail/credentials/manager_test.go
Normal file
30
internal/mail/credentials/manager_test.go
Normal file
@ -0,0 +1,30 @@
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
|
||||
manager, err := NewManager("v1:"+key, "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("new manager: %v", err)
|
||||
}
|
||||
|
||||
blob, err := manager.Encrypt("alice@example.com", "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
if !IsEncrypted(blob) {
|
||||
t.Fatalf("expected encrypted blob prefix")
|
||||
}
|
||||
|
||||
username, password, err := manager.Decrypt(blob)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
if username != "alice@example.com" || password != "secret" {
|
||||
t.Fatalf("unexpected decrypted credentials: %s/%s", username, password)
|
||||
}
|
||||
}
|
||||
93
internal/mail/imap/parse.go
Normal file
93
internal/mail/imap/parse.go
Normal file
@ -0,0 +1,93 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
imapTypes "github.com/emersion/go-imap/v2"
|
||||
)
|
||||
|
||||
type EmailAddress struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
func addressesToJSON(addrs []imapTypes.Address) []byte {
|
||||
result := make([]EmailAddress, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
result = append(result, EmailAddress{
|
||||
Name: a.Name,
|
||||
Address: a.Addr(),
|
||||
})
|
||||
}
|
||||
b, _ := json.Marshal(result)
|
||||
return b
|
||||
}
|
||||
|
||||
func parseBody(raw []byte) (text string, html string) {
|
||||
if len(raw) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return string(raw), ""
|
||||
}
|
||||
|
||||
contentType := msg.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
body, _ := io.ReadAll(msg.Body)
|
||||
return string(body), ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
return parseMultipart(msg.Body, params["boundary"])
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(msg.Body)
|
||||
if mediaType == "text/html" {
|
||||
return "", string(body)
|
||||
}
|
||||
return string(body), ""
|
||||
}
|
||||
|
||||
func parseMultipart(r io.Reader, boundary string) (text string, html string) {
|
||||
mr := multipart.NewReader(r, boundary)
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
partType := part.Header.Get("Content-Type")
|
||||
mediaType, params, _ := mime.ParseMediaType(partType)
|
||||
|
||||
switch {
|
||||
case mediaType == "text/plain":
|
||||
body, _ := io.ReadAll(part)
|
||||
text = string(body)
|
||||
case mediaType == "text/html":
|
||||
body, _ := io.ReadAll(part)
|
||||
html = string(body)
|
||||
case strings.HasPrefix(mediaType, "multipart/"):
|
||||
t, h := parseMultipart(part, params["boundary"])
|
||||
if text == "" {
|
||||
text = t
|
||||
}
|
||||
if html == "" {
|
||||
html = h
|
||||
}
|
||||
}
|
||||
}
|
||||
return text, html
|
||||
}
|
||||
277
internal/mail/imap/sync.go
Normal file
277
internal/mail/imap/sync.go
Normal file
@ -0,0 +1,277 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
||||
)
|
||||
|
||||
type SyncWorker struct {
|
||||
db *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
interval time.Duration
|
||||
credentials *credentials.Manager
|
||||
}
|
||||
|
||||
func NewSyncWorker(db *pgxpool.Pool, interval time.Duration, credManager *credentials.Manager) *SyncWorker {
|
||||
return &SyncWorker{
|
||||
db: db,
|
||||
logger: slog.Default().With("component", "imap-sync"),
|
||||
interval: interval,
|
||||
credentials: credManager,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SyncWorker) Start(ctx context.Context) {
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
w.logger.Info("imap sync worker started", "interval", w.interval)
|
||||
|
||||
// Initial sync
|
||||
w.syncAllAccounts(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
w.logger.Info("imap sync worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.syncAllAccounts(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SyncWorker) syncAllAccounts(ctx context.Context) {
|
||||
rows, err := w.db.Query(ctx, `
|
||||
SELECT id, imap_host, imap_port, imap_tls, credentials, sync_state
|
||||
FROM mail_accounts
|
||||
WHERE is_active = true
|
||||
`)
|
||||
if err != nil {
|
||||
w.logger.Error("failed to query accounts", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
accountID string
|
||||
host string
|
||||
port int
|
||||
useTLS bool
|
||||
creds []byte
|
||||
syncState []byte
|
||||
)
|
||||
if err := rows.Scan(&accountID, &host, &port, &useTLS, &creds, &syncState); err != nil {
|
||||
w.logger.Error("failed to scan account", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := w.syncAccount(ctx, accountID, host, port, useTLS, creds, syncState); err != nil {
|
||||
w.logger.Error("sync failed", "account_id", accountID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SyncWorker) syncAccount(ctx context.Context, accountID, host string, port int, useTLS bool, creds, syncState []byte) error {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
var client *imapclient.Client
|
||||
var err error
|
||||
|
||||
opts := &imapclient.Options{}
|
||||
if useTLS {
|
||||
client, err = imapclient.DialTLS(addr, opts)
|
||||
} else {
|
||||
client, err = imapclient.DialStartTLS(addr, opts)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
username, password, err := w.parseCredentials(creds)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt credentials: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Login(username, password).Wait(); err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
|
||||
// List mailboxes
|
||||
mailboxes, err := client.List("", "*", nil).Collect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("list: %w", err)
|
||||
}
|
||||
|
||||
for _, mbox := range mailboxes {
|
||||
if err := w.syncFolder(ctx, client, accountID, mbox.Mailbox); err != nil {
|
||||
w.logger.Error("folder sync failed", "account_id", accountID, "folder", mbox.Mailbox, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
_, err = w.db.Exec(ctx, `UPDATE mail_accounts SET last_sync_at = NOW() WHERE id = $1`, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *SyncWorker) syncFolder(ctx context.Context, client *imapclient.Client, accountID, folderName string) error {
|
||||
selectData, err := client.Select(folderName, nil).Wait()
|
||||
if err != nil {
|
||||
return fmt.Errorf("select %s: %w", folderName, err)
|
||||
}
|
||||
|
||||
// Upsert folder record
|
||||
var folderID string
|
||||
err = w.db.QueryRow(ctx, `
|
||||
INSERT INTO mail_folders (account_id, name, remote_name, uidvalidity, message_count)
|
||||
VALUES ($1, $2, $2, $3, $4)
|
||||
ON CONFLICT (account_id, remote_name) DO UPDATE
|
||||
SET uidvalidity = EXCLUDED.uidvalidity,
|
||||
message_count = EXCLUDED.message_count,
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
`, accountID, folderName, selectData.UIDValidity, selectData.NumMessages).Scan(&folderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert folder: %w", err)
|
||||
}
|
||||
|
||||
if selectData.NumMessages == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get highest UID we already have for this folder
|
||||
var lastUID uint32
|
||||
_ = w.db.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder_id = $1
|
||||
`, folderID).Scan(&lastUID)
|
||||
|
||||
// Fetch messages newer than our last UID
|
||||
seqSet := imap.UIDSet{}
|
||||
seqSet.AddRange(imap.UID(lastUID+1), imap.UID(0)) // lastUID+1 to *
|
||||
|
||||
fetchOpts := &imap.FetchOptions{
|
||||
UID: true,
|
||||
Flags: true,
|
||||
Envelope: true,
|
||||
BodySection: []*imap.FetchItemBodySection{{}},
|
||||
}
|
||||
|
||||
fetchCmd := client.Fetch(seqSet, fetchOpts)
|
||||
|
||||
for {
|
||||
msg := fetchCmd.Next()
|
||||
if msg == nil {
|
||||
break
|
||||
}
|
||||
if err := w.processMessage(ctx, msg, accountID, folderID); err != nil {
|
||||
w.logger.Error("process message failed", "folder", folderName, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return fetchCmd.Close()
|
||||
}
|
||||
|
||||
func (w *SyncWorker) processMessage(ctx context.Context, msg *imapclient.FetchMessageData, accountID, folderID string) error {
|
||||
var envelope *imap.Envelope
|
||||
var uid imap.UID
|
||||
var flags []imap.Flag
|
||||
var bodyContent []byte
|
||||
|
||||
for {
|
||||
item := msg.Next()
|
||||
if item == nil {
|
||||
break
|
||||
}
|
||||
switch data := item.(type) {
|
||||
case imapclient.FetchItemDataUID:
|
||||
uid = data.UID
|
||||
case imapclient.FetchItemDataFlags:
|
||||
flags = data.Flags
|
||||
case imapclient.FetchItemDataEnvelope:
|
||||
envelope = data.Envelope
|
||||
case imapclient.FetchItemDataBodySection:
|
||||
if data.Literal == nil {
|
||||
break
|
||||
}
|
||||
buf := make([]byte, 0, 4096)
|
||||
b := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := data.Literal.Read(b)
|
||||
buf = append(buf, b[:n]...)
|
||||
if readErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
bodyContent = buf
|
||||
}
|
||||
}
|
||||
|
||||
if envelope == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
flagStrs := make([]string, len(flags))
|
||||
for i, f := range flags {
|
||||
flagStrs[i] = string(f)
|
||||
}
|
||||
|
||||
fromAddr := addressesToJSON(envelope.From)
|
||||
toAddrs := addressesToJSON(envelope.To)
|
||||
ccAddrs := addressesToJSON(envelope.Cc)
|
||||
|
||||
bodyText, bodyHTML := parseBody(bodyContent)
|
||||
snippet := truncate(bodyText, 200)
|
||||
|
||||
_, err := w.db.Exec(ctx, `
|
||||
INSERT INTO messages (account_id, folder_id, uid, message_id, subject, from_addr, to_addrs, cc_addrs, date, snippet, body_text, body_html, flags, in_reply_to)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT (folder_id, uid) DO NOTHING
|
||||
`, accountID, folderID, uid, envelope.MessageID, envelope.Subject,
|
||||
fromAddr, toAddrs, ccAddrs, envelope.Date, snippet, bodyText, bodyHTML, flagStrs, strings.Join(envelope.InReplyTo, " "))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *SyncWorker) parseCredentials(creds []byte) (string, string, error) {
|
||||
if len(creds) == 0 {
|
||||
return "", "", errors.New("missing credentials")
|
||||
}
|
||||
if !credentials.IsEncrypted(creds) {
|
||||
return "", "", errors.New("plaintext credentials forbidden")
|
||||
}
|
||||
if w.credentials == nil {
|
||||
return "", "", errors.New("credential manager not configured")
|
||||
}
|
||||
return w.credentials.Decrypt(creds)
|
||||
}
|
||||
|
||||
func splitBytes(data []byte, sep byte) [][]byte {
|
||||
var parts [][]byte
|
||||
start := 0
|
||||
for i, b := range data {
|
||||
if b == sep {
|
||||
parts = append(parts, data[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
parts = append(parts, data[start:])
|
||||
return parts
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen]
|
||||
}
|
||||
188
internal/mail/rules/engine.go
Normal file
188
internal/mail/rules/engine.go
Normal file
@ -0,0 +1,188 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Engine struct {
|
||||
db *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewEngine(db *pgxpool.Pool) *Engine {
|
||||
return &Engine{
|
||||
db: db,
|
||||
logger: slog.Default().With("component", "rules-engine"),
|
||||
}
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Priority int `json:"priority"`
|
||||
Conditions []Condition `json:"conditions"`
|
||||
Actions []Action `json:"actions"`
|
||||
}
|
||||
|
||||
type Condition struct {
|
||||
Field string `json:"field"` // from, to, subject, body, has_attachment
|
||||
Operator string `json:"operator"` // contains, equals, starts_with, ends_with, matches
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
Type string `json:"type"` // label, move, archive, delete, mark_read, forward, webhook
|
||||
Value string `json:"value"` // label name, folder name, email, webhook_id
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
BodyText string `json:"body_text"`
|
||||
HasAttachments bool `json:"has_attachments"`
|
||||
}
|
||||
|
||||
func (e *Engine) Evaluate(ctx context.Context, userID string, msg *Message) error {
|
||||
rows, err := e.db.Query(ctx, `
|
||||
SELECT id, name, conditions, actions
|
||||
FROM mail_rules
|
||||
WHERE user_id = $1 AND is_active = true
|
||||
ORDER BY priority ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query rules: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
ruleID string
|
||||
name string
|
||||
condJSON []byte
|
||||
actJSON []byte
|
||||
)
|
||||
if err := rows.Scan(&ruleID, &name, &condJSON, &actJSON); err != nil {
|
||||
e.logger.Error("scan rule", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
var conditions []Condition
|
||||
var actions []Action
|
||||
json.Unmarshal(condJSON, &conditions)
|
||||
json.Unmarshal(actJSON, &actions)
|
||||
|
||||
if matchesAll(conditions, msg) {
|
||||
e.logger.Info("rule matched", "rule_id", ruleID, "rule_name", name, "message_id", msg.ID)
|
||||
for _, action := range actions {
|
||||
if err := e.executeAction(ctx, action, msg); err != nil {
|
||||
e.logger.Error("action failed", "rule_id", ruleID, "action", action.Type, "error", err)
|
||||
}
|
||||
}
|
||||
// Increment match count
|
||||
e.db.Exec(ctx, `UPDATE mail_rules SET match_count = match_count + 1 WHERE id = $1`, ruleID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func matchesAll(conditions []Condition, msg *Message) bool {
|
||||
for _, cond := range conditions {
|
||||
if !matchCondition(cond, msg) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchCondition(cond Condition, msg *Message) bool {
|
||||
var fieldValue string
|
||||
switch cond.Field {
|
||||
case "from":
|
||||
fieldValue = msg.From
|
||||
case "to":
|
||||
fieldValue = strings.Join(msg.To, ", ")
|
||||
case "subject":
|
||||
fieldValue = msg.Subject
|
||||
case "body":
|
||||
fieldValue = msg.BodyText
|
||||
case "has_attachment":
|
||||
if msg.HasAttachments {
|
||||
fieldValue = "true"
|
||||
} else {
|
||||
fieldValue = "false"
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
fieldLower := strings.ToLower(fieldValue)
|
||||
valueLower := strings.ToLower(cond.Value)
|
||||
|
||||
switch cond.Operator {
|
||||
case "contains":
|
||||
return strings.Contains(fieldLower, valueLower)
|
||||
case "equals":
|
||||
return fieldLower == valueLower
|
||||
case "starts_with":
|
||||
return strings.HasPrefix(fieldLower, valueLower)
|
||||
case "ends_with":
|
||||
return strings.HasSuffix(fieldLower, valueLower)
|
||||
case "not_contains":
|
||||
return !strings.Contains(fieldLower, valueLower)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) executeAction(ctx context.Context, action Action, msg *Message) error {
|
||||
switch action.Type {
|
||||
case "label":
|
||||
_, err := e.db.Exec(ctx, `
|
||||
UPDATE messages SET labels = array_append(labels, $1), updated_at = NOW()
|
||||
WHERE id = $2 AND NOT ($1 = ANY(labels))
|
||||
`, action.Value, msg.ID)
|
||||
return err
|
||||
case "move":
|
||||
_, err := e.db.Exec(ctx, `
|
||||
UPDATE messages SET folder_id = (
|
||||
SELECT id FROM mail_folders WHERE account_id = (
|
||||
SELECT account_id FROM messages WHERE id = $2
|
||||
) AND name = $1 LIMIT 1
|
||||
), updated_at = NOW()
|
||||
WHERE id = $2
|
||||
`, action.Value, msg.ID)
|
||||
return err
|
||||
case "archive":
|
||||
_, err := e.db.Exec(ctx, `
|
||||
UPDATE messages SET flags = array_append(flags, '\Archive'), updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, msg.ID)
|
||||
return err
|
||||
case "mark_read":
|
||||
_, err := e.db.Exec(ctx, `
|
||||
UPDATE messages SET flags = array_append(flags, '\Seen'), updated_at = NOW()
|
||||
WHERE id = $1 AND NOT ('\Seen' = ANY(flags))
|
||||
`, msg.ID)
|
||||
return err
|
||||
case "delete":
|
||||
_, err := e.db.Exec(ctx, `
|
||||
UPDATE messages SET flags = array_append(flags, '\Deleted'), updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, msg.ID)
|
||||
return err
|
||||
case "webhook":
|
||||
// Webhook execution is handled by the webhooks package
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown action type: %s", action.Type)
|
||||
}
|
||||
}
|
||||
155
internal/mail/smtp/outbox.go
Normal file
155
internal/mail/smtp/outbox.go
Normal file
@ -0,0 +1,155 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type OutboxProcessor struct {
|
||||
db *pgxpool.Pool
|
||||
sender *Sender
|
||||
logger *slog.Logger
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
func NewOutboxProcessor(db *pgxpool.Pool, sender *Sender, interval time.Duration) *OutboxProcessor {
|
||||
return &OutboxProcessor{
|
||||
db: db,
|
||||
sender: sender,
|
||||
logger: slog.Default().With("component", "outbox"),
|
||||
interval: interval,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OutboxProcessor) Start(ctx context.Context) {
|
||||
ticker := time.NewTicker(p.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
p.logger.Info("outbox processor started", "interval", p.interval)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
p.logger.Info("outbox processor stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.processQueue(ctx)
|
||||
p.processScheduled(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OutboxProcessor) processQueue(ctx context.Context) {
|
||||
rows, err := p.db.Query(ctx, `
|
||||
UPDATE outbox SET status = 'sending', updated_at = NOW()
|
||||
WHERE id IN (
|
||||
SELECT id FROM outbox
|
||||
WHERE status = 'queued'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 10
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id, account_id, to_addrs, cc_addrs, bcc_addrs, subject, body_text, body_html, in_reply_to, references_header
|
||||
`)
|
||||
if err != nil {
|
||||
p.logger.Error("failed to query outbox", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
id string
|
||||
accountID string
|
||||
toJSON []byte
|
||||
ccJSON []byte
|
||||
bccJSON []byte
|
||||
subject string
|
||||
bodyText string
|
||||
bodyHTML string
|
||||
inReplyTo string
|
||||
references []string
|
||||
)
|
||||
|
||||
if err := rows.Scan(&id, &accountID, &toJSON, &ccJSON, &bccJSON, &subject, &bodyText, &bodyHTML, &inReplyTo, &references); err != nil {
|
||||
p.logger.Error("scan outbox row", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
to := parseJSONAddresses(toJSON)
|
||||
cc := parseJSONAddresses(ccJSON)
|
||||
bcc := parseJSONAddresses(bccJSON)
|
||||
|
||||
// Get the from address
|
||||
var fromEmail string
|
||||
_ = p.db.QueryRow(ctx, `
|
||||
SELECT mi.email FROM mail_identities mi
|
||||
JOIN mail_accounts ma ON mi.account_id = ma.id
|
||||
WHERE ma.id = $1 AND mi.is_default = true
|
||||
LIMIT 1
|
||||
`, accountID).Scan(&fromEmail)
|
||||
|
||||
if fromEmail == "" {
|
||||
_ = p.db.QueryRow(ctx, `SELECT email FROM mail_accounts WHERE id = $1`, accountID).Scan(&fromEmail)
|
||||
}
|
||||
|
||||
req := &SendRequest{
|
||||
AccountID: accountID,
|
||||
From: fromEmail,
|
||||
To: to,
|
||||
Cc: cc,
|
||||
Bcc: bcc,
|
||||
Subject: subject,
|
||||
BodyText: bodyText,
|
||||
BodyHTML: bodyHTML,
|
||||
InReplyTo: inReplyTo,
|
||||
References: references,
|
||||
}
|
||||
|
||||
if err := p.sender.Send(ctx, req); err != nil {
|
||||
p.logger.Error("send failed", "outbox_id", id, "error", err)
|
||||
_, _ = p.db.Exec(ctx, `
|
||||
UPDATE outbox SET status = 'queued', retry_count = retry_count + 1, error = $2, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, id, err.Error())
|
||||
} else {
|
||||
_, _ = p.db.Exec(ctx, `
|
||||
UPDATE outbox SET status = 'sent', sent_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OutboxProcessor) processScheduled(ctx context.Context) {
|
||||
_, err := p.db.Exec(ctx, `
|
||||
UPDATE outbox SET status = 'queued', updated_at = NOW()
|
||||
WHERE status = 'queued' AND scheduled_at IS NOT NULL AND scheduled_at <= NOW()
|
||||
`)
|
||||
if err != nil {
|
||||
p.logger.Error("failed to process scheduled", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseJSONAddresses(data []byte) []string {
|
||||
var addrs []struct {
|
||||
Address string `json:"address"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &addrs); err != nil {
|
||||
// Try as plain string array
|
||||
var plain []string
|
||||
if err := json.Unmarshal(data, &plain); err == nil {
|
||||
return plain
|
||||
}
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
result = append(result, a.Address)
|
||||
}
|
||||
return result
|
||||
}
|
||||
137
internal/mail/smtp/sender.go
Normal file
137
internal/mail/smtp/sender.go
Normal file
@ -0,0 +1,137 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
|
||||
)
|
||||
|
||||
type Sender struct {
|
||||
db *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
credentials *credentials.Manager
|
||||
}
|
||||
|
||||
func NewSender(db *pgxpool.Pool, credManager *credentials.Manager) *Sender {
|
||||
return &Sender{
|
||||
db: db,
|
||||
logger: slog.Default().With("component", "smtp-sender"),
|
||||
credentials: credManager,
|
||||
}
|
||||
}
|
||||
|
||||
type SendRequest struct {
|
||||
AccountID string
|
||||
From string
|
||||
To []string
|
||||
Cc []string
|
||||
Bcc []string
|
||||
Subject string
|
||||
BodyText string
|
||||
BodyHTML string
|
||||
InReplyTo string
|
||||
References []string
|
||||
}
|
||||
|
||||
func (s *Sender) Send(ctx context.Context, req *SendRequest) error {
|
||||
var host string
|
||||
var port int
|
||||
var useTLS bool
|
||||
var creds []byte
|
||||
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT smtp_host, smtp_port, smtp_tls, credentials
|
||||
FROM mail_accounts WHERE id = $1
|
||||
`, req.AccountID).Scan(&host, &port, &useTLS, &creds)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query account: %w", err)
|
||||
}
|
||||
|
||||
username, password, err := s.parseCredentials(creds)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt credentials: %w", err)
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
msg := buildMessage(req)
|
||||
|
||||
allRecipients := make([]string, 0, len(req.To)+len(req.Cc)+len(req.Bcc))
|
||||
allRecipients = append(allRecipients, req.To...)
|
||||
allRecipients = append(allRecipients, req.Cc...)
|
||||
allRecipients = append(allRecipients, req.Bcc...)
|
||||
|
||||
auth := sasl.NewPlainClient("", username, password)
|
||||
|
||||
var sendErr error
|
||||
if useTLS {
|
||||
sendErr = gosmtp.SendMailTLS(addr, auth, req.From, allRecipients, strings.NewReader(msg))
|
||||
} else {
|
||||
sendErr = gosmtp.SendMail(addr, auth, req.From, allRecipients, strings.NewReader(msg))
|
||||
}
|
||||
|
||||
if sendErr != nil {
|
||||
return fmt.Errorf("send: %w", sendErr)
|
||||
}
|
||||
|
||||
s.logger.Info("email sent", "from", req.From, "to", req.To, "subject", req.Subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildMessage(req *SendRequest) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(fmt.Sprintf("From: %s\r\n", req.From))
|
||||
b.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(req.To, ", ")))
|
||||
if len(req.Cc) > 0 {
|
||||
b.WriteString(fmt.Sprintf("Cc: %s\r\n", strings.Join(req.Cc, ", ")))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Subject: %s\r\n", req.Subject))
|
||||
b.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().Format(time.RFC1123Z)))
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
|
||||
if req.InReplyTo != "" {
|
||||
b.WriteString(fmt.Sprintf("In-Reply-To: %s\r\n", req.InReplyTo))
|
||||
}
|
||||
if len(req.References) > 0 {
|
||||
b.WriteString(fmt.Sprintf("References: %s\r\n", strings.Join(req.References, " ")))
|
||||
}
|
||||
|
||||
if req.BodyHTML != "" {
|
||||
boundary := fmt.Sprintf("----=_Part_%d", time.Now().UnixNano())
|
||||
b.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
|
||||
b.WriteString(req.BodyText)
|
||||
b.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
|
||||
b.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n")
|
||||
b.WriteString(req.BodyHTML)
|
||||
b.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
|
||||
} else {
|
||||
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
|
||||
b.WriteString(req.BodyText)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *Sender) parseCredentials(creds []byte) (string, string, error) {
|
||||
if len(creds) == 0 {
|
||||
return "", "", errors.New("missing credentials")
|
||||
}
|
||||
if !credentials.IsEncrypted(creds) {
|
||||
return "", "", errors.New("plaintext credentials forbidden")
|
||||
}
|
||||
if s.credentials == nil {
|
||||
return "", "", errors.New("credential manager not configured")
|
||||
}
|
||||
return s.credentials.Decrypt(creds)
|
||||
}
|
||||
125
internal/mail/webhooks/executor.go
Normal file
125
internal/mail/webhooks/executor.go
Normal file
@ -0,0 +1,125 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Executor struct {
|
||||
db *pgxpool.Pool
|
||||
client *http.Client
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewExecutor(db *pgxpool.Pool) *Executor {
|
||||
return &Executor{
|
||||
db: db,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
logger: slog.Default().With("component", "webhooks"),
|
||||
}
|
||||
}
|
||||
|
||||
type MessageContext struct {
|
||||
SenderName string `json:"sender_name"`
|
||||
SenderEmail string `json:"sender_email"`
|
||||
Subject string `json:"subject"`
|
||||
BodyText string `json:"body_text"`
|
||||
BodyHTML string `json:"body_html"`
|
||||
Date string `json:"date"`
|
||||
Recipients string `json:"recipients"`
|
||||
HasAttachment bool `json:"has_attachment"`
|
||||
MessageID string `json:"message_id"`
|
||||
}
|
||||
|
||||
func (e *Executor) Execute(ctx context.Context, templateID string, msgCtx *MessageContext) error {
|
||||
var (
|
||||
url string
|
||||
method string
|
||||
headersJSON []byte
|
||||
bodyTemplate string
|
||||
)
|
||||
|
||||
err := e.db.QueryRow(ctx, `
|
||||
SELECT url, method, headers, body_template
|
||||
FROM webhook_templates
|
||||
WHERE id = $1 AND is_active = true
|
||||
`, templateID).Scan(&url, &method, &headersJSON, &bodyTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query template: %w", err)
|
||||
}
|
||||
|
||||
body := interpolate(bodyTemplate, msgCtx)
|
||||
|
||||
start := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
var headers map[string]string
|
||||
json.Unmarshal(headersJSON, &headers)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := e.client.Do(req)
|
||||
duration := time.Since(start).Milliseconds()
|
||||
|
||||
var statusCode int
|
||||
var responseBody string
|
||||
var execError string
|
||||
|
||||
if err != nil {
|
||||
execError = err.Error()
|
||||
} else {
|
||||
statusCode = resp.StatusCode
|
||||
respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
responseBody = string(respBytes)
|
||||
}
|
||||
|
||||
_, logErr := e.db.Exec(ctx, `
|
||||
INSERT INTO webhook_logs (template_id, message_id, status_code, response_body, error, duration_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, templateID, msgCtx.MessageID, statusCode, responseBody, execError, duration)
|
||||
if logErr != nil {
|
||||
e.logger.Error("failed to log webhook", "error", logErr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
if statusCode >= 400 {
|
||||
return fmt.Errorf("webhook returned %d", statusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func interpolate(template string, ctx *MessageContext) string {
|
||||
r := strings.NewReplacer(
|
||||
"$sender.name", ctx.SenderName,
|
||||
"$sender.email", ctx.SenderEmail,
|
||||
"$subject", ctx.Subject,
|
||||
"$body.textContent", ctx.BodyText,
|
||||
"$body.htmlContent", ctx.BodyHTML,
|
||||
"$date", ctx.Date,
|
||||
"$recipients.to", ctx.Recipients,
|
||||
"$message_id", ctx.MessageID,
|
||||
)
|
||||
return r.Replace(template)
|
||||
}
|
||||
104
internal/meet/meet.go
Normal file
104
internal/meet/meet.go
Normal file
@ -0,0 +1,104 @@
|
||||
package meet
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppID string
|
||||
AppSecret string
|
||||
Domain string
|
||||
}
|
||||
|
||||
type RoomToken struct {
|
||||
Token string `json:"token"`
|
||||
Room string `json:"room"`
|
||||
Domain string `json:"domain"`
|
||||
MeetURL string `json:"meet_url"`
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
IsMod bool `json:"is_moderator"`
|
||||
}
|
||||
|
||||
func NewConfig(appID, appSecret, domain string) *Config {
|
||||
return &Config{
|
||||
AppID: appID,
|
||||
AppSecret: appSecret,
|
||||
Domain: domain,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) GenerateToken(room string, user *UserInfo, ttl time.Duration) (*RoomToken, error) {
|
||||
now := time.Now()
|
||||
exp := now.Add(ttl)
|
||||
|
||||
header := map[string]string{
|
||||
"alg": "HS256",
|
||||
"typ": "JWT",
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"iss": c.AppID,
|
||||
"sub": "meet.jitsi",
|
||||
"aud": "ulti",
|
||||
"iat": now.Unix(),
|
||||
"exp": exp.Unix(),
|
||||
"room": room,
|
||||
"context": map[string]any{
|
||||
"user": map[string]any{
|
||||
"id": user.ID,
|
||||
"name": user.Name,
|
||||
"email": user.Email,
|
||||
"avatar": user.Avatar,
|
||||
"moderator": user.IsMod,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
token, err := signJWT(header, payload, c.AppSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &RoomToken{
|
||||
Token: token,
|
||||
Room: room,
|
||||
Domain: "meet.jitsi",
|
||||
MeetURL: fmt.Sprintf("https://%s/meet/%s?jwt=%s", c.Domain, room, token),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func signJWT(header map[string]string, payload map[string]any, secret string) (string, error) {
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
headerB64 := base64URLEncode(headerJSON)
|
||||
payloadB64 := base64URLEncode(payloadJSON)
|
||||
signingInput := headerB64 + "." + payloadB64
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(signingInput))
|
||||
signature := base64URLEncode(mac.Sum(nil))
|
||||
|
||||
return signingInput + "." + signature, nil
|
||||
}
|
||||
|
||||
func base64URLEncode(data []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
226
internal/nextcloud/calendar.go
Normal file
226
internal/nextcloud/calendar.go
Normal file
@ -0,0 +1,226 @@
|
||||
package nextcloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Calendar struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Color string `json:"color"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
UID string `json:"uid"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Location string `json:"location"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
AllDay bool `json:"all_day"`
|
||||
RawICS string `json:"raw_ics,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) ListCalendars(ctx context.Context, userID string) ([]Calendar, error) {
|
||||
path := fmt.Sprintf("/remote.php/dav/calendars/%s/", userID)
|
||||
body := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="urn:ietf:params:xml:ns:caldav" xmlns:apple="http://apple.com/ns/ical/">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<apple:calendar-color/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>`
|
||||
|
||||
resp, err := c.DoAsUser(ctx, "PROPFIND", path, strings.NewReader(body), userID, map[string]string{
|
||||
"Depth": "1",
|
||||
"Content-Type": "application/xml",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return parseCalendarList(resp.Body, path)
|
||||
}
|
||||
|
||||
func (c *Client) ListEvents(ctx context.Context, userID, calendarPath string, from, to time.Time) ([]Event, error) {
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<c:calendar-data/>
|
||||
</d:prop>
|
||||
<c:filter>
|
||||
<c:comp-filter name="VCALENDAR">
|
||||
<c:comp-filter name="VEVENT">
|
||||
<c:time-range start="%s" end="%s"/>
|
||||
</c:comp-filter>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>`, from.Format("20060102T150405Z"), to.Format("20060102T150405Z"))
|
||||
|
||||
resp, err := c.DoAsUser(ctx, "REPORT", calendarPath, strings.NewReader(body), userID, map[string]string{
|
||||
"Depth": "1",
|
||||
"Content-Type": "application/xml",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return parseEventList(resp.Body)
|
||||
}
|
||||
|
||||
func (c *Client) CreateEvent(ctx context.Context, userID, calendarPath string, event *Event) error {
|
||||
ics := buildICS(event)
|
||||
uid := event.UID
|
||||
if uid == "" {
|
||||
uid = fmt.Sprintf("%d@ulti", time.Now().UnixNano())
|
||||
}
|
||||
eventPath := fmt.Sprintf("%s%s.ics", calendarPath, uid)
|
||||
|
||||
resp, err := c.DoAsUser(ctx, "PUT", eventPath, strings.NewReader(ics), userID, map[string]string{
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 201 && resp.StatusCode != 204 {
|
||||
return fmt.Errorf("create event failed: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteEvent(ctx context.Context, userID, eventPath string) error {
|
||||
resp, err := c.DoAsUser(ctx, "DELETE", eventPath, nil, userID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 204 {
|
||||
return fmt.Errorf("delete event failed: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildICS(event *Event) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("BEGIN:VCALENDAR\r\n")
|
||||
b.WriteString("VERSION:2.0\r\n")
|
||||
b.WriteString("PRODID:-//Ulti Suite//EN\r\n")
|
||||
b.WriteString("BEGIN:VEVENT\r\n")
|
||||
if event.UID != "" {
|
||||
b.WriteString(fmt.Sprintf("UID:%s\r\n", event.UID))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", event.Summary))
|
||||
if event.Description != "" {
|
||||
b.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", event.Description))
|
||||
}
|
||||
if event.Location != "" {
|
||||
b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", event.Location))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", event.Start))
|
||||
b.WriteString(fmt.Sprintf("DTEND:%s\r\n", event.End))
|
||||
b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z")))
|
||||
b.WriteString("END:VEVENT\r\n")
|
||||
b.WriteString("END:VCALENDAR\r\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func parseCalendarList(body io.Reader, basePath string) ([]Calendar, error) {
|
||||
var ms multistatus
|
||||
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
calendars := make([]Calendar, 0)
|
||||
for _, r := range ms.Responses {
|
||||
if r.Href == basePath {
|
||||
continue
|
||||
}
|
||||
name := r.Propstat.Prop.DisplayName
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
calendars = append(calendars, Calendar{
|
||||
ID: strings.TrimSuffix(strings.TrimPrefix(r.Href, basePath), "/"),
|
||||
DisplayName: name,
|
||||
Color: r.Propstat.Prop.CalendarColor,
|
||||
Path: r.Href,
|
||||
})
|
||||
}
|
||||
return calendars, nil
|
||||
}
|
||||
|
||||
func parseEventList(body io.Reader) ([]Event, error) {
|
||||
var ms calMultistatus
|
||||
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
events := make([]Event, 0, len(ms.Responses))
|
||||
for _, r := range ms.Responses {
|
||||
ics := r.Propstat.Prop.CalendarData
|
||||
event := parseICS(ics)
|
||||
event.RawICS = ics
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func parseICS(ics string) Event {
|
||||
var e Event
|
||||
for _, line := range strings.Split(ics, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "UID:"):
|
||||
e.UID = strings.TrimPrefix(line, "UID:")
|
||||
case strings.HasPrefix(line, "SUMMARY:"):
|
||||
e.Summary = strings.TrimPrefix(line, "SUMMARY:")
|
||||
case strings.HasPrefix(line, "DESCRIPTION:"):
|
||||
e.Description = strings.TrimPrefix(line, "DESCRIPTION:")
|
||||
case strings.HasPrefix(line, "LOCATION:"):
|
||||
e.Location = strings.TrimPrefix(line, "LOCATION:")
|
||||
case strings.HasPrefix(line, "DTSTART"):
|
||||
e.Start = extractValue(line)
|
||||
case strings.HasPrefix(line, "DTEND"):
|
||||
e.End = extractValue(line)
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func extractValue(line string) string {
|
||||
if idx := strings.LastIndex(line, ":"); idx >= 0 {
|
||||
return line[idx+1:]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
type calMultistatus struct {
|
||||
XMLName xml.Name `xml:"multistatus"`
|
||||
Responses []calResponse `xml:"response"`
|
||||
}
|
||||
|
||||
type calResponse struct {
|
||||
Href string `xml:"href"`
|
||||
Propstat calPropstat `xml:"propstat"`
|
||||
}
|
||||
|
||||
type calPropstat struct {
|
||||
Prop calProp `xml:"prop"`
|
||||
}
|
||||
|
||||
type calProp struct {
|
||||
ETag string `xml:"getetag"`
|
||||
CalendarData string `xml:"calendar-data"`
|
||||
}
|
||||
64
internal/nextcloud/client.go
Normal file
64
internal/nextcloud/client.go
Normal file
@ -0,0 +1,64 @@
|
||||
package nextcloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
adminUser string
|
||||
adminPass string
|
||||
}
|
||||
|
||||
func NewClient(baseURL, adminUser, adminPass string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
adminUser: adminUser,
|
||||
adminPass: adminPass,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, headers map[string]string) (*http.Response, error) {
|
||||
url := c.baseURL + path
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.adminUser, c.adminPass)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
return c.httpClient.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) DoAsUser(ctx context.Context, method, path string, body io.Reader, userID string, headers map[string]string) (*http.Response, error) {
|
||||
url := c.baseURL + path
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(c.adminUser, c.adminPass)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
req.Header.Set("X-NC-User", userID)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
return c.httpClient.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) WebDAVPath(userID, path string) string {
|
||||
return fmt.Sprintf("/remote.php/dav/files/%s/%s", userID, path)
|
||||
}
|
||||
232
internal/nextcloud/contacts.go
Normal file
232
internal/nextcloud/contacts.go
Normal file
@ -0,0 +1,232 @@
|
||||
package nextcloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AddressBook struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type Contact struct {
|
||||
UID string `json:"uid"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Org string `json:"org"`
|
||||
RawVCard string `json:"raw_vcard,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) ListAddressBooks(ctx context.Context, userID string) ([]AddressBook, error) {
|
||||
path := fmt.Sprintf("/remote.php/dav/addressbooks/users/%s/", userID)
|
||||
body := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>`
|
||||
|
||||
resp, err := c.DoAsUser(ctx, "PROPFIND", path, strings.NewReader(body), userID, map[string]string{
|
||||
"Depth": "1",
|
||||
"Content-Type": "application/xml",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return parseAddressBookList(resp.Body, path)
|
||||
}
|
||||
|
||||
func (c *Client) ListContacts(ctx context.Context, userID, bookPath string) ([]Contact, error) {
|
||||
body := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<card:address-data/>
|
||||
</d:prop>
|
||||
</card:addressbook-query>`
|
||||
|
||||
resp, err := c.DoAsUser(ctx, "REPORT", bookPath, strings.NewReader(body), userID, map[string]string{
|
||||
"Depth": "1",
|
||||
"Content-Type": "application/xml",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return parseContactList(resp.Body)
|
||||
}
|
||||
|
||||
func (c *Client) CreateContact(ctx context.Context, userID, bookPath string, contact *Contact) error {
|
||||
vcard := buildVCard(contact)
|
||||
uid := contact.UID
|
||||
if uid == "" {
|
||||
uid = fmt.Sprintf("%d@ulti", time.Now().UnixNano())
|
||||
}
|
||||
contactPath := fmt.Sprintf("%s%s.vcf", bookPath, uid)
|
||||
|
||||
resp, err := c.DoAsUser(ctx, "PUT", contactPath, strings.NewReader(vcard), userID, map[string]string{
|
||||
"Content-Type": "text/vcard; charset=utf-8",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 201 && resp.StatusCode != 204 {
|
||||
return fmt.Errorf("create contact failed: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteContact(ctx context.Context, userID, contactPath string) error {
|
||||
resp, err := c.DoAsUser(ctx, "DELETE", contactPath, nil, userID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 204 {
|
||||
return fmt.Errorf("delete contact failed: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) SearchContacts(ctx context.Context, userID, bookPath, query string) ([]Contact, error) {
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<card:address-data/>
|
||||
</d:prop>
|
||||
<card:filter>
|
||||
<card:prop-filter name="FN">
|
||||
<card:text-match collation="i;unicode-casemap" match-type="contains">%s</card:text-match>
|
||||
</card:prop-filter>
|
||||
</card:filter>
|
||||
</card:addressbook-query>`, query)
|
||||
|
||||
resp, err := c.DoAsUser(ctx, "REPORT", bookPath, strings.NewReader(body), userID, map[string]string{
|
||||
"Depth": "1",
|
||||
"Content-Type": "application/xml",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return parseContactList(resp.Body)
|
||||
}
|
||||
|
||||
func buildVCard(contact *Contact) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("BEGIN:VCARD\r\n")
|
||||
b.WriteString("VERSION:3.0\r\n")
|
||||
if contact.UID != "" {
|
||||
b.WriteString(fmt.Sprintf("UID:%s\r\n", contact.UID))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("FN:%s\r\n", contact.FullName))
|
||||
if contact.Email != "" {
|
||||
b.WriteString(fmt.Sprintf("EMAIL;TYPE=INTERNET:%s\r\n", contact.Email))
|
||||
}
|
||||
if contact.Phone != "" {
|
||||
b.WriteString(fmt.Sprintf("TEL;TYPE=CELL:%s\r\n", contact.Phone))
|
||||
}
|
||||
if contact.Org != "" {
|
||||
b.WriteString(fmt.Sprintf("ORG:%s\r\n", contact.Org))
|
||||
}
|
||||
b.WriteString("END:VCARD\r\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func parseAddressBookList(body io.Reader, basePath string) ([]AddressBook, error) {
|
||||
var ms multistatus
|
||||
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
books := make([]AddressBook, 0)
|
||||
for _, r := range ms.Responses {
|
||||
if r.Href == basePath {
|
||||
continue
|
||||
}
|
||||
name := r.Propstat.Prop.DisplayName
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
books = append(books, AddressBook{
|
||||
ID: strings.TrimSuffix(strings.TrimPrefix(r.Href, basePath), "/"),
|
||||
DisplayName: name,
|
||||
Path: r.Href,
|
||||
})
|
||||
}
|
||||
return books, nil
|
||||
}
|
||||
|
||||
func parseContactList(body io.Reader) ([]Contact, error) {
|
||||
var ms cardMultistatus
|
||||
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contacts := make([]Contact, 0, len(ms.Responses))
|
||||
for _, r := range ms.Responses {
|
||||
vcard := r.Propstat.Prop.AddressData
|
||||
contact := parseVCard(vcard)
|
||||
contact.RawVCard = vcard
|
||||
contacts = append(contacts, contact)
|
||||
}
|
||||
return contacts, nil
|
||||
}
|
||||
|
||||
func parseVCard(vcard string) Contact {
|
||||
var c Contact
|
||||
for _, line := range strings.Split(vcard, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "UID:"):
|
||||
c.UID = strings.TrimPrefix(line, "UID:")
|
||||
case strings.HasPrefix(line, "FN:"):
|
||||
c.FullName = strings.TrimPrefix(line, "FN:")
|
||||
case strings.HasPrefix(line, "EMAIL"):
|
||||
if idx := strings.LastIndex(line, ":"); idx >= 0 {
|
||||
c.Email = line[idx+1:]
|
||||
}
|
||||
case strings.HasPrefix(line, "TEL"):
|
||||
if idx := strings.LastIndex(line, ":"); idx >= 0 {
|
||||
c.Phone = line[idx+1:]
|
||||
}
|
||||
case strings.HasPrefix(line, "ORG:"):
|
||||
c.Org = strings.TrimPrefix(line, "ORG:")
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
type cardMultistatus struct {
|
||||
XMLName xml.Name `xml:"multistatus"`
|
||||
Responses []cardResponse `xml:"response"`
|
||||
}
|
||||
|
||||
type cardResponse struct {
|
||||
Href string `xml:"href"`
|
||||
Propstat cardPropstat `xml:"propstat"`
|
||||
}
|
||||
|
||||
type cardPropstat struct {
|
||||
Prop cardProp `xml:"prop"`
|
||||
}
|
||||
|
||||
type cardProp struct {
|
||||
ETag string `xml:"getetag"`
|
||||
AddressData string `xml:"address-data"`
|
||||
}
|
||||
249
internal/nextcloud/drive.go
Normal file
249
internal/nextcloud/drive.go
Normal file
@ -0,0 +1,249 @@
|
||||
package nextcloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FileInfo struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "file" or "directory"
|
||||
Size int64 `json:"size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
LastModified string `json:"last_modified"`
|
||||
ETag string `json:"etag"`
|
||||
}
|
||||
|
||||
type ShareInfo struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
ShareType int `json:"share_type"`
|
||||
Permissions int `json:"permissions"`
|
||||
URL string `json:"url"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) ListFiles(ctx context.Context, userID, path string) ([]FileInfo, error) {
|
||||
davPath := c.WebDAVPath(userID, path)
|
||||
body := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:prop>
|
||||
<d:getlastmodified/>
|
||||
<d:getetag/>
|
||||
<d:getcontenttype/>
|
||||
<d:getcontentlength/>
|
||||
<d:resourcetype/>
|
||||
<oc:fileid/>
|
||||
<oc:size/>
|
||||
</d:prop>
|
||||
</d:propfind>`
|
||||
|
||||
resp, err := c.DoAsUser(ctx, "PROPFIND", davPath, strings.NewReader(body), userID, map[string]string{
|
||||
"Depth": "1",
|
||||
"Content-Type": "application/xml",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 207 {
|
||||
return nil, fmt.Errorf("propfind failed: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return parsePropfindResponse(resp.Body, davPath)
|
||||
}
|
||||
|
||||
func (c *Client) Upload(ctx context.Context, userID, path string, content io.Reader, contentType string) error {
|
||||
davPath := c.WebDAVPath(userID, path)
|
||||
headers := map[string]string{}
|
||||
if contentType != "" {
|
||||
headers["Content-Type"] = contentType
|
||||
}
|
||||
|
||||
resp, err := c.DoAsUser(ctx, "PUT", davPath, content, userID, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 201 && resp.StatusCode != 204 {
|
||||
return fmt.Errorf("upload failed: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Download(ctx context.Context, userID, path string) (io.ReadCloser, string, error) {
|
||||
davPath := c.WebDAVPath(userID, path)
|
||||
resp, err := c.DoAsUser(ctx, "GET", davPath, nil, userID, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
return nil, "", fmt.Errorf("download failed: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp.Body, resp.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateFolder(ctx context.Context, userID, path string) error {
|
||||
davPath := c.WebDAVPath(userID, path)
|
||||
resp, err := c.DoAsUser(ctx, "MKCOL", davPath, nil, userID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 201 {
|
||||
return fmt.Errorf("mkcol failed: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Delete(ctx context.Context, userID, path string) error {
|
||||
davPath := c.WebDAVPath(userID, path)
|
||||
resp, err := c.DoAsUser(ctx, "DELETE", davPath, nil, userID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 204 {
|
||||
return fmt.Errorf("delete failed: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Move(ctx context.Context, userID, srcPath, destPath string) error {
|
||||
davSrc := c.WebDAVPath(userID, srcPath)
|
||||
destURL := c.baseURL + c.WebDAVPath(userID, destPath)
|
||||
resp, err := c.DoAsUser(ctx, "MOVE", davSrc, nil, userID, map[string]string{
|
||||
"Destination": destURL,
|
||||
"Overwrite": "F",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 201 && resp.StatusCode != 204 {
|
||||
return fmt.Errorf("move failed: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateShare(ctx context.Context, userID, path string, shareType int, permissions int) (*ShareInfo, error) {
|
||||
formData := fmt.Sprintf("path=%s&shareType=%d&permissions=%d", path, shareType, permissions)
|
||||
resp, err := c.DoAsUser(ctx, "POST", "/ocs/v2.php/apps/files_sharing/api/v1/shares",
|
||||
strings.NewReader(formData), userID, map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var ocsResp struct {
|
||||
OCS struct {
|
||||
Data struct {
|
||||
ID int `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Permissions int `json:"permissions"`
|
||||
Expiration string `json:"expiration"`
|
||||
} `json:"data"`
|
||||
} `json:"ocs"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ShareInfo{
|
||||
ID: fmt.Sprintf("%d", ocsResp.OCS.Data.ID),
|
||||
Path: path,
|
||||
ShareType: shareType,
|
||||
Permissions: ocsResp.OCS.Data.Permissions,
|
||||
URL: ocsResp.OCS.Data.URL,
|
||||
ExpiresAt: ocsResp.OCS.Data.Expiration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PROPFIND XML response parsing
|
||||
type multistatus struct {
|
||||
XMLName xml.Name `xml:"multistatus"`
|
||||
Responses []response `xml:"response"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Href string `xml:"href"`
|
||||
Propstat propstat `xml:"propstat"`
|
||||
}
|
||||
|
||||
type propstat struct {
|
||||
Prop prop `xml:"prop"`
|
||||
Status string `xml:"status"`
|
||||
}
|
||||
|
||||
type prop struct {
|
||||
LastModified string `xml:"getlastmodified"`
|
||||
ETag string `xml:"getetag"`
|
||||
ContentType string `xml:"getcontenttype"`
|
||||
ContentLength int64 `xml:"getcontentlength"`
|
||||
ResourceType resourceType `xml:"resourcetype"`
|
||||
Size int64 `xml:"size"`
|
||||
DisplayName string `xml:"displayname"`
|
||||
CalendarColor string `xml:"calendar-color"`
|
||||
}
|
||||
|
||||
type resourceType struct {
|
||||
Collection *struct{} `xml:"collection"`
|
||||
}
|
||||
|
||||
func parsePropfindResponse(body io.Reader, basePath string) ([]FileInfo, error) {
|
||||
var ms multistatus
|
||||
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files := make([]FileInfo, 0, len(ms.Responses))
|
||||
for i, r := range ms.Responses {
|
||||
if i == 0 {
|
||||
continue // skip the folder itself
|
||||
}
|
||||
|
||||
name := r.Href
|
||||
if idx := strings.LastIndex(strings.TrimSuffix(name, "/"), "/"); idx >= 0 {
|
||||
name = name[idx+1:]
|
||||
}
|
||||
name = strings.TrimSuffix(name, "/")
|
||||
|
||||
fileType := "file"
|
||||
if r.Propstat.Prop.ResourceType.Collection != nil {
|
||||
fileType = "directory"
|
||||
}
|
||||
|
||||
size := r.Propstat.Prop.ContentLength
|
||||
if r.Propstat.Prop.Size > 0 {
|
||||
size = r.Propstat.Prop.Size
|
||||
}
|
||||
|
||||
files = append(files, FileInfo{
|
||||
Path: r.Href,
|
||||
Name: name,
|
||||
Type: fileType,
|
||||
Size: size,
|
||||
MimeType: r.Propstat.Prop.ContentType,
|
||||
LastModified: r.Propstat.Prop.LastModified,
|
||||
ETag: strings.Trim(r.Propstat.Prop.ETag, "\""),
|
||||
})
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
111
internal/permission/permission.go
Normal file
111
internal/permission/permission.go
Normal file
@ -0,0 +1,111 @@
|
||||
package permission
|
||||
|
||||
import "strings"
|
||||
|
||||
// Role is a platform-level role carried in OIDC groups.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleAdmin Role = "admin"
|
||||
RoleUser Role = "user"
|
||||
RoleService Role = "service"
|
||||
)
|
||||
|
||||
// Resource is a suite module protected by resource-scoped permissions.
|
||||
type Resource string
|
||||
|
||||
const (
|
||||
ResourceDrive Resource = "drive"
|
||||
ResourcePhotos Resource = "photos"
|
||||
ResourceContacts Resource = "contacts"
|
||||
ResourceCalendar Resource = "calendar"
|
||||
)
|
||||
|
||||
// Level is a resource permission with read < write < admin ordering.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
LevelRead Level = iota + 1
|
||||
LevelWrite
|
||||
LevelAdmin
|
||||
)
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelRead:
|
||||
return "read"
|
||||
case LevelWrite:
|
||||
return "write"
|
||||
case LevelAdmin:
|
||||
return "admin"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func ParseLevel(s string) (Level, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "read":
|
||||
return LevelRead, true
|
||||
case "write":
|
||||
return LevelWrite, true
|
||||
case "admin":
|
||||
return LevelAdmin, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func levelRank(l Level) int {
|
||||
return int(l)
|
||||
}
|
||||
|
||||
// HasRole reports whether groups grant the given platform role.
|
||||
func HasRole(groups []string, role Role) bool {
|
||||
want := string(role)
|
||||
for _, g := range groups {
|
||||
g = strings.ToLower(strings.TrimSpace(g))
|
||||
if g == want || g == "role:"+want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasAnyRole reports whether groups grant at least one of the roles.
|
||||
func HasAnyRole(groups []string, roles ...Role) bool {
|
||||
for _, role := range roles {
|
||||
if HasRole(groups, role) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasPermission reports whether groups grant at least the required level on resource.
|
||||
// Platform admins bypass resource checks. Higher levels satisfy lower ones.
|
||||
func HasPermission(groups []string, resource Resource, required Level) bool {
|
||||
if HasRole(groups, RoleAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
want := string(resource)
|
||||
max := Level(0)
|
||||
|
||||
for _, g := range groups {
|
||||
g = strings.ToLower(strings.TrimSpace(g))
|
||||
prefix := want + ":"
|
||||
if !strings.HasPrefix(g, prefix) {
|
||||
continue
|
||||
}
|
||||
level, ok := ParseLevel(strings.TrimPrefix(g, prefix))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if levelRank(level) > levelRank(max) {
|
||||
max = level
|
||||
}
|
||||
}
|
||||
|
||||
return levelRank(max) >= levelRank(required)
|
||||
}
|
||||
69
internal/permission/permission_test.go
Normal file
69
internal/permission/permission_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package permission
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasRole(t *testing.T) {
|
||||
tests := []struct {
|
||||
groups []string
|
||||
role Role
|
||||
want bool
|
||||
}{
|
||||
{[]string{"role:admin"}, RoleAdmin, true},
|
||||
{[]string{"admin"}, RoleAdmin, true},
|
||||
{[]string{"role:user"}, RoleAdmin, false},
|
||||
{[]string{" role:service "}, RoleService, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := HasRole(tt.groups, tt.role); got != tt.want {
|
||||
t.Fatalf("HasRole(%v, %q) = %v, want %v", tt.groups, tt.role, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPermissionHierarchy(t *testing.T) {
|
||||
groups := []string{"drive:write"}
|
||||
|
||||
if !HasPermission(groups, ResourceDrive, LevelRead) {
|
||||
t.Fatal("write should satisfy read")
|
||||
}
|
||||
if !HasPermission(groups, ResourceDrive, LevelWrite) {
|
||||
t.Fatal("write should satisfy write")
|
||||
}
|
||||
if HasPermission(groups, ResourceDrive, LevelAdmin) {
|
||||
t.Fatal("write should not satisfy admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPermissionAdminBypass(t *testing.T) {
|
||||
groups := []string{"role:admin"}
|
||||
|
||||
if !HasPermission(groups, ResourcePhotos, LevelAdmin) {
|
||||
t.Fatal("platform admin should bypass resource checks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPermissionResourceAdmin(t *testing.T) {
|
||||
groups := []string{"calendar:admin"}
|
||||
|
||||
if !HasPermission(groups, ResourceCalendar, LevelRead) {
|
||||
t.Fatal("resource admin should satisfy read")
|
||||
}
|
||||
if !HasPermission(groups, ResourceCalendar, LevelWrite) {
|
||||
t.Fatal("resource admin should satisfy write")
|
||||
}
|
||||
if !HasPermission(groups, ResourceCalendar, LevelAdmin) {
|
||||
t.Fatal("resource admin should satisfy admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPermissionIsolation(t *testing.T) {
|
||||
groups := []string{"contacts:read"}
|
||||
|
||||
if !HasPermission(groups, ResourceContacts, LevelRead) {
|
||||
t.Fatal("expected contacts read")
|
||||
}
|
||||
if HasPermission(groups, ResourceDrive, LevelRead) {
|
||||
t.Fatal("contacts permission must not grant drive access")
|
||||
}
|
||||
}
|
||||
120
internal/photos/client.go
Normal file
120
internal/photos/client.go
Normal file
@ -0,0 +1,120 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Asset struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
OriginalPath string `json:"originalPath"`
|
||||
OriginalName string `json:"originalFileName"`
|
||||
MimeType string `json:"originalMimeType"`
|
||||
FileSize int64 `json:"exifInfo.fileSizeInByte"`
|
||||
CreatedAt string `json:"fileCreatedAt"`
|
||||
IsFavorite bool `json:"isFavorite"`
|
||||
ThumbHash string `json:"thumbhash"`
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"albumName"`
|
||||
AssetCount int `json:"assetCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (c *Client) GetAssets(ctx context.Context, apiKey string, page, size int) ([]Asset, error) {
|
||||
url := fmt.Sprintf("%s/assets?page=%d&size=%d", c.baseURL, page, size)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var assets []Asset
|
||||
json.NewDecoder(resp.Body).Decode(&assets)
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetAlbums(ctx context.Context, apiKey string) ([]Album, error) {
|
||||
url := fmt.Sprintf("%s/albums", c.baseURL)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var albums []Album
|
||||
json.NewDecoder(resp.Body).Decode(&albums)
|
||||
return albums, nil
|
||||
}
|
||||
|
||||
func (c *Client) UploadAsset(ctx context.Context, apiKey string, body io.Reader, contentType string) (string, error) {
|
||||
url := fmt.Sprintf("%s/assets", c.baseURL)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, body)
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
return result.ID, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetAssetThumbnail(ctx context.Context, apiKey, assetID string) (io.ReadCloser, string, error) {
|
||||
url := fmt.Sprintf("%s/assets/%s/thumbnail", c.baseURL, assetID)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return resp.Body, resp.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteAsset(ctx context.Context, apiKey, assetID string) error {
|
||||
url := fmt.Sprintf("%s/assets/%s", c.baseURL, assetID)
|
||||
req, _ := http.NewRequestWithContext(ctx, "DELETE", url, nil)
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
97
internal/realtime/hub.go
Normal file
97
internal/realtime/hub.go
Normal file
@ -0,0 +1,97 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Payload any `json:"payload"`
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]map[*conn]struct{} // userID -> connections
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type conn struct {
|
||||
ws *websocket.Conn
|
||||
userID string
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[string]map[*conn]struct{}),
|
||||
logger: slog.Default().With("component", "ws-hub"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) HandleWS(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
http.Error(w, "missing user_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: []string{"*"},
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("websocket accept", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
c := &conn{ws: ws, userID: userID}
|
||||
h.register(c)
|
||||
defer h.unregister(c)
|
||||
|
||||
ctx := r.Context()
|
||||
for {
|
||||
_, _, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Broadcast(userID string, event Event) {
|
||||
h.mu.RLock()
|
||||
conns := h.clients[userID]
|
||||
h.mu.RUnlock()
|
||||
|
||||
for c := range conns {
|
||||
if err := wsjson.Write(context.Background(), c.ws, event); err != nil {
|
||||
h.logger.Error("ws write", "error", err, "user_id", userID)
|
||||
go h.unregister(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) register(c *conn) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.clients[c.userID] == nil {
|
||||
h.clients[c.userID] = make(map[*conn]struct{})
|
||||
}
|
||||
h.clients[c.userID][c] = struct{}{}
|
||||
h.logger.Info("ws connected", "user_id", c.userID)
|
||||
}
|
||||
|
||||
func (h *Hub) unregister(c *conn) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if conns, ok := h.clients[c.userID]; ok {
|
||||
delete(conns, c)
|
||||
if len(conns) == 0 {
|
||||
delete(h.clients, c.userID)
|
||||
}
|
||||
}
|
||||
c.ws.Close(websocket.StatusNormalClosure, "")
|
||||
}
|
||||
55
internal/search/handler.go
Normal file
55
internal/search/handler.go
Normal file
@ -0,0 +1,55 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandler(db *pgxpool.Pool) *Handler {
|
||||
return &Handler{
|
||||
svc: NewService(db),
|
||||
logger: slog.Default().With("component", "search"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
q := r.URL.Query().Get("q")
|
||||
if verr := validateSearchQuery(q); verr != nil {
|
||||
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidQueryParam, "invalid query parameters", verr.Details)
|
||||
return
|
||||
}
|
||||
|
||||
types := r.URL.Query().Get("types")
|
||||
if verr := validateSearchTypes(types); verr != nil {
|
||||
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidQueryParam, "invalid query parameters", verr.Details)
|
||||
return
|
||||
}
|
||||
|
||||
params, err := query.ParseListRequest(r)
|
||||
if err != nil {
|
||||
apivalidate.WriteQueryError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.Search(r.Context(), claims.Sub, q, types, params)
|
||||
if err != nil {
|
||||
h.logger.Error("search", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
180
internal/search/service.go
Normal file
180
internal/search/service.go
Normal file
@ -0,0 +1,180 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewService(db *pgxpool.Pool) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
Date any `json:"date,omitempty"`
|
||||
}
|
||||
|
||||
type SearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Results []Result `json:"results"`
|
||||
Count int `json:"count"`
|
||||
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) Search(ctx context.Context, externalID, q, typesRaw string, params query.ListParams) (SearchResponse, error) {
|
||||
typeList := parseTypes(typesRaw)
|
||||
tsQuery := toTSQuery(q)
|
||||
|
||||
allResults := make([]Result, 0)
|
||||
for _, t := range typeList {
|
||||
switch t {
|
||||
case "mail":
|
||||
mailResults, err := s.searchMail(ctx, externalID, tsQuery, params)
|
||||
if err != nil {
|
||||
return SearchResponse{}, err
|
||||
}
|
||||
allResults = append(allResults, mailResults...)
|
||||
case "contacts":
|
||||
contactResults, err := s.searchContacts(ctx, q, params)
|
||||
if err != nil {
|
||||
return SearchResponse{}, err
|
||||
}
|
||||
allResults = append(allResults, contactResults...)
|
||||
case "events":
|
||||
// Events are in Nextcloud CalDAV, not in PG - skip for now.
|
||||
}
|
||||
}
|
||||
|
||||
total := int64(len(allResults))
|
||||
page, _ := paginateResults(allResults, params.Offset(), params.Limit())
|
||||
|
||||
return SearchResponse{
|
||||
Query: q,
|
||||
Results: page,
|
||||
Count: len(page),
|
||||
Pagination: params.Meta(&total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseTypes(typesRaw string) []string {
|
||||
if strings.TrimSpace(typesRaw) == "" {
|
||||
return []string{"mail", "contacts", "events"}
|
||||
}
|
||||
parts := strings.Split(typesRaw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func paginateResults(results []Result, offset, limit int) ([]Result, int64) {
|
||||
total := int64(len(results))
|
||||
if offset >= len(results) {
|
||||
return []Result{}, total
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(results) {
|
||||
end = len(results)
|
||||
}
|
||||
return results[offset:end], total
|
||||
}
|
||||
|
||||
func (s *Service) searchMail(ctx context.Context, externalID, tsQuery string, params query.ListParams) ([]Result, error) {
|
||||
limit := params.Offset() + params.Limit()
|
||||
if limit < 20 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT m.id, m.subject, m.snippet, m.date,
|
||||
ts_rank(m.search_vector, to_tsquery('simple', $1)) as rank
|
||||
FROM messages m
|
||||
JOIN mail_accounts ma ON m.account_id = ma.id
|
||||
WHERE ma.user_id = (SELECT id FROM users WHERE external_id = $2)
|
||||
AND m.search_vector @@ to_tsquery('simple', $1)
|
||||
ORDER BY rank DESC
|
||||
LIMIT $3
|
||||
`, tsQuery, externalID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
results := make([]Result, 0)
|
||||
for rows.Next() {
|
||||
var id, subject, snippet string
|
||||
var date any
|
||||
var rank float64
|
||||
if err := rows.Scan(&id, &subject, &snippet, &date, &rank); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, Result{
|
||||
Type: "mail",
|
||||
ID: id,
|
||||
Title: subject,
|
||||
Snippet: snippet,
|
||||
Date: date,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) searchContacts(ctx context.Context, queryText string, params query.ListParams) ([]Result, error) {
|
||||
limit := params.Offset() + params.Limit()
|
||||
if limit < 10 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, name, email FROM users
|
||||
WHERE (name ILIKE '%' || $1 || '%' OR email ILIKE '%' || $1 || '%')
|
||||
LIMIT $2
|
||||
`, queryText, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
results := make([]Result, 0)
|
||||
for rows.Next() {
|
||||
var id, name, email string
|
||||
if err := rows.Scan(&id, &name, &email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, Result{
|
||||
Type: "contact",
|
||||
ID: id,
|
||||
Title: name,
|
||||
Snippet: email,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func toTSQuery(input string) string {
|
||||
words := strings.Fields(input)
|
||||
for i, w := range words {
|
||||
words[i] = w + ":*"
|
||||
}
|
||||
return strings.Join(words, " & ")
|
||||
}
|
||||
56
internal/search/validate.go
Normal file
56
internal/search/validate.go
Normal file
@ -0,0 +1,56 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
)
|
||||
|
||||
const (
|
||||
minSearchQueryLen = 1
|
||||
maxSearchQueryLen = 500
|
||||
)
|
||||
|
||||
var allowedSearchTypes = map[string]struct{}{
|
||||
"mail": {},
|
||||
"contacts": {},
|
||||
"events": {},
|
||||
}
|
||||
|
||||
func validateSearchQuery(q string) *apivalidate.ValidationError {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "q", Message: "required",
|
||||
})
|
||||
}
|
||||
if len(q) < minSearchQueryLen {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "q", Message: "too short",
|
||||
})
|
||||
}
|
||||
if len(q) > maxSearchQueryLen {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "q", Message: "too long",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSearchTypes(typesRaw string) *apivalidate.ValidationError {
|
||||
if strings.TrimSpace(typesRaw) == "" {
|
||||
return nil
|
||||
}
|
||||
for _, part := range strings.Split(typesRaw, ",") {
|
||||
t := strings.TrimSpace(part)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowedSearchTypes[t]; !ok {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "types", Message: "invalid value: " + t,
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
25
internal/secrets/provider.go
Normal file
25
internal/secrets/provider.go
Normal file
@ -0,0 +1,25 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Env resolves secret from KEY or KEY_FILE.
|
||||
// KEY_FILE enables runtime secret injection via mounted files.
|
||||
func Env(key string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
|
||||
filePath := os.Getenv(key + "_FILE")
|
||||
if filePath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(content))
|
||||
}
|
||||
52
internal/securityaudit/logger.go
Normal file
52
internal/securityaudit/logger.go
Normal file
@ -0,0 +1,52 @@
|
||||
package securityaudit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionLogin = "login"
|
||||
ActionTokenRejected = "token_rejected"
|
||||
ActionAdminAction = "admin_action"
|
||||
ActionCriticalDeletion = "critical_deletion"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
db *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewLogger(db *pgxpool.Pool) *Logger {
|
||||
return &Logger{
|
||||
db: db,
|
||||
logger: slog.Default().With("component", "security-audit"),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Log(ctx context.Context, actor, action string, details map[string]any) {
|
||||
if l == nil || l.db == nil {
|
||||
return
|
||||
}
|
||||
if actor == "" {
|
||||
actor = "system"
|
||||
}
|
||||
if details == nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
|
||||
detailsJSON, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
l.logger.Error("marshal audit details", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := l.db.Exec(ctx, `
|
||||
INSERT INTO audit_logs (actor, action, details) VALUES ($1, $2, $3)
|
||||
`, actor, action, detailsJSON); err != nil {
|
||||
l.logger.Error("insert audit log", "error", err, "actor", actor, "action", action)
|
||||
}
|
||||
}
|
||||
4
migrations/000001_init.down.sql
Normal file
4
migrations/000001_init.down.sql
Normal file
@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS settings;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP EXTENSION IF EXISTS "pgcrypto";
|
||||
DROP EXTENSION IF EXISTS "uuid-ossp";
|
||||
19
migrations/000001_init.up.sql
Normal file
19
migrations/000001_init.up.sql
Normal file
@ -0,0 +1,19 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
external_id TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE settings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
preferences JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
11
migrations/000002_mail.down.sql
Normal file
11
migrations/000002_mail.down.sql
Normal file
@ -0,0 +1,11 @@
|
||||
DROP TRIGGER IF EXISTS messages_search_trigger ON messages;
|
||||
DROP FUNCTION IF EXISTS messages_search_vector_update();
|
||||
DROP TABLE IF EXISTS webhook_logs;
|
||||
DROP TABLE IF EXISTS webhook_templates;
|
||||
DROP TABLE IF EXISTS outbox;
|
||||
DROP TABLE IF EXISTS mail_rules;
|
||||
DROP TABLE IF EXISTS attachments;
|
||||
DROP TABLE IF EXISTS messages;
|
||||
DROP TABLE IF EXISTS mail_folders;
|
||||
DROP TABLE IF EXISTS mail_identities;
|
||||
DROP TABLE IF EXISTS mail_accounts;
|
||||
194
migrations/000002_mail.up.sql
Normal file
194
migrations/000002_mail.up.sql
Normal file
@ -0,0 +1,194 @@
|
||||
CREATE TABLE mail_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'imap',
|
||||
imap_host TEXT NOT NULL DEFAULT '',
|
||||
imap_port INT NOT NULL DEFAULT 993,
|
||||
imap_tls BOOLEAN NOT NULL DEFAULT true,
|
||||
smtp_host TEXT NOT NULL DEFAULT '',
|
||||
smtp_port INT NOT NULL DEFAULT 587,
|
||||
smtp_tls BOOLEAN NOT NULL DEFAULT true,
|
||||
credentials BYTEA,
|
||||
last_sync_at TIMESTAMPTZ,
|
||||
sync_state JSONB NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mail_accounts_user ON mail_accounts(user_id);
|
||||
|
||||
CREATE TABLE mail_identities (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
account_id UUID NOT NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||
signature_html TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mail_identities_account ON mail_identities(account_id);
|
||||
|
||||
CREATE TABLE mail_folders (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
account_id UUID NOT NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
remote_name TEXT NOT NULL,
|
||||
folder_type TEXT NOT NULL DEFAULT 'custom',
|
||||
uidvalidity BIGINT NOT NULL DEFAULT 0,
|
||||
highest_modseq BIGINT NOT NULL DEFAULT 0,
|
||||
message_count INT NOT NULL DEFAULT 0,
|
||||
unread_count INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(account_id, remote_name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mail_folders_account ON mail_folders(account_id);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
account_id UUID NOT NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
|
||||
folder_id UUID NOT NULL REFERENCES mail_folders(id) ON DELETE CASCADE,
|
||||
message_id TEXT NOT NULL DEFAULT '',
|
||||
thread_id UUID,
|
||||
uid BIGINT NOT NULL DEFAULT 0,
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
from_addr JSONB NOT NULL DEFAULT '[]',
|
||||
to_addrs JSONB NOT NULL DEFAULT '[]',
|
||||
cc_addrs JSONB NOT NULL DEFAULT '[]',
|
||||
bcc_addrs JSONB NOT NULL DEFAULT '[]',
|
||||
reply_to JSONB NOT NULL DEFAULT '[]',
|
||||
date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
snippet TEXT NOT NULL DEFAULT '',
|
||||
body_text TEXT NOT NULL DEFAULT '',
|
||||
body_html TEXT NOT NULL DEFAULT '',
|
||||
flags TEXT[] NOT NULL DEFAULT '{}',
|
||||
labels TEXT[] NOT NULL DEFAULT '{}',
|
||||
has_attachments BOOLEAN NOT NULL DEFAULT false,
|
||||
raw_size INT NOT NULL DEFAULT 0,
|
||||
in_reply_to TEXT NOT NULL DEFAULT '',
|
||||
references_header TEXT[] NOT NULL DEFAULT '{}',
|
||||
search_vector tsvector,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_messages_account_folder ON messages(account_id, folder_id);
|
||||
CREATE INDEX idx_messages_thread ON messages(thread_id);
|
||||
CREATE INDEX idx_messages_date ON messages(date DESC);
|
||||
CREATE INDEX idx_messages_message_id ON messages(message_id);
|
||||
CREATE INDEX idx_messages_flags ON messages USING GIN(flags);
|
||||
CREATE INDEX idx_messages_labels ON messages USING GIN(labels);
|
||||
CREATE INDEX idx_messages_search ON messages USING GIN(search_vector);
|
||||
CREATE UNIQUE INDEX idx_messages_uid ON messages(folder_id, uid);
|
||||
|
||||
-- Auto-update search_vector on insert/update
|
||||
CREATE FUNCTION messages_search_vector_update() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('simple', COALESCE(NEW.subject, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', COALESCE(NEW.from_addr::text, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', COALESCE(NEW.to_addrs::text, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', COALESCE(NEW.body_text, '')), 'C');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER messages_search_trigger
|
||||
BEFORE INSERT OR UPDATE OF subject, from_addr, to_addrs, body_text
|
||||
ON messages
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION messages_search_vector_update();
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size BIGINT NOT NULL DEFAULT 0,
|
||||
s3_bucket TEXT NOT NULL DEFAULT 'mail-attachments',
|
||||
s3_key TEXT NOT NULL DEFAULT '',
|
||||
content_id TEXT NOT NULL DEFAULT '',
|
||||
is_inline BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_attachments_message ON attachments(message_id);
|
||||
|
||||
CREATE TABLE mail_rules (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
account_id UUID REFERENCES mail_accounts(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
conditions JSONB NOT NULL DEFAULT '[]',
|
||||
actions JSONB NOT NULL DEFAULT '[]',
|
||||
llm_config JSONB,
|
||||
match_count BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mail_rules_user ON mail_rules(user_id);
|
||||
CREATE INDEX idx_mail_rules_priority ON mail_rules(user_id, priority);
|
||||
|
||||
CREATE TABLE webhook_templates (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
method TEXT NOT NULL DEFAULT 'POST',
|
||||
headers JSONB NOT NULL DEFAULT '{}',
|
||||
body_template TEXT NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_webhook_templates_user ON webhook_templates(user_id);
|
||||
|
||||
CREATE TABLE webhook_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
template_id UUID NOT NULL REFERENCES webhook_templates(id) ON DELETE CASCADE,
|
||||
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
status_code INT,
|
||||
response_body TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INT NOT NULL DEFAULT 0,
|
||||
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_webhook_logs_template ON webhook_logs(template_id);
|
||||
CREATE INDEX idx_webhook_logs_executed ON webhook_logs(executed_at DESC);
|
||||
|
||||
CREATE TABLE outbox (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
account_id UUID NOT NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
|
||||
identity_id UUID REFERENCES mail_identities(id) ON DELETE SET NULL,
|
||||
to_addrs JSONB NOT NULL DEFAULT '[]',
|
||||
cc_addrs JSONB NOT NULL DEFAULT '[]',
|
||||
bcc_addrs JSONB NOT NULL DEFAULT '[]',
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
body_text TEXT NOT NULL DEFAULT '',
|
||||
body_html TEXT NOT NULL DEFAULT '',
|
||||
in_reply_to TEXT NOT NULL DEFAULT '',
|
||||
references_header TEXT[] NOT NULL DEFAULT '{}',
|
||||
attachments JSONB NOT NULL DEFAULT '[]',
|
||||
scheduled_at TIMESTAMPTZ,
|
||||
sent_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_outbox_user ON outbox(user_id);
|
||||
CREATE INDEX idx_outbox_status ON outbox(status) WHERE status IN ('queued', 'sending');
|
||||
CREATE INDEX idx_outbox_scheduled ON outbox(scheduled_at) WHERE scheduled_at IS NOT NULL AND status = 'queued';
|
||||
1
migrations/000003_admin.down.sql
Normal file
1
migrations/000003_admin.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS audit_logs;
|
||||
11
migrations/000003_admin.up.sql
Normal file
11
migrations/000003_admin.up.sql
Normal file
@ -0,0 +1,11 @@
|
||||
CREATE TABLE audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_logs_actor ON audit_logs(actor);
|
||||
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
|
||||
CREATE INDEX idx_audit_logs_created ON audit_logs(created_at DESC);
|
||||
2
migrations/000004_mail_credentials_encryption.down.sql
Normal file
2
migrations/000004_mail_credentials_encryption.down.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE mail_accounts
|
||||
DROP CONSTRAINT IF EXISTS mail_accounts_credentials_encrypted_chk;
|
||||
6
migrations/000004_mail_credentials_encryption.up.sql
Normal file
6
migrations/000004_mail_credentials_encryption.up.sql
Normal file
@ -0,0 +1,6 @@
|
||||
ALTER TABLE mail_accounts
|
||||
ADD CONSTRAINT mail_accounts_credentials_encrypted_chk
|
||||
CHECK (
|
||||
credentials IS NULL
|
||||
OR substring(credentials from 1 for 5) = 'UMC1|'::bytea
|
||||
) NOT VALID;
|
||||
30
migrations/000005_data_integrity.down.sql
Normal file
30
migrations/000005_data_integrity.down.sql
Normal file
@ -0,0 +1,30 @@
|
||||
ALTER TABLE outbox
|
||||
DROP CONSTRAINT IF EXISTS outbox_retry_count_nonnegative_chk;
|
||||
|
||||
ALTER TABLE webhook_logs
|
||||
DROP CONSTRAINT IF EXISTS webhook_logs_duration_nonnegative_chk;
|
||||
|
||||
ALTER TABLE messages
|
||||
DROP CONSTRAINT IF EXISTS messages_raw_size_nonnegative_chk;
|
||||
|
||||
ALTER TABLE attachments
|
||||
DROP CONSTRAINT IF EXISTS attachments_size_nonnegative_chk;
|
||||
|
||||
ALTER TABLE mail_rules
|
||||
DROP CONSTRAINT IF EXISTS mail_rules_account_user_fk;
|
||||
|
||||
ALTER TABLE outbox
|
||||
DROP CONSTRAINT IF EXISTS outbox_account_user_fk;
|
||||
|
||||
ALTER TABLE messages
|
||||
DROP CONSTRAINT IF EXISTS messages_folder_account_fk;
|
||||
|
||||
DROP INDEX IF EXISTS uq_mail_folders_id_account;
|
||||
DROP INDEX IF EXISTS uq_mail_accounts_id_user;
|
||||
DROP INDEX IF EXISTS idx_outbox_account_status;
|
||||
DROP INDEX IF EXISTS idx_outbox_status_created;
|
||||
DROP INDEX IF EXISTS idx_webhook_templates_user_active;
|
||||
DROP INDEX IF EXISTS idx_mail_rules_user_active_priority;
|
||||
DROP INDEX IF EXISTS idx_messages_folder_date;
|
||||
DROP INDEX IF EXISTS idx_messages_account_date;
|
||||
DROP INDEX IF EXISTS idx_mail_accounts_user_created;
|
||||
63
migrations/000005_data_integrity.up.sql
Normal file
63
migrations/000005_data_integrity.up.sql
Normal file
@ -0,0 +1,63 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_accounts_user_created
|
||||
ON mail_accounts(user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_account_date
|
||||
ON messages(account_id, date DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_folder_date
|
||||
ON messages(folder_id, date DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_rules_user_active_priority
|
||||
ON mail_rules(user_id, is_active, priority);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_templates_user_active
|
||||
ON webhook_templates(user_id, is_active);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_status_created
|
||||
ON outbox(status, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_account_status
|
||||
ON outbox(account_id, status);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_mail_accounts_id_user
|
||||
ON mail_accounts(id, user_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_mail_folders_id_account
|
||||
ON mail_folders(id, account_id);
|
||||
|
||||
ALTER TABLE messages
|
||||
ADD CONSTRAINT messages_folder_account_fk
|
||||
FOREIGN KEY (folder_id, account_id)
|
||||
REFERENCES mail_folders(id, account_id)
|
||||
ON DELETE CASCADE
|
||||
NOT VALID;
|
||||
|
||||
ALTER TABLE outbox
|
||||
ADD CONSTRAINT outbox_account_user_fk
|
||||
FOREIGN KEY (account_id, user_id)
|
||||
REFERENCES mail_accounts(id, user_id)
|
||||
ON DELETE CASCADE
|
||||
NOT VALID;
|
||||
|
||||
ALTER TABLE mail_rules
|
||||
ADD CONSTRAINT mail_rules_account_user_fk
|
||||
FOREIGN KEY (account_id, user_id)
|
||||
REFERENCES mail_accounts(id, user_id)
|
||||
ON DELETE CASCADE
|
||||
NOT VALID;
|
||||
|
||||
ALTER TABLE attachments
|
||||
ADD CONSTRAINT attachments_size_nonnegative_chk
|
||||
CHECK (size >= 0) NOT VALID;
|
||||
|
||||
ALTER TABLE messages
|
||||
ADD CONSTRAINT messages_raw_size_nonnegative_chk
|
||||
CHECK (raw_size >= 0) NOT VALID;
|
||||
|
||||
ALTER TABLE webhook_logs
|
||||
ADD CONSTRAINT webhook_logs_duration_nonnegative_chk
|
||||
CHECK (duration_ms >= 0) NOT VALID;
|
||||
|
||||
ALTER TABLE outbox
|
||||
ADD CONSTRAINT outbox_retry_count_nonnegative_chk
|
||||
CHECK (retry_count >= 0) NOT VALID;
|
||||
56
project-plan/README.md
Normal file
56
project-plan/README.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Ulti Suite — Plan projet
|
||||
|
||||
Alternative souveraine et open-source à Google Suite. Chaque service est conçu pour être aussi fonctionnel et compatible avec les outils Google existants, en s'appuyant sur des technologies open-source éprouvées quand un acteur fiable existe déjà (ex: Nextcloud pour le stockage).
|
||||
|
||||
## Principes directeurs
|
||||
|
||||
- **Parité fonctionnelle** avec Google Suite, puis dépassement sur les features avancées (IA, webhooks, automatisations)
|
||||
- **Compatibilité** — import/export natif des formats Google, migration progressive sans friction
|
||||
- **Souveraineté** — hébergeable en propre, données sous contrôle de l'utilisateur
|
||||
- **UX uniforme** — même expérience web, desktop (Tauri) et mobile
|
||||
- **Interopérabilité interne** — tous les services communiquent entre eux nativement
|
||||
- **Open-source first** — s'appuyer sur des briques existantes fiables plutôt que tout réinventer
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Équivalent Google | Fichier plan | Statut |
|
||||
|---------|-------------------|--------------|--------|
|
||||
| Ultimail | Gmail | [ultimail.md](ultimail.md) | Partiel (backend avancé, incomplet) |
|
||||
| Contacts | Google Contacts | [contacts.md](contacts.md) | Partiel (API proxy CardDAV) |
|
||||
| Agenda | Google Calendar | [agenda.md](agenda.md) | Partiel (API proxy CalDAV) |
|
||||
| Ultidrive | Google Drive | [ultidrive.md](ultidrive.md) | Partiel (API proxy WebDAV/OCS) |
|
||||
| Ultimeet | Google Meet | [ultimeet.md](ultimeet.md) | Partiel (JWT Jitsi minimal) |
|
||||
| Administration | Google Admin | [administration.md](administration.md) | Partiel (squelette API) |
|
||||
| Ultiphotos | Google Photos | [ultiphotos.md](ultiphotos.md) | Partiel (proxy Immich minimal) |
|
||||
| Ultimaps | Google Maps | [ultimaps.md](ultimaps.md) | Non commencé |
|
||||
|
||||
## Architecture commune
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Clients (web / Tauri / mobile) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ API Gateway / Auth unifiée │
|
||||
├──────┬──────┬──────┬──────┬──────┬──────────┤
|
||||
│ Mail │Drive │Meet │Agenda│Photos│ ... │
|
||||
│ │ │ │ │ │ │
|
||||
├──────┴──────┴──────┴──────┴──────┴──────────┤
|
||||
│ Services partagés │
|
||||
│ ├─ Auth & comptes (SSO, 2FA, OIDC) │
|
||||
│ ├─ Contacts (carnet unifié) │
|
||||
│ ├─ Notifications (push, mail, webhooks) │
|
||||
│ ├─ Recherche transversale │
|
||||
│ └─ Administration & quotas │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Stockage │
|
||||
│ ├─ PostgreSQL (métadonnées, config, auth) │
|
||||
│ ├─ Object storage (fichiers, médias) │
|
||||
│ └─ Cache (Redis) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Exécution
|
||||
|
||||
- Checklist opérationnelle complète: [checklist-execution.md](checklist-execution.md)
|
||||
- Roadmap officielle 3 phases: [roadmap-3-phases.md](roadmap-3-phases.md)
|
||||
- Definition of Done (backend/frontend/sécurité/tests/observabilité): [definition-of-done.md](definition-of-done.md)
|
||||
82
project-plan/administration.md
Normal file
82
project-plan/administration.md
Normal file
@ -0,0 +1,82 @@
|
||||
# Administration
|
||||
|
||||
**Équivalent** : Google Admin Console
|
||||
**Statut** : Partiel (squelette API)
|
||||
|
||||
---
|
||||
|
||||
## Résumé
|
||||
|
||||
Console d'administration centralisée pour gérer utilisateurs, organisations, quotas, sécurité et politiques de tous les services Ulti Suite.
|
||||
|
||||
## État d'implémentation réel (mai 2026)
|
||||
|
||||
### Déjà implémenté
|
||||
|
||||
- API backend montée sous `/api/v1/admin`.
|
||||
- Endpoints list/get users, set quota, delete user, list audit, stats globales.
|
||||
- Table `audit_logs` et logs de base disponibles.
|
||||
|
||||
### Partiel / incomplet
|
||||
|
||||
- RBAC admin strict non finalisé.
|
||||
- Gestion utilisateurs limitée (pas de create/invite/disable/reactivate).
|
||||
- Quotas essentiellement orientés mail, pas multi-service complet.
|
||||
|
||||
### Non commencé
|
||||
|
||||
- Groupes, unités organisationnelles et politiques avancées.
|
||||
- Provisioning SCIM.
|
||||
- Gouvernance sécurité complète (sessions actives, revocation, politiques avancées).
|
||||
|
||||
## Points de différenciation vs Google Admin
|
||||
|
||||
- Interface unifiée pour tous les services (pas de consoles séparées)
|
||||
- Politiques granulaires par groupe/utilisateur/service
|
||||
- Audit log complet avec export et alertes
|
||||
- Auto-hébergeable avec support multi-tenant
|
||||
- API admin complète (automatisation de la gestion)
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Utilisateurs & organisations
|
||||
- [ ] CRUD utilisateurs
|
||||
- [ ] Groupes et unités organisationnelles
|
||||
- [ ] Rôles et permissions (RBAC)
|
||||
- [ ] SSO (SAML, OIDC)
|
||||
- [ ] 2FA obligatoire / optionnel par groupe
|
||||
- [ ] Provisioning SCIM
|
||||
|
||||
### Quotas & stockage
|
||||
- [ ] Quotas par utilisateur / groupe / service
|
||||
- [ ] Dashboard utilisation (mail, drive, meet)
|
||||
- [ ] Alertes de dépassement
|
||||
- [ ] Politiques de rétention
|
||||
|
||||
### Sécurité
|
||||
- [ ] Audit log (qui a fait quoi, quand)
|
||||
- [ ] Alertes sécurité (connexions suspectes, brute force)
|
||||
- [ ] Sessions actives et révocation
|
||||
- [ ] Politiques de mot de passe
|
||||
- [ ] IP whitelist / blacklist
|
||||
|
||||
### Services
|
||||
- [ ] Activation / désactivation de services par groupe
|
||||
- [ ] Configuration globale par service
|
||||
- [ ] Domaines mail et DNS (SPF, DKIM, DMARC)
|
||||
- [ ] Gestion des tokens API et webhooks
|
||||
- [ ] Monitoring et health checks
|
||||
|
||||
### Multi-tenant
|
||||
- [ ] Organisations isolées
|
||||
- [ ] Branding personnalisé par organisation
|
||||
- [ ] Facturation et plans
|
||||
|
||||
## Briques technologiques envisagées
|
||||
|
||||
| Besoin | Option open-source |
|
||||
|--------|--------------------|
|
||||
| Auth / SSO | Keycloak, Authentik, Zitadel |
|
||||
| RBAC | Casbin, OPA |
|
||||
| Audit | Temporal, custom event store |
|
||||
| Monitoring | Prometheus + Grafana |
|
||||
138
project-plan/agenda.md
Normal file
138
project-plan/agenda.md
Normal file
@ -0,0 +1,138 @@
|
||||
# Agenda
|
||||
|
||||
**Équivalent** : Google Calendar
|
||||
**Statut** : Partiel (API proxy CalDAV)
|
||||
|
||||
---
|
||||
|
||||
## Résumé
|
||||
|
||||
Calendrier reproduisant rigoureusement le comportement et l'interface de Google Calendar, potentiellement comme frontend pour la solution CalDAV de Nextcloud.
|
||||
|
||||
## État d'implémentation réel (mai 2026)
|
||||
|
||||
### Déjà implémenté
|
||||
|
||||
- API backend montée sous `/api/v1/calendar` (si Nextcloud activé).
|
||||
- Endpoints list calendriers, list événements, create événement, delete événement.
|
||||
- Client CalDAV Nextcloud intégré côté serveur.
|
||||
|
||||
### Partiel / incomplet
|
||||
|
||||
- Pas d'update événement avec gestion ETag/If-Match.
|
||||
- Pas de free/busy ni gestion complète invitations/réponses participants.
|
||||
- Recherche globale events pas encore branchée.
|
||||
|
||||
### Non commencé
|
||||
|
||||
- Automatisations événements (webhooks complets, actions programmées).
|
||||
- Expérience agenda frontend de production branchée backend réel.
|
||||
- Intégration avancée meeting/ressources/salles.
|
||||
|
||||
## Approche technique
|
||||
|
||||
Même logique qu'Ultidrive : on reproduit l'UX Google Calendar à l'identique, mais le backend calendrier peut être délégué.
|
||||
|
||||
### Option "frontend Nextcloud"
|
||||
|
||||
L'approche la plus simple : Agenda = **frontend custom pour le backend CalDAV de Nextcloud** :
|
||||
- Nextcloud Calendar gère le stockage, la sync CalDAV, les invitations, la récurrence
|
||||
- Agenda = interface identique à Google Calendar (même stack que le reste de la suite)
|
||||
- Avantage : interopérable avec tout client CalDAV standard (Thunderbird, Apple Calendar, etc.)
|
||||
|
||||
### Backend alternatif
|
||||
|
||||
Si besoin de découpler de Nextcloud :
|
||||
- Serveur CalDAV dédié (Radicale, Baikal, sabre/dav)
|
||||
- Même API CalDAV, juste un backend différent
|
||||
|
||||
## Points de différenciation vs Google Calendar
|
||||
|
||||
- Interface identique à Google Calendar (migration sans friction)
|
||||
- CalDAV natif (interopérable avec tout client standard)
|
||||
- Auto-hébergeable, données sous contrôle
|
||||
- Créneaux de disponibilité partagés publiquement (style Calendly intégré)
|
||||
- Automatisations à la création/modification d'événements (webhooks, notifications custom)
|
||||
- Planification assistée par IA (suggestion de créneaux optimaux selon habitudes)
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Core
|
||||
- [ ] Événements (titre, description, lieu, participants, récurrence)
|
||||
- [ ] Multi-agendas (perso, pro, partagés)
|
||||
- [ ] Vues jour / semaine / mois / planning
|
||||
- [ ] Invitations et réponses (accepter, refuser, peut-être)
|
||||
- [ ] Rappels et notifications
|
||||
- [ ] Fuseaux horaires
|
||||
|
||||
### Collaboration
|
||||
- [ ] Agendas partagés (lecture / écriture / admin)
|
||||
- [ ] Créneaux de disponibilité publics (booking)
|
||||
- [ ] Recherche de créneaux libres entre participants
|
||||
- [ ] Salles et ressources
|
||||
|
||||
### Intégration suite
|
||||
- [ ] Événements depuis les mails (invitations .ics dans Ultimail)
|
||||
- [ ] Création automatique de lien Ultimeet
|
||||
- [ ] Pièces jointes depuis Ultidrive
|
||||
- [ ] Contacts comme participants (autocomplétion)
|
||||
|
||||
### Automatisations
|
||||
- [ ] Webhooks à la création/modif/suppression d'événements
|
||||
- [ ] Actions programmées (rappels, envoi de docs avant réunion)
|
||||
|
||||
## Intégration Nextcloud — étude technique
|
||||
|
||||
### APIs CalDAV disponibles (confirmé)
|
||||
|
||||
Nextcloud utilise sabre/dav comme serveur CalDAV. Les endpoints :
|
||||
|
||||
| Opération | Méthode | Endpoint |
|
||||
|-----------|---------|----------|
|
||||
| Lister calendriers | PROPFIND | `/remote.php/dav/calendars/{user}/` |
|
||||
| Lister événements | REPORT (calendar-query) | `/remote.php/dav/calendars/{user}/{calendar}/` |
|
||||
| Créer événement | PUT | `.../calendars/{user}/{calendar}/{uid}.ics` |
|
||||
| Modifier événement | PUT | Même (avec If-Match etag) |
|
||||
| Supprimer événement | DELETE | Même |
|
||||
| Sync incrémentale | REPORT (sync-collection) | Via sync-token |
|
||||
| Événements à venir | GET | `/ocs/v2.php/apps/dav/api/v1/events/upcoming` |
|
||||
| Free/busy | POST | `/remote.php/dav/calendars/{user}/outbox/` |
|
||||
|
||||
### Fonctionnement
|
||||
|
||||
- Les événements sont stockés au format iCalendar (.ics)
|
||||
- Support natif : récurrence (RRULE), invitations (ATTENDEE + iMIP), rappels (VALARM)
|
||||
- Sync-token pour synchronisation incrémentale efficace (le client ne recharge que les changements)
|
||||
- Partage de calendriers via OCS ou CalDAV ACL
|
||||
|
||||
### Avantages de cette approche
|
||||
|
||||
1. **Interopérable immédiatement** — tout client CalDAV (Thunderbird, Apple Calendar, DAVx5 sur Android) fonctionne avec le même backend
|
||||
2. **Pas de logique calendrier à recoder** — récurrence, fuseaux horaires, invitations : tout est géré par sabre/dav
|
||||
3. **Notifications serveur** — Nextcloud push notifications pour les rappels même si le frontend n'est pas ouvert
|
||||
4. **Même instance** que celle utilisée pour Ultidrive → un seul backend à maintenir
|
||||
|
||||
### Architecture cible
|
||||
|
||||
```
|
||||
┌──────────────┐ CalDAV (REPORT, PUT, DELETE) ┌──────────────┐
|
||||
│ Agenda │─────────────────────────────────────▶│ Nextcloud │
|
||||
│ (frontend) │ OCS (upcoming events, sharing) │ (PHP-FPM) │
|
||||
└──────────────┘ └──────────────┘
|
||||
│
|
||||
Même instance que
|
||||
celle d'Ultidrive
|
||||
```
|
||||
|
||||
Le frontend Agenda parse les réponses CalDAV (XML/iCalendar) et les affiche dans une UI identique à Google Calendar. Librairie JS recommandée pour parser iCalendar : `ical.js` ou `tsdav` (client CalDAV TypeScript).
|
||||
|
||||
## Briques technologiques envisagées
|
||||
|
||||
| Besoin | Option retenue | Alternatives |
|
||||
|--------|----------------|--------------|
|
||||
| Backend CalDAV | Nextcloud sabre/dav (même instance qu'Ultidrive) | Radicale, Baikal |
|
||||
| Client CalDAV (JS) | tsdav (TypeScript) | ical.js + fetch custom |
|
||||
| Parsing iCalendar | ical.js | — |
|
||||
| Récurrence | rrule.js (expansion côté client) | — |
|
||||
| Auth | Partagée via Ulti Suite (OIDC → NC) | — |
|
||||
| Notifications/rappels | NC push + Service Worker | — |
|
||||
292
project-plan/checklist-execution.md
Normal file
292
project-plan/checklist-execution.md
Normal file
@ -0,0 +1,292 @@
|
||||
# Ulti Suite — Checklist d'exécution complète
|
||||
|
||||
Objectif: transformer état actuel (partiellement implémenté) vers produit fonctionnel bout en bout.
|
||||
|
||||
## 0) Gouvernance & alignement docs
|
||||
|
||||
- [x] Mettre à jour statuts dans `project-plan/README.md` selon réalité code (ne plus marquer "Planifié" ce qui existe déjà en API).
|
||||
- [x] Séparer clairement, dans chaque `.md`, ce qui est **déjà implémenté**, **partiel**, **non commencé**.
|
||||
- [x] Définir roadmap officielle en 3 phases: MVP fonctionnel, hardening, différenciation IA/automations.
|
||||
- [x] Ajouter "Definition of Done" par service (backend, frontend, sécurité, tests, observabilité).
|
||||
|
||||
### Owners nominaux (pilotage)
|
||||
|
||||
| Stream | Owner nominal | Backup |
|
||||
|---|---|---|
|
||||
| Produit / priorisation | `@PO` | `@Lead` |
|
||||
| Backend core mail | `@BE-Core` | `@BE-Platform` |
|
||||
| Intégrations suite (NC/Jitsi/Immich) | `@BE-Integrations` | `@BE-Core` |
|
||||
| Frontend web | `@FE-Lead` | `@FE-App` |
|
||||
| Sécurité | `@Security` | `@BE-Platform` |
|
||||
| Infra / DevOps / Observabilité | `@DevOps` | `@SRE` |
|
||||
| QA / tests e2e | `@QA` | `@FE-App` |
|
||||
|
||||
|
||||
## 1) Fondations transverses (obligatoire avant features)
|
||||
|
||||
### Auth, identité, permissions
|
||||
|
||||
- [x] Imposer auth OIDC sur toutes routes privées, sans mode silencieux en prod.
|
||||
- [x] Créer middleware de rôles (admin/user/service) et l'appliquer sur `/api/v1/admin`.
|
||||
- [x] Mapper comptes Ulti ↔ comptes mail externes avec règles ownership strictes.
|
||||
- [x] Ajouter journal d'audit sécurité (login, token rejeté, action admin, suppression critique).
|
||||
- [x] Définir modèle permissions commun (read/write/admin) réutilisable Drive/Photos/Contacts/Agenda.
|
||||
|
||||
### Sécurité secrets & credentials
|
||||
|
||||
- [x] Chiffrer credentials IMAP/SMTP au repos (AES-GCM + key rotation planifiée).
|
||||
- [x] Interdire stockage plaintext secrets dans DB.
|
||||
- [x] Centraliser gestion secrets `.env`/runtime pour dev/prod.
|
||||
- [x] Ajouter politique rotation secrets (OIDC, SMTP, webhook shared secrets).
|
||||
- [x] Ajouter validation stricte inputs (URL webhooks, payload size, MIME, etc.).
|
||||
|
||||
### Qualité backend
|
||||
|
||||
- [x] Ajouter couche service claire entre handlers HTTP et accès DB.
|
||||
- [x] Uniformiser erreurs API (code, message, details, trace id).
|
||||
- [x] Ajouter validations payload complètes sur tous endpoints.
|
||||
- [x] Ajouter pagination/filtrage standard partout (contacts, events, files, assets, messages).
|
||||
- [x] Ajouter migrations DB manquantes pour besoins réels (indexes, contraintes d'intégrité, FK manquantes).
|
||||
|
||||
### Observabilité & exploitation
|
||||
|
||||
- [ ] Ajouter request-id/correlation-id dans logs.
|
||||
- [ ] Ajouter métriques Prometheus (latence, erreurs, jobs sync, queue outbox, webhook success rate).
|
||||
- [ ] Ajouter health checks détaillés (DB, Redis/KeyDB, Nextcloud, Immich, Jitsi).
|
||||
- [ ] Ajouter dashboard Grafana baseline.
|
||||
- [ ] Définir alerting (mail sync bloquée, outbox bloquée, erreurs 5xx anormales).
|
||||
|
||||
### Tests & CI
|
||||
|
||||
- [ ] Couvrir endpoints critiques par tests d'intégration.
|
||||
- [ ] Ajouter tests worker IMAP/smtp outbox/rules/webhooks.
|
||||
- [ ] Ajouter tests de migration DB en CI.
|
||||
- [ ] Ajouter tests e2e frontend sur parcours clés (lire mail, envoyer, planifier, rechercher).
|
||||
- [ ] Bloquer merge si tests critiques échouent.
|
||||
|
||||
## 2) Backend monolithe (`ulti-backend`)
|
||||
|
||||
### 2.1 API Mail (Ultimail backend)
|
||||
|
||||
#### Correctifs prioritaires
|
||||
|
||||
- [ ] Corriger logique outbox "scheduled" vs "queued" pour envoi planifié.
|
||||
- [ ] Vérifier ownership sur `UpdateLabels`, `UpdateFlags`, `DeleteMessage`, `UpdateRule`, `DeleteRule`, `DeleteWebhook`.
|
||||
- [ ] Corriger flux création utilisateur manquant (external_id OIDC absent -> échec sous-requêtes user_id).
|
||||
- [ ] Ajouter gestion robuste erreurs SQL (`rows.Scan`, `Exec` result check, `rows.Err`).
|
||||
- [ ] Corriger cohérence threading (`thread_id`, `references`, `in_reply_to`).
|
||||
|
||||
#### Implémentation manquante
|
||||
|
||||
- [ ] Endpoint brouillons (create/update/delete/list).
|
||||
- [ ] Endpoint pièces jointes (upload/download/inline/cid mapping).
|
||||
- [ ] Endpoint dossiers/labels (CRUD + mapping IMAP flags/folders).
|
||||
- [ ] Endpoint recherche avancée (filtres expéditeur, date, attachment, label, account).
|
||||
- [ ] Endpoint identities (alias/from/reply-to/signature par compte).
|
||||
|
||||
#### Hardening
|
||||
|
||||
- [ ] Limiter taille body/attachments.
|
||||
- [ ] Sanitizer HTML côté lecture.
|
||||
- [ ] Protection anti-abus envoi (rate limit, retry backoff, circuit breaker SMTP).
|
||||
- [ ] Idempotency key sur envoi.
|
||||
|
||||
### 2.2 Sync IMAP & pipeline mail
|
||||
|
||||
- [ ] Persister état sync incrémental fiable (UIDVALIDITY, MODSEQ, last seen UID).
|
||||
- [ ] Support suppressions/updates IMAP, pas seulement insert nouveaux messages.
|
||||
- [ ] Gérer dossiers spéciaux (Sent/Drafts/Trash/Archive/Spam) cross-provider.
|
||||
- [ ] Extraire et stocker attachments vers object storage.
|
||||
- [ ] Lancer rules engine à réception et tracer résultats.
|
||||
- [ ] Déclencher webhooks selon règles matchées.
|
||||
- [ ] Publier events realtime WS après sync.
|
||||
|
||||
### 2.3 SMTP / Outbox / Scheduling
|
||||
|
||||
- [ ] Normaliser statuts outbox (`draft`, `queued`, `scheduled`, `sending`, `sent`, `failed`, `cancelled`).
|
||||
- [ ] Implémenter retries exponentiels + dead-letter strategy.
|
||||
- [ ] Implémenter "send now", "reschedule", "cancel scheduled".
|
||||
- [ ] Écrire message envoyé dans dossier Sent (sync cohérente UI).
|
||||
- [ ] Gérer inline attachments MIME multipart mixed/alternative.
|
||||
|
||||
### 2.4 Rules & Webhooks
|
||||
|
||||
- [ ] Câbler réellement `rules.Engine` dans pipeline réception.
|
||||
- [ ] Câbler réellement `webhooks.Executor` depuis actions de règles.
|
||||
- [ ] Ajouter simulation/test endpoint "run rule on sample message".
|
||||
- [ ] Ajouter templates webhook versionnés + preview rendu variables.
|
||||
- [ ] Ajouter signatures webhook (HMAC) + retry + backoff + DLQ.
|
||||
- [ ] Ajouter observabilité des exécutions (latence, erreur, payload tronqué).
|
||||
|
||||
### 2.5 Realtime (`/ws`)
|
||||
|
||||
- [ ] Remplacer `user_id` query param non sûr par auth token WS.
|
||||
- [ ] Ajouter événements typés (mail.created, mail.updated, outbox.updated, contact.updated...).
|
||||
- [ ] Ajouter heartbeat/ping/pong.
|
||||
- [ ] Gérer reconnexion client + rattrapage delta.
|
||||
- [ ] Limiter connexions par user/session.
|
||||
|
||||
### 2.6 Search
|
||||
|
||||
- [ ] Finaliser recherche events (Agenda) et contacts (CardDAV), pas fallback users local.
|
||||
- [ ] Ajouter recherche multi-index (mail+contacts+files+events) avec score unifié.
|
||||
- [ ] Ajouter snippets contextuels et highlighting.
|
||||
- [ ] Ajouter filtres type/date/account.
|
||||
- [ ] Préparer option Meilisearch/Typesense activable.
|
||||
|
||||
### 2.7 Modules suite (Drive/Calendar/Contacts/Meet/Photos/Admin)
|
||||
|
||||
#### Drive
|
||||
|
||||
- [ ] Ajouter upload chunked gros fichiers.
|
||||
- [ ] Ajouter rename/copy/list trash/recent/starred.
|
||||
- [ ] Ajouter ACL simplifiées (owner/editor/viewer) mappées correctement.
|
||||
- [ ] Ajouter quotas et erreurs métier propres.
|
||||
|
||||
#### Calendar
|
||||
|
||||
- [ ] Ajouter update événement + gestion ETag/If-Match.
|
||||
- [ ] Ajouter invitations/réponses participants.
|
||||
- [ ] Ajouter free/busy endpoint.
|
||||
- [ ] Ajouter création lien Meet auto depuis event.
|
||||
|
||||
#### Contacts
|
||||
|
||||
- [ ] Ajouter update contact + ETag.
|
||||
- [ ] Ajouter sync incrémentale (sync-token).
|
||||
- [ ] Ajouter fusion doublons serveur.
|
||||
- [ ] Ajouter endpoints enrichissement interactions mail/réunions/fichiers.
|
||||
|
||||
#### Meet
|
||||
|
||||
- [ ] Valider claims JWT Jitsi (iss/sub/aud/domain) selon déploiement réel.
|
||||
- [ ] Ajouter logique droits modérateur (owner room, policy).
|
||||
- [ ] Ajouter enregistrement + stockage Drive.
|
||||
- [ ] Ajouter métadonnées sessions (participants, durée, logs).
|
||||
|
||||
#### Photos
|
||||
|
||||
- [ ] Corriger mapping API Immich (champs JSON exacts, erreurs HTTP).
|
||||
- [ ] Ajouter pagination/tri/filtres robustes.
|
||||
- [ ] Ajouter albums CRUD complets.
|
||||
- [ ] Ajouter liaison stockage/quota avec Ultidrive.
|
||||
|
||||
#### Admin
|
||||
|
||||
- [ ] Ajouter RBAC admin strict.
|
||||
- [ ] Ajouter CRUD utilisateurs complet (create/invite/disable/reactivate).
|
||||
- [ ] Ajouter quotas multi-service (mail/drive/photos).
|
||||
- [ ] Ajouter pages stats exploitables + export audit.
|
||||
|
||||
## 3) Frontend web (`gmail-interface-clone`)
|
||||
|
||||
### 3.1 Fondation data/API
|
||||
|
||||
- [ ] Remplacer `lib/email-data.ts` par source serveur paginée.
|
||||
- [ ] Remplacer `lib/contacts/mock-data.ts` et `mock-accounts` par API backend.
|
||||
- [ ] Créer client API unique (baseURL, auth bearer, gestion erreurs, timeout, retry).
|
||||
- [ ] Ajouter couche query/cache (SWR ou TanStack Query).
|
||||
- [ ] Définir contrat TypeScript partagé avec backend (DTOs versionnés).
|
||||
|
||||
### 3.2 Mail UI
|
||||
|
||||
- [ ] Brancher liste mails, lecture, flags, labels, delete sur `/api/v1/mail`.
|
||||
- [ ] Brancher compose/send/scheduled sur outbox backend.
|
||||
- [ ] Brancher recherche UI sur backend search (fallback local supprimé).
|
||||
- [ ] Brancher multi-comptes réels (accounts API + switch account effectif).
|
||||
- [ ] Brancher settings (pas page placeholder) avec persistance backend.
|
||||
|
||||
### 3.3 Contacts UI
|
||||
|
||||
- [ ] Brancher store contacts sur API contacts.
|
||||
- [ ] Brancher création/édition/suppression/fusion sur backend.
|
||||
- [ ] Brancher import/export vCard/CSV.
|
||||
- [ ] Ajouter état synchronisation + résolution conflits.
|
||||
|
||||
### 3.4 Agenda/Drive/Meet/Photos UI
|
||||
|
||||
- [ ] Créer shell pages front pour chaque service avec navigation cohérente.
|
||||
- [ ] Implémenter workflows MVP: Drive list/upload/download, Agenda list/create event, Meet create/join, Photos list/upload.
|
||||
- [ ] Réutiliser patterns UI partagés (toasts, loaders, erreurs, confirmations).
|
||||
- [ ] Ajouter permissions UI selon rôle.
|
||||
|
||||
### 3.5 Realtime UX
|
||||
|
||||
- [ ] Connecter WS sécurisé.
|
||||
- [ ] Rafraîchir liste/detail sans reload quand events backend arrivent.
|
||||
- [ ] Gérer offline/reconnect/sync status visible.
|
||||
- [ ] Ajouter indicateur santé sync par compte.
|
||||
|
||||
### 3.6 Accessibilité, i18n, perf
|
||||
|
||||
- [ ] Audit a11y (navigation clavier, aria, contraste).
|
||||
- [ ] Uniformiser FR/EN i18n.
|
||||
- [ ] Réduire payload initial (code splitting, lazy heavy panels).
|
||||
- [ ] Mesurer Core Web Vitals et fixer régressions.
|
||||
|
||||
## 4) Infrastructure & déploiement
|
||||
|
||||
### Docker/compose/proxy
|
||||
|
||||
- [ ] Vérifier routes nginx (`/api`, `/ws`, `/cloud`, `/meet`, `/auth`) en env réel.
|
||||
- [ ] Ajouter TLS (Let's Encrypt ou reverse proxy dédié) pour prod.
|
||||
- [ ] Ajouter limites upload/timeout proxy adaptées gros fichiers.
|
||||
- [ ] Ajouter profiles compose clairs (dev, prod, modules optionnels).
|
||||
|
||||
### Données & backup
|
||||
|
||||
- [ ] Politique backup PostgreSQL + object storage + Nextcloud.
|
||||
- [ ] Test restauration périodique.
|
||||
- [ ] Politique rétention logs et audit.
|
||||
|
||||
### Sécurité runtime
|
||||
|
||||
- [ ] Durcir containers (users non-root, capabilities minimales, seccomp).
|
||||
- [ ] Mettre scan vulnérabilités dépendances/images en CI.
|
||||
- [ ] Ajouter CSP/headers sécurité côté frontend + proxy.
|
||||
|
||||
## 5) Plateformes futures
|
||||
|
||||
### Desktop (Tauri)
|
||||
|
||||
- [ ] Initialiser wrapper Tauri.
|
||||
- [ ] Gérer auth desktop sécurisée (token storage natif).
|
||||
- [ ] Ajouter notifications système.
|
||||
- [ ] Ajouter intégration filesystem native (uploads drag/drop large files).
|
||||
|
||||
### Mobile
|
||||
|
||||
- [ ] Définir stratégie mobile (PWA avancée vs app native).
|
||||
- [ ] Implémenter MVP mobile mail (liste/lecture/compose simple).
|
||||
- [ ] Ajouter sync background + notifications push.
|
||||
|
||||
## 6) Validation "ça fonctionne"
|
||||
|
||||
### Parcours utilisateur obligatoires
|
||||
|
||||
- [ ] Connecter compte OIDC, créer compte mail IMAP/SMTP, voir inbox sync.
|
||||
- [ ] Lire mail HTML/texte + télécharger pièce jointe.
|
||||
- [ ] Envoyer mail immédiat, vérifier réception et dossier Sent.
|
||||
- [ ] Planifier envoi, replanifier, envoyer maintenant.
|
||||
- [ ] Créer règle (label + webhook), vérifier exécution réelle.
|
||||
- [ ] Rechercher mail/contact/event depuis recherche globale.
|
||||
- [ ] Créer contact, retrouver en auto-complétion compose.
|
||||
- [ ] Créer événement agenda + lien meet + invitation mail.
|
||||
- [ ] Upload fichier drive + partage lien.
|
||||
|
||||
### SLO minima
|
||||
|
||||
- [ ] API p95 < 300ms hors endpoints lourds.
|
||||
- [ ] Échec sync mail < 1% sur 24h.
|
||||
- [ ] Taux succès webhook > 99% hors erreurs distantes.
|
||||
- [ ] Disponibilité service mensuelle cible >= 99.5%.
|
||||
|
||||
---
|
||||
|
||||
## Ordre recommandé d'exécution
|
||||
|
||||
1. **Blocage critique**: sécurité credentials + auth/rbac + bug outbox scheduled.
|
||||
2. **MVP mail réel**: sync IMAP fiable + outbox + UI branchée backend.
|
||||
3. **Intégrations suite MVP**: contacts/calendar/drive branchés parcours simples.
|
||||
4. **Observabilité + tests**: CI solide, dashboards, alerting.
|
||||
5. **Différenciation**: règles avancées, webhooks templates, IA.
|
||||
97
project-plan/contacts.md
Normal file
97
project-plan/contacts.md
Normal file
@ -0,0 +1,97 @@
|
||||
# Contacts
|
||||
|
||||
**Équivalent** : Google Contacts
|
||||
**Statut** : Partiel (API proxy CardDAV)
|
||||
|
||||
---
|
||||
|
||||
## Résumé
|
||||
|
||||
Carnet d'adresses unifié partagé entre tous les services Ulti Suite. Gère les contacts personnels, professionnels, groupes, et synchronisation avec sources externes (CardDAV, Google, etc.).
|
||||
|
||||
## État d'implémentation réel (mai 2026)
|
||||
|
||||
### Déjà implémenté
|
||||
|
||||
- API backend montée sous `/api/v1/contacts` (si Nextcloud activé).
|
||||
- Endpoints list carnets, list contacts, create contact, delete contact, search.
|
||||
- Client CardDAV Nextcloud intégré côté serveur.
|
||||
|
||||
### Partiel / incomplet
|
||||
|
||||
- Pas d'update contact avec ETag.
|
||||
- Pas de sync incrémentale via sync-token.
|
||||
- Recherche globale suite ne s'appuie pas encore pleinement sur CardDAV.
|
||||
|
||||
### Non commencé
|
||||
|
||||
- Import/export vCard et CSV côté API produit final.
|
||||
- Fusion de doublons côté backend.
|
||||
- Enrichissement interactions (mails/réunions/fichiers) en API contact.
|
||||
|
||||
## Points de différenciation vs Google Contacts
|
||||
|
||||
- Carnet unifié cross-services natif (mail, agenda, meet, drive)
|
||||
- Import/export CardDAV bidirectionnel
|
||||
- Champs personnalisés illimités et typés
|
||||
- Fusion intelligente de doublons (IA-assistée)
|
||||
- Tags et groupes dynamiques (filtres auto-mis à jour)
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Core
|
||||
- [ ] CRUD contacts (nom, emails, téléphones, adresses, organisations)
|
||||
- [ ] Groupes / étiquettes
|
||||
- [ ] Photo de profil
|
||||
- [ ] Champs personnalisés
|
||||
- [ ] Historique d'interactions (derniers mails, réunions, fichiers partagés)
|
||||
|
||||
### Sync & import
|
||||
- [ ] Import/export vCard, CSV
|
||||
- [ ] Sync CardDAV bidirectionnelle
|
||||
- [ ] Import depuis Google Contacts
|
||||
- [ ] Détection et fusion de doublons
|
||||
- [ ] Auto-complétion depuis l'historique mail
|
||||
|
||||
### Intégration suite
|
||||
- [ ] Suggestions dans Ultimail (compose, destinataires)
|
||||
- [ ] Disponibilité agenda dans la fiche contact
|
||||
- [ ] Fichiers partagés (Ultidrive) dans la fiche
|
||||
- [ ] Lien direct vers Ultimeet
|
||||
|
||||
## Intégration Nextcloud — étude technique
|
||||
|
||||
### APIs CardDAV disponibles (confirmé)
|
||||
|
||||
Même instance Nextcloud qu'Ultidrive et Agenda. Endpoints :
|
||||
|
||||
| Opération | Méthode | Endpoint |
|
||||
|-----------|---------|----------|
|
||||
| Lister carnets | PROPFIND | `/remote.php/dav/addressbooks/users/{user}/` |
|
||||
| Lister contacts | REPORT (addressbook-query) | `.../addressbooks/users/{user}/{book}/` |
|
||||
| Créer contact | PUT | `.../addressbooks/users/{user}/{book}/{uid}.vcf` |
|
||||
| Modifier contact | PUT | Même (avec If-Match etag) |
|
||||
| Supprimer contact | DELETE | Même |
|
||||
| Sync incrémentale | REPORT (sync-collection) | Via sync-token |
|
||||
| Recherche | REPORT (addressbook-query) | Filtres sur FN, EMAIL, TEL, ORG, etc. |
|
||||
|
||||
### Champs indexés (recherche rapide)
|
||||
|
||||
BDAY, UID, N, FN, TITLE, ROLE, NOTE, NICKNAME, ORG, CATEGORIES, EMAIL, TEL, IMPP, ADR, URL, GEO, CLOUD, X-SOCIALPROFILE
|
||||
|
||||
### Avantages
|
||||
|
||||
- Carnet d'adresses système auto-populé avec tous les utilisateurs NC
|
||||
- Partage de carnets entre utilisateurs
|
||||
- Interopérable avec clients CardDAV (Apple Contacts, DAVx5, Thunderbird)
|
||||
- Même backend que fichiers et calendrier → une seule instance à maintenir
|
||||
|
||||
## Briques technologiques envisagées
|
||||
|
||||
| Besoin | Option retenue | Alternatives |
|
||||
|--------|----------------|--------------|
|
||||
| Backend CardDAV | Nextcloud sabre/dav (même instance) | Radicale, Baikal |
|
||||
| Client CardDAV (JS) | tsdav (TypeScript) | fetch + XML custom |
|
||||
| Parsing vCard | vcard4 ou ical.js | — |
|
||||
| Recherche avancée | Meilisearch (indexation custom) | NC search intégré |
|
||||
| Auth | Partagée via Ulti Suite (OIDC → NC) | — |
|
||||
45
project-plan/definition-of-done.md
Normal file
45
project-plan/definition-of-done.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Definition of Done (DoD)
|
||||
|
||||
Ce document définit le minimum à atteindre avant de considérer une livraison "done".
|
||||
|
||||
## Backend
|
||||
|
||||
- [ ] Spécification API et contrat DTO à jour.
|
||||
- [ ] Validation payload + gestion erreurs normalisée.
|
||||
- [ ] Ownership et permissions vérifiés sur mutations.
|
||||
- [ ] Logs et métriques ajoutés pour endpoint/worker concerné.
|
||||
- [ ] Migrations DB fournies si modèle impacté.
|
||||
- [ ] Tests d'intégration ajoutés sur cas nominal + erreurs clés.
|
||||
- [ ] Documentation d'exploitation mise à jour (config, limites, dépendances).
|
||||
|
||||
## Frontend
|
||||
|
||||
- [ ] Flux UI branché sur backend réel (pas de mock en fallback silencieux).
|
||||
- [ ] États loading/empty/error gérés proprement.
|
||||
- [ ] Navigation URL/state cohérente, sans régression UX majeure.
|
||||
- [ ] Accessibilité minimale respectée (clavier, libellés, focus).
|
||||
- [ ] Tracking erreurs front en place (console propre, fallback utilisateur).
|
||||
- [ ] Tests e2e ou tests ciblés ajoutés sur parcours modifié.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- [ ] Aucune donnée sensible en clair (credentials/tokens) dans DB, logs, réponses API.
|
||||
- [ ] Contrôles authn/authz testés (accès autorisé/refusé).
|
||||
- [ ] Inputs externes validés et bornés (taille, type, format).
|
||||
- [ ] Endpoints sensibles protégés contre abus (rate limit/idempotency si nécessaire).
|
||||
- [ ] Surface WS/Webhook durcie (signature, token, origine, timeout).
|
||||
|
||||
## Tests
|
||||
|
||||
- [ ] Tests unitaires/intégration ajoutés pour la logique créée/modifiée.
|
||||
- [ ] Cas d'échec et cas limites couverts.
|
||||
- [ ] CI exécute automatiquement les tests pertinents.
|
||||
- [ ] Aucune régression sur les parcours critiques connus.
|
||||
|
||||
## Observabilité
|
||||
|
||||
- [ ] request-id/correlation-id présent dans logs serveur.
|
||||
- [ ] Métriques métier ajoutées (succès, erreurs, latence, queue depth selon besoin).
|
||||
- [ ] Alertes pertinentes définies pour incidents probables.
|
||||
- [ ] Healthcheck du composant concerné vérifiable en environnement cible.
|
||||
- [ ] Dashboard ou vue de suivi disponible pour le run en production.
|
||||
85
project-plan/roadmap-3-phases.md
Normal file
85
project-plan/roadmap-3-phases.md
Normal file
@ -0,0 +1,85 @@
|
||||
# Roadmap officielle — 3 phases
|
||||
|
||||
Cette roadmap aligne les travaux backend + frontend avec l'état réel du projet.
|
||||
|
||||
## Cadre temporel (échelle relative)
|
||||
|
||||
- `duration+` = court
|
||||
- `duration++` = moyen
|
||||
- `duration+++` = long
|
||||
|
||||
## Owners nominaux par phase
|
||||
|
||||
| Phase | Durée relative | Owner nominal | Co-owners |
|
||||
|---|---|---|---|
|
||||
| Phase 1 — MVP fonctionnel | `duration++` | `@BE-Core` | `@FE-Lead`, `@PO` |
|
||||
| Phase 2 — Hardening | `duration+` | `@DevOps` | `@Security`, `@BE-Platform`, `@QA` |
|
||||
| Phase 3 — Différenciation | `duration+++` | `@PO` | `@BE-Integrations`, `@FE-Lead` |
|
||||
|
||||
## Phase 1 — MVP fonctionnel (priorité immédiate, `duration++`)
|
||||
|
||||
### Objectif
|
||||
|
||||
Rendre Ultimail utilisable de bout en bout avec un backend réel (plus de mock pour les parcours mail critiques).
|
||||
|
||||
### Livrables
|
||||
|
||||
- Corriger les blocants backend mail (ownership endpoints, bug outbox planifié, provisioning user OIDC).
|
||||
- Finaliser pipeline IMAP/SMTP minimum viable (sync inbox, envoi immédiat, envoi planifié cohérent).
|
||||
- Câbler rules + webhooks dans le flux de réception.
|
||||
- Exposer endpoints manquants MVP (brouillons, pièces jointes, labels/dossiers de base).
|
||||
- Brancher frontend mail sur API backend (lecture, actions, compose, recherche backend).
|
||||
- Brancher multi-comptes réel côté frontend.
|
||||
|
||||
### Critères de sortie
|
||||
|
||||
- Parcours validés: connexion -> sync inbox -> lecture -> envoi immédiat -> planifié/replanifié/send-now.
|
||||
- Règle simple + webhook exécutés réellement sur mail entrant.
|
||||
- Frontend principal mail ne dépend plus des mocks pour ces parcours.
|
||||
|
||||
## Phase 2 — Hardening (production-ready, `duration+`)
|
||||
|
||||
### Objectif
|
||||
|
||||
Sécuriser, fiabiliser et observer la plateforme pour exploitation continue.
|
||||
|
||||
### Livrables
|
||||
|
||||
- Chiffrement credentials au repos, validation inputs stricte, RBAC admin.
|
||||
- WS sécurisé par token (suppression `user_id` en query).
|
||||
- Retries/backoff + DLQ pour outbox et webhooks.
|
||||
- Observabilité complète (request-id, métriques, dashboards, alerting).
|
||||
- Normalisation erreurs API + pagination cross endpoints.
|
||||
- Couverture tests backend/CI (intégration handlers, workers, migrations) + tests e2e front parcours critiques.
|
||||
|
||||
### Critères de sortie
|
||||
|
||||
- SLO minimaux atteints en staging.
|
||||
- CI bloque les regressions sur flux critiques.
|
||||
- Aucun secret sensible en clair en base.
|
||||
|
||||
## Phase 3 — Différenciation (IA + automatisations avancées, `duration+++`)
|
||||
|
||||
### Objectif
|
||||
|
||||
Dépasser la parité Gmail/Google par les fonctions différenciantes Ulti Suite.
|
||||
|
||||
### Livrables
|
||||
|
||||
- Rules engine avancé (simulation, priorités, conflits, actions composables).
|
||||
- Webhooks templates versionnés (preview, signature HMAC, retries et observabilité fine).
|
||||
- Tri IA configurable par règle (provider, prompt, garde-fous coût/latence).
|
||||
- Recherche globale multi-services (mail + contacts + agenda + drive).
|
||||
- APIs fine-grained pour agents externes (tokens/scopes).
|
||||
|
||||
### Critères de sortie
|
||||
|
||||
- Un utilisateur peut configurer automatisations et IA sans code.
|
||||
- Résultats traçables et monitorés en production.
|
||||
- Recherche cross-services opérationnelle avec pertinence acceptable.
|
||||
|
||||
## Règle de priorisation
|
||||
|
||||
1. Corriger ce qui casse le produit (blocants/sécurité/cohérence).
|
||||
2. Stabiliser le coeur Ultimail en réel.
|
||||
3. Étendre puis différencier.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user