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:
R3D347HR4Y 2026-05-22 16:02:53 +02:00
commit d86f5f6c17
105 changed files with 10449 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
.git
.env
project-plan
README.md
.gitignore
.dockerignore
.idea
.vscode
deploy

182
.env.example Normal file
View 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
View 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
View 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
View 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 lentré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
View 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
View 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
View 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
View 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:

View 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
View 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

View 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

View 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
View 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."

View 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;
}
}

View 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
View 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
View 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=

View 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)
}

View 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,
})
}

View 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
}

View 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"
)

View 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,
})
}

View 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)
}
}

View 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)
}

View 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()
}

View 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")
}
}

View 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)
}

View 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)
}
}

View 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)
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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")
}
}

View 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)
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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)
}

View 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)
}

View 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
}

View 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
}

View 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),
)
})
}

View 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)
})
}
}

View 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))
})
}

View 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)
}
}

View 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
}

View 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)
}
}

View 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)
}

View 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
}

View 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
View 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
}

View 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
View 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
View 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
}

View 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)
}

View 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)
}
}

View 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
}

View 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)
}
}

View 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
View 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]
}

View 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)
}
}

View 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
}

View 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)
}

View 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
View 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)
}

View 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"`
}

View 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)
}

View 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
View 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
}

View 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)
}

View 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
View 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
View 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, "")
}

View 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
View 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, " & ")
}

View 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
}

View 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))
}

View 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)
}
}

View 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";

View 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()
);

View 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;

View 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';

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS audit_logs;

View 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);

View File

@ -0,0 +1,2 @@
ALTER TABLE mail_accounts
DROP CONSTRAINT IF EXISTS mail_accounts_credentials_encrypted_chk;

View 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;

View 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;

View 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
View 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)

View 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
View 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 | — |

View 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
View 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) | — |

View 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.

View 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