Compare commits
23 Commits
191c9b08e0
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 225fb1306b | |||
| b54493f785 | |||
| 3b2ea9959a | |||
| cf054959bd | |||
| 12de5a230a | |||
| 25897e913e | |||
| 81e454d86d | |||
| 7b87f2e392 | |||
| 6de90acef7 | |||
| 64c656bde1 | |||
| d74cfeee62 | |||
| 52bf1ff3c7 | |||
| e448ec66dc | |||
| 3d3ba84df0 | |||
| 81578f6e27 | |||
| b66151ddd9 | |||
| c845771946 | |||
| 93fddc3769 | |||
| c77ff07ce9 | |||
| 2004f9c5b2 | |||
| 139a990ebc | |||
| 7cc5448a7c | |||
| 5d8559b495 |
+1
-1
@@ -36,7 +36,7 @@ DEFAULT_FROM_EMAIL=honeyDue <noreply@honeyDue.treytartt.com>
|
||||
# Release builds: com.myhoneydue.honeyDue
|
||||
# Debug builds: com.myhoneydue.honeyDue.dev
|
||||
APPLE_CLIENT_ID=com.myhoneydue.honeyDue.dev
|
||||
APPLE_TEAM_ID=V3PF3M6B6U
|
||||
APPLE_TEAM_ID=X86BR9WTLD
|
||||
|
||||
# APNs Settings (iOS Push Notifications)
|
||||
# Direct APNs integration - no external push server needed
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Admin panel build stage
|
||||
FROM node:20-alpine AS admin-builder
|
||||
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS admin-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -109,7 +109,7 @@ FROM go-base AS worker
|
||||
CMD ["/app/worker"]
|
||||
|
||||
# Admin panel runtime stage
|
||||
FROM node:20-alpine AS admin
|
||||
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS admin
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -131,7 +131,7 @@ ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
# Default production stage (for Dokku - runs API + Admin)
|
||||
FROM node:20-alpine AS production
|
||||
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS production
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata curl
|
||||
|
||||
+34
-2
@@ -9,6 +9,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/treytartt/honeydue-api/internal/router"
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
"github.com/treytartt/honeydue-api/internal/tracing"
|
||||
"github.com/treytartt/honeydue-api/internal/worker"
|
||||
"github.com/treytartt/honeydue-api/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -54,11 +56,13 @@ func main() {
|
||||
// Initialize OpenTelemetry tracing — exports to obs.88oakapps.com
|
||||
// (Jaeger via OTLP/HTTP) when OBS_TRACES_URL is set; otherwise installs
|
||||
// a no-op tracer so call sites can use otel.Tracer() unconditionally.
|
||||
// config.SecretValue (not os.Getenv) so file-mounted secrets resolve
|
||||
// after audit F8 removed these from the process environment.
|
||||
tracingShutdown, err := tracing.Init(context.Background(), tracing.Config{
|
||||
ServiceName: "honeydue-api",
|
||||
Environment: deploymentEnvironment(cfg.Server.Debug),
|
||||
EndpointURL: os.Getenv("OBS_TRACES_URL"),
|
||||
BearerToken: os.Getenv("OBS_INGEST_TOKEN"),
|
||||
EndpointURL: config.SecretValue("OBS_TRACES_URL"),
|
||||
BearerToken: config.SecretValue("OBS_INGEST_TOKEN"),
|
||||
SampleRatio: tracing.SampleRatioFromEnv(),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -192,6 +196,28 @@ func main() {
|
||||
Msg("Push notification client initialized")
|
||||
}
|
||||
|
||||
// Initialize Asynq enqueuer (api-side). Used by services that move
|
||||
// long-running work off the request path (currently: task-completion
|
||||
// notification fan-out). Same Redis as cmd/worker — file-mounted password
|
||||
// applied separately because cfg.Redis.URL does not embed it (audit HIGH-1).
|
||||
var taskEnqueuer *worker.TaskClient
|
||||
if redisOpt, parseErr := asynq.ParseRedisURI(cfg.Redis.URL); parseErr != nil {
|
||||
log.Warn().Err(parseErr).Msg("Failed to parse Redis URL for Asynq enqueuer — completion notifications will run inline")
|
||||
} else if clientOpt, ok := redisOpt.(asynq.RedisClientOpt); ok {
|
||||
if cfg.Redis.Password != "" {
|
||||
clientOpt.Password = cfg.Redis.Password
|
||||
}
|
||||
taskEnqueuer = worker.NewTaskClient(clientOpt)
|
||||
defer func() {
|
||||
if cerr := taskEnqueuer.Close(); cerr != nil {
|
||||
log.Warn().Err(cerr).Msg("Failed to close Asynq enqueuer on shutdown")
|
||||
}
|
||||
}()
|
||||
log.Info().Msg("Asynq enqueuer initialized")
|
||||
} else {
|
||||
log.Warn().Msg("Redis opt is not RedisClientOpt — Asynq enqueuer skipped; completion notifications will run inline")
|
||||
}
|
||||
|
||||
// Setup router with dependencies (includes admin panel at /admin)
|
||||
deps := &router.Dependencies{
|
||||
DB: db,
|
||||
@@ -203,6 +229,12 @@ func main() {
|
||||
StorageService: storageService,
|
||||
MonitoringService: monitoringService,
|
||||
}
|
||||
// Only assign the enqueuer when we actually constructed one. Assigning a
|
||||
// nil *worker.TaskClient directly would create a typed-nil interface that
|
||||
// fails the `if deps.TaskEnqueuer != nil` check in router.SetupRouter.
|
||||
if taskEnqueuer != nil {
|
||||
deps.TaskEnqueuer = taskEnqueuer
|
||||
}
|
||||
e := router.SetupRouter(deps)
|
||||
|
||||
// Create HTTP server
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
// shouldInitEmail returns true if email config has host and user set.
|
||||
func shouldInitEmail(host, user string) bool {
|
||||
return host != "" && user != ""
|
||||
}
|
||||
|
||||
// shouldInitStorage returns true if upload directory is configured.
|
||||
func shouldInitStorage(uploadDir string) bool {
|
||||
return uploadDir != ""
|
||||
}
|
||||
|
||||
// shouldInitEncryption returns true if encryption key is set.
|
||||
func shouldInitEncryption(encryptionKey string) bool {
|
||||
return encryptionKey != ""
|
||||
}
|
||||
|
||||
// connectWithRetry attempts a connection with exponential backoff.
|
||||
// Returns nil on success or the last error after all retries fail.
|
||||
func connectWithRetry(connect func() error, maxRetries int) error {
|
||||
var err error
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
err = connect()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(time.Duration(i+1) * time.Millisecond) // use ms in tests
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- shouldInitEmail ---
|
||||
|
||||
func TestShouldInitEmail_BothSet_True(t *testing.T) {
|
||||
if !shouldInitEmail("smtp.example.com", "user@example.com") {
|
||||
t.Error("expected true when both set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldInitEmail_MissingHost_False(t *testing.T) {
|
||||
if shouldInitEmail("", "user@example.com") {
|
||||
t.Error("expected false when host empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldInitEmail_MissingUser_False(t *testing.T) {
|
||||
if shouldInitEmail("smtp.example.com", "") {
|
||||
t.Error("expected false when user empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldInitEmail_BothEmpty_False(t *testing.T) {
|
||||
if shouldInitEmail("", "") {
|
||||
t.Error("expected false when both empty")
|
||||
}
|
||||
}
|
||||
|
||||
// --- shouldInitStorage ---
|
||||
|
||||
func TestShouldInitStorage_Set_True(t *testing.T) {
|
||||
if !shouldInitStorage("/uploads") {
|
||||
t.Error("expected true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldInitStorage_Empty_False(t *testing.T) {
|
||||
if shouldInitStorage("") {
|
||||
t.Error("expected false")
|
||||
}
|
||||
}
|
||||
|
||||
// --- shouldInitEncryption ---
|
||||
|
||||
func TestShouldInitEncryption_Set_True(t *testing.T) {
|
||||
if !shouldInitEncryption("secret-key-123") {
|
||||
t.Error("expected true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldInitEncryption_Empty_False(t *testing.T) {
|
||||
if shouldInitEncryption("") {
|
||||
t.Error("expected false")
|
||||
}
|
||||
}
|
||||
|
||||
// --- connectWithRetry ---
|
||||
|
||||
func TestConnectWithRetry_SucceedsFirst_NoRetry(t *testing.T) {
|
||||
calls := 0
|
||||
err := connectWithRetry(func() error {
|
||||
calls++
|
||||
return nil
|
||||
}, 3)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectWithRetry_SucceedsSecond_OneRetry(t *testing.T) {
|
||||
calls := 0
|
||||
err := connectWithRetry(func() error {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return errors.New("fail")
|
||||
}
|
||||
return nil
|
||||
}, 3)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectWithRetry_AllFail_ReturnsError(t *testing.T) {
|
||||
calls := 0
|
||||
err := connectWithRetry(func() error {
|
||||
calls++
|
||||
return errors.New("fail")
|
||||
}, 3)
|
||||
if err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("calls = %d, want 3", calls)
|
||||
}
|
||||
}
|
||||
+71
-6
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/treytartt/honeydue-api/internal/repositories"
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
"github.com/treytartt/honeydue-api/internal/tracing"
|
||||
"github.com/treytartt/honeydue-api/internal/worker"
|
||||
"github.com/treytartt/honeydue-api/internal/worker/jobs"
|
||||
"github.com/treytartt/honeydue-api/pkg/utils"
|
||||
)
|
||||
@@ -47,11 +48,13 @@ func main() {
|
||||
|
||||
// Initialize OpenTelemetry tracing for the worker process. Same OTLP
|
||||
// destination as the api; service.name distinguishes them in Jaeger.
|
||||
// config.SecretValue (not os.Getenv) so file-mounted secrets resolve
|
||||
// after audit F8 removed these from the process environment.
|
||||
tracingShutdown, err := tracing.Init(context.Background(), tracing.Config{
|
||||
ServiceName: "honeydue-worker",
|
||||
Environment: workerDeploymentEnv(cfg.Server.Debug),
|
||||
EndpointURL: os.Getenv("OBS_TRACES_URL"),
|
||||
BearerToken: os.Getenv("OBS_INGEST_TOKEN"),
|
||||
EndpointURL: config.SecretValue("OBS_TRACES_URL"),
|
||||
BearerToken: config.SecretValue("OBS_INGEST_TOKEN"),
|
||||
SampleRatio: tracing.SampleRatioFromEnv(),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -106,6 +109,17 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to parse Redis URL")
|
||||
}
|
||||
// Audit HIGH-1: the Redis password is a file-mounted secret (REDIS_PASSWORD),
|
||||
// not embedded in REDIS_URL — REDIS_URL travels in the honeydue-config
|
||||
// ConfigMap. Apply the password onto the parsed opt so the Asynq server,
|
||||
// inspector and monitoring client (all derived from redisOpt below)
|
||||
// authenticate against a requirepass-protected Redis.
|
||||
if cfg.Redis.Password != "" {
|
||||
if clientOpt, ok := redisOpt.(asynq.RedisClientOpt); ok {
|
||||
clientOpt.Password = cfg.Redis.Password
|
||||
redisOpt = clientOpt
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize monitoring service (if Redis is available)
|
||||
var monitoringService *monitoring.Service
|
||||
@@ -167,11 +181,15 @@ func main() {
|
||||
// Create job handler
|
||||
jobHandler := jobs.NewHandler(db, pushClient, emailService, notificationService, cfg)
|
||||
|
||||
// Wire upload service for the pending_uploads cleanup cron. Storage may
|
||||
// be local-disk (no S3 backend), in which case the upload service stays
|
||||
// nil and the cleanup handler no-ops. Cache is optional — the cleanup
|
||||
// path doesn't rate-limit and works fine with a nil cache.
|
||||
// Wire upload service for the pending_uploads cleanup cron AND share the
|
||||
// underlying storage service with the TaskService below so the worker can
|
||||
// load completion images for email embedding. Storage may be local-disk
|
||||
// (no S3 backend), in which case the upload service stays nil and the
|
||||
// cleanup handler no-ops. Cache is optional — the cleanup path doesn't
|
||||
// rate-limit and works fine with a nil cache.
|
||||
var sharedStorageService *services.StorageService
|
||||
if storageService, sErr := services.NewStorageService(&cfg.Storage); sErr == nil {
|
||||
sharedStorageService = storageService
|
||||
if s3 := storageService.S3Backend(); s3 != nil {
|
||||
pendingUploadRepo := repositories.NewPendingUploadRepository(db)
|
||||
uploadService := services.NewUploadService(pendingUploadRepo, s3, &cfg.Storage, nil)
|
||||
@@ -181,6 +199,25 @@ func main() {
|
||||
log.Warn().Err(sErr).Msg("Failed to initialize storage service for upload cleanup; cleanup cron will no-op")
|
||||
}
|
||||
|
||||
// Wire a TaskService for the task-completed notification handler. The
|
||||
// worker re-creates this (vs. importing the api's wired instance) because
|
||||
// each binary owns its own dependency graph. The handler is fully nil-safe
|
||||
// — if any of the wired services are absent, the corresponding side of
|
||||
// notification delivery (push or email) is skipped.
|
||||
taskRepo := repositories.NewTaskRepository(db)
|
||||
residenceRepo := repositories.NewResidenceRepository(db)
|
||||
workerTaskService := services.NewTaskService(taskRepo, residenceRepo)
|
||||
if notificationService != nil {
|
||||
workerTaskService.SetNotificationService(notificationService)
|
||||
}
|
||||
if emailService != nil {
|
||||
workerTaskService.SetEmailService(emailService)
|
||||
}
|
||||
if sharedStorageService != nil {
|
||||
workerTaskService.SetStorageService(sharedStorageService)
|
||||
}
|
||||
jobHandler.SetTaskService(workerTaskService)
|
||||
|
||||
// Create Asynq mux and register handlers
|
||||
mux := asynq.NewServeMux()
|
||||
|
||||
@@ -195,6 +232,11 @@ func main() {
|
||||
mux.HandleFunc(jobs.TypeOnboardingEmails, jobHandler.HandleOnboardingEmails)
|
||||
mux.HandleFunc(jobs.TypeReminderLogCleanup, jobHandler.HandleReminderLogCleanup)
|
||||
mux.HandleFunc(jobs.TypeUploadCleanup, jobHandler.HandleUploadCleanup)
|
||||
mux.HandleFunc(jobs.TypeNotificationCleanup, jobHandler.HandleNotificationCleanup)
|
||||
mux.HandleFunc(jobs.TypeWebhookLogCleanup, jobHandler.HandleWebhookLogCleanup)
|
||||
mux.HandleFunc(jobs.TypeAuditLogCleanup, jobHandler.HandleAuditLogCleanup)
|
||||
mux.HandleFunc(worker.TypeTaskCompletedNotification, jobHandler.HandleTaskCompletedNotification)
|
||||
mux.HandleFunc(worker.TypeDataExport, jobHandler.HandleDataExport)
|
||||
|
||||
// Register email job handlers (welcome, verification, password reset, password changed)
|
||||
if emailService != nil {
|
||||
@@ -243,6 +285,23 @@ func main() {
|
||||
}
|
||||
log.Info().Str("cron", "30 * * * *").Msg("Registered pending_uploads cleanup job (runs hourly)")
|
||||
|
||||
// Data-retention cleanups (BE-2). Staggered off the 3:00 reminder cleanup to
|
||||
// avoid piling DELETEs onto the same Neon connection window.
|
||||
if _, err := scheduler.Register("0 2 * * *", asynq.NewTask(jobs.TypeNotificationCleanup, nil)); err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to register notification cleanup job")
|
||||
}
|
||||
log.Info().Str("cron", "0 2 * * *").Msg("Registered notification cleanup job (daily 02:00 UTC, 90d retention)")
|
||||
|
||||
if _, err := scheduler.Register("30 2 * * 0", asynq.NewTask(jobs.TypeWebhookLogCleanup, nil)); err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to register webhook log cleanup job")
|
||||
}
|
||||
log.Info().Str("cron", "30 2 * * 0").Msg("Registered webhook log cleanup job (weekly Sun 02:30 UTC, 180d retention)")
|
||||
|
||||
if _, err := scheduler.Register("30 3 * * 0", asynq.NewTask(jobs.TypeAuditLogCleanup, nil)); err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to register audit log cleanup job")
|
||||
}
|
||||
log.Info().Str("cron", "30 3 * * 0").Msg("Registered audit log cleanup job (weekly Sun 03:30 UTC, 365d retention)")
|
||||
|
||||
// Handle graceful shutdown
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
@@ -254,6 +313,12 @@ func main() {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
// Expose Prometheus metrics so vmagent can scrape the worker. The
|
||||
// apns_send_*, fcm_send_*, asynq_job_* and cache_ops_* series have been
|
||||
// recorded on this process all along — they were just never exposed, which
|
||||
// is why those dashboard panels read empty. Same :6060 as health; in-cluster
|
||||
// only (not externally published).
|
||||
healthMux.Handle("/metrics", prom.HTTPHandler())
|
||||
healthSrv := &http.Server{
|
||||
Addr: workerHealthAddr,
|
||||
Handler: healthMux,
|
||||
|
||||
@@ -92,7 +92,7 @@ ADMIN_PW="$(openssl rand -base64 16)"
|
||||
|
||||
EMAIL_USER="treytartt@fastmail.com"
|
||||
APNS_KEY_ID="9R5Q7ZX874"
|
||||
APNS_TEAM_ID="V3PF3M6B6U"
|
||||
APNS_TEAM_ID="X86BR9WTLD"
|
||||
|
||||
log ""
|
||||
log "Pre-filled from existing dev server:"
|
||||
|
||||
@@ -3,6 +3,7 @@ config.yaml
|
||||
|
||||
# Generated files
|
||||
kubeconfig
|
||||
kubeconfig.*
|
||||
cluster-config.yaml
|
||||
prod.env
|
||||
|
||||
|
||||
@@ -0,0 +1,966 @@
|
||||
# honeyDue k3s Cluster — Operations Runbook
|
||||
|
||||
Living document for the honeyDue production cluster. Add entries when you hit
|
||||
something non-obvious so future-you (or your replacement) doesn't have to
|
||||
rediscover it.
|
||||
|
||||
Last full revision: **2026-06-03** (Hetzner → OVH BHS cutover; cluster solo
|
||||
production from that date forward). For pre-OVH history, see
|
||||
`MIGRATION_NOTES.md` (Swarm → k3s migration on Hetzner, 2026-04-24).
|
||||
|
||||
---
|
||||
|
||||
## 1. Topology and inventory
|
||||
|
||||
### Hosting
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Provider | OVHcloud (us.ovhcloud.com) |
|
||||
| Datacenter | BHS — Beauharnois, Quebec, Canada |
|
||||
| Plan | VPS-1 × 3 (~$6.46/mo each, ~$19/mo total) |
|
||||
| Node spec | 4 vCPU (Intel Haswell, shared), 7.6 GB RAM, 75 GB NVMe |
|
||||
| Public bandwidth | 400 Mbps per node, unlimited traffic |
|
||||
| Private network | **None.** Nodes have public IPv4 + IPv6 only; inter-node traffic crosses the public internet (encrypted by flannel WireGuard backend — see §3) |
|
||||
|
||||
### Nodes
|
||||
|
||||
| SSH alias | Kubernetes node name | Public IPv4 | Public IPv6 | Roles |
|
||||
|---|---|---|---|---|
|
||||
| `ovhcloud1` | `vps-1624d691` | `51.81.83.33` | `2604:2dc0:101:200::5a9a` | control-plane, etcd, redis-pinned |
|
||||
| `ovhcloud2` | `vps-c0f51be2` | `51.81.87.86` | `2604:2dc0:101:200::30d4` | control-plane, etcd |
|
||||
| `ovhcloud3` | `vps-dbca24c7` | `51.81.85.248` | `2604:2dc0:101:200::450f` | control-plane, etcd |
|
||||
|
||||
The cluster is **all-control-plane** (workloads schedule on the same nodes that
|
||||
run etcd and the API server). `vps-1624d691` carries the
|
||||
`honeydue/redis=true` label so the Redis Deployment's `nodeSelector` binds
|
||||
there; the Redis PVC (`local-path`, host-pinned) lives on that node's disk.
|
||||
|
||||
### SSH access
|
||||
|
||||
`~/.ssh/config` entries (operator workstation):
|
||||
|
||||
```
|
||||
Host ovhcloud1
|
||||
HostName 51.81.83.33
|
||||
Port 22
|
||||
User ubuntu
|
||||
IdentityFile ~/.ssh/ovhcloud
|
||||
IdentitiesOnly yes
|
||||
Host ovhcloud2
|
||||
HostName 51.81.87.86
|
||||
Port 22
|
||||
User ubuntu
|
||||
IdentityFile ~/.ssh/ovhcloud
|
||||
IdentitiesOnly yes
|
||||
Host ovhcloud3
|
||||
HostName 51.81.85.248
|
||||
Port 22
|
||||
User ubuntu
|
||||
IdentityFile ~/.ssh/ovhcloud
|
||||
IdentitiesOnly yes
|
||||
```
|
||||
|
||||
`ubuntu` has passwordless sudo (`/etc/sudoers.d/90-cloud-init-users` from OVH's
|
||||
cloud-init).
|
||||
|
||||
### kubectl access
|
||||
|
||||
```bash
|
||||
export KUBECONFIG=/Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/deploy-k3s/kubeconfig
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
The `deploy-k3s/kubeconfig` file (mode 0600, gitignored) is the OVH cluster's
|
||||
admin kubeconfig with `server: https://51.81.83.33:6443`. A stale Hetzner copy
|
||||
lives next to it as `kubeconfig.hetzner.bak` for historical reference; the
|
||||
Hetzner cluster is powered off and that file's API server is unreachable.
|
||||
|
||||
To refresh from the cluster (if the local copy is lost or rotated):
|
||||
|
||||
```bash
|
||||
ssh ovhcloud1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
|
||||
| sed 's|server: https://127.0.0.1:6443|server: https://51.81.83.33:6443|' \
|
||||
> deploy-k3s/kubeconfig
|
||||
chmod 600 deploy-k3s/kubeconfig
|
||||
```
|
||||
|
||||
The k3s API at `:6443` is open to the public internet (token-protected).
|
||||
|
||||
---
|
||||
|
||||
## 2. Software
|
||||
|
||||
### Kernel-level
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| OS | Ubuntu 26.04 LTS (set by OVH's VPS-1 image) |
|
||||
| Kernel | `7.0.0-14-generic` |
|
||||
| Init | systemd |
|
||||
| Container runtime | containerd 2.2.2 (bundled with k3s) |
|
||||
| Firewall | `ufw` (per-node, configured at install — see §3) |
|
||||
| Other host packages | `fail2ban` (SSH brute-force protection, default jail), `unattended-upgrades` (security updates), `open-iscsi` (k3s prereq for some storage backends), `curl` |
|
||||
|
||||
### Kubernetes
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Distribution | k3s |
|
||||
| Version | **`v1.34.6+k3s1`** (pinned in `config.yaml:cluster.k3s_version`) |
|
||||
| Control plane | 3-node HA, embedded etcd (no external Postgres backing store) |
|
||||
| CNI / networking | flannel with **WireGuard-native backend** (`--flannel-backend=wireguard-native`). Encrypts pod-to-pod and etcd peer traffic because nodes only have public IPs (no private network). ~3-5% CPU overhead under load. |
|
||||
| Service LB | klipper-lb (default k3s `servicelb`). The `svclb-traefik` DaemonSet binds host ports `:80` and `:443` on each node and forwards to the Traefik Service. **Not** the DaemonSet-w/-hostNetwork Traefik pattern used on the old Hetzner cluster — see §10 *Differences from MIGRATION_NOTES*. |
|
||||
| Ingress controller | Traefik (k3s default), single-replica Deployment, exposed via klipper-lb |
|
||||
| DNS | CoreDNS (k3s default) |
|
||||
| Secrets encryption | Enabled (`--secrets-encryption`); etcd values are AES-CBC encrypted at rest |
|
||||
| kubeconfig perms | `0600` (`--write-kubeconfig-mode=0600`) |
|
||||
| Cloud controller | Disabled (`--disable-cloud-controller`) — no provider integration on OVH |
|
||||
| Misc | `--node-ip` / `--node-external-ip` / `--advertise-address` all set to each node's public IPv4. TLS SANs cover all 3 IPs so any IP can serve the API. |
|
||||
|
||||
### Application stack (in cluster, `honeydue` namespace)
|
||||
|
||||
| Deployment | Replicas | Image (digest-pinned) | Notes |
|
||||
|---|---:|---|---|
|
||||
| `api` | 3 | `gitea.treytartt.com/admin/honeydue-api@sha256:34fde6...` | Go REST API on `:8000`, exposes `/metrics` |
|
||||
| `web` | 3 | `gitea.treytartt.com/admin/honeydue-web@sha256:8c62cf...` | Next.js, server-side proxy to api |
|
||||
| `admin` | 1 | `gitea.treytartt.com/admin/honeydue-admin@sha256:b81263...` | Next.js admin panel, gated behind Traefik basic-auth |
|
||||
| `worker` | 1 | `gitea.treytartt.com/admin/honeydue-worker@sha256:fe1f5e...` | Asynq scheduler + Redis-backed jobs (singleton — must not run as >1 replica or every cron fires N×) |
|
||||
| `redis` | 1 | `redis:7-alpine@sha256:6ab0b6...` | Pinned to `vps-1624d691` via `honeydue/redis=true`. PVC `redis-data` (local-path, 5 Gi). Password-auth required. |
|
||||
| `vmagent` | 1 | `victoriametrics/vmagent@sha256:...` (default tag) | Scrapes api `/metrics` + kube-state-metrics; remote-writes to obs.88oakapps.com |
|
||||
| `kube-state-metrics` | 1 | `kube-state-metrics@sha256:...` | In `kube-system`, scraped by vmagent for `kube_*` cluster-state metrics |
|
||||
| `alloy-logs` (DaemonSet) | 3 (1/node) | `grafana/alloy@sha256:...` | Tails `/var/log/pods/*` and ships to Loki at obs.88oakapps.com |
|
||||
|
||||
The Asynq scheduler inside `worker` registers these cron jobs:
|
||||
|
||||
| Cron | Job | Notes |
|
||||
|---|---|---|
|
||||
| `0 * * * *` | Smart reminder check (per-user hour) | Default user hour: 14:00 UTC |
|
||||
| `0 * * * *` | Daily digest check (per-user hour) | Default user hour: 03:00 UTC |
|
||||
| `0 10 * * *` | Onboarding emails | 10:00 UTC |
|
||||
| `0 3 * * *` | Reminder log cleanup | 03:00 UTC |
|
||||
| `30 * * * *` | Pending uploads cleanup | xx:30 every hour |
|
||||
|
||||
### External dependencies
|
||||
|
||||
| Service | Endpoint | Purpose | Failure mode |
|
||||
|---|---|---|---|
|
||||
| Neon Postgres | `ep-floral-truth-amttbc5a-pooler.c-5.us-east-1.aws.neon.tech:5432` | App data. Pooler endpoint (transaction-mode PgBouncer in front of Neon compute) so connections stay warm. | api / worker pods crash-loop with `dial tcp: connection refused`. Health endpoint returns `postgres: error`. |
|
||||
| Backblaze B2 (S3-compatible) | `s3.us-east-005.backblazeb2.com` (bucket `honeyDueProd`) | User uploads (photos, PDFs, completion attachments) | Upload routes return 5xx; reads of cached/static files still work. |
|
||||
| Cloudflare | `myhoneydue.com` zone | DNS + TLS termination + edge cache + DDoS | Traffic stops reaching origin. Direct `https://51.81.x.x` still works for diagnostics. |
|
||||
| obs.88oakapps.com | Operator-run Grafana + VictoriaMetrics + Loki | Metrics & logs | vmagent + alloy-logs back off and retry. No app-side impact. |
|
||||
| Apple APNs | `api.push.apple.com:443` (production) | iOS push notifications | Push fails; circuit breaker opens; failure logged. App functionality unaffected. |
|
||||
| Fastmail SMTP | `smtp.fastmail.com:587` | Transactional emails (verification, recovery, digests) | Email send fails in the worker; logged; user reset/digest flow degrades. |
|
||||
| Gitea registry | `gitea.treytartt.com` | Container image registry | Deploys can't pull. Existing pods keep running on cached images. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Network and firewall
|
||||
|
||||
### Per-node `ufw` configuration
|
||||
|
||||
Applied during install (same on all 3 nodes):
|
||||
|
||||
```
|
||||
default deny incoming
|
||||
default allow outgoing
|
||||
allow 22/tcp (SSH, world)
|
||||
allow 80/tcp (HTTP via Cloudflare, world — see GAP-1)
|
||||
allow 443/tcp (HTTPS, same — GAP-1)
|
||||
allow 6443/tcp (k3s API, world, token-protected)
|
||||
allow 2379:2380/tcp from <other 2 OVH IPs> (etcd client + peer)
|
||||
allow 10250/tcp from <other 2 OVH IPs> (kubelet)
|
||||
allow 51820/udp from <other 2 OVH IPs> (WireGuard tunnel)
|
||||
allow 8472/udp from <other 2 OVH IPs> (VXLAN, defense-in-depth fallback)
|
||||
```
|
||||
|
||||
To inspect: `ssh ovhcloudN sudo ufw status numbered`.
|
||||
|
||||
### Cluster networking
|
||||
|
||||
- **Pod CIDR**: `10.42.0.0/16` (default k3s)
|
||||
- **Service CIDR**: `10.43.0.0/16` (default k3s)
|
||||
- **Flannel backend**: WireGuard-native. Each node hosts a `flannel-wg` interface on UDP 51820 and tunnels pod traffic to peers. Verify: `ssh ovhcloudN ip -d link show flannel-wg`.
|
||||
|
||||
### Traefik ingress flow
|
||||
|
||||
```
|
||||
Cloudflare → node:80/443 (public)
|
||||
→ klipper-lb svclb-traefik DaemonSet pod (hostPort:80/443)
|
||||
→ Traefik Service (ClusterIP 10.43.245.127:80/443)
|
||||
→ Traefik Deployment pod (single replica)
|
||||
→ matches Ingress host rule (api.myhoneydue.com etc.)
|
||||
→ routes to backend Service (api / web / admin)
|
||||
→ backend Pod
|
||||
```
|
||||
|
||||
The Traefik default also lives in `kube-system` and is managed by k3s's
|
||||
HelmChart. **No HelmChartConfig override is applied on OVH** (unlike Hetzner
|
||||
— see §10).
|
||||
|
||||
---
|
||||
|
||||
## 4. DNS configuration (Cloudflare)
|
||||
|
||||
The `myhoneydue.com` zone in Cloudflare has these public records. **All
|
||||
hostnames are proxied (orange cloud)** — required by the `cloudflare-only`
|
||||
Traefik middleware which 403s any non-CF source IP.
|
||||
|
||||
| Host | Type | Values | Proxy |
|
||||
|---|---|---|---|
|
||||
| `api.myhoneydue.com` | A × 3 | `51.81.83.33`, `51.81.87.86`, `51.81.85.248` | Proxied |
|
||||
| `app.myhoneydue.com` | A × 3 | (same trio) | Proxied |
|
||||
| `admin.myhoneydue.com` | A × 3 | (same trio) | Proxied |
|
||||
| `myhoneydue.com` (apex `@`) | A × 3 | (same trio) | Proxied |
|
||||
|
||||
Cloudflare round-robins among the 3 origins, klipper-lb on whichever node CF
|
||||
hits forwards to Traefik, and Traefik routes by Host header. Per-request,
|
||||
effectively load-balanced across the 3 nodes for ingress, with no central LB.
|
||||
|
||||
**SSL/TLS mode**: Flexible (CF terminates TLS at the edge; origin is plain
|
||||
HTTP on `:80`). Upgrading to Full (strict) is on the deferred list — would
|
||||
need an origin certificate provisioned to `cloudflare-origin-cert` secret and
|
||||
Traefik configured for TLS termination.
|
||||
|
||||
---
|
||||
|
||||
## 5. Filesystem layout (`deploy-k3s/`)
|
||||
|
||||
```
|
||||
deploy-k3s/
|
||||
├── config.yaml # Single config source (gitignored; contains tokens)
|
||||
├── config.yaml.example # Template
|
||||
├── kubeconfig # OVH admin kubeconfig (gitignored, 0600)
|
||||
├── kubeconfig.hetzner.bak # Old Hetzner kubeconfig (unreachable, kept for history)
|
||||
├── kubeconfig.tunnel # Optional: localhost-pointing copy for SSH-tunnel use
|
||||
├── secrets/
|
||||
│ ├── README.md
|
||||
│ ├── postgres_password.txt # Neon DB password
|
||||
│ ├── secret_key.txt # 32+ char app-token signing secret
|
||||
│ ├── email_host_password.txt # Fastmail SMTP app password
|
||||
│ ├── fcm_server_key.txt # FCM server key (currently unused — Android push disabled)
|
||||
│ ├── apns_auth_key.p8 # APNs auth key (binary)
|
||||
│ ├── cloudflare-origin.crt # Origin certificate (currently unused — CF Flexible)
|
||||
│ └── cloudflare-origin.key
|
||||
│ (all gitignored except README.md)
|
||||
├── manifests/
|
||||
│ ├── namespace.yaml
|
||||
│ ├── network-policies.yaml # default-deny + per-app egress/ingress (13 NetPols total)
|
||||
│ ├── rbac.yaml # api/worker/admin/web/redis ServiceAccounts (NOT applied by 03-deploy.sh; manual once)
|
||||
│ ├── pod-disruption-budgets.yaml # api-pdb, web-pdb, worker-pdb (NOT applied by 03-deploy.sh; manual once)
|
||||
│ ├── traefik-helmchartconfig.yaml # Hetzner-only DaemonSet+hostNetwork override (do NOT apply on OVH; we use default klipper-lb)
|
||||
│ ├── kyverno-verify-images.yaml # Operator-gated policy (do NOT apply blindly — see file comment)
|
||||
│ ├── api/{deployment,service,hpa}.yaml
|
||||
│ ├── worker/deployment.yaml
|
||||
│ ├── admin/{deployment,service}.yaml
|
||||
│ ├── web/{deployment,service}.yaml
|
||||
│ ├── redis/{deployment,service,pvc}.yaml
|
||||
│ ├── ingress/{middleware,ingress-simple}.yaml
|
||||
│ ├── migrate/job.yaml # goose migration Job (image-subbed at deploy time)
|
||||
│ ├── observability/{kube-state-metrics,vmagent,alloy-logs}.yaml
|
||||
│ └── kratos/ # Ory Kratos identity service (NOT yet deployed; gated on operator OIDC setup)
|
||||
└── scripts/
|
||||
├── _config.sh # Sourced by all scripts: cfg(), generate_env(), generate_cluster_config()
|
||||
├── 01-provision-cluster.sh # Hetzner-Cloud-specific (uses hetzner-k3s CLI) — DO NOT RUN ON OVH
|
||||
├── 02-setup-secrets.sh # Creates honeydue-secrets etc. from secrets/ + config.yaml; kubeconfig-driven
|
||||
├── 03-deploy.sh # Build + push + apply manifests + roll deployments; kubeconfig-driven
|
||||
├── 04-verify.sh # Post-deploy health + security checks; kubeconfig-driven
|
||||
└── rollback.sh # `kubectl rollout undo` across all deployments
|
||||
```
|
||||
|
||||
The `deploy/prod.env` file (sibling to `deploy-k3s/`, gitignored) holds
|
||||
observability + admin credentials that `02/03-deploy.sh` read but never
|
||||
display:
|
||||
|
||||
```
|
||||
OBS_INGEST_URL (https://obs.88oakapps.com/api/v1/write)
|
||||
OBS_TRACES_URL (https://obs.88oakapps.com/v1/traces)
|
||||
OBS_INGEST_TOKEN (bearer token for VM + Loki + traces — all use same token)
|
||||
GRAFANA_URL (https://grafana.88oakapps.com)
|
||||
GRAFANA_ADMIN_USER (admin)
|
||||
GRAFANA_ADMIN_PASSWORD
|
||||
ADMIN_EMAIL / ADMIN_PASSWORD (in-app admin login)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Install from clean boxes — the truthful procedure
|
||||
|
||||
This is what we ran on 2026-06-03 to stand up the live cluster, exactly. If
|
||||
you ever rebuild from zero this is the canonical sequence. Total wall-clock:
|
||||
~12 min for cluster bootstrap; ~10 min for workloads.
|
||||
|
||||
### 6.1 Prerequisites
|
||||
|
||||
- 3 fresh Ubuntu VPS instances (any provider with public IPv4, ≥4 GB RAM,
|
||||
≥40 GB disk)
|
||||
- `~/.ssh/config` entries (`ovhcloud1/2/3`) pointing at them, with
|
||||
passwordless sudo
|
||||
- Local `kubectl` and `curl`
|
||||
- The repo's `deploy-k3s/secrets/` populated (or the ability to copy live
|
||||
secrets from another running cluster — see §7.2)
|
||||
- `deploy/prod.env` populated with obs token + Grafana creds
|
||||
|
||||
### 6.2 Per-node OS hardening + firewall (all 3 in parallel)
|
||||
|
||||
For each `ovhcloudN`, over SSH:
|
||||
|
||||
```sh
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq fail2ban unattended-upgrades open-iscsi curl ufw
|
||||
sudo systemctl enable --now iscsid fail2ban
|
||||
sudo dpkg-reconfigure -f noninteractive -plow unattended-upgrades
|
||||
|
||||
sudo ufw --force reset
|
||||
sudo ufw default deny incoming
|
||||
sudo ufw default allow outgoing
|
||||
sudo ufw allow 22/tcp
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw allow 6443/tcp
|
||||
SELF=$(hostname -I | awk '{print $1}')
|
||||
for peer in 51.81.83.33 51.81.87.86 51.81.85.248; do
|
||||
[ "$peer" = "$SELF" ] && continue
|
||||
sudo ufw allow from "$peer" to any port 2379:2380 proto tcp
|
||||
sudo ufw allow from "$peer" to any port 10250 proto tcp
|
||||
sudo ufw allow from "$peer" to any port 51820 proto udp
|
||||
sudo ufw allow from "$peer" to any port 8472 proto udp
|
||||
done
|
||||
sudo ufw --force enable
|
||||
```
|
||||
|
||||
**Watch ordering:** `allow 22/tcp` MUST precede `ufw enable`. Existing SSH
|
||||
sessions survive (`ufw` only affects new connections), but a misordered script
|
||||
locks you out of fresh logins.
|
||||
|
||||
### 6.3 Install k3s on `ovhcloud1` (the init node)
|
||||
|
||||
```sh
|
||||
ssh ovhcloud1 'curl -sfL https://get.k3s.io | \
|
||||
INSTALL_K3S_VERSION=v1.34.6+k3s1 \
|
||||
sh -s - server \
|
||||
--cluster-init \
|
||||
--node-ip=51.81.83.33 \
|
||||
--node-external-ip=51.81.83.33 \
|
||||
--advertise-address=51.81.83.33 \
|
||||
--flannel-backend=wireguard-native \
|
||||
--flannel-external-ip \
|
||||
--secrets-encryption \
|
||||
--write-kubeconfig-mode=0600 \
|
||||
--tls-san=51.81.83.33 \
|
||||
--tls-san=51.81.87.86 \
|
||||
--tls-san=51.81.85.248 \
|
||||
--disable-cloud-controller'
|
||||
```
|
||||
|
||||
Wait for `sudo k3s kubectl get nodes` to show this node Ready (~2-5 s).
|
||||
Read the cluster token:
|
||||
|
||||
```sh
|
||||
ssh ovhcloud1 'sudo cat /var/lib/rancher/k3s/server/node-token'
|
||||
```
|
||||
|
||||
### 6.4 Join `ovhcloud2`, then `ovhcloud3` (sequential)
|
||||
|
||||
Joining etcd one node at a time avoids split-brain on slow networks.
|
||||
Replace `<TOKEN>` with the value from 6.3.
|
||||
|
||||
For `ovhcloud2`:
|
||||
|
||||
```sh
|
||||
ssh ovhcloud2 'curl -sfL https://get.k3s.io | \
|
||||
INSTALL_K3S_VERSION=v1.34.6+k3s1 \
|
||||
K3S_TOKEN=<TOKEN> \
|
||||
sh -s - server \
|
||||
--server=https://51.81.83.33:6443 \
|
||||
--node-ip=51.81.87.86 \
|
||||
--node-external-ip=51.81.87.86 \
|
||||
--advertise-address=51.81.87.86 \
|
||||
--flannel-backend=wireguard-native \
|
||||
--flannel-external-ip \
|
||||
--secrets-encryption \
|
||||
--write-kubeconfig-mode=0600 \
|
||||
--tls-san=51.81.83.33 --tls-san=51.81.87.86 --tls-san=51.81.85.248 \
|
||||
--disable-cloud-controller'
|
||||
```
|
||||
|
||||
Then identical for `ovhcloud3` with `--node-ip=51.81.85.248` and
|
||||
`--advertise-address=51.81.85.248`. After each, wait for `kubectl get nodes`
|
||||
to show the new node Ready before proceeding.
|
||||
|
||||
### 6.5 Pull kubeconfig to the operator workstation
|
||||
|
||||
```sh
|
||||
ssh ovhcloud1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
|
||||
| sed 's|server: https://127.0.0.1:6443|server: https://51.81.83.33:6443|' \
|
||||
> deploy-k3s/kubeconfig
|
||||
chmod 600 deploy-k3s/kubeconfig
|
||||
export KUBECONFIG=$(pwd)/deploy-k3s/kubeconfig
|
||||
kubectl get nodes -o wide # All 3 Ready, INTERNAL-IP = public IP
|
||||
```
|
||||
|
||||
### 6.6 Label the redis node
|
||||
|
||||
```sh
|
||||
kubectl label node vps-1624d691 honeydue/redis=true --overwrite
|
||||
```
|
||||
|
||||
(Use whichever k8s node name corresponds to `ovhcloud1`. The Redis
|
||||
Deployment's `nodeSelector` binds to this label.)
|
||||
|
||||
### 6.7 Bootstrap manifests NOT applied by `03-deploy.sh`
|
||||
|
||||
These must be applied manually on a fresh cluster, **before** running
|
||||
`03-deploy.sh`, or workloads will fail to schedule:
|
||||
|
||||
```sh
|
||||
kubectl apply -f deploy-k3s/manifests/rbac.yaml
|
||||
kubectl apply -f deploy-k3s/manifests/pod-disruption-budgets.yaml
|
||||
```
|
||||
|
||||
`rbac.yaml` creates the 5 ServiceAccounts (`api`, `worker`, `admin`, `web`,
|
||||
`redis`) referenced by the Deployment manifests. Without these, ReplicaSets
|
||||
hang on `FailedCreate: error looking up service account` and pods never
|
||||
start. Symptom on first deploy: `kubectl get deploy` shows `0 up-to-date`
|
||||
across the board with no pod activity — see §9 *Gotchas*.
|
||||
|
||||
**Do NOT apply** `traefik-helmchartconfig.yaml` (Hetzner-only — see §10) or
|
||||
`kyverno-verify-images.yaml` (gated on operator Kyverno install).
|
||||
|
||||
### 6.8 Seed secrets
|
||||
|
||||
Two paths; pick whichever fits your situation:
|
||||
|
||||
**Path A — clean install from local files** (the original design):
|
||||
|
||||
```sh
|
||||
KUBECONFIG=$(pwd)/deploy-k3s/kubeconfig ./deploy-k3s/scripts/02-setup-secrets.sh
|
||||
```
|
||||
|
||||
Requires `deploy-k3s/secrets/` to contain real `postgres_password.txt`,
|
||||
`secret_key.txt`, `email_host_password.txt`, `fcm_server_key.txt`,
|
||||
`apns_auth_key.p8`, `cloudflare-origin.crt`, `cloudflare-origin.key`. The
|
||||
script reads `config.yaml` for `registry.*`, `redis.password`,
|
||||
`admin.basic_auth_*`, and `storage.b2_*`.
|
||||
|
||||
**Path B — clone live secrets from another running cluster** (what we
|
||||
actually did during the migration; useful if `secrets/` is empty or you want
|
||||
exact-byte equivalence):
|
||||
|
||||
```sh
|
||||
HETZNER=$(pwd)/deploy-k3s/kubeconfig.hetzner.bak # or any kubeconfig with the secrets
|
||||
OVH=$(pwd)/deploy-k3s/kubeconfig
|
||||
kubectl --kubeconfig=$OVH apply -f deploy-k3s/manifests/namespace.yaml
|
||||
for S in honeydue-secrets honeydue-apns-key gitea-credentials cloudflare-origin-cert admin-basic-auth; do
|
||||
kubectl --kubeconfig=$HETZNER -n honeydue get secret $S -o json \
|
||||
| python3 -c "
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
m = d['metadata']
|
||||
for k in ('uid','resourceVersion','creationTimestamp','generation','managedFields','ownerReferences','selfLink'):
|
||||
m.pop(k, None)
|
||||
m.pop('annotations', None)
|
||||
print(json.dumps(d))" \
|
||||
| kubectl --kubeconfig=$OVH apply -f -
|
||||
done
|
||||
```
|
||||
|
||||
After either path, verify:
|
||||
|
||||
```sh
|
||||
kubectl -n honeydue get secrets
|
||||
# Expect: admin-basic-auth, cloudflare-origin-cert, gitea-credentials,
|
||||
# honeydue-apns-key, honeydue-secrets
|
||||
```
|
||||
|
||||
### 6.9 Deploy workloads
|
||||
|
||||
```sh
|
||||
KUBECONFIG=$(pwd)/deploy-k3s/kubeconfig \
|
||||
./deploy-k3s/scripts/03-deploy.sh --skip-build --tag latest
|
||||
```
|
||||
|
||||
- `--skip-build` skips Docker build + push, deploys whatever's already in the
|
||||
registry at the named tag. Use this when migrating between clusters to
|
||||
guarantee both run identical bits.
|
||||
- Without flags it builds the api / worker / admin / web images from the
|
||||
local repo HEAD and pushes to `gitea.treytartt.com` first.
|
||||
- The script applies (in order): namespace, network-policies (13 of them),
|
||||
redis, ingress, then runs the goose migration Job (blocking on success),
|
||||
then api / worker / admin / web Deployments, then observability
|
||||
(kube-state-metrics, vmagent, alloy-logs).
|
||||
- It does NOT apply: `rbac.yaml`, `pod-disruption-budgets.yaml`,
|
||||
`traefik-helmchartconfig.yaml`, `kyverno-verify-images.yaml`. The first
|
||||
two must be applied manually (see §6.7); the latter two are Hetzner-only
|
||||
or operator-gated.
|
||||
- It does NOT apply: anything under `kratos/` (skipped until
|
||||
`kratos-secrets` exists, which requires real OIDC client IDs).
|
||||
|
||||
### 6.10 Verify
|
||||
|
||||
```sh
|
||||
KUBECONFIG=$(pwd)/deploy-k3s/kubeconfig ./deploy-k3s/scripts/04-verify.sh
|
||||
```
|
||||
|
||||
Expect: all deployments `READY=desired`, 13 NetworkPolicies, 7 ServiceAccounts
|
||||
(api, worker, admin, web, redis, vmagent, alloy-logs), 3 PDBs, cloudflare-only
|
||||
middleware present, in-cluster `/api/health/` returns 200.
|
||||
|
||||
External smoke test (DNS-aware, but the api `/health/` route is exempt from
|
||||
the cloudflare-only middleware so direct-IP works for diagnostics):
|
||||
|
||||
```sh
|
||||
for IP in 51.81.83.33 51.81.87.86 51.81.85.248; do
|
||||
curl -s -o /dev/null -w "$IP -> %{http_code}\n" \
|
||||
-H 'Host: api.myhoneydue.com' http://$IP/api/health/
|
||||
done
|
||||
# All three should return 200.
|
||||
```
|
||||
|
||||
### 6.11 DNS cutover (if migrating)
|
||||
|
||||
In the Cloudflare dashboard for `myhoneydue.com`, set the 4 hostnames in §4 to
|
||||
the OVH IPs and keep proxied. Effective propagation ~30 s to 5 min through
|
||||
the Cloudflare proxy.
|
||||
|
||||
If you have a previous cluster, **scale its worker to 0 before flipping** to
|
||||
avoid scheduled-job double-fires:
|
||||
|
||||
```sh
|
||||
KUBECONFIG=<previous> kubectl -n honeydue scale deploy/worker --replicas=0
|
||||
# (cut DNS)
|
||||
KUBECONFIG=<new> kubectl -n honeydue scale deploy/worker --replicas=1
|
||||
```
|
||||
|
||||
Run those last two lines back-to-back. Worker work is mostly scheduled
|
||||
(hourly+), so a brief gap is harmless; overlap would cause duplicate emails.
|
||||
|
||||
---
|
||||
|
||||
## 7. Day-to-day operations
|
||||
|
||||
### Common kubectl one-liners
|
||||
|
||||
```sh
|
||||
export KUBECONFIG=$(pwd)/deploy-k3s/kubeconfig
|
||||
|
||||
# Cluster state
|
||||
kubectl get nodes -o wide
|
||||
kubectl -n honeydue get pods
|
||||
kubectl -n honeydue get deploy
|
||||
kubectl top nodes
|
||||
kubectl -n honeydue top pods
|
||||
|
||||
# Tail logs
|
||||
kubectl -n honeydue logs deploy/api -f --tail=50
|
||||
kubectl -n honeydue logs -l app.kubernetes.io/name=api -f --tail=20
|
||||
stern -n honeydue api # if stern is installed (multi-pod)
|
||||
|
||||
# Restart a deployment (no image change, picks up ConfigMap changes)
|
||||
kubectl -n honeydue rollout restart deploy/api
|
||||
|
||||
# Rollback one revision
|
||||
kubectl -n honeydue rollout undo deploy/api
|
||||
|
||||
# Scale (worker MUST stay at 0 or 1)
|
||||
kubectl -n honeydue scale deploy/api --replicas=4
|
||||
|
||||
# Get into a pod
|
||||
kubectl -n honeydue exec -it deploy/api -- sh
|
||||
```
|
||||
|
||||
### Redeploy after code changes
|
||||
|
||||
```sh
|
||||
KUBECONFIG=$(pwd)/deploy-k3s/kubeconfig ./deploy-k3s/scripts/03-deploy.sh
|
||||
```
|
||||
|
||||
Builds images from local HEAD, tags with the git short SHA, pushes to Gitea,
|
||||
runs `goose up` (idempotent), rolls api/worker/admin/web. Total: ~3-5 min
|
||||
when images change.
|
||||
|
||||
To deploy without rebuilding (pin to a specific tag):
|
||||
|
||||
```sh
|
||||
./deploy-k3s/scripts/03-deploy.sh --skip-build --tag <tag-or-:latest>
|
||||
```
|
||||
|
||||
### Migrations
|
||||
|
||||
Goose migrations live in `migrations/`. New file pattern:
|
||||
|
||||
```
|
||||
make migrate-new name=add_foo_column # generates migrations/YYYYMMDDHHMMSS_add_foo_column.sql
|
||||
# Edit the file with -- +goose Up / -- +goose Down sections
|
||||
```
|
||||
|
||||
`03-deploy.sh` runs a one-shot Job (`manifests/migrate/job.yaml`) that
|
||||
executes `goose up` against Neon (direct compute endpoint, not pooler — see
|
||||
file comment). The Job blocks api/worker rollout and aborts the deploy on
|
||||
failure. No app pod runs `AutoMigrate`; api/worker startup verifies
|
||||
`goose_db_version` is current and refuses to boot on mismatch.
|
||||
|
||||
### Grafana
|
||||
|
||||
URL: https://grafana.88oakapps.com (creds in `deploy/prod.env`)
|
||||
|
||||
Three dashboards in the `honeyDue` folder:
|
||||
|
||||
| UID | Title | Use |
|
||||
|---|---|---|
|
||||
| `honeydue-eli5-overview` | honeyDue — Overview (ELI5) | Single-screen at-a-glance health: pods up, crashes, errors, RPS, latency, Postgres, memory, top endpoints, push failures, worker activity, recent error logs. Created 2026-06-03. |
|
||||
| `honeydue-red` | honeyDue API — RED | Rate/Errors/Duration cuts (legacy) |
|
||||
| `honeydue-logs` | honeyDue — Production Logs | Live log explorer |
|
||||
|
||||
For the ELI5 dashboard's queries, **api-side metrics use `service="api"`,
|
||||
NOT `namespace="honeydue"`.** vmagent's scrape config drops the namespace
|
||||
label from api metrics — only `service`, `pod`, `node`, `job`, plus the
|
||||
metric's own labels (route, method, status, etc.) survive. Queries that
|
||||
filter on `namespace="honeydue"` for api metrics silently match nothing.
|
||||
|
||||
### kubectl tunnel (if 6443 is firewalled to your IP)
|
||||
|
||||
Currently `6443` is open WAN-side (matching the previous Hetzner posture).
|
||||
If you tighten that to operator-IPs-only and your IP changes, use an SSH
|
||||
tunnel:
|
||||
|
||||
```sh
|
||||
ssh -fN -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 \
|
||||
-i ~/.ssh/ovhcloud \
|
||||
-L 127.0.0.1:6443:127.0.0.1:6443 \
|
||||
ubuntu@51.81.83.33
|
||||
|
||||
cp deploy-k3s/kubeconfig deploy-k3s/kubeconfig.tunnel
|
||||
sed -i.bak 's|https://51.81.83.33:6443|https://127.0.0.1:6443|' deploy-k3s/kubeconfig.tunnel
|
||||
export KUBECONFIG="$(pwd)/deploy-k3s/kubeconfig.tunnel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Disaster recovery
|
||||
|
||||
### "I lost the kubeconfig"
|
||||
|
||||
```sh
|
||||
ssh ovhcloud1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
|
||||
| sed 's|server: https://127.0.0.1:6443|server: https://51.81.83.33:6443|' \
|
||||
> deploy-k3s/kubeconfig
|
||||
chmod 600 deploy-k3s/kubeconfig
|
||||
```
|
||||
|
||||
If `ovhcloud1` is down but `ovhcloud2` or `3` is up, swap host and IP — the
|
||||
TLS SAN covers all three.
|
||||
|
||||
### "A node is unresponsive"
|
||||
|
||||
```sh
|
||||
kubectl drain vps-XXX --ignore-daemonsets --delete-emptydir-data
|
||||
# Reboot via OVH manager or:
|
||||
ssh ovhcloudN sudo reboot
|
||||
# Wait for Ready, then:
|
||||
kubectl uncordon vps-XXX
|
||||
```
|
||||
|
||||
The cluster tolerates 1 node down (etcd quorum 2/3). With 2 down, etcd
|
||||
loses quorum and the API server stops accepting writes.
|
||||
|
||||
### "etcd quorum lost (2+ nodes dead)"
|
||||
|
||||
Bring nodes back online if possible. If not:
|
||||
|
||||
```sh
|
||||
ssh ovhcloud1 'sudo k3s server --cluster-reset --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/<latest>'
|
||||
```
|
||||
|
||||
k3s takes automatic etcd snapshots every 12h, keeping 5. List with:
|
||||
|
||||
```sh
|
||||
ssh ovhcloud1 sudo ls -la /var/lib/rancher/k3s/server/db/snapshots/
|
||||
```
|
||||
|
||||
This is destructive — workload state since the snapshot is lost, but Neon
|
||||
(actual app data) is unaffected.
|
||||
|
||||
### "I have to rebuild the whole cluster from scratch"
|
||||
|
||||
Provision 3 fresh boxes, then exactly the sequence in §6. End-to-end is
|
||||
~30 min. The dependencies that make this possible:
|
||||
|
||||
| Stays put through rebuild | Where |
|
||||
|---|---|
|
||||
| Application data | Neon Postgres (managed) |
|
||||
| User uploads | Backblaze B2 (managed) |
|
||||
| Container images | `gitea.treytartt.com` (self-hosted, but not on the OVH cluster) |
|
||||
| Operator secrets | `deploy-k3s/secrets/` + `config.yaml` + `deploy/prod.env` on the operator workstation (gitignored) |
|
||||
| DNS | Cloudflare control panel |
|
||||
|
||||
If `gitea.treytartt.com` is on the same OVH cluster, you have a circular
|
||||
dependency — rebuilding requires images you can't pull until the cluster is
|
||||
up. Currently Gitea is NOT in the honeyDue cluster (separate Hetzner-era
|
||||
host), so this isn't a problem today, but worth flagging if that ever
|
||||
changes.
|
||||
|
||||
### "Cutover back to Hetzner / failover to a backup cluster"
|
||||
|
||||
There is **no warm standby today.** Bringing up a second cluster is the
|
||||
same §6 procedure on different hardware, then a Cloudflare DNS swap. The
|
||||
worker-swap dance is critical:
|
||||
|
||||
```sh
|
||||
KUBECONFIG=<current> kubectl -n honeydue scale deploy/worker --replicas=0
|
||||
# (Update Cloudflare DNS to new cluster's IPs — proxied)
|
||||
KUBECONFIG=<new> kubectl -n honeydue scale deploy/worker --replicas=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Known gotchas
|
||||
|
||||
### 9.1 First-deploy "0 up-to-date" across all Deployments
|
||||
|
||||
**Symptoms:** `kubectl get deploy` shows `READY 0/N, UP-TO-DATE 0` for
|
||||
api/worker/admin/web/redis. `kubectl get events` shows
|
||||
`FailedCreate: error looking up service account honeydue/<name>: serviceaccount "..." not found`.
|
||||
|
||||
**Cause:** `rbac.yaml` (ServiceAccounts) is NOT applied by `03-deploy.sh`. On
|
||||
a fresh cluster the SAs don't exist; the ReplicaSet controller can't create
|
||||
pods.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```sh
|
||||
kubectl apply -f deploy-k3s/manifests/rbac.yaml
|
||||
kubectl -n honeydue rollout restart deploy/api deploy/worker deploy/admin deploy/web deploy/redis
|
||||
```
|
||||
|
||||
This was hit during the 2026-06-03 OVH bootstrap. Permanently fix by adding
|
||||
`kubectl apply -f rbac.yaml` to `03-deploy.sh` between the namespace and
|
||||
network-policies apply, but until that lands, follow §6.7 on every fresh
|
||||
cluster.
|
||||
|
||||
### 9.2 vmagent SD broken on fresh deploy ("0 pods up" in Grafana)
|
||||
|
||||
**Symptoms:**
|
||||
- Grafana panels using `kube_*` metrics or `up{job=...}` show 0
|
||||
- vmagent logs: `dial tcp 10.43.0.1:443: connect: connection refused` every ~30 s
|
||||
- Direct test from a pod also refused
|
||||
|
||||
**Cause:** k3s's NetworkPolicy controller evaluates egress rules *after*
|
||||
kube-proxy's DNAT (not before, contrary to spec). Pod-to-`kubernetes`-Service
|
||||
(`10.43.0.1:443`) gets DNAT'd to `<node_ip>:6443`, *then* the policy check
|
||||
runs. Without an explicit egress rule for `:6443`, the packet is rejected.
|
||||
|
||||
The `allow-egress-from-vmagent` NetPol in `network-policies.yaml` includes
|
||||
both rules:
|
||||
|
||||
```yaml
|
||||
- to:
|
||||
- ipBlock: { cidr: 10.43.0.0/16 }
|
||||
ports:
|
||||
- { port: 443, protocol: TCP }
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 0.0.0.0/0
|
||||
except: [10.42.0.0/16]
|
||||
ports:
|
||||
- { port: 6443, protocol: TCP }
|
||||
```
|
||||
|
||||
**If this happens:** confirm `network-policies.yaml` was applied:
|
||||
|
||||
```sh
|
||||
kubectl -n honeydue get netpol allow-egress-from-vmagent -o yaml | grep -A 5 6443
|
||||
```
|
||||
|
||||
Counter-evidence that confirms diagnosis: `kube-state-metrics` in
|
||||
`kube-system` works fine (no NetPols in that namespace).
|
||||
|
||||
### 9.3 vmagent appears healthy but no data in Grafana
|
||||
|
||||
vmagent's `/-/healthy` returns 200 as long as the process is alive and
|
||||
remote-write is TCP-functional. It doesn't check that scrapes are actually
|
||||
*succeeding*. The liveness probe in `vmagent.yaml` queries `/api/v1/targets`
|
||||
and fails the pod if no target is `up`. After ~3 failures (~3 min), kubelet
|
||||
recycles it.
|
||||
|
||||
If vmagent runs for weeks but Grafana is empty, the probe was disabled or
|
||||
the exec command broke.
|
||||
|
||||
### 9.4 vmagent bearer token destroyed by direct `kubectl apply`
|
||||
|
||||
The committed `vmagent.yaml` has `bearer_token: TOKEN_PLACEHOLDER`. The real
|
||||
token is `sed`-substituted at deploy time by `03-deploy.sh`. Applying the
|
||||
file directly:
|
||||
|
||||
```sh
|
||||
kubectl apply -f deploy-k3s/manifests/observability/vmagent.yaml # WRONG
|
||||
```
|
||||
|
||||
overwrites the Secret with the literal `TOKEN_PLACEHOLDER` and remote-writes
|
||||
401. To restore without a full redeploy:
|
||||
|
||||
```sh
|
||||
OBS_TOKEN_B64=$(kubectl -n honeydue get secret honeydue-secrets \
|
||||
-o jsonpath='{.data.OBS_INGEST_TOKEN}')
|
||||
kubectl -n honeydue patch secret vmagent-remote-write --type=json \
|
||||
-p="[{\"op\":\"replace\",\"path\":\"/data/bearer_token\",\"value\":\"${OBS_TOKEN_B64}\"}]"
|
||||
kubectl -n honeydue rollout restart deploy/vmagent
|
||||
```
|
||||
|
||||
Or just re-run `./deploy-k3s/scripts/03-deploy.sh` — the sed handles it.
|
||||
|
||||
### 9.5 Dashboard queries: api metrics need `service="api"` not `namespace="honeydue"`
|
||||
|
||||
vmagent's scrape config (`vmagent-config` ConfigMap) explicitly chooses which
|
||||
Kubernetes pod-metadata labels to copy onto each scraped series. **Namespace
|
||||
isn't one of them.** Labels you can use on api-side metrics:
|
||||
|
||||
- `service` (literal `"api"`)
|
||||
- `job` (literal `"api"`)
|
||||
- `pod` (the api pod name)
|
||||
- `node` (the k8s node name)
|
||||
- `cluster` (vmagent external_label, currently `"honeydue-k3s"`)
|
||||
- `environment` (vmagent external_label, currently `"prod"`)
|
||||
- Plus each metric's own labels (`method`, `route`, `status` for HTTP; etc.)
|
||||
|
||||
`kube_*` metrics from kube-state-metrics DO carry `namespace` natively
|
||||
(KSM publishes it as a label, vmagent passes it through). Loki streams have
|
||||
`namespace` because alloy-logs explicitly relabels it. So the rule is:
|
||||
|
||||
| Metric prefix | Use |
|
||||
|---|---|
|
||||
| `kube_*` | `namespace="honeydue"` |
|
||||
| `http_*`, `gorm_*`, `go_*`, `process_*` (api) | `service="api"` |
|
||||
| Loki logs `{...}` | `namespace="honeydue"` |
|
||||
|
||||
### 9.6 Cluster-label collision when two clusters run together
|
||||
|
||||
Both Hetzner and OVH vmagents push as `cluster=honeydue-k3s, environment=prod`
|
||||
(same external_labels). During the migration overlap this made dashboards
|
||||
sum both clusters' data. The simplest narrowing during overlap is by node
|
||||
name pattern (`node=~"vps-.*"` for OVH, `node=~"ubuntu-.*"` for Hetzner). If
|
||||
you ever bring up a backup cluster long-term, change one cluster's
|
||||
`external_labels.cluster` to something distinct (e.g. `honeydue-ovh`
|
||||
vs. `honeydue-backup`).
|
||||
|
||||
### 9.7 Worker double-firing scheduled jobs
|
||||
|
||||
If two `worker` Deployments run concurrently (e.g. two clusters both pointing
|
||||
at the same Neon DB), Asynq schedulers each fire crons independently — users
|
||||
get duplicate emails. Workaround: scale all-but-one worker to 0. This is the
|
||||
exact mechanic used during cutovers (§6.11).
|
||||
|
||||
### 9.8 Node kubeconfig mode
|
||||
|
||||
`/etc/rancher/k3s/k3s.yaml` on each node is mode `0600` because we install
|
||||
with `--write-kubeconfig-mode=0600`. Tightening from k3s default (0644) was
|
||||
intentional. Don't change without coordinating — any tooling on the node
|
||||
that expects to read it (none today) will break.
|
||||
|
||||
---
|
||||
|
||||
## 10. Differences from MIGRATION_NOTES.md (Hetzner-era)
|
||||
|
||||
`MIGRATION_NOTES.md` documents the Swarm → k3s migration on Hetzner
|
||||
(2026-04-24). Most of it still applies, with these OVH-specific deltas:
|
||||
|
||||
| What MIGRATION_NOTES says | What OVH actually has |
|
||||
|---|---|
|
||||
| `hetzner-k3s` provisioner | Manual k3s install (§6) |
|
||||
| Hetzner Load Balancer (not used) → Cloudflare round-robin | Same — Cloudflare round-robin (§4) |
|
||||
| Traefik as DaemonSet + hostNetwork via HelmChartConfig | Traefik default Deployment + klipper-lb svclb DaemonSet. The `traefik-helmchartconfig.yaml` file is **NOT applied** on OVH. |
|
||||
| `servicelb` disabled (`--disable=servicelb`) | `servicelb` enabled (we didn't pass `--disable=servicelb`). This is what makes klipper-lb work. |
|
||||
| sysctl `net.ipv4.ip_unprivileged_port_start=0` for hostNetwork Traefik | Not needed — klipper-lb proxies the port binding instead |
|
||||
| UFW rules between 3 Hetzner IPs | UFW rules between 3 OVH IPs (51.81.83.33, 51.81.87.86, 51.81.85.248) |
|
||||
| Kubeconfig at `~/.kube/honeydue-k3s.yaml` | Kubeconfig at `deploy-k3s/kubeconfig` |
|
||||
| TLS at origin: not configured (CF Flexible) | Same — CF Flexible. `cloudflare-origin-cert` Secret exists (carried over) but Ingress doesn't reference it. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Outstanding follow-ups (deferred, not blocking)
|
||||
|
||||
1. **No warm standby / rollback cluster.** OVH is solo production. An OVH
|
||||
outage is a real outage; recovery time = §6 procedure (~30 min). User
|
||||
plans to bring a second cluster up as a target.
|
||||
2. **UFW allows 80/443 from world.** Hetzner had a network-layer Cloudflare-IP
|
||||
allowlist on these ports. OVH currently relies on the L7
|
||||
`cloudflare-only` Traefik middleware, which protects admin but NOT api /
|
||||
web / apex (those routes have to be reachable from anywhere, but they're
|
||||
then trivially DDoSable bypassing Cloudflare). Fix: add ufw allow rules
|
||||
restricting `80/tcp` and `443/tcp` to Cloudflare's published IP ranges
|
||||
(~22 IPv4 prefixes from https://www.cloudflare.com/ips-v4/).
|
||||
3. **Cloudflare TLS Flexible → Full(strict).** Origin certs exist as Secret
|
||||
but Ingress doesn't terminate TLS. Upgrading to Full(strict) requires
|
||||
Traefik configured with the cert + an HTTPS entrypoint + Ingress
|
||||
`tls:` block.
|
||||
4. **`rbac.yaml` + `pod-disruption-budgets.yaml` should be in `03-deploy.sh`.**
|
||||
They're currently bootstrap-only. Adding them is idempotent and prevents
|
||||
the §9.1 footgun.
|
||||
5. **Push notification metrics are log-derived, not counters.** Successes
|
||||
aren't logged or counted. Proper Prometheus instrumentation (~15 lines in
|
||||
`internal/push/client.go`) would give a real success/failure ratio.
|
||||
6. **Worker has no `/metrics` endpoint.** `cmd/worker/main.go` serves `:6060`
|
||||
for healthz only. Adding Asynq's `metrics.NewPrometheusExporter()` + a
|
||||
ServiceMonitor + uncommenting the `worker` job stanza in
|
||||
`vmagent-config` ConfigMap would give real queue depth and job latency.
|
||||
7. **Ory Kratos.** Manifests exist (`manifests/kratos/`) but the deploy
|
||||
is gated on operator-side prerequisites (Neon `kratos` database,
|
||||
`auth.myhoneydue.com` DNS, real Apple+Google OIDC clients, Kratos image
|
||||
tag pinned). Until `kratos-secrets` exists, `03-deploy.sh` silently
|
||||
skips the Kratos apply.
|
||||
8. **Hetzner cluster fully retired? `config.yaml` `nodes:` block describes
|
||||
OVH; the bak kubeconfig is at `kubeconfig.hetzner.bak`. Boxes themselves
|
||||
are operator-managed.
|
||||
|
||||
### 11.1 Dashboard observability gaps (raised 2026-06-03 during dashboard build)
|
||||
|
||||
Surfaced while building the `honeydue-eli5-overview` Grafana dashboard. Each
|
||||
needs code or infra changes to expose; none blocks today's operations.
|
||||
|
||||
9. **node-exporter not deployed.** No node-level metrics today
|
||||
(`node_filesystem_avail_bytes`, `node_memory_*`, `node_load1`, etc.).
|
||||
The dashboard's pod-level memory/CPU panels are app-process only — a
|
||||
node running out of disk would silently fail the cluster before any
|
||||
dashboard signal showed it. Highest-priority Tier-3 item. Fix: deploy
|
||||
`node-exporter` as a DaemonSet (~50 lines of YAML), add a scrape stanza
|
||||
to `vmagent-config`, add a `Node disk free` stat panel.
|
||||
10. **Traefik metrics not enabled.** Traefik can expose `/metrics` with
|
||||
`traefik_entrypoint_requests_total` + `traefik_service_request_duration_seconds`,
|
||||
giving edge-level visibility into requests that never reached api
|
||||
pods (404s, redirects, middleware blocks). Enable via a
|
||||
HelmChartConfig override that sets `metrics.prometheus.entryPoint=metrics`
|
||||
+ adds a `:9100` entryPoint + a scrape stanza. Skipped today to avoid
|
||||
Traefik restart risk; safe additive change when ready.
|
||||
11. **Push notification success/failure counters** (already #5). Add
|
||||
`prometheus.NewCounterVec` in `internal/push/client.go` with labels
|
||||
`platform={ios,android}, outcome={success,failed,breaker_open,disabled}`.
|
||||
Increments at every Send/SendActionable branch. Replaces the
|
||||
log-derived "Push failures" stat on the dashboard with a real success
|
||||
rate.
|
||||
12. **Worker queue / job metrics** (already #6). Asynq has a built-in
|
||||
Prometheus exporter (`asynq/x/metrics`). Wire it into the worker's
|
||||
`:6060` health server (a single `healthMux.Handle` line) and
|
||||
uncomment the worker scrape stanza in `vmagent-config`. Surfaces
|
||||
queue depth, retry count, processing time per task type.
|
||||
13. **Cache hit / miss rate.** `internal/services/cache_service.go` has
|
||||
no counters. Add a Counter with labels `{operation=get|set, result=hit|miss}`
|
||||
around the cache wrapper. ~10 lines. Useful once real traffic flows
|
||||
to verify the ETag and Redis caches are paying their keep.
|
||||
14. **APNs send-latency histogram.** Wrap `internal/push/apns.go::Send`
|
||||
in a `prometheus.NewHistogramVec` keyed on outcome. Tells you when
|
||||
Apple's gateway is slow (which correlates with their incident page).
|
||||
|
||||
---
|
||||
|
||||
## 12. Audit trail
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-04-24 | Initial k3s cluster on Hetzner (Swarm → k3s migration) — see MIGRATION_NOTES.md |
|
||||
| 2026-04-25 | `config.yaml` reconstructed from live ConfigMap (original file lost) |
|
||||
| 2026-05-15 | Audit fixes: Redis auth required, admin basic auth, secrets-encryption flag |
|
||||
| 2026-05-16 | `02-setup-secrets.sh` started carrying B2 credentials (was a manifest/script drift) |
|
||||
| 2026-06-02 | Kratos scaffolding committed (not deployed) |
|
||||
| 2026-06-03 | **Hetzner → OVH BHS cutover.** New 3-node cluster on 51.81.83.33, .87.86, .85.248. DNS cut on Cloudflare. Hetzner kubeconfig moved to `.bak`. Grafana `honeydue-eli5-overview` dashboard created. Hetzner cluster powered off later same day. |
|
||||
| 2026-06-03 | Dashboard build-out: extended `honeydue-eli5-overview` to 22 panels covering Tier-1 (HTTP status, CPU per pod, goroutines, top slow) and Tier-2 (GC, network I/O, pod uptime, top 5xx) signals. Surfaced Tier-3 instrumentation gaps in §11.1. |
|
||||
+896
-676
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ load_balancer_ip: ""
|
||||
domains:
|
||||
api: api.myhoneydue.com
|
||||
admin: admin.myhoneydue.com
|
||||
app: app.myhoneydue.com # web client host — added to CORS_ALLOWED_ORIGINS
|
||||
base: myhoneydue.com
|
||||
|
||||
# --- Container Registry (GHCR) ---
|
||||
|
||||
@@ -23,8 +23,11 @@ spec:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
serviceAccountName: admin
|
||||
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
|
||||
# the ServiceAccount-level setting in rbac.yaml.
|
||||
automountServiceAccountToken: false
|
||||
imagePullSecrets:
|
||||
- name: ghcr-credentials
|
||||
- name: gitea-credentials
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
@@ -35,6 +38,7 @@ spec:
|
||||
containers:
|
||||
- name: admin
|
||||
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
|
||||
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
protocol: TCP
|
||||
|
||||
@@ -23,8 +23,11 @@ spec:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
serviceAccountName: api
|
||||
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
|
||||
# the ServiceAccount-level setting in rbac.yaml.
|
||||
automountServiceAccountToken: false
|
||||
imagePullSecrets:
|
||||
- name: ghcr-credentials
|
||||
- name: gitea-credentials
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
@@ -35,6 +38,7 @@ spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
|
||||
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
protocol: TCP
|
||||
@@ -46,65 +50,16 @@ spec:
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: honeydue-config
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: SECRET_KEY
|
||||
- name: EMAIL_HOST_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: EMAIL_HOST_PASSWORD
|
||||
- name: FCM_SERVER_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: FCM_SERVER_KEY
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: REDIS_PASSWORD
|
||||
optional: true
|
||||
# B2 (Backblaze) credentials. With both set, StorageConfig.IsS3()
|
||||
# returns true and uploads stream to B2 via minio-go. With either
|
||||
# missing, code falls back to local filesystem — and since
|
||||
# readOnlyRootFilesystem is true on this container, that fallback
|
||||
# silently fails. So both must be wired or uploads break.
|
||||
- name: B2_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: B2_KEY_ID
|
||||
- name: B2_APP_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: B2_APP_KEY
|
||||
# Observability — push traces (and any future OTLP metrics) to
|
||||
# obs.88oakapps.com. Token gates ingest at nginx; URL is the
|
||||
# same one vmagent uses for metric remote-write. Both come from
|
||||
# honeydue-secrets so they aren't world-readable in ConfigMap.
|
||||
- name: OBS_TRACES_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: OBS_TRACES_URL
|
||||
optional: true
|
||||
- name: OBS_INGEST_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: OBS_INGEST_TOKEN
|
||||
optional: true
|
||||
# Audit CODE-F8: secrets are NOT injected as environment variables.
|
||||
# Env vars are readable for the life of the pod via /proc/<pid>/environ
|
||||
# and leak into crash dumps / child processes. honeydue-secrets is
|
||||
# mounted read-only at /etc/honeydue/secrets (mode 0400) and the Go
|
||||
# config layer (config.loadFileSecrets) reads each key from its file.
|
||||
# Non-secret config still arrives via the configMapRef above.
|
||||
volumeMounts:
|
||||
- name: app-secrets
|
||||
mountPath: /etc/honeydue/secrets
|
||||
readOnly: true
|
||||
- name: apns-key
|
||||
mountPath: /secrets/apns
|
||||
readOnly: true
|
||||
@@ -121,11 +76,12 @@ spec:
|
||||
httpGet:
|
||||
path: /api/health/
|
||||
port: 8000
|
||||
# MigrateWithLock in cmd/api/main.go runs pg_advisory_lock on
|
||||
# every startup. On a cold boot with 3 replicas, the first does
|
||||
# AutoMigrate (~90s) and the others wait on the lock, so real
|
||||
# startup runs 90–240s. 48 × 5s = 240s grace absorbs it without
|
||||
# healthcheck killing a still-starting replica.
|
||||
# Schema migrations run separately in the honeydue-migrate Job
|
||||
# *before* this Deployment rolls — the api itself does not migrate
|
||||
# (it only verifies goose_db_version at boot). Cold start still
|
||||
# pays the DB pool warm-up + Redis connect + APNs/FCM client init
|
||||
# before /api/health/ goes green. 48 × 5s = 240s grace keeps the
|
||||
# probe from killing a still-starting replica.
|
||||
failureThreshold: 48
|
||||
periodSeconds: 5
|
||||
readinessProbe:
|
||||
@@ -143,6 +99,12 @@ spec:
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
volumes:
|
||||
# Audit CODE-F8: the whole honeydue-secrets Secret, projected as files.
|
||||
# defaultMode 0400 → readable only by the container's runAsUser (1000).
|
||||
- name: app-secrets
|
||||
secret:
|
||||
secretName: honeydue-secrets
|
||||
defaultMode: 0400
|
||||
- name: apns-key
|
||||
secret:
|
||||
secretName: honeydue-apns-key
|
||||
|
||||
@@ -53,7 +53,12 @@ metadata:
|
||||
labels:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd
|
||||
# cloudflare-only + admin-auth wired in (audit F2/F3/CODE-L6). Order
|
||||
# matters: reject non-Cloudflare IPs, then basic auth, then headers,
|
||||
# then rate limit. The admin-basic-auth secret is created by
|
||||
# 02-setup-secrets.sh from config.yaml admin.basic_auth_* — that runs
|
||||
# before 03-deploy.sh, so the middleware always has its secret.
|
||||
traefik.ingress.kubernetes.io/router.middlewares: honeydue-cloudflare-only@kubernetescrd,honeydue-admin-auth@kubernetescrd,honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
@@ -98,3 +103,98 @@ spec:
|
||||
name: web
|
||||
port:
|
||||
number: 3000
|
||||
---
|
||||
# Auth-endpoint Ingress (audit F10 / LIVE-L12). A dedicated Ingress for the
|
||||
# auth paths so Traefik gives their longer path-prefix routers a higher
|
||||
# priority than honeydue-api's "/" router — these paths then get
|
||||
# auth-rate-limit (5/min) instead of the general rate-limit (100/min).
|
||||
# Anything not matched here falls through to honeydue-api unchanged.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: honeydue-api-auth
|
||||
namespace: honeydue
|
||||
labels:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: honeydue-auth-rate-limit@kubernetescrd,honeydue-security-headers@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- api.myhoneydue.com
|
||||
secretName: cloudflare-origin-cert
|
||||
rules:
|
||||
- host: api.myhoneydue.com
|
||||
http:
|
||||
paths:
|
||||
- path: /api/auth/login
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /api/auth/register
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /api/auth/forgot-password
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /api/auth/reset-password
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /api/residences/join-with-code
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /api/auth/verify-reset-code
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /api/auth/apple-sign-in
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /api/auth/google-sign-in
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /api/auth/refresh
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /api/auth/account
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api
|
||||
port:
|
||||
number: 8000
|
||||
|
||||
@@ -21,12 +21,20 @@ spec:
|
||||
headers:
|
||||
frameDeny: true
|
||||
contentTypeNosniff: true
|
||||
browserXssFilter: true
|
||||
# browserXssFilter removed (audit L7): it emits the deprecated
|
||||
# X-XSS-Protection header, which can itself introduce XSS in legacy
|
||||
# browsers. Modern browsers ignore it.
|
||||
referrerPolicy: "strict-origin-when-cross-origin"
|
||||
customResponseHeaders:
|
||||
X-Content-Type-Options: "nosniff"
|
||||
X-Frame-Options: "DENY"
|
||||
Strict-Transport-Security: "max-age=31536000; includeSubDomains"
|
||||
# HSTS: 2-year max-age + preload (audit L5/CODE-L3). After this is
|
||||
# live on api/admin/app, submit myhoneydue.com to hstspreload.org.
|
||||
Strict-Transport-Security: "max-age=63072000; includeSubDomains; preload"
|
||||
# Cross-origin isolation (audit F9). COEP (require-corp) is omitted —
|
||||
# it commonly breaks third-party embeds; add only after testing.
|
||||
Cross-Origin-Opener-Policy: "same-origin"
|
||||
Cross-Origin-Resource-Policy: "same-origin"
|
||||
# Content-Security-Policy is intentionally NOT set here — the Go API
|
||||
# sets a CSP in internal/router/router.go that permits Google Fonts
|
||||
# for the landing page. Two CSP headers would intersect and break it.
|
||||
@@ -83,3 +91,24 @@ spec:
|
||||
basicAuth:
|
||||
secret: admin-basic-auth
|
||||
realm: "honeyDue Admin"
|
||||
|
||||
---
|
||||
# Strict rate limit for auth endpoints (audit F10 / LIVE-L12).
|
||||
# Applied via the honeydue-api-auth Ingress to login / register /
|
||||
# forgot-password / reset-password / join-with-code. depth: 2 makes the
|
||||
# limiter key on the real client IP rather than the Cloudflare edge IP
|
||||
# (request path: client -> Cloudflare -> Traefik). This is the edge half;
|
||||
# the per-account lockout in the Go app is the robust half.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: auth-rate-limit
|
||||
namespace: honeydue
|
||||
spec:
|
||||
rateLimit:
|
||||
average: 5
|
||||
burst: 10
|
||||
period: 1m
|
||||
sourceCriterion:
|
||||
ipStrategy:
|
||||
depth: 2
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Ory Kratos — honeyDue identity service (Phase 1: infrastructure)
|
||||
|
||||
This directory deploys [Ory Kratos](https://www.ory.sh/kratos/) into the
|
||||
`honeydue` namespace as the identity provider — replacing the hand-rolled auth
|
||||
in `internal/services/auth_service.go` etc.
|
||||
|
||||
**Phase 1 is infrastructure only.** Once deployed, Kratos runs but nothing uses
|
||||
it yet — the honeyDue Go API still does its own auth. Phase 2 (backend swap)
|
||||
and Phase 3 (KMP/web clients) follow. Migrating onto Kratos can lose all
|
||||
existing user data — honeyDue is pre-production, so no user import is done.
|
||||
|
||||
The deploy is **gated**: `03-deploy.sh` applies Kratos only when the
|
||||
`kratos-secrets` Secret exists, and `02-setup-secrets.sh` creates that Secret
|
||||
only when `config.yaml` has a `kratos:` block. Until then the existing stack
|
||||
deploys completely unaffected.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `configmap.yaml` | `kratos.yml`, identity schema, Google/Apple OIDC claim mappers (no secrets) |
|
||||
| `migrate-job.yaml` | `kratos migrate sql` — schema migration, run before the Deployment |
|
||||
| `kratos.yaml` | Deployment (×2), Service, NetworkPolicies |
|
||||
| `ingress.yaml` | `auth.myhoneydue.com` → Kratos public API :4433 |
|
||||
|
||||
## Operator prerequisites (must be done before deploying)
|
||||
|
||||
1. **Kratos version** — Ory uses CalVer (`v25.x` / `v26.x`). Pick the current
|
||||
stable, then replace `REPLACE_WITH_CURRENT_STABLE_TAG` in `kratos.yaml` and
|
||||
`migrate-job.yaml` with `oryd/kratos:vXX.Y@sha256:<digest>`, and set the
|
||||
matching `version:` in `configmap.yaml`.
|
||||
|
||||
2. **Kratos database** — create a separate Neon database named `kratos` (do not
|
||||
share honeyDue's). Capture its connection string as the DSN.
|
||||
|
||||
3. **DNS** — add `auth.myhoneydue.com` in Cloudflare (proxied), pointing at the
|
||||
cluster ingress like the other honeyDue hosts. Confirm the
|
||||
`cloudflare-origin-cert` TLS secret covers `auth.myhoneydue.com`.
|
||||
|
||||
4. **Google OAuth client** — Google Cloud Console → create an OAuth 2.0 client.
|
||||
Redirect URI: `https://auth.myhoneydue.com/self-service/methods/oidc/callback/google`.
|
||||
Put the **client ID** into `configmap.yaml` (`GOOGLE_OAUTH_CLIENT_ID`); the
|
||||
**client secret** goes in `config.yaml`.
|
||||
|
||||
5. **Apple Sign In** — Apple Developer → a Services ID + a Sign in with Apple
|
||||
key. Return URL: `https://auth.myhoneydue.com/self-service/methods/oidc/callback/apple`.
|
||||
Put the **Services ID / Team ID / Key ID** into `configmap.yaml`
|
||||
(`APPLE_SERVICES_ID` / `APPLE_TEAM_ID` / `APPLE_PRIVATE_KEY_ID`); the **.p8
|
||||
private key** goes in `config.yaml`.
|
||||
|
||||
6. **`config.yaml`** — add a `kratos:` block:
|
||||
```yaml
|
||||
kratos:
|
||||
dsn: "postgres://USER:PASS@HOST/kratos?sslmode=require"
|
||||
secrets_cookie: "<openssl rand -hex 16>" # generate ONCE, keep stable
|
||||
secrets_cipher: "<openssl rand -hex 16>" # must be exactly 32 chars
|
||||
smtp_connection_uri: "smtps://USER:PASS@smtp.fastmail.com:465/"
|
||||
google_client_secret: "<from Google Cloud Console>"
|
||||
apple_private_key: |
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
...
|
||||
-----END PRIVATE KEY-----
|
||||
```
|
||||
`secrets_cookie` / `secrets_cipher` must stay stable forever — rotating them
|
||||
invalidates every session and makes encrypted data unreadable.
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
cd honeyDueAPI-go
|
||||
export KUBECONFIG="$(pwd)/deploy-k3s/kubeconfig"
|
||||
./deploy-k3s/scripts/02-setup-secrets.sh # creates kratos-secrets from config.yaml
|
||||
./deploy-k3s/scripts/03-deploy.sh # applies kratos manifests, runs migrate, rolls
|
||||
```
|
||||
|
||||
`03-deploy.sh` applies `configmap.yaml` → runs `migrate-job.yaml` → waits →
|
||||
applies `kratos.yaml` + `ingress.yaml`.
|
||||
|
||||
## Verify
|
||||
|
||||
- `kubectl -n honeydue get pods -l app.kubernetes.io/name=kratos` — 2/2 Running
|
||||
- `kubectl -n honeydue logs job/kratos-migrate` — migration succeeded
|
||||
- `curl https://auth.myhoneydue.com/health/ready` — `{"status":"ok"}`
|
||||
- `curl https://auth.myhoneydue.com/self-service/registration/api` — returns a flow
|
||||
|
||||
## Not yet done (later phases)
|
||||
|
||||
- **Phase 2** — honeyDue Go backend: swap `middleware/auth.go` for Kratos
|
||||
session validation, drop the hand-rolled auth code, rebuild the `users`
|
||||
table keyed on the Kratos identity ID.
|
||||
- **Phase 3** — KMP mobile + Next.js web clients point at Kratos flows.
|
||||
- Admin-panel auth stays on its own JWT (out of scope).
|
||||
@@ -0,0 +1,232 @@
|
||||
# Ory Kratos configuration for honeyDue.
|
||||
#
|
||||
# Secrets are NOT in this ConfigMap. The DSN, cookie/cipher secrets, SMTP URI
|
||||
# and OIDC client secrets are injected as environment variables from the
|
||||
# kratos-secrets Secret (see kratos.yaml). Kratos is configured natively via
|
||||
# env vars, so this is the idiomatic split — only non-secret config here.
|
||||
#
|
||||
# OIDC scope: Apple-only as of 2026-06-03. Google is intentionally absent;
|
||||
# adding it later is additive — append a `- id: google` block under
|
||||
# selfservice.methods.oidc.config.providers (it becomes index 1) and bind a
|
||||
# matching CLIENT_SECRET env in kratos.yaml.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: kratos-config
|
||||
namespace: honeydue
|
||||
labels:
|
||||
app.kubernetes.io/name: kratos
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
data:
|
||||
kratos.yml: |
|
||||
# version must track the Kratos image tag — kratos.yaml + migrate-job.yaml
|
||||
# both pin oryd/kratos:v26.2.0 (2026-06-03). See kratos/README.md.
|
||||
version: v1.3.0 # internal config schema version; do not change unless Kratos release notes require it
|
||||
|
||||
serve:
|
||||
public:
|
||||
base_url: https://auth.myhoneydue.com/
|
||||
cors:
|
||||
enabled: true
|
||||
allowed_origins:
|
||||
- https://myhoneydue.com
|
||||
- https://app.myhoneydue.com
|
||||
- https://admin.myhoneydue.com
|
||||
allowed_methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
|
||||
allowed_headers: [Authorization, Content-Type, X-Session-Token, Cookie]
|
||||
exposed_headers: [Content-Type, Set-Cookie]
|
||||
# Required: the web clients call Kratos browser flows with
|
||||
# credentials (the ory_kratos_session cookie). Safe here because
|
||||
# allowed_origins is an explicit list, never a wildcard.
|
||||
allow_credentials: true
|
||||
admin:
|
||||
base_url: http://kratos.honeydue.svc.cluster.local:4434/
|
||||
|
||||
selfservice:
|
||||
default_browser_return_url: https://app.myhoneydue.com/
|
||||
allowed_return_urls:
|
||||
- https://app.myhoneydue.com
|
||||
- https://myhoneydue.com
|
||||
- honeydue://callback
|
||||
|
||||
methods:
|
||||
password:
|
||||
enabled: true
|
||||
code: # email one-time codes (verify/recover)
|
||||
enabled: true
|
||||
oidc:
|
||||
enabled: true
|
||||
config:
|
||||
providers:
|
||||
# index 0 — Apple Sign In. apple_private_key (.p8 contents) is
|
||||
# injected via env SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_APPLE_PRIVATE_KEY.
|
||||
# client_id is the Apple Services ID (here: the bundle ID, which
|
||||
# was configured as a Services ID with Sign In with Apple
|
||||
# capability — see operator notes in README.md §5).
|
||||
- id: apple
|
||||
provider: apple
|
||||
# Production bundle id. Apple issues id_tokens with
|
||||
# `aud` = the requesting app's bundle id, so this is the
|
||||
# primary audience Kratos verifies against.
|
||||
client_id: com.myhoneydue.honeyDue
|
||||
# Debug builds out of Xcode use a `.dev` bundle id (see
|
||||
# iosApp/honeyDue.xcodeproj — Debug config). Their id_tokens
|
||||
# therefore have `aud: com.myhoneydue.honeyDue.dev`, which
|
||||
# the primary client_id check rejects. Whitelist the dev
|
||||
# audience so Apple Sign In works from a non-Release Xcode
|
||||
# build without per-build Kratos reconfiguration.
|
||||
additional_id_token_audiences:
|
||||
- com.myhoneydue.honeyDue.dev
|
||||
apple_team_id: X86BR9WTLD
|
||||
apple_private_key_id: HQD3NCF99C
|
||||
mapper_url: file:///etc/kratos/oidc.apple.jsonnet
|
||||
scope: [openid, email, name]
|
||||
|
||||
flows:
|
||||
error:
|
||||
ui_url: https://app.myhoneydue.com/auth/error
|
||||
login:
|
||||
ui_url: https://app.myhoneydue.com/auth/login
|
||||
lifespan: 10m
|
||||
registration:
|
||||
ui_url: https://app.myhoneydue.com/auth/registration
|
||||
lifespan: 10m
|
||||
after:
|
||||
password:
|
||||
hooks:
|
||||
- hook: session # auto-login after registration
|
||||
oidc:
|
||||
hooks:
|
||||
- hook: session
|
||||
verification:
|
||||
enabled: true
|
||||
ui_url: https://app.myhoneydue.com/auth/verification
|
||||
use: code
|
||||
after:
|
||||
default_browser_return_url: https://app.myhoneydue.com/
|
||||
recovery:
|
||||
enabled: true
|
||||
ui_url: https://app.myhoneydue.com/auth/recovery
|
||||
use: code
|
||||
settings:
|
||||
ui_url: https://app.myhoneydue.com/auth/settings
|
||||
privileged_session_max_age: 15m
|
||||
logout:
|
||||
after:
|
||||
default_browser_return_url: https://app.myhoneydue.com/
|
||||
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
leak_sensitive_values: false
|
||||
|
||||
ciphers:
|
||||
algorithm: xchacha20-poly1305
|
||||
|
||||
hashers:
|
||||
algorithm: bcrypt
|
||||
bcrypt:
|
||||
cost: 12
|
||||
|
||||
identity:
|
||||
default_schema_id: honeydue
|
||||
schemas:
|
||||
- id: honeydue
|
||||
url: file:///etc/kratos/identity.schema.json
|
||||
|
||||
courier:
|
||||
smtp:
|
||||
from_address: noreply@myhoneydue.com
|
||||
from_name: honeyDue
|
||||
# connection_uri is injected via env COURIER_SMTP_CONNECTION_URI
|
||||
|
||||
session:
|
||||
lifespan: 720h # 30-day sessions (mobile)
|
||||
cookie:
|
||||
domain: myhoneydue.com
|
||||
same_site: Lax
|
||||
|
||||
identity.schema.json: |
|
||||
{
|
||||
"$id": "https://honeydue.app/identity.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "honeyDue user",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"title": "Email",
|
||||
"minLength": 3,
|
||||
"maxLength": 320,
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": { "identifier": true },
|
||||
"code": { "identifier": true, "via": "email" },
|
||||
"totp": { "account_name": true }
|
||||
},
|
||||
"verification": { "via": "email" },
|
||||
"recovery": { "via": "email" }
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"title": "Name",
|
||||
"properties": {
|
||||
"first": { "type": "string", "title": "First name", "maxLength": 100 },
|
||||
"last": { "type": "string", "title": "Last name", "maxLength": 100 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["email"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oidc.google.jsonnet: |
|
||||
// Maps Google OIDC claims onto the honeyDue identity schema.
|
||||
local claims = std.extVar('claims');
|
||||
{
|
||||
identity: {
|
||||
traits: {
|
||||
email: claims.email,
|
||||
[if 'given_name' in claims || 'family_name' in claims then 'name']: {
|
||||
first: if 'given_name' in claims then claims.given_name else '',
|
||||
last: if 'family_name' in claims then claims.family_name else '',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
oidc.apple.jsonnet: |
|
||||
// Maps Apple OIDC claims onto the honeyDue identity schema. Apple only
|
||||
// returns the name on the very first authorization and not in the ID
|
||||
// token claims, so only email is mapped here.
|
||||
//
|
||||
// Sign in with Apple emails are marked verified UNCONDITIONALLY: completing
|
||||
// SIWA cryptographically proves the user controls that Apple ID, and Apple
|
||||
// owns/verifies the (relay or real) email, so a 6-digit code would be
|
||||
// redundant. We deliberately do NOT gate this on Apple's `email_verified`
|
||||
// claim — Apple omits that claim on many authorizations (only sends it on
|
||||
// the first grant), which made auto-verification random: sometimes verified,
|
||||
// sometimes a surprise code prompt (observed 2026-06-03). Marking it
|
||||
// verified on every SIWA makes the behaviour consistent: Apple users never
|
||||
// see a code; password sign-ups still verify via the honeyDue API flow.
|
||||
local claims = std.extVar('claims');
|
||||
{
|
||||
identity: {
|
||||
traits: {
|
||||
email: claims.email,
|
||||
},
|
||||
verified_addresses: std.prune([
|
||||
if 'email' in claims then {
|
||||
via: 'email',
|
||||
value: claims.email,
|
||||
},
|
||||
]),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Public ingress for Ory Kratos — auth.myhoneydue.com → Kratos public API :4433.
|
||||
#
|
||||
# Middlewares match the honeyDue API ingress (security-headers + rate-limit).
|
||||
# The cloudflare-only middleware is intentionally NOT applied here: on this
|
||||
# cluster, klipper-lb SNATs the source IP before Traefik sees it, so
|
||||
# cloudflare-only's IP allowlist rejects every legitimate Cloudflare request
|
||||
# (verified 2026-06-03 — iOS Apple Sign In failed silently because Kratos
|
||||
# never received the request). The api ingress doesn't use cloudflare-only
|
||||
# for the same reason. DDoS protection still rides on Cloudflare's edge.
|
||||
#
|
||||
# Kratos's self-service flows are multi-request, so the strict auth-rate-limit
|
||||
# (5/min) is intentionally NOT used here — Kratos applies its own per-flow
|
||||
# protections.
|
||||
#
|
||||
# OPERATOR: confirm the cloudflare-origin-cert TLS secret covers
|
||||
# auth.myhoneydue.com (apex + wildcard origin cert), and add the
|
||||
# auth.myhoneydue.com DNS record in Cloudflare (proxied) → cluster ingress.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: honeydue-auth
|
||||
namespace: honeydue
|
||||
labels:
|
||||
app.kubernetes.io/name: kratos
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- auth.myhoneydue.com
|
||||
secretName: cloudflare-origin-cert
|
||||
rules:
|
||||
- host: auth.myhoneydue.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: kratos
|
||||
port:
|
||||
number: 4433
|
||||
@@ -0,0 +1,208 @@
|
||||
# Ory Kratos — identity service for honeyDue.
|
||||
#
|
||||
# Deployed once the operator has completed the prerequisites in kratos/README.md
|
||||
# (Neon `kratos` database, auth.myhoneydue.com DNS, Apple Sign In OIDC client,
|
||||
# and the kratos-secrets Secret). Until then 03-deploy.sh skips the Kratos
|
||||
# apply, so the existing stack is unaffected.
|
||||
#
|
||||
# IMAGE: pinned to oryd/kratos v26.2.0 (CalVer current stable as of 2026-06-03)
|
||||
# with the linux/amd64 digest. The schema-migration Job is in migrate-job.yaml
|
||||
# and runs before this Deployment rolls.
|
||||
#
|
||||
# OIDC: currently Apple-only (configmap.yaml providers[0]). Google was scoped
|
||||
# out at deploy time; adding it later is additive — append to providers[] in
|
||||
# configmap.yaml and add the matching CLIENT_SECRET env binding here.
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: kratos
|
||||
namespace: honeydue
|
||||
labels:
|
||||
app.kubernetes.io/name: kratos
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
replicas: 2
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 0
|
||||
maxSurge: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: kratos
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: kratos
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: kratos
|
||||
image: oryd/kratos:v26.2.0@sha256:92eedc292ff8e1a918ac442c88ed0abe44610c75121700963114549908a45ac3
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- serve
|
||||
- --config
|
||||
- /etc/kratos/kratos.yml
|
||||
- --watch-courier # send verification/recovery email in-process
|
||||
ports:
|
||||
- name: public
|
||||
containerPort: 4433
|
||||
- name: admin
|
||||
containerPort: 4434
|
||||
env:
|
||||
# Kratos is configured natively via env vars; secrets come from
|
||||
# the kratos-secrets Secret rather than the ConfigMap.
|
||||
- name: DSN
|
||||
valueFrom: { secretKeyRef: { name: kratos-secrets, key: dsn } }
|
||||
- name: SECRETS_COOKIE
|
||||
valueFrom: { secretKeyRef: { name: kratos-secrets, key: secrets_cookie } }
|
||||
- name: SECRETS_CIPHER
|
||||
valueFrom: { secretKeyRef: { name: kratos-secrets, key: secrets_cipher } }
|
||||
- name: COURIER_SMTP_CONNECTION_URI
|
||||
valueFrom: { secretKeyRef: { name: kratos-secrets, key: smtp_connection_uri } }
|
||||
# OIDC provider secrets — index must match the providers list
|
||||
# order in configmap.yaml. Apple-only for now (index 0).
|
||||
- name: SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_APPLE_PRIVATE_KEY
|
||||
valueFrom: { secretKeyRef: { name: kratos-secrets, key: apple_private_key } }
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/kratos
|
||||
readOnly: true
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 4434
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/alive
|
||||
port: 4434
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 512Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: kratos-config
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 64Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: kratos
|
||||
namespace: honeydue
|
||||
labels:
|
||||
app.kubernetes.io/name: kratos
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: kratos
|
||||
ports:
|
||||
- name: public
|
||||
port: 4433
|
||||
targetPort: 4433
|
||||
- name: admin
|
||||
port: 4434
|
||||
targetPort: 4434
|
||||
---
|
||||
# Ingress to Kratos. Traefik (the auth.myhoneydue.com IngressRoute) reaches
|
||||
# only the public API :4433. The honeyDue api pods reach the public API :4433
|
||||
# (session whoami) AND the admin API :4434 (identity deletion on account
|
||||
# close). The admin API :4434 takes no other cluster ingress.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-ingress-to-kratos
|
||||
namespace: honeydue
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: kratos
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
# Traefik ingress controller -> public API only.
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- port: 4433
|
||||
protocol: TCP
|
||||
# honeyDue api pods -> public API (whoami) + admin API (identity deletion).
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: api
|
||||
ports:
|
||||
- port: 4433
|
||||
protocol: TCP
|
||||
- port: 4434
|
||||
protocol: TCP
|
||||
---
|
||||
# Kratos egress: DNS, the Neon Postgres database, SMTP, and HTTPS to the
|
||||
# OIDC providers (Apple/Google token + JWKS endpoints).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-egress-from-kratos
|
||||
namespace: honeydue
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: kratos
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector: {}
|
||||
ports:
|
||||
- port: 53
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
# Neon Postgres (external)
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 0.0.0.0/0
|
||||
except:
|
||||
- 10.42.0.0/16
|
||||
- 10.43.0.0/16
|
||||
ports:
|
||||
- port: 5432
|
||||
protocol: TCP
|
||||
# SMTP (Fastmail) + HTTPS to Apple/Google OIDC endpoints (external)
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 0.0.0.0/0
|
||||
except:
|
||||
- 10.42.0.0/16
|
||||
- 10.43.0.0/16
|
||||
ports:
|
||||
- port: 465
|
||||
protocol: TCP
|
||||
- port: 443
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,51 @@
|
||||
# Ory Kratos schema migration — runs `kratos migrate sql` against the Kratos
|
||||
# database before the Kratos Deployment rolls. 03-deploy.sh applies this,
|
||||
# waits for completion, then applies kratos.yaml.
|
||||
#
|
||||
# IMAGE: pinned to oryd/kratos v26.2.0 (CalVer current stable as of 2026-06-03)
|
||||
# with the linux/amd64 digest. Bump in sync with kratos.yaml's image.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: kratos-migrate
|
||||
namespace: honeydue
|
||||
labels:
|
||||
app.kubernetes.io/name: kratos
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: kratos
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: kratos-migrate
|
||||
image: oryd/kratos:v26.2.0@sha256:92eedc292ff8e1a918ac442c88ed0abe44610c75121700963114549908a45ac3
|
||||
imagePullPolicy: IfNotPresent
|
||||
args: ["migrate", "sql", "-e", "--yes"]
|
||||
env:
|
||||
- name: DSN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: kratos-secrets
|
||||
key: dsn
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
@@ -0,0 +1,61 @@
|
||||
# Kyverno image-signature verification policy (audit CODE-L5).
|
||||
#
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# THIS MANIFEST IS NOT APPLIED BY 03-deploy.sh. It is intentionally outside
|
||||
# the script's apply set. Applying it before the prerequisites are in place
|
||||
# would block every honeydue Pod from scheduling. Operator steps:
|
||||
#
|
||||
# 1. Install Kyverno in the cluster (it is an admission controller):
|
||||
# kubectl create -f https://github.com/kyverno/kyverno/releases/latest/download/install.yaml
|
||||
# 2. Generate a cosign key pair and keep the private key safe:
|
||||
# cosign generate-key-pair # -> cosign.key (PRIVATE) + cosign.pub
|
||||
# Set COSIGN_KEY=cosign.key in the deploy environment so 03-deploy.sh
|
||||
# signs images after pushing them (the signing step is already wired,
|
||||
# guarded, into 03-deploy.sh).
|
||||
# 3. Paste the contents of cosign.pub into the publicKeys block below.
|
||||
# 4. Apply this policy: kubectl apply -f deploy-k3s/manifests/kyverno-verify-images.yaml
|
||||
# 5. After confirming honeydue Pods still schedule, flip
|
||||
# validationFailureAction from Audit to Enforce.
|
||||
#
|
||||
# Until then it is a documented, ready-to-use template — not active config.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
apiVersion: kyverno.io/v1
|
||||
kind: ClusterPolicy
|
||||
metadata:
|
||||
name: verify-honeydue-images
|
||||
annotations:
|
||||
policies.kyverno.io/title: Verify honeyDue image signatures
|
||||
policies.kyverno.io/description: >-
|
||||
Requires that honeyDue application images pulled into the honeydue
|
||||
namespace carry a valid cosign signature made with the operator's key.
|
||||
spec:
|
||||
# Audit first — logs violations without blocking. Switch to Enforce once
|
||||
# signing is confirmed working end to end.
|
||||
validationFailureAction: Audit
|
||||
background: false
|
||||
webhookTimeoutSeconds: 30
|
||||
rules:
|
||||
- name: verify-gitea-image-signatures
|
||||
match:
|
||||
any:
|
||||
- resources:
|
||||
kinds:
|
||||
- Pod
|
||||
namespaces:
|
||||
- honeydue
|
||||
verifyImages:
|
||||
# Only the images we build and sign. Public base images
|
||||
# (redis, vmagent) are pinned by digest instead — see their manifests.
|
||||
- imageReferences:
|
||||
- "gitea.treytartt.com/admin/honeydue-api*"
|
||||
- "gitea.treytartt.com/admin/honeydue-worker*"
|
||||
- "gitea.treytartt.com/admin/honeydue-admin*"
|
||||
- "gitea.treytartt.com/admin/honeydue-web*"
|
||||
attestors:
|
||||
- count: 1
|
||||
entries:
|
||||
- keys:
|
||||
publicKeys: |-
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
REPLACE_WITH_CONTENTS_OF_cosign.pub
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -27,8 +27,10 @@ spec:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
# The migrate Job never calls the k8s API (audit F11).
|
||||
automountServiceAccountToken: false
|
||||
imagePullSecrets:
|
||||
- name: ghcr-credentials
|
||||
- name: gitea-credentials
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
@@ -38,6 +40,7 @@ spec:
|
||||
containers:
|
||||
- name: goose
|
||||
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh — same as api
|
||||
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit
|
||||
command: ["/bin/sh", "-c"]
|
||||
# DB_HOST in the ConfigMap points at the -pooler endpoint for runtime.
|
||||
# goose's session-scoped advisory lock can't survive PgBouncer
|
||||
|
||||
@@ -140,6 +140,20 @@ spec:
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 6379
|
||||
# Kratos (in-cluster). The auth middleware validates every session via
|
||||
# http://kratos:4433/sessions/whoami; the AuthService also uses :4434
|
||||
# for account deletion (DELETE /admin/identities/{id}). k3s evaluates
|
||||
# egress rules AFTER kube-proxy DNAT (runbook §9.2), so this podSelector
|
||||
# rule covers Service ClusterIP traffic correctly.
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: kratos
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 4433
|
||||
- protocol: TCP
|
||||
port: 4434
|
||||
# External services: Neon DB (5432), SMTP (587), HTTPS (443 — APNs, FCM, B2, PostHog)
|
||||
- to:
|
||||
- ipBlock:
|
||||
@@ -275,3 +289,154 @@ spec:
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 443
|
||||
|
||||
---
|
||||
# vmagent egress.
|
||||
#
|
||||
# IMPORTANT (gotcha): k3s's built-in NetworkPolicy controller appears to
|
||||
# evaluate egress rules AFTER kube-proxy's DNAT, not before (contrary to
|
||||
# the k8s spec). So traffic from a pod to the kubernetes Service
|
||||
# (ClusterIP 10.43.0.1:443) is policy-checked as dst=<node_public_ip>:6443.
|
||||
# That's why we need an explicit rule for :6443 to public IPs, even though
|
||||
# we already allow :443 to the cluster service CIDR.
|
||||
#
|
||||
# Without the :6443 rule, vmagent's k8s service discovery silently fails
|
||||
# and zero pods get scraped. See deploy-k3s/RUNBOOK.md ("vmagent SD broken").
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-egress-from-vmagent
|
||||
namespace: honeydue
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: vmagent
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
# DNS (cluster-internal)
|
||||
- to:
|
||||
- namespaceSelector: {}
|
||||
ports:
|
||||
- port: 53
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
# k8s API server via ClusterIP (pre-DNAT view)
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.43.0.0/16
|
||||
ports:
|
||||
- port: 443
|
||||
protocol: TCP
|
||||
# k8s API server post-DNAT (real path k3s NetPol enforcer sees) — REQUIRED
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 0.0.0.0/0
|
||||
except:
|
||||
- 10.42.0.0/16
|
||||
ports:
|
||||
- port: 6443
|
||||
protocol: TCP
|
||||
# Scrape api Pods on :8000
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.42.0.0/16
|
||||
ports:
|
||||
- port: 8000
|
||||
protocol: TCP
|
||||
# Scrape kube-state-metrics Pod on :8080 (pod CIDR)
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.42.0.0/16
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
# HTTPS to public (remote-write to obs.88oakapps.com via Cloudflare)
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 0.0.0.0/0
|
||||
except:
|
||||
- 10.42.0.0/16
|
||||
- 10.43.0.0/16
|
||||
ports:
|
||||
- port: 443
|
||||
protocol: TCP
|
||||
|
||||
---
|
||||
# Allow vmagent → api ingress on :8000 so api pods accept scrapes.
|
||||
# api Pods are otherwise locked down by default-deny-all + allow-ingress-to-api
|
||||
# (which only allows Traefik). This adds vmagent specifically.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-vmagent-to-api
|
||||
namespace: honeydue
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: api
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: vmagent
|
||||
ports:
|
||||
- port: 8000
|
||||
protocol: TCP
|
||||
|
||||
---
|
||||
# alloy-logs egress — Grafana Alloy discovers honeydue pods via the k8s API
|
||||
# and pushes their logs to Loki at obs.88oakapps.com. Same k3s NetworkPolicy
|
||||
# DNAT gotcha as vmagent: API-server traffic is policy-checked as
|
||||
# dst=<node_public_ip>:6443, so an explicit :6443 rule is required.
|
||||
# Alloy reads log FILES from a hostPath, so it needs no ingress and no
|
||||
# egress to pod :8000/:8080 — only DNS, the API server, and obs HTTPS.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-egress-from-alloy-logs
|
||||
namespace: honeydue
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: alloy-logs
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
# DNS (cluster-internal)
|
||||
- to:
|
||||
- namespaceSelector: {}
|
||||
ports:
|
||||
- port: 53
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
# k8s API server via ClusterIP (pre-DNAT view)
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.43.0.0/16
|
||||
ports:
|
||||
- port: 443
|
||||
protocol: TCP
|
||||
# k8s API server post-DNAT (real path k3s NetPol enforcer sees) — REQUIRED
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 0.0.0.0/0
|
||||
except:
|
||||
- 10.42.0.0/16
|
||||
ports:
|
||||
- port: 6443
|
||||
protocol: TCP
|
||||
# HTTPS to public (log push to obs.88oakapps.com via Cloudflare)
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 0.0.0.0/0
|
||||
except:
|
||||
- 10.42.0.0/16
|
||||
- 10.43.0.0/16
|
||||
ports:
|
||||
- port: 443
|
||||
protocol: TCP
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# honeyDue log shipper — Grafana Alloy as a DaemonSet.
|
||||
#
|
||||
# Each node runs one Alloy pod that tails the honeydue-namespace pod logs in
|
||||
# /var/log/pods and pushes them to Loki at obs.88oakapps.com/loki/api/v1/push
|
||||
# (the same nginx ingest endpoint + bearer token vmagent uses for metrics).
|
||||
#
|
||||
# Runs as root: /var/log/pods is 0750 root:root on the k3s nodes, so a
|
||||
# non-root uid cannot even traverse it. The container is otherwise locked
|
||||
# down — all capabilities dropped, read-only root filesystem, seccomp
|
||||
# RuntimeDefault — and root inside the container reads only a read-only
|
||||
# hostPath mount of /var/log/pods. This is the one root-running workload in
|
||||
# the namespace (standard for log collectors); see docs/deployment.
|
||||
#
|
||||
# 03-deploy.sh substitutes TOKEN_PLACEHOLDER with OBS_INGEST_TOKEN from
|
||||
# deploy/prod.env before applying — the token never lands in the repo.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: alloy-logs
|
||||
namespace: honeydue
|
||||
labels:
|
||||
app.kubernetes.io/name: alloy-logs
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
---
|
||||
# Least privilege: Alloy's discovery.kubernetes only lists/watches pods, and
|
||||
# only in the honeydue namespace — so a namespaced Role, not a ClusterRole.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: alloy-logs
|
||||
namespace: honeydue
|
||||
labels:
|
||||
app.kubernetes.io/name: alloy-logs
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: alloy-logs
|
||||
namespace: honeydue
|
||||
labels:
|
||||
app.kubernetes.io/name: alloy-logs
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: alloy-logs
|
||||
namespace: honeydue
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: alloy-logs
|
||||
---
|
||||
# Bearer token for the Loki push endpoint. TOKEN_PLACEHOLDER is replaced by
|
||||
# 03-deploy.sh with OBS_INGEST_TOKEN (same token vmagent uses).
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: alloy-logs-auth
|
||||
namespace: honeydue
|
||||
labels:
|
||||
app.kubernetes.io/name: alloy-logs
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
type: Opaque
|
||||
stringData:
|
||||
bearer_token: TOKEN_PLACEHOLDER
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: alloy-logs
|
||||
namespace: honeydue
|
||||
labels:
|
||||
app.kubernetes.io/name: alloy-logs
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
data:
|
||||
config.alloy: |
|
||||
// honeyDue log shipper. Each DaemonSet instance discovers honeydue-namespace
|
||||
// pods via the Kubernetes API, tails the container log files present on its
|
||||
// own node (/var/log/pods), and pushes them to Loki at obs.88oakapps.com.
|
||||
|
||||
logging {
|
||||
level = "warn"
|
||||
format = "logfmt"
|
||||
}
|
||||
|
||||
discovery.kubernetes "pods" {
|
||||
role = "pod"
|
||||
namespaces {
|
||||
names = ["honeydue"]
|
||||
}
|
||||
}
|
||||
|
||||
// Turn pod metadata into Loki labels and build the on-disk log path.
|
||||
discovery.relabel "pod_logs" {
|
||||
targets = discovery.kubernetes.pods.targets
|
||||
|
||||
rule {
|
||||
source_labels = ["__meta_kubernetes_namespace"]
|
||||
action = "replace"
|
||||
target_label = "namespace"
|
||||
}
|
||||
rule {
|
||||
source_labels = ["__meta_kubernetes_pod_name"]
|
||||
action = "replace"
|
||||
target_label = "pod"
|
||||
}
|
||||
rule {
|
||||
source_labels = ["__meta_kubernetes_pod_container_name"]
|
||||
action = "replace"
|
||||
target_label = "container"
|
||||
}
|
||||
rule {
|
||||
source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"]
|
||||
action = "replace"
|
||||
target_label = "app"
|
||||
}
|
||||
rule {
|
||||
source_labels = ["__meta_kubernetes_pod_node_name"]
|
||||
action = "replace"
|
||||
target_label = "node"
|
||||
}
|
||||
// /var/log/pods/<namespace>_<pod>_<uid>/<container>/<n>.log
|
||||
rule {
|
||||
source_labels = ["__meta_kubernetes_pod_uid", "__meta_kubernetes_pod_container_name"]
|
||||
separator = "/"
|
||||
action = "replace"
|
||||
replacement = "/var/log/pods/*$1/*.log"
|
||||
target_label = "__path__"
|
||||
}
|
||||
}
|
||||
|
||||
local.file_match "pod_logs" {
|
||||
path_targets = discovery.relabel.pod_logs.output
|
||||
}
|
||||
|
||||
loki.source.file "pod_logs" {
|
||||
targets = local.file_match.pod_logs.targets
|
||||
forward_to = [loki.process.pod_logs.receiver]
|
||||
// With no stored read offset (fresh node, or positions wiped), start
|
||||
// at the END of each file instead of re-shipping history — otherwise
|
||||
// Loki rejects the now-too-old entries ("entry too far behind") and
|
||||
// shipping stalls. Offsets persist on a hostPath (see volumes), so a
|
||||
// normal pod restart resumes exactly where it left off.
|
||||
tail_from_end = true
|
||||
}
|
||||
|
||||
// Parse the CRI log format (timestamp / stream / flags / message),
|
||||
// then drop probe/scrape noise before shipping.
|
||||
loki.process "pod_logs" {
|
||||
forward_to = [loki.write.obs.receiver]
|
||||
|
||||
stage.cri {}
|
||||
|
||||
// Drop successful probe/scrape access logs. k8s liveness/readiness
|
||||
// hits /api/health/ every few seconds and vmagent scrapes /metrics
|
||||
// on a 15s interval — all 2xx, pure noise that drowns real logs.
|
||||
// A non-2xx health check, or one logged above info level, does NOT
|
||||
// match this regex and is kept.
|
||||
stage.drop {
|
||||
expression = "\"level\":\"info\".*\"path\":\"/(api/health/?|metrics)\".*\"status\":2[0-9][0-9]"
|
||||
drop_counter_reason = "probe_access_ok"
|
||||
}
|
||||
}
|
||||
|
||||
loki.write "obs" {
|
||||
endpoint {
|
||||
url = "https://obs.88oakapps.com/loki/api/v1/push"
|
||||
bearer_token_file = "/etc/alloy-secrets/bearer_token"
|
||||
}
|
||||
external_labels = {
|
||||
cluster = "honeydue-k3s",
|
||||
environment = "prod",
|
||||
}
|
||||
}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: alloy-logs
|
||||
namespace: honeydue
|
||||
labels:
|
||||
app.kubernetes.io/name: alloy-logs
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: alloy-logs
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: alloy-logs
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
serviceAccountName: alloy-logs
|
||||
# Alloy needs its SA token — discovery.kubernetes talks to the API server.
|
||||
automountServiceAccountToken: true
|
||||
# Root is required to traverse /var/log/pods (0750 root:root). The
|
||||
# container is otherwise fully confined (see container securityContext).
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
runAsGroup: 0
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
tolerations:
|
||||
# DaemonSet must run on every node, including any control-plane taint.
|
||||
- key: node-role.kubernetes.io/control-plane
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: alloy
|
||||
image: grafana/alloy:v1.5.1@sha256:01a63f4e032ce54ee94b22049bc27f597e74f85566478c377f4b5c7f020c1eb3
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- run
|
||||
- /etc/alloy/config.alloy
|
||||
- --storage.path=/tmp/alloy
|
||||
- --server.http.listen-addr=0.0.0.0:12345
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 12345
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/alloy
|
||||
readOnly: true
|
||||
- name: auth
|
||||
mountPath: /etc/alloy-secrets
|
||||
readOnly: true
|
||||
- name: varlogpods
|
||||
mountPath: /var/log/pods
|
||||
readOnly: true
|
||||
- name: tmp
|
||||
mountPath: /tmp/alloy
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /-/ready
|
||||
port: 12345
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 150m
|
||||
memory: 256Mi
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: alloy-logs
|
||||
- name: auth
|
||||
secret:
|
||||
secretName: alloy-logs-auth
|
||||
defaultMode: 0400
|
||||
- name: varlogpods
|
||||
hostPath:
|
||||
path: /var/log/pods
|
||||
type: Directory
|
||||
# Alloy's positions/WAL store. A hostPath (not emptyDir) so file read
|
||||
# offsets survive pod restarts — otherwise every restart re-reads log
|
||||
# files from the start and Loki rejects the now-too-old entries.
|
||||
- name: tmp
|
||||
hostPath:
|
||||
path: /var/lib/honeydue-alloy-logs
|
||||
type: DirectoryOrCreate
|
||||
@@ -0,0 +1,223 @@
|
||||
# kube-state-metrics — exposes cluster object state (pods, deployments,
|
||||
# services, etc.) as Prometheus metrics. vmagent scrapes it via the api
|
||||
# group defined in vmagent-config; Grafana panels that count pods,
|
||||
# replicas, etc. consume the `kube_*` metrics this produces.
|
||||
#
|
||||
# Lives in kube-system because it watches resources cluster-wide.
|
||||
# RBAC is cluster-scoped (ClusterRole + ClusterRoleBinding).
|
||||
#
|
||||
# Image: registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.13.0
|
||||
# (latest stable as of authoring; bump when a newer minor is released)
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: kube-state-metrics
|
||||
namespace: kube-system
|
||||
labels:
|
||||
app.kubernetes.io/name: kube-state-metrics
|
||||
app.kubernetes.io/part-of: honeydue-observability
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: kube-state-metrics
|
||||
labels:
|
||||
app.kubernetes.io/name: kube-state-metrics
|
||||
app.kubernetes.io/part-of: honeydue-observability
|
||||
rules:
|
||||
# Core resources
|
||||
- apiGroups: [""]
|
||||
resources:
|
||||
- configmaps
|
||||
- secrets
|
||||
- nodes
|
||||
- pods
|
||||
- services
|
||||
- serviceaccounts
|
||||
- resourcequotas
|
||||
- replicationcontrollers
|
||||
- limitranges
|
||||
- persistentvolumeclaims
|
||||
- persistentvolumes
|
||||
- namespaces
|
||||
- endpoints
|
||||
verbs: [list, watch]
|
||||
# Apps
|
||||
- apiGroups: ["apps"]
|
||||
resources:
|
||||
- statefulsets
|
||||
- daemonsets
|
||||
- deployments
|
||||
- replicasets
|
||||
verbs: [list, watch]
|
||||
# Batch
|
||||
- apiGroups: ["batch"]
|
||||
resources:
|
||||
- cronjobs
|
||||
- jobs
|
||||
verbs: [list, watch]
|
||||
# Autoscaling
|
||||
- apiGroups: ["autoscaling"]
|
||||
resources:
|
||||
- horizontalpodautoscalers
|
||||
verbs: [list, watch]
|
||||
# Authentication / authorization (used by some ksm collectors)
|
||||
- apiGroups: ["authentication.k8s.io"]
|
||||
resources: [tokenreviews]
|
||||
verbs: [create]
|
||||
- apiGroups: ["authorization.k8s.io"]
|
||||
resources: [subjectaccessreviews]
|
||||
verbs: [create]
|
||||
# Policy
|
||||
- apiGroups: ["policy"]
|
||||
resources: [poddisruptionbudgets]
|
||||
verbs: [list, watch]
|
||||
# Certificate signing
|
||||
- apiGroups: ["certificates.k8s.io"]
|
||||
resources: [certificatesigningrequests]
|
||||
verbs: [list, watch]
|
||||
# Discovery
|
||||
- apiGroups: ["discovery.k8s.io"]
|
||||
resources: [endpointslices]
|
||||
verbs: [list, watch]
|
||||
# Storage
|
||||
- apiGroups: ["storage.k8s.io"]
|
||||
resources:
|
||||
- storageclasses
|
||||
- volumeattachments
|
||||
verbs: [list, watch]
|
||||
# Admission policy
|
||||
- apiGroups: ["admissionregistration.k8s.io"]
|
||||
resources:
|
||||
- mutatingwebhookconfigurations
|
||||
- validatingwebhookconfigurations
|
||||
verbs: [list, watch]
|
||||
# Networking
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources:
|
||||
- networkpolicies
|
||||
- ingressclasses
|
||||
- ingresses
|
||||
verbs: [list, watch]
|
||||
# Coordination (leader election)
|
||||
- apiGroups: ["coordination.k8s.io"]
|
||||
resources: [leases]
|
||||
verbs: [list, watch]
|
||||
# RBAC
|
||||
- apiGroups: ["rbac.authorization.k8s.io"]
|
||||
resources:
|
||||
- clusterrolebindings
|
||||
- clusterroles
|
||||
- rolebindings
|
||||
- roles
|
||||
verbs: [list, watch]
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: kube-state-metrics
|
||||
labels:
|
||||
app.kubernetes.io/name: kube-state-metrics
|
||||
app.kubernetes.io/part-of: honeydue-observability
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: kube-state-metrics
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: kube-state-metrics
|
||||
namespace: kube-system
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: kube-state-metrics
|
||||
namespace: kube-system
|
||||
labels:
|
||||
app.kubernetes.io/name: kube-state-metrics
|
||||
app.kubernetes.io/part-of: honeydue-observability
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: kube-state-metrics
|
||||
ports:
|
||||
- name: http-metrics
|
||||
port: 8080
|
||||
targetPort: http-metrics
|
||||
protocol: TCP
|
||||
- name: telemetry
|
||||
port: 8081
|
||||
targetPort: telemetry
|
||||
protocol: TCP
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: kube-state-metrics
|
||||
namespace: kube-system
|
||||
labels:
|
||||
app.kubernetes.io/name: kube-state-metrics
|
||||
app.kubernetes.io/part-of: honeydue-observability
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: kube-state-metrics
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: kube-state-metrics
|
||||
app.kubernetes.io/part-of: honeydue-observability
|
||||
spec:
|
||||
serviceAccountName: kube-state-metrics
|
||||
automountServiceAccountToken: true
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
fsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: kube-state-metrics
|
||||
image: registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.13.0
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http-metrics
|
||||
- containerPort: 8081
|
||||
name: telemetry
|
||||
args:
|
||||
- --port=8080
|
||||
- --telemetry-port=8081
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
readOnlyRootFilesystem: true
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: http-metrics
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: http-metrics
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
@@ -0,0 +1,126 @@
|
||||
# node-exporter — per-node host metrics (filesystem, memory, load, CPU).
|
||||
# Runs as a normal pod (NOT hostNetwork) so vmagent scrapes it pod-to-pod over
|
||||
# the cluster CIDR, avoiding any dependency on node public IPs (the netpol
|
||||
# node-IP list is OVH-stale). Host /proc, /sys and / are bind-mounted read-only
|
||||
# so the filesystem/memory/load collectors read the real host, not the pod ns.
|
||||
# Added 2026-06-08 to close RUNBOOK §11.1 gap #9 (node disk/mem were unmonitored).
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: node-exporter
|
||||
namespace: honeydue
|
||||
labels:
|
||||
app.kubernetes.io/name: node-exporter
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: node-exporter
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: node-exporter
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
# Run on every node, including any tainted control-plane nodes.
|
||||
tolerations:
|
||||
- operator: Exists
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: node-exporter
|
||||
image: quay.io/prometheus/node-exporter:v1.8.2 # TODO digest-pin (audit K3S-F14)
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- --path.procfs=/host/proc
|
||||
- --path.sysfs=/host/sys
|
||||
- --path.rootfs=/host/root
|
||||
# Only report real host mounts; drop the kubelet/container churn.
|
||||
- --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|run|var/lib/kubelet/.+|var/lib/docker/.+|var/lib/containerd/.+)($|/)
|
||||
- --collector.filesystem.fs-types-exclude=^(autofs|binfmt_misc|bpf|cgroup2?|configfs|debugfs|devpts|devtmpfs|fusectl|hugetlbfs|iso9660|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs)$
|
||||
- --no-collector.wifi
|
||||
- --no-collector.hwmon
|
||||
- --web.listen-address=:9100
|
||||
ports:
|
||||
- name: metrics
|
||||
containerPort: 9100
|
||||
protocol: TCP
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
resources:
|
||||
requests:
|
||||
cpu: 30m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 128Mi
|
||||
volumeMounts:
|
||||
- name: proc
|
||||
mountPath: /host/proc
|
||||
readOnly: true
|
||||
- name: sys
|
||||
mountPath: /host/sys
|
||||
readOnly: true
|
||||
- name: root
|
||||
mountPath: /host/root
|
||||
mountPropagation: HostToContainer
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: proc
|
||||
hostPath:
|
||||
path: /proc
|
||||
- name: sys
|
||||
hostPath:
|
||||
path: /sys
|
||||
- name: root
|
||||
hostPath:
|
||||
path: /
|
||||
---
|
||||
# default-deny-all blocks ingress; allow vmagent to scrape :9100.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-ingress-to-node-exporter
|
||||
namespace: honeydue
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: node-exporter
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: vmagent
|
||||
ports:
|
||||
- port: 9100
|
||||
protocol: TCP
|
||||
---
|
||||
# vmagent's existing egress policy only opens :8000/:8080 to the pod CIDR.
|
||||
# Additive policy (NetworkPolicies are OR'd) opening :9100 for the node-exporter
|
||||
# scrape — leaves the working allow-egress-from-vmagent policy untouched.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-egress-from-vmagent-to-node-exporter
|
||||
namespace: honeydue
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: vmagent
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.42.0.0/16
|
||||
ports:
|
||||
- port: 9100
|
||||
protocol: TCP
|
||||
@@ -42,18 +42,61 @@ data:
|
||||
- target_label: service
|
||||
replacement: api
|
||||
|
||||
# honeyDue worker — also exposes /metrics if/when we add it.
|
||||
# Keep this stanza commented until the worker has a /metrics endpoint;
|
||||
# uncommented form drops scrapes silently.
|
||||
# - job_name: worker
|
||||
# kubernetes_sd_configs:
|
||||
# - role: pod
|
||||
# namespaces:
|
||||
# names: [honeydue]
|
||||
# relabel_configs:
|
||||
# - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
|
||||
# action: keep
|
||||
# regex: worker
|
||||
# kube-state-metrics — cluster object state (kube_pod_*, kube_deployment_*,
|
||||
# etc.) needed for Grafana panels that count pods/replicas/etc.
|
||||
- job_name: kube-state-metrics
|
||||
kubernetes_sd_configs:
|
||||
- role: endpoints
|
||||
namespaces:
|
||||
names: [kube-system]
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_kubernetes_service_label_app_kubernetes_io_name]
|
||||
action: keep
|
||||
regex: kube-state-metrics
|
||||
- source_labels: [__meta_kubernetes_endpoint_port_name]
|
||||
action: keep
|
||||
regex: http-metrics
|
||||
|
||||
# node-exporter — per-node host metrics (node_filesystem_*, node_memory_*,
|
||||
# node_load*). Pod-networked DaemonSet scraped on :9100 over the pod CIDR.
|
||||
- job_name: node-exporter
|
||||
kubernetes_sd_configs:
|
||||
- role: pod
|
||||
namespaces:
|
||||
names: [honeydue]
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
|
||||
action: keep
|
||||
regex: node-exporter
|
||||
- source_labels: [__meta_kubernetes_pod_container_port_number]
|
||||
action: keep
|
||||
regex: "9100"
|
||||
- source_labels: [__meta_kubernetes_pod_name]
|
||||
target_label: pod
|
||||
- source_labels: [__meta_kubernetes_pod_node_name]
|
||||
target_label: node
|
||||
- target_label: service
|
||||
replacement: node-exporter
|
||||
|
||||
# honeyDue worker — exposes /metrics on :6060 (apns/fcm/asynq/cache series).
|
||||
- job_name: worker
|
||||
kubernetes_sd_configs:
|
||||
- role: pod
|
||||
namespaces:
|
||||
names: [honeydue]
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
|
||||
action: keep
|
||||
regex: worker
|
||||
- source_labels: [__meta_kubernetes_pod_container_port_number]
|
||||
action: keep
|
||||
regex: "6060"
|
||||
- source_labels: [__meta_kubernetes_pod_name]
|
||||
target_label: pod
|
||||
- source_labels: [__meta_kubernetes_pod_node_name]
|
||||
target_label: node
|
||||
- target_label: service
|
||||
replacement: worker
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
@@ -104,6 +147,35 @@ roleRef:
|
||||
name: vmagent
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
|
||||
---
|
||||
# Allow vmagent to discover the kube-state-metrics Service/Endpoints in
|
||||
# kube-system so the kube-state-metrics scrape job can find its target.
|
||||
# Cross-namespace SD needs an explicit RoleBinding here.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: vmagent-kube-system
|
||||
namespace: kube-system
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: [services, endpoints, pods]
|
||||
verbs: [get, list, watch]
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: vmagent-kube-system
|
||||
namespace: kube-system
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: vmagent
|
||||
namespace: honeydue
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: vmagent-kube-system
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
@@ -135,7 +207,17 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: vmagent
|
||||
image: victoriametrics/vmagent:v1.106.1
|
||||
# Pinned by digest (audit K3S-F14).
|
||||
image: victoriametrics/vmagent:v1.106.1@sha256:90208a667c0baf65f7536b92a84c40b6e35ffe8e88bda7e4447b97b06c6ba6b8
|
||||
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit
|
||||
# Container-level hardening (audit F7) — matches the other 5
|
||||
# workloads. vmagent only writes to the /tmp/vmagent emptyDir
|
||||
# (its remoteWrite buffer), so a read-only root filesystem holds.
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
args:
|
||||
- "-promscrape.config=/etc/vmagent/scrape.yaml"
|
||||
- "-remoteWrite.url=https://obs.88oakapps.com/api/v1/write"
|
||||
@@ -162,12 +244,32 @@ spec:
|
||||
readOnly: true
|
||||
- name: buffer
|
||||
mountPath: /tmp/vmagent
|
||||
livenessProbe:
|
||||
# Process startup gate. /-/healthy returns 200 once vmagent has
|
||||
# parsed config — gives the agent up to 2 min to come up before
|
||||
# liveness starts evaluating.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /-/healthy
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
failureThreshold: 24
|
||||
# Real liveness check: are scrapes actually succeeding?
|
||||
# /-/healthy was the old probe and returned 200 for 17 days even
|
||||
# while vmagent had zero healthy targets (stale k8s SD watch).
|
||||
# This exec probe queries vmagent's own targets API and fails if
|
||||
# NO target is in state "up". Three consecutive failures (3 min)
|
||||
# → kubelet kills the pod → fresh SD watch.
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- 'n=$(wget -qO- -T 4 http://localhost:8429/api/v1/targets 2>/dev/null | grep -c ''"health":"up"''); [ "$n" -gt 0 ]'
|
||||
initialDelaySeconds: 180
|
||||
periodSeconds: 120
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /-/healthy
|
||||
|
||||
@@ -20,6 +20,9 @@ spec:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
serviceAccountName: redis
|
||||
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
|
||||
# the ServiceAccount-level setting in rbac.yaml.
|
||||
automountServiceAccountToken: false
|
||||
nodeSelector:
|
||||
honeydue/redis: "true"
|
||||
securityContext:
|
||||
@@ -31,7 +34,9 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
# Pinned by digest (audit K3S-F14) — redis:7-alpine is 7.4.9-alpine.
|
||||
image: redis:7-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99
|
||||
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -23,8 +23,11 @@ spec:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
serviceAccountName: web
|
||||
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
|
||||
# the ServiceAccount-level setting in rbac.yaml.
|
||||
automountServiceAccountToken: false
|
||||
imagePullSecrets:
|
||||
- name: ghcr-credentials
|
||||
- name: gitea-credentials
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
@@ -43,6 +46,7 @@ spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh or manual sed
|
||||
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
protocol: TCP
|
||||
|
||||
@@ -27,8 +27,11 @@ spec:
|
||||
app.kubernetes.io/part-of: honeydue
|
||||
spec:
|
||||
serviceAccountName: worker
|
||||
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
|
||||
# the ServiceAccount-level setting in rbac.yaml.
|
||||
automountServiceAccountToken: false
|
||||
imagePullSecrets:
|
||||
- name: ghcr-credentials
|
||||
- name: gitea-credentials
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
@@ -39,6 +42,12 @@ spec:
|
||||
containers:
|
||||
- name: worker
|
||||
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
|
||||
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
|
||||
ports:
|
||||
# health + Prometheus /metrics (in-cluster only; scraped by vmagent)
|
||||
- name: metrics
|
||||
containerPort: 6060
|
||||
protocol: TCP
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
@@ -47,64 +56,16 @@ spec:
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: honeydue-config
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: SECRET_KEY
|
||||
- name: EMAIL_HOST_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: EMAIL_HOST_PASSWORD
|
||||
- name: FCM_SERVER_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: FCM_SERVER_KEY
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: REDIS_PASSWORD
|
||||
optional: true
|
||||
# B2 (Backblaze) credentials. The worker needs these to delete
|
||||
# B2 objects when the pending_uploads cleanup cron reaps
|
||||
# expired upload sessions. Without them the worker falls back
|
||||
# to local-disk storage which fails on this pod's read-only
|
||||
# root filesystem and disables the cleanup cron.
|
||||
- name: B2_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: B2_KEY_ID
|
||||
- name: B2_APP_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: B2_APP_KEY
|
||||
# Observability — workers emit traces (e.g., asynq job spans) to
|
||||
# obs.88oakapps.com over OTLP/HTTP. service.name=honeydue-worker
|
||||
# so api and worker show up as separate services in Jaeger.
|
||||
- name: OBS_TRACES_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: OBS_TRACES_URL
|
||||
optional: true
|
||||
- name: OBS_INGEST_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeydue-secrets
|
||||
key: OBS_INGEST_TOKEN
|
||||
optional: true
|
||||
# Audit CODE-F8: secrets are NOT injected as environment variables.
|
||||
# Env vars are readable for the life of the pod via /proc/<pid>/environ
|
||||
# and leak into crash dumps / child processes. honeydue-secrets is
|
||||
# mounted read-only at /etc/honeydue/secrets (mode 0400) and the Go
|
||||
# config layer (config.loadFileSecrets) reads each key from its file.
|
||||
# Non-secret config still arrives via the configMapRef above.
|
||||
volumeMounts:
|
||||
- name: app-secrets
|
||||
mountPath: /etc/honeydue/secrets
|
||||
readOnly: true
|
||||
- name: apns-key
|
||||
mountPath: /secrets/apns
|
||||
readOnly: true
|
||||
@@ -124,6 +85,12 @@ spec:
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
volumes:
|
||||
# Audit CODE-F8: the whole honeydue-secrets Secret, projected as files.
|
||||
# defaultMode 0400 → readable only by the container's runAsUser (1000).
|
||||
- name: app-secrets
|
||||
secret:
|
||||
secretName: honeydue-secrets
|
||||
defaultMode: 0400
|
||||
- name: apns-key
|
||||
secret:
|
||||
secretName: honeydue-apns-key
|
||||
@@ -133,3 +100,46 @@ spec:
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 64Mi
|
||||
---
|
||||
# Allow vmagent to scrape the worker's /metrics on :6060 (default-deny-all is in
|
||||
# force; the worker otherwise receives no ingress). Additive — see node-exporter.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-ingress-to-worker-metrics
|
||||
namespace: honeydue
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: worker
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: vmagent
|
||||
ports:
|
||||
- port: 6060
|
||||
protocol: TCP
|
||||
---
|
||||
# vmagent's base egress policy only opens :8000/:8080 to the pod CIDR; this
|
||||
# additive policy opens :6060 for the worker scrape (leaves the base untouched).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-egress-from-vmagent-to-worker
|
||||
namespace: honeydue
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: vmagent
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.42.0.0/16
|
||||
ports:
|
||||
- port: 6060
|
||||
protocol: TCP
|
||||
|
||||
@@ -68,6 +68,25 @@ SECRET_ARGS=(
|
||||
if [[ -n "${REDIS_PASSWORD}" ]]; then
|
||||
log " Including REDIS_PASSWORD in secrets"
|
||||
SECRET_ARGS+=(--from-literal="REDIS_PASSWORD=${REDIS_PASSWORD}")
|
||||
else
|
||||
# Audit K3S-F1 (CRITICAL) / MEDIUM-4: refuse to deploy with an unauthenticated
|
||||
# Redis. A previous version only warned here, which let a deploy from an
|
||||
# unedited config.yaml silently bring Redis up with no password.
|
||||
die "redis.password is empty in config.yaml — refusing to deploy: Redis would run with NO authentication (audit K3S-F1). Set a strong value, e.g.: openssl rand -base64 32"
|
||||
fi
|
||||
|
||||
# B2 (Backblaze) object-storage credentials. The api/worker manifests
|
||||
# reference B2_KEY_ID / B2_APP_KEY as required secret keys, so honeydue-secrets
|
||||
# MUST carry them or those pods fail to start. Sourced from config.yaml so the
|
||||
# script and the manifests no longer drift (was a latent gap before 2026-05-16).
|
||||
B2_KEY_ID_VAL="$(cfg storage.b2_key_id 2>/dev/null || true)"
|
||||
B2_APP_KEY_VAL="$(cfg storage.b2_app_key 2>/dev/null || true)"
|
||||
if [[ -n "${B2_KEY_ID_VAL}" && -n "${B2_APP_KEY_VAL}" ]]; then
|
||||
log " Including B2_KEY_ID / B2_APP_KEY in secrets"
|
||||
SECRET_ARGS+=(--from-literal="B2_KEY_ID=${B2_KEY_ID_VAL}")
|
||||
SECRET_ARGS+=(--from-literal="B2_APP_KEY=${B2_APP_KEY_VAL}")
|
||||
else
|
||||
warn "storage.b2_key_id / b2_app_key not set in config.yaml — B2 uploads will be disabled."
|
||||
fi
|
||||
|
||||
# Observability ingest credentials live in deploy/prod.env (gitignored) so
|
||||
@@ -100,22 +119,24 @@ kubectl create secret generic honeydue-apns-key \
|
||||
--from-file="apns_auth_key.p8=${SECRETS_DIR}/apns_auth_key.p8" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
# --- Create GHCR registry credentials ---
|
||||
# --- Create container registry credentials ---
|
||||
# Secret name is gitea-credentials (audit F6): the registry is self-hosted
|
||||
# Gitea, not GHCR. Every deployment manifest references this same name.
|
||||
|
||||
REGISTRY_SERVER="$(cfg registry.server)"
|
||||
REGISTRY_USER="$(cfg registry.username)"
|
||||
REGISTRY_TOKEN="$(cfg registry.token)"
|
||||
|
||||
if [[ -n "${REGISTRY_SERVER}" && -n "${REGISTRY_USER}" && -n "${REGISTRY_TOKEN}" ]]; then
|
||||
log "Creating ghcr-credentials..."
|
||||
kubectl create secret docker-registry ghcr-credentials \
|
||||
log "Creating gitea-credentials..."
|
||||
kubectl create secret docker-registry gitea-credentials \
|
||||
--namespace="${NAMESPACE}" \
|
||||
--docker-server="${REGISTRY_SERVER}" \
|
||||
--docker-username="${REGISTRY_USER}" \
|
||||
--docker-password="${REGISTRY_TOKEN}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
else
|
||||
warn "Registry credentials incomplete in config.yaml — skipping ghcr-credentials."
|
||||
warn "Registry credentials incomplete in config.yaml — skipping gitea-credentials."
|
||||
fi
|
||||
|
||||
# --- Create Cloudflare origin cert ---
|
||||
@@ -132,7 +153,8 @@ kubectl create secret tls cloudflare-origin-cert \
|
||||
if [[ -n "${ADMIN_AUTH_USER}" && -n "${ADMIN_AUTH_PASSWORD}" ]]; then
|
||||
command -v htpasswd >/dev/null 2>&1 || die "Missing: htpasswd (install apache2-utils)"
|
||||
log "Creating admin-basic-auth secret..."
|
||||
HTPASSWD="$(htpasswd -nb "${ADMIN_AUTH_USER}" "${ADMIN_AUTH_PASSWORD}")"
|
||||
# -B forces bcrypt (Traefik BasicAuth supports it; avoids weak apr1-MD5).
|
||||
HTPASSWD="$(htpasswd -nbB "${ADMIN_AUTH_USER}" "${ADMIN_AUTH_PASSWORD}")"
|
||||
kubectl create secret generic admin-basic-auth \
|
||||
--namespace="${NAMESPACE}" \
|
||||
--from-literal=users="${HTPASSWD}" \
|
||||
@@ -142,6 +164,35 @@ else
|
||||
warn "Admin panel will NOT have basic auth protection."
|
||||
fi
|
||||
|
||||
# --- Create Kratos secrets (Ory Kratos identity service) ---
|
||||
# Created only when config.yaml has a kratos.dsn. Until then 03-deploy.sh skips
|
||||
# the Kratos deploy entirely, so the existing stack is unaffected.
|
||||
|
||||
KRATOS_DSN="$(cfg kratos.dsn 2>/dev/null || true)"
|
||||
if [[ -n "${KRATOS_DSN}" ]]; then
|
||||
log "Creating kratos-secrets..."
|
||||
KR_COOKIE="$(cfg kratos.secrets_cookie 2>/dev/null || true)"
|
||||
KR_CIPHER="$(cfg kratos.secrets_cipher 2>/dev/null || true)"
|
||||
KR_SMTP="$(cfg kratos.smtp_connection_uri 2>/dev/null || true)"
|
||||
KR_GOOGLE="$(cfg kratos.google_client_secret 2>/dev/null || true)"
|
||||
KR_APPLE="$(cfg kratos.apple_private_key 2>/dev/null || true)"
|
||||
[[ -n "${KR_COOKIE}" && -n "${KR_CIPHER}" ]] \
|
||||
|| die "kratos.secrets_cookie / secrets_cipher must be set (generate once: openssl rand -hex 16)"
|
||||
[[ ${#KR_CIPHER} -eq 32 ]] \
|
||||
|| die "kratos.secrets_cipher must be exactly 32 characters (openssl rand -hex 16)"
|
||||
kubectl create secret generic kratos-secrets \
|
||||
--namespace="${NAMESPACE}" \
|
||||
--from-literal="dsn=${KRATOS_DSN}" \
|
||||
--from-literal="secrets_cookie=${KR_COOKIE}" \
|
||||
--from-literal="secrets_cipher=${KR_CIPHER}" \
|
||||
--from-literal="smtp_connection_uri=${KR_SMTP}" \
|
||||
--from-literal="google_client_secret=${KR_GOOGLE}" \
|
||||
--from-literal="apple_private_key=${KR_APPLE}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
else
|
||||
warn "config.yaml has no kratos.dsn — skipping kratos-secrets (Kratos not yet configured)."
|
||||
fi
|
||||
|
||||
# --- Done ---
|
||||
|
||||
log ""
|
||||
|
||||
@@ -128,6 +128,56 @@ else
|
||||
warn "Skipping build. Using images for tag: ${DEPLOY_TAG}"
|
||||
fi
|
||||
|
||||
# --- Resolve immutable image digests (audit F5) ---
|
||||
# A short-SHA tag is mutable — anyone who can push to the registry can
|
||||
# overwrite it, and imagePullPolicy then pulls the new bits silently. We
|
||||
# deploy by @sha256: digest instead, pinning the exact image that was just
|
||||
# built and pushed. `docker push` populates RepoDigests; with --skip-build
|
||||
# (no local image) resolve_ref falls back to the tag.
|
||||
resolve_ref() {
|
||||
local img="$1" digest
|
||||
digest="$(docker inspect --format='{{range .RepoDigests}}{{println .}}{{end}}' "${img}" 2>/dev/null | grep -m1 '@sha256:' || true)"
|
||||
if [[ -n "${digest}" ]]; then
|
||||
printf '%s' "${digest}"
|
||||
else
|
||||
warn "could not resolve a digest for ${img} — deploying by mutable tag"
|
||||
printf '%s' "${img}"
|
||||
fi
|
||||
}
|
||||
API_REF="$(resolve_ref "${API_IMAGE}")"
|
||||
WORKER_REF="$(resolve_ref "${WORKER_IMAGE}")"
|
||||
ADMIN_REF="$(resolve_ref "${ADMIN_IMAGE}")"
|
||||
WEB_REF="$(resolve_ref "${WEB_IMAGE}")"
|
||||
log "Deploying by digest:"
|
||||
log " API: ${API_REF}"
|
||||
log " Worker: ${WORKER_REF}"
|
||||
log " Admin: ${ADMIN_REF}"
|
||||
|
||||
# --- Image scan + signing (audit CODE-L5) ---
|
||||
# Both steps are best-effort: the deploy does NOT fail if the tools are
|
||||
# absent, so an operator who has not set up cosign/trivy yet is not blocked.
|
||||
# Install trivy + cosign and export COSIGN_KEY to enforce. Cluster-side
|
||||
# admission verification (Kyverno/Connaisseur) is a separate operator step.
|
||||
if [[ "${SKIP_BUILD}" == "false" ]]; then
|
||||
if command -v trivy >/dev/null 2>&1; then
|
||||
log "Scanning images with Trivy (HIGH,CRITICAL)..."
|
||||
for img in "${API_IMAGE}" "${WORKER_IMAGE}" "${ADMIN_IMAGE}"; do
|
||||
trivy image --severity HIGH,CRITICAL --exit-code 0 --quiet "${img}" \
|
||||
|| warn "Trivy reported findings for ${img}"
|
||||
done
|
||||
else
|
||||
warn "trivy not installed — skipping image vulnerability scan (audit L5)"
|
||||
fi
|
||||
if command -v cosign >/dev/null 2>&1 && [[ -n "${COSIGN_KEY:-}" ]]; then
|
||||
log "Signing images with cosign..."
|
||||
for ref in "${API_REF}" "${WORKER_REF}" "${ADMIN_REF}"; do
|
||||
cosign sign --yes --key "${COSIGN_KEY}" "${ref}" || warn "cosign sign failed for ${ref}"
|
||||
done
|
||||
else
|
||||
warn "cosign not configured (need cosign + COSIGN_KEY) — skipping image signing (audit L5)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Generate and apply ConfigMap from config.yaml ---
|
||||
|
||||
log "Generating env from config.yaml..."
|
||||
@@ -146,6 +196,14 @@ kubectl create configmap honeydue-config \
|
||||
log "Applying manifests..."
|
||||
|
||||
kubectl apply -f "${MANIFESTS}/namespace.yaml"
|
||||
|
||||
# NetworkPolicies first — default-deny-all + per-app allow rules.
|
||||
# These MUST be applied; without them the cluster falls back to default-allow
|
||||
# (worse posture) AND the vmagent egress rule for :6443 (which fixes a k3s
|
||||
# post-DNAT enforcement quirk for k8s API discovery) is missing.
|
||||
# See deploy-k3s/RUNBOOK.md ("vmagent SD broken on fresh deploy").
|
||||
kubectl apply -f "${MANIFESTS}/network-policies.yaml"
|
||||
|
||||
kubectl apply -f "${MANIFESTS}/redis/"
|
||||
kubectl apply -f "${MANIFESTS}/ingress/"
|
||||
|
||||
@@ -158,7 +216,7 @@ kubectl apply -f "${MANIFESTS}/ingress/"
|
||||
# pod sees a stale schema.
|
||||
log "Running database migrations (goose Job)..."
|
||||
kubectl delete job honeydue-migrate -n "${NAMESPACE}" --ignore-not-found --wait=true >/dev/null
|
||||
sed "s|image: IMAGE_PLACEHOLDER|image: ${API_IMAGE}|" "${MANIFESTS}/migrate/job.yaml" | kubectl apply -f -
|
||||
sed "s|image: IMAGE_PLACEHOLDER|image: ${API_REF}|" "${MANIFESTS}/migrate/job.yaml" | kubectl apply -f -
|
||||
if ! kubectl wait --namespace="${NAMESPACE}" --for=condition=complete --timeout=10m job/honeydue-migrate; then
|
||||
warn "migration Job failed — see logs:"
|
||||
kubectl logs -n "${NAMESPACE}" job/honeydue-migrate --tail=200 || true
|
||||
@@ -167,33 +225,65 @@ fi
|
||||
log "Migrations applied; proceeding with api/worker rollout"
|
||||
|
||||
# Apply deployments with image substitution
|
||||
sed "s|image: IMAGE_PLACEHOLDER|image: ${API_IMAGE}|" "${MANIFESTS}/api/deployment.yaml" | kubectl apply -f -
|
||||
sed "s|image: IMAGE_PLACEHOLDER|image: ${API_REF}|" "${MANIFESTS}/api/deployment.yaml" | kubectl apply -f -
|
||||
kubectl apply -f "${MANIFESTS}/api/service.yaml"
|
||||
kubectl apply -f "${MANIFESTS}/api/hpa.yaml"
|
||||
|
||||
sed "s|image: IMAGE_PLACEHOLDER|image: ${WORKER_IMAGE}|" "${MANIFESTS}/worker/deployment.yaml" | kubectl apply -f -
|
||||
sed "s|image: IMAGE_PLACEHOLDER|image: ${WORKER_REF}|" "${MANIFESTS}/worker/deployment.yaml" | kubectl apply -f -
|
||||
|
||||
sed "s|image: IMAGE_PLACEHOLDER|image: ${ADMIN_IMAGE}|" "${MANIFESTS}/admin/deployment.yaml" | kubectl apply -f -
|
||||
sed "s|image: IMAGE_PLACEHOLDER|image: ${ADMIN_REF}|" "${MANIFESTS}/admin/deployment.yaml" | kubectl apply -f -
|
||||
kubectl apply -f "${MANIFESTS}/admin/service.yaml"
|
||||
|
||||
if [[ -d "${MANIFESTS}/web" ]]; then
|
||||
sed "s|image: IMAGE_PLACEHOLDER|image: ${WEB_IMAGE}|" "${MANIFESTS}/web/deployment.yaml" | kubectl apply -f -
|
||||
sed "s|image: IMAGE_PLACEHOLDER|image: ${WEB_REF}|" "${MANIFESTS}/web/deployment.yaml" | kubectl apply -f -
|
||||
kubectl apply -f "${MANIFESTS}/web/service.yaml"
|
||||
fi
|
||||
|
||||
# Observability — vmagent scrapes api Pods :8000/metrics and remote-writes
|
||||
# to obs.88oakapps.com. The bearer token comes from deploy/prod.env so it
|
||||
# stays out of the repo; the manifest holds TOKEN_PLACEHOLDER.
|
||||
# Observability — vmagent scrapes api Pods :8000/metrics + kube-state-metrics
|
||||
# :8080/metrics and remote-writes everything to obs.88oakapps.com. The bearer
|
||||
# token comes from deploy/prod.env so it stays out of the repo; the manifest
|
||||
# holds TOKEN_PLACEHOLDER. kube-state-metrics provides the kube_* metrics
|
||||
# Grafana panels need to count pods, deployments, etc.
|
||||
if [[ -d "${MANIFESTS}/observability" ]]; then
|
||||
# kube-state-metrics — no secrets, plain apply
|
||||
kubectl apply -f "${MANIFESTS}/observability/kube-state-metrics.yaml"
|
||||
|
||||
# vmagent — needs the bearer-token substitution
|
||||
# prod.env lives at the repo's deploy/ dir (sibling of deploy-k3s/), not
|
||||
# under deploy-k3s/. It's gitignored — operator copies values there once.
|
||||
OBS_TOKEN="$(grep -E '^OBS_INGEST_TOKEN=' "${REPO_DIR}/deploy/prod.env" 2>/dev/null | cut -d= -f2- || true)"
|
||||
if [[ -z "${OBS_TOKEN}" ]]; then
|
||||
warn "OBS_INGEST_TOKEN not found in deploy/prod.env — skipping vmagent apply"
|
||||
warn "OBS_INGEST_TOKEN not found in deploy/prod.env — skipping vmagent + alloy-logs apply"
|
||||
else
|
||||
sed "s|TOKEN_PLACEHOLDER|${OBS_TOKEN}|" "${MANIFESTS}/observability/vmagent.yaml" | kubectl apply -f -
|
||||
# alloy-logs — DaemonSet that tails honeydue pod logs and pushes them to
|
||||
# Loki at obs.88oakapps.com. Same OBS_INGEST_TOKEN as vmagent.
|
||||
if [[ -f "${MANIFESTS}/observability/alloy-logs.yaml" ]]; then
|
||||
sed "s|TOKEN_PLACEHOLDER|${OBS_TOKEN}|" "${MANIFESTS}/observability/alloy-logs.yaml" | kubectl apply -f -
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Ory Kratos (identity service) ---
|
||||
# Applied only when kratos-secrets exists — i.e. the operator has completed the
|
||||
# Kratos prerequisites in deploy-k3s/manifests/kratos/README.md. Otherwise
|
||||
# skipped, so the existing stack deploys unaffected.
|
||||
if kubectl -n "${NAMESPACE}" get secret kratos-secrets >/dev/null 2>&1; then
|
||||
log "Deploying Ory Kratos..."
|
||||
kubectl apply -f "${MANIFESTS}/kratos/configmap.yaml"
|
||||
# The migrate Job is immutable — delete any prior run, then apply + wait.
|
||||
kubectl delete job kratos-migrate -n "${NAMESPACE}" --ignore-not-found --wait=true >/dev/null
|
||||
kubectl apply -f "${MANIFESTS}/kratos/migrate-job.yaml"
|
||||
if ! kubectl wait --namespace="${NAMESPACE}" --for=condition=complete --timeout=5m job/kratos-migrate; then
|
||||
warn "Kratos migration Job failed — logs:"
|
||||
kubectl logs -n "${NAMESPACE}" job/kratos-migrate --tail=100 || true
|
||||
die "aborting: Kratos schema migration failed"
|
||||
fi
|
||||
kubectl apply -f "${MANIFESTS}/kratos/kratos.yaml"
|
||||
kubectl apply -f "${MANIFESTS}/kratos/ingress.yaml"
|
||||
else
|
||||
log "kratos-secrets not present — skipping Kratos deploy (see manifests/kratos/README.md)."
|
||||
fi
|
||||
|
||||
# --- Wait for rollouts ---
|
||||
|
||||
@@ -209,6 +299,12 @@ fi
|
||||
if kubectl -n "${NAMESPACE}" get deployment vmagent >/dev/null 2>&1; then
|
||||
kubectl rollout status deployment/vmagent -n "${NAMESPACE}" --timeout=120s
|
||||
fi
|
||||
if kubectl -n "${NAMESPACE}" get daemonset alloy-logs >/dev/null 2>&1; then
|
||||
kubectl rollout status daemonset/alloy-logs -n "${NAMESPACE}" --timeout=120s
|
||||
fi
|
||||
if kubectl -n "${NAMESPACE}" get deployment kratos >/dev/null 2>&1; then
|
||||
kubectl rollout status deployment/kratos -n "${NAMESPACE}" --timeout=180s
|
||||
fi
|
||||
|
||||
# --- Done ---
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ lines = [
|
||||
# API
|
||||
'DEBUG=false',
|
||||
f\"ALLOWED_HOSTS={d['api']},{d['base']}\",
|
||||
f\"CORS_ALLOWED_ORIGINS=https://{d['base']},https://{d['admin']}\",
|
||||
f\"CORS_ALLOWED_ORIGINS=https://{d['base']},https://{d['admin']},https://{d.get('app', 'app.' + d['base'])}\",
|
||||
'TIMEZONE=UTC',
|
||||
f\"BASE_URL=https://{d['base']}\",
|
||||
'PORT=8000',
|
||||
@@ -119,9 +119,14 @@ lines = [
|
||||
f\"DB_MAX_IDLE_CONNS={db['max_idle_conns']}\",
|
||||
f\"DB_MAX_LIFETIME={db['max_lifetime']}\",
|
||||
f\"DB_MAX_IDLE_TIME={db.get('max_idle_time', '0s')}\",
|
||||
# Redis (in-namespace DNS short form — password injected if configured;
|
||||
# short form works because /etc/resolv.conf in pods searches honeydue.svc.cluster.local)
|
||||
f\"REDIS_URL=redis://{':%s@' % val(rd.get('password')) if rd.get('password') else ''}redis:6379/0\",
|
||||
# Redis — in-namespace DNS short form (works because pod /etc/resolv.conf
|
||||
# searches honeydue.svc.cluster.local). Audit HIGH-1: the password is
|
||||
# intentionally NOT embedded here. This URL is emitted into the
|
||||
# honeydue-config ConfigMap, which is NOT encrypted at rest and is
|
||||
# readable by anyone with `get configmap`. The Redis password travels
|
||||
# only in honeydue-secrets as REDIS_PASSWORD (file-mounted, F8); the API
|
||||
# applies it in cache_service.go and the worker onto its Asynq opt.
|
||||
'REDIS_URL=redis://redis:6379/0',
|
||||
'REDIS_DB=0',
|
||||
# Email
|
||||
f\"EMAIL_HOST={em['host']}\",
|
||||
@@ -218,8 +223,18 @@ config = {
|
||||
'image': 'ubuntu-24.04',
|
||||
},
|
||||
'additional_packages': ['open-iscsi'],
|
||||
'post_create_commands': ['sudo systemctl enable --now iscsid'],
|
||||
'k3s_config_file': 'secrets-encryption: true\n',
|
||||
# Audit K3S-CG2: harden the node OS at provision time — fail2ban for SSH
|
||||
# brute-force, unattended-upgrades for automatic security patches.
|
||||
'post_create_commands': [
|
||||
'sudo systemctl enable --now iscsid',
|
||||
'sudo apt-get update -qq',
|
||||
'sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq fail2ban unattended-upgrades',
|
||||
'sudo systemctl enable --now fail2ban',
|
||||
'sudo dpkg-reconfigure -f noninteractive -plow unattended-upgrades',
|
||||
],
|
||||
# Audit K3S-CG1 / K3S-F4: encrypt Secrets at rest in etcd, and write the
|
||||
# node kubeconfig as mode 0600 (not world-readable).
|
||||
'k3s_config_file': 'secrets-encryption: true\nwrite-kubeconfig-mode: \"0600\"\n',
|
||||
}
|
||||
|
||||
print(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$id": "https://honeydue.app/identity.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "honeyDue user",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"title": "Email",
|
||||
"minLength": 3,
|
||||
"maxLength": 320,
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": { "identifier": true },
|
||||
"code": { "identifier": true, "via": "email" },
|
||||
"totp": { "account_name": true }
|
||||
},
|
||||
"verification": { "via": "email" },
|
||||
"recovery": { "via": "email" }
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"title": "Name",
|
||||
"properties": {
|
||||
"first": { "type": "string", "title": "First name", "maxLength": 100 },
|
||||
"last": { "type": "string", "title": "Last name", "maxLength": 100 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["email"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
version: v1.3.0
|
||||
|
||||
serve:
|
||||
public:
|
||||
base_url: http://localhost:4433/
|
||||
cors:
|
||||
enabled: true
|
||||
allowed_origins:
|
||||
- http://localhost
|
||||
- http://localhost:3000
|
||||
- http://localhost:8000
|
||||
- http://127.0.0.1
|
||||
allowed_methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
|
||||
allowed_headers: [Authorization, Content-Type, X-Session-Token, Cookie]
|
||||
exposed_headers: [Content-Type, Set-Cookie]
|
||||
allow_credentials: true
|
||||
admin:
|
||||
base_url: http://kratos:4434/
|
||||
|
||||
selfservice:
|
||||
default_browser_return_url: http://localhost:8000/
|
||||
allowed_return_urls:
|
||||
- http://localhost:8000
|
||||
- honeydue://callback
|
||||
|
||||
methods:
|
||||
password:
|
||||
enabled: true
|
||||
config:
|
||||
min_password_length: 8
|
||||
identifier_similarity_check_enabled: false
|
||||
code:
|
||||
enabled: true
|
||||
oidc:
|
||||
enabled: false
|
||||
|
||||
flows:
|
||||
error:
|
||||
ui_url: http://localhost:8000/auth/error
|
||||
login:
|
||||
ui_url: http://localhost:8000/auth/login
|
||||
lifespan: 10m
|
||||
registration:
|
||||
ui_url: http://localhost:8000/auth/registration
|
||||
lifespan: 10m
|
||||
after:
|
||||
password:
|
||||
hooks:
|
||||
- hook: session
|
||||
verification:
|
||||
enabled: true
|
||||
ui_url: http://localhost:8000/auth/verification
|
||||
use: code
|
||||
after:
|
||||
default_browser_return_url: http://localhost:8000/
|
||||
recovery:
|
||||
enabled: true
|
||||
ui_url: http://localhost:8000/auth/recovery
|
||||
use: code
|
||||
settings:
|
||||
ui_url: http://localhost:8000/auth/settings
|
||||
privileged_session_max_age: 15m
|
||||
logout:
|
||||
after:
|
||||
default_browser_return_url: http://localhost:8000/
|
||||
|
||||
log:
|
||||
level: debug
|
||||
format: text
|
||||
leak_sensitive_values: true
|
||||
|
||||
secrets:
|
||||
cookie:
|
||||
- local-dev-cookie-secret-please-change-this-32chars
|
||||
cipher:
|
||||
- 0123456789abcdef0123456789abcdef
|
||||
|
||||
ciphers:
|
||||
algorithm: xchacha20-poly1305
|
||||
|
||||
hashers:
|
||||
algorithm: bcrypt
|
||||
bcrypt:
|
||||
cost: 8
|
||||
|
||||
identity:
|
||||
default_schema_id: honeydue
|
||||
schemas:
|
||||
- id: honeydue
|
||||
url: file:///etc/config/kratos/identity.schema.json
|
||||
|
||||
courier:
|
||||
smtp:
|
||||
connection_uri: smtp://mailpit:1025/?disable_starttls=true
|
||||
from_address: noreply@localhost
|
||||
from_name: honeyDue Local
|
||||
|
||||
session:
|
||||
lifespan: 720h
|
||||
cookie:
|
||||
same_site: Lax
|
||||
@@ -1,6 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# DEPRECATED — production migrated from Docker Swarm to k3s on 2026-04-24.
|
||||
# This script targets the old Swarm manager + registry flow and will fail
|
||||
# at the SSH/Swarm validation step because hetzner1 no longer runs dockerd.
|
||||
#
|
||||
# Use the k3s deploy stack instead:
|
||||
#
|
||||
# export KUBECONFIG="$(pwd)/deploy-k3s/kubeconfig"
|
||||
# ./deploy-k3s/scripts/03-deploy.sh
|
||||
#
|
||||
# If you don't have deploy-k3s/kubeconfig locally, fetch it once:
|
||||
# ssh -i ~/.ssh/hetzner deploy@hetzner1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
|
||||
# | sed 's|server: https://127.0.0.1:6443|server: https://178.104.247.152:6443|' \
|
||||
# > deploy-k3s/kubeconfig
|
||||
# chmod 600 deploy-k3s/kubeconfig
|
||||
#
|
||||
# To override and run anyway (do NOT do this casually), set:
|
||||
# ALLOW_LEGACY_SWARM_DEPLOY=1 ./deploy/scripts/deploy_prod.sh
|
||||
if [[ "${ALLOW_LEGACY_SWARM_DEPLOY:-0}" != "1" ]]; then
|
||||
printf '[deploy][error] %s\n' \
|
||||
"deploy_prod.sh is the legacy Docker Swarm flow. Production now runs on k3s." \
|
||||
"Use ./deploy-k3s/scripts/03-deploy.sh instead (see top of this script for setup)." \
|
||||
"If you really need the old Swarm path, set ALLOW_LEGACY_SWARM_DEPLOY=1." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
REPO_DIR="$(cd "${DEPLOY_DIR}/.." && pwd)"
|
||||
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-honeydue}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./deploy/local/postgres-init:/docker-entrypoint-initdb.d:ro
|
||||
ports:
|
||||
- "${DB_PORT:-5433}:5432" # 5433 externally to avoid conflicts with local postgres
|
||||
healthcheck:
|
||||
@@ -91,6 +92,10 @@ services:
|
||||
|
||||
# Storage encryption
|
||||
STORAGE_ENCRYPTION_KEY: ${STORAGE_ENCRYPTION_KEY}
|
||||
|
||||
# Kratos (identity service)
|
||||
KRATOS_PUBLIC_URL: "http://kratos:4433"
|
||||
KRATOS_ADMIN_URL: "http://kratos:4434"
|
||||
volumes:
|
||||
- ./push_certs:/certs:ro
|
||||
- ./uploads:/app/uploads
|
||||
@@ -99,6 +104,8 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
kratos:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/api/health/"]
|
||||
interval: 30s
|
||||
@@ -184,6 +191,59 @@ services:
|
||||
networks:
|
||||
- honeydue-network
|
||||
|
||||
# Mailpit — local SMTP catcher (for Kratos email codes during onboarding)
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
container_name: honeydue-mailpit
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${MAILPIT_SMTP_PORT:-1025}:1025"
|
||||
- "${MAILPIT_HTTP_PORT:-8025}:8025"
|
||||
networks:
|
||||
- honeydue-network
|
||||
|
||||
# Kratos schema migration (one-shot, runs before kratos starts)
|
||||
kratos-migrate:
|
||||
image: oryd/kratos:v1.3.0
|
||||
container_name: honeydue-kratos-migrate
|
||||
command: ["migrate", "sql", "-e", "--yes"]
|
||||
environment:
|
||||
DSN: "postgres://${POSTGRES_USER:-honeydue}:${POSTGRES_PASSWORD:-honeydue_dev_password}@db:5432/kratos?sslmode=disable"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- honeydue-network
|
||||
restart: "no"
|
||||
|
||||
# Ory Kratos — identity service
|
||||
kratos:
|
||||
image: oryd/kratos:v1.3.0
|
||||
container_name: honeydue-kratos
|
||||
restart: unless-stopped
|
||||
command: ["serve", "--config", "/etc/config/kratos/kratos.yml", "--watch-courier", "--dev"]
|
||||
ports:
|
||||
- "${KRATOS_PUBLIC_PORT:-4433}:4433"
|
||||
- "${KRATOS_ADMIN_PORT:-4434}:4434"
|
||||
environment:
|
||||
DSN: "postgres://${POSTGRES_USER:-honeydue}:${POSTGRES_PASSWORD:-honeydue_dev_password}@db:5432/kratos?sslmode=disable"
|
||||
LOG_LEVEL: "debug"
|
||||
volumes:
|
||||
- ./deploy/local/kratos:/etc/config/kratos:ro
|
||||
depends_on:
|
||||
kratos-migrate:
|
||||
condition: service_completed_successfully
|
||||
mailpit:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:4434/health/ready"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
networks:
|
||||
- honeydue-network
|
||||
|
||||
# Dozzle — lightweight real-time log viewer
|
||||
dozzle:
|
||||
image: amir20/dozzle:latest
|
||||
|
||||
@@ -8,6 +8,13 @@ long-haul components, and dedicated service accounts with dropped
|
||||
capabilities inside containers. This chapter documents each layer, the
|
||||
rationale, and what's currently missing (and why).
|
||||
|
||||
> **Updated 2026-05-15 — security remediation.** The 2026-05 audits
|
||||
> (`live_scan_5_12.md`, `k3_audit_5_12.md`, `security_scan_5_12.md`) drove a
|
||||
> full remediation pass. **`deploy-k3s/SECURITY.md` is the authoritative,
|
||||
> per-finding current-state record.** This chapter is corrected for the
|
||||
> major items below; where any other detail conflicts with `SECURITY.md`,
|
||||
> `SECURITY.md` wins.
|
||||
|
||||
## Threat model
|
||||
|
||||
Who we're defending against, in rough order of likelihood:
|
||||
@@ -54,8 +61,8 @@ Cloudflare sits in front of every public request.
|
||||
- **Authorize requests** — that's the app's job
|
||||
- **Protect origin if origin IP leaks** — once someone knows a node IP
|
||||
they can bypass CF. Mitigation: keep origin firewall strict (Chapter 4).
|
||||
- **Encrypt between CF and origin** — we're on SSL=Flexible, so CF↔origin
|
||||
is HTTP. This is in our TODO (Chapter 20, upgrade to Full-strict).
|
||||
- **~~Encrypt between CF and origin~~** — done (2026-04-24): SSL mode is
|
||||
Full (strict); CF↔origin is TLS with a Cloudflare Origin CA cert.
|
||||
|
||||
### The proxy-IP problem
|
||||
|
||||
@@ -75,8 +82,8 @@ This means a malicious request that bypasses CF (by hitting the node IP
|
||||
directly) can't spoof headers — Traefik ignores `X-Forwarded-*` unless
|
||||
the source IP is in CF's ranges.
|
||||
|
||||
**TODO** (Chapter 20): Enforce at UFW level — allow 80/tcp only from
|
||||
CF IP ranges. Today any IP can reach the origin on port 80.
|
||||
**Done (2026-04-24):** the node UFW allowlist permits `:443` only from
|
||||
Cloudflare's IP ranges; the `Anywhere` rules on `:80`/`:443` were removed.
|
||||
|
||||
## Layer 2 — Node (OS, SSH, firewall)
|
||||
|
||||
@@ -297,15 +304,13 @@ The `deploy-k3s/manifests/network-policies.yaml` scaffold defines:
|
||||
reach api pods on port 8000
|
||||
- **allow-ingress-to-admin** — same, for admin:3000
|
||||
|
||||
**These are not currently applied.** Without them, our pods can freely
|
||||
talk to anything — including, theoretically, malicious destinations if
|
||||
an attacker gets RCE inside a pod.
|
||||
**Applied.** `03-deploy.sh` applies
|
||||
`deploy-k3s/manifests/network-policies.yaml` on every deploy — default-deny
|
||||
plus the explicit per-app allows below. Traefik runs `hostNetwork`, so its
|
||||
traffic is matched by node-IP `ipBlock`s plus the pod CIDR `10.42.0.0/16`,
|
||||
not a `namespaceSelector`.
|
||||
|
||||
**TODO** (Chapter 20): Apply network policies. The scaffold is there; we
|
||||
just need to `kubectl apply -f deploy-k3s/manifests/network-policies.yaml`
|
||||
and test that nothing breaks.
|
||||
|
||||
### What network policies would prevent
|
||||
### What network policies prevent
|
||||
|
||||
| Attack scenario | NetworkPolicy blocks |
|
||||
|---|---|
|
||||
@@ -324,13 +329,10 @@ renewed Let's Encrypt or CF-managed cert for `*.myhoneydue.com`.
|
||||
|
||||
### CF ↔ origin
|
||||
|
||||
**Plaintext HTTP** (SSL = Flexible). An attacker with access to the
|
||||
Cloudflare-to-Hetzner path could read traffic. In practice nobody who
|
||||
isn't Cloudflare or Hetzner sits on that path.
|
||||
|
||||
**TODO** (Chapter 20): Upgrade to SSL = Full (strict) with a Cloudflare
|
||||
Origin CA certificate. This encrypts CF ↔ origin and verifies that
|
||||
origin's cert is the CF-issued one (prevents MitM if DNS is compromised).
|
||||
**TLS — SSL = Full (strict)** (since 2026-04-24). A Cloudflare Origin CA
|
||||
certificate (`cloudflare-origin-cert` secret) is installed on all three
|
||||
ingresses; Cloudflare validates it. Both user↔CF and CF↔origin are
|
||||
encrypted, and a DNS-hijack MitM is defeated by the origin-cert check.
|
||||
|
||||
### API ↔ Neon Postgres
|
||||
|
||||
@@ -454,11 +456,14 @@ Mitigations:
|
||||
- Gitea itself is behind login; PAT is scoped to read:packages +
|
||||
write:packages only
|
||||
- Gitea runs on the operator's infrastructure (same operator account)
|
||||
- Image tags are SHA-pinned (`:237c6b8`) not `:latest` → attacker can't
|
||||
replace an existing tag's image without us noticing the digest change
|
||||
- Workloads deploy by immutable `@sha256:` digest, not by mutable tag
|
||||
(`03-deploy.sh` resolves the digest after push; the redis/vmagent/node
|
||||
base images are digest-pinned too) — a swapped tag cannot reach the
|
||||
cluster.
|
||||
|
||||
**TODO** (Chapter 20): Add cosign signing at build time, verify at pull
|
||||
time.
|
||||
**TODO**: cosign signing is wired into `03-deploy.sh` (guarded — runs when
|
||||
`cosign` + `COSIGN_KEY` are present); cluster-side admission verification
|
||||
(Kyverno/Connaisseur) is still pending. See `deploy-k3s/SECURITY.md` → L5.
|
||||
|
||||
## Operator workstation security
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 06 — Traefik Ingress
|
||||
|
||||
> **Updated 2026-05-15 (security remediation):** the Traefik middleware set
|
||||
> changed — `cloudflare-only` + `admin-auth` are now attached to the admin
|
||||
> ingress, a strict `auth-rate-limit` middleware fronts the auth endpoints
|
||||
> (via a dedicated `honeydue-api-auth` Ingress), and `security-headers`
|
||||
> gained COOP/CORP + a 2-year preload HSTS and dropped the deprecated
|
||||
> `X-XSS-Protection`. `deploy-k3s/SECURITY.md` is the authoritative
|
||||
> current-state record.
|
||||
|
||||
## Summary
|
||||
|
||||
Traefik is the reverse proxy that routes external HTTP requests to the
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 07 — Services
|
||||
|
||||
> **Updated 2026-05-15 (security remediation):** Redis now requires a
|
||||
> password (`config.yaml` `redis.password` → `honeydue-secrets`), all
|
||||
> workloads deploy by immutable `@sha256:` digest, and the redis/vmagent
|
||||
> base images are digest-pinned. `deploy-k3s/SECURITY.md` is the
|
||||
> authoritative current-state record.
|
||||
|
||||
## Summary
|
||||
|
||||
Five workloads run in the `honeydue` namespace: **api** (Go REST API, 3
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 10 — Secrets & Config
|
||||
|
||||
> **Updated 2026-05-15 (security remediation):** `honeydue-secrets` now
|
||||
> carries `REDIS_PASSWORD`; an `admin-basic-auth` Secret backs the admin
|
||||
> ingress; rotation is documented in `docs/runbooks/secret-rotation.md`;
|
||||
> and the Go config can read file-mounted secrets (`HONEYDUE_SECRETS_DIR`).
|
||||
> `deploy-k3s/SECURITY.md` is the authoritative current-state record.
|
||||
|
||||
## Summary
|
||||
|
||||
Non-sensitive config (hostnames, ports, feature flags, etc.) lives in
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Runbook — Secret Rotation
|
||||
|
||||
Closes audit finding `K3S-F12` (secrets unrotated since cluster bootstrap,
|
||||
no rotation cadence). See `deploy-k3s/SECURITY.md` Stage 2.
|
||||
|
||||
**Cadence:** rotate every secret at least **annually**. Rotate
|
||||
**immediately** on suspected exposure, on an operator-device loss, or when
|
||||
anyone who has seen a secret leaves the project.
|
||||
|
||||
**Record keeping:** after each rotation, annotate the secret so the age is
|
||||
visible:
|
||||
|
||||
```bash
|
||||
kubectl -n honeydue annotate secret <name> \
|
||||
honeydue.dev/last-rotated="$(date -u +%Y-%m-%d)" --overwrite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How rotation works
|
||||
|
||||
Every secret has a **source of truth** on the operator workstation. The
|
||||
deploy scripts read those sources and (re)create the Kubernetes Secrets.
|
||||
Rotation is always: **update the source → re-run `02-setup-secrets.sh` →
|
||||
restart the pods that consume it → revoke the old credential at its
|
||||
provider.**
|
||||
|
||||
`02-setup-secrets.sh` uses `kubectl apply` (via `--dry-run=client -o yaml`),
|
||||
so re-running it is idempotent and only changes what you changed.
|
||||
|
||||
| Kubernetes Secret | Source of truth | Consumed by |
|
||||
|---|---|---|
|
||||
| `honeydue-secrets` → `POSTGRES_PASSWORD` | `deploy-k3s/secrets/postgres_password.txt` | api, worker |
|
||||
| `honeydue-secrets` → `SECRET_KEY` | `deploy-k3s/secrets/secret_key.txt` | api, worker |
|
||||
| `honeydue-secrets` → `EMAIL_HOST_PASSWORD` | `deploy-k3s/secrets/email_host_password.txt` | api, worker |
|
||||
| `honeydue-secrets` → `FCM_SERVER_KEY` | `deploy-k3s/secrets/fcm_server_key.txt` | api, worker |
|
||||
| `honeydue-secrets` → `REDIS_PASSWORD` | `config.yaml` key `redis.password` | api, worker, redis |
|
||||
| `honeydue-secrets` → `OBS_INGEST_TOKEN` | `deploy/prod.env` | api, worker |
|
||||
| `honeydue-apns-key` → `apns_auth_key.p8` | `deploy-k3s/secrets/apns_auth_key.p8` | api, worker |
|
||||
| `cloudflare-origin-cert` | `deploy-k3s/secrets/cloudflare-origin.{crt,key}` | Traefik ingress |
|
||||
| `ghcr-credentials` | `config.yaml` block `registry.*` | image pulls (all pods) |
|
||||
| `admin-basic-auth` | `config.yaml` keys `admin.basic_auth_user` / `..._password` | Traefik `admin-auth` middleware |
|
||||
|
||||
The `deploy-k3s/secrets/` directory and `config.yaml` are **gitignored** —
|
||||
never commit them.
|
||||
|
||||
---
|
||||
|
||||
## Standard rotation procedure
|
||||
|
||||
```bash
|
||||
cd honeyDueAPI-go
|
||||
export KUBECONFIG="$(pwd)/deploy-k3s/kubeconfig"
|
||||
|
||||
# 1. Update the source (file under deploy-k3s/secrets/ or a config.yaml key)
|
||||
# 2. Recreate the Kubernetes Secrets from sources
|
||||
./deploy-k3s/scripts/02-setup-secrets.sh
|
||||
|
||||
# 3. Restart the consumers (see per-secret notes below for which)
|
||||
kubectl -n honeydue rollout restart deploy/api deploy/worker
|
||||
|
||||
# 4. Confirm health
|
||||
kubectl -n honeydue rollout status deploy/api
|
||||
kubectl -n honeydue rollout status deploy/worker
|
||||
|
||||
# 5. Revoke the OLD credential at its provider (see per-secret notes)
|
||||
# 6. Annotate the rotated secret with today's date
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Per-secret notes
|
||||
|
||||
### `POSTGRES_PASSWORD`
|
||||
1. Rotate the role password in the Neon dashboard.
|
||||
2. Write the new value to `deploy-k3s/secrets/postgres_password.txt`.
|
||||
3. `02-setup-secrets.sh`, then `rollout restart deploy/api deploy/worker`.
|
||||
4. Watch logs for connection errors; the old password stops working the
|
||||
moment Neon applies the change, so do steps 2–3 promptly.
|
||||
|
||||
### `SECRET_KEY` ⚠️ user-visible
|
||||
This signs auth tokens. **Rotating it logs every user out** — all existing
|
||||
tokens become invalid and every client must re-authenticate.
|
||||
1. Generate: `openssl rand -hex 32`.
|
||||
2. Write to `deploy-k3s/secrets/secret_key.txt` (must be ≥32 chars — the
|
||||
script enforces this; the app refuses to start in production without it).
|
||||
3. `02-setup-secrets.sh`, then `rollout restart deploy/api deploy/worker`.
|
||||
- Only rotate on a schedule or on suspected compromise — not casually.
|
||||
- A future improvement (overlap window via a key-id header) would let old
|
||||
tokens validate during the transition; not implemented today.
|
||||
|
||||
### `EMAIL_HOST_PASSWORD`
|
||||
1. Generate a new app password in Fastmail; keep the old one alive briefly.
|
||||
2. Write to `deploy-k3s/secrets/email_host_password.txt`.
|
||||
3. `02-setup-secrets.sh`, `rollout restart deploy/api deploy/worker`.
|
||||
4. Delete the old Fastmail app password.
|
||||
|
||||
### `FCM_SERVER_KEY`
|
||||
1. Rotate the key in the Firebase console.
|
||||
2. Write to `deploy-k3s/secrets/fcm_server_key.txt`.
|
||||
3. `02-setup-secrets.sh`, `rollout restart deploy/api deploy/worker`.
|
||||
|
||||
### `REDIS_PASSWORD`
|
||||
Source is `config.yaml` key `redis.password` (hex only — it is embedded in
|
||||
the `REDIS_URL`, so non-hex characters would break URL parsing).
|
||||
1. Generate: `openssl rand -hex 32`.
|
||||
2. Set `redis.password` in `config.yaml`.
|
||||
3. `02-setup-secrets.sh`.
|
||||
4. Restart **redis as well as** api/worker so the new `--requirepass` and
|
||||
the new `REDIS_URL` land together:
|
||||
`kubectl -n honeydue rollout restart deploy/redis deploy/api deploy/worker`.
|
||||
Expect a few seconds where api/worker reconnect.
|
||||
|
||||
### `apns_auth_key.p8`
|
||||
1. Revoke the key in the Apple Developer console, generate a new `.p8`.
|
||||
2. Replace `deploy-k3s/secrets/apns_auth_key.p8`.
|
||||
3. `02-setup-secrets.sh`, `rollout restart deploy/api deploy/worker`.
|
||||
4. If the Key ID changed, update `push.apns_key_id` in `config.yaml` too.
|
||||
|
||||
### `cloudflare-origin-cert`
|
||||
1. Generate a new Origin CA certificate in the Cloudflare dashboard.
|
||||
2. Replace `deploy-k3s/secrets/cloudflare-origin.crt` and `.key`.
|
||||
3. `02-setup-secrets.sh`. Traefik picks up the new TLS secret; no app
|
||||
restart needed. Verify the served cert with `openssl s_client`.
|
||||
|
||||
### `ghcr-credentials` (Gitea registry)
|
||||
1. Generate a new PAT in Gitea (scope: `read:packages`).
|
||||
2. Update the `registry.token` value in `config.yaml`.
|
||||
3. `02-setup-secrets.sh`. No restart needed unless a pull is pending.
|
||||
4. Revoke the old PAT in Gitea.
|
||||
|
||||
### `admin-basic-auth`
|
||||
Source is `config.yaml` keys `admin.basic_auth_user` / `basic_auth_password`.
|
||||
1. Set a new password (e.g. `openssl rand -hex 24`).
|
||||
2. `02-setup-secrets.sh` regenerates the bcrypt htpasswd secret.
|
||||
3. No app restart needed — Traefik reloads the `admin-auth` middleware.
|
||||
4. Distribute the new credential to whoever uses the admin panel.
|
||||
|
||||
---
|
||||
|
||||
## After any rotation
|
||||
|
||||
- Run `./deploy-k3s/scripts/04-verify.sh` and confirm no `✗` lines.
|
||||
- Annotate the rotated secret (see "Record keeping" above).
|
||||
- If the rotation was due to a compromise, also follow the relevant
|
||||
playbook in `deploy-k3s/SECURITY.md` → Appendix (Incident response).
|
||||
@@ -27,10 +27,10 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/api v0.257.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -117,9 +117,9 @@ require (
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
|
||||
@@ -241,12 +241,12 @@ go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
@@ -262,16 +262,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -1,215 +1,30 @@
|
||||
// apple_social_auth_handler is a stub — the user_applesocialauth table was
|
||||
// dropped in the Ory Kratos migration (phase 2). Social sign-in is now
|
||||
// handled by Kratos.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/admin/dto"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
)
|
||||
|
||||
// AdminAppleSocialAuthHandler handles admin Apple social auth management endpoints
|
||||
// AdminAppleSocialAuthHandler is a no-op stub.
|
||||
type AdminAppleSocialAuthHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAdminAppleSocialAuthHandler creates a new admin Apple social auth handler
|
||||
func NewAdminAppleSocialAuthHandler(db *gorm.DB) *AdminAppleSocialAuthHandler {
|
||||
return &AdminAppleSocialAuthHandler{db: db}
|
||||
}
|
||||
|
||||
// AppleSocialAuthResponse represents the response for an Apple social auth entry
|
||||
type AppleSocialAuthResponse struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
UserEmail string `json:"user_email"`
|
||||
AppleID string `json:"apple_id"`
|
||||
Email string `json:"email"`
|
||||
IsPrivateEmail bool `json:"is_private_email"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UpdateAppleSocialAuthRequest represents the request to update an Apple social auth entry
|
||||
type UpdateAppleSocialAuthRequest struct {
|
||||
Email *string `json:"email"`
|
||||
IsPrivateEmail *bool `json:"is_private_email"`
|
||||
}
|
||||
|
||||
// List handles GET /api/admin/apple-social-auth
|
||||
func (h *AdminAppleSocialAuthHandler) List(c echo.Context) error {
|
||||
var filters dto.PaginationParams
|
||||
if err := c.Bind(&filters); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
var entries []models.AppleSocialAuth
|
||||
var total int64
|
||||
|
||||
query := h.db.Model(&models.AppleSocialAuth{}).Preload("User")
|
||||
|
||||
// Apply search
|
||||
if filters.Search != "" {
|
||||
search := "%" + filters.Search + "%"
|
||||
query = query.Joins("JOIN auth_user ON auth_user.id = user_applesocialauth.user_id").
|
||||
Where("user_applesocialauth.apple_id ILIKE ? OR user_applesocialauth.email ILIKE ? OR auth_user.username ILIKE ? OR auth_user.email ILIKE ?",
|
||||
search, search, search, search)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
query.Count(&total)
|
||||
|
||||
// Apply sorting (allowlist prevents SQL injection via sort_by parameter)
|
||||
sortBy := filters.GetSafeSortBy([]string{
|
||||
"id", "user_id", "apple_id", "email", "is_private_email",
|
||||
"created_at", "updated_at",
|
||||
}, "created_at")
|
||||
query = query.Order(sortBy + " " + filters.GetSortDir())
|
||||
|
||||
// Apply pagination
|
||||
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
|
||||
|
||||
if err := query.Find(&entries).Error; err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entries"})
|
||||
}
|
||||
|
||||
// Build response
|
||||
responses := make([]AppleSocialAuthResponse, len(entries))
|
||||
for i, entry := range entries {
|
||||
responses[i] = h.toResponse(&entry)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||
}
|
||||
|
||||
// Get handles GET /api/admin/apple-social-auth/:id
|
||||
func (h *AdminAppleSocialAuthHandler) Get(c echo.Context) error {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
|
||||
}
|
||||
|
||||
var entry models.AppleSocialAuth
|
||||
if err := h.db.Preload("User").First(&entry, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Apple social auth entry not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entry"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, h.toResponse(&entry))
|
||||
}
|
||||
|
||||
// GetByUser handles GET /api/admin/apple-social-auth/user/:user_id
|
||||
func (h *AdminAppleSocialAuthHandler) GetByUser(c echo.Context) error {
|
||||
userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
|
||||
}
|
||||
|
||||
var entry models.AppleSocialAuth
|
||||
if err := h.db.Preload("User").Where("user_id = ?", userID).First(&entry).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Apple social auth entry not found for user"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entry"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, h.toResponse(&entry))
|
||||
}
|
||||
|
||||
// Update handles PUT /api/admin/apple-social-auth/:id
|
||||
func (h *AdminAppleSocialAuthHandler) Update(c echo.Context) error {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
|
||||
}
|
||||
|
||||
var entry models.AppleSocialAuth
|
||||
if err := h.db.First(&entry, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Apple social auth entry not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entry"})
|
||||
}
|
||||
|
||||
var req UpdateAppleSocialAuthRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
if req.Email != nil {
|
||||
entry.Email = *req.Email
|
||||
}
|
||||
if req.IsPrivateEmail != nil {
|
||||
entry.IsPrivateEmail = *req.IsPrivateEmail
|
||||
}
|
||||
|
||||
if err := h.db.Save(&entry).Error; err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update Apple social auth entry"})
|
||||
}
|
||||
|
||||
h.db.Preload("User").First(&entry, id)
|
||||
return c.JSON(http.StatusOK, h.toResponse(&entry))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/admin/apple-social-auth/:id
|
||||
func (h *AdminAppleSocialAuthHandler) Delete(c echo.Context) error {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
|
||||
}
|
||||
|
||||
var entry models.AppleSocialAuth
|
||||
if err := h.db.First(&entry, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Apple social auth entry not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entry"})
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&entry).Error; err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete Apple social auth entry"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Apple social auth entry deleted successfully"})
|
||||
}
|
||||
|
||||
// BulkDelete handles DELETE /api/admin/apple-social-auth/bulk
|
||||
func (h *AdminAppleSocialAuthHandler) BulkDelete(c echo.Context) error {
|
||||
var req dto.BulkDeleteRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
result := h.db.Where("id IN ?", req.IDs).Delete(&models.AppleSocialAuth{})
|
||||
if result.Error != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete Apple social auth entries"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Apple social auth entries deleted successfully", "count": result.RowsAffected})
|
||||
}
|
||||
|
||||
// toResponse converts an AppleSocialAuth model to AppleSocialAuthResponse
|
||||
func (h *AdminAppleSocialAuthHandler) toResponse(entry *models.AppleSocialAuth) AppleSocialAuthResponse {
|
||||
response := AppleSocialAuthResponse{
|
||||
ID: entry.ID,
|
||||
UserID: entry.UserID,
|
||||
AppleID: entry.AppleID,
|
||||
Email: entry.Email,
|
||||
IsPrivateEmail: entry.IsPrivateEmail,
|
||||
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: entry.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
if entry.User.ID != 0 {
|
||||
response.Username = entry.User.Username
|
||||
response.UserEmail = entry.User.Email
|
||||
}
|
||||
|
||||
return response
|
||||
func (h *AdminAppleSocialAuthHandler) gone(c echo.Context) error {
|
||||
return c.JSON(http.StatusGone, map[string]string{"message": "Apple social auth is managed by Ory Kratos"})
|
||||
}
|
||||
func (h *AdminAppleSocialAuthHandler) List(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminAppleSocialAuthHandler) Get(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminAppleSocialAuthHandler) Delete(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminAppleSocialAuthHandler) BulkDelete(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminAppleSocialAuthHandler) Update(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminAppleSocialAuthHandler) GetByUser(c echo.Context) error { return h.gone(c) }
|
||||
|
||||
@@ -1,144 +1,27 @@
|
||||
// auth_token_handler is a stub — the user_authtoken table was dropped in the
|
||||
// Ory Kratos migration (phase 2). Auth tokens are now Kratos sessions.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/admin/dto"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
)
|
||||
|
||||
// AdminAuthTokenHandler handles admin auth token management endpoints
|
||||
// AdminAuthTokenHandler is a no-op stub.
|
||||
type AdminAuthTokenHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAdminAuthTokenHandler creates a new admin auth token handler
|
||||
func NewAdminAuthTokenHandler(db *gorm.DB) *AdminAuthTokenHandler {
|
||||
return &AdminAuthTokenHandler{db: db}
|
||||
}
|
||||
|
||||
// AuthTokenResponse represents an auth token in API responses
|
||||
type AuthTokenResponse struct {
|
||||
Key string `json:"key"`
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
// List handles GET /api/admin/auth-tokens
|
||||
func (h *AdminAuthTokenHandler) List(c echo.Context) error {
|
||||
var filters dto.PaginationParams
|
||||
if err := c.Bind(&filters); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
var tokens []models.AuthToken
|
||||
var total int64
|
||||
|
||||
query := h.db.Model(&models.AuthToken{}).Preload("User")
|
||||
|
||||
// Apply search (search by user info)
|
||||
if filters.Search != "" {
|
||||
search := "%" + filters.Search + "%"
|
||||
query = query.Joins("JOIN auth_user ON auth_user.id = user_authtoken.user_id").
|
||||
Where(
|
||||
"auth_user.username ILIKE ? OR auth_user.email ILIKE ? OR user_authtoken.key ILIKE ?",
|
||||
search, search, search,
|
||||
)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
query.Count(&total)
|
||||
|
||||
// Apply sorting (allowlist prevents SQL injection via sort_by parameter)
|
||||
sortBy := filters.GetSafeSortBy([]string{
|
||||
"created", "user_id",
|
||||
}, "created")
|
||||
query = query.Order(sortBy + " " + filters.GetSortDir())
|
||||
|
||||
// Apply pagination
|
||||
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
|
||||
|
||||
if err := query.Find(&tokens).Error; err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch auth tokens"})
|
||||
}
|
||||
|
||||
// Build response
|
||||
responses := make([]AuthTokenResponse, len(tokens))
|
||||
for i, token := range tokens {
|
||||
responses[i] = AuthTokenResponse{
|
||||
Key: token.Key,
|
||||
UserID: token.UserID,
|
||||
Username: token.User.Username,
|
||||
Email: token.User.Email,
|
||||
Created: token.Created.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||
}
|
||||
|
||||
// Get handles GET /api/admin/auth-tokens/:id (id is actually user_id)
|
||||
func (h *AdminAuthTokenHandler) Get(c echo.Context) error {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
|
||||
}
|
||||
|
||||
var token models.AuthToken
|
||||
if err := h.db.Preload("User").Where("user_id = ?", id).First(&token).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Auth token not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch auth token"})
|
||||
}
|
||||
|
||||
response := AuthTokenResponse{
|
||||
Key: token.Key,
|
||||
UserID: token.UserID,
|
||||
Username: token.User.Username,
|
||||
Email: token.User.Email,
|
||||
Created: token.Created.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/admin/auth-tokens/:id (revoke token)
|
||||
func (h *AdminAuthTokenHandler) Delete(c echo.Context) error {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
|
||||
}
|
||||
|
||||
result := h.db.Where("user_id = ?", id).Delete(&models.AuthToken{})
|
||||
if result.Error != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to revoke token"})
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Auth token not found"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Auth token revoked successfully"})
|
||||
}
|
||||
|
||||
// BulkDelete handles DELETE /api/admin/auth-tokens/bulk
|
||||
func (h *AdminAuthTokenHandler) BulkDelete(c echo.Context) error {
|
||||
var req dto.BulkDeleteRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
result := h.db.Where("user_id IN ?", req.IDs).Delete(&models.AuthToken{})
|
||||
if result.Error != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to revoke tokens"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Auth tokens revoked successfully", "count": result.RowsAffected})
|
||||
func (h *AdminAuthTokenHandler) gone(c echo.Context) error {
|
||||
return c.JSON(http.StatusGone, map[string]string{"message": "auth tokens are managed by Ory Kratos"})
|
||||
}
|
||||
func (h *AdminAuthTokenHandler) List(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminAuthTokenHandler) Get(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminAuthTokenHandler) Delete(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminAuthTokenHandler) BulkDelete(c echo.Context) error { return h.gone(c) }
|
||||
|
||||
@@ -1,162 +1,28 @@
|
||||
// confirmation_code_handler is a stub — the user_confirmationcode table was
|
||||
// dropped in the Ory Kratos migration (phase 2). Email verification is now
|
||||
// handled by Kratos.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/admin/dto"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
)
|
||||
|
||||
// maskCode masks a confirmation code, showing only the last 4 characters.
|
||||
func maskCode(code string) string {
|
||||
if len(code) <= 4 {
|
||||
return strings.Repeat("*", len(code))
|
||||
}
|
||||
return strings.Repeat("*", len(code)-4) + code[len(code)-4:]
|
||||
}
|
||||
|
||||
// AdminConfirmationCodeHandler handles admin confirmation code management endpoints
|
||||
// AdminConfirmationCodeHandler is a no-op stub.
|
||||
type AdminConfirmationCodeHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAdminConfirmationCodeHandler creates a new admin confirmation code handler
|
||||
func NewAdminConfirmationCodeHandler(db *gorm.DB) *AdminConfirmationCodeHandler {
|
||||
return &AdminConfirmationCodeHandler{db: db}
|
||||
}
|
||||
|
||||
// ConfirmationCodeResponse represents a confirmation code in API responses
|
||||
type ConfirmationCodeResponse struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
IsUsed bool `json:"is_used"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// List handles GET /api/admin/confirmation-codes
|
||||
func (h *AdminConfirmationCodeHandler) List(c echo.Context) error {
|
||||
var filters dto.PaginationParams
|
||||
if err := c.Bind(&filters); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
var codes []models.ConfirmationCode
|
||||
var total int64
|
||||
|
||||
query := h.db.Model(&models.ConfirmationCode{}).Preload("User")
|
||||
|
||||
// Apply search (search by user info or code)
|
||||
if filters.Search != "" {
|
||||
search := "%" + filters.Search + "%"
|
||||
query = query.Joins("JOIN auth_user ON auth_user.id = user_confirmationcode.user_id").
|
||||
Where(
|
||||
"auth_user.username ILIKE ? OR auth_user.email ILIKE ? OR user_confirmationcode.code ILIKE ?",
|
||||
search, search, search,
|
||||
)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
query.Count(&total)
|
||||
|
||||
// Apply sorting (allowlist prevents SQL injection via sort_by parameter)
|
||||
sortBy := filters.GetSafeSortBy([]string{
|
||||
"id", "user_id", "created_at", "expires_at", "is_used",
|
||||
}, "created_at")
|
||||
query = query.Order(sortBy + " " + filters.GetSortDir())
|
||||
|
||||
// Apply pagination
|
||||
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
|
||||
|
||||
if err := query.Find(&codes).Error; err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch confirmation codes"})
|
||||
}
|
||||
|
||||
// Build response
|
||||
responses := make([]ConfirmationCodeResponse, len(codes))
|
||||
for i, code := range codes {
|
||||
responses[i] = ConfirmationCodeResponse{
|
||||
ID: code.ID,
|
||||
UserID: code.UserID,
|
||||
Username: code.User.Username,
|
||||
Email: code.User.Email,
|
||||
Code: maskCode(code.Code),
|
||||
ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
||||
IsUsed: code.IsUsed,
|
||||
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||
}
|
||||
|
||||
// Get handles GET /api/admin/confirmation-codes/:id
|
||||
func (h *AdminConfirmationCodeHandler) Get(c echo.Context) error {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
|
||||
}
|
||||
|
||||
var code models.ConfirmationCode
|
||||
if err := h.db.Preload("User").First(&code, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Confirmation code not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch confirmation code"})
|
||||
}
|
||||
|
||||
response := ConfirmationCodeResponse{
|
||||
ID: code.ID,
|
||||
UserID: code.UserID,
|
||||
Username: code.User.Username,
|
||||
Email: code.User.Email,
|
||||
Code: maskCode(code.Code),
|
||||
ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
||||
IsUsed: code.IsUsed,
|
||||
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/admin/confirmation-codes/:id
|
||||
func (h *AdminConfirmationCodeHandler) Delete(c echo.Context) error {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
|
||||
}
|
||||
|
||||
result := h.db.Delete(&models.ConfirmationCode{}, id)
|
||||
if result.Error != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete confirmation code"})
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Confirmation code not found"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Confirmation code deleted successfully"})
|
||||
}
|
||||
|
||||
// BulkDelete handles DELETE /api/admin/confirmation-codes/bulk
|
||||
func (h *AdminConfirmationCodeHandler) BulkDelete(c echo.Context) error {
|
||||
var req dto.BulkDeleteRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
result := h.db.Where("id IN ?", req.IDs).Delete(&models.ConfirmationCode{})
|
||||
if result.Error != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete confirmation codes"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Confirmation codes deleted successfully", "count": result.RowsAffected})
|
||||
func (h *AdminConfirmationCodeHandler) gone(c echo.Context) error {
|
||||
return c.JSON(http.StatusGone, map[string]string{"message": "confirmation codes are managed by Ory Kratos"})
|
||||
}
|
||||
func (h *AdminConfirmationCodeHandler) List(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminConfirmationCodeHandler) Get(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminConfirmationCodeHandler) Delete(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminConfirmationCodeHandler) BulkDelete(c echo.Context) error { return h.gone(c) }
|
||||
|
||||
@@ -1,159 +1,28 @@
|
||||
// password_reset_code_handler is a stub — the user_passwordresetcode table
|
||||
// was dropped in the Ory Kratos migration (phase 2). Password resets are now
|
||||
// handled by Kratos.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/admin/dto"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
)
|
||||
|
||||
// AdminPasswordResetCodeHandler handles admin password reset code management endpoints
|
||||
// AdminPasswordResetCodeHandler is a no-op stub.
|
||||
type AdminPasswordResetCodeHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetCodeHandler creates a new admin password reset code handler
|
||||
func NewAdminPasswordResetCodeHandler(db *gorm.DB) *AdminPasswordResetCodeHandler {
|
||||
return &AdminPasswordResetCodeHandler{db: db}
|
||||
}
|
||||
|
||||
// PasswordResetCodeResponse represents a password reset code in API responses
|
||||
type PasswordResetCodeResponse struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
ResetToken string `json:"reset_token"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Used bool `json:"used"`
|
||||
Attempts int `json:"attempts"`
|
||||
MaxAttempts int `json:"max_attempts"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// List handles GET /api/admin/password-reset-codes
|
||||
func (h *AdminPasswordResetCodeHandler) List(c echo.Context) error {
|
||||
var filters dto.PaginationParams
|
||||
if err := c.Bind(&filters); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
var codes []models.PasswordResetCode
|
||||
var total int64
|
||||
|
||||
query := h.db.Model(&models.PasswordResetCode{}).Preload("User")
|
||||
|
||||
// Apply search (search by user info or token)
|
||||
if filters.Search != "" {
|
||||
search := "%" + filters.Search + "%"
|
||||
query = query.Joins("JOIN auth_user ON auth_user.id = user_passwordresetcode.user_id").
|
||||
Where(
|
||||
"auth_user.username ILIKE ? OR auth_user.email ILIKE ? OR user_passwordresetcode.reset_token ILIKE ?",
|
||||
search, search, search,
|
||||
)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
query.Count(&total)
|
||||
|
||||
// Apply sorting (allowlist prevents SQL injection via sort_by parameter)
|
||||
sortBy := filters.GetSafeSortBy([]string{
|
||||
"id", "user_id", "created_at", "expires_at", "used",
|
||||
}, "created_at")
|
||||
query = query.Order(sortBy + " " + filters.GetSortDir())
|
||||
|
||||
// Apply pagination
|
||||
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
|
||||
|
||||
if err := query.Find(&codes).Error; err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch password reset codes"})
|
||||
}
|
||||
|
||||
// Build response
|
||||
responses := make([]PasswordResetCodeResponse, len(codes))
|
||||
for i, code := range codes {
|
||||
responses[i] = PasswordResetCodeResponse{
|
||||
ID: code.ID,
|
||||
UserID: code.UserID,
|
||||
Username: code.User.Username,
|
||||
Email: code.User.Email,
|
||||
ResetToken: code.ResetToken[:8] + "..." + code.ResetToken[len(code.ResetToken)-4:], // Truncate for display
|
||||
ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
||||
Used: code.Used,
|
||||
Attempts: code.Attempts,
|
||||
MaxAttempts: code.MaxAttempts,
|
||||
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||
}
|
||||
|
||||
// Get handles GET /api/admin/password-reset-codes/:id
|
||||
func (h *AdminPasswordResetCodeHandler) Get(c echo.Context) error {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
|
||||
}
|
||||
|
||||
var code models.PasswordResetCode
|
||||
if err := h.db.Preload("User").First(&code, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Password reset code not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch password reset code"})
|
||||
}
|
||||
|
||||
response := PasswordResetCodeResponse{
|
||||
ID: code.ID,
|
||||
UserID: code.UserID,
|
||||
Username: code.User.Username,
|
||||
Email: code.User.Email,
|
||||
ResetToken: code.ResetToken[:8] + "..." + code.ResetToken[len(code.ResetToken)-4:],
|
||||
ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
||||
Used: code.Used,
|
||||
Attempts: code.Attempts,
|
||||
MaxAttempts: code.MaxAttempts,
|
||||
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/admin/password-reset-codes/:id
|
||||
func (h *AdminPasswordResetCodeHandler) Delete(c echo.Context) error {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
|
||||
}
|
||||
|
||||
result := h.db.Delete(&models.PasswordResetCode{}, id)
|
||||
if result.Error != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete password reset code"})
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Password reset code not found"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Password reset code deleted successfully"})
|
||||
}
|
||||
|
||||
// BulkDelete handles DELETE /api/admin/password-reset-codes/bulk
|
||||
func (h *AdminPasswordResetCodeHandler) BulkDelete(c echo.Context) error {
|
||||
var req dto.BulkDeleteRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
result := h.db.Where("id IN ?", req.IDs).Delete(&models.PasswordResetCode{})
|
||||
if result.Error != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete password reset codes"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Password reset codes deleted successfully", "count": result.RowsAffected})
|
||||
func (h *AdminPasswordResetCodeHandler) gone(c echo.Context) error {
|
||||
return c.JSON(http.StatusGone, map[string]string{"message": "password reset codes are managed by Ory Kratos"})
|
||||
}
|
||||
func (h *AdminPasswordResetCodeHandler) List(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminPasswordResetCodeHandler) Get(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminPasswordResetCodeHandler) Delete(c echo.Context) error { return h.gone(c) }
|
||||
func (h *AdminPasswordResetCodeHandler) BulkDelete(c echo.Context) error { return h.gone(c) }
|
||||
|
||||
@@ -248,137 +248,20 @@ func (h *AdminSettingsHandler) cacheAllLookups(ctx context.Context) (bool, error
|
||||
}
|
||||
log.Debug().Int("count", len(taskTemplates)).Msg("Cached task templates")
|
||||
|
||||
// Build and cache the unified seeded data response
|
||||
// Import the grouped response type
|
||||
seededData := map[string]interface{}{
|
||||
"residence_types": residenceTypes,
|
||||
"task_categories": categories,
|
||||
"task_priorities": priorities,
|
||||
"task_frequencies": frequencies,
|
||||
"contractor_specialties": specialties,
|
||||
"task_templates": buildGroupedTemplates(taskTemplates),
|
||||
// Invalidate the unified seeded-data cache for every locale. The combined
|
||||
// response is localized (lookup display_name + home-profile options) and is
|
||||
// rebuilt per-locale on demand by the static_data handler, so the correct
|
||||
// action after a lookup change is to clear all language variants rather than
|
||||
// pre-warm a single (non-localized) blob.
|
||||
if err := cache.InvalidateSeededData(ctx); err != nil {
|
||||
return false, fmt.Errorf("failed to invalidate seeded data: %w", err)
|
||||
}
|
||||
|
||||
etag, err := cache.CacheSeededData(ctx, seededData)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to cache seeded data: %w", err)
|
||||
}
|
||||
log.Debug().Str("etag", etag).Msg("Cached unified seeded data")
|
||||
log.Debug().Msg("Invalidated per-locale seeded data cache")
|
||||
|
||||
log.Info().Msg("All lookup data cached in Redis successfully")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// buildGroupedTemplates groups task templates by category for the seeded data response
|
||||
func buildGroupedTemplates(templates []models.TaskTemplate) map[string]interface{} {
|
||||
type templateResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
Category map[string]interface{} `json:"category,omitempty"`
|
||||
FrequencyID *uint `json:"frequency_id"`
|
||||
Frequency map[string]interface{} `json:"frequency,omitempty"`
|
||||
IconIOS string `json:"icon_ios"`
|
||||
IconAndroid string `json:"icon_android"`
|
||||
Tags []string `json:"tags"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type categoryGroup struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
Templates []templateResponse `json:"templates"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
categoryMap := make(map[string]*categoryGroup)
|
||||
categoryOrder := []string{}
|
||||
|
||||
for _, t := range templates {
|
||||
categoryName := "Uncategorized"
|
||||
var categoryID *uint
|
||||
if t.Category != nil {
|
||||
categoryName = t.Category.Name
|
||||
categoryID = &t.Category.ID
|
||||
}
|
||||
|
||||
if _, exists := categoryMap[categoryName]; !exists {
|
||||
categoryMap[categoryName] = &categoryGroup{
|
||||
CategoryName: categoryName,
|
||||
CategoryID: categoryID,
|
||||
Templates: []templateResponse{},
|
||||
}
|
||||
categoryOrder = append(categoryOrder, categoryName)
|
||||
}
|
||||
|
||||
resp := templateResponse{
|
||||
ID: t.ID,
|
||||
Title: t.Title,
|
||||
Description: t.Description,
|
||||
CategoryID: t.CategoryID,
|
||||
FrequencyID: t.FrequencyID,
|
||||
IconIOS: t.IconIOS,
|
||||
IconAndroid: t.IconAndroid,
|
||||
Tags: parseTags(t.Tags),
|
||||
DisplayOrder: t.DisplayOrder,
|
||||
IsActive: t.IsActive,
|
||||
}
|
||||
|
||||
if t.Category != nil {
|
||||
resp.Category = map[string]interface{}{
|
||||
"id": t.Category.ID,
|
||||
"name": t.Category.Name,
|
||||
"description": t.Category.Description,
|
||||
"icon": t.Category.Icon,
|
||||
"color": t.Category.Color,
|
||||
"display_order": t.Category.DisplayOrder,
|
||||
}
|
||||
}
|
||||
if t.Frequency != nil {
|
||||
resp.Frequency = map[string]interface{}{
|
||||
"id": t.Frequency.ID,
|
||||
"name": t.Frequency.Name,
|
||||
"days": t.Frequency.Days,
|
||||
"display_order": t.Frequency.DisplayOrder,
|
||||
}
|
||||
}
|
||||
|
||||
categoryMap[categoryName].Templates = append(categoryMap[categoryName].Templates, resp)
|
||||
}
|
||||
|
||||
categories := make([]categoryGroup, len(categoryOrder))
|
||||
totalCount := 0
|
||||
for i, name := range categoryOrder {
|
||||
group := categoryMap[name]
|
||||
group.Count = len(group.Templates)
|
||||
totalCount += group.Count
|
||||
categories[i] = *group
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"categories": categories,
|
||||
"total_count": totalCount,
|
||||
}
|
||||
}
|
||||
|
||||
// parseTags splits a comma-separated tags string into a slice
|
||||
func parseTags(tags string) []string {
|
||||
if tags == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(tags, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SeedTestData handles POST /api/admin/settings/seed-test-data
|
||||
func (h *AdminSettingsHandler) SeedTestData(c echo.Context) error {
|
||||
if err := h.runSeedFile("002_test_data.sql"); err != nil {
|
||||
|
||||
@@ -207,9 +207,7 @@ func (h *AdminUserHandler) Create(c echo.Context) error {
|
||||
user.IsSuperuser = *req.IsSuperuser
|
||||
}
|
||||
|
||||
if err := user.SetPassword(req.Password); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to hash password"})
|
||||
}
|
||||
// Password management is handled by Ory Kratos; no local password hashing.
|
||||
|
||||
if err := h.db.Create(&user).Error; err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create user"})
|
||||
@@ -284,10 +282,9 @@ func (h *AdminUserHandler) Update(c echo.Context) error {
|
||||
if req.IsSuperuser != nil {
|
||||
user.IsSuperuser = *req.IsSuperuser
|
||||
}
|
||||
// Password management is handled by Ory Kratos; local password update ignored.
|
||||
if req.Password != nil {
|
||||
if err := user.SetPassword(*req.Password); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to hash password"})
|
||||
}
|
||||
_ = req.Password // Password changes must go through Kratos admin API
|
||||
}
|
||||
|
||||
if err := h.db.Save(&user).Error; err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
@@ -141,6 +142,13 @@ type SecurityConfig struct {
|
||||
MaxPasswordResetRate int // per hour
|
||||
TokenExpiryDays int // Number of days before auth tokens expire (default 90)
|
||||
TokenRefreshDays int // Token must be at least this many days old before refresh (default 60)
|
||||
// KratosPublicURL is the Ory Kratos public API base URL. The auth
|
||||
// middleware validates sessions against {KratosPublicURL}/sessions/whoami.
|
||||
KratosPublicURL string
|
||||
// KratosAdminURL is the Ory Kratos admin API base URL. Account deletion
|
||||
// removes the user's Kratos identity via
|
||||
// {KratosAdminURL}/admin/identities/{id}.
|
||||
KratosAdminURL string
|
||||
}
|
||||
|
||||
// StorageConfig holds file storage settings.
|
||||
@@ -216,6 +224,11 @@ func Load() (*Config, error) {
|
||||
// Set defaults
|
||||
setDefaults()
|
||||
|
||||
// Audit F8: overlay file-mounted secrets onto Viper. No-op when the
|
||||
// directory is absent (local/dev), so this is safe to ship before the
|
||||
// manifests mount honeydue-secrets as a volume.
|
||||
loadFileSecrets()
|
||||
|
||||
// Parse DATABASE_URL if set (Dokku-style)
|
||||
dbConfig := DatabaseConfig{
|
||||
Host: viper.GetString("DB_HOST"),
|
||||
@@ -298,6 +311,8 @@ func Load() (*Config, error) {
|
||||
MaxPasswordResetRate: 3,
|
||||
TokenExpiryDays: viper.GetInt("TOKEN_EXPIRY_DAYS"),
|
||||
TokenRefreshDays: viper.GetInt("TOKEN_REFRESH_DAYS"),
|
||||
KratosPublicURL: viper.GetString("KRATOS_PUBLIC_URL"),
|
||||
KratosAdminURL: viper.GetString("KRATOS_ADMIN_URL"),
|
||||
},
|
||||
Storage: StorageConfig{
|
||||
UploadDir: viper.GetString("STORAGE_UPLOAD_DIR"),
|
||||
@@ -405,6 +420,8 @@ func setDefaults() {
|
||||
|
||||
// Token expiry defaults
|
||||
viper.SetDefault("TOKEN_EXPIRY_DAYS", 90) // Tokens expire after 90 days
|
||||
viper.SetDefault("KRATOS_PUBLIC_URL", "http://kratos:4433") // Ory Kratos public API
|
||||
viper.SetDefault("KRATOS_ADMIN_URL", "http://kratos:4434") // Ory Kratos admin API
|
||||
viper.SetDefault("TOKEN_REFRESH_DAYS", 60) // Tokens can be refreshed after 60 days
|
||||
|
||||
// Storage defaults
|
||||
@@ -432,14 +449,67 @@ func isWeakSecretKey(key string) bool {
|
||||
return knownWeakSecretKeys[strings.ToLower(strings.TrimSpace(key))]
|
||||
}
|
||||
|
||||
// loadFileSecrets overlays file-mounted secrets onto Viper (audit F8). When
|
||||
// the honeydue-secrets Secret is mounted as a volume at /etc/honeydue/secrets
|
||||
// each key is a file; reading the value here and viper.Set-ing it (highest
|
||||
// Viper precedence) keeps the secret out of the process environment
|
||||
// (/proc/<pid>/environ), which plain env-var injection cannot. When the
|
||||
// directory is absent it is a silent no-op and env vars are used as before.
|
||||
func loadFileSecrets() {
|
||||
dir := os.Getenv("HONEYDUE_SECRETS_DIR")
|
||||
if dir == "" {
|
||||
dir = "/etc/honeydue/secrets"
|
||||
}
|
||||
for _, k := range []string{
|
||||
"POSTGRES_PASSWORD", "SECRET_KEY", "EMAIL_HOST_PASSWORD", "FCM_SERVER_KEY",
|
||||
"REDIS_PASSWORD", "B2_KEY_ID", "B2_APP_KEY", "OBS_INGEST_TOKEN", "OBS_TRACES_URL",
|
||||
} {
|
||||
b, err := os.ReadFile(dir + "/" + k)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if v := strings.TrimSpace(string(b)); v != "" {
|
||||
viper.Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SecretValue resolves a configuration value that is not part of the typed
|
||||
// Config struct. It reads through Viper, so a value supplied via a file-mounted
|
||||
// secret (audit F8, loaded by loadFileSecrets) is found just like an env var.
|
||||
//
|
||||
// Must be called after Load(). Used by cmd/api and cmd/worker for the
|
||||
// observability endpoints, which are needed before the full Config is wired
|
||||
// and would otherwise be read with os.Getenv — which misses file-mounted
|
||||
// secrets entirely once F8 removes them from the process environment.
|
||||
func SecretValue(key string) string {
|
||||
return viper.GetString(key)
|
||||
}
|
||||
|
||||
// randomHexKey returns a cryptographically secure random hex string
|
||||
// representing n random bytes (2n hex characters).
|
||||
func randomHexKey(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func validate(cfg *Config) error {
|
||||
// S-08: Validate SECRET_KEY against known weak defaults
|
||||
// M8: SECRET_KEY validation — no static fallback secret in the binary.
|
||||
if cfg.Security.SecretKey == "" {
|
||||
if cfg.Server.Debug {
|
||||
// In debug mode, use a default key with a warning for local development
|
||||
cfg.Security.SecretKey = "change-me-in-production-secret-key-12345"
|
||||
fmt.Println("WARNING: SECRET_KEY not set, using default (debug mode only)")
|
||||
fmt.Println("WARNING: *** DO NOT USE THIS DEFAULT KEY IN PRODUCTION ***")
|
||||
// Debug only: generate a random key per boot. Tokens signed with
|
||||
// it do not survive a restart, which is acceptable for local dev
|
||||
// and far safer than a well-known hardcoded fallback.
|
||||
randomKey, err := randomHexKey(32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate ephemeral debug SECRET_KEY: %w", err)
|
||||
}
|
||||
cfg.Security.SecretKey = randomKey
|
||||
fmt.Println("WARNING: SECRET_KEY not set, generated an ephemeral random key (debug mode only)")
|
||||
fmt.Println("WARNING: tokens will not survive a restart — set SECRET_KEY for stable local sessions")
|
||||
} else {
|
||||
// In production, refuse to start without a proper secret key
|
||||
return fmt.Errorf("FATAL: SECRET_KEY environment variable is required in production (DEBUG=false)")
|
||||
@@ -452,6 +522,12 @@ func validate(cfg *Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
// C4: fixed confirmation codes ("123456") must never be enabled outside
|
||||
// debug — with DEBUG=false they are a full authentication bypass.
|
||||
if cfg.Server.DebugFixedCodes && !cfg.Server.Debug {
|
||||
return fmt.Errorf("FATAL: DEBUG_FIXED_CODES is enabled with DEBUG=false — fixed confirmation codes must never run in production")
|
||||
}
|
||||
|
||||
// Database password might come from DATABASE_URL, don't require it separately
|
||||
// The actual connection will fail if credentials are wrong
|
||||
|
||||
|
||||
@@ -106,8 +106,10 @@ func TestLoad_Validation_MissingSecretKey_DebugMode(t *testing.T) {
|
||||
|
||||
c, err := Load()
|
||||
require.NoError(t, err)
|
||||
// In debug mode, a default key is assigned
|
||||
assert.Equal(t, "change-me-in-production-secret-key-12345", c.Security.SecretKey)
|
||||
// Audit M8: in debug mode an ephemeral random key is generated per boot
|
||||
// (no static fallback). It must be a non-empty 64-char hex string.
|
||||
assert.Len(t, c.Security.SecretKey, 64)
|
||||
assert.NotEqual(t, "change-me-in-production-secret-key-12345", c.Security.SecretKey)
|
||||
}
|
||||
|
||||
func TestLoad_Validation_WeakSecretKey_Production(t *testing.T) {
|
||||
@@ -133,6 +135,33 @@ func TestLoad_Validation_WeakSecretKey_DebugMode(t *testing.T) {
|
||||
assert.Equal(t, "secret", c.Security.SecretKey)
|
||||
}
|
||||
|
||||
// Audit C4: DEBUG_FIXED_CODES makes confirmation codes a fixed "123456" — a
|
||||
// full authentication bypass. With DEBUG=false, validate() must refuse to boot
|
||||
// rather than ship that bypass to production.
|
||||
func TestLoad_Validation_DebugFixedCodes_Production(t *testing.T) {
|
||||
// validate() directly — avoids the sync.Once issue Load() has on failure.
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Debug: false, DebugFixedCodes: true},
|
||||
Security: SecurityConfig{SecretKey: "a-strong-secret-key-for-tests"},
|
||||
}
|
||||
|
||||
err := validate(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "DEBUG_FIXED_CODES")
|
||||
}
|
||||
|
||||
// With DEBUG=true the fixed codes are an intended local-dev convenience, so
|
||||
// the same combination must NOT error.
|
||||
func TestLoad_Validation_DebugFixedCodes_DebugMode(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Debug: true, DebugFixedCodes: true},
|
||||
Security: SecurityConfig{SecretKey: "a-strong-secret-key-for-tests"},
|
||||
}
|
||||
|
||||
err := validate(cfg)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestLoad_Validation_EncryptionKey_Valid(t *testing.T) {
|
||||
resetConfigState()
|
||||
t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests")
|
||||
|
||||
@@ -244,12 +244,7 @@ func Migrate() error {
|
||||
|
||||
// User and auth tables
|
||||
&models.User{},
|
||||
&models.AuthToken{},
|
||||
&models.UserProfile{},
|
||||
&models.ConfirmationCode{},
|
||||
&models.PasswordResetCode{},
|
||||
&models.AppleSocialAuth{},
|
||||
&models.GoogleSocialAuth{},
|
||||
|
||||
// Admin users (separate from app users)
|
||||
&models.AdminUser{},
|
||||
|
||||
@@ -9,7 +9,10 @@ import (
|
||||
// ContractorSpecialtyResponse represents a contractor specialty
|
||||
type ContractorSpecialtyResponse struct {
|
||||
ID uint `json:"id"`
|
||||
// Name is the stable English identifier (clients match on this).
|
||||
Name string `json:"name"`
|
||||
// DisplayName is the localized label for the request's Accept-Language.
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
|
||||
@@ -11,7 +11,10 @@ import (
|
||||
// ResidenceTypeResponse represents a residence type in the API response
|
||||
type ResidenceTypeResponse struct {
|
||||
ID uint `json:"id"`
|
||||
// Name is the stable English identifier (clients match on this).
|
||||
Name string `json:"name"`
|
||||
// DisplayName is the localized label for the request's Accept-Language.
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// ResidenceUserResponse represents a user with access to a residence
|
||||
|
||||
@@ -14,7 +14,10 @@ import (
|
||||
// TaskCategoryResponse represents a task category
|
||||
type TaskCategoryResponse struct {
|
||||
ID uint `json:"id"`
|
||||
// Name is the stable English identifier (clients match on this).
|
||||
Name string `json:"name"`
|
||||
// DisplayName is the localized label for the request's Accept-Language.
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
@@ -25,6 +28,7 @@ type TaskCategoryResponse struct {
|
||||
type TaskPriorityResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Level int `json:"level"`
|
||||
Color string `json:"color"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
@@ -34,6 +38,7 @@ type TaskPriorityResponse struct {
|
||||
type TaskFrequencyResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Days *int `json:"days"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
}
|
||||
|
||||
@@ -2,21 +2,31 @@ package responses
|
||||
|
||||
// PresignUploadResponse is what /api/uploads/presign returns to the client.
|
||||
//
|
||||
// The client uses URL + Fields to build a multipart/form-data POST directly
|
||||
// to S3-compatible storage (B2). Once the upload completes, the client calls
|
||||
// the relevant entity-creation endpoint (POST /api/task-completions/, POST
|
||||
// /api/documents/) with `upload_ids: [Id]` to claim and attach the object.
|
||||
// Flow: the client makes one PUT request to URL with the raw object bytes
|
||||
// as the body and Headers as the request headers (verbatim — the signature
|
||||
// binds them). On success, the client passes ID back via upload_ids[] on
|
||||
// POST /api/task-completions/ or POST /api/documents/ to claim and attach
|
||||
// the object.
|
||||
//
|
||||
// We use PUT (not POST) because Backblaze B2's S3-compatible endpoint does
|
||||
// not implement the S3 POST Object form upload — it returns HTTP 501 on
|
||||
// every request style. PUT works against AWS S3, B2, and MinIO uniformly.
|
||||
type PresignUploadResponse struct {
|
||||
// ID is the pending_uploads.id the client passes back via upload_ids[].
|
||||
ID uint `json:"id"`
|
||||
|
||||
// URL is the storage endpoint to POST to (no query string).
|
||||
// URL is the signed PUT URL. Includes all auth as query parameters.
|
||||
URL string `json:"upload_url"`
|
||||
|
||||
// Fields are the form fields (policy, signature, key, etc.) that must be
|
||||
// submitted with the multipart form. The file part must be named "file"
|
||||
// and come last per S3 POST policy rules.
|
||||
Fields map[string]string `json:"fields"`
|
||||
// Method is always "PUT" — emitted explicitly so clients don't have to
|
||||
// hardcode it. Reserved for the rare case we ever offer alternative
|
||||
// upload mechanisms.
|
||||
Method string `json:"method"`
|
||||
|
||||
// Headers must be sent verbatim on the PUT request. Currently includes
|
||||
// Content-Type and Content-Length; both are signed, and B2 will reject
|
||||
// any PUT whose headers don't match.
|
||||
Headers map[string]string `json:"headers"`
|
||||
|
||||
// Key is the object key chosen by the server. Echoed for client logging
|
||||
// and debugging; the canonical reference is via ID.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -13,20 +12,22 @@ import (
|
||||
"github.com/treytartt/honeydue-api/internal/middleware"
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
"github.com/treytartt/honeydue-api/internal/validator"
|
||||
"github.com/treytartt/honeydue-api/internal/worker"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication endpoints
|
||||
// AuthHandler handles user profile and account management endpoints.
|
||||
// Session lifecycle (login, register, logout, password reset) is delegated
|
||||
// to Ory Kratos; this handler only deals with the honeyDue user record.
|
||||
type AuthHandler struct {
|
||||
authService *services.AuthService
|
||||
emailService *services.EmailService
|
||||
cache *services.CacheService
|
||||
appleAuthService *services.AppleAuthService
|
||||
googleAuthService *services.GoogleAuthService
|
||||
storageService *services.StorageService
|
||||
auditService *services.AuditService
|
||||
enqueuer worker.Enqueuer
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
// NewAuthHandler creates a new auth handler.
|
||||
func NewAuthHandler(authService *services.AuthService, emailService *services.EmailService, cache *services.CacheService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
@@ -35,128 +36,78 @@ func NewAuthHandler(authService *services.AuthService, emailService *services.Em
|
||||
}
|
||||
}
|
||||
|
||||
// SetAppleAuthService sets the Apple auth service (called after initialization)
|
||||
func (h *AuthHandler) SetAppleAuthService(appleAuth *services.AppleAuthService) {
|
||||
h.appleAuthService = appleAuth
|
||||
}
|
||||
|
||||
// SetGoogleAuthService sets the Google auth service (called after initialization)
|
||||
func (h *AuthHandler) SetGoogleAuthService(googleAuth *services.GoogleAuthService) {
|
||||
h.googleAuthService = googleAuth
|
||||
}
|
||||
|
||||
// SetStorageService sets the storage service for file deletion during account deletion
|
||||
// SetStorageService sets the storage service for file deletion during account deletion.
|
||||
func (h *AuthHandler) SetStorageService(storageService *services.StorageService) {
|
||||
h.storageService = storageService
|
||||
}
|
||||
|
||||
// SetAuditService sets the audit service for logging security events
|
||||
// SetAuditService sets the audit service for logging security events.
|
||||
func (h *AuthHandler) SetAuditService(auditService *services.AuditService) {
|
||||
h.auditService = auditService
|
||||
}
|
||||
|
||||
// Login handles POST /api/auth/login/
|
||||
func (h *AuthHandler) Login(c echo.Context) error {
|
||||
var req requests.LoginRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return apperrors.BadRequest("error.invalid_request")
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
|
||||
// SetEnqueuer sets the async task enqueuer (used by the GDPR data-export endpoint).
|
||||
func (h *AuthHandler) SetEnqueuer(enqueuer worker.Enqueuer) {
|
||||
h.enqueuer = enqueuer
|
||||
}
|
||||
|
||||
response, err := h.authService.Login(c.Request().Context(), &req)
|
||||
// ExportData handles POST /api/auth/export/ — queues a GDPR data-export job that
|
||||
// emails the user a zip of all their data. Async (202) because gathering,
|
||||
// zipping, and emailing can take seconds; doing it inline would block the request.
|
||||
func (h *AuthHandler) ExportData(c echo.Context) error {
|
||||
noStore(c)
|
||||
user, err := middleware.MustGetAuthUser(c)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("identifier", req.Username).Msg("Login failed")
|
||||
if h.auditService != nil {
|
||||
h.auditService.LogEvent(c, nil, services.AuditEventLoginFailed, map[string]interface{}{
|
||||
"identifier": req.Username,
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if h.enqueuer == nil {
|
||||
return echo.NewHTTPError(http.StatusServiceUnavailable, "data export is temporarily unavailable")
|
||||
}
|
||||
if err := h.enqueuer.EnqueueDataExport(user.ID); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to enqueue data export")
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to queue data export")
|
||||
}
|
||||
if h.auditService != nil {
|
||||
userID := response.User.ID
|
||||
h.auditService.LogEvent(c, &userID, services.AuditEventLogin, nil)
|
||||
h.auditService.LogEvent(c, &user.ID, services.AuditEventDataExport, map[string]interface{}{
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusAccepted, map[string]string{
|
||||
"message": "Your data export has been queued. You'll receive an email with your data shortly.",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
// noStore marks a response as non-cacheable.
|
||||
func noStore(c echo.Context) {
|
||||
c.Response().Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
|
||||
// Register handles POST /api/auth/register/
|
||||
// Register handles POST /api/auth/register/ — creates a new password account.
|
||||
//
|
||||
// The identity is admin-created in Kratos with an unverified email and no
|
||||
// auto-sent code (see services.AuthService.Register). The client logs in right
|
||||
// after to get a session, then completes email verification. Returns 201 with
|
||||
// no token; 409 if the email is taken; 400 on a weak password.
|
||||
func (h *AuthHandler) Register(c echo.Context) error {
|
||||
var req requests.RegisterRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return apperrors.BadRequest("error.invalid_request")
|
||||
return apperrors.BadRequest("error.invalid_request_body")
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
|
||||
}
|
||||
|
||||
response, confirmationCode, err := h.authService.Register(c.Request().Context(), &req)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Registration failed")
|
||||
if err := h.authService.Register(c.Request().Context(), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if h.auditService != nil {
|
||||
userID := response.User.ID
|
||||
h.auditService.LogEvent(c, &userID, services.AuditEventRegister, map[string]interface{}{
|
||||
"username": req.Username,
|
||||
"email": req.Email,
|
||||
return c.JSON(http.StatusCreated, map[string]string{
|
||||
"message": "Account created. Please verify your email.",
|
||||
})
|
||||
}
|
||||
|
||||
// Send welcome email with confirmation code (async)
|
||||
if h.emailService != nil && confirmationCode != "" {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().Interface("panic", r).Str("email", req.Email).Msg("Panic in welcome email goroutine")
|
||||
}
|
||||
}()
|
||||
if err := h.emailService.SendWelcomeEmail(req.Email, req.FirstName, confirmationCode); err != nil {
|
||||
log.Error().Err(err).Str("email", req.Email).Msg("Failed to send welcome email")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// Logout handles POST /api/auth/logout/
|
||||
func (h *AuthHandler) Logout(c echo.Context) error {
|
||||
token := middleware.GetAuthToken(c)
|
||||
if token == "" {
|
||||
return apperrors.Unauthorized("error.not_authenticated")
|
||||
}
|
||||
|
||||
// Log audit event before invalidating the token
|
||||
if h.auditService != nil {
|
||||
user := middleware.GetAuthUser(c)
|
||||
if user != nil {
|
||||
h.auditService.LogEvent(c, &user.ID, services.AuditEventLogout, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate token in database
|
||||
if err := h.authService.Logout(c.Request().Context(), token); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to delete token from database")
|
||||
}
|
||||
|
||||
// Invalidate token in cache
|
||||
if h.cache != nil {
|
||||
if err := h.cache.InvalidateAuthToken(c.Request().Context(), token); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to invalidate token in cache")
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Logged out successfully"})
|
||||
}
|
||||
|
||||
// CurrentUser handles GET /api/auth/me/
|
||||
func (h *AuthHandler) CurrentUser(c echo.Context) error {
|
||||
noStore(c)
|
||||
user, err := middleware.MustGetAuthUser(c)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -168,6 +119,25 @@ func (h *AuthHandler) CurrentUser(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// user_profile.verified is a one-time mirror set at provision time
|
||||
// (see middleware/kratos_auth.go::provision). Kratos remains the source
|
||||
// of truth for email-verification state — it can flip from false → true
|
||||
// the instant the user completes the verification flow, and nothing
|
||||
// updates the local column. Override the response with the live value
|
||||
// the Kratos auth middleware already stashed in context so /auth/me
|
||||
// reflects current reality. Also opportunistically sync the DB mirror
|
||||
// (best-effort, ignore error) so background queries that read the
|
||||
// column see the same answer.
|
||||
if verified, ok := c.Get(middleware.AuthVerifiedKey).(bool); ok {
|
||||
mirrorStale := response.Profile != nil && response.Profile.Verified != verified
|
||||
if response.Profile != nil {
|
||||
response.Profile.Verified = verified
|
||||
}
|
||||
if verified && mirrorStale {
|
||||
_ = h.authService.MarkUserVerified(c.Request().Context(), user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
@@ -195,296 +165,6 @@ func (h *AuthHandler) UpdateProfile(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// VerifyEmail handles POST /api/auth/verify-email/
|
||||
func (h *AuthHandler) VerifyEmail(c echo.Context) error {
|
||||
user, err := middleware.MustGetAuthUser(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req requests.VerifyEmailRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return apperrors.BadRequest("error.invalid_request")
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
|
||||
}
|
||||
|
||||
err = h.authService.VerifyEmail(c.Request().Context(), user.ID, req.Code)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Email verification failed")
|
||||
return err
|
||||
}
|
||||
|
||||
// Send post-verification welcome email with tips (async)
|
||||
if h.emailService != nil {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().Interface("panic", r).Str("email", user.Email).Msg("Panic in post-verification email goroutine")
|
||||
}
|
||||
}()
|
||||
if err := h.emailService.SendPostVerificationEmail(user.Email, user.FirstName); err != nil {
|
||||
log.Error().Err(err).Str("email", user.Email).Msg("Failed to send post-verification email")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, responses.VerifyEmailResponse{
|
||||
Message: "Email verified successfully",
|
||||
Verified: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ResendVerification handles POST /api/auth/resend-verification/
|
||||
func (h *AuthHandler) ResendVerification(c echo.Context) error {
|
||||
user, err := middleware.MustGetAuthUser(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
code, err := h.authService.ResendVerificationCode(c.Request().Context(), user.ID)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Failed to resend verification")
|
||||
return err
|
||||
}
|
||||
|
||||
// Send verification email (async)
|
||||
if h.emailService != nil {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().Interface("panic", r).Str("email", user.Email).Msg("Panic in verification email goroutine")
|
||||
}
|
||||
}()
|
||||
if err := h.emailService.SendVerificationEmail(user.Email, user.FirstName, code); err != nil {
|
||||
log.Error().Err(err).Str("email", user.Email).Msg("Failed to send verification email")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Verification email sent"})
|
||||
}
|
||||
|
||||
// ForgotPassword handles POST /api/auth/forgot-password/
|
||||
func (h *AuthHandler) ForgotPassword(c echo.Context) error {
|
||||
var req requests.ForgotPasswordRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return apperrors.BadRequest("error.invalid_request")
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
|
||||
}
|
||||
|
||||
code, user, err := h.authService.ForgotPassword(c.Request().Context(), req.Email)
|
||||
if err != nil {
|
||||
var appErr *apperrors.AppError
|
||||
if errors.As(err, &appErr) && appErr.Code == http.StatusTooManyRequests {
|
||||
// Only reveal rate limit errors
|
||||
return err
|
||||
}
|
||||
|
||||
log.Error().Err(err).Str("email", req.Email).Msg("Forgot password failed")
|
||||
// Don't reveal other errors to prevent email enumeration
|
||||
}
|
||||
|
||||
// Send password reset email (async) - only if user found
|
||||
if h.emailService != nil && code != "" && user != nil {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().Interface("panic", r).Str("email", user.Email).Msg("Panic in password reset email goroutine")
|
||||
}
|
||||
}()
|
||||
if err := h.emailService.SendPasswordResetEmail(user.Email, user.FirstName, code); err != nil {
|
||||
log.Error().Err(err).Str("email", user.Email).Msg("Failed to send password reset email")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if h.auditService != nil {
|
||||
h.auditService.LogEvent(c, nil, services.AuditEventPasswordReset, map[string]interface{}{
|
||||
"email": req.Email,
|
||||
})
|
||||
}
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
return c.JSON(http.StatusOK, responses.ForgotPasswordResponse{
|
||||
Message: "Password reset email sent",
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyResetCode handles POST /api/auth/verify-reset-code/
|
||||
func (h *AuthHandler) VerifyResetCode(c echo.Context) error {
|
||||
var req requests.VerifyResetCodeRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return apperrors.BadRequest("error.invalid_request")
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
|
||||
}
|
||||
|
||||
resetToken, err := h.authService.VerifyResetCode(c.Request().Context(), req.Email, req.Code)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("email", req.Email).Msg("Verify reset code failed")
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, responses.VerifyResetCodeResponse{
|
||||
Message: "Reset code verified",
|
||||
ResetToken: resetToken,
|
||||
})
|
||||
}
|
||||
|
||||
// ResetPassword handles POST /api/auth/reset-password/
|
||||
func (h *AuthHandler) ResetPassword(c echo.Context) error {
|
||||
var req requests.ResetPasswordRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return apperrors.BadRequest("error.invalid_request")
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
|
||||
}
|
||||
|
||||
err := h.authService.ResetPassword(c.Request().Context(), req.ResetToken, req.NewPassword)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Password reset failed")
|
||||
return err
|
||||
}
|
||||
|
||||
if h.auditService != nil {
|
||||
h.auditService.LogEvent(c, nil, services.AuditEventPasswordChanged, map[string]interface{}{
|
||||
"method": "reset_token",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, responses.ResetPasswordResponse{
|
||||
Message: "Password reset successful",
|
||||
})
|
||||
}
|
||||
|
||||
// AppleSignIn handles POST /api/auth/apple-sign-in/
|
||||
func (h *AuthHandler) AppleSignIn(c echo.Context) error {
|
||||
var req requests.AppleSignInRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return apperrors.BadRequest("error.invalid_request")
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
|
||||
}
|
||||
|
||||
if h.appleAuthService == nil {
|
||||
log.Error().Msg("Apple auth service not configured")
|
||||
return &apperrors.AppError{
|
||||
Code: 500,
|
||||
MessageKey: "error.apple_signin_not_configured",
|
||||
}
|
||||
}
|
||||
|
||||
response, err := h.authService.AppleSignIn(c.Request().Context(), h.appleAuthService, &req)
|
||||
if err != nil {
|
||||
// Check for legacy Apple Sign In error (not yet migrated)
|
||||
if errors.Is(err, services.ErrAppleSignInFailed) {
|
||||
log.Debug().Err(err).Msg("Apple Sign In failed (legacy error)")
|
||||
return apperrors.Unauthorized("error.invalid_apple_token")
|
||||
}
|
||||
|
||||
log.Debug().Err(err).Msg("Apple Sign In failed")
|
||||
return err
|
||||
}
|
||||
|
||||
// Send welcome email for new users (async)
|
||||
if response.IsNewUser && h.emailService != nil && response.User.Email != "" {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().Interface("panic", r).Str("email", response.User.Email).Msg("Panic in Apple welcome email goroutine")
|
||||
}
|
||||
}()
|
||||
if err := h.emailService.SendAppleWelcomeEmail(response.User.Email, response.User.FirstName); err != nil {
|
||||
log.Error().Err(err).Str("email", response.User.Email).Msg("Failed to send Apple welcome email")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GoogleSignIn handles POST /api/auth/google-sign-in/
|
||||
func (h *AuthHandler) GoogleSignIn(c echo.Context) error {
|
||||
var req requests.GoogleSignInRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return apperrors.BadRequest("error.invalid_request")
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
|
||||
}
|
||||
|
||||
if h.googleAuthService == nil {
|
||||
log.Error().Msg("Google auth service not configured")
|
||||
return &apperrors.AppError{
|
||||
Code: 500,
|
||||
MessageKey: "error.google_signin_not_configured",
|
||||
}
|
||||
}
|
||||
|
||||
response, err := h.authService.GoogleSignIn(c.Request().Context(), h.googleAuthService, &req)
|
||||
if err != nil {
|
||||
// Check for legacy Google Sign In error (not yet migrated)
|
||||
if errors.Is(err, services.ErrGoogleSignInFailed) {
|
||||
log.Debug().Err(err).Msg("Google Sign In failed (legacy error)")
|
||||
return apperrors.Unauthorized("error.invalid_google_token")
|
||||
}
|
||||
|
||||
log.Debug().Err(err).Msg("Google Sign In failed")
|
||||
return err
|
||||
}
|
||||
|
||||
// Send welcome email for new users (async)
|
||||
if response.IsNewUser && h.emailService != nil && response.User.Email != "" {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().Interface("panic", r).Str("email", response.User.Email).Msg("Panic in Google welcome email goroutine")
|
||||
}
|
||||
}()
|
||||
if err := h.emailService.SendGoogleWelcomeEmail(response.User.Email, response.User.FirstName); err != nil {
|
||||
log.Error().Err(err).Str("email", response.User.Email).Msg("Failed to send Google welcome email")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// RefreshToken handles POST /api/auth/refresh/
|
||||
func (h *AuthHandler) RefreshToken(c echo.Context) error {
|
||||
user, err := middleware.MustGetAuthUser(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token := middleware.GetAuthToken(c)
|
||||
if token == "" {
|
||||
return apperrors.Unauthorized("error.not_authenticated")
|
||||
}
|
||||
|
||||
response, err := h.authService.RefreshToken(c.Request().Context(), token, user.ID)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Token refresh failed")
|
||||
return err
|
||||
}
|
||||
|
||||
// If the token was refreshed (new token), invalidate the old one from cache
|
||||
if response.Token != token && h.cache != nil {
|
||||
if cacheErr := h.cache.InvalidateAuthToken(c.Request().Context(), token); cacheErr != nil {
|
||||
log.Warn().Err(cacheErr).Msg("Failed to invalidate old token from cache during refresh")
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteAccount handles DELETE /api/auth/account/
|
||||
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
|
||||
user, err := middleware.MustGetAuthUser(c)
|
||||
@@ -527,13 +207,5 @@ func (h *AuthHandler) DeleteAccount(c echo.Context) error {
|
||||
}()
|
||||
}
|
||||
|
||||
// Invalidate auth token from cache
|
||||
token := middleware.GetAuthToken(c)
|
||||
if h.cache != nil && token != "" {
|
||||
if err := h.cache.InvalidateAuthToken(c.Request().Context(), token); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to invalidate token in cache after account deletion")
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Account deleted successfully"})
|
||||
}
|
||||
|
||||
@@ -35,26 +35,25 @@ func setupDeleteAccountHandler(t *testing.T) (*AuthHandler, *echo.Echo, *gorm.DB
|
||||
return handler, e, db
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteAccount_EmailUser(t *testing.T) {
|
||||
// TestAuthHandler_DeleteAccount_WithConfirmation verifies that DELETE /account/
|
||||
// succeeds when the user sends confirmation: "DELETE".
|
||||
// Post-Kratos: all users (regardless of provider) must confirm with "DELETE".
|
||||
func TestAuthHandler_DeleteAccount_WithConfirmation(t *testing.T) {
|
||||
handler, e, db := setupDeleteAccountHandler(t)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "deletetest", "delete@test.com", "Password123")
|
||||
user := testutil.CreateTestUser(t, db, "deletetest", "delete@test.com", "ignored")
|
||||
|
||||
// Create profile for the user
|
||||
profile := &models.UserProfile{UserID: user.ID, Verified: true}
|
||||
require.NoError(t, db.Create(profile).Error)
|
||||
|
||||
// Create auth token
|
||||
testutil.CreateTestToken(t, db, user.ID)
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.DELETE("/account/", handler.DeleteAccount)
|
||||
|
||||
t.Run("successful deletion with correct password", func(t *testing.T) {
|
||||
password := "Password123"
|
||||
t.Run("successful deletion with DELETE confirmation", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"password": password,
|
||||
"confirmation": "DELETE",
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
|
||||
@@ -74,106 +73,15 @@ func TestAuthHandler_DeleteAccount_EmailUser(t *testing.T) {
|
||||
// Verify profile is deleted
|
||||
db.Model(&models.UserProfile{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// Verify auth token is deleted
|
||||
db.Model(&models.AuthToken{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteAccount_WrongPassword(t *testing.T) {
|
||||
// TestAuthHandler_DeleteAccount_MissingConfirmation verifies that a missing
|
||||
// confirmation string is rejected with 400.
|
||||
func TestAuthHandler_DeleteAccount_MissingConfirmation(t *testing.T) {
|
||||
handler, e, db := setupDeleteAccountHandler(t)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "wrongpw", "wrongpw@test.com", "Password123")
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.DELETE("/account/", handler.DeleteAccount)
|
||||
|
||||
t.Run("wrong password returns 401", func(t *testing.T) {
|
||||
wrongPw := "wrongpassword"
|
||||
req := map[string]interface{}{
|
||||
"password": wrongPw,
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteAccount_MissingPassword(t *testing.T) {
|
||||
handler, e, db := setupDeleteAccountHandler(t)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "nopw", "nopw@test.com", "Password123")
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.DELETE("/account/", handler.DeleteAccount)
|
||||
|
||||
t.Run("missing password returns 400", func(t *testing.T) {
|
||||
req := map[string]interface{}{}
|
||||
|
||||
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteAccount_SocialAuthUser(t *testing.T) {
|
||||
handler, e, db := setupDeleteAccountHandler(t)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "appleuser", "apple@test.com", "randompassword")
|
||||
|
||||
// Create Apple social auth record
|
||||
appleAuth := &models.AppleSocialAuth{
|
||||
UserID: user.ID,
|
||||
AppleID: "apple_sub_123",
|
||||
Email: "apple@test.com",
|
||||
}
|
||||
require.NoError(t, db.Create(appleAuth).Error)
|
||||
|
||||
// Create profile
|
||||
profile := &models.UserProfile{UserID: user.ID, Verified: true}
|
||||
require.NoError(t, db.Create(profile).Error)
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.DELETE("/account/", handler.DeleteAccount)
|
||||
|
||||
t.Run("successful deletion with DELETE confirmation", func(t *testing.T) {
|
||||
confirmation := "DELETE"
|
||||
req := map[string]interface{}{
|
||||
"confirmation": confirmation,
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||
|
||||
// Verify user is deleted
|
||||
var count int64
|
||||
db.Model(&models.User{}).Where("id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// Verify apple auth is deleted
|
||||
db.Model(&models.AppleSocialAuth{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteAccount_SocialAuthMissingConfirmation(t *testing.T) {
|
||||
handler, e, db := setupDeleteAccountHandler(t)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "googleuser", "google@test.com", "randompassword")
|
||||
|
||||
// Create Google social auth record
|
||||
googleAuth := &models.GoogleSocialAuth{
|
||||
UserID: user.ID,
|
||||
GoogleID: "google_sub_456",
|
||||
Email: "google@test.com",
|
||||
}
|
||||
require.NoError(t, db.Create(googleAuth).Error)
|
||||
user := testutil.CreateTestUser(t, db, "nopw", "nopw@test.com", "ignored")
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
@@ -188,9 +96,8 @@ func TestAuthHandler_DeleteAccount_SocialAuthMissingConfirmation(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("wrong confirmation returns 400", func(t *testing.T) {
|
||||
wrongConfirmation := "delete"
|
||||
req := map[string]interface{}{
|
||||
"confirmation": wrongConfirmation,
|
||||
"confirmation": "delete", // lowercase — must be exact "DELETE"
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
|
||||
@@ -199,6 +106,8 @@ func TestAuthHandler_DeleteAccount_SocialAuthMissingConfirmation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestAuthHandler_DeleteAccount_Unauthenticated verifies that 401 is returned
|
||||
// when no auth middleware is set.
|
||||
func TestAuthHandler_DeleteAccount_Unauthenticated(t *testing.T) {
|
||||
handler, e, _ := setupDeleteAccountHandler(t)
|
||||
|
||||
@@ -207,7 +116,7 @@ func TestAuthHandler_DeleteAccount_Unauthenticated(t *testing.T) {
|
||||
|
||||
t.Run("unauthenticated request returns 401", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"password": "Password123",
|
||||
"confirmation": "DELETE",
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "")
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// auth_handler_test.go tests the auth handler endpoints that survived the
|
||||
// Ory Kratos migration: GET /me/ and PUT/PATCH /profile/.
|
||||
// Login, register, logout, forgot-password, and social sign-in are now
|
||||
// handled by Kratos.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -34,204 +38,32 @@ func setupAuthHandler(t *testing.T) (*AuthHandler, *echo.Echo, *repositories.Use
|
||||
return handler, e, userRepo
|
||||
}
|
||||
|
||||
func TestAuthHandler_Register(t *testing.T) {
|
||||
handler, e, _ := setupAuthHandler(t)
|
||||
|
||||
e.POST("/api/auth/register/", handler.Register)
|
||||
|
||||
t.Run("successful registration", func(t *testing.T) {
|
||||
req := requests.RegisterRequest{
|
||||
Username: "newuser",
|
||||
Email: "new@test.com",
|
||||
Password: "Password123",
|
||||
FirstName: "New",
|
||||
LastName: "User",
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusCreated)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
testutil.AssertJSONFieldExists(t, response, "token")
|
||||
testutil.AssertJSONFieldExists(t, response, "user")
|
||||
testutil.AssertJSONFieldExists(t, response, "message")
|
||||
|
||||
user := response["user"].(map[string]interface{})
|
||||
assert.Equal(t, "newuser", user["username"])
|
||||
assert.Equal(t, "new@test.com", user["email"])
|
||||
assert.Equal(t, "New", user["first_name"])
|
||||
assert.Equal(t, "User", user["last_name"])
|
||||
})
|
||||
|
||||
t.Run("registration with missing fields", func(t *testing.T) {
|
||||
req := map[string]string{
|
||||
"username": "test",
|
||||
// Missing email and password
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
|
||||
response := testutil.ParseJSON(t, w.Body.Bytes())
|
||||
testutil.AssertJSONFieldExists(t, response, "error")
|
||||
})
|
||||
|
||||
t.Run("registration with short password", func(t *testing.T) {
|
||||
req := requests.RegisterRequest{
|
||||
Username: "testuser",
|
||||
Email: "test@test.com",
|
||||
Password: "short", // Less than 8 chars
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("registration with duplicate username", func(t *testing.T) {
|
||||
// First registration
|
||||
req := requests.RegisterRequest{
|
||||
Username: "duplicate",
|
||||
Email: "unique1@test.com",
|
||||
Password: "Password123",
|
||||
}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusCreated)
|
||||
|
||||
// Try to register again with same username
|
||||
req.Email = "unique2@test.com"
|
||||
w = testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusConflict) // 409 for duplicate resource
|
||||
|
||||
response := testutil.ParseJSON(t, w.Body.Bytes())
|
||||
assert.Contains(t, response["error"], "Username already taken")
|
||||
})
|
||||
|
||||
t.Run("registration with duplicate email", func(t *testing.T) {
|
||||
// First registration
|
||||
req := requests.RegisterRequest{
|
||||
Username: "user1",
|
||||
Email: "duplicate@test.com",
|
||||
Password: "Password123",
|
||||
}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusCreated)
|
||||
|
||||
// Try to register again with same email
|
||||
req.Username = "user2"
|
||||
w = testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusConflict) // 409 for duplicate resource
|
||||
|
||||
response := testutil.ParseJSON(t, w.Body.Bytes())
|
||||
assert.Contains(t, response["error"], "Email already registered")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_Login(t *testing.T) {
|
||||
handler, e, _ := setupAuthHandler(t)
|
||||
|
||||
e.POST("/api/auth/register/", handler.Register)
|
||||
e.POST("/api/auth/login/", handler.Login)
|
||||
|
||||
// Create a test user
|
||||
registerReq := requests.RegisterRequest{
|
||||
Username: "logintest",
|
||||
Email: "login@test.com",
|
||||
Password: "Password123",
|
||||
FirstName: "Test",
|
||||
LastName: "User",
|
||||
}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", registerReq, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusCreated)
|
||||
|
||||
t.Run("successful login with username", func(t *testing.T) {
|
||||
req := requests.LoginRequest{
|
||||
Username: "logintest",
|
||||
Password: "Password123",
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
testutil.AssertJSONFieldExists(t, response, "token")
|
||||
testutil.AssertJSONFieldExists(t, response, "user")
|
||||
|
||||
user := response["user"].(map[string]interface{})
|
||||
assert.Equal(t, "logintest", user["username"])
|
||||
assert.Equal(t, "login@test.com", user["email"])
|
||||
})
|
||||
|
||||
t.Run("successful login with email", func(t *testing.T) {
|
||||
req := requests.LoginRequest{
|
||||
Username: "login@test.com", // Using email as username
|
||||
Password: "Password123",
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("login with wrong password", func(t *testing.T) {
|
||||
req := requests.LoginRequest{
|
||||
Username: "logintest",
|
||||
Password: "wrongpassword",
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
|
||||
|
||||
response := testutil.ParseJSON(t, w.Body.Bytes())
|
||||
assert.Contains(t, response["error"], "Invalid credentials")
|
||||
})
|
||||
|
||||
t.Run("login with non-existent user", func(t *testing.T) {
|
||||
req := requests.LoginRequest{
|
||||
Username: "nonexistent",
|
||||
Password: "Password123",
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("login with missing fields", func(t *testing.T) {
|
||||
req := map[string]string{
|
||||
"username": "logintest",
|
||||
// Missing password
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_CurrentUser(t *testing.T) {
|
||||
handler, e, userRepo := setupAuthHandler(t)
|
||||
handler, e, _ := setupAuthHandler(t)
|
||||
|
||||
db := testutil.SetupTestDB(t)
|
||||
user := testutil.CreateTestUser(t, db, "metest", "me@test.com", "Password123")
|
||||
user := testutil.CreateTestUser(t, db, "metest", "me@test.com", "")
|
||||
user.FirstName = "Test"
|
||||
user.LastName = "User"
|
||||
userRepo.Update(user)
|
||||
// Use the userRepo from setupAuthHandler's DB, but since we need the user
|
||||
// in the same DB we re-create it there.
|
||||
db2 := testutil.SetupTestDB(t)
|
||||
user2 := testutil.CreateTestUser(t, db2, "metest2", "me2@test.com", "")
|
||||
user2.FirstName = "Test"
|
||||
user2.LastName = "User"
|
||||
userRepo2 := repositories.NewUserRepository(db2)
|
||||
require.NoError(t, userRepo2.Update(user2))
|
||||
|
||||
// Build handler against db2
|
||||
cfg := &config.Config{}
|
||||
authService2 := services.NewAuthService(userRepo2, cfg)
|
||||
handler2 := NewAuthHandler(authService2, nil, nil)
|
||||
|
||||
// Set up route with mock auth middleware
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.GET("/me/", handler.CurrentUser)
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user2))
|
||||
authGroup.GET("/me/", handler2.CurrentUser)
|
||||
|
||||
_ = handler // avoid unused
|
||||
|
||||
t.Run("get current user", func(t *testing.T) {
|
||||
w := testutil.MakeRequest(e, "GET", "/api/auth/me/", nil, "test-token")
|
||||
@@ -242,23 +74,26 @@ func TestAuthHandler_CurrentUser(t *testing.T) {
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "metest", response["username"])
|
||||
assert.Equal(t, "me@test.com", response["email"])
|
||||
assert.Equal(t, "metest2", response["username"])
|
||||
assert.Equal(t, "me2@test.com", response["email"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_UpdateProfile(t *testing.T) {
|
||||
handler, e, userRepo := setupAuthHandler(t)
|
||||
|
||||
db := testutil.SetupTestDB(t)
|
||||
user := testutil.CreateTestUser(t, db, "updatetest", "update@test.com", "Password123")
|
||||
userRepo.Update(user)
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
cfg := &config.Config{}
|
||||
authService := services.NewAuthService(userRepo, cfg)
|
||||
handler := NewAuthHandler(authService, nil, nil)
|
||||
e := testutil.SetupTestRouter()
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "updatetest", "update@test.com", "")
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.PUT("/profile/", handler.UpdateProfile)
|
||||
|
||||
t.Run("update profile", func(t *testing.T) {
|
||||
t.Run("update first and last name", func(t *testing.T) {
|
||||
firstName := "Updated"
|
||||
lastName := "Name"
|
||||
req := requests.UpdateProfileRequest{
|
||||
@@ -278,130 +113,3 @@ func TestAuthHandler_UpdateProfile(t *testing.T) {
|
||||
assert.Equal(t, "Name", response["last_name"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_ForgotPassword(t *testing.T) {
|
||||
handler, e, _ := setupAuthHandler(t)
|
||||
|
||||
e.POST("/api/auth/register/", handler.Register)
|
||||
e.POST("/api/auth/forgot-password/", handler.ForgotPassword)
|
||||
|
||||
// Create a test user
|
||||
registerReq := requests.RegisterRequest{
|
||||
Username: "forgottest",
|
||||
Email: "forgot@test.com",
|
||||
Password: "Password123",
|
||||
}
|
||||
testutil.MakeRequest(e, "POST", "/api/auth/register/", registerReq, "")
|
||||
|
||||
t.Run("forgot password with valid email", func(t *testing.T) {
|
||||
req := requests.ForgotPasswordRequest{
|
||||
Email: "forgot@test.com",
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/forgot-password/", req, "")
|
||||
|
||||
// Always returns 200 to prevent email enumeration
|
||||
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||
|
||||
response := testutil.ParseJSON(t, w.Body.Bytes())
|
||||
testutil.AssertJSONFieldExists(t, response, "message")
|
||||
})
|
||||
|
||||
t.Run("forgot password with invalid email", func(t *testing.T) {
|
||||
req := requests.ForgotPasswordRequest{
|
||||
Email: "nonexistent@test.com",
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/forgot-password/", req, "")
|
||||
|
||||
// Still returns 200 to prevent email enumeration
|
||||
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_Logout(t *testing.T) {
|
||||
handler, e, userRepo := setupAuthHandler(t)
|
||||
|
||||
db := testutil.SetupTestDB(t)
|
||||
user := testutil.CreateTestUser(t, db, "logouttest", "logout@test.com", "Password123")
|
||||
userRepo.Update(user)
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.POST("/logout/", handler.Logout)
|
||||
|
||||
t.Run("successful logout", func(t *testing.T) {
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/logout/", nil, "test-token")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||
|
||||
response := testutil.ParseJSON(t, w.Body.Bytes())
|
||||
assert.Contains(t, response["message"], "Logged out successfully")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_JSONResponses(t *testing.T) {
|
||||
handler, e, _ := setupAuthHandler(t)
|
||||
|
||||
e.POST("/api/auth/register/", handler.Register)
|
||||
e.POST("/api/auth/login/", handler.Login)
|
||||
|
||||
t.Run("register response has correct JSON structure", func(t *testing.T) {
|
||||
req := requests.RegisterRequest{
|
||||
Username: "jsontest",
|
||||
Email: "json@test.com",
|
||||
Password: "Password123",
|
||||
FirstName: "JSON",
|
||||
LastName: "Test",
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusCreated)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify top-level structure
|
||||
assert.Contains(t, response, "token")
|
||||
assert.Contains(t, response, "user")
|
||||
assert.Contains(t, response, "message")
|
||||
|
||||
// Verify token is not empty
|
||||
assert.NotEmpty(t, response["token"])
|
||||
|
||||
// Verify user structure
|
||||
user := response["user"].(map[string]interface{})
|
||||
assert.Contains(t, user, "id")
|
||||
assert.Contains(t, user, "username")
|
||||
assert.Contains(t, user, "email")
|
||||
assert.Contains(t, user, "first_name")
|
||||
assert.Contains(t, user, "last_name")
|
||||
assert.Contains(t, user, "is_active")
|
||||
assert.Contains(t, user, "date_joined")
|
||||
|
||||
// Verify types
|
||||
assert.IsType(t, float64(0), user["id"]) // JSON numbers are float64
|
||||
assert.IsType(t, "", user["username"])
|
||||
assert.IsType(t, "", user["email"])
|
||||
assert.IsType(t, true, user["is_active"])
|
||||
})
|
||||
|
||||
t.Run("error response has correct JSON structure", func(t *testing.T) {
|
||||
req := map[string]string{
|
||||
"username": "test",
|
||||
}
|
||||
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
|
||||
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response, "error")
|
||||
assert.IsType(t, "", response["error"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -506,232 +506,6 @@ func TestTaskHandler_CreateCompletion_NoTaskID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Auth Handler - Additional Coverage
|
||||
// =============================================================================
|
||||
|
||||
func TestAuthHandler_AppleSignIn_NotConfigured(t *testing.T) {
|
||||
handler, e, _ := setupAuthHandler(t)
|
||||
|
||||
e.POST("/api/auth/apple-sign-in/", handler.AppleSignIn)
|
||||
|
||||
t.Run("returns 500 when apple auth not configured", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"id_token": "fake-token",
|
||||
"user_id": "fake-user-id",
|
||||
}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/apple-sign-in/", req, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
t.Run("missing identity_token returns 400", func(t *testing.T) {
|
||||
req := map[string]interface{}{}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/apple-sign-in/", req, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_GoogleSignIn_NotConfigured(t *testing.T) {
|
||||
handler, e, _ := setupAuthHandler(t)
|
||||
|
||||
e.POST("/api/auth/google-sign-in/", handler.GoogleSignIn)
|
||||
|
||||
t.Run("returns 500 when google auth not configured", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"id_token": "fake-token",
|
||||
}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/google-sign-in/", req, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
t.Run("missing id_token returns 400", func(t *testing.T) {
|
||||
req := map[string]interface{}{}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/google-sign-in/", req, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
// setupAuthHandlerWithDB is like setupAuthHandler but also returns the underlying *gorm.DB
|
||||
// for tests that need to create records like ConfirmationCode directly.
|
||||
func setupAuthHandlerWithDB(t *testing.T) (*AuthHandler, *echo.Echo, *gorm.DB) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
cfg := &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
SecretKey: "test-secret-key",
|
||||
PasswordResetExpiry: 15 * time.Minute,
|
||||
ConfirmationExpiry: 24 * time.Hour,
|
||||
MaxPasswordResetRate: 3,
|
||||
},
|
||||
}
|
||||
authService := services.NewAuthService(userRepo, cfg)
|
||||
handler := NewAuthHandler(authService, nil, nil)
|
||||
e := testutil.SetupTestRouter()
|
||||
return handler, e, db
|
||||
}
|
||||
|
||||
func TestAuthHandler_VerifyEmail(t *testing.T) {
|
||||
handler, e, db := setupAuthHandlerWithDB(t)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "verifytest", "verify@test.com", "Password123")
|
||||
|
||||
// Create confirmation code
|
||||
confirmCode := &models.ConfirmationCode{
|
||||
UserID: user.ID,
|
||||
Code: "123456",
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
IsUsed: false,
|
||||
}
|
||||
require.NoError(t, db.Create(confirmCode).Error)
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.POST("/verify-email/", handler.VerifyEmail)
|
||||
|
||||
t.Run("successful verification", func(t *testing.T) {
|
||||
req := requests.VerifyEmailRequest{
|
||||
Code: "123456",
|
||||
}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-email/", req, "test-token")
|
||||
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, response["verified"])
|
||||
})
|
||||
|
||||
t.Run("wrong code returns error", func(t *testing.T) {
|
||||
req := requests.VerifyEmailRequest{
|
||||
Code: "999999",
|
||||
}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-email/", req, "test-token")
|
||||
// Code already used or wrong code
|
||||
assert.True(t, w.Code == http.StatusBadRequest || w.Code == http.StatusNotFound,
|
||||
"expected 400 or 404, got %d", w.Code)
|
||||
})
|
||||
|
||||
t.Run("missing code returns 400", func(t *testing.T) {
|
||||
req := map[string]interface{}{}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-email/", req, "test-token")
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_ResendVerification(t *testing.T) {
|
||||
handler, e, db := setupAuthHandlerWithDB(t)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "resendtest", "resend@test.com", "Password123")
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||
authGroup.POST("/resend-verification/", handler.ResendVerification)
|
||||
|
||||
t.Run("successful resend", func(t *testing.T) {
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/resend-verification/", nil, "test-token")
|
||||
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, response, "message")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_RefreshToken(t *testing.T) {
|
||||
handler, e, db := setupAuthHandlerWithDB(t)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "refreshtest", "refresh@test.com", "Password123")
|
||||
|
||||
// Create auth token and use its actual key in the middleware
|
||||
authToken := testutil.CreateTestToken(t, db, user.ID)
|
||||
|
||||
authGroup := e.Group("/api/auth")
|
||||
authGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
c.Set("auth_user", user)
|
||||
c.Set("auth_token", authToken.Key)
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
authGroup.POST("/refresh/", handler.RefreshToken)
|
||||
|
||||
t.Run("successful refresh", func(t *testing.T) {
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/refresh/", nil, authToken.Key)
|
||||
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, response, "token")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_VerifyResetCode(t *testing.T) {
|
||||
handler, e, _ := setupAuthHandler(t)
|
||||
|
||||
e.POST("/api/auth/register/", handler.Register)
|
||||
e.POST("/api/auth/verify-reset-code/", handler.VerifyResetCode)
|
||||
|
||||
t.Run("invalid code returns error", func(t *testing.T) {
|
||||
req := requests.VerifyResetCodeRequest{
|
||||
Email: "nonexistent@test.com",
|
||||
Code: "999999",
|
||||
}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-reset-code/", req, "")
|
||||
// Should not be 200 since no valid code exists
|
||||
assert.NotEqual(t, http.StatusOK, w.Code)
|
||||
})
|
||||
|
||||
t.Run("missing fields returns 400", func(t *testing.T) {
|
||||
req := map[string]interface{}{}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-reset-code/", req, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_ResetPassword(t *testing.T) {
|
||||
handler, e, _ := setupAuthHandler(t)
|
||||
|
||||
e.POST("/api/auth/reset-password/", handler.ResetPassword)
|
||||
|
||||
t.Run("invalid reset token returns error", func(t *testing.T) {
|
||||
req := requests.ResetPasswordRequest{
|
||||
ResetToken: "invalid-token",
|
||||
NewPassword: "NewPassword123",
|
||||
}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/reset-password/", req, "")
|
||||
assert.NotEqual(t, http.StatusOK, w.Code)
|
||||
})
|
||||
|
||||
t.Run("missing fields returns 400", func(t *testing.T) {
|
||||
req := map[string]interface{}{}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/reset-password/", req, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("short password returns 400", func(t *testing.T) {
|
||||
req := requests.ResetPasswordRequest{
|
||||
ResetToken: "some-token",
|
||||
NewPassword: "short",
|
||||
}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/reset-password/", req, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthHandler_ForgotPassword_MissingEmail(t *testing.T) {
|
||||
handler, e, _ := setupAuthHandler(t)
|
||||
|
||||
e.POST("/api/auth/forgot-password/", handler.ForgotPassword)
|
||||
|
||||
t.Run("missing email returns 400", func(t *testing.T) {
|
||||
req := map[string]interface{}{}
|
||||
w := testutil.MakeRequest(e, "POST", "/api/auth/forgot-password/", req, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Residence Handler - Additional Error Paths
|
||||
// =============================================================================
|
||||
|
||||
@@ -37,6 +37,23 @@ func NewMediaHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// safeContentDisposition builds an inline Content-Disposition header value
|
||||
// with a sanitized filename (audit M1). Control characters (including CR/LF),
|
||||
// double-quote and backslash are stripped so an attacker-controlled upload
|
||||
// filename cannot inject additional response headers (CWE-113).
|
||||
func safeContentDisposition(filename string) string {
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f || r == '"' || r == '\\' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, filename)
|
||||
if cleaned == "" {
|
||||
cleaned = "download"
|
||||
}
|
||||
return `inline; filename="` + cleaned + `"`
|
||||
}
|
||||
|
||||
// ServeDocument serves a document file with access control
|
||||
// GET /api/media/document/:id
|
||||
func (h *MediaHandler) ServeDocument(c echo.Context) error {
|
||||
@@ -71,7 +88,7 @@ func (h *MediaHandler) ServeDocument(c echo.Context) error {
|
||||
// Set caching and disposition headers
|
||||
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
|
||||
if doc.FileName != "" {
|
||||
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+doc.FileName+"\"")
|
||||
c.Response().Header().Set("Content-Disposition", safeContentDisposition(doc.FileName))
|
||||
}
|
||||
return c.Blob(http.StatusOK, mimeType, data)
|
||||
}
|
||||
@@ -114,7 +131,7 @@ func (h *MediaHandler) ServeDocumentImage(c echo.Context) error {
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
|
||||
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(img.ImageURL)+"\"")
|
||||
c.Response().Header().Set("Content-Disposition", safeContentDisposition(filepath.Base(img.ImageURL)))
|
||||
return c.Blob(http.StatusOK, mimeType, data)
|
||||
}
|
||||
|
||||
@@ -162,7 +179,7 @@ func (h *MediaHandler) ServeCompletionImage(c echo.Context) error {
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
|
||||
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(img.ImageURL)+"\"")
|
||||
c.Response().Header().Set("Content-Disposition", safeContentDisposition(filepath.Base(img.ImageURL)))
|
||||
return c.Blob(http.StatusOK, mimeType, data)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ type SeededDataResponse struct {
|
||||
TaskFrequencies interface{} `json:"task_frequencies"`
|
||||
ContractorSpecialties interface{} `json:"contractor_specialties"`
|
||||
TaskTemplates responses.TaskTemplatesGroupedResponse `json:"task_templates"`
|
||||
HomeProfileOptions map[string][]services.HomeProfileOption `json:"home_profile_options"`
|
||||
DocumentTypes []services.HomeProfileOption `json:"document_types"`
|
||||
DocumentCategories []services.HomeProfileOption `json:"document_categories"`
|
||||
}
|
||||
|
||||
// StaticDataHandler handles static/lookup data endpoints
|
||||
@@ -54,13 +57,18 @@ func NewStaticDataHandler(
|
||||
func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
// Lookup display labels and home-profile options are localized for the
|
||||
// request's language, so the cache + ETag are keyed by locale.
|
||||
locale := i18n.GetLocale(c)
|
||||
localizer := i18n.GetLocalizer(c)
|
||||
|
||||
// Check If-None-Match header for conditional request
|
||||
// Strip W/ prefix if present (added by reverse proxy, but we store without it)
|
||||
clientETag := strings.TrimPrefix(c.Request().Header.Get("If-None-Match"), "W/")
|
||||
|
||||
// Try to get cached ETag first (fast path for 304 responses)
|
||||
if h.cache != nil && clientETag != "" {
|
||||
cachedETag, err := h.cache.GetSeededDataETag(ctx)
|
||||
cachedETag, err := h.cache.GetSeededDataETag(ctx, locale)
|
||||
if err == nil && cachedETag == clientETag {
|
||||
// Client has the latest data, return 304 Not Modified
|
||||
return c.NoContent(http.StatusNotModified)
|
||||
@@ -70,10 +78,10 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
|
||||
// Try to get cached seeded data
|
||||
if h.cache != nil {
|
||||
var cachedData SeededDataResponse
|
||||
err := h.cache.GetCachedSeededData(ctx, &cachedData)
|
||||
err := h.cache.GetCachedSeededData(ctx, locale, &cachedData)
|
||||
if err == nil {
|
||||
// Cache hit - get the ETag and return data
|
||||
etag, etagErr := h.cache.GetSeededDataETag(ctx)
|
||||
etag, etagErr := h.cache.GetSeededDataETag(ctx, locale)
|
||||
if etagErr == nil {
|
||||
c.Response().Header().Set("ETag", etag)
|
||||
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
|
||||
@@ -116,6 +124,9 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Localize the lookup display_name fields in place for this request's locale.
|
||||
services.LocalizeLookups(localizer, residenceTypes, taskCategories, taskPriorities, taskFrequencies, contractorSpecialties)
|
||||
|
||||
// Build response
|
||||
seededData := SeededDataResponse{
|
||||
ResidenceTypes: residenceTypes,
|
||||
@@ -124,11 +135,14 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
|
||||
TaskFrequencies: taskFrequencies,
|
||||
ContractorSpecialties: contractorSpecialties,
|
||||
TaskTemplates: taskTemplates,
|
||||
HomeProfileOptions: services.BuildHomeProfileOptions(localizer),
|
||||
DocumentTypes: services.BuildDocumentTypes(localizer),
|
||||
DocumentCategories: services.BuildDocumentCategories(localizer),
|
||||
}
|
||||
|
||||
// Cache the data and get ETag
|
||||
// Cache the data and get ETag (per-locale)
|
||||
if h.cache != nil {
|
||||
etag, cacheErr := h.cache.CacheSeededData(ctx, seededData)
|
||||
etag, cacheErr := h.cache.CacheSeededData(ctx, locale, seededData)
|
||||
if cacheErr != nil {
|
||||
log.Warn().Err(cacheErr).Msg("Failed to cache seeded data")
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
@@ -165,9 +167,13 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
|
||||
if notification.NotificationUUID != "" {
|
||||
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("apple", notification.NotificationUUID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Apple Webhook: Failed to check dedup")
|
||||
// Continue processing on dedup check failure (fail-open)
|
||||
} else if alreadyProcessed {
|
||||
// Audit H6: fail closed. A dedup-check failure must not let a
|
||||
// possibly-duplicate event through (duplicate refunds/grants).
|
||||
// Return 500 so Apple retries once the DB is healthy.
|
||||
log.Error().Err(err).Msg("Apple Webhook: dedup check failed — returning 500 for retry")
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "dedup check failed"})
|
||||
}
|
||||
if alreadyProcessed {
|
||||
log.Info().Str("uuid", notification.NotificationUUID).Msg("Apple Webhook: Duplicate event, skipping")
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
|
||||
}
|
||||
@@ -352,11 +358,25 @@ func (h *SubscriptionWebhookHandler) processAppleNotification(
|
||||
}
|
||||
|
||||
func (h *SubscriptionWebhookHandler) findUserByAppleTransaction(originalTransactionID string) (*models.User, error) {
|
||||
// Look up user subscription by stored receipt data
|
||||
subscription, err := h.subscriptionRepo.FindByAppleReceiptContains(originalTransactionID)
|
||||
// Audit C13: exact match on the indexed apple_original_transaction_id
|
||||
// column. Falls back to the legacy escaped-LIKE scan over
|
||||
// apple_receipt_data only for subscriptions created before that column
|
||||
// existed (and thus not yet populated).
|
||||
subscription, err := h.subscriptionRepo.FindByAppleOriginalTransactionID(originalTransactionID)
|
||||
if err != nil {
|
||||
// Only fall back to the legacy substring scan when the exact-match
|
||||
// column genuinely had no row (a subscription created before the
|
||||
// column existed). A real DB error must propagate — masking it as
|
||||
// "not found" could bind the webhook to the wrong account via the
|
||||
// LIKE scan, or silently drop a legitimate event.
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
subscription, err = h.subscriptionRepo.FindByAppleReceiptContains(originalTransactionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
user, err := h.userRepo.FindByID(subscription.UserID)
|
||||
if err != nil {
|
||||
@@ -566,9 +586,12 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
|
||||
if messageID != "" {
|
||||
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("google", messageID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Google Webhook: Failed to check dedup")
|
||||
// Continue processing on dedup check failure (fail-open)
|
||||
} else if alreadyProcessed {
|
||||
// Audit H6: fail closed — see the Apple handler. Return 500 so
|
||||
// Google Pub/Sub redelivers once the DB is healthy.
|
||||
log.Error().Err(err).Msg("Google Webhook: dedup check failed — returning 500 for retry")
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "dedup check failed"})
|
||||
}
|
||||
if alreadyProcessed {
|
||||
log.Info().Str("message_id", messageID).Msg("Google Webhook: Duplicate event, skipping")
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/apperrors"
|
||||
"github.com/treytartt/honeydue-api/internal/i18n"
|
||||
"github.com/treytartt/honeydue-api/internal/middleware"
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
)
|
||||
@@ -41,7 +42,7 @@ func (h *SuggestionHandler) GetSuggestions(c echo.Context) error {
|
||||
return apperrors.BadRequest("error.invalid_id")
|
||||
}
|
||||
|
||||
resp, err := h.suggestionService.GetSuggestions(uint(residenceID), user.ID)
|
||||
resp, err := h.suggestionService.GetSuggestions(uint(residenceID), user.ID, i18n.GetLocalizer(c))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"error.google_signin_not_configured": "Google-Anmeldung ist nicht konfiguriert",
|
||||
"error.google_signin_failed": "Google-Anmeldung fehlgeschlagen",
|
||||
"error.invalid_google_token": "Ungultiger Google-Identitats-Token",
|
||||
|
||||
"error.invalid_task_id": "Ungultige Aufgaben-ID",
|
||||
"error.invalid_residence_id": "Ungultige Immobilien-ID",
|
||||
"error.invalid_contractor_id": "Ungultige Dienstleister-ID",
|
||||
@@ -34,7 +33,6 @@
|
||||
"error.invalid_user_id": "Ungultige Benutzer-ID",
|
||||
"error.invalid_notification_id": "Ungultige Benachrichtigungs-ID",
|
||||
"error.invalid_device_id": "Ungultige Gerate-ID",
|
||||
|
||||
"error.task_not_found": "Aufgabe nicht gefunden",
|
||||
"error.residence_not_found": "Immobilie nicht gefunden",
|
||||
"error.contractor_not_found": "Dienstleister nicht gefunden",
|
||||
@@ -43,7 +41,6 @@
|
||||
"error.user_not_found": "Benutzer nicht gefunden",
|
||||
"error.share_code_invalid": "Ungultiger Freigabecode",
|
||||
"error.share_code_expired": "Der Freigabecode ist abgelaufen",
|
||||
|
||||
"error.task_access_denied": "Sie haben keinen Zugriff auf diese Aufgabe",
|
||||
"error.residence_access_denied": "Sie haben keinen Zugriff auf diese Immobilie",
|
||||
"error.contractor_access_denied": "Sie haben keinen Zugriff auf diesen Dienstleister",
|
||||
@@ -52,10 +49,8 @@
|
||||
"error.cannot_remove_owner": "Der Eigentumer kann nicht entfernt werden",
|
||||
"error.user_already_member": "Der Benutzer ist bereits Mitglied dieser Immobilie",
|
||||
"error.properties_limit_reached": "Sie haben die maximale Anzahl an Immobilien fur Ihr Abonnement erreicht",
|
||||
|
||||
"error.task_already_cancelled": "Die Aufgabe ist bereits storniert",
|
||||
"error.task_already_archived": "Die Aufgabe ist bereits archiviert",
|
||||
|
||||
"error.failed_to_parse_form": "Formular konnte nicht analysiert werden",
|
||||
"error.task_id_required": "task_id ist erforderlich",
|
||||
"error.invalid_task_id_value": "Ungultige task_id",
|
||||
@@ -64,14 +59,12 @@
|
||||
"error.invalid_residence_id_value": "Ungultige residence_id",
|
||||
"error.title_required": "Titel ist erforderlich",
|
||||
"error.failed_to_upload_file": "Datei konnte nicht hochgeladen werden",
|
||||
|
||||
"message.logged_out": "Erfolgreich abgemeldet",
|
||||
"message.email_verified": "E-Mail erfolgreich verifiziert",
|
||||
"message.verification_email_sent": "Verifizierungs-E-Mail gesendet",
|
||||
"message.password_reset_email_sent": "Wenn ein Konto mit dieser E-Mail existiert, wurde ein Zurucksetzungscode gesendet.",
|
||||
"message.reset_code_verified": "Code erfolgreich verifiziert",
|
||||
"message.password_reset_success": "Passwort erfolgreich zuruckgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an.",
|
||||
|
||||
"message.task_deleted": "Aufgabe erfolgreich geloscht",
|
||||
"message.task_in_progress": "Aufgabe als in Bearbeitung markiert",
|
||||
"message.task_cancelled": "Aufgabe storniert",
|
||||
@@ -79,46 +72,35 @@
|
||||
"message.task_archived": "Aufgabe archiviert",
|
||||
"message.task_unarchived": "Aufgabe dearchiviert",
|
||||
"message.completion_deleted": "Abschluss erfolgreich geloscht",
|
||||
|
||||
"message.residence_deleted": "Immobilie erfolgreich geloscht",
|
||||
"message.user_removed": "Benutzer von der Immobilie entfernt",
|
||||
"message.tasks_report_generated": "Aufgabenbericht erfolgreich erstellt",
|
||||
"message.tasks_report_sent": "Aufgabenbericht erstellt und an {{.Email}} gesendet",
|
||||
"message.tasks_report_email_failed": "Aufgabenbericht erstellt, aber E-Mail konnte nicht gesendet werden",
|
||||
|
||||
"message.contractor_deleted": "Dienstleister erfolgreich geloscht",
|
||||
|
||||
"message.document_deleted": "Dokument erfolgreich geloscht",
|
||||
"message.document_activated": "Dokument aktiviert",
|
||||
"message.document_deactivated": "Dokument deaktiviert",
|
||||
|
||||
"message.notification_marked_read": "Benachrichtigung als gelesen markiert",
|
||||
"message.all_notifications_marked_read": "Alle Benachrichtigungen als gelesen markiert",
|
||||
"message.device_removed": "Gerät entfernt",
|
||||
|
||||
"message.subscription_upgraded": "Abonnement erfolgreich aktualisiert",
|
||||
"message.subscription_cancelled": "Abonnement gekündigt. Sie behalten die Pro-Vorteile bis zum Ende Ihres Abrechnungszeitraums.",
|
||||
"message.subscription_restored": "Abonnement erfolgreich wiederhergestellt",
|
||||
|
||||
"message.file_deleted": "Datei erfolgreich gelöscht",
|
||||
"message.static_data_refreshed": "Statische Daten aktualisiert",
|
||||
|
||||
"error.notification_not_found": "Benachrichtigung nicht gefunden",
|
||||
"error.invalid_platform": "Ungültige Plattform",
|
||||
|
||||
"error.upgrade_trigger_not_found": "Upgrade-Trigger nicht gefunden",
|
||||
"error.receipt_data_required": "receipt_data ist für iOS erforderlich",
|
||||
"error.purchase_token_required": "purchase_token ist für Android erforderlich",
|
||||
|
||||
"error.no_file_provided": "Keine Datei bereitgestellt",
|
||||
|
||||
"error.failed_to_fetch_residence_types": "Fehler beim Abrufen der Immobilientypen",
|
||||
"error.failed_to_fetch_task_categories": "Fehler beim Abrufen der Aufgabenkategorien",
|
||||
"error.failed_to_fetch_task_priorities": "Fehler beim Abrufen der Aufgabenprioritäten",
|
||||
"error.failed_to_fetch_task_frequencies": "Fehler beim Abrufen der Aufgabenfrequenzen",
|
||||
"error.failed_to_fetch_task_statuses": "Fehler beim Abrufen der Aufgabenstatus",
|
||||
"error.failed_to_fetch_contractor_specialties": "Fehler beim Abrufen der Dienstleister-Spezialitäten",
|
||||
|
||||
"push.task_due_soon.title": "Aufgabe Bald Fallig",
|
||||
"push.task_due_soon.body": "{{.TaskTitle}} ist fallig am {{.DueDate}}",
|
||||
"push.task_overdue.title": "Uberfällige Aufgabe",
|
||||
@@ -129,63 +111,137 @@
|
||||
"push.task_assigned.body": "Ihnen wurde {{.TaskTitle}} zugewiesen",
|
||||
"push.residence_shared.title": "Immobilie Geteilt",
|
||||
"push.residence_shared.body": "{{.UserName}} hat {{.ResidenceName}} mit Ihnen geteilt",
|
||||
|
||||
"email.welcome.subject": "Willkommen bei honeyDue!",
|
||||
"email.verification.subject": "Bestatigen Sie Ihre E-Mail",
|
||||
"email.password_reset.subject": "Passwort-Zurucksetzungscode",
|
||||
"email.tasks_report.subject": "Aufgabenbericht fur {{.ResidenceName}}",
|
||||
|
||||
"lookup.residence_type.house": "Haus",
|
||||
"lookup.residence_type.apartment": "Wohnung",
|
||||
"lookup.residence_type.condo": "Eigentumswohnung",
|
||||
"lookup.residence_type.townhouse": "Reihenhaus",
|
||||
"lookup.residence_type.mobile_home": "Mobilheim",
|
||||
"lookup.residence_type.other": "Sonstiges",
|
||||
|
||||
"lookup.task_category.plumbing": "Sanitär",
|
||||
"lookup.task_category.electrical": "Elektrik",
|
||||
"lookup.task_category.hvac": "Heizung/Klimaanlage",
|
||||
"lookup.task_category.appliances": "Gerate",
|
||||
"lookup.task_category.exterior": "Aussenbereich",
|
||||
"lookup.task_category.hvac": "HLK",
|
||||
"lookup.task_category.appliances": "Haushaltsgeräte",
|
||||
"lookup.task_category.exterior": "Außenbereich",
|
||||
"lookup.task_category.interior": "Innenbereich",
|
||||
"lookup.task_category.landscaping": "Gartenpflege",
|
||||
"lookup.task_category.safety": "Sicherheit",
|
||||
"lookup.task_category.cleaning": "Reinigung",
|
||||
"lookup.task_category.pest_control": "Schadlingsbekampfung",
|
||||
"lookup.task_category.pest_control": "Schädlingsbekämpfung",
|
||||
"lookup.task_category.seasonal": "Saisonal",
|
||||
"lookup.task_category.other": "Sonstiges",
|
||||
|
||||
"lookup.task_priority.low": "Niedrig",
|
||||
"lookup.task_priority.medium": "Mittel",
|
||||
"lookup.task_priority.high": "Hoch",
|
||||
"lookup.task_priority.urgent": "Dringend",
|
||||
|
||||
"lookup.task_status.pending": "Ausstehend",
|
||||
"lookup.task_status.in_progress": "In Bearbeitung",
|
||||
"lookup.task_status.completed": "Abgeschlossen",
|
||||
"lookup.task_status.cancelled": "Storniert",
|
||||
"lookup.task_status.archived": "Archiviert",
|
||||
|
||||
"lookup.task_frequency.once": "Einmalig",
|
||||
"lookup.task_frequency.daily": "Taglich",
|
||||
"lookup.task_frequency.weekly": "Wochentlich",
|
||||
"lookup.task_frequency.daily": "Täglich",
|
||||
"lookup.task_frequency.weekly": "Wöchentlich",
|
||||
"lookup.task_frequency.biweekly": "Alle 2 Wochen",
|
||||
"lookup.task_frequency.monthly": "Monatlich",
|
||||
"lookup.task_frequency.quarterly": "Vierteljahrlich",
|
||||
"lookup.task_frequency.quarterly": "Vierteljährlich",
|
||||
"lookup.task_frequency.semiannually": "Halbjahrlich",
|
||||
"lookup.task_frequency.annually": "Jahrlich",
|
||||
|
||||
"lookup.task_frequency.annually": "Jährlich",
|
||||
"lookup.contractor_specialty.plumber": "Klempner",
|
||||
"lookup.contractor_specialty.electrician": "Elektriker",
|
||||
"lookup.contractor_specialty.hvac_technician": "HLK-Techniker",
|
||||
"lookup.contractor_specialty.handyman": "Handwerker",
|
||||
"lookup.contractor_specialty.landscaper": "Landschaftsgartner",
|
||||
"lookup.contractor_specialty.landscaper": "Landschaftsgärtner",
|
||||
"lookup.contractor_specialty.roofer": "Dachdecker",
|
||||
"lookup.contractor_specialty.painter": "Maler",
|
||||
"lookup.contractor_specialty.carpenter": "Schreiner",
|
||||
"lookup.contractor_specialty.pest_control": "Schadlingsbekampfung",
|
||||
"lookup.contractor_specialty.pest_control": "Schädlingsbekämpfung",
|
||||
"lookup.contractor_specialty.cleaning": "Reinigung",
|
||||
"lookup.contractor_specialty.pool_service": "Pool-Service",
|
||||
"lookup.contractor_specialty.pool_service": "Poolservice",
|
||||
"lookup.contractor_specialty.general_contractor": "Generalunternehmer",
|
||||
"lookup.contractor_specialty.other": "Sonstiges"
|
||||
"lookup.contractor_specialty.other": "Sonstiges",
|
||||
"suggestion.reason.has_pool": "Ihr Zuhause hat einen Pool",
|
||||
"suggestion.reason.has_sprinkler_system": "Ihr Zuhause hat eine Bewässerungsanlage",
|
||||
"suggestion.reason.has_septic": "Ihr Zuhause hat eine Klärgrube",
|
||||
"suggestion.reason.has_fireplace": "Ihr Zuhause hat einen Kamin",
|
||||
"suggestion.reason.has_garage": "Ihr Zuhause hat eine Garage",
|
||||
"suggestion.reason.has_basement": "Ihr Zuhause hat einen Keller",
|
||||
"suggestion.reason.has_attic": "Ihr Zuhause hat einen Dachboden",
|
||||
"suggestion.reason.heating_type": "Passt zu Ihrer Heizung",
|
||||
"suggestion.reason.cooling_type": "Passt zu Ihrer Kühlung",
|
||||
"suggestion.reason.water_heater_type": "Passt zu Ihrem Warmwasserbereiter",
|
||||
"suggestion.reason.roof_type": "Passt zu Ihrem Dach",
|
||||
"suggestion.reason.exterior_type": "Passt zu Ihrer Fassade",
|
||||
"suggestion.reason.flooring_primary": "Passt zu Ihrem Bodenbelag",
|
||||
"suggestion.reason.landscaping_type": "Passt zu Ihrer Gartengestaltung",
|
||||
"suggestion.reason.property_type": "Empfohlen für Ihren Immobilientyp",
|
||||
"suggestion.reason.climate_region": "Empfohlen für Ihr Klima",
|
||||
"lookup.residence_type.duplex": "Doppelhaus",
|
||||
"lookup.residence_type.vacation_home": "Ferienhaus",
|
||||
"lookup.task_category.general": "Allgemein",
|
||||
"lookup.task_frequency.bi_weekly": "Zweiwöchentlich",
|
||||
"lookup.task_frequency.semi_annually": "Halbjährlich",
|
||||
"lookup.task_frequency.custom": "Benutzerdefiniert",
|
||||
"lookup.contractor_specialty.appliance_repair": "Gerätereparatur",
|
||||
"lookup.contractor_specialty.cleaner": "Reinigungskraft",
|
||||
"lookup.contractor_specialty.locksmith": "Schlosser",
|
||||
"lookup.home_profile.gas_furnace": "Gasheizung",
|
||||
"lookup.home_profile.electric_furnace": "Elektroheizung",
|
||||
"lookup.home_profile.heat_pump": "Wärmepumpe",
|
||||
"lookup.home_profile.boiler": "Heizkessel",
|
||||
"lookup.home_profile.radiant": "Strahlungsheizung",
|
||||
"lookup.home_profile.other": "Sonstiges",
|
||||
"lookup.home_profile.central_ac": "Zentrale Klimaanlage",
|
||||
"lookup.home_profile.window_ac": "Fensterklimagerät",
|
||||
"lookup.home_profile.evaporative": "Verdunstung",
|
||||
"lookup.home_profile.none": "Keine",
|
||||
"lookup.home_profile.tank_gas": "Speicher (Gas)",
|
||||
"lookup.home_profile.tank_electric": "Speicher (Elektro)",
|
||||
"lookup.home_profile.tankless_gas": "Durchlauf (Gas)",
|
||||
"lookup.home_profile.tankless_electric": "Durchlauf (Elektro)",
|
||||
"lookup.home_profile.solar": "Solar",
|
||||
"lookup.home_profile.asphalt_shingle": "Asphaltschindel",
|
||||
"lookup.home_profile.metal": "Metall",
|
||||
"lookup.home_profile.tile": "Ziegel",
|
||||
"lookup.home_profile.slate": "Schiefer",
|
||||
"lookup.home_profile.wood_shake": "Holzschindel",
|
||||
"lookup.home_profile.flat": "Flach",
|
||||
"lookup.home_profile.brick": "Backstein",
|
||||
"lookup.home_profile.vinyl_siding": "Vinylverkleidung",
|
||||
"lookup.home_profile.wood_siding": "Holzverkleidung",
|
||||
"lookup.home_profile.stucco": "Putz",
|
||||
"lookup.home_profile.stone": "Stein",
|
||||
"lookup.home_profile.fiber_cement": "Faserzement",
|
||||
"lookup.home_profile.hardwood": "Hartholz",
|
||||
"lookup.home_profile.laminate": "Laminat",
|
||||
"lookup.home_profile.carpet": "Teppich",
|
||||
"lookup.home_profile.vinyl": "Vinyl",
|
||||
"lookup.home_profile.concrete": "Beton",
|
||||
"lookup.home_profile.lawn": "Rasen",
|
||||
"lookup.home_profile.desert": "Wüste",
|
||||
"lookup.home_profile.xeriscape": "Xeriscaping",
|
||||
"lookup.home_profile.garden": "Garten",
|
||||
"lookup.home_profile.mixed": "Gemischt",
|
||||
"lookup.document_type.warranty": "Garantie",
|
||||
"lookup.document_type.manual": "Benutzerhandbuch",
|
||||
"lookup.document_type.receipt": "Beleg/Rechnung",
|
||||
"lookup.document_type.inspection": "Inspektionsbericht",
|
||||
"lookup.document_type.permit": "Genehmigung",
|
||||
"lookup.document_type.deed": "Urkunde/Titel",
|
||||
"lookup.document_type.insurance": "Versicherung",
|
||||
"lookup.document_type.contract": "Vertrag",
|
||||
"lookup.document_type.photo": "Foto",
|
||||
"lookup.document_type.other": "Sonstiges",
|
||||
"lookup.document_category.appliance": "Haushaltsgerät",
|
||||
"lookup.document_category.hvac": "HLK",
|
||||
"lookup.document_category.plumbing": "Sanitär",
|
||||
"lookup.document_category.electrical": "Elektrik",
|
||||
"lookup.document_category.roofing": "Dach",
|
||||
"lookup.document_category.structural": "Struktur",
|
||||
"lookup.document_category.landscaping": "Gartengestaltung",
|
||||
"lookup.document_category.general": "Allgemein",
|
||||
"lookup.document_category.other": "Sonstiges"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"error.google_signin_not_configured": "Google Sign In is not configured",
|
||||
"error.google_signin_failed": "Google Sign In failed",
|
||||
"error.invalid_google_token": "Invalid Google identity token",
|
||||
|
||||
"error.invalid_task_id": "Invalid task ID",
|
||||
"error.invalid_residence_id": "Invalid residence ID",
|
||||
"error.invalid_contractor_id": "Invalid contractor ID",
|
||||
@@ -37,7 +36,6 @@
|
||||
"error.invalid_user_id": "Invalid user ID",
|
||||
"error.invalid_notification_id": "Invalid notification ID",
|
||||
"error.invalid_device_id": "Invalid device ID",
|
||||
|
||||
"error.task_not_found": "Task not found",
|
||||
"error.residence_not_found": "Residence not found",
|
||||
"error.contractor_not_found": "Contractor not found",
|
||||
@@ -46,7 +44,6 @@
|
||||
"error.user_not_found": "User not found",
|
||||
"error.share_code_invalid": "Invalid share code",
|
||||
"error.share_code_expired": "Share code has expired",
|
||||
|
||||
"error.task_access_denied": "You don't have access to this task",
|
||||
"error.residence_access_denied": "You don't have access to this property",
|
||||
"error.contractor_access_denied": "You don't have access to this contractor",
|
||||
@@ -55,10 +52,8 @@
|
||||
"error.cannot_remove_owner": "Cannot remove the property owner",
|
||||
"error.user_already_member": "User is already a member of this property",
|
||||
"error.properties_limit_reached": "You have reached the maximum number of properties for your subscription",
|
||||
|
||||
"error.task_already_cancelled": "Task is already cancelled",
|
||||
"error.task_already_archived": "Task is already archived",
|
||||
|
||||
"error.failed_to_parse_form": "Failed to parse multipart form",
|
||||
"error.task_id_required": "task_id is required",
|
||||
"error.invalid_task_id_value": "Invalid task_id",
|
||||
@@ -67,14 +62,12 @@
|
||||
"error.invalid_residence_id_value": "Invalid residence_id",
|
||||
"error.title_required": "title is required",
|
||||
"error.failed_to_upload_file": "Failed to upload file",
|
||||
|
||||
"message.logged_out": "Logged out successfully",
|
||||
"message.email_verified": "Email verified successfully",
|
||||
"message.verification_email_sent": "Verification email sent",
|
||||
"message.password_reset_email_sent": "If an account with that email exists, a password reset code has been sent.",
|
||||
"message.reset_code_verified": "Code verified successfully",
|
||||
"message.password_reset_success": "Password reset successfully. Please log in with your new password.",
|
||||
|
||||
"message.task_deleted": "Task deleted successfully",
|
||||
"message.task_in_progress": "Task marked as in progress",
|
||||
"message.task_cancelled": "Task cancelled",
|
||||
@@ -82,44 +75,34 @@
|
||||
"message.task_archived": "Task archived",
|
||||
"message.task_unarchived": "Task unarchived",
|
||||
"message.completion_deleted": "Completion deleted successfully",
|
||||
|
||||
"message.residence_deleted": "Residence deleted successfully",
|
||||
"message.user_removed": "User removed from residence",
|
||||
"message.tasks_report_generated": "Tasks report generated successfully",
|
||||
"message.tasks_report_sent": "Tasks report generated and sent to {{.Email}}",
|
||||
"message.tasks_report_email_failed": "Tasks report generated but email could not be sent",
|
||||
|
||||
"message.contractor_deleted": "Contractor deleted successfully",
|
||||
|
||||
"message.document_deleted": "Document deleted successfully",
|
||||
"message.document_activated": "Document activated",
|
||||
"message.document_deactivated": "Document deactivated",
|
||||
|
||||
"message.notification_marked_read": "Notification marked as read",
|
||||
"message.all_notifications_marked_read": "All notifications marked as read",
|
||||
"message.device_removed": "Device removed",
|
||||
|
||||
"message.subscription_upgraded": "Subscription upgraded successfully",
|
||||
"message.subscription_cancelled": "Subscription cancelled. You will retain Pro benefits until the end of your billing period.",
|
||||
"message.subscription_restored": "Subscription restored successfully",
|
||||
|
||||
"message.file_deleted": "File deleted successfully",
|
||||
"message.static_data_refreshed": "Static data refreshed",
|
||||
|
||||
"error.notification_not_found": "Notification not found",
|
||||
"error.invalid_platform": "Invalid platform",
|
||||
|
||||
"error.upgrade_trigger_not_found": "Upgrade trigger not found",
|
||||
"error.receipt_data_required": "receipt_data is required for iOS",
|
||||
"error.purchase_token_required": "purchase_token is required for Android",
|
||||
|
||||
"error.no_file_provided": "No file provided",
|
||||
"error.url_required": "File URL is required",
|
||||
"error.file_access_denied": "You don't have access to this file",
|
||||
"error.days_out_of_range": "Days parameter must be between 1 and 3650",
|
||||
"error.platform_required": "Platform is required (ios or android)",
|
||||
"error.registration_id_required": "Registration ID is required",
|
||||
|
||||
"error.failed_to_fetch_residence_types": "Failed to fetch residence types",
|
||||
"error.failed_to_fetch_task_categories": "Failed to fetch task categories",
|
||||
"error.failed_to_fetch_task_priorities": "Failed to fetch task priorities",
|
||||
@@ -129,7 +112,6 @@
|
||||
"error.failed_to_fetch_templates": "Failed to fetch task templates",
|
||||
"error.failed_to_search_templates": "Failed to search task templates",
|
||||
"error.template_not_found": "Task template not found",
|
||||
|
||||
"push.task_due_soon.title": "Task Due Soon",
|
||||
"push.task_due_soon.body": "{{.TaskTitle}} is due {{.DueDate}}",
|
||||
"push.task_overdue.title": "Overdue Task",
|
||||
@@ -140,19 +122,16 @@
|
||||
"push.task_assigned.body": "You have been assigned to {{.TaskTitle}}",
|
||||
"push.residence_shared.title": "Property Shared",
|
||||
"push.residence_shared.body": "{{.UserName}} shared {{.ResidenceName}} with you",
|
||||
|
||||
"email.welcome.subject": "Welcome to honeyDue!",
|
||||
"email.verification.subject": "Verify Your Email",
|
||||
"email.password_reset.subject": "Password Reset Code",
|
||||
"email.tasks_report.subject": "Tasks Report for {{.ResidenceName}}",
|
||||
|
||||
"lookup.residence_type.house": "House",
|
||||
"lookup.residence_type.apartment": "Apartment",
|
||||
"lookup.residence_type.condo": "Condo",
|
||||
"lookup.residence_type.townhouse": "Townhouse",
|
||||
"lookup.residence_type.mobile_home": "Mobile Home",
|
||||
"lookup.residence_type.other": "Other",
|
||||
|
||||
"lookup.task_category.plumbing": "Plumbing",
|
||||
"lookup.task_category.electrical": "Electrical",
|
||||
"lookup.task_category.hvac": "HVAC",
|
||||
@@ -165,18 +144,15 @@
|
||||
"lookup.task_category.pest_control": "Pest Control",
|
||||
"lookup.task_category.seasonal": "Seasonal",
|
||||
"lookup.task_category.other": "Other",
|
||||
|
||||
"lookup.task_priority.low": "Low",
|
||||
"lookup.task_priority.medium": "Medium",
|
||||
"lookup.task_priority.high": "High",
|
||||
"lookup.task_priority.urgent": "Urgent",
|
||||
|
||||
"lookup.task_status.pending": "Pending",
|
||||
"lookup.task_status.in_progress": "In Progress",
|
||||
"lookup.task_status.completed": "Completed",
|
||||
"lookup.task_status.cancelled": "Cancelled",
|
||||
"lookup.task_status.archived": "Archived",
|
||||
|
||||
"lookup.task_frequency.once": "Once",
|
||||
"lookup.task_frequency.daily": "Daily",
|
||||
"lookup.task_frequency.weekly": "Weekly",
|
||||
@@ -185,7 +161,6 @@
|
||||
"lookup.task_frequency.quarterly": "Quarterly",
|
||||
"lookup.task_frequency.semiannually": "Every 6 Months",
|
||||
"lookup.task_frequency.annually": "Annually",
|
||||
|
||||
"lookup.contractor_specialty.plumber": "Plumber",
|
||||
"lookup.contractor_specialty.electrician": "Electrician",
|
||||
"lookup.contractor_specialty.hvac_technician": "HVAC Technician",
|
||||
@@ -198,5 +173,86 @@
|
||||
"lookup.contractor_specialty.cleaning": "Cleaning",
|
||||
"lookup.contractor_specialty.pool_service": "Pool Service",
|
||||
"lookup.contractor_specialty.general_contractor": "General Contractor",
|
||||
"lookup.contractor_specialty.other": "Other"
|
||||
"lookup.contractor_specialty.other": "Other",
|
||||
"suggestion.reason.has_pool": "Your home has a pool",
|
||||
"suggestion.reason.has_sprinkler_system": "Your home has a sprinkler system",
|
||||
"suggestion.reason.has_septic": "Your home has a septic system",
|
||||
"suggestion.reason.has_fireplace": "Your home has a fireplace",
|
||||
"suggestion.reason.has_garage": "Your home has a garage",
|
||||
"suggestion.reason.has_basement": "Your home has a basement",
|
||||
"suggestion.reason.has_attic": "Your home has an attic",
|
||||
"suggestion.reason.heating_type": "Matches your heating system",
|
||||
"suggestion.reason.cooling_type": "Matches your cooling system",
|
||||
"suggestion.reason.water_heater_type": "Matches your water heater",
|
||||
"suggestion.reason.roof_type": "Matches your roof",
|
||||
"suggestion.reason.exterior_type": "Matches your exterior",
|
||||
"suggestion.reason.flooring_primary": "Matches your flooring",
|
||||
"suggestion.reason.landscaping_type": "Matches your landscaping",
|
||||
"suggestion.reason.property_type": "Recommended for your property type",
|
||||
"suggestion.reason.climate_region": "Recommended for your climate",
|
||||
"lookup.residence_type.duplex": "Duplex",
|
||||
"lookup.residence_type.vacation_home": "Vacation Home",
|
||||
"lookup.task_category.general": "General",
|
||||
"lookup.task_frequency.bi_weekly": "Bi-Weekly",
|
||||
"lookup.task_frequency.semi_annually": "Semi-Annually",
|
||||
"lookup.task_frequency.custom": "Custom",
|
||||
"lookup.contractor_specialty.appliance_repair": "Appliance Repair",
|
||||
"lookup.contractor_specialty.cleaner": "Cleaner",
|
||||
"lookup.contractor_specialty.locksmith": "Locksmith",
|
||||
"lookup.home_profile.gas_furnace": "Gas Furnace",
|
||||
"lookup.home_profile.electric_furnace": "Electric Furnace",
|
||||
"lookup.home_profile.heat_pump": "Heat Pump",
|
||||
"lookup.home_profile.boiler": "Boiler",
|
||||
"lookup.home_profile.radiant": "Radiant",
|
||||
"lookup.home_profile.other": "Other",
|
||||
"lookup.home_profile.central_ac": "Central AC",
|
||||
"lookup.home_profile.window_ac": "Window AC",
|
||||
"lookup.home_profile.evaporative": "Evaporative",
|
||||
"lookup.home_profile.none": "None",
|
||||
"lookup.home_profile.tank_gas": "Tank (Gas)",
|
||||
"lookup.home_profile.tank_electric": "Tank (Electric)",
|
||||
"lookup.home_profile.tankless_gas": "Tankless (Gas)",
|
||||
"lookup.home_profile.tankless_electric": "Tankless (Electric)",
|
||||
"lookup.home_profile.solar": "Solar",
|
||||
"lookup.home_profile.asphalt_shingle": "Asphalt Shingle",
|
||||
"lookup.home_profile.metal": "Metal",
|
||||
"lookup.home_profile.tile": "Tile",
|
||||
"lookup.home_profile.slate": "Slate",
|
||||
"lookup.home_profile.wood_shake": "Wood Shake",
|
||||
"lookup.home_profile.flat": "Flat",
|
||||
"lookup.home_profile.brick": "Brick",
|
||||
"lookup.home_profile.vinyl_siding": "Vinyl Siding",
|
||||
"lookup.home_profile.wood_siding": "Wood Siding",
|
||||
"lookup.home_profile.stucco": "Stucco",
|
||||
"lookup.home_profile.stone": "Stone",
|
||||
"lookup.home_profile.fiber_cement": "Fiber Cement",
|
||||
"lookup.home_profile.hardwood": "Hardwood",
|
||||
"lookup.home_profile.laminate": "Laminate",
|
||||
"lookup.home_profile.carpet": "Carpet",
|
||||
"lookup.home_profile.vinyl": "Vinyl",
|
||||
"lookup.home_profile.concrete": "Concrete",
|
||||
"lookup.home_profile.lawn": "Lawn",
|
||||
"lookup.home_profile.desert": "Desert",
|
||||
"lookup.home_profile.xeriscape": "Xeriscape",
|
||||
"lookup.home_profile.garden": "Garden",
|
||||
"lookup.home_profile.mixed": "Mixed",
|
||||
"lookup.document_type.warranty": "Warranty",
|
||||
"lookup.document_type.manual": "User Manual",
|
||||
"lookup.document_type.receipt": "Receipt/Invoice",
|
||||
"lookup.document_type.inspection": "Inspection Report",
|
||||
"lookup.document_type.permit": "Permit",
|
||||
"lookup.document_type.deed": "Deed/Title",
|
||||
"lookup.document_type.insurance": "Insurance",
|
||||
"lookup.document_type.contract": "Contract",
|
||||
"lookup.document_type.photo": "Photo",
|
||||
"lookup.document_type.other": "Other",
|
||||
"lookup.document_category.appliance": "Appliance",
|
||||
"lookup.document_category.hvac": "HVAC",
|
||||
"lookup.document_category.plumbing": "Plumbing",
|
||||
"lookup.document_category.electrical": "Electrical",
|
||||
"lookup.document_category.roofing": "Roofing",
|
||||
"lookup.document_category.structural": "Structural",
|
||||
"lookup.document_category.landscaping": "Landscaping",
|
||||
"lookup.document_category.general": "General",
|
||||
"lookup.document_category.other": "Other"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"error.google_signin_not_configured": "El inicio de sesion con Google no esta configurado",
|
||||
"error.google_signin_failed": "Error en el inicio de sesion con Google",
|
||||
"error.invalid_google_token": "Token de identidad de Google no valido",
|
||||
|
||||
"error.invalid_task_id": "ID de tarea no valido",
|
||||
"error.invalid_residence_id": "ID de propiedad no valido",
|
||||
"error.invalid_contractor_id": "ID de contratista no valido",
|
||||
@@ -34,7 +33,6 @@
|
||||
"error.invalid_user_id": "ID de usuario no valido",
|
||||
"error.invalid_notification_id": "ID de notificacion no valido",
|
||||
"error.invalid_device_id": "ID de dispositivo no valido",
|
||||
|
||||
"error.task_not_found": "Tarea no encontrada",
|
||||
"error.residence_not_found": "Propiedad no encontrada",
|
||||
"error.contractor_not_found": "Contratista no encontrado",
|
||||
@@ -43,7 +41,6 @@
|
||||
"error.user_not_found": "Usuario no encontrado",
|
||||
"error.share_code_invalid": "Codigo de compartir no valido",
|
||||
"error.share_code_expired": "El codigo de compartir ha expirado",
|
||||
|
||||
"error.task_access_denied": "No tienes acceso a esta tarea",
|
||||
"error.residence_access_denied": "No tienes acceso a esta propiedad",
|
||||
"error.contractor_access_denied": "No tienes acceso a este contratista",
|
||||
@@ -52,10 +49,8 @@
|
||||
"error.cannot_remove_owner": "No se puede eliminar al propietario de la propiedad",
|
||||
"error.user_already_member": "El usuario ya es miembro de esta propiedad",
|
||||
"error.properties_limit_reached": "Has alcanzado el numero maximo de propiedades para tu suscripcion",
|
||||
|
||||
"error.task_already_cancelled": "La tarea ya esta cancelada",
|
||||
"error.task_already_archived": "La tarea ya esta archivada",
|
||||
|
||||
"error.failed_to_parse_form": "Error al analizar el formulario",
|
||||
"error.task_id_required": "Se requiere task_id",
|
||||
"error.invalid_task_id_value": "task_id no valido",
|
||||
@@ -64,14 +59,12 @@
|
||||
"error.invalid_residence_id_value": "residence_id no valido",
|
||||
"error.title_required": "Se requiere el titulo",
|
||||
"error.failed_to_upload_file": "Error al subir el archivo",
|
||||
|
||||
"message.logged_out": "Sesion cerrada correctamente",
|
||||
"message.email_verified": "Correo electronico verificado correctamente",
|
||||
"message.verification_email_sent": "Correo de verificacion enviado",
|
||||
"message.password_reset_email_sent": "Si existe una cuenta con ese correo electronico, se ha enviado un codigo de restablecimiento de contrasena.",
|
||||
"message.reset_code_verified": "Codigo verificado correctamente",
|
||||
"message.password_reset_success": "Contrasena restablecida correctamente. Por favor, inicia sesion con tu nueva contrasena.",
|
||||
|
||||
"message.task_deleted": "Tarea eliminada correctamente",
|
||||
"message.task_in_progress": "Tarea marcada como en progreso",
|
||||
"message.task_cancelled": "Tarea cancelada",
|
||||
@@ -79,46 +72,35 @@
|
||||
"message.task_archived": "Tarea archivada",
|
||||
"message.task_unarchived": "Tarea desarchivada",
|
||||
"message.completion_deleted": "Finalizacion eliminada correctamente",
|
||||
|
||||
"message.residence_deleted": "Propiedad eliminada correctamente",
|
||||
"message.user_removed": "Usuario eliminado de la propiedad",
|
||||
"message.tasks_report_generated": "Informe de tareas generado correctamente",
|
||||
"message.tasks_report_sent": "Informe de tareas generado y enviado a {{.Email}}",
|
||||
"message.tasks_report_email_failed": "Informe de tareas generado pero no se pudo enviar el correo",
|
||||
|
||||
"message.contractor_deleted": "Contratista eliminado correctamente",
|
||||
|
||||
"message.document_deleted": "Documento eliminado correctamente",
|
||||
"message.document_activated": "Documento activado",
|
||||
"message.document_deactivated": "Documento desactivado",
|
||||
|
||||
"message.notification_marked_read": "Notificación marcada como leída",
|
||||
"message.all_notifications_marked_read": "Todas las notificaciones marcadas como leídas",
|
||||
"message.device_removed": "Dispositivo eliminado",
|
||||
|
||||
"message.subscription_upgraded": "Suscripción actualizada correctamente",
|
||||
"message.subscription_cancelled": "Suscripción cancelada. Mantendrás los beneficios Pro hasta el final de tu período de facturación.",
|
||||
"message.subscription_restored": "Suscripción restaurada correctamente",
|
||||
|
||||
"message.file_deleted": "Archivo eliminado correctamente",
|
||||
"message.static_data_refreshed": "Datos estáticos actualizados",
|
||||
|
||||
"error.notification_not_found": "Notificación no encontrada",
|
||||
"error.invalid_platform": "Plataforma no válida",
|
||||
|
||||
"error.upgrade_trigger_not_found": "Trigger de actualización no encontrado",
|
||||
"error.receipt_data_required": "Se requiere receipt_data para iOS",
|
||||
"error.purchase_token_required": "Se requiere purchase_token para Android",
|
||||
|
||||
"error.no_file_provided": "No se proporcionó ningún archivo",
|
||||
|
||||
"error.failed_to_fetch_residence_types": "Error al obtener los tipos de propiedad",
|
||||
"error.failed_to_fetch_task_categories": "Error al obtener las categorías de tareas",
|
||||
"error.failed_to_fetch_task_priorities": "Error al obtener las prioridades de tareas",
|
||||
"error.failed_to_fetch_task_frequencies": "Error al obtener las frecuencias de tareas",
|
||||
"error.failed_to_fetch_task_statuses": "Error al obtener los estados de tareas",
|
||||
"error.failed_to_fetch_contractor_specialties": "Error al obtener las especialidades de contratistas",
|
||||
|
||||
"push.task_due_soon.title": "Tarea Proxima a Vencer",
|
||||
"push.task_due_soon.body": "{{.TaskTitle}} vence {{.DueDate}}",
|
||||
"push.task_overdue.title": "Tarea Vencida",
|
||||
@@ -129,44 +111,38 @@
|
||||
"push.task_assigned.body": "Se te ha asignado {{.TaskTitle}}",
|
||||
"push.residence_shared.title": "Propiedad Compartida",
|
||||
"push.residence_shared.body": "{{.UserName}} compartio {{.ResidenceName}} contigo",
|
||||
|
||||
"email.welcome.subject": "Bienvenido a honeyDue!",
|
||||
"email.verification.subject": "Verifica Tu Correo Electronico",
|
||||
"email.password_reset.subject": "Codigo de Restablecimiento de Contrasena",
|
||||
"email.tasks_report.subject": "Informe de Tareas para {{.ResidenceName}}",
|
||||
|
||||
"lookup.residence_type.house": "Casa",
|
||||
"lookup.residence_type.apartment": "Apartamento",
|
||||
"lookup.residence_type.condo": "Condominio",
|
||||
"lookup.residence_type.townhouse": "Casa Adosada",
|
||||
"lookup.residence_type.mobile_home": "Casa Movil",
|
||||
"lookup.residence_type.townhouse": "Casa adosada",
|
||||
"lookup.residence_type.mobile_home": "Casa móvil",
|
||||
"lookup.residence_type.other": "Otro",
|
||||
|
||||
"lookup.task_category.plumbing": "Plomeria",
|
||||
"lookup.task_category.electrical": "Electricidad",
|
||||
"lookup.task_category.hvac": "Climatizacion",
|
||||
"lookup.task_category.appliances": "Electrodomesticos",
|
||||
"lookup.task_category.plumbing": "Fontanería",
|
||||
"lookup.task_category.electrical": "Eléctrico",
|
||||
"lookup.task_category.hvac": "Climatización",
|
||||
"lookup.task_category.appliances": "Electrodomésticos",
|
||||
"lookup.task_category.exterior": "Exterior",
|
||||
"lookup.task_category.interior": "Interior",
|
||||
"lookup.task_category.landscaping": "Jardineria",
|
||||
"lookup.task_category.safety": "Seguridad",
|
||||
"lookup.task_category.cleaning": "Limpieza",
|
||||
"lookup.task_category.pest_control": "Control de Plagas",
|
||||
"lookup.task_category.pest_control": "Control de plagas",
|
||||
"lookup.task_category.seasonal": "Estacional",
|
||||
"lookup.task_category.other": "Otro",
|
||||
|
||||
"lookup.task_priority.low": "Baja",
|
||||
"lookup.task_priority.medium": "Media",
|
||||
"lookup.task_priority.high": "Alta",
|
||||
"lookup.task_priority.urgent": "Urgente",
|
||||
|
||||
"lookup.task_status.pending": "Pendiente",
|
||||
"lookup.task_status.in_progress": "En Progreso",
|
||||
"lookup.task_status.completed": "Completada",
|
||||
"lookup.task_status.cancelled": "Cancelada",
|
||||
"lookup.task_status.archived": "Archivada",
|
||||
|
||||
"lookup.task_frequency.once": "Una Vez",
|
||||
"lookup.task_frequency.once": "Una vez",
|
||||
"lookup.task_frequency.daily": "Diario",
|
||||
"lookup.task_frequency.weekly": "Semanal",
|
||||
"lookup.task_frequency.biweekly": "Cada 2 Semanas",
|
||||
@@ -174,18 +150,98 @@
|
||||
"lookup.task_frequency.quarterly": "Trimestral",
|
||||
"lookup.task_frequency.semiannually": "Cada 6 Meses",
|
||||
"lookup.task_frequency.annually": "Anual",
|
||||
|
||||
"lookup.contractor_specialty.plumber": "Plomero",
|
||||
"lookup.contractor_specialty.plumber": "Fontanero",
|
||||
"lookup.contractor_specialty.electrician": "Electricista",
|
||||
"lookup.contractor_specialty.hvac_technician": "Tecnico de Climatizacion",
|
||||
"lookup.contractor_specialty.hvac_technician": "Técnico de climatización",
|
||||
"lookup.contractor_specialty.handyman": "Manitas",
|
||||
"lookup.contractor_specialty.landscaper": "Jardinero",
|
||||
"lookup.contractor_specialty.roofer": "Techador",
|
||||
"lookup.contractor_specialty.painter": "Pintor",
|
||||
"lookup.contractor_specialty.carpenter": "Carpintero",
|
||||
"lookup.contractor_specialty.pest_control": "Control de Plagas",
|
||||
"lookup.contractor_specialty.pest_control": "Control de plagas",
|
||||
"lookup.contractor_specialty.cleaning": "Limpieza",
|
||||
"lookup.contractor_specialty.pool_service": "Servicio de Piscina",
|
||||
"lookup.contractor_specialty.general_contractor": "Contratista General",
|
||||
"lookup.contractor_specialty.other": "Otro"
|
||||
"lookup.contractor_specialty.pool_service": "Servicio de piscina",
|
||||
"lookup.contractor_specialty.general_contractor": "Contratista general",
|
||||
"lookup.contractor_specialty.other": "Otro",
|
||||
"suggestion.reason.has_pool": "Tu casa tiene piscina",
|
||||
"suggestion.reason.has_sprinkler_system": "Tu casa tiene sistema de riego",
|
||||
"suggestion.reason.has_septic": "Tu casa tiene fosa séptica",
|
||||
"suggestion.reason.has_fireplace": "Tu casa tiene chimenea",
|
||||
"suggestion.reason.has_garage": "Tu casa tiene garaje",
|
||||
"suggestion.reason.has_basement": "Tu casa tiene sótano",
|
||||
"suggestion.reason.has_attic": "Tu casa tiene ático",
|
||||
"suggestion.reason.heating_type": "Coincide con tu sistema de calefacción",
|
||||
"suggestion.reason.cooling_type": "Coincide con tu sistema de refrigeración",
|
||||
"suggestion.reason.water_heater_type": "Coincide con tu calentador de agua",
|
||||
"suggestion.reason.roof_type": "Coincide con tu tejado",
|
||||
"suggestion.reason.exterior_type": "Coincide con tu exterior",
|
||||
"suggestion.reason.flooring_primary": "Coincide con tu suelo",
|
||||
"suggestion.reason.landscaping_type": "Coincide con tu jardín",
|
||||
"suggestion.reason.property_type": "Recomendado para tu tipo de propiedad",
|
||||
"suggestion.reason.climate_region": "Recomendado para tu clima",
|
||||
"lookup.residence_type.duplex": "Dúplex",
|
||||
"lookup.residence_type.vacation_home": "Casa de vacaciones",
|
||||
"lookup.task_category.general": "General",
|
||||
"lookup.task_frequency.bi_weekly": "Quincenal",
|
||||
"lookup.task_frequency.semi_annually": "Semestral",
|
||||
"lookup.task_frequency.custom": "Personalizado",
|
||||
"lookup.contractor_specialty.appliance_repair": "Reparación de electrodomésticos",
|
||||
"lookup.contractor_specialty.cleaner": "Limpiador",
|
||||
"lookup.contractor_specialty.locksmith": "Cerrajero",
|
||||
"lookup.home_profile.gas_furnace": "Calefactor de gas",
|
||||
"lookup.home_profile.electric_furnace": "Calefactor eléctrico",
|
||||
"lookup.home_profile.heat_pump": "Bomba de calor",
|
||||
"lookup.home_profile.boiler": "Caldera",
|
||||
"lookup.home_profile.radiant": "Radiante",
|
||||
"lookup.home_profile.other": "Otro",
|
||||
"lookup.home_profile.central_ac": "AC central",
|
||||
"lookup.home_profile.window_ac": "AC de ventana",
|
||||
"lookup.home_profile.evaporative": "Evaporativo",
|
||||
"lookup.home_profile.none": "Ninguno",
|
||||
"lookup.home_profile.tank_gas": "Tanque (gas)",
|
||||
"lookup.home_profile.tank_electric": "Tanque (eléctrico)",
|
||||
"lookup.home_profile.tankless_gas": "Sin tanque (gas)",
|
||||
"lookup.home_profile.tankless_electric": "Sin tanque (eléctrico)",
|
||||
"lookup.home_profile.solar": "Solar",
|
||||
"lookup.home_profile.asphalt_shingle": "Teja asfáltica",
|
||||
"lookup.home_profile.metal": "Metal",
|
||||
"lookup.home_profile.tile": "Teja",
|
||||
"lookup.home_profile.slate": "Pizarra",
|
||||
"lookup.home_profile.wood_shake": "Tablilla de madera",
|
||||
"lookup.home_profile.flat": "Plano",
|
||||
"lookup.home_profile.brick": "Ladrillo",
|
||||
"lookup.home_profile.vinyl_siding": "Revestimiento de vinilo",
|
||||
"lookup.home_profile.wood_siding": "Revestimiento de madera",
|
||||
"lookup.home_profile.stucco": "Estuco",
|
||||
"lookup.home_profile.stone": "Piedra",
|
||||
"lookup.home_profile.fiber_cement": "Fibrocemento",
|
||||
"lookup.home_profile.hardwood": "Madera dura",
|
||||
"lookup.home_profile.laminate": "Laminado",
|
||||
"lookup.home_profile.carpet": "Alfombra",
|
||||
"lookup.home_profile.vinyl": "Vinilo",
|
||||
"lookup.home_profile.concrete": "Hormigón",
|
||||
"lookup.home_profile.lawn": "Césped",
|
||||
"lookup.home_profile.desert": "Desierto",
|
||||
"lookup.home_profile.xeriscape": "Xerojardinería",
|
||||
"lookup.home_profile.garden": "Jardín",
|
||||
"lookup.home_profile.mixed": "Mixto",
|
||||
"lookup.document_type.warranty": "Garantía",
|
||||
"lookup.document_type.manual": "Manual de usuario",
|
||||
"lookup.document_type.receipt": "Recibo/Factura",
|
||||
"lookup.document_type.inspection": "Informe de inspección",
|
||||
"lookup.document_type.permit": "Permiso",
|
||||
"lookup.document_type.deed": "Escritura/Título",
|
||||
"lookup.document_type.insurance": "Seguro",
|
||||
"lookup.document_type.contract": "Contrato",
|
||||
"lookup.document_type.photo": "Foto",
|
||||
"lookup.document_type.other": "Otro",
|
||||
"lookup.document_category.appliance": "Electrodoméstico",
|
||||
"lookup.document_category.hvac": "Climatización",
|
||||
"lookup.document_category.plumbing": "Fontanería",
|
||||
"lookup.document_category.electrical": "Eléctrico",
|
||||
"lookup.document_category.roofing": "Tejado",
|
||||
"lookup.document_category.structural": "Estructural",
|
||||
"lookup.document_category.landscaping": "Jardinería",
|
||||
"lookup.document_category.general": "General",
|
||||
"lookup.document_category.other": "Otro"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"error.google_signin_not_configured": "La connexion Google n'est pas configuree",
|
||||
"error.google_signin_failed": "Echec de la connexion Google",
|
||||
"error.invalid_google_token": "Jeton d'identite Google non valide",
|
||||
|
||||
"error.invalid_task_id": "ID de tache non valide",
|
||||
"error.invalid_residence_id": "ID de propriete non valide",
|
||||
"error.invalid_contractor_id": "ID de prestataire non valide",
|
||||
@@ -34,7 +33,6 @@
|
||||
"error.invalid_user_id": "ID d'utilisateur non valide",
|
||||
"error.invalid_notification_id": "ID de notification non valide",
|
||||
"error.invalid_device_id": "ID d'appareil non valide",
|
||||
|
||||
"error.task_not_found": "Tache non trouvee",
|
||||
"error.residence_not_found": "Propriete non trouvee",
|
||||
"error.contractor_not_found": "Prestataire non trouve",
|
||||
@@ -43,7 +41,6 @@
|
||||
"error.user_not_found": "Utilisateur non trouve",
|
||||
"error.share_code_invalid": "Code de partage non valide",
|
||||
"error.share_code_expired": "Le code de partage a expire",
|
||||
|
||||
"error.task_access_denied": "Vous n'avez pas acces a cette tache",
|
||||
"error.residence_access_denied": "Vous n'avez pas acces a cette propriete",
|
||||
"error.contractor_access_denied": "Vous n'avez pas acces a ce prestataire",
|
||||
@@ -52,10 +49,8 @@
|
||||
"error.cannot_remove_owner": "Impossible de retirer le proprietaire",
|
||||
"error.user_already_member": "L'utilisateur est deja membre de cette propriete",
|
||||
"error.properties_limit_reached": "Vous avez atteint le nombre maximum de proprietes pour votre abonnement",
|
||||
|
||||
"error.task_already_cancelled": "La tache est deja annulee",
|
||||
"error.task_already_archived": "La tache est deja archivee",
|
||||
|
||||
"error.failed_to_parse_form": "Echec de l'analyse du formulaire",
|
||||
"error.task_id_required": "task_id est requis",
|
||||
"error.invalid_task_id_value": "task_id non valide",
|
||||
@@ -64,14 +59,12 @@
|
||||
"error.invalid_residence_id_value": "residence_id non valide",
|
||||
"error.title_required": "Le titre est requis",
|
||||
"error.failed_to_upload_file": "Echec du telechargement du fichier",
|
||||
|
||||
"message.logged_out": "Deconnexion reussie",
|
||||
"message.email_verified": "Email verifie avec succes",
|
||||
"message.verification_email_sent": "Email de verification envoye",
|
||||
"message.password_reset_email_sent": "Si un compte existe avec cet email, un code de reinitialisation a ete envoye.",
|
||||
"message.reset_code_verified": "Code verifie avec succes",
|
||||
"message.password_reset_success": "Mot de passe reinitialise avec succes. Veuillez vous connecter avec votre nouveau mot de passe.",
|
||||
|
||||
"message.task_deleted": "Tache supprimee avec succes",
|
||||
"message.task_in_progress": "Tache marquee comme en cours",
|
||||
"message.task_cancelled": "Tache annulee",
|
||||
@@ -79,46 +72,35 @@
|
||||
"message.task_archived": "Tache archivee",
|
||||
"message.task_unarchived": "Tache desarchivee",
|
||||
"message.completion_deleted": "Completion supprimee avec succes",
|
||||
|
||||
"message.residence_deleted": "Propriete supprimee avec succes",
|
||||
"message.user_removed": "Utilisateur retire de la propriete",
|
||||
"message.tasks_report_generated": "Rapport de taches genere avec succes",
|
||||
"message.tasks_report_sent": "Rapport de taches genere et envoye a {{.Email}}",
|
||||
"message.tasks_report_email_failed": "Rapport de taches genere mais l'email n'a pas pu etre envoye",
|
||||
|
||||
"message.contractor_deleted": "Prestataire supprime avec succes",
|
||||
|
||||
"message.document_deleted": "Document supprime avec succes",
|
||||
"message.document_activated": "Document active",
|
||||
"message.document_deactivated": "Document desactive",
|
||||
|
||||
"message.notification_marked_read": "Notification marquée comme lue",
|
||||
"message.all_notifications_marked_read": "Toutes les notifications marquées comme lues",
|
||||
"message.device_removed": "Appareil supprimé",
|
||||
|
||||
"message.subscription_upgraded": "Abonnement mis à niveau avec succès",
|
||||
"message.subscription_cancelled": "Abonnement annulé. Vous conserverez les avantages Pro jusqu'à la fin de votre période de facturation.",
|
||||
"message.subscription_restored": "Abonnement restauré avec succès",
|
||||
|
||||
"message.file_deleted": "Fichier supprimé avec succès",
|
||||
"message.static_data_refreshed": "Données statiques actualisées",
|
||||
|
||||
"error.notification_not_found": "Notification non trouvée",
|
||||
"error.invalid_platform": "Plateforme non valide",
|
||||
|
||||
"error.upgrade_trigger_not_found": "Déclencheur de mise à niveau non trouvé",
|
||||
"error.receipt_data_required": "receipt_data est requis pour iOS",
|
||||
"error.purchase_token_required": "purchase_token est requis pour Android",
|
||||
|
||||
"error.no_file_provided": "Aucun fichier fourni",
|
||||
|
||||
"error.failed_to_fetch_residence_types": "Échec de la récupération des types de propriété",
|
||||
"error.failed_to_fetch_task_categories": "Échec de la récupération des catégories de tâches",
|
||||
"error.failed_to_fetch_task_priorities": "Échec de la récupération des priorités de tâches",
|
||||
"error.failed_to_fetch_task_frequencies": "Échec de la récupération des fréquences de tâches",
|
||||
"error.failed_to_fetch_task_statuses": "Échec de la récupération des statuts de tâches",
|
||||
"error.failed_to_fetch_contractor_specialties": "Échec de la récupération des spécialités des prestataires",
|
||||
|
||||
"push.task_due_soon.title": "Tache Bientot Due",
|
||||
"push.task_due_soon.body": "{{.TaskTitle}} est due le {{.DueDate}}",
|
||||
"push.task_overdue.title": "Tache en Retard",
|
||||
@@ -129,44 +111,38 @@
|
||||
"push.task_assigned.body": "{{.TaskTitle}} vous a ete assignee",
|
||||
"push.residence_shared.title": "Propriete Partagee",
|
||||
"push.residence_shared.body": "{{.UserName}} a partage {{.ResidenceName}} avec vous",
|
||||
|
||||
"email.welcome.subject": "Bienvenue sur honeyDue !",
|
||||
"email.verification.subject": "Verifiez Votre Email",
|
||||
"email.password_reset.subject": "Code de Reinitialisation de Mot de Passe",
|
||||
"email.tasks_report.subject": "Rapport de Taches pour {{.ResidenceName}}",
|
||||
|
||||
"lookup.residence_type.house": "Maison",
|
||||
"lookup.residence_type.apartment": "Appartement",
|
||||
"lookup.residence_type.condo": "Copropriete",
|
||||
"lookup.residence_type.townhouse": "Maison de Ville",
|
||||
"lookup.residence_type.mobile_home": "Mobil-home",
|
||||
"lookup.residence_type.condo": "Copropriété",
|
||||
"lookup.residence_type.townhouse": "Maison de ville",
|
||||
"lookup.residence_type.mobile_home": "Maison mobile",
|
||||
"lookup.residence_type.other": "Autre",
|
||||
|
||||
"lookup.task_category.plumbing": "Plomberie",
|
||||
"lookup.task_category.electrical": "Electricite",
|
||||
"lookup.task_category.hvac": "Climatisation",
|
||||
"lookup.task_category.appliances": "Electromenager",
|
||||
"lookup.task_category.exterior": "Exterieur",
|
||||
"lookup.task_category.interior": "Interieur",
|
||||
"lookup.task_category.electrical": "Électricité",
|
||||
"lookup.task_category.hvac": "CVC",
|
||||
"lookup.task_category.appliances": "Électroménager",
|
||||
"lookup.task_category.exterior": "Extérieur",
|
||||
"lookup.task_category.interior": "Intérieur",
|
||||
"lookup.task_category.landscaping": "Jardinage",
|
||||
"lookup.task_category.safety": "Securite",
|
||||
"lookup.task_category.safety": "Sécurité",
|
||||
"lookup.task_category.cleaning": "Nettoyage",
|
||||
"lookup.task_category.pest_control": "Lutte Antiparasitaire",
|
||||
"lookup.task_category.pest_control": "Lutte antiparasitaire",
|
||||
"lookup.task_category.seasonal": "Saisonnier",
|
||||
"lookup.task_category.other": "Autre",
|
||||
|
||||
"lookup.task_priority.low": "Basse",
|
||||
"lookup.task_priority.medium": "Moyenne",
|
||||
"lookup.task_priority.high": "Haute",
|
||||
"lookup.task_priority.urgent": "Urgente",
|
||||
|
||||
"lookup.task_status.pending": "En Attente",
|
||||
"lookup.task_status.in_progress": "En Cours",
|
||||
"lookup.task_status.completed": "Terminee",
|
||||
"lookup.task_status.cancelled": "Annulee",
|
||||
"lookup.task_status.archived": "Archivee",
|
||||
|
||||
"lookup.task_frequency.once": "Une Fois",
|
||||
"lookup.task_frequency.once": "Une fois",
|
||||
"lookup.task_frequency.daily": "Quotidien",
|
||||
"lookup.task_frequency.weekly": "Hebdomadaire",
|
||||
"lookup.task_frequency.biweekly": "Toutes les 2 Semaines",
|
||||
@@ -174,18 +150,98 @@
|
||||
"lookup.task_frequency.quarterly": "Trimestriel",
|
||||
"lookup.task_frequency.semiannually": "Tous les 6 Mois",
|
||||
"lookup.task_frequency.annually": "Annuel",
|
||||
|
||||
"lookup.contractor_specialty.plumber": "Plombier",
|
||||
"lookup.contractor_specialty.electrician": "Electricien",
|
||||
"lookup.contractor_specialty.electrician": "Électricien",
|
||||
"lookup.contractor_specialty.hvac_technician": "Technicien CVC",
|
||||
"lookup.contractor_specialty.handyman": "Bricoleur",
|
||||
"lookup.contractor_specialty.landscaper": "Paysagiste",
|
||||
"lookup.contractor_specialty.roofer": "Couvreur",
|
||||
"lookup.contractor_specialty.painter": "Peintre",
|
||||
"lookup.contractor_specialty.carpenter": "Menuisier",
|
||||
"lookup.contractor_specialty.pest_control": "Desinsectisation",
|
||||
"lookup.contractor_specialty.carpenter": "Charpentier",
|
||||
"lookup.contractor_specialty.pest_control": "Lutte antiparasitaire",
|
||||
"lookup.contractor_specialty.cleaning": "Nettoyage",
|
||||
"lookup.contractor_specialty.pool_service": "Service Piscine",
|
||||
"lookup.contractor_specialty.general_contractor": "Entrepreneur General",
|
||||
"lookup.contractor_specialty.other": "Autre"
|
||||
"lookup.contractor_specialty.pool_service": "Service de piscine",
|
||||
"lookup.contractor_specialty.general_contractor": "Entrepreneur général",
|
||||
"lookup.contractor_specialty.other": "Autre",
|
||||
"suggestion.reason.has_pool": "Votre logement a une piscine",
|
||||
"suggestion.reason.has_sprinkler_system": "Votre logement a un système d'arrosage",
|
||||
"suggestion.reason.has_septic": "Votre logement a une fosse septique",
|
||||
"suggestion.reason.has_fireplace": "Votre logement a une cheminée",
|
||||
"suggestion.reason.has_garage": "Votre logement a un garage",
|
||||
"suggestion.reason.has_basement": "Votre logement a un sous-sol",
|
||||
"suggestion.reason.has_attic": "Votre logement a des combles",
|
||||
"suggestion.reason.heating_type": "Correspond à votre système de chauffage",
|
||||
"suggestion.reason.cooling_type": "Correspond à votre système de climatisation",
|
||||
"suggestion.reason.water_heater_type": "Correspond à votre chauffe-eau",
|
||||
"suggestion.reason.roof_type": "Correspond à votre toiture",
|
||||
"suggestion.reason.exterior_type": "Correspond à votre extérieur",
|
||||
"suggestion.reason.flooring_primary": "Correspond à votre revêtement de sol",
|
||||
"suggestion.reason.landscaping_type": "Correspond à votre aménagement paysager",
|
||||
"suggestion.reason.property_type": "Recommandé pour votre type de logement",
|
||||
"suggestion.reason.climate_region": "Recommandé pour votre climat",
|
||||
"lookup.residence_type.duplex": "Duplex",
|
||||
"lookup.residence_type.vacation_home": "Maison de vacances",
|
||||
"lookup.task_category.general": "Général",
|
||||
"lookup.task_frequency.bi_weekly": "Bimensuel",
|
||||
"lookup.task_frequency.semi_annually": "Semestriel",
|
||||
"lookup.task_frequency.custom": "Personnalisé",
|
||||
"lookup.contractor_specialty.appliance_repair": "Réparation d'électroménager",
|
||||
"lookup.contractor_specialty.cleaner": "Agent de nettoyage",
|
||||
"lookup.contractor_specialty.locksmith": "Serrurier",
|
||||
"lookup.home_profile.gas_furnace": "Fournaise au gaz",
|
||||
"lookup.home_profile.electric_furnace": "Fournaise électrique",
|
||||
"lookup.home_profile.heat_pump": "Pompe à chaleur",
|
||||
"lookup.home_profile.boiler": "Chaudière",
|
||||
"lookup.home_profile.radiant": "Rayonnant",
|
||||
"lookup.home_profile.other": "Autre",
|
||||
"lookup.home_profile.central_ac": "Climatisation centrale",
|
||||
"lookup.home_profile.window_ac": "Climatiseur de fenêtre",
|
||||
"lookup.home_profile.evaporative": "Évaporatif",
|
||||
"lookup.home_profile.none": "Aucun",
|
||||
"lookup.home_profile.tank_gas": "Réservoir (gaz)",
|
||||
"lookup.home_profile.tank_electric": "Réservoir (électrique)",
|
||||
"lookup.home_profile.tankless_gas": "Sans réservoir (gaz)",
|
||||
"lookup.home_profile.tankless_electric": "Sans réservoir (électrique)",
|
||||
"lookup.home_profile.solar": "Solaire",
|
||||
"lookup.home_profile.asphalt_shingle": "Bardeau d'asphalte",
|
||||
"lookup.home_profile.metal": "Métal",
|
||||
"lookup.home_profile.tile": "Tuile",
|
||||
"lookup.home_profile.slate": "Ardoise",
|
||||
"lookup.home_profile.wood_shake": "Bardeau de bois",
|
||||
"lookup.home_profile.flat": "Plat",
|
||||
"lookup.home_profile.brick": "Brique",
|
||||
"lookup.home_profile.vinyl_siding": "Revêtement vinyle",
|
||||
"lookup.home_profile.wood_siding": "Revêtement bois",
|
||||
"lookup.home_profile.stucco": "Stuc",
|
||||
"lookup.home_profile.stone": "Pierre",
|
||||
"lookup.home_profile.fiber_cement": "Fibrociment",
|
||||
"lookup.home_profile.hardwood": "Bois franc",
|
||||
"lookup.home_profile.laminate": "Stratifié",
|
||||
"lookup.home_profile.carpet": "Moquette",
|
||||
"lookup.home_profile.vinyl": "Vinyle",
|
||||
"lookup.home_profile.concrete": "Béton",
|
||||
"lookup.home_profile.lawn": "Pelouse",
|
||||
"lookup.home_profile.desert": "Désert",
|
||||
"lookup.home_profile.xeriscape": "Xéropaysagisme",
|
||||
"lookup.home_profile.garden": "Jardin",
|
||||
"lookup.home_profile.mixed": "Mixte",
|
||||
"lookup.document_type.warranty": "Garantie",
|
||||
"lookup.document_type.manual": "Manuel d'utilisation",
|
||||
"lookup.document_type.receipt": "Reçu/Facture",
|
||||
"lookup.document_type.inspection": "Rapport d'inspection",
|
||||
"lookup.document_type.permit": "Permis",
|
||||
"lookup.document_type.deed": "Acte/Titre",
|
||||
"lookup.document_type.insurance": "Assurance",
|
||||
"lookup.document_type.contract": "Contrat",
|
||||
"lookup.document_type.photo": "Photo",
|
||||
"lookup.document_type.other": "Autre",
|
||||
"lookup.document_category.appliance": "Électroménager",
|
||||
"lookup.document_category.hvac": "CVC",
|
||||
"lookup.document_category.plumbing": "Plomberie",
|
||||
"lookup.document_category.electrical": "Électricité",
|
||||
"lookup.document_category.roofing": "Toiture",
|
||||
"lookup.document_category.structural": "Structure",
|
||||
"lookup.document_category.landscaping": "Aménagement paysager",
|
||||
"lookup.document_category.general": "Général",
|
||||
"lookup.document_category.other": "Autre"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"error.google_signin_not_configured": "L'accesso con Google non è configurato",
|
||||
"error.google_signin_failed": "Accesso con Google fallito",
|
||||
"error.invalid_google_token": "Token di identità Google non valido",
|
||||
|
||||
"error.invalid_task_id": "ID attività non valido",
|
||||
"error.invalid_residence_id": "ID immobile non valido",
|
||||
"error.invalid_contractor_id": "ID fornitore non valido",
|
||||
@@ -34,7 +33,6 @@
|
||||
"error.invalid_user_id": "ID utente non valido",
|
||||
"error.invalid_notification_id": "ID notifica non valido",
|
||||
"error.invalid_device_id": "ID dispositivo non valido",
|
||||
|
||||
"error.task_not_found": "Attività non trovata",
|
||||
"error.residence_not_found": "Immobile non trovato",
|
||||
"error.contractor_not_found": "Fornitore non trovato",
|
||||
@@ -43,7 +41,6 @@
|
||||
"error.user_not_found": "Utente non trovato",
|
||||
"error.share_code_invalid": "Codice di condivisione non valido",
|
||||
"error.share_code_expired": "Il codice di condivisione è scaduto",
|
||||
|
||||
"error.task_access_denied": "Non hai accesso a questa attività",
|
||||
"error.residence_access_denied": "Non hai accesso a questo immobile",
|
||||
"error.contractor_access_denied": "Non hai accesso a questo fornitore",
|
||||
@@ -52,10 +49,8 @@
|
||||
"error.cannot_remove_owner": "Impossibile rimuovere il proprietario dell'immobile",
|
||||
"error.user_already_member": "L'utente è già membro di questo immobile",
|
||||
"error.properties_limit_reached": "Hai raggiunto il numero massimo di immobili per il tuo abbonamento",
|
||||
|
||||
"error.task_already_cancelled": "L'attività è già stata annullata",
|
||||
"error.task_already_archived": "L'attività è già stata archiviata",
|
||||
|
||||
"error.failed_to_parse_form": "Impossibile analizzare il modulo multipart",
|
||||
"error.task_id_required": "task_id è obbligatorio",
|
||||
"error.invalid_task_id_value": "task_id non valido",
|
||||
@@ -64,14 +59,12 @@
|
||||
"error.invalid_residence_id_value": "residence_id non valido",
|
||||
"error.title_required": "title è obbligatorio",
|
||||
"error.failed_to_upload_file": "Impossibile caricare il file",
|
||||
|
||||
"message.logged_out": "Disconnessione avvenuta con successo",
|
||||
"message.email_verified": "Email verificata con successo",
|
||||
"message.verification_email_sent": "Email di verifica inviata",
|
||||
"message.password_reset_email_sent": "Se esiste un account con quell'email, è stato inviato un codice di reimpostazione password.",
|
||||
"message.reset_code_verified": "Codice verificato con successo",
|
||||
"message.password_reset_success": "Password reimpostata con successo. Accedi con la tua nuova password.",
|
||||
|
||||
"message.task_deleted": "Attività eliminata con successo",
|
||||
"message.task_in_progress": "Attività contrassegnata come in corso",
|
||||
"message.task_cancelled": "Attività annullata",
|
||||
@@ -79,46 +72,35 @@
|
||||
"message.task_archived": "Attività archiviata",
|
||||
"message.task_unarchived": "Attività ripristinata dall'archivio",
|
||||
"message.completion_deleted": "Completamento eliminato con successo",
|
||||
|
||||
"message.residence_deleted": "Immobile eliminato con successo",
|
||||
"message.user_removed": "Utente rimosso dall'immobile",
|
||||
"message.tasks_report_generated": "Report attività generato con successo",
|
||||
"message.tasks_report_sent": "Report attività generato e inviato a {{.Email}}",
|
||||
"message.tasks_report_email_failed": "Report attività generato ma l'email non è stata inviata",
|
||||
|
||||
"message.contractor_deleted": "Fornitore eliminato con successo",
|
||||
|
||||
"message.document_deleted": "Documento eliminato con successo",
|
||||
"message.document_activated": "Documento attivato",
|
||||
"message.document_deactivated": "Documento disattivato",
|
||||
|
||||
"message.notification_marked_read": "Notifica contrassegnata come letta",
|
||||
"message.all_notifications_marked_read": "Tutte le notifiche contrassegnate come lette",
|
||||
"message.device_removed": "Dispositivo rimosso",
|
||||
|
||||
"message.subscription_upgraded": "Abbonamento aggiornato con successo",
|
||||
"message.subscription_cancelled": "Abbonamento annullato. Manterrai i vantaggi Pro fino alla fine del periodo di fatturazione.",
|
||||
"message.subscription_restored": "Abbonamento ripristinato con successo",
|
||||
|
||||
"message.file_deleted": "File eliminato con successo",
|
||||
"message.static_data_refreshed": "Dati statici aggiornati",
|
||||
|
||||
"error.notification_not_found": "Notifica non trovata",
|
||||
"error.invalid_platform": "Piattaforma non valida",
|
||||
|
||||
"error.upgrade_trigger_not_found": "Trigger di aggiornamento non trovato",
|
||||
"error.receipt_data_required": "receipt_data è obbligatorio per iOS",
|
||||
"error.purchase_token_required": "purchase_token è obbligatorio per Android",
|
||||
|
||||
"error.no_file_provided": "Nessun file fornito",
|
||||
|
||||
"error.failed_to_fetch_residence_types": "Impossibile recuperare i tipi di immobile",
|
||||
"error.failed_to_fetch_task_categories": "Impossibile recuperare le categorie di attività",
|
||||
"error.failed_to_fetch_task_priorities": "Impossibile recuperare le priorità delle attività",
|
||||
"error.failed_to_fetch_task_frequencies": "Impossibile recuperare le frequenze delle attività",
|
||||
"error.failed_to_fetch_task_statuses": "Impossibile recuperare gli stati delle attività",
|
||||
"error.failed_to_fetch_contractor_specialties": "Impossibile recuperare le specializzazioni dei fornitori",
|
||||
|
||||
"push.task_due_soon.title": "Attività in Scadenza",
|
||||
"push.task_due_soon.body": "{{.TaskTitle}} scade {{.DueDate}}",
|
||||
"push.task_overdue.title": "Attività Scaduta",
|
||||
@@ -129,22 +111,19 @@
|
||||
"push.task_assigned.body": "Ti è stata assegnata {{.TaskTitle}}",
|
||||
"push.residence_shared.title": "Immobile Condiviso",
|
||||
"push.residence_shared.body": "{{.UserName}} ha condiviso {{.ResidenceName}} con te",
|
||||
|
||||
"email.welcome.subject": "Benvenuto su honeyDue!",
|
||||
"email.verification.subject": "Verifica la Tua Email",
|
||||
"email.password_reset.subject": "Codice di Reimpostazione Password",
|
||||
"email.tasks_report.subject": "Report Attività per {{.ResidenceName}}",
|
||||
|
||||
"lookup.residence_type.house": "Casa",
|
||||
"lookup.residence_type.apartment": "Appartamento",
|
||||
"lookup.residence_type.condo": "Condominio",
|
||||
"lookup.residence_type.townhouse": "Villetta a Schiera",
|
||||
"lookup.residence_type.mobile_home": "Casa Mobile",
|
||||
"lookup.residence_type.townhouse": "Villetta a schiera",
|
||||
"lookup.residence_type.mobile_home": "Casa mobile",
|
||||
"lookup.residence_type.other": "Altro",
|
||||
|
||||
"lookup.task_category.plumbing": "Idraulica",
|
||||
"lookup.task_category.electrical": "Elettricità",
|
||||
"lookup.task_category.hvac": "Climatizzazione",
|
||||
"lookup.task_category.electrical": "Elettrico",
|
||||
"lookup.task_category.hvac": "HVAC",
|
||||
"lookup.task_category.appliances": "Elettrodomestici",
|
||||
"lookup.task_category.exterior": "Esterno",
|
||||
"lookup.task_category.interior": "Interno",
|
||||
@@ -154,38 +133,115 @@
|
||||
"lookup.task_category.pest_control": "Disinfestazione",
|
||||
"lookup.task_category.seasonal": "Stagionale",
|
||||
"lookup.task_category.other": "Altro",
|
||||
|
||||
"lookup.task_priority.low": "Bassa",
|
||||
"lookup.task_priority.medium": "Media",
|
||||
"lookup.task_priority.high": "Alta",
|
||||
"lookup.task_priority.urgent": "Urgente",
|
||||
|
||||
"lookup.task_status.pending": "In Attesa",
|
||||
"lookup.task_status.in_progress": "In Corso",
|
||||
"lookup.task_status.completed": "Completata",
|
||||
"lookup.task_status.cancelled": "Annullata",
|
||||
"lookup.task_status.archived": "Archiviata",
|
||||
|
||||
"lookup.task_frequency.once": "Una Volta",
|
||||
"lookup.task_frequency.daily": "Giornaliera",
|
||||
"lookup.task_frequency.once": "Una volta",
|
||||
"lookup.task_frequency.daily": "Giornaliero",
|
||||
"lookup.task_frequency.weekly": "Settimanale",
|
||||
"lookup.task_frequency.biweekly": "Ogni 2 Settimane",
|
||||
"lookup.task_frequency.monthly": "Mensile",
|
||||
"lookup.task_frequency.quarterly": "Trimestrale",
|
||||
"lookup.task_frequency.semiannually": "Ogni 6 Mesi",
|
||||
"lookup.task_frequency.annually": "Annuale",
|
||||
|
||||
"lookup.contractor_specialty.plumber": "Idraulico",
|
||||
"lookup.contractor_specialty.electrician": "Elettricista",
|
||||
"lookup.contractor_specialty.hvac_technician": "Tecnico Climatizzazione",
|
||||
"lookup.contractor_specialty.hvac_technician": "Tecnico HVAC",
|
||||
"lookup.contractor_specialty.handyman": "Tuttofare",
|
||||
"lookup.contractor_specialty.landscaper": "Giardiniere",
|
||||
"lookup.contractor_specialty.roofer": "Lattoniere",
|
||||
"lookup.contractor_specialty.roofer": "Conciatetti",
|
||||
"lookup.contractor_specialty.painter": "Imbianchino",
|
||||
"lookup.contractor_specialty.carpenter": "Falegname",
|
||||
"lookup.contractor_specialty.pest_control": "Disinfestazione",
|
||||
"lookup.contractor_specialty.cleaning": "Pulizia",
|
||||
"lookup.contractor_specialty.pool_service": "Manutenzione Piscine",
|
||||
"lookup.contractor_specialty.general_contractor": "Impresa Generale",
|
||||
"lookup.contractor_specialty.other": "Altro"
|
||||
"lookup.contractor_specialty.pool_service": "Servizio piscina",
|
||||
"lookup.contractor_specialty.general_contractor": "Imprenditore generale",
|
||||
"lookup.contractor_specialty.other": "Altro",
|
||||
"suggestion.reason.has_pool": "La tua casa ha una piscina",
|
||||
"suggestion.reason.has_sprinkler_system": "La tua casa ha un impianto di irrigazione",
|
||||
"suggestion.reason.has_septic": "La tua casa ha una fossa settica",
|
||||
"suggestion.reason.has_fireplace": "La tua casa ha un camino",
|
||||
"suggestion.reason.has_garage": "La tua casa ha un garage",
|
||||
"suggestion.reason.has_basement": "La tua casa ha un seminterrato",
|
||||
"suggestion.reason.has_attic": "La tua casa ha una soffitta",
|
||||
"suggestion.reason.heating_type": "Corrisponde al tuo impianto di riscaldamento",
|
||||
"suggestion.reason.cooling_type": "Corrisponde al tuo impianto di raffreddamento",
|
||||
"suggestion.reason.water_heater_type": "Corrisponde al tuo scaldabagno",
|
||||
"suggestion.reason.roof_type": "Corrisponde al tuo tetto",
|
||||
"suggestion.reason.exterior_type": "Corrisponde al tuo esterno",
|
||||
"suggestion.reason.flooring_primary": "Corrisponde alla tua pavimentazione",
|
||||
"suggestion.reason.landscaping_type": "Corrisponde al tuo giardino",
|
||||
"suggestion.reason.property_type": "Consigliato per il tuo tipo di immobile",
|
||||
"suggestion.reason.climate_region": "Consigliato per il tuo clima",
|
||||
"lookup.residence_type.duplex": "Bifamiliare",
|
||||
"lookup.residence_type.vacation_home": "Casa vacanze",
|
||||
"lookup.task_category.general": "Generale",
|
||||
"lookup.task_frequency.bi_weekly": "Bisettimanale",
|
||||
"lookup.task_frequency.semi_annually": "Semestrale",
|
||||
"lookup.task_frequency.custom": "Personalizzato",
|
||||
"lookup.contractor_specialty.appliance_repair": "Riparazione elettrodomestici",
|
||||
"lookup.contractor_specialty.cleaner": "Addetto alle pulizie",
|
||||
"lookup.contractor_specialty.locksmith": "Fabbro",
|
||||
"lookup.home_profile.gas_furnace": "Caldaia a gas",
|
||||
"lookup.home_profile.electric_furnace": "Caldaia elettrica",
|
||||
"lookup.home_profile.heat_pump": "Pompa di calore",
|
||||
"lookup.home_profile.boiler": "Caldaia",
|
||||
"lookup.home_profile.radiant": "Radiante",
|
||||
"lookup.home_profile.other": "Altro",
|
||||
"lookup.home_profile.central_ac": "Climatizzatore centrale",
|
||||
"lookup.home_profile.window_ac": "Climatizzatore a finestra",
|
||||
"lookup.home_profile.evaporative": "Evaporativo",
|
||||
"lookup.home_profile.none": "Nessuno",
|
||||
"lookup.home_profile.tank_gas": "Serbatoio (gas)",
|
||||
"lookup.home_profile.tank_electric": "Serbatoio (elettrico)",
|
||||
"lookup.home_profile.tankless_gas": "Senza serbatoio (gas)",
|
||||
"lookup.home_profile.tankless_electric": "Senza serbatoio (elettrico)",
|
||||
"lookup.home_profile.solar": "Solare",
|
||||
"lookup.home_profile.asphalt_shingle": "Tegola bituminosa",
|
||||
"lookup.home_profile.metal": "Metallo",
|
||||
"lookup.home_profile.tile": "Tegola",
|
||||
"lookup.home_profile.slate": "Ardesia",
|
||||
"lookup.home_profile.wood_shake": "Scandola di legno",
|
||||
"lookup.home_profile.flat": "Piatto",
|
||||
"lookup.home_profile.brick": "Mattone",
|
||||
"lookup.home_profile.vinyl_siding": "Rivestimento in vinile",
|
||||
"lookup.home_profile.wood_siding": "Rivestimento in legno",
|
||||
"lookup.home_profile.stucco": "Stucco",
|
||||
"lookup.home_profile.stone": "Pietra",
|
||||
"lookup.home_profile.fiber_cement": "Fibrocemento",
|
||||
"lookup.home_profile.hardwood": "Legno duro",
|
||||
"lookup.home_profile.laminate": "Laminato",
|
||||
"lookup.home_profile.carpet": "Moquette",
|
||||
"lookup.home_profile.vinyl": "Vinile",
|
||||
"lookup.home_profile.concrete": "Cemento",
|
||||
"lookup.home_profile.lawn": "Prato",
|
||||
"lookup.home_profile.desert": "Deserto",
|
||||
"lookup.home_profile.xeriscape": "Xeriscaping",
|
||||
"lookup.home_profile.garden": "Giardino",
|
||||
"lookup.home_profile.mixed": "Misto",
|
||||
"lookup.document_type.warranty": "Garanzia",
|
||||
"lookup.document_type.manual": "Manuale d'uso",
|
||||
"lookup.document_type.receipt": "Ricevuta/Fattura",
|
||||
"lookup.document_type.inspection": "Rapporto di ispezione",
|
||||
"lookup.document_type.permit": "Permesso",
|
||||
"lookup.document_type.deed": "Atto/Titolo",
|
||||
"lookup.document_type.insurance": "Assicurazione",
|
||||
"lookup.document_type.contract": "Contratto",
|
||||
"lookup.document_type.photo": "Foto",
|
||||
"lookup.document_type.other": "Altro",
|
||||
"lookup.document_category.appliance": "Elettrodomestico",
|
||||
"lookup.document_category.hvac": "HVAC",
|
||||
"lookup.document_category.plumbing": "Idraulica",
|
||||
"lookup.document_category.electrical": "Elettrico",
|
||||
"lookup.document_category.roofing": "Tetto",
|
||||
"lookup.document_category.structural": "Strutturale",
|
||||
"lookup.document_category.landscaping": "Giardinaggio",
|
||||
"lookup.document_category.general": "Generale",
|
||||
"lookup.document_category.other": "Altro"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"error.google_signin_not_configured": "Google サインインが設定されていません",
|
||||
"error.google_signin_failed": "Google サインインに失敗しました",
|
||||
"error.invalid_google_token": "無効な Google ID トークンです",
|
||||
|
||||
"error.invalid_task_id": "無効なタスクIDです",
|
||||
"error.invalid_residence_id": "無効な物件IDです",
|
||||
"error.invalid_contractor_id": "無効な業者IDです",
|
||||
@@ -34,7 +33,6 @@
|
||||
"error.invalid_user_id": "無効なユーザーIDです",
|
||||
"error.invalid_notification_id": "無効な通知IDです",
|
||||
"error.invalid_device_id": "無効なデバイスIDです",
|
||||
|
||||
"error.task_not_found": "タスクが見つかりません",
|
||||
"error.residence_not_found": "物件が見つかりません",
|
||||
"error.contractor_not_found": "業者が見つかりません",
|
||||
@@ -43,7 +41,6 @@
|
||||
"error.user_not_found": "ユーザーが見つかりません",
|
||||
"error.share_code_invalid": "無効な共有コードです",
|
||||
"error.share_code_expired": "共有コードの有効期限が切れています",
|
||||
|
||||
"error.task_access_denied": "このタスクへのアクセス権がありません",
|
||||
"error.residence_access_denied": "この物件へのアクセス権がありません",
|
||||
"error.contractor_access_denied": "この業者へのアクセス権がありません",
|
||||
@@ -52,10 +49,8 @@
|
||||
"error.cannot_remove_owner": "物件のオーナーを削除することはできません",
|
||||
"error.user_already_member": "このユーザーは既にこの物件のメンバーです",
|
||||
"error.properties_limit_reached": "サブスクリプションで許可されている物件の最大数に達しました",
|
||||
|
||||
"error.task_already_cancelled": "タスクは既にキャンセルされています",
|
||||
"error.task_already_archived": "タスクは既にアーカイブされています",
|
||||
|
||||
"error.failed_to_parse_form": "マルチパートフォームの解析に失敗しました",
|
||||
"error.task_id_required": "task_id は必須です",
|
||||
"error.invalid_task_id_value": "無効な task_id です",
|
||||
@@ -64,14 +59,12 @@
|
||||
"error.invalid_residence_id_value": "無効な residence_id です",
|
||||
"error.title_required": "タイトルは必須です",
|
||||
"error.failed_to_upload_file": "ファイルのアップロードに失敗しました",
|
||||
|
||||
"message.logged_out": "ログアウトしました",
|
||||
"message.email_verified": "メールアドレスの認証が完了しました",
|
||||
"message.verification_email_sent": "認証メールを送信しました",
|
||||
"message.password_reset_email_sent": "該当するアカウントが存在する場合、パスワードリセットコードが送信されました。",
|
||||
"message.reset_code_verified": "コードの認証が完了しました",
|
||||
"message.password_reset_success": "パスワードのリセットが完了しました。新しいパスワードでログインしてください。",
|
||||
|
||||
"message.task_deleted": "タスクを削除しました",
|
||||
"message.task_in_progress": "タスクを進行中に設定しました",
|
||||
"message.task_cancelled": "タスクをキャンセルしました",
|
||||
@@ -79,46 +72,35 @@
|
||||
"message.task_archived": "タスクをアーカイブしました",
|
||||
"message.task_unarchived": "タスクのアーカイブを解除しました",
|
||||
"message.completion_deleted": "完了記録を削除しました",
|
||||
|
||||
"message.residence_deleted": "物件を削除しました",
|
||||
"message.user_removed": "ユーザーを物件から削除しました",
|
||||
"message.tasks_report_generated": "タスクレポートを生成しました",
|
||||
"message.tasks_report_sent": "タスクレポートを生成し、{{.Email}} に送信しました",
|
||||
"message.tasks_report_email_failed": "タスクレポートは生成されましたが、メールの送信に失敗しました",
|
||||
|
||||
"message.contractor_deleted": "業者を削除しました",
|
||||
|
||||
"message.document_deleted": "書類を削除しました",
|
||||
"message.document_activated": "書類を有効化しました",
|
||||
"message.document_deactivated": "書類を無効化しました",
|
||||
|
||||
"message.notification_marked_read": "通知を既読にしました",
|
||||
"message.all_notifications_marked_read": "すべての通知を既読にしました",
|
||||
"message.device_removed": "デバイスを削除しました",
|
||||
|
||||
"message.subscription_upgraded": "サブスクリプションをアップグレードしました",
|
||||
"message.subscription_cancelled": "サブスクリプションをキャンセルしました。請求期間終了まで Pro 機能をご利用いただけます。",
|
||||
"message.subscription_restored": "サブスクリプションを復元しました",
|
||||
|
||||
"message.file_deleted": "ファイルを削除しました",
|
||||
"message.static_data_refreshed": "静的データを更新しました",
|
||||
|
||||
"error.notification_not_found": "通知が見つかりません",
|
||||
"error.invalid_platform": "無効なプラットフォームです",
|
||||
|
||||
"error.upgrade_trigger_not_found": "アップグレードトリガーが見つかりません",
|
||||
"error.receipt_data_required": "iOS の場合、receipt_data は必須です",
|
||||
"error.purchase_token_required": "Android の場合、purchase_token は必須です",
|
||||
|
||||
"error.no_file_provided": "ファイルが提供されていません",
|
||||
|
||||
"error.failed_to_fetch_residence_types": "物件タイプの取得に失敗しました",
|
||||
"error.failed_to_fetch_task_categories": "タスクカテゴリの取得に失敗しました",
|
||||
"error.failed_to_fetch_task_priorities": "タスク優先度の取得に失敗しました",
|
||||
"error.failed_to_fetch_task_frequencies": "タスク頻度の取得に失敗しました",
|
||||
"error.failed_to_fetch_task_statuses": "タスクステータスの取得に失敗しました",
|
||||
"error.failed_to_fetch_contractor_specialties": "業者専門分野の取得に失敗しました",
|
||||
|
||||
"push.task_due_soon.title": "タスクの期限が近づいています",
|
||||
"push.task_due_soon.body": "{{.TaskTitle}} の期限は {{.DueDate}} です",
|
||||
"push.task_overdue.title": "期限切れのタスク",
|
||||
@@ -129,19 +111,16 @@
|
||||
"push.task_assigned.body": "{{.TaskTitle}} に割り当てられました",
|
||||
"push.residence_shared.title": "物件が共有されました",
|
||||
"push.residence_shared.body": "{{.UserName}} が {{.ResidenceName}} を共有しました",
|
||||
|
||||
"email.welcome.subject": "honeyDue へようこそ!",
|
||||
"email.verification.subject": "メールアドレスの認証",
|
||||
"email.password_reset.subject": "パスワードリセットコード",
|
||||
"email.tasks_report.subject": "{{.ResidenceName}} のタスクレポート",
|
||||
|
||||
"lookup.residence_type.house": "一戸建て",
|
||||
"lookup.residence_type.apartment": "アパート",
|
||||
"lookup.residence_type.condo": "マンション",
|
||||
"lookup.residence_type.condo": "分譲マンション",
|
||||
"lookup.residence_type.townhouse": "タウンハウス",
|
||||
"lookup.residence_type.mobile_home": "移動式住宅",
|
||||
"lookup.residence_type.mobile_home": "モバイルホーム",
|
||||
"lookup.residence_type.other": "その他",
|
||||
|
||||
"lookup.task_category.plumbing": "配管",
|
||||
"lookup.task_category.electrical": "電気",
|
||||
"lookup.task_category.hvac": "空調",
|
||||
@@ -154,19 +133,16 @@
|
||||
"lookup.task_category.pest_control": "害虫駆除",
|
||||
"lookup.task_category.seasonal": "季節",
|
||||
"lookup.task_category.other": "その他",
|
||||
|
||||
"lookup.task_priority.low": "低",
|
||||
"lookup.task_priority.medium": "中",
|
||||
"lookup.task_priority.high": "高",
|
||||
"lookup.task_priority.urgent": "緊急",
|
||||
|
||||
"lookup.task_status.pending": "保留中",
|
||||
"lookup.task_status.in_progress": "進行中",
|
||||
"lookup.task_status.completed": "完了",
|
||||
"lookup.task_status.cancelled": "キャンセル",
|
||||
"lookup.task_status.archived": "アーカイブ",
|
||||
|
||||
"lookup.task_frequency.once": "一度のみ",
|
||||
"lookup.task_frequency.once": "1回",
|
||||
"lookup.task_frequency.daily": "毎日",
|
||||
"lookup.task_frequency.weekly": "毎週",
|
||||
"lookup.task_frequency.biweekly": "2週間ごと",
|
||||
@@ -174,18 +150,98 @@
|
||||
"lookup.task_frequency.quarterly": "四半期ごと",
|
||||
"lookup.task_frequency.semiannually": "半年ごと",
|
||||
"lookup.task_frequency.annually": "毎年",
|
||||
|
||||
"lookup.contractor_specialty.plumber": "配管工",
|
||||
"lookup.contractor_specialty.electrician": "電気工事士",
|
||||
"lookup.contractor_specialty.hvac_technician": "空調技術者",
|
||||
"lookup.contractor_specialty.electrician": "電気技師",
|
||||
"lookup.contractor_specialty.hvac_technician": "空調技師",
|
||||
"lookup.contractor_specialty.handyman": "便利屋",
|
||||
"lookup.contractor_specialty.landscaper": "造園業者",
|
||||
"lookup.contractor_specialty.roofer": "屋根職人",
|
||||
"lookup.contractor_specialty.painter": "塗装工",
|
||||
"lookup.contractor_specialty.painter": "塗装業者",
|
||||
"lookup.contractor_specialty.carpenter": "大工",
|
||||
"lookup.contractor_specialty.pest_control": "害虫駆除業者",
|
||||
"lookup.contractor_specialty.pest_control": "害虫駆除",
|
||||
"lookup.contractor_specialty.cleaning": "清掃業者",
|
||||
"lookup.contractor_specialty.pool_service": "プールサービス",
|
||||
"lookup.contractor_specialty.general_contractor": "総合建設業者",
|
||||
"lookup.contractor_specialty.other": "その他"
|
||||
"lookup.contractor_specialty.general_contractor": "総合請負業者",
|
||||
"lookup.contractor_specialty.other": "その他",
|
||||
"suggestion.reason.has_pool": "ご自宅にプールがあります",
|
||||
"suggestion.reason.has_sprinkler_system": "ご自宅にスプリンクラーがあります",
|
||||
"suggestion.reason.has_septic": "ご自宅に浄化槽があります",
|
||||
"suggestion.reason.has_fireplace": "ご自宅に暖炉があります",
|
||||
"suggestion.reason.has_garage": "ご自宅にガレージがあります",
|
||||
"suggestion.reason.has_basement": "ご自宅に地下室があります",
|
||||
"suggestion.reason.has_attic": "ご自宅に屋根裏があります",
|
||||
"suggestion.reason.heating_type": "暖房設備に合っています",
|
||||
"suggestion.reason.cooling_type": "冷房設備に合っています",
|
||||
"suggestion.reason.water_heater_type": "給湯器に合っています",
|
||||
"suggestion.reason.roof_type": "屋根に合っています",
|
||||
"suggestion.reason.exterior_type": "外装に合っています",
|
||||
"suggestion.reason.flooring_primary": "床材に合っています",
|
||||
"suggestion.reason.landscaping_type": "造園に合っています",
|
||||
"suggestion.reason.property_type": "ご自宅の種類におすすめです",
|
||||
"suggestion.reason.climate_region": "お住まいの気候におすすめです",
|
||||
"lookup.residence_type.duplex": "二世帯住宅",
|
||||
"lookup.residence_type.vacation_home": "別荘",
|
||||
"lookup.task_category.general": "一般",
|
||||
"lookup.task_frequency.bi_weekly": "隔週",
|
||||
"lookup.task_frequency.semi_annually": "半年ごと",
|
||||
"lookup.task_frequency.custom": "カスタム",
|
||||
"lookup.contractor_specialty.appliance_repair": "家電修理",
|
||||
"lookup.contractor_specialty.cleaner": "清掃業者",
|
||||
"lookup.contractor_specialty.locksmith": "錠前師",
|
||||
"lookup.home_profile.gas_furnace": "ガス炉",
|
||||
"lookup.home_profile.electric_furnace": "電気炉",
|
||||
"lookup.home_profile.heat_pump": "ヒートポンプ",
|
||||
"lookup.home_profile.boiler": "ボイラー",
|
||||
"lookup.home_profile.radiant": "放射式",
|
||||
"lookup.home_profile.other": "その他",
|
||||
"lookup.home_profile.central_ac": "セントラルエアコン",
|
||||
"lookup.home_profile.window_ac": "窓用エアコン",
|
||||
"lookup.home_profile.evaporative": "気化式",
|
||||
"lookup.home_profile.none": "なし",
|
||||
"lookup.home_profile.tank_gas": "タンク式(ガス)",
|
||||
"lookup.home_profile.tank_electric": "タンク式(電気)",
|
||||
"lookup.home_profile.tankless_gas": "タンクレス(ガス)",
|
||||
"lookup.home_profile.tankless_electric": "タンクレス(電気)",
|
||||
"lookup.home_profile.solar": "ソーラー",
|
||||
"lookup.home_profile.asphalt_shingle": "アスファルトシングル",
|
||||
"lookup.home_profile.metal": "金属",
|
||||
"lookup.home_profile.tile": "タイル",
|
||||
"lookup.home_profile.slate": "スレート",
|
||||
"lookup.home_profile.wood_shake": "木製シェイク",
|
||||
"lookup.home_profile.flat": "平型",
|
||||
"lookup.home_profile.brick": "レンガ",
|
||||
"lookup.home_profile.vinyl_siding": "ビニールサイディング",
|
||||
"lookup.home_profile.wood_siding": "木製サイディング",
|
||||
"lookup.home_profile.stucco": "スタッコ",
|
||||
"lookup.home_profile.stone": "石",
|
||||
"lookup.home_profile.fiber_cement": "繊維強化セメント",
|
||||
"lookup.home_profile.hardwood": "無垢材",
|
||||
"lookup.home_profile.laminate": "ラミネート",
|
||||
"lookup.home_profile.carpet": "カーペット",
|
||||
"lookup.home_profile.vinyl": "ビニール",
|
||||
"lookup.home_profile.concrete": "コンクリート",
|
||||
"lookup.home_profile.lawn": "芝生",
|
||||
"lookup.home_profile.desert": "砂漠",
|
||||
"lookup.home_profile.xeriscape": "ゼリスケープ",
|
||||
"lookup.home_profile.garden": "庭園",
|
||||
"lookup.home_profile.mixed": "混合",
|
||||
"lookup.document_type.warranty": "保証",
|
||||
"lookup.document_type.manual": "ユーザーマニュアル",
|
||||
"lookup.document_type.receipt": "領収書/請求書",
|
||||
"lookup.document_type.inspection": "点検報告書",
|
||||
"lookup.document_type.permit": "許可証",
|
||||
"lookup.document_type.deed": "権利証/証書",
|
||||
"lookup.document_type.insurance": "保険",
|
||||
"lookup.document_type.contract": "契約",
|
||||
"lookup.document_type.photo": "写真",
|
||||
"lookup.document_type.other": "その他",
|
||||
"lookup.document_category.appliance": "家電",
|
||||
"lookup.document_category.hvac": "空調",
|
||||
"lookup.document_category.plumbing": "配管",
|
||||
"lookup.document_category.electrical": "電気",
|
||||
"lookup.document_category.roofing": "屋根",
|
||||
"lookup.document_category.structural": "構造",
|
||||
"lookup.document_category.landscaping": "造園",
|
||||
"lookup.document_category.general": "一般",
|
||||
"lookup.document_category.other": "その他"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"error.google_signin_not_configured": "Google 로그인이 설정되지 않았습니다",
|
||||
"error.google_signin_failed": "Google 로그인에 실패했습니다",
|
||||
"error.invalid_google_token": "유효하지 않은 Google 인증 토큰입니다",
|
||||
|
||||
"error.invalid_task_id": "유효하지 않은 작업 ID입니다",
|
||||
"error.invalid_residence_id": "유효하지 않은 주거지 ID입니다",
|
||||
"error.invalid_contractor_id": "유효하지 않은 계약업체 ID입니다",
|
||||
@@ -34,7 +33,6 @@
|
||||
"error.invalid_user_id": "유효하지 않은 사용자 ID입니다",
|
||||
"error.invalid_notification_id": "유효하지 않은 알림 ID입니다",
|
||||
"error.invalid_device_id": "유효하지 않은 기기 ID입니다",
|
||||
|
||||
"error.task_not_found": "작업을 찾을 수 없습니다",
|
||||
"error.residence_not_found": "주거지를 찾을 수 없습니다",
|
||||
"error.contractor_not_found": "계약업체를 찾을 수 없습니다",
|
||||
@@ -43,7 +41,6 @@
|
||||
"error.user_not_found": "사용자를 찾을 수 없습니다",
|
||||
"error.share_code_invalid": "유효하지 않은 공유 코드입니다",
|
||||
"error.share_code_expired": "공유 코드가 만료되었습니다",
|
||||
|
||||
"error.task_access_denied": "이 작업에 접근할 권한이 없습니다",
|
||||
"error.residence_access_denied": "이 주거지에 접근할 권한이 없습니다",
|
||||
"error.contractor_access_denied": "이 계약업체에 접근할 권한이 없습니다",
|
||||
@@ -52,10 +49,8 @@
|
||||
"error.cannot_remove_owner": "주거지 소유자는 삭제할 수 없습니다",
|
||||
"error.user_already_member": "이미 이 주거지의 멤버입니다",
|
||||
"error.properties_limit_reached": "구독 플랜의 최대 주거지 수에 도달했습니다",
|
||||
|
||||
"error.task_already_cancelled": "이미 취소된 작업입니다",
|
||||
"error.task_already_archived": "이미 보관된 작업입니다",
|
||||
|
||||
"error.failed_to_parse_form": "멀티파트 폼 파싱에 실패했습니다",
|
||||
"error.task_id_required": "task_id가 필요합니다",
|
||||
"error.invalid_task_id_value": "유효하지 않은 task_id 값입니다",
|
||||
@@ -64,14 +59,12 @@
|
||||
"error.invalid_residence_id_value": "유효하지 않은 residence_id 값입니다",
|
||||
"error.title_required": "제목이 필요합니다",
|
||||
"error.failed_to_upload_file": "파일 업로드에 실패했습니다",
|
||||
|
||||
"message.logged_out": "로그아웃되었습니다",
|
||||
"message.email_verified": "이메일이 인증되었습니다",
|
||||
"message.verification_email_sent": "인증 이메일이 발송되었습니다",
|
||||
"message.password_reset_email_sent": "해당 이메일로 등록된 계정이 있는 경우 비밀번호 재설정 코드가 발송되었습니다.",
|
||||
"message.reset_code_verified": "코드가 인증되었습니다",
|
||||
"message.password_reset_success": "비밀번호가 재설정되었습니다. 새 비밀번호로 로그인해주세요.",
|
||||
|
||||
"message.task_deleted": "작업이 삭제되었습니다",
|
||||
"message.task_in_progress": "작업이 진행 중으로 표시되었습니다",
|
||||
"message.task_cancelled": "작업이 취소되었습니다",
|
||||
@@ -79,46 +72,35 @@
|
||||
"message.task_archived": "작업이 보관되었습니다",
|
||||
"message.task_unarchived": "작업 보관이 해제되었습니다",
|
||||
"message.completion_deleted": "완료 기록이 삭제되었습니다",
|
||||
|
||||
"message.residence_deleted": "주거지가 삭제되었습니다",
|
||||
"message.user_removed": "주거지에서 사용자가 제거되었습니다",
|
||||
"message.tasks_report_generated": "작업 보고서가 생성되었습니다",
|
||||
"message.tasks_report_sent": "작업 보고서가 생성되어 {{.Email}}로 발송되었습니다",
|
||||
"message.tasks_report_email_failed": "작업 보고서가 생성되었지만 이메일 발송에 실패했습니다",
|
||||
|
||||
"message.contractor_deleted": "계약업체가 삭제되었습니다",
|
||||
|
||||
"message.document_deleted": "문서가 삭제되었습니다",
|
||||
"message.document_activated": "문서가 활성화되었습니다",
|
||||
"message.document_deactivated": "문서가 비활성화되었습니다",
|
||||
|
||||
"message.notification_marked_read": "알림이 읽음으로 표시되었습니다",
|
||||
"message.all_notifications_marked_read": "모든 알림이 읽음으로 표시되었습니다",
|
||||
"message.device_removed": "기기가 제거되었습니다",
|
||||
|
||||
"message.subscription_upgraded": "구독이 업그레이드되었습니다",
|
||||
"message.subscription_cancelled": "구독이 취소되었습니다. 결제 기간이 종료될 때까지 Pro 혜택을 유지하실 수 있습니다.",
|
||||
"message.subscription_restored": "구독이 복원되었습니다",
|
||||
|
||||
"message.file_deleted": "파일이 삭제되었습니다",
|
||||
"message.static_data_refreshed": "정적 데이터가 새로고침되었습니다",
|
||||
|
||||
"error.notification_not_found": "알림을 찾을 수 없습니다",
|
||||
"error.invalid_platform": "유효하지 않은 플랫폼입니다",
|
||||
|
||||
"error.upgrade_trigger_not_found": "업그레이드 트리거를 찾을 수 없습니다",
|
||||
"error.receipt_data_required": "iOS의 경우 receipt_data가 필요합니다",
|
||||
"error.purchase_token_required": "Android의 경우 purchase_token이 필요합니다",
|
||||
|
||||
"error.no_file_provided": "파일이 제공되지 않았습니다",
|
||||
|
||||
"error.failed_to_fetch_residence_types": "주거지 유형을 가져오는데 실패했습니다",
|
||||
"error.failed_to_fetch_task_categories": "작업 카테고리를 가져오는데 실패했습니다",
|
||||
"error.failed_to_fetch_task_priorities": "작업 우선순위를 가져오는데 실패했습니다",
|
||||
"error.failed_to_fetch_task_frequencies": "작업 빈도를 가져오는데 실패했습니다",
|
||||
"error.failed_to_fetch_task_statuses": "작업 상태를 가져오는데 실패했습니다",
|
||||
"error.failed_to_fetch_contractor_specialties": "계약업체 전문 분야를 가져오는데 실패했습니다",
|
||||
|
||||
"push.task_due_soon.title": "작업 마감 임박",
|
||||
"push.task_due_soon.body": "{{.TaskTitle}}의 마감일은 {{.DueDate}}입니다",
|
||||
"push.task_overdue.title": "지연된 작업",
|
||||
@@ -129,23 +111,20 @@
|
||||
"push.task_assigned.body": "{{.TaskTitle}}이(가) 할당되었습니다",
|
||||
"push.residence_shared.title": "주거지 공유",
|
||||
"push.residence_shared.body": "{{.UserName}}님이 {{.ResidenceName}}을(를) 공유했습니다",
|
||||
|
||||
"email.welcome.subject": "honeyDue에 오신 것을 환영합니다!",
|
||||
"email.verification.subject": "이메일 인증",
|
||||
"email.password_reset.subject": "비밀번호 재설정 코드",
|
||||
"email.tasks_report.subject": "{{.ResidenceName}} 작업 보고서",
|
||||
|
||||
"lookup.residence_type.house": "단독주택",
|
||||
"lookup.residence_type.house": "주택",
|
||||
"lookup.residence_type.apartment": "아파트",
|
||||
"lookup.residence_type.condo": "콘도",
|
||||
"lookup.residence_type.townhouse": "타운하우스",
|
||||
"lookup.residence_type.mobile_home": "이동식 주택",
|
||||
"lookup.residence_type.other": "기타",
|
||||
|
||||
"lookup.task_category.plumbing": "배관",
|
||||
"lookup.task_category.electrical": "전기",
|
||||
"lookup.task_category.hvac": "냉난방",
|
||||
"lookup.task_category.appliances": "가전제품",
|
||||
"lookup.task_category.appliances": "가전",
|
||||
"lookup.task_category.exterior": "외부",
|
||||
"lookup.task_category.interior": "내부",
|
||||
"lookup.task_category.landscaping": "조경",
|
||||
@@ -154,18 +133,15 @@
|
||||
"lookup.task_category.pest_control": "해충 방제",
|
||||
"lookup.task_category.seasonal": "계절별",
|
||||
"lookup.task_category.other": "기타",
|
||||
|
||||
"lookup.task_priority.low": "낮음",
|
||||
"lookup.task_priority.medium": "보통",
|
||||
"lookup.task_priority.high": "높음",
|
||||
"lookup.task_priority.urgent": "긴급",
|
||||
|
||||
"lookup.task_status.pending": "대기 중",
|
||||
"lookup.task_status.in_progress": "진행 중",
|
||||
"lookup.task_status.completed": "완료",
|
||||
"lookup.task_status.cancelled": "취소됨",
|
||||
"lookup.task_status.archived": "보관됨",
|
||||
|
||||
"lookup.task_frequency.once": "한 번",
|
||||
"lookup.task_frequency.daily": "매일",
|
||||
"lookup.task_frequency.weekly": "매주",
|
||||
@@ -174,18 +150,98 @@
|
||||
"lookup.task_frequency.quarterly": "분기별",
|
||||
"lookup.task_frequency.semiannually": "6개월마다",
|
||||
"lookup.task_frequency.annually": "매년",
|
||||
|
||||
"lookup.contractor_specialty.plumber": "배관공",
|
||||
"lookup.contractor_specialty.electrician": "전기 기사",
|
||||
"lookup.contractor_specialty.hvac_technician": "냉난방 기사",
|
||||
"lookup.contractor_specialty.handyman": "편리공",
|
||||
"lookup.contractor_specialty.handyman": "수리공",
|
||||
"lookup.contractor_specialty.landscaper": "조경사",
|
||||
"lookup.contractor_specialty.roofer": "지붕공",
|
||||
"lookup.contractor_specialty.painter": "도배공",
|
||||
"lookup.contractor_specialty.roofer": "지붕 기술자",
|
||||
"lookup.contractor_specialty.painter": "페인터",
|
||||
"lookup.contractor_specialty.carpenter": "목수",
|
||||
"lookup.contractor_specialty.pest_control": "해충 방제",
|
||||
"lookup.contractor_specialty.cleaning": "청소",
|
||||
"lookup.contractor_specialty.pool_service": "수영장 관리",
|
||||
"lookup.contractor_specialty.general_contractor": "종합 건설업체",
|
||||
"lookup.contractor_specialty.other": "기타"
|
||||
"lookup.contractor_specialty.pool_service": "수영장 서비스",
|
||||
"lookup.contractor_specialty.general_contractor": "종합 건설업자",
|
||||
"lookup.contractor_specialty.other": "기타",
|
||||
"suggestion.reason.has_pool": "집에 수영장이 있습니다",
|
||||
"suggestion.reason.has_sprinkler_system": "집에 스프링클러 시스템이 있습니다",
|
||||
"suggestion.reason.has_septic": "집에 정화조가 있습니다",
|
||||
"suggestion.reason.has_fireplace": "집에 벽난로가 있습니다",
|
||||
"suggestion.reason.has_garage": "집에 차고가 있습니다",
|
||||
"suggestion.reason.has_basement": "집에 지하실이 있습니다",
|
||||
"suggestion.reason.has_attic": "집에 다락방이 있습니다",
|
||||
"suggestion.reason.heating_type": "난방 시스템과 일치합니다",
|
||||
"suggestion.reason.cooling_type": "냉방 시스템과 일치합니다",
|
||||
"suggestion.reason.water_heater_type": "온수기와 일치합니다",
|
||||
"suggestion.reason.roof_type": "지붕과 일치합니다",
|
||||
"suggestion.reason.exterior_type": "외장과 일치합니다",
|
||||
"suggestion.reason.flooring_primary": "바닥재와 일치합니다",
|
||||
"suggestion.reason.landscaping_type": "조경과 일치합니다",
|
||||
"suggestion.reason.property_type": "주택 유형에 추천됩니다",
|
||||
"suggestion.reason.climate_region": "거주 지역 기후에 추천됩니다",
|
||||
"lookup.residence_type.duplex": "듀플렉스",
|
||||
"lookup.residence_type.vacation_home": "별장",
|
||||
"lookup.task_category.general": "일반",
|
||||
"lookup.task_frequency.bi_weekly": "격주",
|
||||
"lookup.task_frequency.semi_annually": "반기별",
|
||||
"lookup.task_frequency.custom": "사용자 지정",
|
||||
"lookup.contractor_specialty.appliance_repair": "가전 수리",
|
||||
"lookup.contractor_specialty.cleaner": "청소부",
|
||||
"lookup.contractor_specialty.locksmith": "열쇠공",
|
||||
"lookup.home_profile.gas_furnace": "가스 난로",
|
||||
"lookup.home_profile.electric_furnace": "전기 난로",
|
||||
"lookup.home_profile.heat_pump": "열펌프",
|
||||
"lookup.home_profile.boiler": "보일러",
|
||||
"lookup.home_profile.radiant": "복사식",
|
||||
"lookup.home_profile.other": "기타",
|
||||
"lookup.home_profile.central_ac": "중앙 에어컨",
|
||||
"lookup.home_profile.window_ac": "창문형 에어컨",
|
||||
"lookup.home_profile.evaporative": "증발식",
|
||||
"lookup.home_profile.none": "없음",
|
||||
"lookup.home_profile.tank_gas": "탱크식(가스)",
|
||||
"lookup.home_profile.tank_electric": "탱크식(전기)",
|
||||
"lookup.home_profile.tankless_gas": "탱크리스(가스)",
|
||||
"lookup.home_profile.tankless_electric": "탱크리스(전기)",
|
||||
"lookup.home_profile.solar": "태양광",
|
||||
"lookup.home_profile.asphalt_shingle": "아스팔트 슁글",
|
||||
"lookup.home_profile.metal": "금속",
|
||||
"lookup.home_profile.tile": "타일",
|
||||
"lookup.home_profile.slate": "슬레이트",
|
||||
"lookup.home_profile.wood_shake": "목재 셰이크",
|
||||
"lookup.home_profile.flat": "평지붕",
|
||||
"lookup.home_profile.brick": "벽돌",
|
||||
"lookup.home_profile.vinyl_siding": "비닐 사이딩",
|
||||
"lookup.home_profile.wood_siding": "목재 사이딩",
|
||||
"lookup.home_profile.stucco": "스투코",
|
||||
"lookup.home_profile.stone": "석재",
|
||||
"lookup.home_profile.fiber_cement": "섬유 시멘트",
|
||||
"lookup.home_profile.hardwood": "원목",
|
||||
"lookup.home_profile.laminate": "라미네이트",
|
||||
"lookup.home_profile.carpet": "카펫",
|
||||
"lookup.home_profile.vinyl": "비닐",
|
||||
"lookup.home_profile.concrete": "콘크리트",
|
||||
"lookup.home_profile.lawn": "잔디",
|
||||
"lookup.home_profile.desert": "사막",
|
||||
"lookup.home_profile.xeriscape": "제리스케이프",
|
||||
"lookup.home_profile.garden": "정원",
|
||||
"lookup.home_profile.mixed": "혼합",
|
||||
"lookup.document_type.warranty": "보증",
|
||||
"lookup.document_type.manual": "사용 설명서",
|
||||
"lookup.document_type.receipt": "영수증/송장",
|
||||
"lookup.document_type.inspection": "점검 보고서",
|
||||
"lookup.document_type.permit": "허가증",
|
||||
"lookup.document_type.deed": "증서/권리증",
|
||||
"lookup.document_type.insurance": "보험",
|
||||
"lookup.document_type.contract": "계약",
|
||||
"lookup.document_type.photo": "사진",
|
||||
"lookup.document_type.other": "기타",
|
||||
"lookup.document_category.appliance": "가전",
|
||||
"lookup.document_category.hvac": "냉난방",
|
||||
"lookup.document_category.plumbing": "배관",
|
||||
"lookup.document_category.electrical": "전기",
|
||||
"lookup.document_category.roofing": "지붕",
|
||||
"lookup.document_category.structural": "구조",
|
||||
"lookup.document_category.landscaping": "조경",
|
||||
"lookup.document_category.general": "일반",
|
||||
"lookup.document_category.other": "기타"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"error.google_signin_not_configured": "Google Sign In is niet geconfigureerd",
|
||||
"error.google_signin_failed": "Google Sign In mislukt",
|
||||
"error.invalid_google_token": "Ongeldig Google identiteitstoken",
|
||||
|
||||
"error.invalid_task_id": "Ongeldig taak-ID",
|
||||
"error.invalid_residence_id": "Ongeldig woning-ID",
|
||||
"error.invalid_contractor_id": "Ongeldig aannemer-ID",
|
||||
@@ -34,7 +33,6 @@
|
||||
"error.invalid_user_id": "Ongeldig gebruikers-ID",
|
||||
"error.invalid_notification_id": "Ongeldig notificatie-ID",
|
||||
"error.invalid_device_id": "Ongeldig apparaat-ID",
|
||||
|
||||
"error.task_not_found": "Taak niet gevonden",
|
||||
"error.residence_not_found": "Woning niet gevonden",
|
||||
"error.contractor_not_found": "Aannemer niet gevonden",
|
||||
@@ -43,7 +41,6 @@
|
||||
"error.user_not_found": "Gebruiker niet gevonden",
|
||||
"error.share_code_invalid": "Ongeldige deelcode",
|
||||
"error.share_code_expired": "Deelcode is verlopen",
|
||||
|
||||
"error.task_access_denied": "U heeft geen toegang tot deze taak",
|
||||
"error.residence_access_denied": "U heeft geen toegang tot deze woning",
|
||||
"error.contractor_access_denied": "U heeft geen toegang tot deze aannemer",
|
||||
@@ -52,10 +49,8 @@
|
||||
"error.cannot_remove_owner": "Kan de woningeigenaar niet verwijderen",
|
||||
"error.user_already_member": "Gebruiker is al lid van deze woning",
|
||||
"error.properties_limit_reached": "U heeft het maximale aantal woningen voor uw abonnement bereikt",
|
||||
|
||||
"error.task_already_cancelled": "Taak is al geannuleerd",
|
||||
"error.task_already_archived": "Taak is al gearchiveerd",
|
||||
|
||||
"error.failed_to_parse_form": "Multipart formulier parsen mislukt",
|
||||
"error.task_id_required": "task_id is verplicht",
|
||||
"error.invalid_task_id_value": "Ongeldig task_id",
|
||||
@@ -64,14 +59,12 @@
|
||||
"error.invalid_residence_id_value": "Ongeldig residence_id",
|
||||
"error.title_required": "titel is verplicht",
|
||||
"error.failed_to_upload_file": "Bestand uploaden mislukt",
|
||||
|
||||
"message.logged_out": "Succesvol uitgelogd",
|
||||
"message.email_verified": "E-mailadres succesvol geverifieerd",
|
||||
"message.verification_email_sent": "Verificatie e-mail verzonden",
|
||||
"message.password_reset_email_sent": "Als er een account met dat e-mailadres bestaat, is er een wachtwoord resetcode verzonden.",
|
||||
"message.reset_code_verified": "Code succesvol geverifieerd",
|
||||
"message.password_reset_success": "Wachtwoord succesvol gereset. Log in met uw nieuwe wachtwoord.",
|
||||
|
||||
"message.task_deleted": "Taak succesvol verwijderd",
|
||||
"message.task_in_progress": "Taak gemarkeerd als in uitvoering",
|
||||
"message.task_cancelled": "Taak geannuleerd",
|
||||
@@ -79,46 +72,35 @@
|
||||
"message.task_archived": "Taak gearchiveerd",
|
||||
"message.task_unarchived": "Taak gearchiveerd ongedaan gemaakt",
|
||||
"message.completion_deleted": "Voltooiing succesvol verwijderd",
|
||||
|
||||
"message.residence_deleted": "Woning succesvol verwijderd",
|
||||
"message.user_removed": "Gebruiker verwijderd van woning",
|
||||
"message.tasks_report_generated": "Takenrapport succesvol gegenereerd",
|
||||
"message.tasks_report_sent": "Takenrapport gegenereerd en verzonden naar {{.Email}}",
|
||||
"message.tasks_report_email_failed": "Takenrapport gegenereerd maar e-mail kon niet worden verzonden",
|
||||
|
||||
"message.contractor_deleted": "Aannemer succesvol verwijderd",
|
||||
|
||||
"message.document_deleted": "Document succesvol verwijderd",
|
||||
"message.document_activated": "Document geactiveerd",
|
||||
"message.document_deactivated": "Document gedeactiveerd",
|
||||
|
||||
"message.notification_marked_read": "Notificatie gemarkeerd als gelezen",
|
||||
"message.all_notifications_marked_read": "Alle notificaties gemarkeerd als gelezen",
|
||||
"message.device_removed": "Apparaat verwijderd",
|
||||
|
||||
"message.subscription_upgraded": "Abonnement succesvol geüpgraded",
|
||||
"message.subscription_cancelled": "Abonnement geannuleerd. U behoudt Pro voordelen tot het einde van uw factureringsperiode.",
|
||||
"message.subscription_restored": "Abonnement succesvol hersteld",
|
||||
|
||||
"message.file_deleted": "Bestand succesvol verwijderd",
|
||||
"message.static_data_refreshed": "Statische gegevens vernieuwd",
|
||||
|
||||
"error.notification_not_found": "Notificatie niet gevonden",
|
||||
"error.invalid_platform": "Ongeldig platform",
|
||||
|
||||
"error.upgrade_trigger_not_found": "Upgrade trigger niet gevonden",
|
||||
"error.receipt_data_required": "receipt_data is verplicht voor iOS",
|
||||
"error.purchase_token_required": "purchase_token is verplicht voor Android",
|
||||
|
||||
"error.no_file_provided": "Geen bestand aangeleverd",
|
||||
|
||||
"error.failed_to_fetch_residence_types": "Woningtypes ophalen mislukt",
|
||||
"error.failed_to_fetch_task_categories": "Taakcategorieën ophalen mislukt",
|
||||
"error.failed_to_fetch_task_priorities": "Taakprioriteiten ophalen mislukt",
|
||||
"error.failed_to_fetch_task_frequencies": "Taakfrequenties ophalen mislukt",
|
||||
"error.failed_to_fetch_task_statuses": "Taakstatussen ophalen mislukt",
|
||||
"error.failed_to_fetch_contractor_specialties": "Aannemer specialiteiten ophalen mislukt",
|
||||
|
||||
"push.task_due_soon.title": "Taak Vervalt Binnenkort",
|
||||
"push.task_due_soon.body": "{{.TaskTitle}} vervalt {{.DueDate}}",
|
||||
"push.task_overdue.title": "Verlopen Taak",
|
||||
@@ -129,55 +111,48 @@
|
||||
"push.task_assigned.body": "U bent toegewezen aan {{.TaskTitle}}",
|
||||
"push.residence_shared.title": "Woning Gedeeld",
|
||||
"push.residence_shared.body": "{{.UserName}} heeft {{.ResidenceName}} met u gedeeld",
|
||||
|
||||
"email.welcome.subject": "Welkom bij honeyDue!",
|
||||
"email.verification.subject": "Verifieer Uw E-mailadres",
|
||||
"email.password_reset.subject": "Wachtwoord Resetcode",
|
||||
"email.tasks_report.subject": "Takenrapport voor {{.ResidenceName}}",
|
||||
|
||||
"lookup.residence_type.house": "Huis",
|
||||
"lookup.residence_type.apartment": "Appartement",
|
||||
"lookup.residence_type.condo": "Appartement met eigen grond",
|
||||
"lookup.residence_type.condo": "Koopflat",
|
||||
"lookup.residence_type.townhouse": "Rijtjeshuis",
|
||||
"lookup.residence_type.mobile_home": "Stacaravan",
|
||||
"lookup.residence_type.other": "Anders",
|
||||
|
||||
"lookup.task_category.plumbing": "Loodgieterij",
|
||||
"lookup.task_category.electrical": "Elektriciteit",
|
||||
"lookup.task_category.hvac": "Verwarming en Ventilatie",
|
||||
"lookup.residence_type.other": "Overig",
|
||||
"lookup.task_category.plumbing": "Loodgieterswerk",
|
||||
"lookup.task_category.electrical": "Elektrisch",
|
||||
"lookup.task_category.hvac": "HVAC",
|
||||
"lookup.task_category.appliances": "Apparaten",
|
||||
"lookup.task_category.exterior": "Buitenkant",
|
||||
"lookup.task_category.interior": "Binnenkant",
|
||||
"lookup.task_category.exterior": "Buiten",
|
||||
"lookup.task_category.interior": "Binnen",
|
||||
"lookup.task_category.landscaping": "Tuinonderhoud",
|
||||
"lookup.task_category.safety": "Veiligheid",
|
||||
"lookup.task_category.cleaning": "Schoonmaak",
|
||||
"lookup.task_category.pest_control": "Ongediertebestrijding",
|
||||
"lookup.task_category.seasonal": "Seizoensgebonden",
|
||||
"lookup.task_category.other": "Anders",
|
||||
|
||||
"lookup.task_priority.low": "Laag",
|
||||
"lookup.task_priority.medium": "Gemiddeld",
|
||||
"lookup.task_priority.high": "Hoog",
|
||||
"lookup.task_priority.urgent": "Urgent",
|
||||
|
||||
"lookup.task_status.pending": "In afwachting",
|
||||
"lookup.task_status.in_progress": "In uitvoering",
|
||||
"lookup.task_status.completed": "Voltooid",
|
||||
"lookup.task_status.cancelled": "Geannuleerd",
|
||||
"lookup.task_status.archived": "Gearchiveerd",
|
||||
|
||||
"lookup.task_frequency.once": "Eenmalig",
|
||||
"lookup.task_frequency.daily": "Dagelijks",
|
||||
"lookup.task_frequency.weekly": "Wekelijks",
|
||||
"lookup.task_frequency.biweekly": "Om de 2 Weken",
|
||||
"lookup.task_frequency.monthly": "Maandelijks",
|
||||
"lookup.task_frequency.quarterly": "Per Kwartaal",
|
||||
"lookup.task_frequency.quarterly": "Per kwartaal",
|
||||
"lookup.task_frequency.semiannually": "Om de 6 Maanden",
|
||||
"lookup.task_frequency.annually": "Jaarlijks",
|
||||
|
||||
"lookup.contractor_specialty.plumber": "Loodgieter",
|
||||
"lookup.contractor_specialty.electrician": "Elektricien",
|
||||
"lookup.contractor_specialty.hvac_technician": "HVAC Monteur",
|
||||
"lookup.contractor_specialty.hvac_technician": "HVAC-technicus",
|
||||
"lookup.contractor_specialty.handyman": "Klusjesman",
|
||||
"lookup.contractor_specialty.landscaper": "Hovenier",
|
||||
"lookup.contractor_specialty.roofer": "Dakdekker",
|
||||
@@ -185,7 +160,88 @@
|
||||
"lookup.contractor_specialty.carpenter": "Timmerman",
|
||||
"lookup.contractor_specialty.pest_control": "Ongediertebestrijding",
|
||||
"lookup.contractor_specialty.cleaning": "Schoonmaak",
|
||||
"lookup.contractor_specialty.pool_service": "Zwembadonderhoud",
|
||||
"lookup.contractor_specialty.general_contractor": "Algemeen Aannemer",
|
||||
"lookup.contractor_specialty.other": "Anders"
|
||||
"lookup.contractor_specialty.pool_service": "Zwembadservice",
|
||||
"lookup.contractor_specialty.general_contractor": "Hoofdaannemer",
|
||||
"lookup.contractor_specialty.other": "Anders",
|
||||
"suggestion.reason.has_pool": "Je woning heeft een zwembad",
|
||||
"suggestion.reason.has_sprinkler_system": "Je woning heeft een sproei-installatie",
|
||||
"suggestion.reason.has_septic": "Je woning heeft een septische tank",
|
||||
"suggestion.reason.has_fireplace": "Je woning heeft een open haard",
|
||||
"suggestion.reason.has_garage": "Je woning heeft een garage",
|
||||
"suggestion.reason.has_basement": "Je woning heeft een kelder",
|
||||
"suggestion.reason.has_attic": "Je woning heeft een zolder",
|
||||
"suggestion.reason.heating_type": "Past bij je verwarmingssysteem",
|
||||
"suggestion.reason.cooling_type": "Past bij je koelsysteem",
|
||||
"suggestion.reason.water_heater_type": "Past bij je boiler",
|
||||
"suggestion.reason.roof_type": "Past bij je dak",
|
||||
"suggestion.reason.exterior_type": "Past bij je gevel",
|
||||
"suggestion.reason.flooring_primary": "Past bij je vloer",
|
||||
"suggestion.reason.landscaping_type": "Past bij je tuin",
|
||||
"suggestion.reason.property_type": "Aanbevolen voor je type woning",
|
||||
"suggestion.reason.climate_region": "Aanbevolen voor je klimaat",
|
||||
"lookup.residence_type.duplex": "Twee-onder-een-kap",
|
||||
"lookup.residence_type.vacation_home": "Vakantiehuis",
|
||||
"lookup.task_category.general": "Algemeen",
|
||||
"lookup.task_frequency.bi_weekly": "Tweewekelijks",
|
||||
"lookup.task_frequency.semi_annually": "Halfjaarlijks",
|
||||
"lookup.task_frequency.custom": "Aangepast",
|
||||
"lookup.contractor_specialty.appliance_repair": "Apparaatreparatie",
|
||||
"lookup.contractor_specialty.cleaner": "Schoonmaker",
|
||||
"lookup.contractor_specialty.locksmith": "Slotenmaker",
|
||||
"lookup.home_profile.gas_furnace": "Gasketel",
|
||||
"lookup.home_profile.electric_furnace": "Elektrische ketel",
|
||||
"lookup.home_profile.heat_pump": "Warmtepomp",
|
||||
"lookup.home_profile.boiler": "CV-ketel",
|
||||
"lookup.home_profile.radiant": "Stralingsverwarming",
|
||||
"lookup.home_profile.other": "Overig",
|
||||
"lookup.home_profile.central_ac": "Centrale airco",
|
||||
"lookup.home_profile.window_ac": "Raamairco",
|
||||
"lookup.home_profile.evaporative": "Verdampings",
|
||||
"lookup.home_profile.none": "Geen",
|
||||
"lookup.home_profile.tank_gas": "Boiler (gas)",
|
||||
"lookup.home_profile.tank_electric": "Boiler (elektrisch)",
|
||||
"lookup.home_profile.tankless_gas": "Doorstroom (gas)",
|
||||
"lookup.home_profile.tankless_electric": "Doorstroom (elektrisch)",
|
||||
"lookup.home_profile.solar": "Zonne-energie",
|
||||
"lookup.home_profile.asphalt_shingle": "Asfaltshingle",
|
||||
"lookup.home_profile.metal": "Metaal",
|
||||
"lookup.home_profile.tile": "Dakpan",
|
||||
"lookup.home_profile.slate": "Leisteen",
|
||||
"lookup.home_profile.wood_shake": "Houten shingle",
|
||||
"lookup.home_profile.flat": "Plat",
|
||||
"lookup.home_profile.brick": "Baksteen",
|
||||
"lookup.home_profile.vinyl_siding": "Vinyl gevelbekleding",
|
||||
"lookup.home_profile.wood_siding": "Houten gevelbekleding",
|
||||
"lookup.home_profile.stucco": "Stucwerk",
|
||||
"lookup.home_profile.stone": "Steen",
|
||||
"lookup.home_profile.fiber_cement": "Vezelcement",
|
||||
"lookup.home_profile.hardwood": "Hardhout",
|
||||
"lookup.home_profile.laminate": "Laminaat",
|
||||
"lookup.home_profile.carpet": "Tapijt",
|
||||
"lookup.home_profile.vinyl": "Vinyl",
|
||||
"lookup.home_profile.concrete": "Beton",
|
||||
"lookup.home_profile.lawn": "Gazon",
|
||||
"lookup.home_profile.desert": "Woestijn",
|
||||
"lookup.home_profile.xeriscape": "Xeriscaping",
|
||||
"lookup.home_profile.garden": "Tuin",
|
||||
"lookup.home_profile.mixed": "Gemengd",
|
||||
"lookup.document_type.warranty": "Garantie",
|
||||
"lookup.document_type.manual": "Handleiding",
|
||||
"lookup.document_type.receipt": "Bon/Factuur",
|
||||
"lookup.document_type.inspection": "Inspectierapport",
|
||||
"lookup.document_type.permit": "Vergunning",
|
||||
"lookup.document_type.deed": "Akte/Eigendomsbewijs",
|
||||
"lookup.document_type.insurance": "Verzekering",
|
||||
"lookup.document_type.contract": "Contract",
|
||||
"lookup.document_type.photo": "Foto",
|
||||
"lookup.document_type.other": "Overig",
|
||||
"lookup.document_category.appliance": "Apparaat",
|
||||
"lookup.document_category.hvac": "HVAC",
|
||||
"lookup.document_category.plumbing": "Loodgieterswerk",
|
||||
"lookup.document_category.electrical": "Elektrisch",
|
||||
"lookup.document_category.roofing": "Dak",
|
||||
"lookup.document_category.structural": "Constructie",
|
||||
"lookup.document_category.landscaping": "Tuinaanleg",
|
||||
"lookup.document_category.general": "Algemeen",
|
||||
"lookup.document_category.other": "Overig"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"error.google_signin_not_configured": "O login com Google nao esta configurado",
|
||||
"error.google_signin_failed": "Falha no login com Google",
|
||||
"error.invalid_google_token": "Token de identidade Google invalido",
|
||||
|
||||
"error.invalid_task_id": "ID da tarefa invalido",
|
||||
"error.invalid_residence_id": "ID da propriedade invalido",
|
||||
"error.invalid_contractor_id": "ID do prestador invalido",
|
||||
@@ -34,7 +33,6 @@
|
||||
"error.invalid_user_id": "ID do usuario invalido",
|
||||
"error.invalid_notification_id": "ID da notificacao invalido",
|
||||
"error.invalid_device_id": "ID do dispositivo invalido",
|
||||
|
||||
"error.task_not_found": "Tarefa nao encontrada",
|
||||
"error.residence_not_found": "Propriedade nao encontrada",
|
||||
"error.contractor_not_found": "Prestador nao encontrado",
|
||||
@@ -43,7 +41,6 @@
|
||||
"error.user_not_found": "Usuario nao encontrado",
|
||||
"error.share_code_invalid": "Codigo de compartilhamento invalido",
|
||||
"error.share_code_expired": "O codigo de compartilhamento expirou",
|
||||
|
||||
"error.task_access_denied": "Voce nao tem acesso a esta tarefa",
|
||||
"error.residence_access_denied": "Voce nao tem acesso a esta propriedade",
|
||||
"error.contractor_access_denied": "Voce nao tem acesso a este prestador",
|
||||
@@ -52,10 +49,8 @@
|
||||
"error.cannot_remove_owner": "Nao e possivel remover o proprietario",
|
||||
"error.user_already_member": "O usuario ja e membro desta propriedade",
|
||||
"error.properties_limit_reached": "Voce atingiu o numero maximo de propriedades para sua assinatura",
|
||||
|
||||
"error.task_already_cancelled": "A tarefa ja esta cancelada",
|
||||
"error.task_already_archived": "A tarefa ja esta arquivada",
|
||||
|
||||
"error.failed_to_parse_form": "Falha ao analisar o formulario",
|
||||
"error.task_id_required": "task_id e obrigatorio",
|
||||
"error.invalid_task_id_value": "task_id invalido",
|
||||
@@ -64,14 +59,12 @@
|
||||
"error.invalid_residence_id_value": "residence_id invalido",
|
||||
"error.title_required": "Titulo e obrigatorio",
|
||||
"error.failed_to_upload_file": "Falha ao enviar arquivo",
|
||||
|
||||
"message.logged_out": "Logout realizado com sucesso",
|
||||
"message.email_verified": "Email verificado com sucesso",
|
||||
"message.verification_email_sent": "Email de verificacao enviado",
|
||||
"message.password_reset_email_sent": "Se existir uma conta com este email, um codigo de redefinicao foi enviado.",
|
||||
"message.reset_code_verified": "Codigo verificado com sucesso",
|
||||
"message.password_reset_success": "Senha redefinida com sucesso. Por favor, faca login com sua nova senha.",
|
||||
|
||||
"message.task_deleted": "Tarefa excluida com sucesso",
|
||||
"message.task_in_progress": "Tarefa marcada como em andamento",
|
||||
"message.task_cancelled": "Tarefa cancelada",
|
||||
@@ -79,46 +72,35 @@
|
||||
"message.task_archived": "Tarefa arquivada",
|
||||
"message.task_unarchived": "Tarefa desarquivada",
|
||||
"message.completion_deleted": "Conclusao excluida com sucesso",
|
||||
|
||||
"message.residence_deleted": "Propriedade excluida com sucesso",
|
||||
"message.user_removed": "Usuario removido da propriedade",
|
||||
"message.tasks_report_generated": "Relatorio de tarefas gerado com sucesso",
|
||||
"message.tasks_report_sent": "Relatorio de tarefas gerado e enviado para {{.Email}}",
|
||||
"message.tasks_report_email_failed": "Relatorio de tarefas gerado mas o email nao pode ser enviado",
|
||||
|
||||
"message.contractor_deleted": "Prestador excluido com sucesso",
|
||||
|
||||
"message.document_deleted": "Documento excluido com sucesso",
|
||||
"message.document_activated": "Documento ativado",
|
||||
"message.document_deactivated": "Documento desativado",
|
||||
|
||||
"message.notification_marked_read": "Notificação marcada como lida",
|
||||
"message.all_notifications_marked_read": "Todas as notificações marcadas como lidas",
|
||||
"message.device_removed": "Dispositivo removido",
|
||||
|
||||
"message.subscription_upgraded": "Assinatura atualizada com sucesso",
|
||||
"message.subscription_cancelled": "Assinatura cancelada. Você manterá os benefícios Pro até o final do seu período de faturamento.",
|
||||
"message.subscription_restored": "Assinatura restaurada com sucesso",
|
||||
|
||||
"message.file_deleted": "Arquivo excluído com sucesso",
|
||||
"message.static_data_refreshed": "Dados estáticos atualizados",
|
||||
|
||||
"error.notification_not_found": "Notificação não encontrada",
|
||||
"error.invalid_platform": "Plataforma inválida",
|
||||
|
||||
"error.upgrade_trigger_not_found": "Gatilho de atualização não encontrado",
|
||||
"error.receipt_data_required": "receipt_data é obrigatório para iOS",
|
||||
"error.purchase_token_required": "purchase_token é obrigatório para Android",
|
||||
|
||||
"error.no_file_provided": "Nenhum arquivo fornecido",
|
||||
|
||||
"error.failed_to_fetch_residence_types": "Falha ao buscar tipos de propriedade",
|
||||
"error.failed_to_fetch_task_categories": "Falha ao buscar categorias de tarefas",
|
||||
"error.failed_to_fetch_task_priorities": "Falha ao buscar prioridades de tarefas",
|
||||
"error.failed_to_fetch_task_frequencies": "Falha ao buscar frequências de tarefas",
|
||||
"error.failed_to_fetch_task_statuses": "Falha ao buscar status de tarefas",
|
||||
"error.failed_to_fetch_contractor_specialties": "Falha ao buscar especialidades de prestadores",
|
||||
|
||||
"push.task_due_soon.title": "Tarefa Proxima do Vencimento",
|
||||
"push.task_due_soon.body": "{{.TaskTitle}} vence em {{.DueDate}}",
|
||||
"push.task_overdue.title": "Tarefa Atrasada",
|
||||
@@ -129,63 +111,137 @@
|
||||
"push.task_assigned.body": "{{.TaskTitle}} foi atribuida a voce",
|
||||
"push.residence_shared.title": "Propriedade Compartilhada",
|
||||
"push.residence_shared.body": "{{.UserName}} compartilhou {{.ResidenceName}} com voce",
|
||||
|
||||
"email.welcome.subject": "Bem-vindo ao honeyDue!",
|
||||
"email.verification.subject": "Verifique Seu Email",
|
||||
"email.password_reset.subject": "Codigo de Redefinicao de Senha",
|
||||
"email.tasks_report.subject": "Relatorio de Tarefas para {{.ResidenceName}}",
|
||||
|
||||
"lookup.residence_type.house": "Casa",
|
||||
"lookup.residence_type.apartment": "Apartamento",
|
||||
"lookup.residence_type.condo": "Condominio",
|
||||
"lookup.residence_type.condo": "Condomínio",
|
||||
"lookup.residence_type.townhouse": "Sobrado",
|
||||
"lookup.residence_type.mobile_home": "Casa Movel",
|
||||
"lookup.residence_type.mobile_home": "Casa móvel",
|
||||
"lookup.residence_type.other": "Outro",
|
||||
|
||||
"lookup.task_category.plumbing": "Encanamento",
|
||||
"lookup.task_category.electrical": "Eletrica",
|
||||
"lookup.task_category.hvac": "Climatizacao",
|
||||
"lookup.task_category.appliances": "Eletrodomesticos",
|
||||
"lookup.task_category.electrical": "Elétrica",
|
||||
"lookup.task_category.hvac": "AVAC",
|
||||
"lookup.task_category.appliances": "Eletrodomésticos",
|
||||
"lookup.task_category.exterior": "Exterior",
|
||||
"lookup.task_category.interior": "Interior",
|
||||
"lookup.task_category.landscaping": "Paisagismo",
|
||||
"lookup.task_category.safety": "Seguranca",
|
||||
"lookup.task_category.safety": "Segurança",
|
||||
"lookup.task_category.cleaning": "Limpeza",
|
||||
"lookup.task_category.pest_control": "Controle de Pragas",
|
||||
"lookup.task_category.pest_control": "Controle de pragas",
|
||||
"lookup.task_category.seasonal": "Sazonal",
|
||||
"lookup.task_category.other": "Outro",
|
||||
|
||||
"lookup.task_priority.low": "Baixa",
|
||||
"lookup.task_priority.medium": "Media",
|
||||
"lookup.task_priority.medium": "Média",
|
||||
"lookup.task_priority.high": "Alta",
|
||||
"lookup.task_priority.urgent": "Urgente",
|
||||
|
||||
"lookup.task_status.pending": "Pendente",
|
||||
"lookup.task_status.in_progress": "Em Andamento",
|
||||
"lookup.task_status.completed": "Concluida",
|
||||
"lookup.task_status.cancelled": "Cancelada",
|
||||
"lookup.task_status.archived": "Arquivada",
|
||||
|
||||
"lookup.task_frequency.once": "Uma Vez",
|
||||
"lookup.task_frequency.daily": "Diario",
|
||||
"lookup.task_frequency.once": "Uma vez",
|
||||
"lookup.task_frequency.daily": "Diário",
|
||||
"lookup.task_frequency.weekly": "Semanal",
|
||||
"lookup.task_frequency.biweekly": "Quinzenal",
|
||||
"lookup.task_frequency.monthly": "Mensal",
|
||||
"lookup.task_frequency.quarterly": "Trimestral",
|
||||
"lookup.task_frequency.semiannually": "Semestral",
|
||||
"lookup.task_frequency.annually": "Anual",
|
||||
|
||||
"lookup.contractor_specialty.plumber": "Encanador",
|
||||
"lookup.contractor_specialty.electrician": "Eletricista",
|
||||
"lookup.contractor_specialty.hvac_technician": "Tecnico de Climatizacao",
|
||||
"lookup.contractor_specialty.hvac_technician": "Técnico de AVAC",
|
||||
"lookup.contractor_specialty.handyman": "Faz-tudo",
|
||||
"lookup.contractor_specialty.landscaper": "Paisagista",
|
||||
"lookup.contractor_specialty.landscaper": "Jardineiro",
|
||||
"lookup.contractor_specialty.roofer": "Telhadista",
|
||||
"lookup.contractor_specialty.painter": "Pintor",
|
||||
"lookup.contractor_specialty.carpenter": "Carpinteiro",
|
||||
"lookup.contractor_specialty.pest_control": "Controle de Pragas",
|
||||
"lookup.contractor_specialty.pest_control": "Controle de pragas",
|
||||
"lookup.contractor_specialty.cleaning": "Limpeza",
|
||||
"lookup.contractor_specialty.pool_service": "Servico de Piscina",
|
||||
"lookup.contractor_specialty.general_contractor": "Empreiteiro Geral",
|
||||
"lookup.contractor_specialty.other": "Outro"
|
||||
"lookup.contractor_specialty.pool_service": "Serviço de piscina",
|
||||
"lookup.contractor_specialty.general_contractor": "Empreiteiro geral",
|
||||
"lookup.contractor_specialty.other": "Outro",
|
||||
"suggestion.reason.has_pool": "Sua casa tem piscina",
|
||||
"suggestion.reason.has_sprinkler_system": "Sua casa tem sistema de irrigação",
|
||||
"suggestion.reason.has_septic": "Sua casa tem fossa séptica",
|
||||
"suggestion.reason.has_fireplace": "Sua casa tem lareira",
|
||||
"suggestion.reason.has_garage": "Sua casa tem garagem",
|
||||
"suggestion.reason.has_basement": "Sua casa tem porão",
|
||||
"suggestion.reason.has_attic": "Sua casa tem sótão",
|
||||
"suggestion.reason.heating_type": "Combina com seu sistema de aquecimento",
|
||||
"suggestion.reason.cooling_type": "Combina com seu sistema de refrigeração",
|
||||
"suggestion.reason.water_heater_type": "Combina com seu aquecedor de água",
|
||||
"suggestion.reason.roof_type": "Combina com seu telhado",
|
||||
"suggestion.reason.exterior_type": "Combina com seu exterior",
|
||||
"suggestion.reason.flooring_primary": "Combina com seu piso",
|
||||
"suggestion.reason.landscaping_type": "Combina com seu paisagismo",
|
||||
"suggestion.reason.property_type": "Recomendado para seu tipo de imóvel",
|
||||
"suggestion.reason.climate_region": "Recomendado para seu clima",
|
||||
"lookup.residence_type.duplex": "Duplex",
|
||||
"lookup.residence_type.vacation_home": "Casa de férias",
|
||||
"lookup.task_category.general": "Geral",
|
||||
"lookup.task_frequency.bi_weekly": "Quinzenal",
|
||||
"lookup.task_frequency.semi_annually": "Semestral",
|
||||
"lookup.task_frequency.custom": "Personalizado",
|
||||
"lookup.contractor_specialty.appliance_repair": "Reparo de eletrodomésticos",
|
||||
"lookup.contractor_specialty.cleaner": "Faxineiro",
|
||||
"lookup.contractor_specialty.locksmith": "Chaveiro",
|
||||
"lookup.home_profile.gas_furnace": "Aquecedor a gás",
|
||||
"lookup.home_profile.electric_furnace": "Aquecedor elétrico",
|
||||
"lookup.home_profile.heat_pump": "Bomba de calor",
|
||||
"lookup.home_profile.boiler": "Caldeira",
|
||||
"lookup.home_profile.radiant": "Radiante",
|
||||
"lookup.home_profile.other": "Outro",
|
||||
"lookup.home_profile.central_ac": "AC central",
|
||||
"lookup.home_profile.window_ac": "AC de janela",
|
||||
"lookup.home_profile.evaporative": "Evaporativo",
|
||||
"lookup.home_profile.none": "Nenhum",
|
||||
"lookup.home_profile.tank_gas": "Tanque (gás)",
|
||||
"lookup.home_profile.tank_electric": "Tanque (elétrico)",
|
||||
"lookup.home_profile.tankless_gas": "Sem tanque (gás)",
|
||||
"lookup.home_profile.tankless_electric": "Sem tanque (elétrico)",
|
||||
"lookup.home_profile.solar": "Solar",
|
||||
"lookup.home_profile.asphalt_shingle": "Telha asfáltica",
|
||||
"lookup.home_profile.metal": "Metal",
|
||||
"lookup.home_profile.tile": "Telha",
|
||||
"lookup.home_profile.slate": "Ardósia",
|
||||
"lookup.home_profile.wood_shake": "Telha de madeira",
|
||||
"lookup.home_profile.flat": "Plano",
|
||||
"lookup.home_profile.brick": "Tijolo",
|
||||
"lookup.home_profile.vinyl_siding": "Revestimento de vinil",
|
||||
"lookup.home_profile.wood_siding": "Revestimento de madeira",
|
||||
"lookup.home_profile.stucco": "Estuque",
|
||||
"lookup.home_profile.stone": "Pedra",
|
||||
"lookup.home_profile.fiber_cement": "Cimento reforçado",
|
||||
"lookup.home_profile.hardwood": "Madeira de lei",
|
||||
"lookup.home_profile.laminate": "Laminado",
|
||||
"lookup.home_profile.carpet": "Carpete",
|
||||
"lookup.home_profile.vinyl": "Vinil",
|
||||
"lookup.home_profile.concrete": "Concreto",
|
||||
"lookup.home_profile.lawn": "Gramado",
|
||||
"lookup.home_profile.desert": "Deserto",
|
||||
"lookup.home_profile.xeriscape": "Xeropaisagismo",
|
||||
"lookup.home_profile.garden": "Jardim",
|
||||
"lookup.home_profile.mixed": "Misto",
|
||||
"lookup.document_type.warranty": "Garantia",
|
||||
"lookup.document_type.manual": "Manual do usuário",
|
||||
"lookup.document_type.receipt": "Recibo/Fatura",
|
||||
"lookup.document_type.inspection": "Relatório de inspeção",
|
||||
"lookup.document_type.permit": "Licença",
|
||||
"lookup.document_type.deed": "Escritura/Título",
|
||||
"lookup.document_type.insurance": "Seguro",
|
||||
"lookup.document_type.contract": "Contrato",
|
||||
"lookup.document_type.photo": "Foto",
|
||||
"lookup.document_type.other": "Outro",
|
||||
"lookup.document_category.appliance": "Eletrodoméstico",
|
||||
"lookup.document_category.hvac": "AVAC",
|
||||
"lookup.document_category.plumbing": "Encanamento",
|
||||
"lookup.document_category.electrical": "Elétrica",
|
||||
"lookup.document_category.roofing": "Telhado",
|
||||
"lookup.document_category.structural": "Estrutural",
|
||||
"lookup.document_category.landscaping": "Paisagismo",
|
||||
"lookup.document_category.general": "Geral",
|
||||
"lookup.document_category.other": "Outro"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"error.google_signin_not_configured": "未配置 Google 登录",
|
||||
"error.google_signin_failed": "Google 登录失败",
|
||||
"error.invalid_google_token": "Google 身份令牌无效",
|
||||
|
||||
"error.invalid_task_id": "任务 ID 无效",
|
||||
"error.invalid_residence_id": "房产 ID 无效",
|
||||
"error.invalid_contractor_id": "承包商 ID 无效",
|
||||
@@ -34,7 +33,6 @@
|
||||
"error.invalid_user_id": "用户 ID 无效",
|
||||
"error.invalid_notification_id": "通知 ID 无效",
|
||||
"error.invalid_device_id": "设备 ID 无效",
|
||||
|
||||
"error.task_not_found": "未找到任务",
|
||||
"error.residence_not_found": "未找到房产",
|
||||
"error.contractor_not_found": "未找到承包商",
|
||||
@@ -43,7 +41,6 @@
|
||||
"error.user_not_found": "未找到用户",
|
||||
"error.share_code_invalid": "分享码无效",
|
||||
"error.share_code_expired": "分享码已过期",
|
||||
|
||||
"error.task_access_denied": "您无权访问此任务",
|
||||
"error.residence_access_denied": "您无权访问此房产",
|
||||
"error.contractor_access_denied": "您无权访问此承包商",
|
||||
@@ -52,10 +49,8 @@
|
||||
"error.cannot_remove_owner": "无法移除房产所有者",
|
||||
"error.user_already_member": "用户已是此房产的成员",
|
||||
"error.properties_limit_reached": "您已达到订阅计划的房产数量上限",
|
||||
|
||||
"error.task_already_cancelled": "任务已取消",
|
||||
"error.task_already_archived": "任务已归档",
|
||||
|
||||
"error.failed_to_parse_form": "解析多部分表单失败",
|
||||
"error.task_id_required": "需要 task_id",
|
||||
"error.invalid_task_id_value": "task_id 无效",
|
||||
@@ -64,14 +59,12 @@
|
||||
"error.invalid_residence_id_value": "residence_id 无效",
|
||||
"error.title_required": "需要标题",
|
||||
"error.failed_to_upload_file": "上传文件失败",
|
||||
|
||||
"message.logged_out": "已成功退出",
|
||||
"message.email_verified": "邮箱验证成功",
|
||||
"message.verification_email_sent": "验证邮件已发送",
|
||||
"message.password_reset_email_sent": "如果该邮箱存在账户,密码重置验证码已发送。",
|
||||
"message.reset_code_verified": "验证码验证成功",
|
||||
"message.password_reset_success": "密码重置成功,请使用新密码登录。",
|
||||
|
||||
"message.task_deleted": "任务删除成功",
|
||||
"message.task_in_progress": "任务已标记为进行中",
|
||||
"message.task_cancelled": "任务已取消",
|
||||
@@ -79,46 +72,35 @@
|
||||
"message.task_archived": "任务已归档",
|
||||
"message.task_unarchived": "任务已取消归档",
|
||||
"message.completion_deleted": "完成记录删除成功",
|
||||
|
||||
"message.residence_deleted": "房产删除成功",
|
||||
"message.user_removed": "用户已从房产中移除",
|
||||
"message.tasks_report_generated": "任务报告生成成功",
|
||||
"message.tasks_report_sent": "任务报告已生成并发送至 {{.Email}}",
|
||||
"message.tasks_report_email_failed": "任务报告已生成但无法发送邮件",
|
||||
|
||||
"message.contractor_deleted": "承包商删除成功",
|
||||
|
||||
"message.document_deleted": "文档删除成功",
|
||||
"message.document_activated": "文档已激活",
|
||||
"message.document_deactivated": "文档已停用",
|
||||
|
||||
"message.notification_marked_read": "通知已标记为已读",
|
||||
"message.all_notifications_marked_read": "所有通知已标记为已读",
|
||||
"message.device_removed": "设备已移除",
|
||||
|
||||
"message.subscription_upgraded": "订阅升级成功",
|
||||
"message.subscription_cancelled": "订阅已取消。您将保留专业版权益至当前账单周期结束。",
|
||||
"message.subscription_restored": "订阅恢复成功",
|
||||
|
||||
"message.file_deleted": "文件删除成功",
|
||||
"message.static_data_refreshed": "静态数据已刷新",
|
||||
|
||||
"error.notification_not_found": "未找到通知",
|
||||
"error.invalid_platform": "平台无效",
|
||||
|
||||
"error.upgrade_trigger_not_found": "未找到升级触发器",
|
||||
"error.receipt_data_required": "iOS 需要 receipt_data",
|
||||
"error.purchase_token_required": "Android 需要 purchase_token",
|
||||
|
||||
"error.no_file_provided": "未提供文件",
|
||||
|
||||
"error.failed_to_fetch_residence_types": "获取房产类型失败",
|
||||
"error.failed_to_fetch_task_categories": "获取任务分类失败",
|
||||
"error.failed_to_fetch_task_priorities": "获取任务优先级失败",
|
||||
"error.failed_to_fetch_task_frequencies": "获取任务频率失败",
|
||||
"error.failed_to_fetch_task_statuses": "获取任务状态失败",
|
||||
"error.failed_to_fetch_contractor_specialties": "获取承包商专业类别失败",
|
||||
|
||||
"push.task_due_soon.title": "任务即将到期",
|
||||
"push.task_due_soon.body": "{{.TaskTitle}} 将于 {{.DueDate}} 到期",
|
||||
"push.task_overdue.title": "任务已逾期",
|
||||
@@ -129,19 +111,16 @@
|
||||
"push.task_assigned.body": "您已被分配到 {{.TaskTitle}}",
|
||||
"push.residence_shared.title": "房产已分享",
|
||||
"push.residence_shared.body": "{{.UserName}} 与您分享了 {{.ResidenceName}}",
|
||||
|
||||
"email.welcome.subject": "欢迎使用 honeyDue!",
|
||||
"email.verification.subject": "验证您的邮箱",
|
||||
"email.password_reset.subject": "密码重置验证码",
|
||||
"email.tasks_report.subject": "{{.ResidenceName}} 的任务报告",
|
||||
|
||||
"lookup.residence_type.house": "独立屋",
|
||||
"lookup.residence_type.house": "独栋房屋",
|
||||
"lookup.residence_type.apartment": "公寓",
|
||||
"lookup.residence_type.condo": "共管公寓",
|
||||
"lookup.residence_type.townhouse": "联排别墅",
|
||||
"lookup.residence_type.mobile_home": "移动房屋",
|
||||
"lookup.residence_type.other": "其他",
|
||||
|
||||
"lookup.task_category.plumbing": "管道",
|
||||
"lookup.task_category.electrical": "电气",
|
||||
"lookup.task_category.hvac": "暖通空调",
|
||||
@@ -154,18 +133,15 @@
|
||||
"lookup.task_category.pest_control": "害虫防治",
|
||||
"lookup.task_category.seasonal": "季节性",
|
||||
"lookup.task_category.other": "其他",
|
||||
|
||||
"lookup.task_priority.low": "低",
|
||||
"lookup.task_priority.medium": "中",
|
||||
"lookup.task_priority.high": "高",
|
||||
"lookup.task_priority.urgent": "紧急",
|
||||
|
||||
"lookup.task_status.pending": "待处理",
|
||||
"lookup.task_status.in_progress": "进行中",
|
||||
"lookup.task_status.completed": "已完成",
|
||||
"lookup.task_status.cancelled": "已取消",
|
||||
"lookup.task_status.archived": "已归档",
|
||||
|
||||
"lookup.task_frequency.once": "一次",
|
||||
"lookup.task_frequency.daily": "每天",
|
||||
"lookup.task_frequency.weekly": "每周",
|
||||
@@ -174,12 +150,11 @@
|
||||
"lookup.task_frequency.quarterly": "每季度",
|
||||
"lookup.task_frequency.semiannually": "每半年",
|
||||
"lookup.task_frequency.annually": "每年",
|
||||
|
||||
"lookup.contractor_specialty.plumber": "水管工",
|
||||
"lookup.contractor_specialty.plumber": "管道工",
|
||||
"lookup.contractor_specialty.electrician": "电工",
|
||||
"lookup.contractor_specialty.hvac_technician": "暖通空调技师",
|
||||
"lookup.contractor_specialty.handyman": "杂工",
|
||||
"lookup.contractor_specialty.landscaper": "园林工",
|
||||
"lookup.contractor_specialty.landscaper": "园艺师",
|
||||
"lookup.contractor_specialty.roofer": "屋顶工",
|
||||
"lookup.contractor_specialty.painter": "油漆工",
|
||||
"lookup.contractor_specialty.carpenter": "木工",
|
||||
@@ -187,5 +162,86 @@
|
||||
"lookup.contractor_specialty.cleaning": "清洁",
|
||||
"lookup.contractor_specialty.pool_service": "泳池服务",
|
||||
"lookup.contractor_specialty.general_contractor": "总承包商",
|
||||
"lookup.contractor_specialty.other": "其他"
|
||||
"lookup.contractor_specialty.other": "其他",
|
||||
"suggestion.reason.has_pool": "您的住宅有游泳池",
|
||||
"suggestion.reason.has_sprinkler_system": "您的住宅有喷灌系统",
|
||||
"suggestion.reason.has_septic": "您的住宅有化粪池",
|
||||
"suggestion.reason.has_fireplace": "您的住宅有壁炉",
|
||||
"suggestion.reason.has_garage": "您的住宅有车库",
|
||||
"suggestion.reason.has_basement": "您的住宅有地下室",
|
||||
"suggestion.reason.has_attic": "您的住宅有阁楼",
|
||||
"suggestion.reason.heating_type": "与您的供暖系统匹配",
|
||||
"suggestion.reason.cooling_type": "与您的制冷系统匹配",
|
||||
"suggestion.reason.water_heater_type": "与您的热水器匹配",
|
||||
"suggestion.reason.roof_type": "与您的屋顶匹配",
|
||||
"suggestion.reason.exterior_type": "与您的外墙匹配",
|
||||
"suggestion.reason.flooring_primary": "与您的地板匹配",
|
||||
"suggestion.reason.landscaping_type": "与您的庭院匹配",
|
||||
"suggestion.reason.property_type": "为您的房产类型推荐",
|
||||
"suggestion.reason.climate_region": "为您所在气候推荐",
|
||||
"lookup.residence_type.duplex": "双拼住宅",
|
||||
"lookup.residence_type.vacation_home": "度假屋",
|
||||
"lookup.task_category.general": "通用",
|
||||
"lookup.task_frequency.bi_weekly": "每两周",
|
||||
"lookup.task_frequency.semi_annually": "每半年",
|
||||
"lookup.task_frequency.custom": "自定义",
|
||||
"lookup.contractor_specialty.appliance_repair": "家电维修",
|
||||
"lookup.contractor_specialty.cleaner": "清洁工",
|
||||
"lookup.contractor_specialty.locksmith": "锁匠",
|
||||
"lookup.home_profile.gas_furnace": "燃气炉",
|
||||
"lookup.home_profile.electric_furnace": "电炉",
|
||||
"lookup.home_profile.heat_pump": "热泵",
|
||||
"lookup.home_profile.boiler": "锅炉",
|
||||
"lookup.home_profile.radiant": "辐射式",
|
||||
"lookup.home_profile.other": "其他",
|
||||
"lookup.home_profile.central_ac": "中央空调",
|
||||
"lookup.home_profile.window_ac": "窗式空调",
|
||||
"lookup.home_profile.evaporative": "蒸发式",
|
||||
"lookup.home_profile.none": "无",
|
||||
"lookup.home_profile.tank_gas": "储水式(燃气)",
|
||||
"lookup.home_profile.tank_electric": "储水式(电)",
|
||||
"lookup.home_profile.tankless_gas": "即热式(燃气)",
|
||||
"lookup.home_profile.tankless_electric": "即热式(电)",
|
||||
"lookup.home_profile.solar": "太阳能",
|
||||
"lookup.home_profile.asphalt_shingle": "沥青瓦",
|
||||
"lookup.home_profile.metal": "金属",
|
||||
"lookup.home_profile.tile": "瓦片",
|
||||
"lookup.home_profile.slate": "石板",
|
||||
"lookup.home_profile.wood_shake": "木瓦",
|
||||
"lookup.home_profile.flat": "平顶",
|
||||
"lookup.home_profile.brick": "砖",
|
||||
"lookup.home_profile.vinyl_siding": "乙烯基壁板",
|
||||
"lookup.home_profile.wood_siding": "木壁板",
|
||||
"lookup.home_profile.stucco": "灰泥",
|
||||
"lookup.home_profile.stone": "石材",
|
||||
"lookup.home_profile.fiber_cement": "纤维水泥",
|
||||
"lookup.home_profile.hardwood": "硬木",
|
||||
"lookup.home_profile.laminate": "复合地板",
|
||||
"lookup.home_profile.carpet": "地毯",
|
||||
"lookup.home_profile.vinyl": "乙烯基",
|
||||
"lookup.home_profile.concrete": "混凝土",
|
||||
"lookup.home_profile.lawn": "草坪",
|
||||
"lookup.home_profile.desert": "沙漠",
|
||||
"lookup.home_profile.xeriscape": "旱生园艺",
|
||||
"lookup.home_profile.garden": "花园",
|
||||
"lookup.home_profile.mixed": "混合",
|
||||
"lookup.document_type.warranty": "保修",
|
||||
"lookup.document_type.manual": "用户手册",
|
||||
"lookup.document_type.receipt": "收据/发票",
|
||||
"lookup.document_type.inspection": "检查报告",
|
||||
"lookup.document_type.permit": "许可证",
|
||||
"lookup.document_type.deed": "契据/产权",
|
||||
"lookup.document_type.insurance": "保险",
|
||||
"lookup.document_type.contract": "合同",
|
||||
"lookup.document_type.photo": "照片",
|
||||
"lookup.document_type.other": "其他",
|
||||
"lookup.document_category.appliance": "家电",
|
||||
"lookup.document_category.hvac": "暖通空调",
|
||||
"lookup.document_category.plumbing": "管道",
|
||||
"lookup.document_category.electrical": "电气",
|
||||
"lookup.document_category.roofing": "屋顶",
|
||||
"lookup.document_category.structural": "结构",
|
||||
"lookup.document_category.landscaping": "园艺",
|
||||
"lookup.document_category.general": "通用",
|
||||
"lookup.document_category.other": "其他"
|
||||
}
|
||||
|
||||
@@ -190,6 +190,27 @@ func shouldSkipSpecRoute(path string) bool {
|
||||
if strings.HasPrefix(path, "/uploads/") || strings.HasPrefix(path, "/media/") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Auth routes delegated to Ory Kratos (phase 2 auth refactor).
|
||||
// These endpoints are no longer served by the Go API; the spec is retained
|
||||
// as documentation of the Kratos-facing contract.
|
||||
kratosRoutes := map[string]bool{
|
||||
"/auth/login/": true,
|
||||
"/auth/register/": true,
|
||||
"/auth/logout/": true,
|
||||
"/auth/refresh/": true,
|
||||
"/auth/forgot-password/": true,
|
||||
"/auth/verify-reset-code/": true,
|
||||
"/auth/reset-password/": true,
|
||||
"/auth/verify-email/": true,
|
||||
"/auth/resend-verification/": true,
|
||||
"/auth/apple-sign-in/": true,
|
||||
"/auth/google-sign-in/": true,
|
||||
}
|
||||
if kratosRoutes[path] {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
"github.com/treytartt/honeydue-api/internal/handlers"
|
||||
"github.com/treytartt/honeydue-api/internal/middleware"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
"github.com/treytartt/honeydue-api/internal/repositories"
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
"github.com/treytartt/honeydue-api/internal/testutil"
|
||||
@@ -105,11 +108,40 @@ type TestApp struct {
|
||||
TaskRepo *repositories.TaskRepository
|
||||
ContractorRepo *repositories.ContractorRepository
|
||||
AuthService *services.AuthService
|
||||
// tokenStore maps fake token strings to users for the test-auth middleware.
|
||||
tokenStore map[string]*models.User
|
||||
tokenStoreMu sync.RWMutex
|
||||
}
|
||||
|
||||
// fakeAuthMiddleware replaces the real Kratos middleware in integration tests.
|
||||
// It looks up the "Authorization: Token <tok>" value in app.tokenStore.
|
||||
func (app *TestApp) fakeAuthMiddleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ah := c.Request().Header.Get("Authorization")
|
||||
if ah == "" {
|
||||
return apperrors.Unauthorized("error.not_authenticated")
|
||||
}
|
||||
tok := ah
|
||||
if len(ah) > 6 && ah[:6] == "Token " {
|
||||
tok = ah[6:]
|
||||
} else if len(ah) > 7 && ah[:7] == "Bearer " {
|
||||
tok = ah[7:]
|
||||
}
|
||||
app.tokenStoreMu.RLock()
|
||||
user, ok := app.tokenStore[tok]
|
||||
app.tokenStoreMu.RUnlock()
|
||||
if !ok {
|
||||
return apperrors.Unauthorized("error.not_authenticated")
|
||||
}
|
||||
c.Set("auth_user", user)
|
||||
c.Set("auth_token", tok)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupIntegrationTest(t *testing.T) *TestApp {
|
||||
// Echo does not need test mode
|
||||
|
||||
db := testutil.SetupTestDB(t)
|
||||
testutil.SeedLookupData(t, db)
|
||||
|
||||
@@ -123,9 +155,6 @@ func setupIntegrationTest(t *testing.T) *TestApp {
|
||||
cfg := &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
SecretKey: "test-secret-key-for-integration-tests",
|
||||
PasswordResetExpiry: 15 * time.Minute,
|
||||
ConfirmationExpiry: 24 * time.Hour,
|
||||
MaxPasswordResetRate: 3,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -141,28 +170,33 @@ func setupIntegrationTest(t *testing.T) *TestApp {
|
||||
taskHandler := handlers.NewTaskHandler(taskService, nil)
|
||||
contractorHandler := handlers.NewContractorHandler(contractorService)
|
||||
|
||||
// Create router with real middleware
|
||||
e := echo.New()
|
||||
app := &TestApp{
|
||||
DB: db,
|
||||
Router: echo.New(),
|
||||
AuthHandler: authHandler,
|
||||
ResidenceHandler: residenceHandler,
|
||||
TaskHandler: taskHandler,
|
||||
ContractorHandler: contractorHandler,
|
||||
UserRepo: userRepo,
|
||||
ResidenceRepo: residenceRepo,
|
||||
TaskRepo: taskRepo,
|
||||
ContractorRepo: contractorRepo,
|
||||
AuthService: authService,
|
||||
tokenStore: make(map[string]*models.User),
|
||||
}
|
||||
|
||||
e := app.Router
|
||||
e.Validator = validator.NewCustomValidator()
|
||||
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
|
||||
|
||||
// Add timezone middleware globally so X-Timezone header is processed
|
||||
// Timezone middleware processes X-Timezone header
|
||||
e.Use(middleware.TimezoneMiddleware())
|
||||
|
||||
// Public routes
|
||||
auth := e.Group("/api/auth")
|
||||
{
|
||||
auth.POST("/register", authHandler.Register)
|
||||
auth.POST("/login", authHandler.Login)
|
||||
}
|
||||
|
||||
// Protected routes - use AuthMiddleware without Redis cache for testing
|
||||
authMiddleware := middleware.NewAuthMiddleware(db, nil)
|
||||
// Protected routes — guarded by the fake token middleware
|
||||
api := e.Group("/api")
|
||||
api.Use(authMiddleware.TokenAuth())
|
||||
api.Use(app.fakeAuthMiddleware())
|
||||
{
|
||||
api.GET("/auth/me", authHandler.CurrentUser)
|
||||
api.POST("/auth/logout", authHandler.Logout)
|
||||
|
||||
residences := api.Group("/residences")
|
||||
{
|
||||
@@ -216,19 +250,7 @@ func setupIntegrationTest(t *testing.T) *TestApp {
|
||||
api.GET("/contractors/by-residence/:residence_id", contractorHandler.ListContractorsByResidence)
|
||||
}
|
||||
|
||||
return &TestApp{
|
||||
DB: db,
|
||||
Router: e,
|
||||
AuthHandler: authHandler,
|
||||
ResidenceHandler: residenceHandler,
|
||||
TaskHandler: taskHandler,
|
||||
ContractorHandler: contractorHandler,
|
||||
UserRepo: userRepo,
|
||||
ResidenceRepo: residenceRepo,
|
||||
TaskRepo: taskRepo,
|
||||
ContractorRepo: contractorRepo,
|
||||
AuthService: authService,
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// Helper to make authenticated requests
|
||||
@@ -251,156 +273,16 @@ func (app *TestApp) makeAuthenticatedRequest(t *testing.T, method, path string,
|
||||
return w
|
||||
}
|
||||
|
||||
// Helper to register and login a user, returns token
|
||||
func (app *TestApp) registerAndLogin(t *testing.T, username, email, password string) string {
|
||||
// Register
|
||||
registerBody := map[string]string{
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
}
|
||||
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
// Login
|
||||
loginBody := map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var loginResp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &loginResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
return loginResp["token"].(string)
|
||||
}
|
||||
|
||||
// ============ Authentication Flow Tests ============
|
||||
|
||||
func TestIntegration_AuthenticationFlow(t *testing.T) {
|
||||
app := setupIntegrationTest(t)
|
||||
|
||||
// 1. Register a new user
|
||||
registerBody := map[string]string{
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
var registerResp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), ®isterResp)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, registerResp["token"])
|
||||
assert.NotNil(t, registerResp["user"])
|
||||
|
||||
// 2. Login with the same credentials
|
||||
loginBody := map[string]string{
|
||||
"username": "testuser",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var loginResp map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &loginResp)
|
||||
require.NoError(t, err)
|
||||
token := loginResp["token"].(string)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
// 3. Get current user with token
|
||||
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, token)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var meResp map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &meResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "testuser", meResp["username"])
|
||||
assert.Equal(t, "test@example.com", meResp["email"])
|
||||
|
||||
// 4. Access protected route without token should fail
|
||||
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, "")
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
// 5. Access protected route with invalid token should fail
|
||||
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, "invalid-token")
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
// 6. Logout
|
||||
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/logout", nil, token)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestIntegration_RegistrationValidation(t *testing.T) {
|
||||
app := setupIntegrationTest(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]string
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "missing username",
|
||||
body: map[string]string{"email": "test@example.com", "password": "pass123"},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "missing email",
|
||||
body: map[string]string{"username": "testuser", "password": "pass123"},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "missing password",
|
||||
body: map[string]string{"username": "testuser", "email": "test@example.com"},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid email",
|
||||
body: map[string]string{"username": "testuser", "email": "invalid", "password": "pass123"},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", tt.body, "")
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_DuplicateRegistration(t *testing.T) {
|
||||
app := setupIntegrationTest(t)
|
||||
|
||||
// Register first user (password must be >= 8 chars)
|
||||
registerBody := map[string]string{
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "Password123",
|
||||
}
|
||||
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
// Try to register with same username - returns 409 (Conflict)
|
||||
registerBody2 := map[string]string{
|
||||
"username": "testuser",
|
||||
"email": "different@example.com",
|
||||
"password": "Password123",
|
||||
}
|
||||
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody2, "")
|
||||
assert.Equal(t, http.StatusConflict, w.Code)
|
||||
|
||||
// Try to register with same email - returns 409 (Conflict)
|
||||
registerBody3 := map[string]string{
|
||||
"username": "differentuser",
|
||||
"email": "test@example.com",
|
||||
"password": "Password123",
|
||||
}
|
||||
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody3, "")
|
||||
assert.Equal(t, http.StatusConflict, w.Code)
|
||||
// registerAndLogin creates a user directly in the DB and returns a synthetic token
|
||||
// that the fake auth middleware will accept. No HTTP register/login endpoints are called.
|
||||
func (app *TestApp) registerAndLogin(t *testing.T, username, email, _ string) string {
|
||||
t.Helper()
|
||||
user := testutil.CreateTestUser(t, app.DB, username, email, "")
|
||||
tok := uuid.NewString()
|
||||
app.tokenStoreMu.Lock()
|
||||
app.tokenStore[tok] = user
|
||||
app.tokenStoreMu.Unlock()
|
||||
return tok
|
||||
}
|
||||
|
||||
// ============ Residence Flow Tests ============
|
||||
@@ -827,48 +709,16 @@ func TestIntegration_ResponseStructure(t *testing.T) {
|
||||
func TestIntegration_ComprehensiveE2E(t *testing.T) {
|
||||
app := setupIntegrationTest(t)
|
||||
|
||||
// ============ Phase 1: Authentication ============
|
||||
t.Log("Phase 1: Testing authentication flow")
|
||||
// ============ Phase 1: User Setup ============
|
||||
t.Log("Phase 1: Setting up test user")
|
||||
|
||||
// Register new user
|
||||
registerBody := map[string]string{
|
||||
"username": "e2e_testuser",
|
||||
"email": "e2e@example.com",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
|
||||
require.Equal(t, http.StatusCreated, w.Code, "Registration should succeed")
|
||||
|
||||
var registerResp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), ®isterResp)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, registerResp["token"], "Registration should return token")
|
||||
assert.NotNil(t, registerResp["user"], "Registration should return user")
|
||||
|
||||
// Verify login with same credentials
|
||||
loginBody := map[string]string{
|
||||
"username": "e2e_testuser",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
|
||||
require.Equal(t, http.StatusOK, w.Code, "Login should succeed")
|
||||
|
||||
var loginResp map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &loginResp)
|
||||
require.NoError(t, err)
|
||||
token := loginResp["token"].(string)
|
||||
assert.NotEmpty(t, token, "Login should return token")
|
||||
token := app.registerAndLogin(t, "e2e_testuser", "e2e@example.com", "")
|
||||
|
||||
// Verify authenticated access
|
||||
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, token)
|
||||
w := app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, token)
|
||||
require.Equal(t, http.StatusOK, w.Code, "Should access protected route with valid token")
|
||||
|
||||
var meResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &meResp)
|
||||
assert.Equal(t, "e2e_testuser", meResp["username"])
|
||||
assert.Equal(t, "e2e@example.com", meResp["email"])
|
||||
|
||||
t.Log("✓ Authentication flow verified")
|
||||
t.Log("✓ User setup verified")
|
||||
|
||||
// ============ Phase 2: Create 5 Residences ============
|
||||
t.Log("Phase 2: Creating 5 residences")
|
||||
@@ -1244,29 +1094,9 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) {
|
||||
t.Logf("✓ All %d visible tasks verified in correct columns by ID", expectedVisibleTasks)
|
||||
|
||||
// ============ Phase 9: Create User B ============
|
||||
t.Log("Phase 9: Creating User B and verifying login")
|
||||
t.Log("Phase 9: Creating User B")
|
||||
|
||||
// Register User B
|
||||
registerBodyB := map[string]string{
|
||||
"username": "e2e_userb",
|
||||
"email": "e2e_userb@example.com",
|
||||
"password": "SecurePass456!",
|
||||
}
|
||||
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBodyB, "")
|
||||
require.Equal(t, http.StatusCreated, w.Code, "User B registration should succeed")
|
||||
|
||||
// Login as User B
|
||||
loginBodyB := map[string]string{
|
||||
"username": "e2e_userb",
|
||||
"password": "SecurePass456!",
|
||||
}
|
||||
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBodyB, "")
|
||||
require.Equal(t, http.StatusOK, w.Code, "User B login should succeed")
|
||||
|
||||
var loginRespB map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &loginRespB)
|
||||
tokenB := loginRespB["token"].(string)
|
||||
assert.NotEmpty(t, tokenB, "User B should have a token")
|
||||
tokenB := app.registerAndLogin(t, "e2e_userb", "e2e_userb@example.com", "")
|
||||
|
||||
// Verify User B can access their own profile
|
||||
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, tokenB)
|
||||
@@ -1592,8 +1422,6 @@ func formatID(id float64) string {
|
||||
|
||||
// setupContractorTest sets up a test environment including contractor routes
|
||||
func setupContractorTest(t *testing.T) *TestApp {
|
||||
// Echo does not need test mode
|
||||
|
||||
db := testutil.SetupTestDB(t)
|
||||
testutil.SeedLookupData(t, db)
|
||||
|
||||
@@ -1607,9 +1435,6 @@ func setupContractorTest(t *testing.T) *TestApp {
|
||||
cfg := &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
SecretKey: "test-secret-key-for-integration-tests",
|
||||
PasswordResetExpiry: 15 * time.Minute,
|
||||
ConfirmationExpiry: 24 * time.Hour,
|
||||
MaxPasswordResetRate: 3,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1625,29 +1450,32 @@ func setupContractorTest(t *testing.T) *TestApp {
|
||||
taskHandler := handlers.NewTaskHandler(taskService, nil)
|
||||
contractorHandler := handlers.NewContractorHandler(contractorService)
|
||||
|
||||
// Create router with real middleware
|
||||
e := echo.New()
|
||||
app := &TestApp{
|
||||
DB: db,
|
||||
Router: echo.New(),
|
||||
AuthHandler: authHandler,
|
||||
ResidenceHandler: residenceHandler,
|
||||
TaskHandler: taskHandler,
|
||||
ContractorHandler: contractorHandler,
|
||||
UserRepo: userRepo,
|
||||
ResidenceRepo: residenceRepo,
|
||||
TaskRepo: taskRepo,
|
||||
ContractorRepo: contractorRepo,
|
||||
AuthService: authService,
|
||||
tokenStore: make(map[string]*models.User),
|
||||
}
|
||||
|
||||
e := app.Router
|
||||
e.Validator = validator.NewCustomValidator()
|
||||
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
|
||||
|
||||
// Add timezone middleware globally so X-Timezone header is processed
|
||||
// Timezone middleware
|
||||
e.Use(middleware.TimezoneMiddleware())
|
||||
|
||||
// Public routes
|
||||
auth := e.Group("/api/auth")
|
||||
{
|
||||
auth.POST("/register", authHandler.Register)
|
||||
auth.POST("/login", authHandler.Login)
|
||||
}
|
||||
|
||||
// Protected routes
|
||||
authMiddleware := middleware.NewAuthMiddleware(db, nil)
|
||||
api := e.Group("/api")
|
||||
api.Use(authMiddleware.TokenAuth())
|
||||
api.Use(app.fakeAuthMiddleware())
|
||||
{
|
||||
api.GET("/auth/me", authHandler.CurrentUser)
|
||||
api.POST("/auth/logout", authHandler.Logout)
|
||||
|
||||
residences := api.Group("/residences")
|
||||
{
|
||||
residences.GET("", residenceHandler.ListResidences)
|
||||
@@ -1680,19 +1508,7 @@ func setupContractorTest(t *testing.T) *TestApp {
|
||||
}
|
||||
}
|
||||
|
||||
return &TestApp{
|
||||
DB: db,
|
||||
Router: e,
|
||||
AuthHandler: authHandler,
|
||||
ResidenceHandler: residenceHandler,
|
||||
TaskHandler: taskHandler,
|
||||
ContractorHandler: contractorHandler,
|
||||
UserRepo: userRepo,
|
||||
ResidenceRepo: residenceRepo,
|
||||
TaskRepo: taskRepo,
|
||||
ContractorRepo: contractorRepo,
|
||||
AuthService: authService,
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// ============ Test 1: Recurring Task Lifecycle ============
|
||||
@@ -2045,12 +1861,12 @@ func TestIntegration_MultiUserSharing(t *testing.T) {
|
||||
// Phase 9: Remove User B from residence 3
|
||||
t.Log("Phase 9: Remove User B from residence 3")
|
||||
|
||||
// Get User B's ID
|
||||
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, tokenB)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var userBInfo map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &userBInfo)
|
||||
userBID := uint(userBInfo["id"].(float64))
|
||||
// Get User B's ID from the token store
|
||||
app.tokenStoreMu.RLock()
|
||||
userBModel := app.tokenStore[tokenB]
|
||||
app.tokenStoreMu.RUnlock()
|
||||
require.NotNil(t, userBModel, "User B should be in token store")
|
||||
userBID := userBModel.ID
|
||||
|
||||
// Remove User B from residence 3
|
||||
w = app.makeAuthenticatedRequest(t, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d", residenceIDs[2], userBID), nil, tokenA)
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -35,6 +37,48 @@ type SecurityTestApp struct {
|
||||
Router *echo.Echo
|
||||
SubscriptionService *services.SubscriptionService
|
||||
SubscriptionRepo *repositories.SubscriptionRepository
|
||||
tokenStore map[string]*models.User
|
||||
tokenStoreMu sync.RWMutex
|
||||
}
|
||||
|
||||
// fakeAuthMiddleware returns an Echo middleware that authenticates requests using
|
||||
// the in-process tokenStore instead of calling the real Kratos session endpoint.
|
||||
func (app *SecurityTestApp) fakeAuthMiddleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ah := c.Request().Header.Get("Authorization")
|
||||
if ah == "" {
|
||||
return apperrors.Unauthorized("error.not_authenticated")
|
||||
}
|
||||
tok := ah
|
||||
if len(ah) > 6 && ah[:6] == "Token " {
|
||||
tok = ah[6:]
|
||||
} else if len(ah) > 7 && ah[:7] == "Bearer " {
|
||||
tok = ah[7:]
|
||||
}
|
||||
app.tokenStoreMu.RLock()
|
||||
user, ok := app.tokenStore[tok]
|
||||
app.tokenStoreMu.RUnlock()
|
||||
if !ok {
|
||||
return apperrors.Unauthorized("error.not_authenticated")
|
||||
}
|
||||
c.Set("auth_user", user)
|
||||
c.Set("auth_token", tok)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerAndLoginSec creates a user directly in the DB and returns a fake token
|
||||
// that the fakeAuthMiddleware will accept. No HTTP register/login calls are made.
|
||||
func (app *SecurityTestApp) registerAndLoginSec(t *testing.T, username, email, _ string) (string, uint) {
|
||||
t.Helper()
|
||||
user := testutil.CreateTestUser(t, app.DB, username, email, "")
|
||||
tok := uuid.NewString()
|
||||
app.tokenStoreMu.Lock()
|
||||
app.tokenStore[tok] = user
|
||||
app.tokenStoreMu.Unlock()
|
||||
return tok, user.ID
|
||||
}
|
||||
|
||||
func setupSecurityTest(t *testing.T) *SecurityTestApp {
|
||||
@@ -78,27 +122,25 @@ func setupSecurityTest(t *testing.T) *SecurityTestApp {
|
||||
notificationHandler := handlers.NewNotificationHandler(notificationService)
|
||||
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, nil)
|
||||
|
||||
// Create router with real middleware
|
||||
app := &SecurityTestApp{
|
||||
DB: db,
|
||||
SubscriptionService: subscriptionService,
|
||||
SubscriptionRepo: subscriptionRepo,
|
||||
tokenStore: make(map[string]*models.User),
|
||||
}
|
||||
|
||||
// Create router with fake auth middleware
|
||||
e := echo.New()
|
||||
e.Validator = validator.NewCustomValidator()
|
||||
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
|
||||
|
||||
e.Use(middleware.TimezoneMiddleware())
|
||||
|
||||
// Public routes
|
||||
auth := e.Group("/api/auth")
|
||||
{
|
||||
auth.POST("/register", authHandler.Register)
|
||||
auth.POST("/login", authHandler.Login)
|
||||
}
|
||||
|
||||
// Protected routes
|
||||
authMiddleware := middleware.NewAuthMiddleware(db, nil)
|
||||
api := e.Group("/api")
|
||||
api.Use(authMiddleware.TokenAuth())
|
||||
api.Use(app.fakeAuthMiddleware())
|
||||
{
|
||||
api.GET("/auth/me", authHandler.CurrentUser)
|
||||
api.POST("/auth/logout", authHandler.Logout)
|
||||
|
||||
residences := api.Group("/residences")
|
||||
{
|
||||
@@ -146,42 +188,8 @@ func setupSecurityTest(t *testing.T) *SecurityTestApp {
|
||||
}
|
||||
}
|
||||
|
||||
return &SecurityTestApp{
|
||||
DB: db,
|
||||
Router: e,
|
||||
SubscriptionService: subscriptionService,
|
||||
SubscriptionRepo: subscriptionRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// registerAndLoginSec registers and logs in a user, returns token and user ID.
|
||||
func (app *SecurityTestApp) registerAndLoginSec(t *testing.T, username, email, password string) (string, uint) {
|
||||
// Register
|
||||
registerBody := map[string]string{
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
}
|
||||
w := app.makeAuthReq(t, "POST", "/api/auth/register", registerBody, "")
|
||||
require.Equal(t, http.StatusCreated, w.Code, "Registration should succeed for %s", username)
|
||||
|
||||
// Login
|
||||
loginBody := map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
w = app.makeAuthReq(t, "POST", "/api/auth/login", loginBody, "")
|
||||
require.Equal(t, http.StatusOK, w.Code, "Login should succeed for %s", username)
|
||||
|
||||
var loginResp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &loginResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
token := loginResp["token"].(string)
|
||||
userMap := loginResp["user"].(map[string]interface{})
|
||||
userID := uint(userMap["id"].(float64))
|
||||
|
||||
return token, userID
|
||||
app.Router = e
|
||||
return app
|
||||
}
|
||||
|
||||
// makeAuthReq creates and sends an HTTP request through the router.
|
||||
|
||||
@@ -6,12 +6,15 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/apperrors"
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
@@ -22,7 +25,6 @@ import (
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
"github.com/treytartt/honeydue-api/internal/testutil"
|
||||
"github.com/treytartt/honeydue-api/internal/validator"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SubscriptionTestApp holds components for subscription integration testing
|
||||
@@ -31,11 +33,51 @@ type SubscriptionTestApp struct {
|
||||
Router *echo.Echo
|
||||
SubscriptionService *services.SubscriptionService
|
||||
SubscriptionRepo *repositories.SubscriptionRepository
|
||||
tokenStore map[string]*models.User
|
||||
tokenStoreMu sync.RWMutex
|
||||
}
|
||||
|
||||
// fakeAuthMiddleware returns an Echo middleware that authenticates requests using
|
||||
// the in-process tokenStore instead of calling the real Kratos session endpoint.
|
||||
func (app *SubscriptionTestApp) fakeAuthMiddleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ah := c.Request().Header.Get("Authorization")
|
||||
if ah == "" {
|
||||
return apperrors.Unauthorized("error.not_authenticated")
|
||||
}
|
||||
tok := ah
|
||||
if len(ah) > 6 && ah[:6] == "Token " {
|
||||
tok = ah[6:]
|
||||
} else if len(ah) > 7 && ah[:7] == "Bearer " {
|
||||
tok = ah[7:]
|
||||
}
|
||||
app.tokenStoreMu.RLock()
|
||||
user, ok := app.tokenStore[tok]
|
||||
app.tokenStoreMu.RUnlock()
|
||||
if !ok {
|
||||
return apperrors.Unauthorized("error.not_authenticated")
|
||||
}
|
||||
c.Set("auth_user", user)
|
||||
c.Set("auth_token", tok)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerAndLogin creates a user directly in the DB and returns a fake token
|
||||
// and user ID. No HTTP register/login calls are made.
|
||||
func (app *SubscriptionTestApp) registerAndLogin(t *testing.T, username, email, _ string) (string, uint) {
|
||||
t.Helper()
|
||||
user := testutil.CreateTestUser(t, app.DB, username, email, "")
|
||||
tok := uuid.NewString()
|
||||
app.tokenStoreMu.Lock()
|
||||
app.tokenStore[tok] = user
|
||||
app.tokenStoreMu.Unlock()
|
||||
return tok, user.ID
|
||||
}
|
||||
|
||||
func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
|
||||
// Echo does not need test mode
|
||||
|
||||
db := testutil.SetupTestDB(t)
|
||||
testutil.SeedLookupData(t, db)
|
||||
|
||||
@@ -67,22 +109,23 @@ func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
|
||||
residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil, true)
|
||||
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, nil)
|
||||
|
||||
// Create router
|
||||
app := &SubscriptionTestApp{
|
||||
DB: db,
|
||||
SubscriptionService: subscriptionService,
|
||||
SubscriptionRepo: subscriptionRepo,
|
||||
tokenStore: make(map[string]*models.User),
|
||||
}
|
||||
|
||||
// Create router with fake auth middleware
|
||||
e := echo.New()
|
||||
e.Validator = validator.NewCustomValidator()
|
||||
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
|
||||
|
||||
// Public routes
|
||||
auth := e.Group("/api/auth")
|
||||
{
|
||||
auth.POST("/register", authHandler.Register)
|
||||
auth.POST("/login", authHandler.Login)
|
||||
}
|
||||
e.Use(middleware.TimezoneMiddleware())
|
||||
|
||||
// Protected routes
|
||||
authMiddleware := middleware.NewAuthMiddleware(db, nil)
|
||||
api := e.Group("/api")
|
||||
api.Use(authMiddleware.TokenAuth())
|
||||
api.Use(app.fakeAuthMiddleware())
|
||||
{
|
||||
api.GET("/auth/me", authHandler.CurrentUser)
|
||||
|
||||
@@ -98,12 +141,8 @@ func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
|
||||
}
|
||||
}
|
||||
|
||||
return &SubscriptionTestApp{
|
||||
DB: db,
|
||||
Router: e,
|
||||
SubscriptionService: subscriptionService,
|
||||
SubscriptionRepo: subscriptionRepo,
|
||||
}
|
||||
app.Router = e
|
||||
return app
|
||||
}
|
||||
|
||||
// Helper to make authenticated requests
|
||||
@@ -129,39 +168,14 @@ func (app *SubscriptionTestApp) makeAuthenticatedRequest(t *testing.T, method, p
|
||||
return w
|
||||
}
|
||||
|
||||
// Helper to register and login a user, returns token and user ID
|
||||
func (app *SubscriptionTestApp) registerAndLogin(t *testing.T, username, email, password string) (string, uint) {
|
||||
// Register
|
||||
registerBody := map[string]string{
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
}
|
||||
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
// Login
|
||||
loginBody := map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var loginResp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &loginResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
token := loginResp["token"].(string)
|
||||
userMap := loginResp["user"].(map[string]interface{})
|
||||
userID := uint(userMap["id"].(float64))
|
||||
|
||||
return token, userID
|
||||
}
|
||||
|
||||
// TestIntegration_IsFreeBypassesLimitations tests that users with IsFree=true
|
||||
// see limitations_enabled=false regardless of global settings
|
||||
func TestIntegration_IsFreeBypassesLimitations(t *testing.T) {
|
||||
// TEMPORARILY DISABLED — Subscriptions: GetSubscriptionStatus now returns
|
||||
// a limitations_enabled=false stub for everyone, so the assertion that a
|
||||
// normal user sees limitations_enabled=true (per EnableLimitations setting)
|
||||
// no longer holds. Remove this skip when GetSubscriptionStatus is restored.
|
||||
t.Skip("subscription feature disabled — see subscription_service.go TEMPORARILY DISABLED")
|
||||
app := setupSubscriptionTest(t)
|
||||
|
||||
// Register and login a user
|
||||
@@ -280,6 +294,10 @@ func TestIntegration_IsFreeBypassesCheckLimit(t *testing.T) {
|
||||
// TestIntegration_IsFreeIndependentOfTier tests that IsFree works regardless of
|
||||
// the user's subscription tier
|
||||
func TestIntegration_IsFreeIndependentOfTier(t *testing.T) {
|
||||
// TEMPORARILY DISABLED — Subscriptions: GetSubscriptionStatus is stubbed,
|
||||
// so the Pro+!IsFree case (which would normally return limitations_enabled=true)
|
||||
// is no longer reachable. Remove this skip when the feature is restored.
|
||||
t.Skip("subscription feature disabled — see subscription_service.go TEMPORARILY DISABLED")
|
||||
app := setupSubscriptionTest(t)
|
||||
|
||||
// Register and login a user
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// Package kratos is a thin client for the Ory Kratos APIs. honeyDue
|
||||
// delegates all identity concerns (credentials, sessions, verification,
|
||||
// recovery, social sign-in) to Kratos; this client validates sessions
|
||||
// against the public API and deletes identities via the admin API.
|
||||
package kratos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrUnauthorized is returned by Whoami when the session is missing, invalid,
|
||||
// or inactive — the caller should respond 401.
|
||||
var ErrUnauthorized = errors.New("kratos: session invalid or inactive")
|
||||
|
||||
// ErrIdentityExists is returned by CreateIdentity when an identity with the
|
||||
// same credential identifier (email) already exists — caller should respond 409.
|
||||
var ErrIdentityExists = errors.New("kratos: identity already exists")
|
||||
|
||||
// ErrInvalidCredentials is returned by CreateIdentity when Kratos rejects the
|
||||
// password against its policy (too short, breached, etc.) — caller responds 400.
|
||||
type ErrInvalidCredentials struct{ Reason string }
|
||||
|
||||
func (e *ErrInvalidCredentials) Error() string { return "kratos: " + e.Reason }
|
||||
|
||||
// Client talks to the Ory Kratos public and admin APIs.
|
||||
type Client struct {
|
||||
publicURL string
|
||||
adminURL string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient builds a Kratos client. publicURL is the public-API base used for
|
||||
// session validation (e.g. http://kratos:4433 in-cluster); adminURL is the
|
||||
// admin-API base used for identity management (e.g. http://kratos:4434).
|
||||
// Either may be empty when the corresponding API is unused.
|
||||
func NewClient(publicURL, adminURL string) *Client {
|
||||
return &Client{
|
||||
publicURL: strings.TrimRight(publicURL, "/"),
|
||||
adminURL: strings.TrimRight(adminURL, "/"),
|
||||
http: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Identity is the subset of a Kratos identity honeyDue consumes. It mirrors
|
||||
// the identity schema in deploy-k3s/manifests/kratos/configmap.yaml.
|
||||
type Identity struct {
|
||||
ID string `json:"id"` // UUID — the stable identity identifier
|
||||
Traits struct {
|
||||
Email string `json:"email"`
|
||||
Name struct {
|
||||
First string `json:"first"`
|
||||
Last string `json:"last"`
|
||||
} `json:"name"`
|
||||
} `json:"traits"`
|
||||
VerifiableAddresses []struct {
|
||||
Value string `json:"value"`
|
||||
Verified bool `json:"verified"`
|
||||
} `json:"verifiable_addresses"`
|
||||
}
|
||||
|
||||
// Session is a Kratos session as returned by GET /sessions/whoami.
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
Active bool `json:"active"`
|
||||
Identity Identity `json:"identity"`
|
||||
}
|
||||
|
||||
// EmailVerified reports whether any of the identity's email addresses is
|
||||
// verified — the source of truth for honeyDue's RequireVerified gate.
|
||||
func (s *Session) EmailVerified() bool {
|
||||
for _, a := range s.Identity.VerifiableAddresses {
|
||||
if a.Verified {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Whoami validates a session against Kratos. Supply the mobile session token
|
||||
// (sessionToken) OR the browser cookie header (cookie) — whichever is
|
||||
// non-empty is forwarded to Kratos. Returns ErrUnauthorized for an invalid or
|
||||
// inactive session.
|
||||
func (c *Client) Whoami(ctx context.Context, sessionToken, cookie string) (*Session, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.publicURL+"/sessions/whoami", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sessionToken != "" {
|
||||
req.Header.Set("X-Session-Token", sessionToken)
|
||||
}
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kratos whoami: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized, resp.StatusCode == http.StatusForbidden:
|
||||
return nil, ErrUnauthorized
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
return nil, fmt.Errorf("kratos whoami: unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var s Session
|
||||
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
|
||||
return nil, fmt.Errorf("kratos whoami: decode: %w", err)
|
||||
}
|
||||
if !s.Active || s.Identity.ID == "" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// CreateIdentity admin-creates a Kratos identity with a password credential and
|
||||
// an UNVERIFIED email, returning the created identity (with its UUID).
|
||||
//
|
||||
// Why admin-create instead of the self-service registration flow: self-service
|
||||
// registration always auto-emails a verification code to a flow whose id Kratos
|
||||
// never returns to the client (verified 2026-06-03 — true with and without the
|
||||
// session after-hook), so the client can never submit the user's code to the
|
||||
// right flow. Admin creation runs no registration hooks and sends no email, so
|
||||
// honeyDue drives verification explicitly afterward: the client starts its own
|
||||
// verification flow (the single code) and the user enters it. The new identity
|
||||
// is fully usable immediately — the client logs in right after to get a session
|
||||
// (Kratos permits login for unverified identities; app access is gated on the
|
||||
// verified flag, not on Kratos login).
|
||||
//
|
||||
// password is sent in cleartext over the in-cluster admin API (never exposed
|
||||
// publicly); Kratos hashes it with the configured bcrypt cost and applies the
|
||||
// password policy, returning 400 on a weak/breached password and 409 on a
|
||||
// duplicate email.
|
||||
func (c *Client) CreateIdentity(ctx context.Context, email, firstName, lastName, password string) (*Identity, error) {
|
||||
if c.adminURL == "" {
|
||||
return nil, errors.New("kratos: admin URL not configured")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"schema_id": "honeydue",
|
||||
"traits": map[string]any{
|
||||
"email": email,
|
||||
"name": map[string]any{"first": firstName, "last": lastName},
|
||||
},
|
||||
"credentials": map[string]any{
|
||||
"password": map[string]any{
|
||||
"config": map[string]any{"password": password},
|
||||
},
|
||||
},
|
||||
}
|
||||
buf, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.adminURL+"/admin/identities", bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kratos create identity: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusCreated, http.StatusOK:
|
||||
var id Identity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&id); err != nil {
|
||||
return nil, fmt.Errorf("kratos create identity: decode: %w", err)
|
||||
}
|
||||
return &id, nil
|
||||
case http.StatusConflict:
|
||||
return nil, ErrIdentityExists
|
||||
case http.StatusBadRequest:
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &ErrInvalidCredentials{Reason: kratosReason(body)}
|
||||
default:
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("kratos create identity: unexpected status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// kratosReason pulls a human-readable reason out of a Kratos error body
|
||||
// ({"error":{"reason":"...","message":"..."}}), falling back to the raw body.
|
||||
func kratosReason(body []byte) string {
|
||||
var env struct {
|
||||
Error struct {
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &env) == nil {
|
||||
if env.Error.Reason != "" {
|
||||
return env.Error.Reason
|
||||
}
|
||||
if env.Error.Message != "" {
|
||||
return env.Error.Message
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
// DeleteIdentity permanently removes a Kratos identity by its UUID via the
|
||||
// admin API (DELETE /admin/identities/{id}). A 404 is treated as success —
|
||||
// the identity is already gone, which is the desired end state, so the call
|
||||
// is idempotent across retries. Called when a honeyDue account is deleted so
|
||||
// no orphaned, still-loginable identity is left behind.
|
||||
func (c *Client) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||
if c.adminURL == "" {
|
||||
return errors.New("kratos: admin URL not configured")
|
||||
}
|
||||
if identityID == "" {
|
||||
return errors.New("kratos: empty identity id")
|
||||
}
|
||||
endpoint := c.adminURL + "/admin/identities/" + url.PathEscape(identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("kratos delete identity: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusNoContent, http.StatusNotFound:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("kratos delete identity: unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/apperrors"
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
// AuthUserKey is the key used to store the authenticated user in the context
|
||||
AuthUserKey = "auth_user"
|
||||
// AuthTokenKey is the key used to store the token in the context
|
||||
AuthTokenKey = "auth_token"
|
||||
// TokenCacheTTL is the duration to cache tokens in Redis. Tokens are
|
||||
// valid for DefaultTokenExpiryDays (90), and explicit logout invalidates
|
||||
// the cache, so a long TTL here just means most authed requests skip the
|
||||
// auth-token SQL query entirely.
|
||||
TokenCacheTTL = 1 * time.Hour
|
||||
// TokenCachePrefix is the prefix for token cache keys
|
||||
TokenCachePrefix = "auth_token_"
|
||||
// UserCacheTTL is how long full user records are cached in memory to
|
||||
// avoid hitting the database on every authenticated request. Bumped from
|
||||
// 30s — at 30s the trace showed a SELECT auth_user query on most warm
|
||||
// requests because users aren't in cache long enough to hit twice.
|
||||
UserCacheTTL = 5 * time.Minute
|
||||
// UserCacheMaxSize bounds the per-pod in-memory user cache. With ~1KB
|
||||
// per User struct, 5000 entries = ~5MB per pod. Older entries are
|
||||
// evicted LRU before the limit is exceeded.
|
||||
UserCacheMaxSize = 5000
|
||||
|
||||
// DefaultTokenExpiryDays is the default number of days before a token expires.
|
||||
DefaultTokenExpiryDays = 90
|
||||
)
|
||||
|
||||
// AuthMiddleware provides token authentication middleware
|
||||
type AuthMiddleware struct {
|
||||
db *gorm.DB
|
||||
cache *services.CacheService
|
||||
userCache *UserCache
|
||||
tokenExpiryDays int
|
||||
}
|
||||
|
||||
// NewAuthMiddleware creates a new auth middleware instance
|
||||
func NewAuthMiddleware(db *gorm.DB, cache *services.CacheService) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
db: db,
|
||||
cache: cache,
|
||||
userCache: NewUserCache(UserCacheTTL, UserCacheMaxSize),
|
||||
tokenExpiryDays: DefaultTokenExpiryDays,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAuthMiddlewareWithConfig creates a new auth middleware instance with configuration
|
||||
func NewAuthMiddlewareWithConfig(db *gorm.DB, cache *services.CacheService, cfg *config.Config) *AuthMiddleware {
|
||||
expiryDays := DefaultTokenExpiryDays
|
||||
if cfg != nil && cfg.Security.TokenExpiryDays > 0 {
|
||||
expiryDays = cfg.Security.TokenExpiryDays
|
||||
}
|
||||
return &AuthMiddleware{
|
||||
db: db,
|
||||
cache: cache,
|
||||
userCache: NewUserCache(UserCacheTTL, UserCacheMaxSize),
|
||||
tokenExpiryDays: expiryDays,
|
||||
}
|
||||
}
|
||||
|
||||
// TokenExpiryDuration returns the token expiry duration.
|
||||
func (m *AuthMiddleware) TokenExpiryDuration() time.Duration {
|
||||
return time.Duration(m.tokenExpiryDays) * 24 * time.Hour
|
||||
}
|
||||
|
||||
// isTokenExpired checks if a token's created timestamp indicates expiry.
|
||||
func (m *AuthMiddleware) isTokenExpired(created time.Time) bool {
|
||||
if created.IsZero() {
|
||||
return false // Legacy tokens without created time are not expired
|
||||
}
|
||||
return time.Since(created) > m.TokenExpiryDuration()
|
||||
}
|
||||
|
||||
// TokenAuth returns an Echo middleware that validates token authentication
|
||||
func (m *AuthMiddleware) TokenAuth() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Extract token from Authorization header
|
||||
token, err := extractToken(c)
|
||||
if err != nil {
|
||||
return apperrors.Unauthorized("error.not_authenticated")
|
||||
}
|
||||
|
||||
// Try to get user from cache first (includes expiry check)
|
||||
user, err := m.getUserFromCache(c.Request().Context(), token)
|
||||
if err == nil && user != nil {
|
||||
// Cache hit - set user in context and continue
|
||||
c.Set(AuthUserKey, user)
|
||||
c.Set(AuthTokenKey, token)
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Check if the cache indicated token expiry
|
||||
if err != nil && err.Error() == "token expired" {
|
||||
return apperrors.Unauthorized("error.token_expired")
|
||||
}
|
||||
|
||||
// Cache miss - look up token in database
|
||||
user, authToken, err := m.getUserFromDatabaseWithToken(c.Request().Context(), token)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("token", truncateToken(token)).Msg("Token authentication failed")
|
||||
return apperrors.Unauthorized("error.invalid_token")
|
||||
}
|
||||
|
||||
// Check token expiry
|
||||
if m.isTokenExpired(authToken.Created) {
|
||||
log.Debug().Str("token", truncateToken(token)).Time("created", authToken.Created).Msg("Token expired")
|
||||
return apperrors.Unauthorized("error.token_expired")
|
||||
}
|
||||
|
||||
// Cache the user ID and token creation time for future requests
|
||||
if cacheErr := m.cacheTokenInfo(c.Request().Context(), token, user.ID, authToken.Created); cacheErr != nil {
|
||||
log.Warn().Err(cacheErr).Msg("Failed to cache token info")
|
||||
}
|
||||
|
||||
// Set user in context
|
||||
c.Set(AuthUserKey, user)
|
||||
c.Set(AuthTokenKey, token)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalTokenAuth returns middleware that authenticates if token is present but doesn't require it
|
||||
func (m *AuthMiddleware) OptionalTokenAuth() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
token, err := extractToken(c)
|
||||
if err != nil {
|
||||
// No token or invalid format - continue without user
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
user, err := m.getUserFromCache(c.Request().Context(), token)
|
||||
if err == nil && user != nil {
|
||||
c.Set(AuthUserKey, user)
|
||||
c.Set(AuthTokenKey, token)
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Try database
|
||||
user, authToken, err := m.getUserFromDatabaseWithToken(c.Request().Context(), token)
|
||||
if err == nil && !m.isTokenExpired(authToken.Created) {
|
||||
m.cacheTokenInfo(c.Request().Context(), token, user.ID, authToken.Created)
|
||||
c.Set(AuthUserKey, user)
|
||||
c.Set(AuthTokenKey, token)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractToken extracts the token from the Authorization header
|
||||
func extractToken(c echo.Context) (string, error) {
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return "", fmt.Errorf("authorization header required")
|
||||
}
|
||||
|
||||
// Support both "Token xxx" (Django style) and "Bearer xxx" formats
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid authorization header format")
|
||||
}
|
||||
|
||||
scheme := parts[0]
|
||||
token := parts[1]
|
||||
|
||||
if scheme != "Token" && scheme != "Bearer" {
|
||||
return "", fmt.Errorf("invalid authorization scheme: %s", scheme)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("token is empty")
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// getUserFromCache tries to get user from Redis cache, then from the
|
||||
// in-memory user cache, before falling back to the database.
|
||||
// Returns a "token expired" error if the cached creation time indicates expiry.
|
||||
func (m *AuthMiddleware) getUserFromCache(ctx context.Context, token string) (*models.User, error) {
|
||||
if m.cache == nil {
|
||||
return nil, fmt.Errorf("cache not available")
|
||||
}
|
||||
|
||||
userID, createdUnix, err := m.cache.GetCachedAuthTokenWithCreated(ctx, token)
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
return nil, fmt.Errorf("token not in cache")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check token expiry from cached creation time
|
||||
if createdUnix > 0 {
|
||||
created := time.Unix(createdUnix, 0)
|
||||
if m.isTokenExpired(created) {
|
||||
m.cache.InvalidateAuthToken(ctx, token)
|
||||
return nil, fmt.Errorf("token expired")
|
||||
}
|
||||
}
|
||||
|
||||
// Try in-memory user cache first to avoid a DB round-trip
|
||||
if cached := m.userCache.Get(userID); cached != nil {
|
||||
if !cached.IsActive {
|
||||
m.cache.InvalidateAuthToken(ctx, token)
|
||||
m.userCache.Invalidate(userID)
|
||||
return nil, fmt.Errorf("user is inactive")
|
||||
}
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// In-memory cache miss — fetch from database
|
||||
var user models.User
|
||||
if err := m.db.WithContext(ctx).First(&user, userID).Error; err != nil {
|
||||
// User was deleted - invalidate caches
|
||||
m.cache.InvalidateAuthToken(ctx, token)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if !user.IsActive {
|
||||
m.cache.InvalidateAuthToken(ctx, token)
|
||||
return nil, fmt.Errorf("user is inactive")
|
||||
}
|
||||
|
||||
// Store in in-memory cache for subsequent requests
|
||||
m.userCache.Set(&user)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// getUserFromDatabaseWithToken looks up the token in the database and returns
|
||||
// both the user and the auth token record (for expiry checking). The ctx is
|
||||
// threaded into the GORM session so the SQL span attaches to the request trace.
|
||||
//
|
||||
// Uses a single JOIN query instead of GORM's Preload (which issues 2 SELECTs).
|
||||
// Over a transatlantic link this saves ~110ms RTT per cache miss.
|
||||
func (m *AuthMiddleware) getUserFromDatabaseWithToken(ctx context.Context, token string) (*models.User, *models.AuthToken, error) {
|
||||
// Flat result row: every column from auth_user prefixed `u_`, every
|
||||
// column from user_authtoken left in its native shape. Mapping to two
|
||||
// structs is mechanical so we don't need a struct tag soup.
|
||||
type joinedRow struct {
|
||||
// AuthToken columns
|
||||
Key string `gorm:"column:key"`
|
||||
Created time.Time `gorm:"column:created"`
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
// User columns (prefixed to avoid collision with UserID)
|
||||
UID uint `gorm:"column:u_id"`
|
||||
UUsername string `gorm:"column:u_username"`
|
||||
UEmail string `gorm:"column:u_email"`
|
||||
UFirstName string `gorm:"column:u_first_name"`
|
||||
ULastName string `gorm:"column:u_last_name"`
|
||||
UPassword string `gorm:"column:u_password"`
|
||||
UIsActive bool `gorm:"column:u_is_active"`
|
||||
UIsStaff bool `gorm:"column:u_is_staff"`
|
||||
UIsSuper bool `gorm:"column:u_is_superuser"`
|
||||
UDateJoined time.Time `gorm:"column:u_date_joined"`
|
||||
ULastLogin *time.Time `gorm:"column:u_last_login"`
|
||||
}
|
||||
|
||||
var row joinedRow
|
||||
err := m.db.WithContext(ctx).
|
||||
Table("user_authtoken AS t").
|
||||
Select(`
|
||||
t.key, t.created, t.user_id,
|
||||
u.id AS u_id,
|
||||
u.username AS u_username,
|
||||
u.email AS u_email,
|
||||
u.first_name AS u_first_name,
|
||||
u.last_name AS u_last_name,
|
||||
u.password AS u_password,
|
||||
u.is_active AS u_is_active,
|
||||
u.is_staff AS u_is_staff,
|
||||
u.is_superuser AS u_is_superuser,
|
||||
u.date_joined AS u_date_joined,
|
||||
u.last_login AS u_last_login
|
||||
`).
|
||||
Joins("INNER JOIN auth_user u ON u.id = t.user_id").
|
||||
Where("t.key = ?", token).
|
||||
Limit(1).
|
||||
Scan(&row).Error
|
||||
if err != nil || row.Key == "" {
|
||||
return nil, nil, fmt.Errorf("token not found")
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
ID: row.UID,
|
||||
Username: row.UUsername,
|
||||
Email: row.UEmail,
|
||||
FirstName: row.UFirstName,
|
||||
LastName: row.ULastName,
|
||||
Password: row.UPassword,
|
||||
IsActive: row.UIsActive,
|
||||
IsStaff: row.UIsStaff,
|
||||
IsSuperuser: row.UIsSuper,
|
||||
DateJoined: row.UDateJoined,
|
||||
LastLogin: row.ULastLogin,
|
||||
}
|
||||
authToken := models.AuthToken{
|
||||
Key: row.Key,
|
||||
Created: row.Created,
|
||||
UserID: row.UserID,
|
||||
User: user,
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, nil, fmt.Errorf("user is inactive")
|
||||
}
|
||||
|
||||
m.userCache.Set(&user)
|
||||
return &user, &authToken, nil
|
||||
}
|
||||
|
||||
// getUserFromDatabase looks up the token in the database and caches the
|
||||
// resulting user record in memory.
|
||||
// Deprecated: Use getUserFromDatabaseWithToken for new code paths that need expiry checking.
|
||||
func (m *AuthMiddleware) getUserFromDatabase(ctx context.Context, token string) (*models.User, error) {
|
||||
user, _, err := m.getUserFromDatabaseWithToken(ctx, token)
|
||||
return user, err
|
||||
}
|
||||
|
||||
// cacheTokenInfo caches the user ID and token creation time for a token
|
||||
func (m *AuthMiddleware) cacheTokenInfo(ctx context.Context, token string, userID uint, created time.Time) error {
|
||||
if m.cache == nil {
|
||||
return nil
|
||||
}
|
||||
return m.cache.CacheAuthTokenWithCreated(ctx, token, userID, created.Unix())
|
||||
}
|
||||
|
||||
// cacheUserID caches the user ID for a token
|
||||
func (m *AuthMiddleware) cacheUserID(ctx context.Context, token string, userID uint) error {
|
||||
if m.cache == nil {
|
||||
return nil
|
||||
}
|
||||
return m.cache.CacheAuthToken(ctx, token, userID)
|
||||
}
|
||||
|
||||
// InvalidateToken removes a token from the cache
|
||||
func (m *AuthMiddleware) InvalidateToken(ctx context.Context, token string) error {
|
||||
if m.cache == nil {
|
||||
return nil
|
||||
}
|
||||
return m.cache.InvalidateAuthToken(ctx, token)
|
||||
}
|
||||
|
||||
// GetAuthUser retrieves the authenticated user from the Echo context.
|
||||
// Returns nil if the context value is missing or not of the expected type.
|
||||
func GetAuthUser(c echo.Context) *models.User {
|
||||
val := c.Get(AuthUserKey)
|
||||
if val == nil {
|
||||
return nil
|
||||
}
|
||||
user, ok := val.(*models.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// GetAuthToken retrieves the auth token from the Echo context
|
||||
func GetAuthToken(c echo.Context) string {
|
||||
token := c.Get(AuthTokenKey)
|
||||
if token == nil {
|
||||
return ""
|
||||
}
|
||||
tokenStr, ok := token.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return tokenStr
|
||||
}
|
||||
|
||||
// MustGetAuthUser retrieves the authenticated user or returns error with 401
|
||||
func MustGetAuthUser(c echo.Context) (*models.User, error) {
|
||||
user := GetAuthUser(c)
|
||||
if user == nil {
|
||||
return nil, apperrors.Unauthorized("error.not_authenticated")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// truncateToken safely truncates a token string for logging.
|
||||
// Returns at most the first 8 characters followed by "...".
|
||||
func truncateToken(token string) string {
|
||||
if len(token) > 8 {
|
||||
return token[:8] + "..."
|
||||
}
|
||||
return token + "..."
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
)
|
||||
|
||||
// setupTestDB creates a temporary in-memory SQLite database with the required
|
||||
// tables for auth middleware tests.
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.AutoMigrate(&models.User{}, &models.AuthToken{})
|
||||
require.NoError(t, err)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// createTestUserAndToken creates a user and an auth token, then backdates the
|
||||
// token's Created timestamp by the specified number of days.
|
||||
func createTestUserAndToken(t *testing.T, db *gorm.DB, username string, ageDays int) (*models.User, *models.AuthToken) {
|
||||
t.Helper()
|
||||
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Email: username + "@test.com",
|
||||
IsActive: true,
|
||||
}
|
||||
require.NoError(t, user.SetPassword("Password123"))
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
|
||||
token := &models.AuthToken{
|
||||
UserID: user.ID,
|
||||
}
|
||||
require.NoError(t, db.Create(token).Error)
|
||||
|
||||
// Backdate the token's Created timestamp after creation to bypass autoCreateTime
|
||||
backdated := time.Now().UTC().AddDate(0, 0, -ageDays)
|
||||
require.NoError(t, db.Model(token).Update("created", backdated).Error)
|
||||
token.Created = backdated
|
||||
|
||||
return user, token
|
||||
}
|
||||
|
||||
func TestTokenAuth_RejectsExpiredToken(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
_, token := createTestUserAndToken(t, db, "expired_user", 91) // 91 days old > 90 day expiry
|
||||
|
||||
m := NewAuthMiddleware(db, nil) // No Redis cache for these tests
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "Token "+token.Key)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.TokenAuth()(func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error.token_expired")
|
||||
}
|
||||
|
||||
func TestTokenAuth_AcceptsValidToken(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
_, token := createTestUserAndToken(t, db, "valid_user", 30) // 30 days old < 90 day expiry
|
||||
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "Token "+token.Key)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.TokenAuth()(func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
// Verify user was set in context
|
||||
user := GetAuthUser(c)
|
||||
require.NotNil(t, user)
|
||||
assert.Equal(t, "valid_user", user.Username)
|
||||
}
|
||||
|
||||
func TestTokenAuth_AcceptsTokenAtBoundary(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
_, token := createTestUserAndToken(t, db, "boundary_user", 89) // 89 days old, just under 90 day expiry
|
||||
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "Token "+token.Key)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.TokenAuth()(func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestTokenAuth_RejectsInvalidToken(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "Token nonexistent-token")
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.TokenAuth()(func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error.invalid_token")
|
||||
}
|
||||
|
||||
func TestTokenAuth_RejectsNoAuthHeader(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.TokenAuth()(func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error.not_authenticated")
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/apperrors"
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
)
|
||||
@@ -117,3 +118,76 @@ func TestAdminAuth_QueryParamToken_Rejected(t *testing.T) {
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code, "query param token must be rejected")
|
||||
assert.Contains(t, rec.Body.String(), "Authorization required")
|
||||
}
|
||||
|
||||
// requireVerifiedContext builds an Echo context primed as the Authenticate
|
||||
// middleware would leave it: an auth_user and the verified flag.
|
||||
func requireVerifiedContext(user *models.User, verified bool) (echo.Context, *httptest.ResponseRecorder) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/residences/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
if user != nil {
|
||||
c.Set(AuthUserKey, user)
|
||||
}
|
||||
c.Set(AuthVerifiedKey, verified)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// TestRequireVerified_VerifiedUser_Passes confirms a verified user reaches the
|
||||
// wrapped handler. This is the default tier for all app-data routes now that
|
||||
// RequireVerified is applied at the `verified` group level in the router.
|
||||
func TestRequireVerified_VerifiedUser_Passes(t *testing.T) {
|
||||
m := &KratosAuth{}
|
||||
c, _ := requireVerifiedContext(&models.User{Username: "v"}, true)
|
||||
|
||||
reached := false
|
||||
handler := m.RequireVerified()(func(c echo.Context) error {
|
||||
reached = true
|
||||
return c.NoContent(http.StatusOK)
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, reached, "verified user should reach the handler")
|
||||
}
|
||||
|
||||
// TestRequireVerified_UnverifiedUser_403 is the core gating assertion for the
|
||||
// new policy: an authenticated-but-unverified user is rejected with 403 on a
|
||||
// data route, NOT allowed through.
|
||||
func TestRequireVerified_UnverifiedUser_403(t *testing.T) {
|
||||
m := &KratosAuth{}
|
||||
c, _ := requireVerifiedContext(&models.User{Username: "u"}, false)
|
||||
|
||||
reached := false
|
||||
handler := m.RequireVerified()(func(c echo.Context) error {
|
||||
reached = true
|
||||
return c.NoContent(http.StatusOK)
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.Error(t, err)
|
||||
assert.False(t, reached, "unverified user must NOT reach the handler")
|
||||
|
||||
var appErr *apperrors.AppError
|
||||
require.ErrorAs(t, err, &appErr)
|
||||
assert.Equal(t, http.StatusForbidden, appErr.Code)
|
||||
}
|
||||
|
||||
// TestRequireVerified_NoUser_401 confirms RequireVerified rejects an
|
||||
// unauthenticated request with 401 (defense-in-depth even though Authenticate
|
||||
// runs first in the router).
|
||||
func TestRequireVerified_NoUser_401(t *testing.T) {
|
||||
m := &KratosAuth{}
|
||||
c, _ := requireVerifiedContext(nil, false)
|
||||
|
||||
handler := m.RequireVerified()(func(c echo.Context) error {
|
||||
return c.NoContent(http.StatusOK)
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.Error(t, err)
|
||||
|
||||
var appErr *apperrors.AppError
|
||||
require.ErrorAs(t, err, &appErr)
|
||||
assert.Equal(t, http.StatusUnauthorized, appErr.Code)
|
||||
}
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
)
|
||||
|
||||
func TestTokenAuth_BearerScheme_Accepted(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
_, token := createTestUserAndToken(t, db, "bearer_user", 10)
|
||||
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token.Key)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.TokenAuth()(func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
user := GetAuthUser(c)
|
||||
require.NotNil(t, user)
|
||||
assert.Equal(t, "bearer_user", user.Username)
|
||||
}
|
||||
|
||||
func TestTokenAuth_InvalidScheme_Rejected(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
_, token := createTestUserAndToken(t, db, "scheme_user", 10)
|
||||
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "Basic "+token.Key)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.TokenAuth()(func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error.not_authenticated")
|
||||
}
|
||||
|
||||
func TestTokenAuth_MalformedHeader_Rejected(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "JustATokenWithNoScheme")
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.TokenAuth()(func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error.not_authenticated")
|
||||
}
|
||||
|
||||
func TestTokenAuth_EmptyToken_Rejected(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "Token ")
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.TokenAuth()(func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error.not_authenticated")
|
||||
}
|
||||
|
||||
func TestTokenAuth_InactiveUser_Rejected(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
user, token := createTestUserAndToken(t, db, "inactive_user", 10)
|
||||
|
||||
// Deactivate the user
|
||||
require.NoError(t, db.Model(user).Update("is_active", false).Error)
|
||||
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "Token "+token.Key)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.TokenAuth()(func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error.invalid_token")
|
||||
}
|
||||
|
||||
func TestOptionalTokenAuth_NoToken_PassesThrough(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
// No Authorization header
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.OptionalTokenAuth()(func(c echo.Context) error {
|
||||
user := GetAuthUser(c)
|
||||
if user == nil {
|
||||
return c.String(http.StatusOK, "no-user")
|
||||
}
|
||||
return c.String(http.StatusOK, user.Username)
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "no-user", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestOptionalTokenAuth_ValidToken_SetsUser(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
_, token := createTestUserAndToken(t, db, "opt_user", 10)
|
||||
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "Token "+token.Key)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.OptionalTokenAuth()(func(c echo.Context) error {
|
||||
user := GetAuthUser(c)
|
||||
if user == nil {
|
||||
return c.String(http.StatusOK, "no-user")
|
||||
}
|
||||
return c.String(http.StatusOK, user.Username)
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "opt_user", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestOptionalTokenAuth_ExpiredToken_IgnoresUser(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
_, token := createTestUserAndToken(t, db, "expired_opt_user", 91)
|
||||
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "Token "+token.Key)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.OptionalTokenAuth()(func(c echo.Context) error {
|
||||
user := GetAuthUser(c)
|
||||
if user == nil {
|
||||
return c.String(http.StatusOK, "no-user")
|
||||
}
|
||||
return c.String(http.StatusOK, user.Username)
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "no-user", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestOptionalTokenAuth_InvalidToken_IgnoresUser(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "Token nonexistent-token")
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.OptionalTokenAuth()(func(c echo.Context) error {
|
||||
user := GetAuthUser(c)
|
||||
if user == nil {
|
||||
return c.String(http.StatusOK, "no-user")
|
||||
}
|
||||
return c.String(http.StatusOK, user.Username)
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "no-user", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestNewAuthMiddlewareWithConfig_CustomExpiryDays(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
cfg := &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
TokenExpiryDays: 30,
|
||||
},
|
||||
}
|
||||
|
||||
m := NewAuthMiddlewareWithConfig(db, nil, cfg)
|
||||
assert.NotNil(t, m)
|
||||
assert.Equal(t, 30, m.tokenExpiryDays)
|
||||
|
||||
// Token at 29 days should be valid
|
||||
_, token := createTestUserAndToken(t, db, "short_expiry_user", 29)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "Token "+token.Key)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.TokenAuth()(func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestNewAuthMiddlewareWithConfig_ExpiredWithCustomExpiry(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
cfg := &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
TokenExpiryDays: 30,
|
||||
},
|
||||
}
|
||||
|
||||
m := NewAuthMiddlewareWithConfig(db, nil, cfg)
|
||||
|
||||
// Token at 31 days should be expired with 30-day config
|
||||
_, token := createTestUserAndToken(t, db, "custom_expired_user", 31)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
|
||||
req.Header.Set("Authorization", "Token "+token.Key)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
handler := m.TokenAuth()(func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
err := handler(c)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error.token_expired")
|
||||
}
|
||||
|
||||
func TestNewAuthMiddlewareWithConfig_NilConfig_UsesDefault(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
m := NewAuthMiddlewareWithConfig(db, nil, nil)
|
||||
assert.Equal(t, DefaultTokenExpiryDays, m.tokenExpiryDays)
|
||||
}
|
||||
|
||||
func TestGetAuthToken_ReturnsToken(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
c.Set(AuthTokenKey, "test-token-value")
|
||||
assert.Equal(t, "test-token-value", GetAuthToken(c))
|
||||
}
|
||||
|
||||
func TestGetAuthToken_NilContext_ReturnsEmpty(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
// No token set
|
||||
assert.Equal(t, "", GetAuthToken(c))
|
||||
}
|
||||
|
||||
func TestGetAuthToken_WrongType_ReturnsEmpty(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
c.Set(AuthTokenKey, 12345) // Wrong type
|
||||
assert.Equal(t, "", GetAuthToken(c))
|
||||
}
|
||||
|
||||
func TestIsTokenExpired_ZeroTime_NotExpired(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
m := NewAuthMiddleware(db, nil)
|
||||
|
||||
// Legacy tokens without created time should not be expired
|
||||
assert.False(t, m.isTokenExpired(models.AuthToken{}.Created))
|
||||
}
|
||||
|
||||
func TestInvalidateToken_NilCache_NoError(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
m := NewAuthMiddleware(db, nil) // nil cache
|
||||
|
||||
err := m.InvalidateToken(nil, "some-token")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/apperrors"
|
||||
"github.com/treytartt/honeydue-api/internal/kratos"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
// AuthUserKey stores the authenticated *models.User in the echo context.
|
||||
AuthUserKey = "auth_user"
|
||||
// AuthTokenKey stores the raw session credential in the echo context.
|
||||
AuthTokenKey = "auth_token"
|
||||
// AuthVerifiedKey stores the Kratos email-verified flag in the context.
|
||||
// Handlers can read this to override stale local mirrors like
|
||||
// user_profile.verified with the live Kratos truth.
|
||||
AuthVerifiedKey = "auth_email_verified"
|
||||
|
||||
// UserCacheTTL / UserCacheMaxSize bound the in-memory local-user cache.
|
||||
UserCacheTTL = 5 * time.Minute
|
||||
UserCacheMaxSize = 5000
|
||||
|
||||
// kratosSessionCacheTTL is how long a validated session is cached in
|
||||
// Redis, so most authed requests skip the Kratos /whoami round trip.
|
||||
//
|
||||
// PRODUCTION CAVEAT (2026-06-03): until Kratos is deployed in-cluster,
|
||||
// the Whoami fallback ALWAYS fails (no kratos Service). That means every
|
||||
// cache miss = 401 = forced re-login. We mitigate by (a) using a long
|
||||
// TTL and (b) refreshing the TTL on every cache hit (see resolve()).
|
||||
// This is a short-term workaround — restore to a few minutes once Kratos
|
||||
// is live and the runbook §11 #7 prerequisites are done.
|
||||
kratosSessionCacheTTL = 24 * time.Hour
|
||||
kratosSessionPrefix = "kratos_sess:"
|
||||
)
|
||||
|
||||
// KratosAuth authenticates requests against an Ory Kratos session. It
|
||||
// replaces the hand-rolled token auth: the session is validated via Kratos
|
||||
// /sessions/whoami (Redis-cached), and the matching local auth_user row is
|
||||
// lazily provisioned on first sight of a Kratos identity.
|
||||
type KratosAuth struct {
|
||||
kratos *kratos.Client
|
||||
cache *services.CacheService
|
||||
db *gorm.DB
|
||||
userCache *UserCache
|
||||
}
|
||||
|
||||
// NewKratosAuth builds the Kratos auth middleware.
|
||||
func NewKratosAuth(k *kratos.Client, cache *services.CacheService, db *gorm.DB) *KratosAuth {
|
||||
return &KratosAuth{
|
||||
kratos: k,
|
||||
cache: cache,
|
||||
db: db,
|
||||
userCache: NewUserCache(UserCacheTTL, UserCacheMaxSize),
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate validates the Kratos session and requires it.
|
||||
func (m *KratosAuth) Authenticate() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
user, verified, cred, err := m.resolve(c)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Kratos authentication failed")
|
||||
return apperrors.Unauthorized("error.not_authenticated")
|
||||
}
|
||||
c.Set(AuthUserKey, user)
|
||||
c.Set(AuthTokenKey, cred)
|
||||
c.Set(AuthVerifiedKey, verified)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuthenticate authenticates if a session is present, else continues
|
||||
// unauthenticated.
|
||||
func (m *KratosAuth) OptionalAuthenticate() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if user, verified, cred, err := m.resolve(c); err == nil {
|
||||
c.Set(AuthUserKey, user)
|
||||
c.Set(AuthTokenKey, cred)
|
||||
c.Set(AuthVerifiedKey, verified)
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RequireVerified rejects users whose Kratos email address is not verified.
|
||||
// Apply after Authenticate.
|
||||
func (m *KratosAuth) RequireVerified() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if GetAuthUser(c) == nil {
|
||||
return apperrors.Unauthorized("error.not_authenticated")
|
||||
}
|
||||
if verified, _ := c.Get(AuthVerifiedKey).(bool); !verified {
|
||||
return apperrors.Forbidden("error.email_not_verified")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolve validates the request's session and returns the local user.
|
||||
func (m *KratosAuth) resolve(c echo.Context) (*models.User, bool, string, error) {
|
||||
token, cookie := extractSession(c)
|
||||
if token == "" && cookie == "" {
|
||||
return nil, false, "", errors.New("no session credential")
|
||||
}
|
||||
cred := token
|
||||
if cred == "" {
|
||||
cred = cookie
|
||||
}
|
||||
ctx := c.Request().Context()
|
||||
|
||||
// Redis cache: kratos_sess:<hash(cred)> -> "<userID>|<0|1>"
|
||||
cacheKey := kratosSessionPrefix + hashCredential(cred)
|
||||
if m.cache != nil {
|
||||
if v, err := m.cache.GetString(ctx, cacheKey); err == nil && v != "" {
|
||||
// Only a cached `verified=true` is authoritative — email verification
|
||||
// is sticky (it never reverts), so we can safely short-circuit.
|
||||
// A cached `verified=false` is deliberately NOT trusted: the user may
|
||||
// have verified their email since this entry was written, and a stale
|
||||
// false would lock a just-verified user out of every verified-gated
|
||||
// route until the 24h TTL expired (e.g. sign up -> verify -> create a
|
||||
// residence immediately). On a cached false we fall through and
|
||||
// re-resolve the live status from Kratos /whoami below.
|
||||
if user, verified, ok := m.userFromCacheValue(ctx, v); ok && verified {
|
||||
// Sliding-window refresh: extend the TTL on every successful hit
|
||||
// so active (verified) users aren't bounced when their entry
|
||||
// would otherwise expire. Best-effort.
|
||||
_ = m.cache.SetString(ctx, cacheKey, v, kratosSessionCacheTTL)
|
||||
return user, true, cred, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sess, err := m.kratos.Whoami(ctx, token, cookie)
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
user, err := m.provision(ctx, sess)
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
if m.cache != nil {
|
||||
_ = m.cache.SetString(ctx, cacheKey,
|
||||
fmt.Sprintf("%d|%s", user.ID, boolDigit(sess.EmailVerified())), kratosSessionCacheTTL)
|
||||
}
|
||||
return user, sess.EmailVerified(), cred, nil
|
||||
}
|
||||
|
||||
// provision finds the local auth_user row for a Kratos identity, creating it
|
||||
// (and a UserProfile) on first sight. Concurrent first requests are handled
|
||||
// by re-reading after a unique-constraint conflict.
|
||||
func (m *KratosAuth) provision(ctx context.Context, sess *kratos.Session) (*models.User, error) {
|
||||
var user models.User
|
||||
err := m.db.WithContext(ctx).Where("kratos_id = ?", sess.Identity.ID).First(&user).Error
|
||||
if err == nil {
|
||||
return &user, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user = models.User{
|
||||
KratosID: sess.Identity.ID,
|
||||
Email: sess.Identity.Traits.Email,
|
||||
Username: sess.Identity.Traits.Email,
|
||||
FirstName: sess.Identity.Traits.Name.First,
|
||||
LastName: sess.Identity.Traits.Name.Last,
|
||||
IsActive: true,
|
||||
DateJoined: time.Now().UTC(),
|
||||
}
|
||||
txErr := m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&models.UserProfile{
|
||||
UserID: user.ID,
|
||||
Verified: sess.EmailVerified(),
|
||||
}).Error
|
||||
})
|
||||
if txErr != nil {
|
||||
// Likely a concurrent provision of the same identity — re-read.
|
||||
if e := m.db.WithContext(ctx).Where("kratos_id = ?", sess.Identity.ID).First(&user).Error; e == nil {
|
||||
return &user, nil
|
||||
}
|
||||
return nil, txErr
|
||||
}
|
||||
log.Info().Str("kratos_id", sess.Identity.ID).Uint("user_id", user.ID).
|
||||
Msg("provisioned local user from Kratos identity")
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// userFromCacheValue resolves a cached "<userID>|<0|1>" value to a user.
|
||||
func (m *KratosAuth) userFromCacheValue(ctx context.Context, v string) (*models.User, bool, bool) {
|
||||
parts := strings.SplitN(v, "|", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, false, false
|
||||
}
|
||||
var id uint
|
||||
if _, err := fmt.Sscanf(parts[0], "%d", &id); err != nil || id == 0 {
|
||||
return nil, false, false
|
||||
}
|
||||
verified := parts[1] == "1"
|
||||
if cached := m.userCache.Get(id); cached != nil {
|
||||
return cached, verified, true
|
||||
}
|
||||
var user models.User
|
||||
if err := m.db.WithContext(ctx).First(&user, id).Error; err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
m.userCache.Set(&user)
|
||||
return &user, verified, true
|
||||
}
|
||||
|
||||
// extractSession pulls the session credential from the request: the
|
||||
// X-Session-Token header or Authorization bearer (mobile clients), or the
|
||||
// ory_kratos_session cookie (web).
|
||||
func extractSession(c echo.Context) (token, cookie string) {
|
||||
if t := c.Request().Header.Get("X-Session-Token"); t != "" {
|
||||
token = t
|
||||
} else if ah := c.Request().Header.Get("Authorization"); ah != "" {
|
||||
parts := strings.SplitN(ah, " ", 2)
|
||||
if len(parts) == 2 && (parts[0] == "Bearer" || parts[0] == "Token") {
|
||||
token = parts[1]
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
if ck := c.Request().Header.Get("Cookie"); strings.Contains(ck, "ory_kratos_session") {
|
||||
cookie = ck
|
||||
}
|
||||
}
|
||||
return token, cookie
|
||||
}
|
||||
|
||||
func hashCredential(cred string) string {
|
||||
sum := sha256.Sum256([]byte(cred))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func boolDigit(b bool) string {
|
||||
if b {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
// truncateToken returns the first 8 characters of a credential followed by
|
||||
// "..." for safe inclusion in log lines.
|
||||
func truncateToken(tok string) string {
|
||||
if len(tok) <= 8 {
|
||||
return tok + "..."
|
||||
}
|
||||
return tok[:8] + "..."
|
||||
}
|
||||
|
||||
// GetAuthUser retrieves the authenticated user from the echo context.
|
||||
func GetAuthUser(c echo.Context) *models.User {
|
||||
user, _ := c.Get(AuthUserKey).(*models.User)
|
||||
return user
|
||||
}
|
||||
|
||||
// GetAuthToken retrieves the session credential from the echo context.
|
||||
func GetAuthToken(c echo.Context) string {
|
||||
tok, _ := c.Get(AuthTokenKey).(string)
|
||||
return tok
|
||||
}
|
||||
|
||||
// MustGetAuthUser retrieves the authenticated user or returns a 401 error.
|
||||
func MustGetAuthUser(c echo.Context) (*models.User, error) {
|
||||
user := GetAuthUser(c)
|
||||
if user == nil {
|
||||
return nil, apperrors.Unauthorized("error.not_authenticated")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
@@ -99,22 +99,24 @@ func parseTimezone(tz string) *time.Location {
|
||||
return loc
|
||||
}
|
||||
|
||||
// Try parsing as UTC offset (e.g., "-08:00", "+05:30")
|
||||
// We parse a reference time with the given offset to extract the offset value
|
||||
t, err := time.Parse("-07:00", tz)
|
||||
if err == nil {
|
||||
// time.Parse returns a time, we need to extract the offset
|
||||
// The parsed time will have the offset embedded
|
||||
_, offset := t.Zone()
|
||||
// Try parsing as a UTC offset (e.g., "-08:00", "+05:30"). Audit H8:
|
||||
// reject absurd offsets — real timezones are within ±14h of UTC — so a
|
||||
// crafted X-Timezone header cannot shift date math arbitrarily.
|
||||
const maxOffsetSeconds = 14 * 3600
|
||||
if t, err := time.Parse("-07:00", tz); err == nil {
|
||||
if _, offset := t.Zone(); offset >= -maxOffsetSeconds && offset <= maxOffsetSeconds {
|
||||
return time.FixedZone(tz, offset)
|
||||
}
|
||||
return time.UTC
|
||||
}
|
||||
|
||||
// Also try without colon (e.g., "-0800")
|
||||
t, err = time.Parse("-0700", tz)
|
||||
if err == nil {
|
||||
_, offset := t.Zone()
|
||||
if t, err := time.Parse("-0700", tz); err == nil {
|
||||
if _, offset := t.Zone(); offset >= -maxOffsetSeconds && offset <= maxOffsetSeconds {
|
||||
return time.FixedZone(tz, offset)
|
||||
}
|
||||
return time.UTC
|
||||
}
|
||||
|
||||
// Default to UTC
|
||||
return time.UTC
|
||||
|
||||
@@ -19,7 +19,7 @@ func setupModelsTestDB(t *testing.T) *gorm.DB {
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = db.AutoMigrate(&User{}, &AuthToken{}, &UserProfile{})
|
||||
err = db.AutoMigrate(&User{}, &UserProfile{})
|
||||
require.NoError(t, err)
|
||||
return db
|
||||
}
|
||||
@@ -233,104 +233,6 @@ func TestNotificationType_Constants(t *testing.T) {
|
||||
assert.Equal(t, NotificationType("warranty_expiring"), NotificationWarrantyExpiring)
|
||||
}
|
||||
|
||||
// === AuthToken model tests ===
|
||||
|
||||
func TestAuthToken_BeforeCreate_GeneratesKey(t *testing.T) {
|
||||
db := setupModelsTestDB(t)
|
||||
|
||||
user := &User{
|
||||
Username: "tokenuser",
|
||||
Email: "token@test.com",
|
||||
Password: "dummy",
|
||||
IsActive: true,
|
||||
}
|
||||
err := db.Create(user).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
token := &AuthToken{UserID: user.ID}
|
||||
err = db.Create(token).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, token.Key)
|
||||
assert.Len(t, token.Key, 40) // 20 bytes = 40 hex chars
|
||||
assert.False(t, token.Created.IsZero())
|
||||
}
|
||||
|
||||
func TestAuthToken_BeforeCreate_PreservesExistingKey(t *testing.T) {
|
||||
db := setupModelsTestDB(t)
|
||||
|
||||
user := &User{
|
||||
Username: "tokenuser",
|
||||
Email: "token@test.com",
|
||||
Password: "dummy",
|
||||
IsActive: true,
|
||||
}
|
||||
err := db.Create(user).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
existingKey := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
token := &AuthToken{
|
||||
Key: existingKey,
|
||||
UserID: user.ID,
|
||||
}
|
||||
err = db.Create(token).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, existingKey, token.Key)
|
||||
}
|
||||
|
||||
func TestGetOrCreateToken_CreatesNew(t *testing.T) {
|
||||
db := setupModelsTestDB(t)
|
||||
|
||||
user := &User{
|
||||
Username: "newtoken",
|
||||
Email: "newtoken@test.com",
|
||||
Password: "dummy",
|
||||
IsActive: true,
|
||||
}
|
||||
err := db.Create(user).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := GetOrCreateToken(db, user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token.Key)
|
||||
assert.Equal(t, user.ID, token.UserID)
|
||||
}
|
||||
|
||||
func TestGetOrCreateToken_ReturnsExisting(t *testing.T) {
|
||||
db := setupModelsTestDB(t)
|
||||
|
||||
user := &User{
|
||||
Username: "existingtoken",
|
||||
Email: "existingtoken@test.com",
|
||||
Password: "dummy",
|
||||
IsActive: true,
|
||||
}
|
||||
err := db.Create(user).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
token1, err := GetOrCreateToken(db, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
token2, err := GetOrCreateToken(db, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, token1.Key, token2.Key)
|
||||
}
|
||||
|
||||
// === User model additional tests ===
|
||||
|
||||
func TestUser_SetPassword_And_CheckPassword_Integration(t *testing.T) {
|
||||
user := &User{}
|
||||
err := user.SetPassword("Password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, user.CheckPassword("Password123"))
|
||||
assert.False(t, user.CheckPassword("WrongPassword"))
|
||||
assert.False(t, user.CheckPassword(""))
|
||||
assert.False(t, user.CheckPassword("password123")) // case sensitive
|
||||
}
|
||||
|
||||
// === Task model additional tests ===
|
||||
|
||||
func TestTask_IsOverdue_CancelledNotOverdue(t *testing.T) {
|
||||
@@ -564,31 +466,6 @@ func TestGetDefaultProLimits(t *testing.T) {
|
||||
assert.Nil(t, limits.DocumentsLimit)
|
||||
}
|
||||
|
||||
// === ConfirmationCode additional tests ===
|
||||
|
||||
func TestConfirmationCode_TableName(t *testing.T) {
|
||||
cc := ConfirmationCode{}
|
||||
assert.Equal(t, "user_confirmationcode", cc.TableName())
|
||||
}
|
||||
|
||||
// === PasswordResetCode additional tests ===
|
||||
|
||||
func TestPasswordResetCode_TableName(t *testing.T) {
|
||||
prc := PasswordResetCode{}
|
||||
assert.Equal(t, "user_passwordresetcode", prc.TableName())
|
||||
}
|
||||
|
||||
// === Social Auth TableName tests ===
|
||||
|
||||
func TestAppleSocialAuth_TableName(t *testing.T) {
|
||||
a := AppleSocialAuth{}
|
||||
assert.Equal(t, "user_applesocialauth", a.TableName())
|
||||
}
|
||||
|
||||
func TestGoogleSocialAuth_TableName(t *testing.T) {
|
||||
g := GoogleSocialAuth{}
|
||||
assert.Equal(t, "user_googlesocialauth", g.TableName())
|
||||
}
|
||||
|
||||
// === BaseModel tests ===
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ type UserSubscription struct {
|
||||
// In-App Purchase data (Apple / Google)
|
||||
AppleReceiptData *string `gorm:"column:apple_receipt_data;type:text" json:"-"`
|
||||
GooglePurchaseToken *string `gorm:"column:google_purchase_token;type:text" json:"-"`
|
||||
// AppleOriginalTransactionID binds an Apple subscription to one account
|
||||
// (audit C5/C13). A partial unique index enforces one-account-per-txn.
|
||||
AppleOriginalTransactionID *string `gorm:"column:apple_original_transaction_id;type:text" json:"-"`
|
||||
|
||||
// Stripe data (web subscriptions)
|
||||
StripeCustomerID *string `gorm:"column:stripe_customer_id;size:255" json:"-"`
|
||||
|
||||
+16
-218
@@ -1,64 +1,38 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
import "time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User represents the auth_user table (Django's default User model)
|
||||
// User represents the auth_user table. Identity — credentials, email
|
||||
// verification, sessions, social sign-in — is owned by Ory Kratos (phase 2).
|
||||
// This row is honeyDue's local mirror of a Kratos identity, linked by
|
||||
// KratosID; every domain table keeps its existing integer FK to auth_user.id.
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Password string `gorm:"column:password;size:128;not null" json:"-"`
|
||||
LastLogin *time.Time `gorm:"column:last_login" json:"last_login,omitempty"`
|
||||
IsSuperuser bool `gorm:"column:is_superuser;default:false" json:"is_superuser"`
|
||||
Username string `gorm:"column:username;uniqueIndex;size:150;not null" json:"username"`
|
||||
KratosID string `gorm:"column:kratos_id;uniqueIndex;size:36" json:"-"` // Kratos identity UUID
|
||||
Username string `gorm:"column:username;uniqueIndex;size:150" json:"username"`
|
||||
FirstName string `gorm:"column:first_name;size:150" json:"first_name"`
|
||||
LastName string `gorm:"column:last_name;size:150" json:"last_name"`
|
||||
Email string `gorm:"column:email;uniqueIndex;size:254" json:"email"`
|
||||
IsStaff bool `gorm:"column:is_staff;default:false" json:"is_staff"`
|
||||
IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
|
||||
IsSuperuser bool `gorm:"column:is_superuser;default:false" json:"is_superuser"`
|
||||
DateJoined time.Time `gorm:"column:date_joined;autoCreateTime" json:"date_joined"`
|
||||
LastLogin *time.Time `gorm:"column:last_login" json:"last_login,omitempty"`
|
||||
|
||||
// Relations (not stored in auth_user table)
|
||||
// Relations — not columns on auth_user.
|
||||
Profile *UserProfile `gorm:"foreignKey:UserID" json:"profile,omitempty"`
|
||||
AuthToken *AuthToken `gorm:"foreignKey:UserID" json:"-"`
|
||||
OwnedResidences []Residence `gorm:"foreignKey:OwnerID" json:"-"`
|
||||
SharedResidences []Residence `gorm:"many2many:residence_residence_users;" json:"-"`
|
||||
NotificationPref *NotificationPreference `gorm:"foreignKey:UserID" json:"-"`
|
||||
Subscription *UserSubscription `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
// TableName returns the table name for GORM.
|
||||
func (User) TableName() string {
|
||||
return "auth_user"
|
||||
}
|
||||
|
||||
// SetPassword hashes and sets the password
|
||||
func (u *User) SetPassword(password string) error {
|
||||
// Django uses PBKDF2_SHA256 by default, but we'll use bcrypt for Go
|
||||
// Note: This means passwords set by Django won't work with Go's check
|
||||
// For migration, you'd need to either:
|
||||
// 1. Force password reset for all users
|
||||
// 2. Implement Django's PBKDF2 hasher in Go
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Password = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPassword verifies a password against the stored hash
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetFullName returns the user's full name
|
||||
// GetFullName returns the user's display name.
|
||||
func (u *User) GetFullName() string {
|
||||
if u.FirstName != "" && u.LastName != "" {
|
||||
return u.FirstName + " " + u.LastName
|
||||
@@ -69,57 +43,9 @@ func (u *User) GetFullName() string {
|
||||
return u.Username
|
||||
}
|
||||
|
||||
// AuthToken represents the user_authtoken table
|
||||
type AuthToken struct {
|
||||
Key string `gorm:"column:key;primaryKey;size:40" json:"key"`
|
||||
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
|
||||
Created time.Time `gorm:"column:created;autoCreateTime" json:"created"`
|
||||
|
||||
// Relations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (AuthToken) TableName() string {
|
||||
return "user_authtoken"
|
||||
}
|
||||
|
||||
// BeforeCreate generates a token key if not provided
|
||||
func (t *AuthToken) BeforeCreate(tx *gorm.DB) error {
|
||||
if t.Key == "" {
|
||||
t.Key = generateToken()
|
||||
}
|
||||
if t.Created.IsZero() {
|
||||
t.Created = time.Now().UTC()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateToken creates a random 40-character hex token
|
||||
func generateToken() string {
|
||||
b := make([]byte, 20)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// GetOrCreate gets an existing token or creates a new one for the user
|
||||
func GetOrCreateToken(tx *gorm.DB, userID uint) (*AuthToken, error) {
|
||||
var token AuthToken
|
||||
result := tx.Where("user_id = ?", userID).First(&token)
|
||||
|
||||
if result.Error == gorm.ErrRecordNotFound {
|
||||
token = AuthToken{UserID: userID}
|
||||
if err := tx.Create(&token).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// UserProfile represents the user_userprofile table
|
||||
// UserProfile represents the user_userprofile table — honeyDue-specific
|
||||
// profile data, keyed to a local user. Email-verification state is owned by
|
||||
// Kratos; the Verified column is a convenience mirror set at provision time.
|
||||
type UserProfile struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
|
||||
@@ -133,135 +59,7 @@ type UserProfile struct {
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
// TableName returns the table name for GORM.
|
||||
func (UserProfile) TableName() string {
|
||||
return "user_userprofile"
|
||||
}
|
||||
|
||||
// ConfirmationCode represents the user_confirmationcode table
|
||||
type ConfirmationCode struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"column:user_id;index;not null" json:"user_id"`
|
||||
Code string `gorm:"column:code;size:6;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expires_at"`
|
||||
IsUsed bool `gorm:"column:is_used;default:false" json:"is_used"`
|
||||
|
||||
// Relations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (ConfirmationCode) TableName() string {
|
||||
return "user_confirmationcode"
|
||||
}
|
||||
|
||||
// IsValid checks if the confirmation code is still valid
|
||||
func (c *ConfirmationCode) IsValid() bool {
|
||||
return !c.IsUsed && time.Now().UTC().Before(c.ExpiresAt)
|
||||
}
|
||||
|
||||
// GenerateCode creates a random 6-digit code
|
||||
func GenerateConfirmationCode() string {
|
||||
b := make([]byte, 3)
|
||||
rand.Read(b)
|
||||
// Convert to 6-digit number
|
||||
num := int(b[0])<<16 | int(b[1])<<8 | int(b[2])
|
||||
return string(rune('0'+num%10)) + string(rune('0'+(num/10)%10)) +
|
||||
string(rune('0'+(num/100)%10)) + string(rune('0'+(num/1000)%10)) +
|
||||
string(rune('0'+(num/10000)%10)) + string(rune('0'+(num/100000)%10))
|
||||
}
|
||||
|
||||
// PasswordResetCode represents the user_passwordresetcode table
|
||||
type PasswordResetCode struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"column:user_id;index;not null" json:"user_id"`
|
||||
CodeHash string `gorm:"column:code_hash;size:128;not null" json:"-"`
|
||||
ResetToken string `gorm:"column:reset_token;uniqueIndex;size:64;not null" json:"reset_token"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expires_at"`
|
||||
Used bool `gorm:"column:used;default:false" json:"used"`
|
||||
Attempts int `gorm:"column:attempts;default:0" json:"attempts"`
|
||||
MaxAttempts int `gorm:"column:max_attempts;default:5" json:"max_attempts"`
|
||||
|
||||
// Relations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (PasswordResetCode) TableName() string {
|
||||
return "user_passwordresetcode"
|
||||
}
|
||||
|
||||
// SetCode hashes and stores the reset code
|
||||
func (p *PasswordResetCode) SetCode(code string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.CodeHash = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckCode verifies a code against the stored hash
|
||||
func (p *PasswordResetCode) CheckCode(code string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(p.CodeHash), []byte(code))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsValid checks if the reset code is still valid
|
||||
func (p *PasswordResetCode) IsValid() bool {
|
||||
return !p.Used && time.Now().UTC().Before(p.ExpiresAt) && p.Attempts < p.MaxAttempts
|
||||
}
|
||||
|
||||
// IncrementAttempts increments the attempt counter
|
||||
func (p *PasswordResetCode) IncrementAttempts(tx *gorm.DB) error {
|
||||
p.Attempts++
|
||||
return tx.Model(p).Update("attempts", p.Attempts).Error
|
||||
}
|
||||
|
||||
// MarkAsUsed marks the code as used
|
||||
func (p *PasswordResetCode) MarkAsUsed(tx *gorm.DB) error {
|
||||
p.Used = true
|
||||
return tx.Model(p).Update("used", true).Error
|
||||
}
|
||||
|
||||
// GenerateResetToken creates a URL-safe token
|
||||
func GenerateResetToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// AppleSocialAuth represents a user's linked Apple ID for Sign in with Apple
|
||||
type AppleSocialAuth struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
AppleID string `gorm:"column:apple_id;size:255;uniqueIndex;not null" json:"apple_id"` // Apple's unique subject ID
|
||||
Email string `gorm:"column:email;size:254" json:"email"` // May be private relay
|
||||
IsPrivateEmail bool `gorm:"column:is_private_email;default:false" json:"is_private_email"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (AppleSocialAuth) TableName() string {
|
||||
return "user_applesocialauth"
|
||||
}
|
||||
|
||||
// GoogleSocialAuth represents a user's linked Google account for Sign in with Google
|
||||
type GoogleSocialAuth struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
GoogleID string `gorm:"column:google_id;size:255;uniqueIndex;not null" json:"google_id"` // Google's unique subject ID
|
||||
Email string `gorm:"column:email;size:254" json:"email"`
|
||||
Name string `gorm:"column:name;size:255" json:"name"`
|
||||
Picture string `gorm:"column:picture;size:512" json:"picture"` // Profile picture URL
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (GoogleSocialAuth) TableName() string {
|
||||
return "user_googlesocialauth"
|
||||
}
|
||||
|
||||
@@ -2,45 +2,10 @@ package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUser_SetPassword(t *testing.T) {
|
||||
user := &User{}
|
||||
|
||||
err := user.SetPassword("testPassword123")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, user.Password)
|
||||
assert.NotEqual(t, "testPassword123", user.Password) // Should be hashed
|
||||
}
|
||||
|
||||
func TestUser_CheckPassword(t *testing.T) {
|
||||
user := &User{}
|
||||
err := user.SetPassword("correctpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
expected bool
|
||||
}{
|
||||
{"correct password", "correctpassword", true},
|
||||
{"wrong password", "wrongpassword", false},
|
||||
{"empty password", "", false},
|
||||
{"similar password", "correctpassword1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := user.CheckPassword(tt.password)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_GetFullName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -82,136 +47,7 @@ func TestUser_TableName(t *testing.T) {
|
||||
assert.Equal(t, "auth_user", user.TableName())
|
||||
}
|
||||
|
||||
func TestAuthToken_TableName(t *testing.T) {
|
||||
token := AuthToken{}
|
||||
assert.Equal(t, "user_authtoken", token.TableName())
|
||||
}
|
||||
|
||||
func TestUserProfile_TableName(t *testing.T) {
|
||||
profile := UserProfile{}
|
||||
assert.Equal(t, "user_userprofile", profile.TableName())
|
||||
}
|
||||
|
||||
func TestConfirmationCode_IsValid(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
future := now.Add(1 * time.Hour)
|
||||
past := now.Add(-1 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code ConfirmationCode
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "valid code",
|
||||
code: ConfirmationCode{IsUsed: false, ExpiresAt: future},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "used code",
|
||||
code: ConfirmationCode{IsUsed: true, ExpiresAt: future},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "expired code",
|
||||
code: ConfirmationCode{IsUsed: false, ExpiresAt: past},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "used and expired",
|
||||
code: ConfirmationCode{IsUsed: true, ExpiresAt: past},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.code.IsValid()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetCode_IsValid(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
future := now.Add(1 * time.Hour)
|
||||
past := now.Add(-1 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code PasswordResetCode
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "valid code",
|
||||
code: PasswordResetCode{Used: false, ExpiresAt: future, Attempts: 0, MaxAttempts: 5},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "used code",
|
||||
code: PasswordResetCode{Used: true, ExpiresAt: future, Attempts: 0, MaxAttempts: 5},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "expired code",
|
||||
code: PasswordResetCode{Used: false, ExpiresAt: past, Attempts: 0, MaxAttempts: 5},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "max attempts reached",
|
||||
code: PasswordResetCode{Used: false, ExpiresAt: future, Attempts: 5, MaxAttempts: 5},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "attempts under max",
|
||||
code: PasswordResetCode{Used: false, ExpiresAt: future, Attempts: 4, MaxAttempts: 5},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.code.IsValid()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetCode_SetAndCheckCode(t *testing.T) {
|
||||
code := &PasswordResetCode{}
|
||||
|
||||
err := code.SetCode("123456")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, code.CodeHash)
|
||||
|
||||
// Check correct code
|
||||
assert.True(t, code.CheckCode("123456"))
|
||||
|
||||
// Check wrong code
|
||||
assert.False(t, code.CheckCode("654321"))
|
||||
assert.False(t, code.CheckCode(""))
|
||||
}
|
||||
|
||||
func TestGenerateConfirmationCode(t *testing.T) {
|
||||
code := GenerateConfirmationCode()
|
||||
assert.Len(t, code, 6)
|
||||
|
||||
// Generate multiple codes and ensure they're different
|
||||
codes := make(map[string]bool)
|
||||
for i := 0; i < 10; i++ {
|
||||
c := GenerateConfirmationCode()
|
||||
assert.Len(t, c, 6)
|
||||
codes[c] = true
|
||||
}
|
||||
// Most codes should be unique (very unlikely to have collisions)
|
||||
assert.Greater(t, len(codes), 5)
|
||||
}
|
||||
|
||||
func TestGenerateResetToken(t *testing.T) {
|
||||
token := GenerateResetToken()
|
||||
assert.Len(t, token, 64) // 32 bytes = 64 hex chars
|
||||
|
||||
// Ensure uniqueness
|
||||
token2 := GenerateResetToken()
|
||||
assert.NotEqual(t, token, token2)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package prom
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -54,6 +55,11 @@ var (
|
||||
Help: "Duration of asynq background job execution in seconds.",
|
||||
Buckets: []float64{0.01, 0.05, 0.1, 0.5, 1, 5, 10, 30, 60, 300},
|
||||
}, []string{"task_type", "result"})
|
||||
|
||||
cacheOps = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "cache_ops_total",
|
||||
Help: "Redis cache operations by type and result.",
|
||||
}, []string{"operation", "result"}) // operation: get|set; result: hit|miss|ok|error
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -67,6 +73,7 @@ func init() {
|
||||
apnsSendDuration,
|
||||
fcmSendDuration,
|
||||
asynqJobDuration,
|
||||
cacheOps,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,6 +84,20 @@ func Handler() echo.HandlerFunc {
|
||||
return echo.WrapHandler(h)
|
||||
}
|
||||
|
||||
// HTTPHandler returns a net/http handler bound to the package Registry, for the
|
||||
// worker's plain http.ServeMux (the api uses Handler() for Echo). This is what
|
||||
// lets the worker's apns/fcm/asynq histograms actually get scraped — they were
|
||||
// recorded all along but the worker exposed no /metrics endpoint.
|
||||
func HTTPHandler() http.Handler {
|
||||
return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{Registry: Registry})
|
||||
}
|
||||
|
||||
// ObserveCacheOp records a Redis cache operation. operation is "get" or "set";
|
||||
// result is "hit"/"miss"/"error" for gets and "ok"/"error" for sets.
|
||||
func ObserveCacheOp(operation, result string) {
|
||||
cacheOps.WithLabelValues(operation, result).Inc()
|
||||
}
|
||||
|
||||
// HTTPMiddleware records http_request_duration_seconds for every request,
|
||||
// labeled by Echo route pattern, method, and status code.
|
||||
func HTTPMiddleware() echo.MiddlewareFunc {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
)
|
||||
@@ -194,6 +195,60 @@ func (r *ResidenceRepository) HasAccess(residenceID, userID uint) (bool, error)
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// JoinWithShareCode atomically redeems a one-time share code (audit C9/H9):
|
||||
// it locks the share-code row, re-checks validity under the lock, adds the
|
||||
// user to the residence, and deactivates the code — all in one transaction.
|
||||
// Concurrent redemptions of the same code serialize on the row lock; the
|
||||
// loser sees is_active=false and is rejected. A failure to deactivate aborts
|
||||
// the whole join. Returns gorm.ErrRecordNotFound for an unknown, inactive, or
|
||||
// expired code so the caller can map every case to one generic error.
|
||||
func (r *ResidenceRepository) JoinWithShareCode(code string, userID uint) (residenceID uint, alreadyMember bool, err error) {
|
||||
err = r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var sc models.ResidenceShareCode
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("code = ?", code).First(&sc).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if !sc.IsActive || (sc.ExpiresAt != nil && time.Now().UTC().After(*sc.ExpiresAt)) {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
residenceID = sc.ResidenceID
|
||||
|
||||
// Already a member (owner or shared user)?
|
||||
var accessCount int64
|
||||
if e := tx.Raw(`
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM residence_residence
|
||||
WHERE id = ? AND owner_id = ? AND is_active = true
|
||||
UNION
|
||||
SELECT 1 FROM residence_residence_users
|
||||
WHERE residence_id = ? AND user_id = ?
|
||||
) ac
|
||||
`, sc.ResidenceID, userID, sc.ResidenceID, userID).Scan(&accessCount).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if accessCount > 0 {
|
||||
alreadyMember = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if e := tx.Exec(
|
||||
"INSERT INTO residence_residence_users (residence_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
||||
sc.ResidenceID, userID,
|
||||
).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
// One-time use: deactivate the code. A failure here aborts the join.
|
||||
if e := tx.Model(&models.ResidenceShareCode{}).
|
||||
Where("id = ?", sc.ID).Update("is_active", false).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return residenceID, alreadyMember, err
|
||||
}
|
||||
|
||||
// IsOwner checks if a user is the owner of a residence
|
||||
func (r *ResidenceRepository) IsOwner(residenceID, userID uint) (bool, error) {
|
||||
var count int64
|
||||
|
||||
@@ -151,6 +151,28 @@ func (r *SubscriptionRepository) FindByAppleReceiptContains(transactionID string
|
||||
return &sub, nil
|
||||
}
|
||||
|
||||
// FindByAppleOriginalTransactionID finds a subscription by the Apple original
|
||||
// transaction ID (audit C5/C13). Exact match on an indexed column — replaces
|
||||
// the LIKE scan in FindByAppleReceiptContains for both replay detection and
|
||||
// webhook user lookup.
|
||||
func (r *SubscriptionRepository) FindByAppleOriginalTransactionID(originalTransactionID string) (*models.UserSubscription, error) {
|
||||
var sub models.UserSubscription
|
||||
err := r.db.Where("apple_original_transaction_id = ?", originalTransactionID).First(&sub).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sub, nil
|
||||
}
|
||||
|
||||
// UpdateAppleOriginalTransactionID binds an Apple original transaction ID to a
|
||||
// user's subscription. A partial unique index enforces one account per
|
||||
// transaction (audit C5) — a second account claiming the same ID fails here.
|
||||
func (r *SubscriptionRepository) UpdateAppleOriginalTransactionID(userID uint, originalTransactionID string) error {
|
||||
return r.db.Model(&models.UserSubscription{}).
|
||||
Where("user_id = ?", userID).
|
||||
Update("apple_original_transaction_id", originalTransactionID).Error
|
||||
}
|
||||
|
||||
// FindByGoogleToken finds a subscription by Google purchase token
|
||||
// Used by webhooks to find the user associated with a purchase
|
||||
func (r *SubscriptionRepository) FindByGoogleToken(purchaseToken string) (*models.UserSubscription, error) {
|
||||
|
||||
@@ -226,3 +226,48 @@ func TestUpdateExpiresAt(t *testing.T) {
|
||||
require.NotNil(t, updated.ExpiresAt)
|
||||
assert.WithinDuration(t, newExpiry, *updated.ExpiresAt, time.Second, "expires_at should be updated")
|
||||
}
|
||||
|
||||
// TestSubscriptionRepo_IAPTransactionReplayRejected is the regression test for
|
||||
// audit C5/C6: an in-app-purchase transaction (an Apple original transaction
|
||||
// ID or a Google purchase token) may be bound to exactly one account. Without
|
||||
// that guarantee a valid receipt could be replayed against a second account
|
||||
// to grant Pro for free. The guarantee is the pair of partial unique indexes
|
||||
// added by migration 000004; AutoMigrate does not create them, so this test
|
||||
// recreates them verbatim to exercise the same DB-level enforcement.
|
||||
func TestSubscriptionRepo_IAPTransactionReplayRejected(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
require.NoError(t, db.Exec(`CREATE UNIQUE INDEX uq_subscription_apple_original_txn `+
|
||||
`ON subscription_usersubscription (apple_original_transaction_id) `+
|
||||
`WHERE apple_original_transaction_id IS NOT NULL AND apple_original_transaction_id <> ''`).Error)
|
||||
require.NoError(t, db.Exec(`CREATE UNIQUE INDEX uq_subscription_google_purchase_token `+
|
||||
`ON subscription_usersubscription (google_purchase_token) `+
|
||||
`WHERE google_purchase_token IS NOT NULL AND google_purchase_token <> ''`).Error)
|
||||
|
||||
repo := NewSubscriptionRepository(db)
|
||||
userA := testutil.CreateTestUser(t, db, "iapusera", "iapa@test.com", "password")
|
||||
userB := testutil.CreateTestUser(t, db, "iapuserb", "iapb@test.com", "password")
|
||||
require.NoError(t, db.Create(&models.UserSubscription{UserID: userA.ID, Tier: models.TierFree}).Error)
|
||||
require.NoError(t, db.Create(&models.UserSubscription{UserID: userB.ID, Tier: models.TierFree}).Error)
|
||||
|
||||
t.Run("apple transaction cannot be claimed by a second account", func(t *testing.T) {
|
||||
require.NoError(t, repo.UpdateAppleOriginalTransactionID(userA.ID, "apple-original-txn-1"),
|
||||
"the first account binding the transaction must succeed")
|
||||
err := repo.UpdateAppleOriginalTransactionID(userB.ID, "apple-original-txn-1")
|
||||
require.Error(t, err,
|
||||
"replaying account A's Apple transaction onto account B must be rejected (C5)")
|
||||
})
|
||||
|
||||
t.Run("google purchase token cannot be claimed by a second account", func(t *testing.T) {
|
||||
require.NoError(t, repo.UpdatePurchaseToken(userA.ID, "google-purchase-token-1"),
|
||||
"the first account binding the token must succeed")
|
||||
err := repo.UpdatePurchaseToken(userB.ID, "google-purchase-token-1")
|
||||
require.Error(t, err,
|
||||
"replaying account A's Google purchase token onto account B must be rejected (C6)")
|
||||
})
|
||||
|
||||
t.Run("re-binding the same transaction to the same account is allowed", func(t *testing.T) {
|
||||
// A renewal re-submitting the same transaction for its owner must not
|
||||
// be rejected — the partial unique index excludes the row's own value.
|
||||
require.NoError(t, repo.UpdateAppleOriginalTransactionID(userA.ID, "apple-original-txn-1"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,18 +11,21 @@ import (
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
)
|
||||
|
||||
// FindByKratosID finds a user by Kratos identity UUID.
|
||||
func (r *UserRepository) FindByKratosID(kratosID string) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := r.db.Where("kratos_id = ?", kratosID).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrTokenNotFound = errors.New("token not found")
|
||||
ErrCodeNotFound = errors.New("code not found")
|
||||
ErrCodeExpired = errors.New("code expired")
|
||||
ErrCodeUsed = errors.New("code already used")
|
||||
ErrTooManyAttempts = errors.New("too many attempts")
|
||||
ErrRateLimitExceeded = errors.New("rate limit exceeded")
|
||||
ErrAppleAuthNotFound = errors.New("apple social auth not found")
|
||||
ErrGoogleAuthNotFound = errors.New("google social auth not found")
|
||||
)
|
||||
|
||||
// UserRepository handles user-related database operations
|
||||
@@ -63,6 +66,16 @@ func (r *UserRepository) FindByID(id uint) (*models.User, error) {
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// MarkVerified sets user_userprofile.verified=true for the given user.
|
||||
// Syncs the local mirror with Kratos's verifiable_addresses.verified after
|
||||
// a successful verification flow. Idempotent — re-flipping an already-true
|
||||
// row is a guarded no-op write.
|
||||
func (r *UserRepository) MarkVerified(userID uint) error {
|
||||
return r.db.Model(&models.UserProfile{}).
|
||||
Where("user_id = ? AND verified = ?", userID, false).
|
||||
Update("verified", true).Error
|
||||
}
|
||||
|
||||
// FindByIDWithProfile finds a user by ID with profile preloaded
|
||||
func (r *UserRepository) FindByIDWithProfile(id uint) (*models.User, error) {
|
||||
var user models.User
|
||||
@@ -145,72 +158,6 @@ func (r *UserRepository) ExistsByEmail(email string) (bool, error) {
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// --- Auth Token Methods ---
|
||||
|
||||
// GetOrCreateToken gets or creates an auth token for a user.
|
||||
// Wrapped in a transaction to prevent race conditions where two
|
||||
// concurrent requests could create duplicate tokens for the same user.
|
||||
func (r *UserRepository) GetOrCreateToken(userID uint) (*models.AuthToken, error) {
|
||||
var token models.AuthToken
|
||||
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Where("user_id = ?", userID).First(&token)
|
||||
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
token = models.AuthToken{UserID: userID}
|
||||
if err := tx.Create(&token).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// FindTokenByKey looks up an auth token by its key value.
|
||||
func (r *UserRepository) FindTokenByKey(key string) (*models.AuthToken, error) {
|
||||
var token models.AuthToken
|
||||
if err := r.db.Where("key = ?", key).First(&token).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// CreateToken creates a new auth token for a user.
|
||||
func (r *UserRepository) CreateToken(userID uint) (*models.AuthToken, error) {
|
||||
token := models.AuthToken{UserID: userID}
|
||||
if err := r.db.Create(&token).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// DeleteToken deletes an auth token
|
||||
func (r *UserRepository) DeleteToken(token string) error {
|
||||
result := r.db.Where("key = ?", token).Delete(&models.AuthToken{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrTokenNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTokenByUserID deletes an auth token by user ID
|
||||
func (r *UserRepository) DeleteTokenByUserID(userID uint) error {
|
||||
return r.db.Where("user_id = ?", userID).Delete(&models.AuthToken{}).Error
|
||||
}
|
||||
|
||||
// --- User Profile Methods ---
|
||||
|
||||
@@ -241,146 +188,6 @@ func (r *UserRepository) SetProfileVerified(userID uint, verified bool) error {
|
||||
return r.db.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("verified", verified).Error
|
||||
}
|
||||
|
||||
// --- Confirmation Code Methods ---
|
||||
|
||||
// CreateConfirmationCode creates a new confirmation code
|
||||
func (r *UserRepository) CreateConfirmationCode(userID uint, code string, expiresAt time.Time) (*models.ConfirmationCode, error) {
|
||||
// Invalidate any existing unused codes for this user
|
||||
r.db.Model(&models.ConfirmationCode{}).
|
||||
Where("user_id = ? AND is_used = ?", userID, false).
|
||||
Update("is_used", true)
|
||||
|
||||
confirmCode := &models.ConfirmationCode{
|
||||
UserID: userID,
|
||||
Code: code,
|
||||
ExpiresAt: expiresAt,
|
||||
IsUsed: false,
|
||||
}
|
||||
|
||||
if err := r.db.Create(confirmCode).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return confirmCode, nil
|
||||
}
|
||||
|
||||
// FindConfirmationCode finds a valid confirmation code for a user
|
||||
func (r *UserRepository) FindConfirmationCode(userID uint, code string) (*models.ConfirmationCode, error) {
|
||||
var confirmCode models.ConfirmationCode
|
||||
if err := r.db.Where("user_id = ? AND code = ? AND is_used = ?", userID, code, false).
|
||||
First(&confirmCode).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrCodeNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !confirmCode.IsValid() {
|
||||
if confirmCode.IsUsed {
|
||||
return nil, ErrCodeUsed
|
||||
}
|
||||
return nil, ErrCodeExpired
|
||||
}
|
||||
|
||||
return &confirmCode, nil
|
||||
}
|
||||
|
||||
// MarkConfirmationCodeUsed marks a confirmation code as used
|
||||
func (r *UserRepository) MarkConfirmationCodeUsed(codeID uint) error {
|
||||
return r.db.Model(&models.ConfirmationCode{}).Where("id = ?", codeID).Update("is_used", true).Error
|
||||
}
|
||||
|
||||
// --- Password Reset Code Methods ---
|
||||
|
||||
// CreatePasswordResetCode creates a new password reset code
|
||||
func (r *UserRepository) CreatePasswordResetCode(userID uint, codeHash string, resetToken string, expiresAt time.Time) (*models.PasswordResetCode, error) {
|
||||
// Invalidate any existing unused codes for this user
|
||||
r.db.Model(&models.PasswordResetCode{}).
|
||||
Where("user_id = ? AND used = ?", userID, false).
|
||||
Update("used", true)
|
||||
|
||||
resetCode := &models.PasswordResetCode{
|
||||
UserID: userID,
|
||||
CodeHash: codeHash,
|
||||
ResetToken: resetToken,
|
||||
ExpiresAt: expiresAt,
|
||||
Used: false,
|
||||
Attempts: 0,
|
||||
MaxAttempts: 5,
|
||||
}
|
||||
|
||||
if err := r.db.Create(resetCode).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resetCode, nil
|
||||
}
|
||||
|
||||
// FindPasswordResetCode finds a password reset code by email and checks validity
|
||||
func (r *UserRepository) FindPasswordResetCodeByEmail(email string) (*models.PasswordResetCode, *models.User, error) {
|
||||
user, err := r.FindByEmail(email)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var resetCode models.PasswordResetCode
|
||||
if err := r.db.Where("user_id = ? AND used = ?", user.ID, false).
|
||||
Order("created_at DESC").
|
||||
First(&resetCode).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, ErrCodeNotFound
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &resetCode, user, nil
|
||||
}
|
||||
|
||||
// FindPasswordResetCodeByToken finds a password reset code by reset token
|
||||
func (r *UserRepository) FindPasswordResetCodeByToken(resetToken string) (*models.PasswordResetCode, error) {
|
||||
var resetCode models.PasswordResetCode
|
||||
if err := r.db.Where("reset_token = ?", resetToken).First(&resetCode).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrCodeNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resetCode.IsValid() {
|
||||
if resetCode.Used {
|
||||
return nil, ErrCodeUsed
|
||||
}
|
||||
if resetCode.Attempts >= resetCode.MaxAttempts {
|
||||
return nil, ErrTooManyAttempts
|
||||
}
|
||||
return nil, ErrCodeExpired
|
||||
}
|
||||
|
||||
return &resetCode, nil
|
||||
}
|
||||
|
||||
// IncrementResetCodeAttempts increments the attempt counter
|
||||
func (r *UserRepository) IncrementResetCodeAttempts(codeID uint) error {
|
||||
return r.db.Model(&models.PasswordResetCode{}).Where("id = ?", codeID).
|
||||
Update("attempts", gorm.Expr("attempts + 1")).Error
|
||||
}
|
||||
|
||||
// MarkPasswordResetCodeUsed marks a password reset code as used
|
||||
func (r *UserRepository) MarkPasswordResetCodeUsed(codeID uint) error {
|
||||
return r.db.Model(&models.PasswordResetCode{}).Where("id = ?", codeID).Update("used", true).Error
|
||||
}
|
||||
|
||||
// CountRecentPasswordResetRequests counts reset requests in the last hour
|
||||
func (r *UserRepository) CountRecentPasswordResetRequests(userID uint) (int64, error) {
|
||||
var count int64
|
||||
oneHourAgo := time.Now().UTC().Add(-1 * time.Hour)
|
||||
if err := r.db.Model(&models.PasswordResetCode{}).
|
||||
Where("user_id = ? AND created_at > ?", userID, oneHourAgo).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// --- Search Methods ---
|
||||
|
||||
@@ -537,27 +344,11 @@ func (r *UserRepository) FindProfilesInSharedResidences(userID uint) ([]models.U
|
||||
return profiles, err
|
||||
}
|
||||
|
||||
// --- Auth Provider Detection ---
|
||||
|
||||
// FindAuthProvider determines the auth provider for a user.
|
||||
// Returns "apple", "google", or "email".
|
||||
func (r *UserRepository) FindAuthProvider(userID uint) (string, error) {
|
||||
var count int64
|
||||
if err := r.db.Model(&models.AppleSocialAuth{}).Where("user_id = ?", userID).Count(&count).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
if count > 0 {
|
||||
return "apple", nil
|
||||
}
|
||||
|
||||
if err := r.db.Model(&models.GoogleSocialAuth{}).Where("user_id = ?", userID).Count(&count).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
if count > 0 {
|
||||
return "google", nil
|
||||
}
|
||||
|
||||
return "email", nil
|
||||
// FindAuthProvider returns "kratos" for all Kratos-managed users (the sole
|
||||
// provider after the Ory Kratos migration). Kept for compatibility with
|
||||
// callers that still check the provider string.
|
||||
func (r *UserRepository) FindAuthProvider(_ uint) (string, error) {
|
||||
return "kratos", nil
|
||||
}
|
||||
|
||||
// --- Account Deletion ---
|
||||
@@ -682,35 +473,12 @@ func (r *UserRepository) DeleteUserCascade(userID uint) ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 8. Social auth records
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.AppleSocialAuth{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.GoogleSocialAuth{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 9. Confirmation codes
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.ConfirmationCode{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 10. Password reset codes
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.PasswordResetCode{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 11. Auth tokens
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.AuthToken{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 12. User profile
|
||||
// 8. User profile
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.UserProfile{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 13. User
|
||||
// 9. User
|
||||
if err := db.Where("id = ?", userID).Delete(&models.User{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -726,53 +494,6 @@ func (r *UserRepository) DeleteUserCascade(userID uint) ([]string, error) {
|
||||
return cleanURLs, nil
|
||||
}
|
||||
|
||||
// --- Apple Social Auth Methods ---
|
||||
|
||||
// FindByAppleID finds an Apple social auth by Apple ID
|
||||
func (r *UserRepository) FindByAppleID(appleID string) (*models.AppleSocialAuth, error) {
|
||||
var auth models.AppleSocialAuth
|
||||
if err := r.db.Where("apple_id = ?", appleID).First(&auth).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrAppleAuthNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &auth, nil
|
||||
}
|
||||
|
||||
// CreateAppleSocialAuth creates a new Apple social auth record
|
||||
func (r *UserRepository) CreateAppleSocialAuth(auth *models.AppleSocialAuth) error {
|
||||
return r.db.Create(auth).Error
|
||||
}
|
||||
|
||||
// UpdateAppleSocialAuth updates an Apple social auth record
|
||||
func (r *UserRepository) UpdateAppleSocialAuth(auth *models.AppleSocialAuth) error {
|
||||
return r.db.Save(auth).Error
|
||||
}
|
||||
|
||||
// --- Google Social Auth Methods ---
|
||||
|
||||
// FindByGoogleID finds a Google social auth by Google ID
|
||||
func (r *UserRepository) FindByGoogleID(googleID string) (*models.GoogleSocialAuth, error) {
|
||||
var auth models.GoogleSocialAuth
|
||||
if err := r.db.Where("google_id = ?", googleID).First(&auth).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrGoogleAuthNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &auth, nil
|
||||
}
|
||||
|
||||
// CreateGoogleSocialAuth creates a new Google social auth record
|
||||
func (r *UserRepository) CreateGoogleSocialAuth(auth *models.GoogleSocialAuth) error {
|
||||
return r.db.Create(auth).Error
|
||||
}
|
||||
|
||||
// UpdateGoogleSocialAuth updates a Google social auth record
|
||||
func (r *UserRepository) UpdateGoogleSocialAuth(auth *models.GoogleSocialAuth) error {
|
||||
return r.db.Save(auth).Error
|
||||
}
|
||||
|
||||
// WithContext returns a copy of the repository whose underlying *gorm.DB carries
|
||||
// the supplied context. SQL emitted via this copy gets attached to ctx's trace span
|
||||
|
||||
@@ -2,7 +2,6 @@ package repositories
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -78,99 +77,25 @@ func TestUserRepository_ExistsByEmail_CaseInsensitive(t *testing.T) {
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
func TestUserRepository_GetOrCreateToken(t *testing.T) {
|
||||
func TestUserRepository_FindByKratosID(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
user := testutil.CreateTestUser(t, db, "kratosuser", "kratos@example.com", "")
|
||||
|
||||
// Create token
|
||||
token1, err := repo.GetOrCreateToken(user.ID)
|
||||
found, err := repo.FindByKratosID(user.KratosID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token1.Key)
|
||||
|
||||
// Should return same token
|
||||
token2, err := repo.GetOrCreateToken(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, token1.Key, token2.Key)
|
||||
assert.Equal(t, user.ID, found.ID)
|
||||
assert.Equal(t, user.KratosID, found.KratosID)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindTokenByKey(t *testing.T) {
|
||||
func TestUserRepository_FindByKratosID_NotFound(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
token, err := repo.GetOrCreateToken(user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := repo.FindTokenByKey(token.Key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, token.Key, found.Key)
|
||||
assert.Equal(t, user.ID, found.UserID)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindTokenByKey_NotFound(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
_, err := repo.FindTokenByKey("nonexistent-token-key")
|
||||
_, err := repo.FindByKratosID("nonexistent-kratos-id")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrTokenNotFound)
|
||||
}
|
||||
|
||||
func TestUserRepository_DeleteToken(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
token, err := repo.GetOrCreateToken(user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = repo.DeleteToken(token.Key)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = repo.FindTokenByKey(token.Key)
|
||||
assert.ErrorIs(t, err, ErrTokenNotFound)
|
||||
}
|
||||
|
||||
func TestUserRepository_DeleteToken_NotFound(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
err := repo.DeleteToken("nonexistent-key")
|
||||
assert.ErrorIs(t, err, ErrTokenNotFound)
|
||||
}
|
||||
|
||||
func TestUserRepository_DeleteTokenByUserID(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
_, err := repo.GetOrCreateToken(user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = repo.DeleteTokenByUserID(user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Token should be gone
|
||||
var count int64
|
||||
db.Model(&models.AuthToken{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestUserRepository_CreateToken(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
token, err := repo.CreateToken(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token.Key)
|
||||
assert.Equal(t, user.ID, token.UserID)
|
||||
assert.ErrorIs(t, err, ErrUserNotFound)
|
||||
}
|
||||
|
||||
func TestUserRepository_UpdateLastLogin(t *testing.T) {
|
||||
@@ -255,54 +180,6 @@ func TestUserRepository_FindByIDWithProfile_NotFound(t *testing.T) {
|
||||
assert.ErrorIs(t, err, ErrUserNotFound)
|
||||
}
|
||||
|
||||
func TestUserRepository_ConfirmationCode_Lifecycle(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
// Create confirmation code
|
||||
expiresAt := time.Now().UTC().Add(1 * time.Hour)
|
||||
code, err := repo.CreateConfirmationCode(user.ID, "123456", expiresAt)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, code.ID)
|
||||
|
||||
// Find it
|
||||
found, err := repo.FindConfirmationCode(user.ID, "123456")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, code.ID, found.ID)
|
||||
|
||||
// Mark as used
|
||||
err = repo.MarkConfirmationCodeUsed(code.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should not find used code
|
||||
_, err = repo.FindConfirmationCode(user.ID, "123456")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUserRepository_ConfirmationCode_InvalidatesExisting(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
expiresAt := time.Now().UTC().Add(1 * time.Hour)
|
||||
|
||||
// Create first code
|
||||
code1, err := repo.CreateConfirmationCode(user.ID, "111111", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create second code (should invalidate first)
|
||||
_, err = repo.CreateConfirmationCode(user.ID, "222222", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// First code should be used/invalidated
|
||||
var c models.ConfirmationCode
|
||||
db.First(&c, code1.ID)
|
||||
assert.True(t, c.IsUsed)
|
||||
}
|
||||
|
||||
func TestUserRepository_Transaction(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
@@ -331,105 +208,6 @@ func TestUserRepository_DB(t *testing.T) {
|
||||
assert.NotNil(t, repo.DB())
|
||||
}
|
||||
|
||||
func TestUserRepository_FindByAppleID(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "appleuser", "apple@test.com", "Password123")
|
||||
appleAuth := &models.AppleSocialAuth{
|
||||
UserID: user.ID,
|
||||
AppleID: "apple_sub_123",
|
||||
Email: "apple@test.com",
|
||||
}
|
||||
require.NoError(t, db.Create(appleAuth).Error)
|
||||
|
||||
found, err := repo.FindByAppleID("apple_sub_123")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID, found.UserID)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindByAppleID_NotFound(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
_, err := repo.FindByAppleID("nonexistent_apple_id")
|
||||
assert.ErrorIs(t, err, ErrAppleAuthNotFound)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindByGoogleID(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "googleuser", "google@test.com", "Password123")
|
||||
googleAuth := &models.GoogleSocialAuth{
|
||||
UserID: user.ID,
|
||||
GoogleID: "google_sub_123",
|
||||
Email: "google@test.com",
|
||||
}
|
||||
require.NoError(t, db.Create(googleAuth).Error)
|
||||
|
||||
found, err := repo.FindByGoogleID("google_sub_123")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID, found.UserID)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindByGoogleID_NotFound(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
_, err := repo.FindByGoogleID("nonexistent_google_id")
|
||||
assert.ErrorIs(t, err, ErrGoogleAuthNotFound)
|
||||
}
|
||||
|
||||
func TestUserRepository_CreateAndUpdateAppleSocialAuth(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "appleuser", "apple@test.com", "Password123")
|
||||
|
||||
auth := &models.AppleSocialAuth{
|
||||
UserID: user.ID,
|
||||
AppleID: "apple_sub_456",
|
||||
Email: "apple@test.com",
|
||||
}
|
||||
err := repo.CreateAppleSocialAuth(auth)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, auth.ID)
|
||||
|
||||
auth.Email = "updated@test.com"
|
||||
err = repo.UpdateAppleSocialAuth(auth)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := repo.FindByAppleID("apple_sub_456")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "updated@test.com", found.Email)
|
||||
}
|
||||
|
||||
func TestUserRepository_CreateAndUpdateGoogleSocialAuth(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "googleuser", "google@test.com", "Password123")
|
||||
|
||||
auth := &models.GoogleSocialAuth{
|
||||
UserID: user.ID,
|
||||
GoogleID: "google_sub_456",
|
||||
Email: "google@test.com",
|
||||
Name: "Test User",
|
||||
}
|
||||
err := repo.CreateGoogleSocialAuth(auth)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, auth.ID)
|
||||
|
||||
auth.Name = "Updated Name"
|
||||
err = repo.UpdateGoogleSocialAuth(auth)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := repo.FindByGoogleID("google_sub_456")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated Name", found.Name)
|
||||
}
|
||||
|
||||
func TestUserRepository_SearchUsers(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
@@ -2,7 +2,6 @@ package repositories
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -11,207 +10,6 @@ import (
|
||||
"github.com/treytartt/honeydue-api/internal/testutil"
|
||||
)
|
||||
|
||||
// === Password Reset Code Lifecycle ===
|
||||
|
||||
func TestUserRepository_PasswordResetCode_Lifecycle(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
expiresAt := time.Now().UTC().Add(1 * time.Hour)
|
||||
code, err := repo.CreatePasswordResetCode(user.ID, "hash_abc123", "reset_token_xyz", expiresAt)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, code.ID)
|
||||
assert.Equal(t, "hash_abc123", code.CodeHash)
|
||||
assert.Equal(t, "reset_token_xyz", code.ResetToken)
|
||||
assert.False(t, code.Used)
|
||||
assert.Equal(t, 0, code.Attempts)
|
||||
}
|
||||
|
||||
func TestUserRepository_CreatePasswordResetCode_InvalidatesExisting(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
expiresAt := time.Now().UTC().Add(1 * time.Hour)
|
||||
|
||||
code1, err := repo.CreatePasswordResetCode(user.ID, "hash1", "token1", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = repo.CreatePasswordResetCode(user.ID, "hash2", "token2", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// First code should be marked as used
|
||||
var c models.PasswordResetCode
|
||||
db.First(&c, code1.ID)
|
||||
assert.True(t, c.Used)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindPasswordResetCodeByEmail(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
expiresAt := time.Now().UTC().Add(1 * time.Hour)
|
||||
_, err := repo.CreatePasswordResetCode(user.ID, "hash_abc", "token_abc", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, foundUser, err := repo.FindPasswordResetCodeByEmail("test@example.com")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID, foundUser.ID)
|
||||
assert.Equal(t, "hash_abc", found.CodeHash)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindPasswordResetCodeByEmail_UserNotFound(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
_, _, err := repo.FindPasswordResetCodeByEmail("nonexistent@example.com")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindPasswordResetCodeByEmail_NoCode(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
_, _, err := repo.FindPasswordResetCodeByEmail("test@example.com")
|
||||
assert.ErrorIs(t, err, ErrCodeNotFound)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindPasswordResetCodeByToken(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
expiresAt := time.Now().UTC().Add(1 * time.Hour)
|
||||
_, err := repo.CreatePasswordResetCode(user.ID, "hash_xyz", "token_xyz", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := repo.FindPasswordResetCodeByToken("token_xyz")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hash_xyz", found.CodeHash)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindPasswordResetCodeByToken_NotFound(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
_, err := repo.FindPasswordResetCodeByToken("nonexistent_token")
|
||||
assert.ErrorIs(t, err, ErrCodeNotFound)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindPasswordResetCodeByToken_Expired(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
// Already expired
|
||||
expiresAt := time.Now().UTC().Add(-1 * time.Hour)
|
||||
_, err := repo.CreatePasswordResetCode(user.ID, "hash_exp", "token_exp", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = repo.FindPasswordResetCodeByToken("token_exp")
|
||||
assert.ErrorIs(t, err, ErrCodeExpired)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindPasswordResetCodeByToken_Used(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
expiresAt := time.Now().UTC().Add(1 * time.Hour)
|
||||
code, err := repo.CreatePasswordResetCode(user.ID, "hash_used", "token_used", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mark as used
|
||||
err = repo.MarkPasswordResetCodeUsed(code.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = repo.FindPasswordResetCodeByToken("token_used")
|
||||
assert.ErrorIs(t, err, ErrCodeUsed)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindPasswordResetCodeByToken_TooManyAttempts(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
expiresAt := time.Now().UTC().Add(1 * time.Hour)
|
||||
code, err := repo.CreatePasswordResetCode(user.ID, "hash_attempts", "token_attempts", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Max out attempts
|
||||
for i := 0; i < 5; i++ {
|
||||
err = repo.IncrementResetCodeAttempts(code.ID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
_, err = repo.FindPasswordResetCodeByToken("token_attempts")
|
||||
assert.ErrorIs(t, err, ErrTooManyAttempts)
|
||||
}
|
||||
|
||||
func TestUserRepository_IncrementResetCodeAttempts(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
expiresAt := time.Now().UTC().Add(1 * time.Hour)
|
||||
code, err := repo.CreatePasswordResetCode(user.ID, "hash_inc", "token_inc", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = repo.IncrementResetCodeAttempts(code.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var updated models.PasswordResetCode
|
||||
db.First(&updated, code.ID)
|
||||
assert.Equal(t, 1, updated.Attempts)
|
||||
}
|
||||
|
||||
func TestUserRepository_MarkPasswordResetCodeUsed(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
expiresAt := time.Now().UTC().Add(1 * time.Hour)
|
||||
code, err := repo.CreatePasswordResetCode(user.ID, "hash_mark", "token_mark", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = repo.MarkPasswordResetCodeUsed(code.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var updated models.PasswordResetCode
|
||||
db.First(&updated, code.ID)
|
||||
assert.True(t, updated.Used)
|
||||
}
|
||||
|
||||
func TestUserRepository_CountRecentPasswordResetRequests(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
expiresAt := time.Now().UTC().Add(1 * time.Hour)
|
||||
_, err := repo.CreatePasswordResetCode(user.ID, "hash1", "token1", expiresAt)
|
||||
require.NoError(t, err)
|
||||
_, err = repo.CreatePasswordResetCode(user.ID, "hash2", "token2", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
count, err := repo.CountRecentPasswordResetRequests(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), count)
|
||||
}
|
||||
|
||||
// === FindUsersInSharedResidences ===
|
||||
|
||||
func TestUserRepository_FindUsersInSharedResidences(t *testing.T) {
|
||||
@@ -301,33 +99,6 @@ func TestUserRepository_FindProfilesInSharedResidences(t *testing.T) {
|
||||
assert.Len(t, profiles, 2)
|
||||
}
|
||||
|
||||
// === ConfirmationCode Expired ===
|
||||
|
||||
func TestUserRepository_FindConfirmationCode_Expired(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
// Create already-expired code
|
||||
expiresAt := time.Now().UTC().Add(-1 * time.Hour)
|
||||
_, err := repo.CreateConfirmationCode(user.ID, "999999", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = repo.FindConfirmationCode(user.ID, "999999")
|
||||
assert.ErrorIs(t, err, ErrCodeExpired)
|
||||
}
|
||||
|
||||
func TestUserRepository_FindConfirmationCode_NotFound(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123")
|
||||
|
||||
_, err := repo.FindConfirmationCode(user.ID, "000000")
|
||||
assert.ErrorIs(t, err, ErrCodeNotFound)
|
||||
}
|
||||
|
||||
// === Transaction Rollback ===
|
||||
|
||||
func TestUserRepository_Transaction_Rollback(t *testing.T) {
|
||||
|
||||
@@ -19,7 +19,6 @@ func TestUserRepository_Create(t *testing.T) {
|
||||
Email: "test@example.com",
|
||||
IsActive: true,
|
||||
}
|
||||
user.SetPassword("Password123")
|
||||
|
||||
err := repo.Create(user)
|
||||
require.NoError(t, err)
|
||||
@@ -192,39 +191,11 @@ func TestUserRepository_FindAuthProvider(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewUserRepository(db)
|
||||
|
||||
t.Run("email user", func(t *testing.T) {
|
||||
t.Run("kratos user", func(t *testing.T) {
|
||||
user := testutil.CreateTestUser(t, db, "emailuser", "email@test.com", "Password123")
|
||||
provider, err := repo.FindAuthProvider(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "email", provider)
|
||||
})
|
||||
|
||||
t.Run("apple user", func(t *testing.T) {
|
||||
user := testutil.CreateTestUser(t, db, "appleuser", "apple@test.com", "Password123")
|
||||
appleAuth := &models.AppleSocialAuth{
|
||||
UserID: user.ID,
|
||||
AppleID: "apple_sub_test",
|
||||
Email: "apple@test.com",
|
||||
}
|
||||
require.NoError(t, db.Create(appleAuth).Error)
|
||||
|
||||
provider, err := repo.FindAuthProvider(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "apple", provider)
|
||||
})
|
||||
|
||||
t.Run("google user", func(t *testing.T) {
|
||||
user := testutil.CreateTestUser(t, db, "googleuser", "google@test.com", "Password123")
|
||||
googleAuth := &models.GoogleSocialAuth{
|
||||
UserID: user.ID,
|
||||
GoogleID: "google_sub_test",
|
||||
Email: "google@test.com",
|
||||
}
|
||||
require.NoError(t, db.Create(googleAuth).Error)
|
||||
|
||||
provider, err := repo.FindAuthProvider(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "google", provider)
|
||||
assert.Equal(t, "kratos", provider) // All users are Kratos-managed
|
||||
})
|
||||
}
|
||||
|
||||
@@ -235,11 +206,9 @@ func TestUserRepository_DeleteUserCascade(t *testing.T) {
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "deletebare", "deletebare@test.com", "Password123")
|
||||
|
||||
// Create profile and token
|
||||
// Create profile
|
||||
profile := &models.UserProfile{UserID: user.ID, Verified: true}
|
||||
require.NoError(t, db.Create(profile).Error)
|
||||
_, err := models.GetOrCreateToken(db, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var fileURLs []string
|
||||
txErr := repo.Transaction(func(txRepo *UserRepository) error {
|
||||
@@ -261,10 +230,6 @@ func TestUserRepository_DeleteUserCascade(t *testing.T) {
|
||||
// Verify profile is gone
|
||||
db.Model(&models.UserProfile{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// Verify token is gone
|
||||
db.Model(&models.AuthToken{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
})
|
||||
|
||||
t.Run("returns file URLs for cleanup", func(t *testing.T) {
|
||||
|
||||
+88
-66
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/treytartt/honeydue-api/internal/dto/responses"
|
||||
"github.com/treytartt/honeydue-api/internal/handlers"
|
||||
"github.com/treytartt/honeydue-api/internal/i18n"
|
||||
"github.com/treytartt/honeydue-api/internal/kratos"
|
||||
custommiddleware "github.com/treytartt/honeydue-api/internal/middleware"
|
||||
"github.com/treytartt/honeydue-api/internal/monitoring"
|
||||
"github.com/treytartt/honeydue-api/internal/prom"
|
||||
@@ -29,6 +30,7 @@ import (
|
||||
"github.com/treytartt/honeydue-api/internal/repositories"
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
customvalidator "github.com/treytartt/honeydue-api/internal/validator"
|
||||
"github.com/treytartt/honeydue-api/internal/worker"
|
||||
"github.com/treytartt/honeydue-api/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -44,6 +46,11 @@ type Dependencies struct {
|
||||
PushClient *push.Client // Direct APNs/FCM client
|
||||
StorageService *services.StorageService
|
||||
MonitoringService *monitoring.Service
|
||||
// TaskEnqueuer is the Asynq client used to push background work onto the
|
||||
// shared Redis queue. Optional — when nil, services that would enqueue
|
||||
// (currently: task-completion notification fan-out) fall back to their
|
||||
// inline implementation. Tests can omit it; production must wire it.
|
||||
TaskEnqueuer worker.Enqueuer
|
||||
}
|
||||
|
||||
// SetupRouter creates and configures the Echo router
|
||||
@@ -75,10 +82,13 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
// responses are unaffected — they don't load any assets, so any CSP is fine.
|
||||
// frame-ancestors stays 'none' to block clickjacking.
|
||||
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
|
||||
XSSProtection: "1; mode=block",
|
||||
// XSSProtection deliberately empty (audit L7): the X-XSS-Protection
|
||||
// header is deprecated and has itself caused XSS in legacy browsers.
|
||||
XSSProtection: "",
|
||||
ContentTypeNosniff: "nosniff",
|
||||
XFrameOptions: "SAMEORIGIN",
|
||||
HSTSMaxAge: 31536000,
|
||||
HSTSMaxAge: 63072000, // 2 years — preload-eligible (audit L5/CODE-L3)
|
||||
HSTSPreloadEnabled: true,
|
||||
ReferrerPolicy: "strict-origin-when-cross-origin",
|
||||
ContentSecurityPolicy: "default-src 'self'; " +
|
||||
"style-src 'self' https://fonts.googleapis.com; " +
|
||||
@@ -86,6 +96,8 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
"img-src 'self' data:; " +
|
||||
"script-src 'self'; " +
|
||||
"connect-src 'self'; " +
|
||||
"object-src 'none'; " + // audit L8 — disable plugins/embeds
|
||||
"base-uri 'self'; " + // audit L8 — block <base> hijacking
|
||||
"frame-ancestors 'none'",
|
||||
}))
|
||||
e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{
|
||||
@@ -136,9 +148,20 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
// labeled by route pattern, method, and status code.
|
||||
e.Use(prom.HTTPMiddleware())
|
||||
|
||||
// /metrics endpoint exposed for vmagent scrape. No auth — bound to
|
||||
// the cluster network only; not exposed via Cloudflare.
|
||||
e.GET("/metrics", prom.Handler())
|
||||
// /metrics endpoint for the in-cluster vmagent scrape (audit LIVE-L1).
|
||||
// vmagent scrapes api pods directly (pod-to-pod), so its requests carry
|
||||
// no X-Forwarded-For. Any request that DOES carry one reached us through
|
||||
// Traefik/Cloudflare — i.e. the public internet — and is refused with a
|
||||
// 404. The api pod port is not exposed outside the cluster, so a request
|
||||
// cannot reach /metrics without going through Traefik, and Traefik always
|
||||
// appends X-Forwarded-For — the check cannot be bypassed.
|
||||
metricsHandler := prom.Handler()
|
||||
e.GET("/metrics", func(c echo.Context) error {
|
||||
if c.Request().Header.Get("X-Forwarded-For") != "" {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
return metricsHandler(c)
|
||||
})
|
||||
|
||||
// Serve landing page static files (if static directory is configured)
|
||||
staticDir := cfg.Server.StaticDir
|
||||
@@ -184,7 +207,7 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
|
||||
// Initialize services
|
||||
authService := services.NewAuthService(userRepo, cfg)
|
||||
authService.SetNotificationRepository(notificationRepo) // For creating notification preferences on registration
|
||||
authService.SetNotificationRepository(notificationRepo)
|
||||
userService := services.NewUserService(userRepo)
|
||||
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
|
||||
residenceService.SetTaskRepository(taskRepo) // Wire up task repo for statistics
|
||||
@@ -198,12 +221,20 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
taskService.SetEmailService(deps.EmailService)
|
||||
taskService.SetResidenceService(residenceService) // For including TotalSummary in CRUD responses
|
||||
taskService.SetStorageService(deps.StorageService) // For reading completion images for email
|
||||
if deps.TaskEnqueuer != nil {
|
||||
// Offload completion notifications (push + email + B2 image fetches)
|
||||
// to the Asynq worker so POST /api/task-completions/ doesn't pay for
|
||||
// them in the response path. When the enqueuer is absent (tests),
|
||||
// task_service falls back to the inline implementation.
|
||||
taskService.SetTaskCompletedNotificationEnqueuer(deps.TaskEnqueuer)
|
||||
}
|
||||
subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
|
||||
residenceService.SetSubscriptionService(subscriptionService) // Wire up subscription service for tier limit enforcement
|
||||
|
||||
// Wire Redis cache for residence-ID lookups across the four services that
|
||||
// read it on the request hot path. Cache is best-effort; nil cache is OK.
|
||||
if deps.Cache != nil {
|
||||
authService.SetCacheService(deps.Cache)
|
||||
residenceService.SetCacheService(deps.Cache)
|
||||
taskService.SetCacheService(deps.Cache)
|
||||
contractorService.SetCacheService(deps.Cache)
|
||||
@@ -227,22 +258,21 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
subscriptionWebhookHandler.SetStripeService(stripeService)
|
||||
subscriptionWebhookHandler.SetCacheService(deps.Cache)
|
||||
|
||||
// Initialize middleware
|
||||
authMiddleware := custommiddleware.NewAuthMiddlewareWithConfig(deps.DB, deps.Cache, cfg)
|
||||
|
||||
// Initialize Apple auth service
|
||||
appleAuthService := services.NewAppleAuthService(deps.Cache, cfg)
|
||||
googleAuthService := services.NewGoogleAuthService(deps.Cache, cfg)
|
||||
// Initialize Kratos auth middleware (replaces hand-rolled token auth).
|
||||
kratosClient := kratos.NewClient(cfg.Security.KratosPublicURL, cfg.Security.KratosAdminURL)
|
||||
authMiddleware := custommiddleware.NewKratosAuth(kratosClient, deps.Cache, deps.DB)
|
||||
authService.SetKratosClient(kratosClient) // account deletion removes the Kratos identity
|
||||
|
||||
// Initialize audit service for security event logging
|
||||
auditService := services.NewAuditService(deps.DB)
|
||||
|
||||
// Initialize handlers
|
||||
authHandler := handlers.NewAuthHandler(authService, deps.EmailService, deps.Cache)
|
||||
authHandler.SetAppleAuthService(appleAuthService)
|
||||
authHandler.SetGoogleAuthService(googleAuthService)
|
||||
authHandler.SetStorageService(deps.StorageService)
|
||||
authHandler.SetAuditService(auditService)
|
||||
if deps.TaskEnqueuer != nil {
|
||||
authHandler.SetEnqueuer(deps.TaskEnqueuer)
|
||||
}
|
||||
userHandler := handlers.NewUserHandler(userService)
|
||||
residenceHandler := handlers.NewResidenceHandler(residenceService, deps.PDFService, deps.EmailService, cfg.Features.PDFReportsEnabled)
|
||||
taskHandler := handlers.NewTaskHandler(taskService, deps.StorageService)
|
||||
@@ -301,8 +331,13 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
// API group
|
||||
api := e.Group("/api")
|
||||
{
|
||||
// Public auth routes (no auth required)
|
||||
setupPublicAuthRoutes(api, authHandler, cfg.Server.Debug)
|
||||
// Session lifecycle (login, logout, password reset, email verification)
|
||||
// is handled directly by Ory Kratos from the client. Registration is the
|
||||
// exception: it goes through this endpoint, which admin-creates the
|
||||
// Kratos identity so no verification email is auto-sent to an
|
||||
// unreachable flow (see handlers.AuthHandler.Register). Public — the
|
||||
// caller has no session yet.
|
||||
api.POST("/auth/register/", authHandler.Register)
|
||||
|
||||
// Public data routes (no auth required)
|
||||
setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler, taskTemplateHandler)
|
||||
@@ -310,29 +345,44 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
// Subscription webhook routes (no auth - called by Apple/Google servers)
|
||||
setupWebhookRoutes(api, subscriptionWebhookHandler)
|
||||
|
||||
// Protected routes (auth required)
|
||||
// Authenticated routes (valid session required). This level is the
|
||||
// sign-up / shell allow-list: an authenticated-but-UNVERIFIED user may
|
||||
// call these (read their own user + verification status, complete their
|
||||
// profile during sign-up). EVERYTHING else requires a verified email
|
||||
// (see the `verified` sub-group below).
|
||||
protected := api.Group("")
|
||||
protected.Use(authMiddleware.TokenAuth())
|
||||
protected.Use(authMiddleware.Authenticate())
|
||||
protected.Use(custommiddleware.TimezoneMiddleware())
|
||||
{
|
||||
// Allow-list — authenticated, may be unverified.
|
||||
setupProtectedAuthRoutes(protected, authHandler)
|
||||
setupResidenceRoutes(protected, residenceHandler)
|
||||
setupTaskRoutes(protected, taskHandler)
|
||||
setupSuggestionRoutes(protected, suggestionHandler)
|
||||
setupContractorRoutes(protected, contractorHandler)
|
||||
setupDocumentRoutes(protected, documentHandler)
|
||||
setupNotificationRoutes(protected, notificationHandler)
|
||||
setupSubscriptionRoutes(protected, subscriptionHandler)
|
||||
setupUserRoutes(protected, userHandler)
|
||||
|
||||
// Verified routes (authenticated AND email-verified) — the DEFAULT
|
||||
// for all app data and actions. RequireVerified is applied ONCE at
|
||||
// the group level so verification is the default and every route
|
||||
// added under here is gated automatically. (The previous per-route
|
||||
// approach left ~70 routes unverified — see the LIVE auth audit.)
|
||||
verified := protected.Group("")
|
||||
verified.Use(authMiddleware.RequireVerified())
|
||||
{
|
||||
setupResidenceRoutes(verified, residenceHandler)
|
||||
setupTaskRoutes(verified, taskHandler)
|
||||
setupSuggestionRoutes(verified, suggestionHandler)
|
||||
setupContractorRoutes(verified, contractorHandler)
|
||||
setupDocumentRoutes(verified, documentHandler)
|
||||
setupNotificationRoutes(verified, notificationHandler)
|
||||
setupSubscriptionRoutes(verified, subscriptionHandler)
|
||||
setupUserRoutes(verified, userHandler)
|
||||
|
||||
// Upload routes (only if storage service is configured)
|
||||
if uploadHandler != nil {
|
||||
setupUploadRoutes(protected, uploadHandler)
|
||||
setupUploadRoutes(verified, uploadHandler)
|
||||
}
|
||||
|
||||
// Media routes (authenticated media serving)
|
||||
// Media routes (verified media serving)
|
||||
if mediaHandler != nil {
|
||||
setupMediaRoutes(protected, mediaHandler)
|
||||
setupMediaRoutes(verified, mediaHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,51 +549,20 @@ func prometheusMetrics(monSvc *monitoring.Service) echo.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// setupPublicAuthRoutes configures public authentication routes with
|
||||
// per-endpoint rate limiters to mitigate brute-force and credential-stuffing.
|
||||
// Rate limiters are disabled in debug mode to allow UI test suites to run
|
||||
// without hitting 429 errors.
|
||||
func setupPublicAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler, debug bool) {
|
||||
auth := api.Group("/auth")
|
||||
// setupPublicAuthRoutes was removed — session lifecycle (login, register,
|
||||
// logout, password reset, Apple/Google sign-in) is delegated to Ory Kratos.
|
||||
|
||||
if debug {
|
||||
// No rate limiters in debug/local mode
|
||||
auth.POST("/login/", authHandler.Login)
|
||||
auth.POST("/register/", authHandler.Register)
|
||||
auth.POST("/forgot-password/", authHandler.ForgotPassword)
|
||||
auth.POST("/verify-reset-code/", authHandler.VerifyResetCode)
|
||||
auth.POST("/reset-password/", authHandler.ResetPassword)
|
||||
auth.POST("/apple-sign-in/", authHandler.AppleSignIn)
|
||||
auth.POST("/google-sign-in/", authHandler.GoogleSignIn)
|
||||
} else {
|
||||
// Rate limiters — created once, shared across requests.
|
||||
loginRL := custommiddleware.LoginRateLimiter() // 10 req/min
|
||||
registerRL := custommiddleware.RegistrationRateLimiter() // 5 req/min
|
||||
passwordRL := custommiddleware.PasswordResetRateLimiter() // 3 req/min
|
||||
|
||||
auth.POST("/login/", authHandler.Login, loginRL)
|
||||
auth.POST("/register/", authHandler.Register, registerRL)
|
||||
auth.POST("/forgot-password/", authHandler.ForgotPassword, passwordRL)
|
||||
auth.POST("/verify-reset-code/", authHandler.VerifyResetCode, passwordRL)
|
||||
auth.POST("/reset-password/", authHandler.ResetPassword, passwordRL)
|
||||
auth.POST("/apple-sign-in/", authHandler.AppleSignIn, loginRL)
|
||||
auth.POST("/google-sign-in/", authHandler.GoogleSignIn, loginRL)
|
||||
}
|
||||
}
|
||||
|
||||
// setupProtectedAuthRoutes configures protected authentication routes
|
||||
// setupProtectedAuthRoutes configures protected auth routes.
|
||||
// Session lifecycle (login, logout, password reset, email verification) is
|
||||
// delegated to Ory Kratos — only profile and account-deletion routes remain.
|
||||
func setupProtectedAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler) {
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/logout/", authHandler.Logout)
|
||||
auth.POST("/refresh/", authHandler.RefreshToken)
|
||||
auth.GET("/me/", authHandler.CurrentUser)
|
||||
auth.PUT("/profile/", authHandler.UpdateProfile)
|
||||
auth.PATCH("/profile/", authHandler.UpdateProfile)
|
||||
auth.POST("/verify/", authHandler.VerifyEmail) // Alias for mobile app compatibility
|
||||
auth.POST("/verify-email/", authHandler.VerifyEmail) // Original route
|
||||
auth.POST("/resend-verification/", authHandler.ResendVerification)
|
||||
auth.DELETE("/account/", authHandler.DeleteAccount)
|
||||
auth.POST("/export/", authHandler.ExportData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,6 +617,9 @@ func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceH
|
||||
residences.DELETE("/:id/", residenceHandler.DeleteResidence)
|
||||
|
||||
residences.GET("/:id/share-code/", residenceHandler.GetShareCode)
|
||||
// Verification is now enforced at the group level for ALL residence
|
||||
// routes (see the `verified` group in SetupRouter) — the previous
|
||||
// per-route RequireVerified on just these two is no longer needed.
|
||||
residences.POST("/:id/generate-share-code/", residenceHandler.GenerateShareCode)
|
||||
residences.POST("/:id/generate-share-package/", residenceHandler.GenerateSharePackage)
|
||||
residences.POST("/:id/generate-tasks-report/", residenceHandler.GenerateTasksReport)
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
appleKeysURL = "https://appleid.apple.com/auth/keys"
|
||||
appleIssuer = "https://appleid.apple.com"
|
||||
appleKeysCacheTTL = 24 * time.Hour
|
||||
appleKeysCacheKey = "apple:public_keys"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidAppleToken = errors.New("invalid Apple identity token")
|
||||
ErrAppleTokenExpired = errors.New("Apple identity token has expired")
|
||||
ErrInvalidAppleAudience = errors.New("invalid Apple token audience")
|
||||
ErrInvalidAppleIssuer = errors.New("invalid Apple token issuer")
|
||||
ErrAppleKeyNotFound = errors.New("Apple public key not found")
|
||||
)
|
||||
|
||||
// AppleJWKS represents Apple's JSON Web Key Set
|
||||
type AppleJWKS struct {
|
||||
Keys []AppleJWK `json:"keys"`
|
||||
}
|
||||
|
||||
// AppleJWK represents a single JSON Web Key from Apple
|
||||
type AppleJWK struct {
|
||||
Kty string `json:"kty"` // Key type (RSA)
|
||||
Kid string `json:"kid"` // Key ID
|
||||
Use string `json:"use"` // Key use (sig)
|
||||
Alg string `json:"alg"` // Algorithm (RS256)
|
||||
N string `json:"n"` // RSA modulus
|
||||
E string `json:"e"` // RSA exponent
|
||||
}
|
||||
|
||||
// AppleTokenClaims represents the claims in an Apple identity token
|
||||
type AppleTokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified any `json:"email_verified,omitempty"` // Can be bool or string
|
||||
IsPrivateEmail any `json:"is_private_email,omitempty"` // Can be bool or string
|
||||
AuthTime int64 `json:"auth_time,omitempty"`
|
||||
}
|
||||
|
||||
// IsEmailVerified returns whether the email is verified (handles both bool and string types)
|
||||
func (c *AppleTokenClaims) IsEmailVerified() bool {
|
||||
switch v := c.EmailVerified.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return v == "true"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsPrivateRelayEmail returns whether the email is a private relay email
|
||||
func (c *AppleTokenClaims) IsPrivateRelayEmail() bool {
|
||||
switch v := c.IsPrivateEmail.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return v == "true"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AppleAuthService handles Apple Sign In token verification
|
||||
type AppleAuthService struct {
|
||||
cache *CacheService
|
||||
config *config.Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewAppleAuthService creates a new Apple auth service
|
||||
func NewAppleAuthService(cache *CacheService, cfg *config.Config) *AppleAuthService {
|
||||
return &AppleAuthService{
|
||||
cache: cache,
|
||||
config: cfg,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyIdentityToken verifies an Apple identity token and returns the claims
|
||||
func (s *AppleAuthService) VerifyIdentityToken(ctx context.Context, idToken string) (*AppleTokenClaims, error) {
|
||||
// Parse the token header to get the key ID
|
||||
parts := strings.Split(idToken, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, ErrInvalidAppleToken
|
||||
}
|
||||
|
||||
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode token header: %w", err)
|
||||
}
|
||||
|
||||
var header struct {
|
||||
Kid string `json:"kid"`
|
||||
Alg string `json:"alg"`
|
||||
}
|
||||
if err := json.Unmarshal(headerBytes, &header); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token header: %w", err)
|
||||
}
|
||||
|
||||
// Get the public key for this key ID
|
||||
publicKey, err := s.getPublicKey(ctx, header.Kid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse and verify the token
|
||||
token, err := jwt.ParseWithClaims(idToken, &AppleTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify the signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return publicKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrAppleTokenExpired
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*AppleTokenClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, ErrInvalidAppleToken
|
||||
}
|
||||
|
||||
// Verify the issuer
|
||||
if claims.Issuer != appleIssuer {
|
||||
return nil, ErrInvalidAppleIssuer
|
||||
}
|
||||
|
||||
// Verify the audience (should be our bundle ID)
|
||||
if !s.verifyAudience(claims.Audience) {
|
||||
return nil, ErrInvalidAppleAudience
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// verifyAudience checks if the token audience matches our client ID.
|
||||
// In production (non-debug), an empty clientID causes verification to fail
|
||||
// rather than silently bypassing the check.
|
||||
func (s *AppleAuthService) verifyAudience(audience jwt.ClaimStrings) bool {
|
||||
clientID := s.config.AppleAuth.ClientID
|
||||
if clientID == "" {
|
||||
if s.config.Server.Debug {
|
||||
// In debug mode only, skip audience verification for local development
|
||||
return true
|
||||
}
|
||||
// In production, missing client ID means we cannot verify the audience
|
||||
return false
|
||||
}
|
||||
|
||||
for _, aud := range audience {
|
||||
if aud == clientID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getPublicKey retrieves the public key for the given key ID
|
||||
func (s *AppleAuthService) getPublicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
|
||||
// Try to get from cache first
|
||||
keys, err := s.getCachedKeys(ctx)
|
||||
if err != nil || keys == nil {
|
||||
// Fetch fresh keys
|
||||
keys, err = s.fetchApplePublicKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Find the key with the matching ID
|
||||
for keyID, pubKey := range keys {
|
||||
if keyID == kid {
|
||||
return pubKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Key not found in cache, try fetching fresh keys
|
||||
keys, err = s.fetchApplePublicKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pubKey, ok := keys[kid]; ok {
|
||||
return pubKey, nil
|
||||
}
|
||||
|
||||
return nil, ErrAppleKeyNotFound
|
||||
}
|
||||
|
||||
// getCachedKeys retrieves cached Apple public keys from Redis
|
||||
func (s *AppleAuthService) getCachedKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) {
|
||||
if s.cache == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := s.cache.GetString(ctx, appleKeysCacheKey)
|
||||
if err != nil || data == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var jwks AppleJWKS
|
||||
if err := json.Unmarshal([]byte(data), &jwks); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return s.parseJWKS(&jwks)
|
||||
}
|
||||
|
||||
// fetchApplePublicKeys fetches Apple's public keys and caches them
|
||||
func (s *AppleAuthService) fetchApplePublicKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, appleKeysURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Apple keys: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Apple keys endpoint returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var jwks AppleJWKS
|
||||
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode Apple keys: %w", err)
|
||||
}
|
||||
|
||||
// Cache the keys
|
||||
if s.cache != nil {
|
||||
keysJSON, _ := json.Marshal(jwks)
|
||||
_ = s.cache.SetString(ctx, appleKeysCacheKey, string(keysJSON), appleKeysCacheTTL)
|
||||
}
|
||||
|
||||
return s.parseJWKS(&jwks)
|
||||
}
|
||||
|
||||
// parseJWKS converts Apple's JWKS to RSA public keys
|
||||
func (s *AppleAuthService) parseJWKS(jwks *AppleJWKS) (map[string]*rsa.PublicKey, error) {
|
||||
keys := make(map[string]*rsa.PublicKey)
|
||||
|
||||
for _, key := range jwks.Keys {
|
||||
if key.Kty != "RSA" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Decode the modulus (N)
|
||||
nBytes, err := base64.RawURLEncoding.DecodeString(key.N)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
n := new(big.Int).SetBytes(nBytes)
|
||||
|
||||
// Decode the exponent (E)
|
||||
eBytes, err := base64.RawURLEncoding.DecodeString(key.E)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
e := 0
|
||||
for _, b := range eBytes {
|
||||
e = e<<8 + int(b)
|
||||
}
|
||||
|
||||
pubKey := &rsa.PublicKey{
|
||||
N: n,
|
||||
E: e,
|
||||
}
|
||||
|
||||
keys[key.Kid] = pubKey
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user