Compare commits

...

23 Commits

Author SHA1 Message Date
Trey T 225fb1306b dev: add Kratos + Mailpit local-dev stack
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
docker-compose.dev.yml gains a Kratos identity service (public :4433 / admin
:4434) and a Mailpit SMTP catcher for local onboarding email codes, plus a
postgres-init mount. deploy/local/kratos/ holds the local Kratos config +
identity schema (placeholder dev cookie secret only). Supports the local
backend the XCUITest suite seeds against.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:11:06 -05:00
Trey T b54493f785 backend: GDPR export + retention cleanups + worker metrics (BE-1/2/3)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
BE-3 observability: expose the worker's Prometheus metrics on :6060/metrics
(apns/fcm/asynq histograms + a new cache_ops_total counter were recorded all
along but never scraped — which is why those dashboard panels read empty); add
the worker containerPort, the vmagent worker scrape job, and two additive
NetworkPolicies. Instrument cache Get/Set hit/miss.

BE-2 retention: three periodic Asynq cleanup crons mirroring the reminder-log
cleanup — notifications (90d), webhook dedup log (180d), audit_log (365d).

BE-1 GDPR data export: POST /api/auth/export/ enqueues a low-priority Asynq job
that gathers all of the user's data (owned residences + their tasks/contractors/
documents/share-codes, plus profile/notifications/prefs/push-tokens/subscription/
audit log), zips one JSON file per category, and emails it as an attachment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:15:26 -05:00
Trey T 3b2ea9959a deploy: add node-exporter DaemonSet + vmagent scrape job
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Per-node host metrics (node_filesystem_*, node_memory_*, node_load*) were
missing — a node running out of disk would silently fail the cluster before
any dashboard signal (RUNBOOK §11.1 gap #9). Adds:
- node-exporter DaemonSet (pod-networked, :9100; host /proc,/sys,/ ro) so
  vmagent scrapes it pod-to-pod over the cluster CIDR, independent of node
  public IPs (the netpol node-IP list is OVH-stale).
- two additive NetworkPolicies (default-deny-all is in force): ingress to
  node-exporter from vmagent, and vmagent egress to the pod CIDR on :9100.
- a node-exporter scrape job in the vmagent-config ConfigMap.

Feeds the new "Node host health" row (disk/mem/load) on the eli5 dashboard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:41:40 -05:00
Trey T cf054959bd Auth: require email-verified by default for all app-data routes
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Previously only 2 share-code routes required a verified email; every other
authenticated route (residences, tasks, contractors, documents, notifications,
subscription, users, uploads, media — ~70 routes) accepted an authenticated but
UNVERIFIED user. This inverts the default to verified-by-default.

- router.go: add a `verified` sub-group that applies RequireVerified() ONCE at
  the group level, and move all app-data route setups under it. Verification is
  now the default; new routes are gated automatically. The authenticated-only
  allow-list is just the sign-up surface (/auth/me, /auth/profile, /auth/account).
  Public stays: register, health, webhooks, lookups.
- kratos_auth.go: fix a latent bug the gating exposed — the Redis session cache
  stored the verified flag for 24h, so a user who verified their email mid-session
  was still seen as unverified until the TTL expired (sign up -> verify -> create
  residence would 403). Now only a cached verified=true is trusted (verification
  is sticky); a cached verified=false re-resolves the live status from Kratos.
- auth_safety_test.go: add RequireVerified unit tests (verified passes,
  unverified -> 403, no-user -> 401).

Validated: API gating test (unverified->403, verified->200) + full iOS XCUITest
suite green (211 passed) including the onboarding verify->use-immediately flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:49:37 -05:00
Trey T 12de5a230a i18n: backend-localized lookups, suggestions, and static data (10 languages)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
- suggestion_service: fix scorer (stringList unmarshal accepts scalar|array;
  anchor scoring on base universal score so bool matches no longer tie); add
  localizeReasons for human-readable, Accept-Language-localized match reasons
- lookup_i18n: localize lookup display names, home-profile options, document
  types/categories via internal/i18n
- static_data_handler: per-locale seeded-data response (display_name, home
  profile options, document types/categories) with per-locale cache + ETag
- settings_handler: invalidate per-locale seeded-data cache on lookup change
  instead of pre-warming a single non-localized blob
- cache_service: per-locale seeded-data keys + ETag
- DTOs: add DisplayName fields (task/residence/contractor)
- translations: add suggestion.reason.* and lookup.* keys across all 10 langs
- cmd/api: extract startup helpers + tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:54:54 -05:00
Trey t 25897e913e Auto-verify Sign in with Apple emails
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Apple OIDC mapper now marks the email verified unconditionally via
verified_addresses. SIWA cryptographically proves control of the Apple ID and
Apple owns/verifies the (relay) email, so a code is redundant. Gating on
Apple's `email_verified` claim was unreliable — Apple omits it on many
authorizations, which made verification random (sometimes a surprise code
prompt). Password sign-ups still verify via the honeyDue API flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:30:33 -05:00
Trey t 81e454d86d Add admin-create registration + live email-verified flag
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Registration now goes through POST /api/auth/register, which admin-creates the
Kratos identity (unverified email, NO auto-sent code). Kratos self-service
registration never returns the verification flow id, so the client could never
submit the user's code to the right flow; admin creation lets the client own a
single verification flow instead. Also surface the live Kratos verified flag
and fix Apple audience + team IDs.

- kratos.Client.CreateIdentity via admin API; ErrIdentityExists / ErrInvalidCredentials
- AuthService.Register + AuthHandler.Register + public POST /api/auth/register/
- CurrentUser overrides stale user_profile.verified with the live Kratos flag;
  UserRepository.MarkVerified mirrors it back
- configmap: additional_id_token_audiences allows the .dev bundle id_token
- fix Apple/APNs team id V3PF3M6B6U -> X86BR9WTLD in .env.example + dev init

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:46:30 -05:00
Trey t 7b87f2e392 fix(kratos): drop cloudflare-only middleware on auth ingress
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
iOS Sign In with Apple failed silently — the KMP client never reached
Kratos. Traced to the cloudflare-only Traefik middleware rejecting every
request at the auth ingress.

Root cause: on this cluster klipper-lb sits in front of Traefik and
SNATs the source IP. Traefik's ipAllowList sees the klipper-lb pod IP,
not Cloudflare's real source IP — so even legitimate iOS requests
proxied through Cloudflare get 403'd. The api ingress doesn't have
this middleware (and works correctly), so removing it from auth
matches the working pattern.

Kratos is the user-facing OIDC endpoint — every iOS/web user device
needs to reach it. Cloudflare's edge still does DDoS protection;
Kratos applies its own per-flow rate limits. The IP allowlist was
buying nothing here and breaking everything.

Verified after this change:
  - GET /health/alive → 200
  - GET /health/ready → 200
  - GET /self-service/login/api → 200 + valid flow body listing apple
    as an OIDC provider option

Related but not fixed by this commit: the same klipper-lb SNAT issue
affects admin.myhoneydue.com (which retains cloudflare-only). Admin
basic auth still gates real access there, but the IP check is dead
weight. Proper fix is configuring Traefik ipStrategy to read the
client IP from X-Forwarded-For (set by Cloudflare). Tracked as a
follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:14:35 -05:00
Trey t 6de90acef7 feat(kratos): deploy Ory Kratos to production (Apple-only OIDC)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Auth was structurally broken — the api's Kratos middleware was pointing
at http://kratos:4433 but Kratos wasn't deployed. The only thing keeping
users logged in was a 5-min Redis cache; once it expired the middleware
called Whoami → no DNS → 401 → forced relogin with no path back.

This commit deploys Kratos for real:

Manifests:
  - kratos.yaml + migrate-job.yaml: pin oryd/kratos:v26.2.0@sha256:92eedc...
    (CalVer current stable as of 2026-06-03)
  - configmap.yaml: drop Google OIDC provider (not in scope); fill the
    Apple provider with real Services ID / Team ID / Key ID — Apple now
    sits at providers[0]
  - kratos.yaml: drop the Google-secret env binding; rebind APPLE_PRIVATE_KEY
    to PROVIDERS_0_APPLE_PRIVATE_KEY (shifted from index 1)
  - network-policies.yaml: add a kratos egress rule to allow-egress-from-api.
    Without this, even with kratos running, the api gets "connection refused"
    on http://kratos:4433 (post-DNAT NetworkPolicy enforcement — runbook §9.2).

Operator prerequisites that were completed alongside this commit:
  - Neon kratos database created (separate from honeyDue, owner neondb_owner)
  - Cloudflare DNS for auth.myhoneydue.com (3 A records, proxied)
  - kratos: block added to config.yaml (gitignored): DSN to the Neon DIRECT
    endpoint, cookie + cipher secrets generated, Fastmail SMTPS URI,
    .p8 contents inline

Out of scope intentionally:
  - Google sign-in (additive; can append providers[] later)
  - Migrating existing auth_user rows onto Kratos identities — pre-prod;
    existing users will need to sign in fresh, which creates a new Kratos
    identity and a new local user row (per migration plan in
    manifests/kratos/README.md).

Verified end-to-end:
  - 338 schema migrations applied successfully
  - 2/2 kratos pods Ready
  - api → kratos:4433/sessions/whoami returns 401 for invalid token (was
    "connection refused" before this commit's NetworkPolicy patch)
  - auth.myhoneydue.com resolves through CF; cloudflare-only middleware
    keeps the origin protected exactly like the other hostnames

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:08:09 -05:00
Trey t 64c656bde1 fix(auth): keep users logged in while Kratos is down
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Production is running with no Kratos deployed in-cluster (the deploy
script's kratos-secrets prerequisite isn't satisfied yet — see runbook
§11 #7). That means Whoami calls ALWAYS fail, so any time a user's Redis
session cache expires they get a 401, which the iOS app treats as session
invalid → forced re-login → can't re-authenticate because the same
Whoami is the only way back in.

Two-part mitigation:

1. Bump kratosSessionCacheTTL from 5 minutes to 24 hours. Active users
   stay logged in indefinitely; idle users get bounced after a day.
2. Refresh the cache TTL on every successful cache hit (sliding window)
   so usage-driven expiry is no longer a cliff at the original TTL.

When Kratos actually comes up:
  - revert the TTL constant to a sensible value (1-15 min)
  - the sliding-window refresh is fine to keep; it's good UX regardless

Caveat: this papers over the missing Kratos. New sign-ins still cannot
complete because the api needs Kratos to populate the cache the first
time. Real fix is to deploy Kratos.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:48:12 -05:00
Trey t d74cfeee62 feat(subscription): temporarily disable subscription gating
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Subscriptions aren't a shipping feature for now. Make
GET /api/subscription/status/ return a "limitations disabled" / pro-tier
stub at the top of the function with no DB or Redis work:

  - tier="pro"
  - is_active=true
  - limitations_enabled=false  (master kill switch in SubscriptionHelper.kt;
                                every canCreate* check short-circuits true)
  - usage=0 across the board
  - limits map present with empty entries (all-nil = unlimited per the KMM
    model convention) so client tier-lookups don't NPE

The original implementation is preserved verbatim as the unexported
getSubscriptionStatusFromDB method. Re-enabling is a one-line change:
swap GetSubscriptionStatus's body to call s.getSubscriptionStatusFromDB.

Two integration tests in subscription_is_free_test.go assert the original
"limitations actually apply based on settings/IsFree" behavior. They now
t.Skip with the same TEMPORARILY DISABLED marker pointing back to the
service comment. CheckLimit-based tests in the same file still pass
because that codepath is unchanged.

Perf side effect: POST/GET on this route drops to ~1ms (just JSON marshal),
removing 4-5 serial Neon RTTs from every cold call. Was the slowest endpoint
in the live dashboard (~213ms p95 / ~480ms after the pod roll).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:07:06 -05:00
Trey t 52bf1ff3c7 perf(task): offload completion notification fan-out to Asynq worker
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
POST /api/task-completions/ was spending ~1.5-1.75s synchronously on
APNs push + SMTP email + B2 image fetches inside sendTaskCompletedNotification.
Per-user loop made it scale linearly with residence membership; one image
attached + one residence user is the 1.75s baseline observed in the live
honeydue-eli5-overview Grafana panel.

Replace the inline call (and the fire-and-forget goroutine in QuickComplete,
which violated the project's "no goroutines in handlers" rule) with an
Asynq job:

  - new task type notification:task_completed (worker/scheduler.go)
  - new payload {task_id, completion_id} — IDs only, worker re-reads
    canonical state from Postgres so concurrent edits between enqueue
    and dequeue are reflected
  - new HandleTaskCompletedNotification on jobs.Handler delegates to
    TaskService.SendTaskCompletedNotificationByID
  - new dispatchTaskCompletedNotification in task_service.go picks
    between enqueue (preferred) and inline (fallback) when Redis is
    unreachable or the enqueuer isn't wired (tests / local dev)

Other changes required to wire it up:

  - widen worker.NewTaskClient signature to accept asynq.RedisClientOpt
    so the file-mounted Redis password (audit HIGH-1) can be supplied;
    no prior callers, no breakage
  - extend worker.Enqueuer interface with EnqueueTaskCompletedNotification
  - add TaskEnqueuer field to router.Dependencies; wire from cmd/api/main.go
    with the standard typed-nil interface guard
  - wire a worker-side TaskService in cmd/worker/main.go so the handler
    can use the shared SendTaskCompletedNotificationByID implementation
    (storage service shared with the existing upload-cleanup wiring)

Expected impact on POST /api/task-completions/ p50:
  ~1.75s -> ~120-170ms (DB + tx + Asynq enqueue only)

Notifications still deliver; they just go via the worker instead of in
the request path. MaxRetry=3; "row not found" returns nil so a deleted
task/completion doesn't churn the retry loop.

All 31 test packages pass. No DB migrations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 09:34:52 -05:00
Trey t e448ec66dc docs(runbook): rewrite for OVH BHS cluster + Tier-3 observability TODOs
Brings the runbook in line with the 2026-06-03 Hetzner → OVH cutover:

- Section 1-5: topology, machines (3x OVH VPS-1 BHS), software versions,
  network/firewall, DNS, filesystem layout — all reflect the live OVH
  install instead of the historical Hetzner setup.
- Section 6: canonical install-from-clean-boxes procedure (the literal
  commands run on 2026-06-03), so anyone can stand up a backup cluster
  by following along.
- Section 9: keeps existing gotchas (vmagent NetPol, token-blown-away,
  healthy-but-empty) and adds four new ones discovered during the OVH
  build: rbac.yaml not in 03-deploy.sh, namespace label missing from api
  metrics (use service="api"), cluster-label collision when two clusters
  push concurrently, worker double-firing on cutover.
- Section 11.1: enumerates Tier-3 observability gaps surfaced while
  building the honeydue-eli5-overview dashboard (node-exporter not
  deployed, Traefik metrics off, push success counters absent, worker
  /metrics endpoint absent, cache hit rate uninstrumented, APNs latency
  uninstrumented).
- Section 12: dated audit trail of cluster changes.

Pure documentation; no code or manifest changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 09:34:35 -05:00
Trey t 3d3ba84df0 fix(auth): delete the Kratos identity on account deletion
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Account deletion removed all local data but left the Ory Kratos
identity intact — an orphaned identity that can still authenticate.
Close the gap:

- kratos.Client gains the admin API: NewClient(publicURL, adminURL)
  and DeleteIdentity (DELETE /admin/identities/{id}; a 404 is treated
  as success so a retry after a partial failure is idempotent).
- AuthService.DeleteAccount deletes the Kratos identity FIRST; if that
  call fails it aborts before touching local data, so the operation is
  retryable rather than partially applied.
- KRATOS_ADMIN_URL config (default http://kratos:4434) + router wiring.
- kratos NetworkPolicy split: the api pods may now reach the admin API
  :4434 (Traefik still reaches only the public API :4433).
- kratos CORS: allow_credentials + OPTIONS so the web browser flows
  (ory_kratos_session cookie) work; origins stay an explicit allowlist.
- Regression tests: identity teardown happens, and a Kratos failure
  aborts the deletion instead of orphaning local data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:55:33 -05:00
Trey t 81578f6e27 feat(auth): replace hand-rolled auth with Ory Kratos — phase 2 backend
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Delegates all credential management (login, register, password reset,
email verification, social sign-in) to Ory Kratos. The Go API now acts
as a resource server: the new KratosAuth middleware validates sessions
against the Kratos whoami endpoint, writes the local User mirror into
Echo context, and all existing domain handlers continue working
unchanged. Hand-rolled token auth, AuthToken model, apple_auth/
google_auth services, and the auth refresh flow are removed. Tests are
updated to use the fake-token middleware pattern so existing integration
assertions require no rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:55:56 -05:00
Trey t b66151ddd9 feat(auth): scaffold Ory Kratos identity service — phase 1 (infrastructure)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
First phase of replacing the hand-rolled auth (internal/services/auth_service.go
et al.) with Ory Kratos. This commit is infrastructure only — Kratos will run
but nothing consumes it yet; the Go API still does its own auth until phase 2.

Adds deploy-k3s/manifests/kratos/:
- configmap.yaml  — kratos.yml, identity schema, Google/Apple OIDC claim
                     mappers (no secrets in the ConfigMap)
- migrate-job.yaml — `kratos migrate sql`, run before the Deployment
- kratos.yaml     — Deployment (x2), Service, NetworkPolicies
- ingress.yaml    — auth.myhoneydue.com -> Kratos public API :4433
- README.md       — operator prerequisites + deploy runbook

Wiring:
- 02-setup-secrets.sh creates kratos-secrets, gated on a config.yaml `kratos:`
  block (DSN, cookie/cipher, SMTP URI, OIDC client secret, Apple key).
- 03-deploy.sh applies the Kratos manifests + runs the migrate Job, gated on
  the kratos-secrets Secret existing.

Both gates mean the existing stack deploys completely unaffected until the
operator completes the prerequisites (Neon `kratos` DB, auth.myhoneydue.com
DNS, Apple/Google OAuth apps, Kratos image version). Pre-production, so no
user-data migration — see manifests/kratos/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:24:38 -05:00
Trey t c845771946 feat(observability): drop health/metrics probe noise from shipped logs
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
The api logs every request, so k8s liveness/readiness probes on
/api/health/ and vmagent's /metrics scrape drowned Loki in 2xx access
logs. Alloy now drops successful probe/scrape access lines at ingest
(loki.process stage.drop) — a non-2xx health check, or one logged
above info level, still matches nothing and is kept.

Also hardens Alloy's read-offset store: moved /tmp/alloy from an
emptyDir to a hostPath and set loki.source.file tail_from_end=true, so
a pod restart resumes from the saved offset instead of re-reading log
files from the start — which made Loki 400-reject the now-too-old
entries ("entry too far behind") and stalled shipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:29:15 -05:00
Trey t 93fddc3769 feat(observability): ship pod logs to Loki via Grafana Alloy
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Adds a Grafana Alloy DaemonSet that tails honeydue-namespace pod logs
from /var/log/pods and pushes them to Loki at obs.88oakapps.com,
reusing the existing OBS_INGEST_TOKEN (14-day retention).

- deploy-k3s/manifests/observability/alloy-logs.yaml — DaemonSet + RBAC
  + token Secret + Alloy config. Runs as root (/var/log/pods is 0750
  root:root) but otherwise locked down: all caps dropped, read-only
  root filesystem, seccomp RuntimeDefault, read-only hostPath mount.
- network-policies.yaml — allow-egress-from-alloy-logs (DNS + k8s API
  + obs HTTPS), mirroring the vmagent egress policy.
- 03-deploy.sh — applies alloy-logs with the OBS_INGEST_TOKEN
  substitution and waits for the DaemonSet rollout.

The Loki container, nginx /loki/api/v1/push route, and Grafana Loki
datasource live on the obs server and are not repo-managed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:04:09 -05:00
Trey t c77ff07ce9 fix(security): remediate 2026-05-12 audit findings (Stages 2–5)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps),
tracked in deploy-k3s/SECURITY.md, plus fixes from two independent
post-remediation reviews.

Auth & sessions:
- SHA-256 hashed auth-token storage (C1); prior-token cache eviction on
  re-login (MEDIUM-1)
- local Google JWKS verification, iss/aud/exp checks (C2/C3)
- constant-time login + generic errors (L1/LIVE-L11/LIVE-L13)
- per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3)
- verified-email gating, login rate limiting (LIVE-L19, H1-H3)

IAP & webhooks:
- Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6)
- migrations 000003-000006 (token hashing, IAP replay, audit_log +
  webhook_event_log table creation, append-only audit log)

Authorization & races:
- file-ownership owner-OR-member fix (C7), atomic share-code join
  (C9/H9), device-token reassignment (C8/LOW-3)

Secrets & deploy:
- secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis
  password out of the ConfigMap (HIGH-1); B2 keys reconciled
- digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics
  lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban +
  unattended-upgrades at provision; secret-rotation runbook

Build, vet, and the full test suite (incl. -race) pass; the goose
migration chain is verified against PostgreSQL 16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:28:33 -05:00
Trey t 2004f9c5b2 fix(observability): relax vmagent liveness probe — was crash-looping every ~5m
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
The previous probe had timeoutSeconds=1 which is too tight for the
shell pipeline (sh + wget + grep + comparison). On a busy node the
wget call regularly exceeded 1s, the exec timed out, and 3 consecutive
timeouts triggered SIGTERM. Result: vmagent restarted ~5x per 30 min,
causing brief gaps that made the Grafana "Pods up" panel render 0
whenever a refresh happened to coincide with a restart.

The relaxed probe still catches the original failure mode (zero healthy
targets) but only kills the pod after 10 full minutes of consecutive
failure (5 attempts × 2 min period), not 3 minutes (3 × 1 min).

  timeoutSeconds: 1 → 5
  periodSeconds: 60 → 120
  failureThreshold: 3 → 5
  initialDelaySeconds: 120 → 180

Also added wget -T 4 inside the command so wget itself bounds its
network call to 4s — leaving 1s of slack within the 5s exec budget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:39:23 -05:00
Trey t 139a990ebc fix(observability): unbreak vmagent SD on fresh deploy + ship kube-state-metrics
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
vmagent's k8s service discovery has been silently broken for 17+ days
because k3s's NetworkPolicy controller evaluates egress AFTER kube-proxy's
DNAT (contrary to the k8s spec). Pod → ClusterIP 10.43.0.1:443 was
DNAT'd to <node_public_ip>:6443, and the resulting :6443 destination
matched none of vmagent's egress rules → TCP RST → "connection refused"
on every SD watch attempt. Grafana panels using kube_* or up{} metrics
returned empty as a result.

Changes:

- network-policies.yaml: commit the previously-cluster-only NetPols
  (allow-egress-from-vmagent, allow-vmagent-to-api) so a fresh deploy
  produces a working cluster. The vmagent egress rule now includes :6443
  to public IPs (the post-DNAT path) and :8080 to the pod CIDR (for
  scraping kube-state-metrics).

- observability/kube-state-metrics.yaml: new manifest. Provides the
  kube_pod_*, kube_deployment_*, kube_service_* metrics that Grafana
  panels need to count pods, replicas, etc. Runs in kube-system with
  cluster-scoped RBAC.

- observability/vmagent.yaml:
  * add kube-state-metrics scrape job to the ConfigMap
  * add vmagent-kube-system Role+RoleBinding so cross-namespace SD works
  * replace the misleading liveness probe (was /-/healthy, which lies
    while SD is broken) with an exec probe that checks /api/v1/targets
    for at least one healthy target — automatic recovery from future
    stale-SD incidents

- scripts/03-deploy.sh: actually apply network-policies.yaml (was
  committed but never applied) and apply kube-state-metrics.yaml.

- RUNBOOK.md (new): documents the post-DNAT gotcha, the liveness probe
  trap, bearer-token recovery procedure, drift-detection diff, and a
  post-redeploy verification checklist.

- .gitignore: cover kubeconfig.tunnel (created during SSH-tunnelled
  kubectl sessions) so admin client cert can't be committed by accident.

Verified via kubectl --dry-run on all three modified manifests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:30:11 -05:00
Trey t 7cc5448a7c fix(uploads): switch from S3 POST policy to presigned PUT
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backblaze B2's S3-compatible endpoint does not implement the S3 POST
Object operation. It returns HTTP 501 to every POST regardless of URL
style — both path-style (https://s3.<region>.backblazeb2.com/<bucket>/)
and virtual-hosted-style (https://<bucket>.s3.<region>.backblazeb2.com/).

Yesterday's BucketLookupDNS fix produced virtual-hosted URLs, which is
correct for AWS but doesn't help here — B2 rejects POST on either form.
Verified with `curl -X POST https://...backblazeb2.com/honeyDueProd/`
returning 501 directly, with no signature involved.

Replace minio-go's PresignedPostPolicy with PresignHeader + http.MethodPut.
The signed URL now points at a single PUT endpoint, with Content-Type
and Content-Length signed via headers — B2/S3/MinIO all accept it. Drop
the min/max content-length range (we sign exactly one length now);
post-upload size verification still happens in VerifyAndClaim via HEAD.

Response shape:
  - URL  (was: signed POST endpoint)  → now: signed PUT URL
  - Fields → renamed to Headers; client sends them as request headers,
    not multipart form parts
  - Method (new): always "PUT", emitted explicitly so clients don't
    have to hardcode

Companion KMP/iOS commits switch the client paths from multipart POST
to single PUT. Existing builds in the field will need to be rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:41:48 -05:00
Trey t 5d8559b495 chore(deploy): mark deploy_prod.sh as deprecated; point at k3s flow
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Production migrated from Docker Swarm to k3s on 2026-04-24, but
deploy_prod.sh continued to target the old hetzner1 Swarm manager.
Without dockerd running there it spent 30+ seconds doing SSH probes
before dying on a confusing "Got: false" Swarm-state error.

Add an early guard that fails immediately with a pointer to
deploy-k3s/scripts/03-deploy.sh and the kubeconfig-fetch one-liner.
ALLOW_LEGACY_SWARM_DEPLOY=1 still bypasses if anyone needs the old path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:46:13 -05:00
132 changed files with 8774 additions and 8390 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ DEFAULT_FROM_EMAIL=honeyDue <noreply@honeyDue.treytartt.com>
# Release builds: com.myhoneydue.honeyDue # Release builds: com.myhoneydue.honeyDue
# Debug builds: com.myhoneydue.honeyDue.dev # Debug builds: com.myhoneydue.honeyDue.dev
APPLE_CLIENT_ID=com.myhoneydue.honeyDue.dev APPLE_CLIENT_ID=com.myhoneydue.honeyDue.dev
APPLE_TEAM_ID=V3PF3M6B6U APPLE_TEAM_ID=X86BR9WTLD
# APNs Settings (iOS Push Notifications) # APNs Settings (iOS Push Notifications)
# Direct APNs integration - no external push server needed # Direct APNs integration - no external push server needed
+3 -3
View File
@@ -1,5 +1,5 @@
# Admin panel build stage # Admin panel build stage
FROM node:20-alpine AS admin-builder FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS admin-builder
WORKDIR /app WORKDIR /app
@@ -109,7 +109,7 @@ FROM go-base AS worker
CMD ["/app/worker"] CMD ["/app/worker"]
# Admin panel runtime stage # Admin panel runtime stage
FROM node:20-alpine AS admin FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS admin
WORKDIR /app WORKDIR /app
@@ -131,7 +131,7 @@ ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"] CMD ["node", "server.js"]
# Default production stage (for Dokku - runs API + Admin) # 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 # Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata curl RUN apk add --no-cache ca-certificates tzdata curl
+34 -2
View File
@@ -9,6 +9,7 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/hibiken/asynq"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gorm.io/gorm" "gorm.io/gorm"
@@ -20,6 +21,7 @@ import (
"github.com/treytartt/honeydue-api/internal/router" "github.com/treytartt/honeydue-api/internal/router"
"github.com/treytartt/honeydue-api/internal/services" "github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/tracing" "github.com/treytartt/honeydue-api/internal/tracing"
"github.com/treytartt/honeydue-api/internal/worker"
"github.com/treytartt/honeydue-api/pkg/utils" "github.com/treytartt/honeydue-api/pkg/utils"
) )
@@ -54,11 +56,13 @@ func main() {
// Initialize OpenTelemetry tracing — exports to obs.88oakapps.com // Initialize OpenTelemetry tracing — exports to obs.88oakapps.com
// (Jaeger via OTLP/HTTP) when OBS_TRACES_URL is set; otherwise installs // (Jaeger via OTLP/HTTP) when OBS_TRACES_URL is set; otherwise installs
// a no-op tracer so call sites can use otel.Tracer() unconditionally. // 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{ tracingShutdown, err := tracing.Init(context.Background(), tracing.Config{
ServiceName: "honeydue-api", ServiceName: "honeydue-api",
Environment: deploymentEnvironment(cfg.Server.Debug), Environment: deploymentEnvironment(cfg.Server.Debug),
EndpointURL: os.Getenv("OBS_TRACES_URL"), EndpointURL: config.SecretValue("OBS_TRACES_URL"),
BearerToken: os.Getenv("OBS_INGEST_TOKEN"), BearerToken: config.SecretValue("OBS_INGEST_TOKEN"),
SampleRatio: tracing.SampleRatioFromEnv(), SampleRatio: tracing.SampleRatioFromEnv(),
}) })
if err != nil { if err != nil {
@@ -192,6 +196,28 @@ func main() {
Msg("Push notification client initialized") 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) // Setup router with dependencies (includes admin panel at /admin)
deps := &router.Dependencies{ deps := &router.Dependencies{
DB: db, DB: db,
@@ -203,6 +229,12 @@ func main() {
StorageService: storageService, StorageService: storageService,
MonitoringService: monitoringService, 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) e := router.SetupRouter(deps)
// Create HTTP server // Create HTTP server
+32
View File
@@ -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
}
+107
View File
@@ -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
View File
@@ -23,6 +23,7 @@ import (
"github.com/treytartt/honeydue-api/internal/repositories" "github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/services" "github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/tracing" "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/internal/worker/jobs"
"github.com/treytartt/honeydue-api/pkg/utils" "github.com/treytartt/honeydue-api/pkg/utils"
) )
@@ -47,11 +48,13 @@ func main() {
// Initialize OpenTelemetry tracing for the worker process. Same OTLP // Initialize OpenTelemetry tracing for the worker process. Same OTLP
// destination as the api; service.name distinguishes them in Jaeger. // 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{ tracingShutdown, err := tracing.Init(context.Background(), tracing.Config{
ServiceName: "honeydue-worker", ServiceName: "honeydue-worker",
Environment: workerDeploymentEnv(cfg.Server.Debug), Environment: workerDeploymentEnv(cfg.Server.Debug),
EndpointURL: os.Getenv("OBS_TRACES_URL"), EndpointURL: config.SecretValue("OBS_TRACES_URL"),
BearerToken: os.Getenv("OBS_INGEST_TOKEN"), BearerToken: config.SecretValue("OBS_INGEST_TOKEN"),
SampleRatio: tracing.SampleRatioFromEnv(), SampleRatio: tracing.SampleRatioFromEnv(),
}) })
if err != nil { if err != nil {
@@ -106,6 +109,17 @@ func main() {
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to parse Redis URL") 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) // Initialize monitoring service (if Redis is available)
var monitoringService *monitoring.Service var monitoringService *monitoring.Service
@@ -167,11 +181,15 @@ func main() {
// Create job handler // Create job handler
jobHandler := jobs.NewHandler(db, pushClient, emailService, notificationService, cfg) jobHandler := jobs.NewHandler(db, pushClient, emailService, notificationService, cfg)
// Wire upload service for the pending_uploads cleanup cron. Storage may // Wire upload service for the pending_uploads cleanup cron AND share the
// be local-disk (no S3 backend), in which case the upload service stays // underlying storage service with the TaskService below so the worker can
// nil and the cleanup handler no-ops. Cache is optional — the cleanup // load completion images for email embedding. Storage may be local-disk
// path doesn't rate-limit and works fine with a nil cache. // (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 { if storageService, sErr := services.NewStorageService(&cfg.Storage); sErr == nil {
sharedStorageService = storageService
if s3 := storageService.S3Backend(); s3 != nil { if s3 := storageService.S3Backend(); s3 != nil {
pendingUploadRepo := repositories.NewPendingUploadRepository(db) pendingUploadRepo := repositories.NewPendingUploadRepository(db)
uploadService := services.NewUploadService(pendingUploadRepo, s3, &cfg.Storage, nil) 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") 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 // Create Asynq mux and register handlers
mux := asynq.NewServeMux() mux := asynq.NewServeMux()
@@ -195,6 +232,11 @@ func main() {
mux.HandleFunc(jobs.TypeOnboardingEmails, jobHandler.HandleOnboardingEmails) mux.HandleFunc(jobs.TypeOnboardingEmails, jobHandler.HandleOnboardingEmails)
mux.HandleFunc(jobs.TypeReminderLogCleanup, jobHandler.HandleReminderLogCleanup) mux.HandleFunc(jobs.TypeReminderLogCleanup, jobHandler.HandleReminderLogCleanup)
mux.HandleFunc(jobs.TypeUploadCleanup, jobHandler.HandleUploadCleanup) 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) // Register email job handlers (welcome, verification, password reset, password changed)
if emailService != nil { if emailService != nil {
@@ -243,6 +285,23 @@ func main() {
} }
log.Info().Str("cron", "30 * * * *").Msg("Registered pending_uploads cleanup job (runs hourly)") 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 // Handle graceful shutdown
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
@@ -254,6 +313,12 @@ func main() {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`)) _, _ = 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{ healthSrv := &http.Server{
Addr: workerHealthAddr, Addr: workerHealthAddr,
Handler: healthMux, Handler: healthMux,
+1 -1
View File
@@ -92,7 +92,7 @@ ADMIN_PW="$(openssl rand -base64 16)"
EMAIL_USER="treytartt@fastmail.com" EMAIL_USER="treytartt@fastmail.com"
APNS_KEY_ID="9R5Q7ZX874" APNS_KEY_ID="9R5Q7ZX874"
APNS_TEAM_ID="V3PF3M6B6U" APNS_TEAM_ID="X86BR9WTLD"
log "" log ""
log "Pre-filled from existing dev server:" log "Pre-filled from existing dev server:"
+1
View File
@@ -3,6 +3,7 @@ config.yaml
# Generated files # Generated files
kubeconfig kubeconfig
kubeconfig.*
cluster-config.yaml cluster-config.yaml
prod.env prod.env
+966
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -30,6 +30,7 @@ load_balancer_ip: ""
domains: domains:
api: api.myhoneydue.com api: api.myhoneydue.com
admin: admin.myhoneydue.com admin: admin.myhoneydue.com
app: app.myhoneydue.com # web client host — added to CORS_ALLOWED_ORIGINS
base: myhoneydue.com base: myhoneydue.com
# --- Container Registry (GHCR) --- # --- Container Registry (GHCR) ---
+5 -1
View File
@@ -23,8 +23,11 @@ spec:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
spec: spec:
serviceAccountName: admin 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: imagePullSecrets:
- name: ghcr-credentials - name: gitea-credentials
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 1001 runAsUser: 1001
@@ -35,6 +38,7 @@ spec:
containers: containers:
- name: admin - name: admin
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
ports: ports:
- containerPort: 3000 - containerPort: 3000
protocol: TCP protocol: TCP
+26 -64
View File
@@ -23,8 +23,11 @@ spec:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
spec: spec:
serviceAccountName: api 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: imagePullSecrets:
- name: ghcr-credentials - name: gitea-credentials
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 1000 runAsUser: 1000
@@ -35,6 +38,7 @@ spec:
containers: containers:
- name: api - name: api
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
ports: ports:
- containerPort: 8000 - containerPort: 8000
protocol: TCP protocol: TCP
@@ -46,65 +50,16 @@ spec:
envFrom: envFrom:
- configMapRef: - configMapRef:
name: honeydue-config name: honeydue-config
env: # Audit CODE-F8: secrets are NOT injected as environment variables.
- name: POSTGRES_PASSWORD # Env vars are readable for the life of the pod via /proc/<pid>/environ
valueFrom: # and leak into crash dumps / child processes. honeydue-secrets is
secretKeyRef: # mounted read-only at /etc/honeydue/secrets (mode 0400) and the Go
name: honeydue-secrets # config layer (config.loadFileSecrets) reads each key from its file.
key: POSTGRES_PASSWORD # Non-secret config still arrives via the configMapRef above.
- 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
volumeMounts: volumeMounts:
- name: app-secrets
mountPath: /etc/honeydue/secrets
readOnly: true
- name: apns-key - name: apns-key
mountPath: /secrets/apns mountPath: /secrets/apns
readOnly: true readOnly: true
@@ -121,11 +76,12 @@ spec:
httpGet: httpGet:
path: /api/health/ path: /api/health/
port: 8000 port: 8000
# MigrateWithLock in cmd/api/main.go runs pg_advisory_lock on # Schema migrations run separately in the honeydue-migrate Job
# every startup. On a cold boot with 3 replicas, the first does # *before* this Deployment rolls — the api itself does not migrate
# AutoMigrate (~90s) and the others wait on the lock, so real # (it only verifies goose_db_version at boot). Cold start still
# startup runs 90240s. 48 × 5s = 240s grace absorbs it without # pays the DB pool warm-up + Redis connect + APNs/FCM client init
# healthcheck killing a still-starting replica. # before /api/health/ goes green. 48 × 5s = 240s grace keeps the
# probe from killing a still-starting replica.
failureThreshold: 48 failureThreshold: 48
periodSeconds: 5 periodSeconds: 5
readinessProbe: readinessProbe:
@@ -143,6 +99,12 @@ spec:
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 10 timeoutSeconds: 10
volumes: 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 - name: apns-key
secret: secret:
secretName: honeydue-apns-key secretName: honeydue-apns-key
@@ -53,7 +53,12 @@ metadata:
labels: labels:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
annotations: 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: spec:
ingressClassName: traefik ingressClassName: traefik
tls: tls:
@@ -98,3 +103,98 @@ spec:
name: web name: web
port: port:
number: 3000 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
+31 -2
View File
@@ -21,12 +21,20 @@ spec:
headers: headers:
frameDeny: true frameDeny: true
contentTypeNosniff: 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" referrerPolicy: "strict-origin-when-cross-origin"
customResponseHeaders: customResponseHeaders:
X-Content-Type-Options: "nosniff" X-Content-Type-Options: "nosniff"
X-Frame-Options: "DENY" 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 # Content-Security-Policy is intentionally NOT set here — the Go API
# sets a CSP in internal/router/router.go that permits Google Fonts # sets a CSP in internal/router/router.go that permits Google Fonts
# for the landing page. Two CSP headers would intersect and break it. # for the landing page. Two CSP headers would intersect and break it.
@@ -83,3 +91,24 @@ spec:
basicAuth: basicAuth:
secret: admin-basic-auth secret: admin-basic-auth
realm: "honeyDue Admin" 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
+92
View File
@@ -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).
+232
View File
@@ -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,
},
]),
},
}
+44
View File
@@ -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
+208
View File
@@ -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-----
+4 -1
View File
@@ -27,8 +27,10 @@ spec:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
spec: spec:
restartPolicy: Never restartPolicy: Never
# The migrate Job never calls the k8s API (audit F11).
automountServiceAccountToken: false
imagePullSecrets: imagePullSecrets:
- name: ghcr-credentials - name: gitea-credentials
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 1000 runAsUser: 1000
@@ -38,6 +40,7 @@ spec:
containers: containers:
- name: goose - name: goose
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh — same as api image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh — same as api
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit
command: ["/bin/sh", "-c"] command: ["/bin/sh", "-c"]
# DB_HOST in the ConfigMap points at the -pooler endpoint for runtime. # DB_HOST in the ConfigMap points at the -pooler endpoint for runtime.
# goose's session-scoped advisory lock can't survive PgBouncer # goose's session-scoped advisory lock can't survive PgBouncer
+165
View File
@@ -140,6 +140,20 @@ spec:
ports: ports:
- protocol: TCP - protocol: TCP
port: 6379 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) # External services: Neon DB (5432), SMTP (587), HTTPS (443 — APNs, FCM, B2, PostHog)
- to: - to:
- ipBlock: - ipBlock:
@@ -275,3 +289,154 @@ spec:
ports: ports:
- protocol: TCP - protocol: TCP
port: 443 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
+118 -16
View File
@@ -42,18 +42,61 @@ data:
- target_label: service - target_label: service
replacement: api replacement: api
# honeyDue worker — also exposes /metrics if/when we add it. # kube-state-metrics — cluster object state (kube_pod_*, kube_deployment_*,
# Keep this stanza commented until the worker has a /metrics endpoint; # etc.) needed for Grafana panels that count pods/replicas/etc.
# uncommented form drops scrapes silently. - job_name: kube-state-metrics
# - job_name: worker kubernetes_sd_configs:
# kubernetes_sd_configs: - role: endpoints
# - role: pod namespaces:
# namespaces: names: [kube-system]
# names: [honeydue] relabel_configs:
# relabel_configs: - source_labels: [__meta_kubernetes_service_label_app_kubernetes_io_name]
# - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name] action: keep
# action: keep regex: kube-state-metrics
# regex: worker - 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 apiVersion: v1
@@ -104,6 +147,35 @@ roleRef:
name: vmagent name: vmagent
apiGroup: rbac.authorization.k8s.io 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 apiVersion: apps/v1
kind: Deployment kind: Deployment
@@ -135,7 +207,17 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: vmagent - 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: args:
- "-promscrape.config=/etc/vmagent/scrape.yaml" - "-promscrape.config=/etc/vmagent/scrape.yaml"
- "-remoteWrite.url=https://obs.88oakapps.com/api/v1/write" - "-remoteWrite.url=https://obs.88oakapps.com/api/v1/write"
@@ -162,12 +244,32 @@ spec:
readOnly: true readOnly: true
- name: buffer - name: buffer
mountPath: /tmp/vmagent 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: httpGet:
path: /-/healthy path: /-/healthy
port: http port: http
initialDelaySeconds: 10 initialDelaySeconds: 5
periodSeconds: 30 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: readinessProbe:
httpGet: httpGet:
path: /-/healthy path: /-/healthy
+6 -1
View File
@@ -20,6 +20,9 @@ spec:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
spec: spec:
serviceAccountName: redis 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: nodeSelector:
honeydue/redis: "true" honeydue/redis: "true"
securityContext: securityContext:
@@ -31,7 +34,9 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: redis - 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: command:
- sh - sh
- -c - -c
+5 -1
View File
@@ -23,8 +23,11 @@ spec:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
spec: spec:
serviceAccountName: web 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: imagePullSecrets:
- name: ghcr-credentials - name: gitea-credentials
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 1001 runAsUser: 1001
@@ -43,6 +46,7 @@ spec:
containers: containers:
- name: web - name: web
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh or manual sed image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh or manual sed
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
ports: ports:
- containerPort: 3000 - containerPort: 3000
protocol: TCP protocol: TCP
+68 -58
View File
@@ -27,8 +27,11 @@ spec:
app.kubernetes.io/part-of: honeydue app.kubernetes.io/part-of: honeydue
spec: spec:
serviceAccountName: worker 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: imagePullSecrets:
- name: ghcr-credentials - name: gitea-credentials
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 1000 runAsUser: 1000
@@ -39,6 +42,12 @@ spec:
containers: containers:
- name: worker - name: worker
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh 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: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
@@ -47,64 +56,16 @@ spec:
envFrom: envFrom:
- configMapRef: - configMapRef:
name: honeydue-config name: honeydue-config
env: # Audit CODE-F8: secrets are NOT injected as environment variables.
- name: POSTGRES_PASSWORD # Env vars are readable for the life of the pod via /proc/<pid>/environ
valueFrom: # and leak into crash dumps / child processes. honeydue-secrets is
secretKeyRef: # mounted read-only at /etc/honeydue/secrets (mode 0400) and the Go
name: honeydue-secrets # config layer (config.loadFileSecrets) reads each key from its file.
key: POSTGRES_PASSWORD # Non-secret config still arrives via the configMapRef above.
- 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
volumeMounts: volumeMounts:
- name: app-secrets
mountPath: /etc/honeydue/secrets
readOnly: true
- name: apns-key - name: apns-key
mountPath: /secrets/apns mountPath: /secrets/apns
readOnly: true readOnly: true
@@ -124,6 +85,12 @@ spec:
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 5 timeoutSeconds: 5
volumes: 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 - name: apns-key
secret: secret:
secretName: honeydue-apns-key secretName: honeydue-apns-key
@@ -133,3 +100,46 @@ spec:
- name: tmp - name: tmp
emptyDir: emptyDir:
sizeLimit: 64Mi 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
+56 -5
View File
@@ -68,6 +68,25 @@ SECRET_ARGS=(
if [[ -n "${REDIS_PASSWORD}" ]]; then if [[ -n "${REDIS_PASSWORD}" ]]; then
log " Including REDIS_PASSWORD in secrets" log " Including REDIS_PASSWORD in secrets"
SECRET_ARGS+=(--from-literal="REDIS_PASSWORD=${REDIS_PASSWORD}") 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 fi
# Observability ingest credentials live in deploy/prod.env (gitignored) so # 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" \ --from-file="apns_auth_key.p8=${SECRETS_DIR}/apns_auth_key.p8" \
--dry-run=client -o yaml | kubectl apply -f - --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_SERVER="$(cfg registry.server)"
REGISTRY_USER="$(cfg registry.username)" REGISTRY_USER="$(cfg registry.username)"
REGISTRY_TOKEN="$(cfg registry.token)" REGISTRY_TOKEN="$(cfg registry.token)"
if [[ -n "${REGISTRY_SERVER}" && -n "${REGISTRY_USER}" && -n "${REGISTRY_TOKEN}" ]]; then if [[ -n "${REGISTRY_SERVER}" && -n "${REGISTRY_USER}" && -n "${REGISTRY_TOKEN}" ]]; then
log "Creating ghcr-credentials..." log "Creating gitea-credentials..."
kubectl create secret docker-registry ghcr-credentials \ kubectl create secret docker-registry gitea-credentials \
--namespace="${NAMESPACE}" \ --namespace="${NAMESPACE}" \
--docker-server="${REGISTRY_SERVER}" \ --docker-server="${REGISTRY_SERVER}" \
--docker-username="${REGISTRY_USER}" \ --docker-username="${REGISTRY_USER}" \
--docker-password="${REGISTRY_TOKEN}" \ --docker-password="${REGISTRY_TOKEN}" \
--dry-run=client -o yaml | kubectl apply -f - --dry-run=client -o yaml | kubectl apply -f -
else else
warn "Registry credentials incomplete in config.yaml — skipping ghcr-credentials." warn "Registry credentials incomplete in config.yaml — skipping gitea-credentials."
fi fi
# --- Create Cloudflare origin cert --- # --- 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 if [[ -n "${ADMIN_AUTH_USER}" && -n "${ADMIN_AUTH_PASSWORD}" ]]; then
command -v htpasswd >/dev/null 2>&1 || die "Missing: htpasswd (install apache2-utils)" command -v htpasswd >/dev/null 2>&1 || die "Missing: htpasswd (install apache2-utils)"
log "Creating admin-basic-auth secret..." 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 \ kubectl create secret generic admin-basic-auth \
--namespace="${NAMESPACE}" \ --namespace="${NAMESPACE}" \
--from-literal=users="${HTPASSWD}" \ --from-literal=users="${HTPASSWD}" \
@@ -142,6 +164,35 @@ else
warn "Admin panel will NOT have basic auth protection." warn "Admin panel will NOT have basic auth protection."
fi 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 --- # --- Done ---
log "" log ""
+105 -9
View File
@@ -128,6 +128,56 @@ else
warn "Skipping build. Using images for tag: ${DEPLOY_TAG}" warn "Skipping build. Using images for tag: ${DEPLOY_TAG}"
fi 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 --- # --- Generate and apply ConfigMap from config.yaml ---
log "Generating env from config.yaml..." log "Generating env from config.yaml..."
@@ -146,6 +196,14 @@ kubectl create configmap honeydue-config \
log "Applying manifests..." log "Applying manifests..."
kubectl apply -f "${MANIFESTS}/namespace.yaml" 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}/redis/"
kubectl apply -f "${MANIFESTS}/ingress/" kubectl apply -f "${MANIFESTS}/ingress/"
@@ -158,7 +216,7 @@ kubectl apply -f "${MANIFESTS}/ingress/"
# pod sees a stale schema. # pod sees a stale schema.
log "Running database migrations (goose Job)..." log "Running database migrations (goose Job)..."
kubectl delete job honeydue-migrate -n "${NAMESPACE}" --ignore-not-found --wait=true >/dev/null 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 if ! kubectl wait --namespace="${NAMESPACE}" --for=condition=complete --timeout=10m job/honeydue-migrate; then
warn "migration Job failed — see logs:" warn "migration Job failed — see logs:"
kubectl logs -n "${NAMESPACE}" job/honeydue-migrate --tail=200 || true kubectl logs -n "${NAMESPACE}" job/honeydue-migrate --tail=200 || true
@@ -167,32 +225,64 @@ fi
log "Migrations applied; proceeding with api/worker rollout" log "Migrations applied; proceeding with api/worker rollout"
# Apply deployments with image substitution # 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/service.yaml"
kubectl apply -f "${MANIFESTS}/api/hpa.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" kubectl apply -f "${MANIFESTS}/admin/service.yaml"
if [[ -d "${MANIFESTS}/web" ]]; then 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" kubectl apply -f "${MANIFESTS}/web/service.yaml"
fi fi
# Observability — vmagent scrapes api Pods :8000/metrics and remote-writes # Observability — vmagent scrapes api Pods :8000/metrics + kube-state-metrics
# to obs.88oakapps.com. The bearer token comes from deploy/prod.env so it # :8080/metrics and remote-writes everything to obs.88oakapps.com. The bearer
# stays out of the repo; the manifest holds TOKEN_PLACEHOLDER. # 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 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 # 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. # 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)" OBS_TOKEN="$(grep -E '^OBS_INGEST_TOKEN=' "${REPO_DIR}/deploy/prod.env" 2>/dev/null | cut -d= -f2- || true)"
if [[ -z "${OBS_TOKEN}" ]]; then 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 else
sed "s|TOKEN_PLACEHOLDER|${OBS_TOKEN}|" "${MANIFESTS}/observability/vmagent.yaml" | kubectl apply -f - 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
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 fi
# --- Wait for rollouts --- # --- Wait for rollouts ---
@@ -209,6 +299,12 @@ fi
if kubectl -n "${NAMESPACE}" get deployment vmagent >/dev/null 2>&1; then if kubectl -n "${NAMESPACE}" get deployment vmagent >/dev/null 2>&1; then
kubectl rollout status deployment/vmagent -n "${NAMESPACE}" --timeout=120s kubectl rollout status deployment/vmagent -n "${NAMESPACE}" --timeout=120s
fi 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 --- # --- Done ---
+21 -6
View File
@@ -100,7 +100,7 @@ lines = [
# API # API
'DEBUG=false', 'DEBUG=false',
f\"ALLOWED_HOSTS={d['api']},{d['base']}\", 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', 'TIMEZONE=UTC',
f\"BASE_URL=https://{d['base']}\", f\"BASE_URL=https://{d['base']}\",
'PORT=8000', 'PORT=8000',
@@ -119,9 +119,14 @@ lines = [
f\"DB_MAX_IDLE_CONNS={db['max_idle_conns']}\", f\"DB_MAX_IDLE_CONNS={db['max_idle_conns']}\",
f\"DB_MAX_LIFETIME={db['max_lifetime']}\", f\"DB_MAX_LIFETIME={db['max_lifetime']}\",
f\"DB_MAX_IDLE_TIME={db.get('max_idle_time', '0s')}\", f\"DB_MAX_IDLE_TIME={db.get('max_idle_time', '0s')}\",
# Redis (in-namespace DNS short form — password injected if configured; # Redis in-namespace DNS short form (works because pod /etc/resolv.conf
# short form works because /etc/resolv.conf in pods searches honeydue.svc.cluster.local) # searches honeydue.svc.cluster.local). Audit HIGH-1: the password is
f\"REDIS_URL=redis://{':%s@' % val(rd.get('password')) if rd.get('password') else ''}redis:6379/0\", # 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', 'REDIS_DB=0',
# Email # Email
f\"EMAIL_HOST={em['host']}\", f\"EMAIL_HOST={em['host']}\",
@@ -218,8 +223,18 @@ config = {
'image': 'ubuntu-24.04', 'image': 'ubuntu-24.04',
}, },
'additional_packages': ['open-iscsi'], 'additional_packages': ['open-iscsi'],
'post_create_commands': ['sudo systemctl enable --now iscsid'], # Audit K3S-CG2: harden the node OS at provision time — fail2ban for SSH
'k3s_config_file': 'secrets-encryption: true\n', # 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)) print(yaml.dump(config, default_flow_style=False, sort_keys=False))
+39
View File
@@ -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
}
}
}
+101
View File
@@ -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
+25
View File
@@ -1,6 +1,31 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail 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)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
REPO_DIR="$(cd "${DEPLOY_DIR}/.." && pwd)" REPO_DIR="$(cd "${DEPLOY_DIR}/.." && pwd)"
+60
View File
@@ -14,6 +14,7 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-honeydue} POSTGRES_DB: ${POSTGRES_DB:-honeydue}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./deploy/local/postgres-init:/docker-entrypoint-initdb.d:ro
ports: ports:
- "${DB_PORT:-5433}:5432" # 5433 externally to avoid conflicts with local postgres - "${DB_PORT:-5433}:5432" # 5433 externally to avoid conflicts with local postgres
healthcheck: healthcheck:
@@ -91,6 +92,10 @@ services:
# Storage encryption # Storage encryption
STORAGE_ENCRYPTION_KEY: ${STORAGE_ENCRYPTION_KEY} STORAGE_ENCRYPTION_KEY: ${STORAGE_ENCRYPTION_KEY}
# Kratos (identity service)
KRATOS_PUBLIC_URL: "http://kratos:4433"
KRATOS_ADMIN_URL: "http://kratos:4434"
volumes: volumes:
- ./push_certs:/certs:ro - ./push_certs:/certs:ro
- ./uploads:/app/uploads - ./uploads:/app/uploads
@@ -99,6 +104,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
kratos:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/api/health/"] test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/api/health/"]
interval: 30s interval: 30s
@@ -184,6 +191,59 @@ services:
networks: networks:
- honeydue-network - 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 — lightweight real-time log viewer
dozzle: dozzle:
image: amir20/dozzle:latest image: amir20/dozzle:latest
+28 -23
View File
@@ -8,6 +8,13 @@ long-haul components, and dedicated service accounts with dropped
capabilities inside containers. This chapter documents each layer, the capabilities inside containers. This chapter documents each layer, the
rationale, and what's currently missing (and why). 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 ## Threat model
Who we're defending against, in rough order of likelihood: 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 - **Authorize requests** — that's the app's job
- **Protect origin if origin IP leaks** — once someone knows a node IP - **Protect origin if origin IP leaks** — once someone knows a node IP
they can bypass CF. Mitigation: keep origin firewall strict (Chapter 4). they can bypass CF. Mitigation: keep origin firewall strict (Chapter 4).
- **Encrypt between CF and origin** — we're on SSL=Flexible, so CF↔origin - **~~Encrypt between CF and origin~~** — done (2026-04-24): SSL mode is
is HTTP. This is in our TODO (Chapter 20, upgrade to Full-strict). Full (strict); CF↔origin is TLS with a Cloudflare Origin CA cert.
### The proxy-IP problem ### 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 directly) can't spoof headers — Traefik ignores `X-Forwarded-*` unless
the source IP is in CF's ranges. the source IP is in CF's ranges.
**TODO** (Chapter 20): Enforce at UFW level — allow 80/tcp only from **Done (2026-04-24):** the node UFW allowlist permits `:443` only from
CF IP ranges. Today any IP can reach the origin on port 80. Cloudflare's IP ranges; the `Anywhere` rules on `:80`/`:443` were removed.
## Layer 2 — Node (OS, SSH, firewall) ## 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 reach api pods on port 8000
- **allow-ingress-to-admin** — same, for admin:3000 - **allow-ingress-to-admin** — same, for admin:3000
**These are not currently applied.** Without them, our pods can freely **Applied.** `03-deploy.sh` applies
talk to anything — including, theoretically, malicious destinations if `deploy-k3s/manifests/network-policies.yaml` on every deploy — default-deny
an attacker gets RCE inside a pod. 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 ### What network policies prevent
just need to `kubectl apply -f deploy-k3s/manifests/network-policies.yaml`
and test that nothing breaks.
### What network policies would prevent
| Attack scenario | NetworkPolicy blocks | | Attack scenario | NetworkPolicy blocks |
|---|---| |---|---|
@@ -324,13 +329,10 @@ renewed Let's Encrypt or CF-managed cert for `*.myhoneydue.com`.
### CF ↔ origin ### CF ↔ origin
**Plaintext HTTP** (SSL = Flexible). An attacker with access to the **TLS — SSL = Full (strict)** (since 2026-04-24). A Cloudflare Origin CA
Cloudflare-to-Hetzner path could read traffic. In practice nobody who certificate (`cloudflare-origin-cert` secret) is installed on all three
isn't Cloudflare or Hetzner sits on that path. ingresses; Cloudflare validates it. Both user↔CF and CF↔origin are
encrypted, and a DNS-hijack MitM is defeated by the origin-cert check.
**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).
### API ↔ Neon Postgres ### API ↔ Neon Postgres
@@ -454,11 +456,14 @@ Mitigations:
- Gitea itself is behind login; PAT is scoped to read:packages + - Gitea itself is behind login; PAT is scoped to read:packages +
write:packages only write:packages only
- Gitea runs on the operator's infrastructure (same operator account) - Gitea runs on the operator's infrastructure (same operator account)
- Image tags are SHA-pinned (`:237c6b8`) not `:latest` → attacker can't - Workloads deploy by immutable `@sha256:` digest, not by mutable tag
replace an existing tag's image without us noticing the digest change (`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 **TODO**: cosign signing is wired into `03-deploy.sh` (guarded — runs when
time. `cosign` + `COSIGN_KEY` are present); cluster-side admission verification
(Kyverno/Connaisseur) is still pending. See `deploy-k3s/SECURITY.md` → L5.
## Operator workstation security ## Operator workstation security
+8
View File
@@ -1,5 +1,13 @@
# 06 — Traefik Ingress # 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 ## Summary
Traefik is the reverse proxy that routes external HTTP requests to the Traefik is the reverse proxy that routes external HTTP requests to the
+6
View File
@@ -1,5 +1,11 @@
# 07 — Services # 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 ## Summary
Five workloads run in the `honeydue` namespace: **api** (Go REST API, 3 Five workloads run in the `honeydue` namespace: **api** (Go REST API, 3
+6
View File
@@ -1,5 +1,11 @@
# 10 — Secrets & Config # 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 ## Summary
Non-sensitive config (hostnames, ports, feature flags, etc.) lives in Non-sensitive config (hostnames, ports, feature flags, etc.) lives in
+146
View File
@@ -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 23 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).
+6 -6
View File
@@ -27,10 +27,10 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 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/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/sdk 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/oauth2 v0.35.0
golang.org/x/term v0.41.0 golang.org/x/term v0.43.0
golang.org/x/text v0.35.0 golang.org/x/text v0.37.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
google.golang.org/api v0.257.0 google.golang.org/api v0.257.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
@@ -117,9 +117,9 @@ require (
go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.44.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
+10 -10
View File
@@ -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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 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-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= 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 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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-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.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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= 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.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.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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= 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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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= 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 package handlers
import ( import (
"net/http" "net/http"
"strconv"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"gorm.io/gorm" "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 { type AdminAppleSocialAuthHandler struct {
db *gorm.DB db *gorm.DB
} }
// NewAdminAppleSocialAuthHandler creates a new admin Apple social auth handler
func NewAdminAppleSocialAuthHandler(db *gorm.DB) *AdminAppleSocialAuthHandler { func NewAdminAppleSocialAuthHandler(db *gorm.DB) *AdminAppleSocialAuthHandler {
return &AdminAppleSocialAuthHandler{db: db} return &AdminAppleSocialAuthHandler{db: db}
} }
// AppleSocialAuthResponse represents the response for an Apple social auth entry func (h *AdminAppleSocialAuthHandler) gone(c echo.Context) error {
type AppleSocialAuthResponse struct { return c.JSON(http.StatusGone, map[string]string{"message": "Apple social auth is managed by Ory Kratos"})
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) 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) }
+9 -126
View File
@@ -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 package handlers
import ( import (
"net/http" "net/http"
"strconv"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"gorm.io/gorm" "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 { type AdminAuthTokenHandler struct {
db *gorm.DB db *gorm.DB
} }
// NewAdminAuthTokenHandler creates a new admin auth token handler
func NewAdminAuthTokenHandler(db *gorm.DB) *AdminAuthTokenHandler { func NewAdminAuthTokenHandler(db *gorm.DB) *AdminAuthTokenHandler {
return &AdminAuthTokenHandler{db: db} return &AdminAuthTokenHandler{db: db}
} }
// AuthTokenResponse represents an auth token in API responses func (h *AdminAuthTokenHandler) gone(c echo.Context) error {
type AuthTokenResponse struct { return c.JSON(http.StatusGone, map[string]string{"message": "auth tokens are managed by Ory Kratos"})
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) 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 package handlers
import ( import (
"net/http" "net/http"
"strconv"
"strings"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"gorm.io/gorm" "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. // AdminConfirmationCodeHandler is a no-op stub.
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
type AdminConfirmationCodeHandler struct { type AdminConfirmationCodeHandler struct {
db *gorm.DB db *gorm.DB
} }
// NewAdminConfirmationCodeHandler creates a new admin confirmation code handler
func NewAdminConfirmationCodeHandler(db *gorm.DB) *AdminConfirmationCodeHandler { func NewAdminConfirmationCodeHandler(db *gorm.DB) *AdminConfirmationCodeHandler {
return &AdminConfirmationCodeHandler{db: db} return &AdminConfirmationCodeHandler{db: db}
} }
// ConfirmationCodeResponse represents a confirmation code in API responses func (h *AdminConfirmationCodeHandler) gone(c echo.Context) error {
type ConfirmationCodeResponse struct { return c.JSON(http.StatusGone, map[string]string{"message": "confirmation codes are managed by Ory Kratos"})
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) 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 package handlers
import ( import (
"net/http" "net/http"
"strconv"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"gorm.io/gorm" "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 { type AdminPasswordResetCodeHandler struct {
db *gorm.DB db *gorm.DB
} }
// NewAdminPasswordResetCodeHandler creates a new admin password reset code handler
func NewAdminPasswordResetCodeHandler(db *gorm.DB) *AdminPasswordResetCodeHandler { func NewAdminPasswordResetCodeHandler(db *gorm.DB) *AdminPasswordResetCodeHandler {
return &AdminPasswordResetCodeHandler{db: db} return &AdminPasswordResetCodeHandler{db: db}
} }
// PasswordResetCodeResponse represents a password reset code in API responses func (h *AdminPasswordResetCodeHandler) gone(c echo.Context) error {
type PasswordResetCodeResponse struct { return c.JSON(http.StatusGone, map[string]string{"message": "password reset codes are managed by Ory Kratos"})
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) 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) }
+8 -125
View File
@@ -248,137 +248,20 @@ func (h *AdminSettingsHandler) cacheAllLookups(ctx context.Context) (bool, error
} }
log.Debug().Int("count", len(taskTemplates)).Msg("Cached task templates") log.Debug().Int("count", len(taskTemplates)).Msg("Cached task templates")
// Build and cache the unified seeded data response // Invalidate the unified seeded-data cache for every locale. The combined
// Import the grouped response type // response is localized (lookup display_name + home-profile options) and is
seededData := map[string]interface{}{ // rebuilt per-locale on demand by the static_data handler, so the correct
"residence_types": residenceTypes, // action after a lookup change is to clear all language variants rather than
"task_categories": categories, // pre-warm a single (non-localized) blob.
"task_priorities": priorities, if err := cache.InvalidateSeededData(ctx); err != nil {
"task_frequencies": frequencies, return false, fmt.Errorf("failed to invalidate seeded data: %w", err)
"contractor_specialties": specialties,
"task_templates": buildGroupedTemplates(taskTemplates),
} }
log.Debug().Msg("Invalidated per-locale seeded data cache")
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.Info().Msg("All lookup data cached in Redis successfully") log.Info().Msg("All lookup data cached in Redis successfully")
return true, nil 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 // SeedTestData handles POST /api/admin/settings/seed-test-data
func (h *AdminSettingsHandler) SeedTestData(c echo.Context) error { func (h *AdminSettingsHandler) SeedTestData(c echo.Context) error {
if err := h.runSeedFile("002_test_data.sql"); err != nil { if err := h.runSeedFile("002_test_data.sql"); err != nil {
+3 -6
View File
@@ -207,9 +207,7 @@ func (h *AdminUserHandler) Create(c echo.Context) error {
user.IsSuperuser = *req.IsSuperuser user.IsSuperuser = *req.IsSuperuser
} }
if err := user.SetPassword(req.Password); err != nil { // Password management is handled by Ory Kratos; no local password hashing.
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to hash password"})
}
if err := h.db.Create(&user).Error; err != nil { if err := h.db.Create(&user).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create user"}) 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 { if req.IsSuperuser != nil {
user.IsSuperuser = *req.IsSuperuser user.IsSuperuser = *req.IsSuperuser
} }
// Password management is handled by Ory Kratos; local password update ignored.
if req.Password != nil { if req.Password != nil {
if err := user.SetPassword(*req.Password); err != nil { _ = req.Password // Password changes must go through Kratos admin API
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to hash password"})
}
} }
if err := h.db.Save(&user).Error; err != nil { if err := h.db.Save(&user).Error; err != nil {
+81 -5
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/url" "net/url"
@@ -141,6 +142,13 @@ type SecurityConfig struct {
MaxPasswordResetRate int // per hour MaxPasswordResetRate int // per hour
TokenExpiryDays int // Number of days before auth tokens expire (default 90) 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) 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. // StorageConfig holds file storage settings.
@@ -216,6 +224,11 @@ func Load() (*Config, error) {
// Set defaults // Set defaults
setDefaults() 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) // Parse DATABASE_URL if set (Dokku-style)
dbConfig := DatabaseConfig{ dbConfig := DatabaseConfig{
Host: viper.GetString("DB_HOST"), Host: viper.GetString("DB_HOST"),
@@ -298,6 +311,8 @@ func Load() (*Config, error) {
MaxPasswordResetRate: 3, MaxPasswordResetRate: 3,
TokenExpiryDays: viper.GetInt("TOKEN_EXPIRY_DAYS"), TokenExpiryDays: viper.GetInt("TOKEN_EXPIRY_DAYS"),
TokenRefreshDays: viper.GetInt("TOKEN_REFRESH_DAYS"), TokenRefreshDays: viper.GetInt("TOKEN_REFRESH_DAYS"),
KratosPublicURL: viper.GetString("KRATOS_PUBLIC_URL"),
KratosAdminURL: viper.GetString("KRATOS_ADMIN_URL"),
}, },
Storage: StorageConfig{ Storage: StorageConfig{
UploadDir: viper.GetString("STORAGE_UPLOAD_DIR"), UploadDir: viper.GetString("STORAGE_UPLOAD_DIR"),
@@ -405,6 +420,8 @@ func setDefaults() {
// Token expiry defaults // Token expiry defaults
viper.SetDefault("TOKEN_EXPIRY_DAYS", 90) // Tokens expire after 90 days 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 viper.SetDefault("TOKEN_REFRESH_DAYS", 60) // Tokens can be refreshed after 60 days
// Storage defaults // Storage defaults
@@ -432,14 +449,67 @@ func isWeakSecretKey(key string) bool {
return knownWeakSecretKeys[strings.ToLower(strings.TrimSpace(key))] 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 { 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.Security.SecretKey == "" {
if cfg.Server.Debug { if cfg.Server.Debug {
// In debug mode, use a default key with a warning for local development // Debug only: generate a random key per boot. Tokens signed with
cfg.Security.SecretKey = "change-me-in-production-secret-key-12345" // it do not survive a restart, which is acceptable for local dev
fmt.Println("WARNING: SECRET_KEY not set, using default (debug mode only)") // and far safer than a well-known hardcoded fallback.
fmt.Println("WARNING: *** DO NOT USE THIS DEFAULT KEY IN PRODUCTION ***") 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 { } else {
// In production, refuse to start without a proper secret key // In production, refuse to start without a proper secret key
return fmt.Errorf("FATAL: SECRET_KEY environment variable is required in production (DEBUG=false)") 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 // Database password might come from DATABASE_URL, don't require it separately
// The actual connection will fail if credentials are wrong // The actual connection will fail if credentials are wrong
+31 -2
View File
@@ -106,8 +106,10 @@ func TestLoad_Validation_MissingSecretKey_DebugMode(t *testing.T) {
c, err := Load() c, err := Load()
require.NoError(t, err) require.NoError(t, err)
// In debug mode, a default key is assigned // Audit M8: in debug mode an ephemeral random key is generated per boot
assert.Equal(t, "change-me-in-production-secret-key-12345", c.Security.SecretKey) // (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) { 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) 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) { func TestLoad_Validation_EncryptionKey_Valid(t *testing.T) {
resetConfigState() resetConfigState()
t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests") t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests")
-5
View File
@@ -244,12 +244,7 @@ func Migrate() error {
// User and auth tables // User and auth tables
&models.User{}, &models.User{},
&models.AuthToken{},
&models.UserProfile{}, &models.UserProfile{},
&models.ConfirmationCode{},
&models.PasswordResetCode{},
&models.AppleSocialAuth{},
&models.GoogleSocialAuth{},
// Admin users (separate from app users) // Admin users (separate from app users)
&models.AdminUser{}, &models.AdminUser{},
+3
View File
@@ -9,7 +9,10 @@ import (
// ContractorSpecialtyResponse represents a contractor specialty // ContractorSpecialtyResponse represents a contractor specialty
type ContractorSpecialtyResponse struct { type ContractorSpecialtyResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
// Name is the stable English identifier (clients match on this).
Name string `json:"name"` Name string `json:"name"`
// DisplayName is the localized label for the request's Accept-Language.
DisplayName string `json:"display_name"`
Description string `json:"description"` Description string `json:"description"`
Icon string `json:"icon"` Icon string `json:"icon"`
DisplayOrder int `json:"display_order"` DisplayOrder int `json:"display_order"`
+3
View File
@@ -11,7 +11,10 @@ import (
// ResidenceTypeResponse represents a residence type in the API response // ResidenceTypeResponse represents a residence type in the API response
type ResidenceTypeResponse struct { type ResidenceTypeResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
// Name is the stable English identifier (clients match on this).
Name string `json:"name"` 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 // ResidenceUserResponse represents a user with access to a residence
+5
View File
@@ -14,7 +14,10 @@ import (
// TaskCategoryResponse represents a task category // TaskCategoryResponse represents a task category
type TaskCategoryResponse struct { type TaskCategoryResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
// Name is the stable English identifier (clients match on this).
Name string `json:"name"` Name string `json:"name"`
// DisplayName is the localized label for the request's Accept-Language.
DisplayName string `json:"display_name"`
Description string `json:"description"` Description string `json:"description"`
Icon string `json:"icon"` Icon string `json:"icon"`
Color string `json:"color"` Color string `json:"color"`
@@ -25,6 +28,7 @@ type TaskCategoryResponse struct {
type TaskPriorityResponse struct { type TaskPriorityResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name"`
Level int `json:"level"` Level int `json:"level"`
Color string `json:"color"` Color string `json:"color"`
DisplayOrder int `json:"display_order"` DisplayOrder int `json:"display_order"`
@@ -34,6 +38,7 @@ type TaskPriorityResponse struct {
type TaskFrequencyResponse struct { type TaskFrequencyResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name"`
Days *int `json:"days"` Days *int `json:"days"`
DisplayOrder int `json:"display_order"` DisplayOrder int `json:"display_order"`
} }
+19 -9
View File
@@ -2,21 +2,31 @@ package responses
// PresignUploadResponse is what /api/uploads/presign returns to the client. // PresignUploadResponse is what /api/uploads/presign returns to the client.
// //
// The client uses URL + Fields to build a multipart/form-data POST directly // Flow: the client makes one PUT request to URL with the raw object bytes
// to S3-compatible storage (B2). Once the upload completes, the client calls // as the body and Headers as the request headers (verbatim — the signature
// the relevant entity-creation endpoint (POST /api/task-completions/, POST // binds them). On success, the client passes ID back via upload_ids[] on
// /api/documents/) with `upload_ids: [Id]` to claim and attach the object. // 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 { type PresignUploadResponse struct {
// ID is the pending_uploads.id the client passes back via upload_ids[]. // ID is the pending_uploads.id the client passes back via upload_ids[].
ID uint `json:"id"` 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"` URL string `json:"upload_url"`
// Fields are the form fields (policy, signature, key, etc.) that must be // Method is always "PUT" — emitted explicitly so clients don't have to
// submitted with the multipart form. The file part must be named "file" // hardcode it. Reserved for the rare case we ever offer alternative
// and come last per S3 POST policy rules. // upload mechanisms.
Fields map[string]string `json:"fields"` 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 // Key is the object key chosen by the server. Echoed for client logging
// and debugging; the canonical reference is via ID. // and debugging; the canonical reference is via ID.
+73 -401
View File
@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"errors"
"net/http" "net/http"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
@@ -13,20 +12,22 @@ import (
"github.com/treytartt/honeydue-api/internal/middleware" "github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/services" "github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/validator" "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 { type AuthHandler struct {
authService *services.AuthService authService *services.AuthService
emailService *services.EmailService emailService *services.EmailService
cache *services.CacheService cache *services.CacheService
appleAuthService *services.AppleAuthService
googleAuthService *services.GoogleAuthService
storageService *services.StorageService storageService *services.StorageService
auditService *services.AuditService 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 { func NewAuthHandler(authService *services.AuthService, emailService *services.EmailService, cache *services.CacheService) *AuthHandler {
return &AuthHandler{ return &AuthHandler{
authService: authService, authService: authService,
@@ -35,128 +36,78 @@ func NewAuthHandler(authService *services.AuthService, emailService *services.Em
} }
} }
// SetAppleAuthService sets the Apple auth service (called after initialization) // SetStorageService sets the storage service for file deletion during account deletion.
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
func (h *AuthHandler) SetStorageService(storageService *services.StorageService) { func (h *AuthHandler) SetStorageService(storageService *services.StorageService) {
h.storageService = 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) { func (h *AuthHandler) SetAuditService(auditService *services.AuditService) {
h.auditService = auditService h.auditService = auditService
} }
// Login handles POST /api/auth/login/ // SetEnqueuer sets the async task enqueuer (used by the GDPR data-export endpoint).
func (h *AuthHandler) Login(c echo.Context) error { func (h *AuthHandler) SetEnqueuer(enqueuer worker.Enqueuer) {
var req requests.LoginRequest h.enqueuer = enqueuer
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))
}
response, err := h.authService.Login(c.Request().Context(), &req)
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.auditService != nil {
userID := response.User.ID
h.auditService.LogEvent(c, &userID, services.AuditEventLogin, nil)
}
return c.JSON(http.StatusOK, response)
} }
// Register handles POST /api/auth/register/ // 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 {
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 {
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.",
})
}
// 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/ — 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 { func (h *AuthHandler) Register(c echo.Context) error {
var req requests.RegisterRequest var req requests.RegisterRequest
if err := c.Bind(&req); err != nil { 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 { if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
} }
if err := h.authService.Register(c.Request().Context(), &req); err != nil {
response, confirmationCode, err := h.authService.Register(c.Request().Context(), &req)
if err != nil {
log.Debug().Err(err).Msg("Registration failed")
return err return err
} }
return c.JSON(http.StatusCreated, map[string]string{
if h.auditService != nil { "message": "Account created. Please verify your email.",
userID := response.User.ID
h.auditService.LogEvent(c, &userID, services.AuditEventRegister, map[string]interface{}{
"username": req.Username,
"email": req.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/ // CurrentUser handles GET /api/auth/me/
func (h *AuthHandler) CurrentUser(c echo.Context) error { func (h *AuthHandler) CurrentUser(c echo.Context) error {
noStore(c)
user, err := middleware.MustGetAuthUser(c) user, err := middleware.MustGetAuthUser(c)
if err != nil { if err != nil {
return err return err
@@ -168,6 +119,25 @@ func (h *AuthHandler) CurrentUser(c echo.Context) error {
return err 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) return c.JSON(http.StatusOK, response)
} }
@@ -195,296 +165,6 @@ func (h *AuthHandler) UpdateProfile(c echo.Context) error {
return c.JSON(http.StatusOK, response) 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/ // DeleteAccount handles DELETE /api/auth/account/
func (h *AuthHandler) DeleteAccount(c echo.Context) error { func (h *AuthHandler) DeleteAccount(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c) 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"}) return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Account deleted successfully"})
} }
+15 -106
View File
@@ -35,26 +35,25 @@ func setupDeleteAccountHandler(t *testing.T) (*AuthHandler, *echo.Echo, *gorm.DB
return handler, e, 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) 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 // Create profile for the user
profile := &models.UserProfile{UserID: user.ID, Verified: true} profile := &models.UserProfile{UserID: user.ID, Verified: true}
require.NoError(t, db.Create(profile).Error) require.NoError(t, db.Create(profile).Error)
// Create auth token
testutil.CreateTestToken(t, db, user.ID)
authGroup := e.Group("/api/auth") authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/account/", handler.DeleteAccount) authGroup.DELETE("/account/", handler.DeleteAccount)
t.Run("successful deletion with correct password", func(t *testing.T) { t.Run("successful deletion with DELETE confirmation", func(t *testing.T) {
password := "Password123"
req := map[string]interface{}{ req := map[string]interface{}{
"password": password, "confirmation": "DELETE",
} }
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token") 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 // Verify profile is deleted
db.Model(&models.UserProfile{}).Where("user_id = ?", user.ID).Count(&count) db.Model(&models.UserProfile{}).Where("user_id = ?", user.ID).Count(&count)
assert.Equal(t, int64(0), 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) handler, e, db := setupDeleteAccountHandler(t)
user := testutil.CreateTestUser(t, db, "wrongpw", "wrongpw@test.com", "Password123") user := testutil.CreateTestUser(t, db, "nopw", "nopw@test.com", "ignored")
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)
authGroup := e.Group("/api/auth") authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user)) 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) { t.Run("wrong confirmation returns 400", func(t *testing.T) {
wrongConfirmation := "delete"
req := map[string]interface{}{ req := map[string]interface{}{
"confirmation": wrongConfirmation, "confirmation": "delete", // lowercase — must be exact "DELETE"
} }
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token") 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) { func TestAuthHandler_DeleteAccount_Unauthenticated(t *testing.T) {
handler, e, _ := setupDeleteAccountHandler(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) { t.Run("unauthenticated request returns 401", func(t *testing.T) {
req := map[string]interface{}{ req := map[string]interface{}{
"password": "Password123", "confirmation": "DELETE",
} }
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "") w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "")
+33 -325
View File
@@ -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 package handlers
import ( import (
@@ -34,204 +38,32 @@ func setupAuthHandler(t *testing.T) (*AuthHandler, *echo.Echo, *repositories.Use
return handler, e, userRepo 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) { func TestAuthHandler_CurrentUser(t *testing.T) {
handler, e, userRepo := setupAuthHandler(t) handler, e, _ := setupAuthHandler(t)
db := testutil.SetupTestDB(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.FirstName = "Test"
user.LastName = "User" 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 := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.Use(testutil.MockAuthMiddleware(user2))
authGroup.GET("/me/", handler.CurrentUser) authGroup.GET("/me/", handler2.CurrentUser)
_ = handler // avoid unused
t.Run("get current user", func(t *testing.T) { t.Run("get current user", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/auth/me/", nil, "test-token") 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) err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "metest", response["username"]) assert.Equal(t, "metest2", response["username"])
assert.Equal(t, "me@test.com", response["email"]) assert.Equal(t, "me2@test.com", response["email"])
}) })
} }
func TestAuthHandler_UpdateProfile(t *testing.T) { func TestAuthHandler_UpdateProfile(t *testing.T) {
handler, e, userRepo := setupAuthHandler(t)
db := testutil.SetupTestDB(t) db := testutil.SetupTestDB(t)
user := testutil.CreateTestUser(t, db, "updatetest", "update@test.com", "Password123") userRepo := repositories.NewUserRepository(db)
userRepo.Update(user) 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 := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/profile/", handler.UpdateProfile) 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" firstName := "Updated"
lastName := "Name" lastName := "Name"
req := requests.UpdateProfileRequest{ req := requests.UpdateProfileRequest{
@@ -278,130 +113,3 @@ func TestAuthHandler_UpdateProfile(t *testing.T) {
assert.Equal(t, "Name", response["last_name"]) 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"])
})
}
-226
View File
@@ -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 // Residence Handler - Additional Error Paths
// ============================================================================= // =============================================================================
+20 -3
View File
@@ -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 // ServeDocument serves a document file with access control
// GET /api/media/document/:id // GET /api/media/document/:id
func (h *MediaHandler) ServeDocument(c echo.Context) error { 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 // Set caching and disposition headers
c.Response().Header().Set("Cache-Control", "private, max-age=3600") c.Response().Header().Set("Cache-Control", "private, max-age=3600")
if doc.FileName != "" { 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) 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("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) 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("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) return c.Blob(http.StatusOK, mimeType, data)
} }
+19 -5
View File
@@ -21,6 +21,9 @@ type SeededDataResponse struct {
TaskFrequencies interface{} `json:"task_frequencies"` TaskFrequencies interface{} `json:"task_frequencies"`
ContractorSpecialties interface{} `json:"contractor_specialties"` ContractorSpecialties interface{} `json:"contractor_specialties"`
TaskTemplates responses.TaskTemplatesGroupedResponse `json:"task_templates"` 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 // StaticDataHandler handles static/lookup data endpoints
@@ -54,13 +57,18 @@ func NewStaticDataHandler(
func (h *StaticDataHandler) GetStaticData(c echo.Context) error { func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
ctx := c.Request().Context() 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 // Check If-None-Match header for conditional request
// Strip W/ prefix if present (added by reverse proxy, but we store without it) // 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/") clientETag := strings.TrimPrefix(c.Request().Header.Get("If-None-Match"), "W/")
// Try to get cached ETag first (fast path for 304 responses) // Try to get cached ETag first (fast path for 304 responses)
if h.cache != nil && clientETag != "" { if h.cache != nil && clientETag != "" {
cachedETag, err := h.cache.GetSeededDataETag(ctx) cachedETag, err := h.cache.GetSeededDataETag(ctx, locale)
if err == nil && cachedETag == clientETag { if err == nil && cachedETag == clientETag {
// Client has the latest data, return 304 Not Modified // Client has the latest data, return 304 Not Modified
return c.NoContent(http.StatusNotModified) return c.NoContent(http.StatusNotModified)
@@ -70,10 +78,10 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
// Try to get cached seeded data // Try to get cached seeded data
if h.cache != nil { if h.cache != nil {
var cachedData SeededDataResponse var cachedData SeededDataResponse
err := h.cache.GetCachedSeededData(ctx, &cachedData) err := h.cache.GetCachedSeededData(ctx, locale, &cachedData)
if err == nil { if err == nil {
// Cache hit - get the ETag and return data // Cache hit - get the ETag and return data
etag, etagErr := h.cache.GetSeededDataETag(ctx) etag, etagErr := h.cache.GetSeededDataETag(ctx, locale)
if etagErr == nil { if etagErr == nil {
c.Response().Header().Set("ETag", etag) c.Response().Header().Set("ETag", etag)
c.Response().Header().Set("Cache-Control", "private, max-age=3600") c.Response().Header().Set("Cache-Control", "private, max-age=3600")
@@ -116,6 +124,9 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
return err 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 // Build response
seededData := SeededDataResponse{ seededData := SeededDataResponse{
ResidenceTypes: residenceTypes, ResidenceTypes: residenceTypes,
@@ -124,11 +135,14 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
TaskFrequencies: taskFrequencies, TaskFrequencies: taskFrequencies,
ContractorSpecialties: contractorSpecialties, ContractorSpecialties: contractorSpecialties,
TaskTemplates: taskTemplates, 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 { if h.cache != nil {
etag, cacheErr := h.cache.CacheSeededData(ctx, seededData) etag, cacheErr := h.cache.CacheSeededData(ctx, locale, seededData)
if cacheErr != nil { if cacheErr != nil {
log.Warn().Err(cacheErr).Msg("Failed to cache seeded data") log.Warn().Err(cacheErr).Msg("Failed to cache seeded data")
} else { } else {
@@ -8,6 +8,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"errors"
"fmt" "fmt"
"io" "io"
"math/big" "math/big"
@@ -20,6 +21,7 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/config" "github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/models"
@@ -165,9 +167,13 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
if notification.NotificationUUID != "" { if notification.NotificationUUID != "" {
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("apple", notification.NotificationUUID) alreadyProcessed, err := h.webhookEventRepo.HasProcessed("apple", notification.NotificationUUID)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Apple Webhook: Failed to check dedup") // Audit H6: fail closed. A dedup-check failure must not let a
// Continue processing on dedup check failure (fail-open) // possibly-duplicate event through (duplicate refunds/grants).
} else if alreadyProcessed { // 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") log.Info().Str("uuid", notification.NotificationUUID).Msg("Apple Webhook: Duplicate event, skipping")
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"}) 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) { func (h *SubscriptionWebhookHandler) findUserByAppleTransaction(originalTransactionID string) (*models.User, error) {
// Look up user subscription by stored receipt data // Audit C13: exact match on the indexed apple_original_transaction_id
subscription, err := h.subscriptionRepo.FindByAppleReceiptContains(originalTransactionID) // 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 { if err != nil {
return nil, err return nil, err
} }
}
user, err := h.userRepo.FindByID(subscription.UserID) user, err := h.userRepo.FindByID(subscription.UserID)
if err != nil { if err != nil {
@@ -566,9 +586,12 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
if messageID != "" { if messageID != "" {
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("google", messageID) alreadyProcessed, err := h.webhookEventRepo.HasProcessed("google", messageID)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Google Webhook: Failed to check dedup") // Audit H6: fail closed — see the Apple handler. Return 500 so
// Continue processing on dedup check failure (fail-open) // Google Pub/Sub redelivers once the DB is healthy.
} else if alreadyProcessed { 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") log.Info().Str("message_id", messageID).Msg("Google Webhook: Duplicate event, skipping")
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"}) return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
} }
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/treytartt/honeydue-api/internal/apperrors" "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/middleware"
"github.com/treytartt/honeydue-api/internal/services" "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") 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 { if err != nil {
return err return err
} }
+93 -37
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "Google-Anmeldung ist nicht konfiguriert", "error.google_signin_not_configured": "Google-Anmeldung ist nicht konfiguriert",
"error.google_signin_failed": "Google-Anmeldung fehlgeschlagen", "error.google_signin_failed": "Google-Anmeldung fehlgeschlagen",
"error.invalid_google_token": "Ungultiger Google-Identitats-Token", "error.invalid_google_token": "Ungultiger Google-Identitats-Token",
"error.invalid_task_id": "Ungultige Aufgaben-ID", "error.invalid_task_id": "Ungultige Aufgaben-ID",
"error.invalid_residence_id": "Ungultige Immobilien-ID", "error.invalid_residence_id": "Ungultige Immobilien-ID",
"error.invalid_contractor_id": "Ungultige Dienstleister-ID", "error.invalid_contractor_id": "Ungultige Dienstleister-ID",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "Ungultige Benutzer-ID", "error.invalid_user_id": "Ungultige Benutzer-ID",
"error.invalid_notification_id": "Ungultige Benachrichtigungs-ID", "error.invalid_notification_id": "Ungultige Benachrichtigungs-ID",
"error.invalid_device_id": "Ungultige Gerate-ID", "error.invalid_device_id": "Ungultige Gerate-ID",
"error.task_not_found": "Aufgabe nicht gefunden", "error.task_not_found": "Aufgabe nicht gefunden",
"error.residence_not_found": "Immobilie nicht gefunden", "error.residence_not_found": "Immobilie nicht gefunden",
"error.contractor_not_found": "Dienstleister nicht gefunden", "error.contractor_not_found": "Dienstleister nicht gefunden",
@@ -43,7 +41,6 @@
"error.user_not_found": "Benutzer nicht gefunden", "error.user_not_found": "Benutzer nicht gefunden",
"error.share_code_invalid": "Ungultiger Freigabecode", "error.share_code_invalid": "Ungultiger Freigabecode",
"error.share_code_expired": "Der Freigabecode ist abgelaufen", "error.share_code_expired": "Der Freigabecode ist abgelaufen",
"error.task_access_denied": "Sie haben keinen Zugriff auf diese Aufgabe", "error.task_access_denied": "Sie haben keinen Zugriff auf diese Aufgabe",
"error.residence_access_denied": "Sie haben keinen Zugriff auf diese Immobilie", "error.residence_access_denied": "Sie haben keinen Zugriff auf diese Immobilie",
"error.contractor_access_denied": "Sie haben keinen Zugriff auf diesen Dienstleister", "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.cannot_remove_owner": "Der Eigentumer kann nicht entfernt werden",
"error.user_already_member": "Der Benutzer ist bereits Mitglied dieser Immobilie", "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.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_cancelled": "Die Aufgabe ist bereits storniert",
"error.task_already_archived": "Die Aufgabe ist bereits archiviert", "error.task_already_archived": "Die Aufgabe ist bereits archiviert",
"error.failed_to_parse_form": "Formular konnte nicht analysiert werden", "error.failed_to_parse_form": "Formular konnte nicht analysiert werden",
"error.task_id_required": "task_id ist erforderlich", "error.task_id_required": "task_id ist erforderlich",
"error.invalid_task_id_value": "Ungultige task_id", "error.invalid_task_id_value": "Ungultige task_id",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "Ungultige residence_id", "error.invalid_residence_id_value": "Ungultige residence_id",
"error.title_required": "Titel ist erforderlich", "error.title_required": "Titel ist erforderlich",
"error.failed_to_upload_file": "Datei konnte nicht hochgeladen werden", "error.failed_to_upload_file": "Datei konnte nicht hochgeladen werden",
"message.logged_out": "Erfolgreich abgemeldet", "message.logged_out": "Erfolgreich abgemeldet",
"message.email_verified": "E-Mail erfolgreich verifiziert", "message.email_verified": "E-Mail erfolgreich verifiziert",
"message.verification_email_sent": "Verifizierungs-E-Mail gesendet", "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.password_reset_email_sent": "Wenn ein Konto mit dieser E-Mail existiert, wurde ein Zurucksetzungscode gesendet.",
"message.reset_code_verified": "Code erfolgreich verifiziert", "message.reset_code_verified": "Code erfolgreich verifiziert",
"message.password_reset_success": "Passwort erfolgreich zuruckgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an.", "message.password_reset_success": "Passwort erfolgreich zuruckgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an.",
"message.task_deleted": "Aufgabe erfolgreich geloscht", "message.task_deleted": "Aufgabe erfolgreich geloscht",
"message.task_in_progress": "Aufgabe als in Bearbeitung markiert", "message.task_in_progress": "Aufgabe als in Bearbeitung markiert",
"message.task_cancelled": "Aufgabe storniert", "message.task_cancelled": "Aufgabe storniert",
@@ -79,46 +72,35 @@
"message.task_archived": "Aufgabe archiviert", "message.task_archived": "Aufgabe archiviert",
"message.task_unarchived": "Aufgabe dearchiviert", "message.task_unarchived": "Aufgabe dearchiviert",
"message.completion_deleted": "Abschluss erfolgreich geloscht", "message.completion_deleted": "Abschluss erfolgreich geloscht",
"message.residence_deleted": "Immobilie erfolgreich geloscht", "message.residence_deleted": "Immobilie erfolgreich geloscht",
"message.user_removed": "Benutzer von der Immobilie entfernt", "message.user_removed": "Benutzer von der Immobilie entfernt",
"message.tasks_report_generated": "Aufgabenbericht erfolgreich erstellt", "message.tasks_report_generated": "Aufgabenbericht erfolgreich erstellt",
"message.tasks_report_sent": "Aufgabenbericht erstellt und an {{.Email}} gesendet", "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.tasks_report_email_failed": "Aufgabenbericht erstellt, aber E-Mail konnte nicht gesendet werden",
"message.contractor_deleted": "Dienstleister erfolgreich geloscht", "message.contractor_deleted": "Dienstleister erfolgreich geloscht",
"message.document_deleted": "Dokument erfolgreich geloscht", "message.document_deleted": "Dokument erfolgreich geloscht",
"message.document_activated": "Dokument aktiviert", "message.document_activated": "Dokument aktiviert",
"message.document_deactivated": "Dokument deaktiviert", "message.document_deactivated": "Dokument deaktiviert",
"message.notification_marked_read": "Benachrichtigung als gelesen markiert", "message.notification_marked_read": "Benachrichtigung als gelesen markiert",
"message.all_notifications_marked_read": "Alle Benachrichtigungen als gelesen markiert", "message.all_notifications_marked_read": "Alle Benachrichtigungen als gelesen markiert",
"message.device_removed": "Gerät entfernt", "message.device_removed": "Gerät entfernt",
"message.subscription_upgraded": "Abonnement erfolgreich aktualisiert", "message.subscription_upgraded": "Abonnement erfolgreich aktualisiert",
"message.subscription_cancelled": "Abonnement gekündigt. Sie behalten die Pro-Vorteile bis zum Ende Ihres Abrechnungszeitraums.", "message.subscription_cancelled": "Abonnement gekündigt. Sie behalten die Pro-Vorteile bis zum Ende Ihres Abrechnungszeitraums.",
"message.subscription_restored": "Abonnement erfolgreich wiederhergestellt", "message.subscription_restored": "Abonnement erfolgreich wiederhergestellt",
"message.file_deleted": "Datei erfolgreich gelöscht", "message.file_deleted": "Datei erfolgreich gelöscht",
"message.static_data_refreshed": "Statische Daten aktualisiert", "message.static_data_refreshed": "Statische Daten aktualisiert",
"error.notification_not_found": "Benachrichtigung nicht gefunden", "error.notification_not_found": "Benachrichtigung nicht gefunden",
"error.invalid_platform": "Ungültige Plattform", "error.invalid_platform": "Ungültige Plattform",
"error.upgrade_trigger_not_found": "Upgrade-Trigger nicht gefunden", "error.upgrade_trigger_not_found": "Upgrade-Trigger nicht gefunden",
"error.receipt_data_required": "receipt_data ist für iOS erforderlich", "error.receipt_data_required": "receipt_data ist für iOS erforderlich",
"error.purchase_token_required": "purchase_token ist für Android erforderlich", "error.purchase_token_required": "purchase_token ist für Android erforderlich",
"error.no_file_provided": "Keine Datei bereitgestellt", "error.no_file_provided": "Keine Datei bereitgestellt",
"error.failed_to_fetch_residence_types": "Fehler beim Abrufen der Immobilientypen", "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_categories": "Fehler beim Abrufen der Aufgabenkategorien",
"error.failed_to_fetch_task_priorities": "Fehler beim Abrufen der Aufgabenprioritäten", "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_frequencies": "Fehler beim Abrufen der Aufgabenfrequenzen",
"error.failed_to_fetch_task_statuses": "Fehler beim Abrufen der Aufgabenstatus", "error.failed_to_fetch_task_statuses": "Fehler beim Abrufen der Aufgabenstatus",
"error.failed_to_fetch_contractor_specialties": "Fehler beim Abrufen der Dienstleister-Spezialitäten", "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.title": "Aufgabe Bald Fallig",
"push.task_due_soon.body": "{{.TaskTitle}} ist fallig am {{.DueDate}}", "push.task_due_soon.body": "{{.TaskTitle}} ist fallig am {{.DueDate}}",
"push.task_overdue.title": "Uberfällige Aufgabe", "push.task_overdue.title": "Uberfällige Aufgabe",
@@ -129,63 +111,137 @@
"push.task_assigned.body": "Ihnen wurde {{.TaskTitle}} zugewiesen", "push.task_assigned.body": "Ihnen wurde {{.TaskTitle}} zugewiesen",
"push.residence_shared.title": "Immobilie Geteilt", "push.residence_shared.title": "Immobilie Geteilt",
"push.residence_shared.body": "{{.UserName}} hat {{.ResidenceName}} mit Ihnen geteilt", "push.residence_shared.body": "{{.UserName}} hat {{.ResidenceName}} mit Ihnen geteilt",
"email.welcome.subject": "Willkommen bei honeyDue!", "email.welcome.subject": "Willkommen bei honeyDue!",
"email.verification.subject": "Bestatigen Sie Ihre E-Mail", "email.verification.subject": "Bestatigen Sie Ihre E-Mail",
"email.password_reset.subject": "Passwort-Zurucksetzungscode", "email.password_reset.subject": "Passwort-Zurucksetzungscode",
"email.tasks_report.subject": "Aufgabenbericht fur {{.ResidenceName}}", "email.tasks_report.subject": "Aufgabenbericht fur {{.ResidenceName}}",
"lookup.residence_type.house": "Haus", "lookup.residence_type.house": "Haus",
"lookup.residence_type.apartment": "Wohnung", "lookup.residence_type.apartment": "Wohnung",
"lookup.residence_type.condo": "Eigentumswohnung", "lookup.residence_type.condo": "Eigentumswohnung",
"lookup.residence_type.townhouse": "Reihenhaus", "lookup.residence_type.townhouse": "Reihenhaus",
"lookup.residence_type.mobile_home": "Mobilheim", "lookup.residence_type.mobile_home": "Mobilheim",
"lookup.residence_type.other": "Sonstiges", "lookup.residence_type.other": "Sonstiges",
"lookup.task_category.plumbing": "Sanitär", "lookup.task_category.plumbing": "Sanitär",
"lookup.task_category.electrical": "Elektrik", "lookup.task_category.electrical": "Elektrik",
"lookup.task_category.hvac": "Heizung/Klimaanlage", "lookup.task_category.hvac": "HLK",
"lookup.task_category.appliances": "Gerate", "lookup.task_category.appliances": "Haushaltsgeräte",
"lookup.task_category.exterior": "Aussenbereich", "lookup.task_category.exterior": "Außenbereich",
"lookup.task_category.interior": "Innenbereich", "lookup.task_category.interior": "Innenbereich",
"lookup.task_category.landscaping": "Gartenpflege", "lookup.task_category.landscaping": "Gartenpflege",
"lookup.task_category.safety": "Sicherheit", "lookup.task_category.safety": "Sicherheit",
"lookup.task_category.cleaning": "Reinigung", "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.seasonal": "Saisonal",
"lookup.task_category.other": "Sonstiges", "lookup.task_category.other": "Sonstiges",
"lookup.task_priority.low": "Niedrig", "lookup.task_priority.low": "Niedrig",
"lookup.task_priority.medium": "Mittel", "lookup.task_priority.medium": "Mittel",
"lookup.task_priority.high": "Hoch", "lookup.task_priority.high": "Hoch",
"lookup.task_priority.urgent": "Dringend", "lookup.task_priority.urgent": "Dringend",
"lookup.task_status.pending": "Ausstehend", "lookup.task_status.pending": "Ausstehend",
"lookup.task_status.in_progress": "In Bearbeitung", "lookup.task_status.in_progress": "In Bearbeitung",
"lookup.task_status.completed": "Abgeschlossen", "lookup.task_status.completed": "Abgeschlossen",
"lookup.task_status.cancelled": "Storniert", "lookup.task_status.cancelled": "Storniert",
"lookup.task_status.archived": "Archiviert", "lookup.task_status.archived": "Archiviert",
"lookup.task_frequency.once": "Einmalig", "lookup.task_frequency.once": "Einmalig",
"lookup.task_frequency.daily": "Taglich", "lookup.task_frequency.daily": "Täglich",
"lookup.task_frequency.weekly": "Wochentlich", "lookup.task_frequency.weekly": "Wöchentlich",
"lookup.task_frequency.biweekly": "Alle 2 Wochen", "lookup.task_frequency.biweekly": "Alle 2 Wochen",
"lookup.task_frequency.monthly": "Monatlich", "lookup.task_frequency.monthly": "Monatlich",
"lookup.task_frequency.quarterly": "Vierteljahrlich", "lookup.task_frequency.quarterly": "Vierteljährlich",
"lookup.task_frequency.semiannually": "Halbjahrlich", "lookup.task_frequency.semiannually": "Halbjahrlich",
"lookup.task_frequency.annually": "Jahrlich", "lookup.task_frequency.annually": "Jährlich",
"lookup.contractor_specialty.plumber": "Klempner", "lookup.contractor_specialty.plumber": "Klempner",
"lookup.contractor_specialty.electrician": "Elektriker", "lookup.contractor_specialty.electrician": "Elektriker",
"lookup.contractor_specialty.hvac_technician": "HLK-Techniker", "lookup.contractor_specialty.hvac_technician": "HLK-Techniker",
"lookup.contractor_specialty.handyman": "Handwerker", "lookup.contractor_specialty.handyman": "Handwerker",
"lookup.contractor_specialty.landscaper": "Landschaftsgartner", "lookup.contractor_specialty.landscaper": "Landschaftsgärtner",
"lookup.contractor_specialty.roofer": "Dachdecker", "lookup.contractor_specialty.roofer": "Dachdecker",
"lookup.contractor_specialty.painter": "Maler", "lookup.contractor_specialty.painter": "Maler",
"lookup.contractor_specialty.carpenter": "Schreiner", "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.cleaning": "Reinigung",
"lookup.contractor_specialty.pool_service": "Pool-Service", "lookup.contractor_specialty.pool_service": "Poolservice",
"lookup.contractor_specialty.general_contractor": "Generalunternehmer", "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"
} }
+82 -26
View File
@@ -28,7 +28,6 @@
"error.google_signin_not_configured": "Google Sign In is not configured", "error.google_signin_not_configured": "Google Sign In is not configured",
"error.google_signin_failed": "Google Sign In failed", "error.google_signin_failed": "Google Sign In failed",
"error.invalid_google_token": "Invalid Google identity token", "error.invalid_google_token": "Invalid Google identity token",
"error.invalid_task_id": "Invalid task ID", "error.invalid_task_id": "Invalid task ID",
"error.invalid_residence_id": "Invalid residence ID", "error.invalid_residence_id": "Invalid residence ID",
"error.invalid_contractor_id": "Invalid contractor ID", "error.invalid_contractor_id": "Invalid contractor ID",
@@ -37,7 +36,6 @@
"error.invalid_user_id": "Invalid user ID", "error.invalid_user_id": "Invalid user ID",
"error.invalid_notification_id": "Invalid notification ID", "error.invalid_notification_id": "Invalid notification ID",
"error.invalid_device_id": "Invalid device ID", "error.invalid_device_id": "Invalid device ID",
"error.task_not_found": "Task not found", "error.task_not_found": "Task not found",
"error.residence_not_found": "Residence not found", "error.residence_not_found": "Residence not found",
"error.contractor_not_found": "Contractor not found", "error.contractor_not_found": "Contractor not found",
@@ -46,7 +44,6 @@
"error.user_not_found": "User not found", "error.user_not_found": "User not found",
"error.share_code_invalid": "Invalid share code", "error.share_code_invalid": "Invalid share code",
"error.share_code_expired": "Share code has expired", "error.share_code_expired": "Share code has expired",
"error.task_access_denied": "You don't have access to this task", "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.residence_access_denied": "You don't have access to this property",
"error.contractor_access_denied": "You don't have access to this contractor", "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.cannot_remove_owner": "Cannot remove the property owner",
"error.user_already_member": "User is already a member of this property", "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.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_cancelled": "Task is already cancelled",
"error.task_already_archived": "Task is already archived", "error.task_already_archived": "Task is already archived",
"error.failed_to_parse_form": "Failed to parse multipart form", "error.failed_to_parse_form": "Failed to parse multipart form",
"error.task_id_required": "task_id is required", "error.task_id_required": "task_id is required",
"error.invalid_task_id_value": "Invalid task_id", "error.invalid_task_id_value": "Invalid task_id",
@@ -67,14 +62,12 @@
"error.invalid_residence_id_value": "Invalid residence_id", "error.invalid_residence_id_value": "Invalid residence_id",
"error.title_required": "title is required", "error.title_required": "title is required",
"error.failed_to_upload_file": "Failed to upload file", "error.failed_to_upload_file": "Failed to upload file",
"message.logged_out": "Logged out successfully", "message.logged_out": "Logged out successfully",
"message.email_verified": "Email verified successfully", "message.email_verified": "Email verified successfully",
"message.verification_email_sent": "Verification email sent", "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.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.reset_code_verified": "Code verified successfully",
"message.password_reset_success": "Password reset successfully. Please log in with your new password.", "message.password_reset_success": "Password reset successfully. Please log in with your new password.",
"message.task_deleted": "Task deleted successfully", "message.task_deleted": "Task deleted successfully",
"message.task_in_progress": "Task marked as in progress", "message.task_in_progress": "Task marked as in progress",
"message.task_cancelled": "Task cancelled", "message.task_cancelled": "Task cancelled",
@@ -82,44 +75,34 @@
"message.task_archived": "Task archived", "message.task_archived": "Task archived",
"message.task_unarchived": "Task unarchived", "message.task_unarchived": "Task unarchived",
"message.completion_deleted": "Completion deleted successfully", "message.completion_deleted": "Completion deleted successfully",
"message.residence_deleted": "Residence deleted successfully", "message.residence_deleted": "Residence deleted successfully",
"message.user_removed": "User removed from residence", "message.user_removed": "User removed from residence",
"message.tasks_report_generated": "Tasks report generated successfully", "message.tasks_report_generated": "Tasks report generated successfully",
"message.tasks_report_sent": "Tasks report generated and sent to {{.Email}}", "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.tasks_report_email_failed": "Tasks report generated but email could not be sent",
"message.contractor_deleted": "Contractor deleted successfully", "message.contractor_deleted": "Contractor deleted successfully",
"message.document_deleted": "Document deleted successfully", "message.document_deleted": "Document deleted successfully",
"message.document_activated": "Document activated", "message.document_activated": "Document activated",
"message.document_deactivated": "Document deactivated", "message.document_deactivated": "Document deactivated",
"message.notification_marked_read": "Notification marked as read", "message.notification_marked_read": "Notification marked as read",
"message.all_notifications_marked_read": "All notifications marked as read", "message.all_notifications_marked_read": "All notifications marked as read",
"message.device_removed": "Device removed", "message.device_removed": "Device removed",
"message.subscription_upgraded": "Subscription upgraded successfully", "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_cancelled": "Subscription cancelled. You will retain Pro benefits until the end of your billing period.",
"message.subscription_restored": "Subscription restored successfully", "message.subscription_restored": "Subscription restored successfully",
"message.file_deleted": "File deleted successfully", "message.file_deleted": "File deleted successfully",
"message.static_data_refreshed": "Static data refreshed", "message.static_data_refreshed": "Static data refreshed",
"error.notification_not_found": "Notification not found", "error.notification_not_found": "Notification not found",
"error.invalid_platform": "Invalid platform", "error.invalid_platform": "Invalid platform",
"error.upgrade_trigger_not_found": "Upgrade trigger not found", "error.upgrade_trigger_not_found": "Upgrade trigger not found",
"error.receipt_data_required": "receipt_data is required for iOS", "error.receipt_data_required": "receipt_data is required for iOS",
"error.purchase_token_required": "purchase_token is required for Android", "error.purchase_token_required": "purchase_token is required for Android",
"error.no_file_provided": "No file provided", "error.no_file_provided": "No file provided",
"error.url_required": "File URL is required", "error.url_required": "File URL is required",
"error.file_access_denied": "You don't have access to this file", "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.days_out_of_range": "Days parameter must be between 1 and 3650",
"error.platform_required": "Platform is required (ios or android)", "error.platform_required": "Platform is required (ios or android)",
"error.registration_id_required": "Registration ID is required", "error.registration_id_required": "Registration ID is required",
"error.failed_to_fetch_residence_types": "Failed to fetch residence types", "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_categories": "Failed to fetch task categories",
"error.failed_to_fetch_task_priorities": "Failed to fetch task priorities", "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_fetch_templates": "Failed to fetch task templates",
"error.failed_to_search_templates": "Failed to search task templates", "error.failed_to_search_templates": "Failed to search task templates",
"error.template_not_found": "Task template not found", "error.template_not_found": "Task template not found",
"push.task_due_soon.title": "Task Due Soon", "push.task_due_soon.title": "Task Due Soon",
"push.task_due_soon.body": "{{.TaskTitle}} is due {{.DueDate}}", "push.task_due_soon.body": "{{.TaskTitle}} is due {{.DueDate}}",
"push.task_overdue.title": "Overdue Task", "push.task_overdue.title": "Overdue Task",
@@ -140,19 +122,16 @@
"push.task_assigned.body": "You have been assigned to {{.TaskTitle}}", "push.task_assigned.body": "You have been assigned to {{.TaskTitle}}",
"push.residence_shared.title": "Property Shared", "push.residence_shared.title": "Property Shared",
"push.residence_shared.body": "{{.UserName}} shared {{.ResidenceName}} with you", "push.residence_shared.body": "{{.UserName}} shared {{.ResidenceName}} with you",
"email.welcome.subject": "Welcome to honeyDue!", "email.welcome.subject": "Welcome to honeyDue!",
"email.verification.subject": "Verify Your Email", "email.verification.subject": "Verify Your Email",
"email.password_reset.subject": "Password Reset Code", "email.password_reset.subject": "Password Reset Code",
"email.tasks_report.subject": "Tasks Report for {{.ResidenceName}}", "email.tasks_report.subject": "Tasks Report for {{.ResidenceName}}",
"lookup.residence_type.house": "House", "lookup.residence_type.house": "House",
"lookup.residence_type.apartment": "Apartment", "lookup.residence_type.apartment": "Apartment",
"lookup.residence_type.condo": "Condo", "lookup.residence_type.condo": "Condo",
"lookup.residence_type.townhouse": "Townhouse", "lookup.residence_type.townhouse": "Townhouse",
"lookup.residence_type.mobile_home": "Mobile Home", "lookup.residence_type.mobile_home": "Mobile Home",
"lookup.residence_type.other": "Other", "lookup.residence_type.other": "Other",
"lookup.task_category.plumbing": "Plumbing", "lookup.task_category.plumbing": "Plumbing",
"lookup.task_category.electrical": "Electrical", "lookup.task_category.electrical": "Electrical",
"lookup.task_category.hvac": "HVAC", "lookup.task_category.hvac": "HVAC",
@@ -165,18 +144,15 @@
"lookup.task_category.pest_control": "Pest Control", "lookup.task_category.pest_control": "Pest Control",
"lookup.task_category.seasonal": "Seasonal", "lookup.task_category.seasonal": "Seasonal",
"lookup.task_category.other": "Other", "lookup.task_category.other": "Other",
"lookup.task_priority.low": "Low", "lookup.task_priority.low": "Low",
"lookup.task_priority.medium": "Medium", "lookup.task_priority.medium": "Medium",
"lookup.task_priority.high": "High", "lookup.task_priority.high": "High",
"lookup.task_priority.urgent": "Urgent", "lookup.task_priority.urgent": "Urgent",
"lookup.task_status.pending": "Pending", "lookup.task_status.pending": "Pending",
"lookup.task_status.in_progress": "In Progress", "lookup.task_status.in_progress": "In Progress",
"lookup.task_status.completed": "Completed", "lookup.task_status.completed": "Completed",
"lookup.task_status.cancelled": "Cancelled", "lookup.task_status.cancelled": "Cancelled",
"lookup.task_status.archived": "Archived", "lookup.task_status.archived": "Archived",
"lookup.task_frequency.once": "Once", "lookup.task_frequency.once": "Once",
"lookup.task_frequency.daily": "Daily", "lookup.task_frequency.daily": "Daily",
"lookup.task_frequency.weekly": "Weekly", "lookup.task_frequency.weekly": "Weekly",
@@ -185,7 +161,6 @@
"lookup.task_frequency.quarterly": "Quarterly", "lookup.task_frequency.quarterly": "Quarterly",
"lookup.task_frequency.semiannually": "Every 6 Months", "lookup.task_frequency.semiannually": "Every 6 Months",
"lookup.task_frequency.annually": "Annually", "lookup.task_frequency.annually": "Annually",
"lookup.contractor_specialty.plumber": "Plumber", "lookup.contractor_specialty.plumber": "Plumber",
"lookup.contractor_specialty.electrician": "Electrician", "lookup.contractor_specialty.electrician": "Electrician",
"lookup.contractor_specialty.hvac_technician": "HVAC Technician", "lookup.contractor_specialty.hvac_technician": "HVAC Technician",
@@ -198,5 +173,86 @@
"lookup.contractor_specialty.cleaning": "Cleaning", "lookup.contractor_specialty.cleaning": "Cleaning",
"lookup.contractor_specialty.pool_service": "Pool Service", "lookup.contractor_specialty.pool_service": "Pool Service",
"lookup.contractor_specialty.general_contractor": "General Contractor", "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"
} }
+95 -39
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "El inicio de sesion con Google no esta configurado", "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.google_signin_failed": "Error en el inicio de sesion con Google",
"error.invalid_google_token": "Token de identidad de Google no valido", "error.invalid_google_token": "Token de identidad de Google no valido",
"error.invalid_task_id": "ID de tarea no valido", "error.invalid_task_id": "ID de tarea no valido",
"error.invalid_residence_id": "ID de propiedad no valido", "error.invalid_residence_id": "ID de propiedad no valido",
"error.invalid_contractor_id": "ID de contratista 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_user_id": "ID de usuario no valido",
"error.invalid_notification_id": "ID de notificacion no valido", "error.invalid_notification_id": "ID de notificacion no valido",
"error.invalid_device_id": "ID de dispositivo no valido", "error.invalid_device_id": "ID de dispositivo no valido",
"error.task_not_found": "Tarea no encontrada", "error.task_not_found": "Tarea no encontrada",
"error.residence_not_found": "Propiedad no encontrada", "error.residence_not_found": "Propiedad no encontrada",
"error.contractor_not_found": "Contratista no encontrado", "error.contractor_not_found": "Contratista no encontrado",
@@ -43,7 +41,6 @@
"error.user_not_found": "Usuario no encontrado", "error.user_not_found": "Usuario no encontrado",
"error.share_code_invalid": "Codigo de compartir no valido", "error.share_code_invalid": "Codigo de compartir no valido",
"error.share_code_expired": "El codigo de compartir ha expirado", "error.share_code_expired": "El codigo de compartir ha expirado",
"error.task_access_denied": "No tienes acceso a esta tarea", "error.task_access_denied": "No tienes acceso a esta tarea",
"error.residence_access_denied": "No tienes acceso a esta propiedad", "error.residence_access_denied": "No tienes acceso a esta propiedad",
"error.contractor_access_denied": "No tienes acceso a este contratista", "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.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.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.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_cancelled": "La tarea ya esta cancelada",
"error.task_already_archived": "La tarea ya esta archivada", "error.task_already_archived": "La tarea ya esta archivada",
"error.failed_to_parse_form": "Error al analizar el formulario", "error.failed_to_parse_form": "Error al analizar el formulario",
"error.task_id_required": "Se requiere task_id", "error.task_id_required": "Se requiere task_id",
"error.invalid_task_id_value": "task_id no valido", "error.invalid_task_id_value": "task_id no valido",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id no valido", "error.invalid_residence_id_value": "residence_id no valido",
"error.title_required": "Se requiere el titulo", "error.title_required": "Se requiere el titulo",
"error.failed_to_upload_file": "Error al subir el archivo", "error.failed_to_upload_file": "Error al subir el archivo",
"message.logged_out": "Sesion cerrada correctamente", "message.logged_out": "Sesion cerrada correctamente",
"message.email_verified": "Correo electronico verificado correctamente", "message.email_verified": "Correo electronico verificado correctamente",
"message.verification_email_sent": "Correo de verificacion enviado", "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.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.reset_code_verified": "Codigo verificado correctamente",
"message.password_reset_success": "Contrasena restablecida correctamente. Por favor, inicia sesion con tu nueva contrasena.", "message.password_reset_success": "Contrasena restablecida correctamente. Por favor, inicia sesion con tu nueva contrasena.",
"message.task_deleted": "Tarea eliminada correctamente", "message.task_deleted": "Tarea eliminada correctamente",
"message.task_in_progress": "Tarea marcada como en progreso", "message.task_in_progress": "Tarea marcada como en progreso",
"message.task_cancelled": "Tarea cancelada", "message.task_cancelled": "Tarea cancelada",
@@ -79,46 +72,35 @@
"message.task_archived": "Tarea archivada", "message.task_archived": "Tarea archivada",
"message.task_unarchived": "Tarea desarchivada", "message.task_unarchived": "Tarea desarchivada",
"message.completion_deleted": "Finalizacion eliminada correctamente", "message.completion_deleted": "Finalizacion eliminada correctamente",
"message.residence_deleted": "Propiedad eliminada correctamente", "message.residence_deleted": "Propiedad eliminada correctamente",
"message.user_removed": "Usuario eliminado de la propiedad", "message.user_removed": "Usuario eliminado de la propiedad",
"message.tasks_report_generated": "Informe de tareas generado correctamente", "message.tasks_report_generated": "Informe de tareas generado correctamente",
"message.tasks_report_sent": "Informe de tareas generado y enviado a {{.Email}}", "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.tasks_report_email_failed": "Informe de tareas generado pero no se pudo enviar el correo",
"message.contractor_deleted": "Contratista eliminado correctamente", "message.contractor_deleted": "Contratista eliminado correctamente",
"message.document_deleted": "Documento eliminado correctamente", "message.document_deleted": "Documento eliminado correctamente",
"message.document_activated": "Documento activado", "message.document_activated": "Documento activado",
"message.document_deactivated": "Documento desactivado", "message.document_deactivated": "Documento desactivado",
"message.notification_marked_read": "Notificación marcada como leída", "message.notification_marked_read": "Notificación marcada como leída",
"message.all_notifications_marked_read": "Todas las notificaciones marcadas como leídas", "message.all_notifications_marked_read": "Todas las notificaciones marcadas como leídas",
"message.device_removed": "Dispositivo eliminado", "message.device_removed": "Dispositivo eliminado",
"message.subscription_upgraded": "Suscripción actualizada correctamente", "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_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.subscription_restored": "Suscripción restaurada correctamente",
"message.file_deleted": "Archivo eliminado correctamente", "message.file_deleted": "Archivo eliminado correctamente",
"message.static_data_refreshed": "Datos estáticos actualizados", "message.static_data_refreshed": "Datos estáticos actualizados",
"error.notification_not_found": "Notificación no encontrada", "error.notification_not_found": "Notificación no encontrada",
"error.invalid_platform": "Plataforma no válida", "error.invalid_platform": "Plataforma no válida",
"error.upgrade_trigger_not_found": "Trigger de actualización no encontrado", "error.upgrade_trigger_not_found": "Trigger de actualización no encontrado",
"error.receipt_data_required": "Se requiere receipt_data para iOS", "error.receipt_data_required": "Se requiere receipt_data para iOS",
"error.purchase_token_required": "Se requiere purchase_token para Android", "error.purchase_token_required": "Se requiere purchase_token para Android",
"error.no_file_provided": "No se proporcionó ningún archivo", "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_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_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_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_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_task_statuses": "Error al obtener los estados de tareas",
"error.failed_to_fetch_contractor_specialties": "Error al obtener las especialidades de contratistas", "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.title": "Tarea Proxima a Vencer",
"push.task_due_soon.body": "{{.TaskTitle}} vence {{.DueDate}}", "push.task_due_soon.body": "{{.TaskTitle}} vence {{.DueDate}}",
"push.task_overdue.title": "Tarea Vencida", "push.task_overdue.title": "Tarea Vencida",
@@ -129,44 +111,38 @@
"push.task_assigned.body": "Se te ha asignado {{.TaskTitle}}", "push.task_assigned.body": "Se te ha asignado {{.TaskTitle}}",
"push.residence_shared.title": "Propiedad Compartida", "push.residence_shared.title": "Propiedad Compartida",
"push.residence_shared.body": "{{.UserName}} compartio {{.ResidenceName}} contigo", "push.residence_shared.body": "{{.UserName}} compartio {{.ResidenceName}} contigo",
"email.welcome.subject": "Bienvenido a honeyDue!", "email.welcome.subject": "Bienvenido a honeyDue!",
"email.verification.subject": "Verifica Tu Correo Electronico", "email.verification.subject": "Verifica Tu Correo Electronico",
"email.password_reset.subject": "Codigo de Restablecimiento de Contrasena", "email.password_reset.subject": "Codigo de Restablecimiento de Contrasena",
"email.tasks_report.subject": "Informe de Tareas para {{.ResidenceName}}", "email.tasks_report.subject": "Informe de Tareas para {{.ResidenceName}}",
"lookup.residence_type.house": "Casa", "lookup.residence_type.house": "Casa",
"lookup.residence_type.apartment": "Apartamento", "lookup.residence_type.apartment": "Apartamento",
"lookup.residence_type.condo": "Condominio", "lookup.residence_type.condo": "Condominio",
"lookup.residence_type.townhouse": "Casa Adosada", "lookup.residence_type.townhouse": "Casa adosada",
"lookup.residence_type.mobile_home": "Casa Movil", "lookup.residence_type.mobile_home": "Casa vil",
"lookup.residence_type.other": "Otro", "lookup.residence_type.other": "Otro",
"lookup.task_category.plumbing": "Fontanería",
"lookup.task_category.plumbing": "Plomeria", "lookup.task_category.electrical": "Eléctrico",
"lookup.task_category.electrical": "Electricidad", "lookup.task_category.hvac": "Climatización",
"lookup.task_category.hvac": "Climatizacion", "lookup.task_category.appliances": "Electrodomésticos",
"lookup.task_category.appliances": "Electrodomesticos",
"lookup.task_category.exterior": "Exterior", "lookup.task_category.exterior": "Exterior",
"lookup.task_category.interior": "Interior", "lookup.task_category.interior": "Interior",
"lookup.task_category.landscaping": "Jardineria", "lookup.task_category.landscaping": "Jardineria",
"lookup.task_category.safety": "Seguridad", "lookup.task_category.safety": "Seguridad",
"lookup.task_category.cleaning": "Limpieza", "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.seasonal": "Estacional",
"lookup.task_category.other": "Otro", "lookup.task_category.other": "Otro",
"lookup.task_priority.low": "Baja", "lookup.task_priority.low": "Baja",
"lookup.task_priority.medium": "Media", "lookup.task_priority.medium": "Media",
"lookup.task_priority.high": "Alta", "lookup.task_priority.high": "Alta",
"lookup.task_priority.urgent": "Urgente", "lookup.task_priority.urgent": "Urgente",
"lookup.task_status.pending": "Pendiente", "lookup.task_status.pending": "Pendiente",
"lookup.task_status.in_progress": "En Progreso", "lookup.task_status.in_progress": "En Progreso",
"lookup.task_status.completed": "Completada", "lookup.task_status.completed": "Completada",
"lookup.task_status.cancelled": "Cancelada", "lookup.task_status.cancelled": "Cancelada",
"lookup.task_status.archived": "Archivada", "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.daily": "Diario",
"lookup.task_frequency.weekly": "Semanal", "lookup.task_frequency.weekly": "Semanal",
"lookup.task_frequency.biweekly": "Cada 2 Semanas", "lookup.task_frequency.biweekly": "Cada 2 Semanas",
@@ -174,18 +150,98 @@
"lookup.task_frequency.quarterly": "Trimestral", "lookup.task_frequency.quarterly": "Trimestral",
"lookup.task_frequency.semiannually": "Cada 6 Meses", "lookup.task_frequency.semiannually": "Cada 6 Meses",
"lookup.task_frequency.annually": "Anual", "lookup.task_frequency.annually": "Anual",
"lookup.contractor_specialty.plumber": "Fontanero",
"lookup.contractor_specialty.plumber": "Plomero",
"lookup.contractor_specialty.electrician": "Electricista", "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.handyman": "Manitas",
"lookup.contractor_specialty.landscaper": "Jardinero", "lookup.contractor_specialty.landscaper": "Jardinero",
"lookup.contractor_specialty.roofer": "Techador", "lookup.contractor_specialty.roofer": "Techador",
"lookup.contractor_specialty.painter": "Pintor", "lookup.contractor_specialty.painter": "Pintor",
"lookup.contractor_specialty.carpenter": "Carpintero", "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.cleaning": "Limpieza",
"lookup.contractor_specialty.pool_service": "Servicio de Piscina", "lookup.contractor_specialty.pool_service": "Servicio de piscina",
"lookup.contractor_specialty.general_contractor": "Contratista General", "lookup.contractor_specialty.general_contractor": "Contratista general",
"lookup.contractor_specialty.other": "Otro" "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"
} }
+98 -42
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "La connexion Google n'est pas configuree", "error.google_signin_not_configured": "La connexion Google n'est pas configuree",
"error.google_signin_failed": "Echec de la connexion Google", "error.google_signin_failed": "Echec de la connexion Google",
"error.invalid_google_token": "Jeton d'identite Google non valide", "error.invalid_google_token": "Jeton d'identite Google non valide",
"error.invalid_task_id": "ID de tache non valide", "error.invalid_task_id": "ID de tache non valide",
"error.invalid_residence_id": "ID de propriete non valide", "error.invalid_residence_id": "ID de propriete non valide",
"error.invalid_contractor_id": "ID de prestataire 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_user_id": "ID d'utilisateur non valide",
"error.invalid_notification_id": "ID de notification non valide", "error.invalid_notification_id": "ID de notification non valide",
"error.invalid_device_id": "ID d'appareil non valide", "error.invalid_device_id": "ID d'appareil non valide",
"error.task_not_found": "Tache non trouvee", "error.task_not_found": "Tache non trouvee",
"error.residence_not_found": "Propriete non trouvee", "error.residence_not_found": "Propriete non trouvee",
"error.contractor_not_found": "Prestataire non trouve", "error.contractor_not_found": "Prestataire non trouve",
@@ -43,7 +41,6 @@
"error.user_not_found": "Utilisateur non trouve", "error.user_not_found": "Utilisateur non trouve",
"error.share_code_invalid": "Code de partage non valide", "error.share_code_invalid": "Code de partage non valide",
"error.share_code_expired": "Le code de partage a expire", "error.share_code_expired": "Le code de partage a expire",
"error.task_access_denied": "Vous n'avez pas acces a cette tache", "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.residence_access_denied": "Vous n'avez pas acces a cette propriete",
"error.contractor_access_denied": "Vous n'avez pas acces a ce prestataire", "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.cannot_remove_owner": "Impossible de retirer le proprietaire",
"error.user_already_member": "L'utilisateur est deja membre de cette propriete", "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.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_cancelled": "La tache est deja annulee",
"error.task_already_archived": "La tache est deja archivee", "error.task_already_archived": "La tache est deja archivee",
"error.failed_to_parse_form": "Echec de l'analyse du formulaire", "error.failed_to_parse_form": "Echec de l'analyse du formulaire",
"error.task_id_required": "task_id est requis", "error.task_id_required": "task_id est requis",
"error.invalid_task_id_value": "task_id non valide", "error.invalid_task_id_value": "task_id non valide",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id non valide", "error.invalid_residence_id_value": "residence_id non valide",
"error.title_required": "Le titre est requis", "error.title_required": "Le titre est requis",
"error.failed_to_upload_file": "Echec du telechargement du fichier", "error.failed_to_upload_file": "Echec du telechargement du fichier",
"message.logged_out": "Deconnexion reussie", "message.logged_out": "Deconnexion reussie",
"message.email_verified": "Email verifie avec succes", "message.email_verified": "Email verifie avec succes",
"message.verification_email_sent": "Email de verification envoye", "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.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.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.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_deleted": "Tache supprimee avec succes",
"message.task_in_progress": "Tache marquee comme en cours", "message.task_in_progress": "Tache marquee comme en cours",
"message.task_cancelled": "Tache annulee", "message.task_cancelled": "Tache annulee",
@@ -79,46 +72,35 @@
"message.task_archived": "Tache archivee", "message.task_archived": "Tache archivee",
"message.task_unarchived": "Tache desarchivee", "message.task_unarchived": "Tache desarchivee",
"message.completion_deleted": "Completion supprimee avec succes", "message.completion_deleted": "Completion supprimee avec succes",
"message.residence_deleted": "Propriete supprimee avec succes", "message.residence_deleted": "Propriete supprimee avec succes",
"message.user_removed": "Utilisateur retire de la propriete", "message.user_removed": "Utilisateur retire de la propriete",
"message.tasks_report_generated": "Rapport de taches genere avec succes", "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_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.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.contractor_deleted": "Prestataire supprime avec succes",
"message.document_deleted": "Document supprime avec succes", "message.document_deleted": "Document supprime avec succes",
"message.document_activated": "Document active", "message.document_activated": "Document active",
"message.document_deactivated": "Document desactive", "message.document_deactivated": "Document desactive",
"message.notification_marked_read": "Notification marquée comme lue", "message.notification_marked_read": "Notification marquée comme lue",
"message.all_notifications_marked_read": "Toutes les notifications marquées comme lues", "message.all_notifications_marked_read": "Toutes les notifications marquées comme lues",
"message.device_removed": "Appareil supprimé", "message.device_removed": "Appareil supprimé",
"message.subscription_upgraded": "Abonnement mis à niveau avec succès", "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_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.subscription_restored": "Abonnement restauré avec succès",
"message.file_deleted": "Fichier supprimé avec succès", "message.file_deleted": "Fichier supprimé avec succès",
"message.static_data_refreshed": "Données statiques actualisées", "message.static_data_refreshed": "Données statiques actualisées",
"error.notification_not_found": "Notification non trouvée", "error.notification_not_found": "Notification non trouvée",
"error.invalid_platform": "Plateforme non valide", "error.invalid_platform": "Plateforme non valide",
"error.upgrade_trigger_not_found": "Déclencheur de mise à niveau non trouvé", "error.upgrade_trigger_not_found": "Déclencheur de mise à niveau non trouvé",
"error.receipt_data_required": "receipt_data est requis pour iOS", "error.receipt_data_required": "receipt_data est requis pour iOS",
"error.purchase_token_required": "purchase_token est requis pour Android", "error.purchase_token_required": "purchase_token est requis pour Android",
"error.no_file_provided": "Aucun fichier fourni", "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_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_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_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_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_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", "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.title": "Tache Bientot Due",
"push.task_due_soon.body": "{{.TaskTitle}} est due le {{.DueDate}}", "push.task_due_soon.body": "{{.TaskTitle}} est due le {{.DueDate}}",
"push.task_overdue.title": "Tache en Retard", "push.task_overdue.title": "Tache en Retard",
@@ -129,44 +111,38 @@
"push.task_assigned.body": "{{.TaskTitle}} vous a ete assignee", "push.task_assigned.body": "{{.TaskTitle}} vous a ete assignee",
"push.residence_shared.title": "Propriete Partagee", "push.residence_shared.title": "Propriete Partagee",
"push.residence_shared.body": "{{.UserName}} a partage {{.ResidenceName}} avec vous", "push.residence_shared.body": "{{.UserName}} a partage {{.ResidenceName}} avec vous",
"email.welcome.subject": "Bienvenue sur honeyDue !", "email.welcome.subject": "Bienvenue sur honeyDue !",
"email.verification.subject": "Verifiez Votre Email", "email.verification.subject": "Verifiez Votre Email",
"email.password_reset.subject": "Code de Reinitialisation de Mot de Passe", "email.password_reset.subject": "Code de Reinitialisation de Mot de Passe",
"email.tasks_report.subject": "Rapport de Taches pour {{.ResidenceName}}", "email.tasks_report.subject": "Rapport de Taches pour {{.ResidenceName}}",
"lookup.residence_type.house": "Maison", "lookup.residence_type.house": "Maison",
"lookup.residence_type.apartment": "Appartement", "lookup.residence_type.apartment": "Appartement",
"lookup.residence_type.condo": "Copropriete", "lookup.residence_type.condo": "Copropriété",
"lookup.residence_type.townhouse": "Maison de Ville", "lookup.residence_type.townhouse": "Maison de ville",
"lookup.residence_type.mobile_home": "Mobil-home", "lookup.residence_type.mobile_home": "Maison mobile",
"lookup.residence_type.other": "Autre", "lookup.residence_type.other": "Autre",
"lookup.task_category.plumbing": "Plomberie", "lookup.task_category.plumbing": "Plomberie",
"lookup.task_category.electrical": "Electricite", "lookup.task_category.electrical": "Électricité",
"lookup.task_category.hvac": "Climatisation", "lookup.task_category.hvac": "CVC",
"lookup.task_category.appliances": "Electromenager", "lookup.task_category.appliances": "Électroménager",
"lookup.task_category.exterior": "Exterieur", "lookup.task_category.exterior": "Extérieur",
"lookup.task_category.interior": "Interieur", "lookup.task_category.interior": "Intérieur",
"lookup.task_category.landscaping": "Jardinage", "lookup.task_category.landscaping": "Jardinage",
"lookup.task_category.safety": "Securite", "lookup.task_category.safety": "Sécurité",
"lookup.task_category.cleaning": "Nettoyage", "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.seasonal": "Saisonnier",
"lookup.task_category.other": "Autre", "lookup.task_category.other": "Autre",
"lookup.task_priority.low": "Basse", "lookup.task_priority.low": "Basse",
"lookup.task_priority.medium": "Moyenne", "lookup.task_priority.medium": "Moyenne",
"lookup.task_priority.high": "Haute", "lookup.task_priority.high": "Haute",
"lookup.task_priority.urgent": "Urgente", "lookup.task_priority.urgent": "Urgente",
"lookup.task_status.pending": "En Attente", "lookup.task_status.pending": "En Attente",
"lookup.task_status.in_progress": "En Cours", "lookup.task_status.in_progress": "En Cours",
"lookup.task_status.completed": "Terminee", "lookup.task_status.completed": "Terminee",
"lookup.task_status.cancelled": "Annulee", "lookup.task_status.cancelled": "Annulee",
"lookup.task_status.archived": "Archivee", "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.daily": "Quotidien",
"lookup.task_frequency.weekly": "Hebdomadaire", "lookup.task_frequency.weekly": "Hebdomadaire",
"lookup.task_frequency.biweekly": "Toutes les 2 Semaines", "lookup.task_frequency.biweekly": "Toutes les 2 Semaines",
@@ -174,18 +150,98 @@
"lookup.task_frequency.quarterly": "Trimestriel", "lookup.task_frequency.quarterly": "Trimestriel",
"lookup.task_frequency.semiannually": "Tous les 6 Mois", "lookup.task_frequency.semiannually": "Tous les 6 Mois",
"lookup.task_frequency.annually": "Annuel", "lookup.task_frequency.annually": "Annuel",
"lookup.contractor_specialty.plumber": "Plombier", "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.hvac_technician": "Technicien CVC",
"lookup.contractor_specialty.handyman": "Bricoleur", "lookup.contractor_specialty.handyman": "Bricoleur",
"lookup.contractor_specialty.landscaper": "Paysagiste", "lookup.contractor_specialty.landscaper": "Paysagiste",
"lookup.contractor_specialty.roofer": "Couvreur", "lookup.contractor_specialty.roofer": "Couvreur",
"lookup.contractor_specialty.painter": "Peintre", "lookup.contractor_specialty.painter": "Peintre",
"lookup.contractor_specialty.carpenter": "Menuisier", "lookup.contractor_specialty.carpenter": "Charpentier",
"lookup.contractor_specialty.pest_control": "Desinsectisation", "lookup.contractor_specialty.pest_control": "Lutte antiparasitaire",
"lookup.contractor_specialty.cleaning": "Nettoyage", "lookup.contractor_specialty.cleaning": "Nettoyage",
"lookup.contractor_specialty.pool_service": "Service Piscine", "lookup.contractor_specialty.pool_service": "Service de piscine",
"lookup.contractor_specialty.general_contractor": "Entrepreneur General", "lookup.contractor_specialty.general_contractor": "Entrepreneur général",
"lookup.contractor_specialty.other": "Autre" "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"
} }
+92 -36
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "L'accesso con Google non è configurato", "error.google_signin_not_configured": "L'accesso con Google non è configurato",
"error.google_signin_failed": "Accesso con Google fallito", "error.google_signin_failed": "Accesso con Google fallito",
"error.invalid_google_token": "Token di identità Google non valido", "error.invalid_google_token": "Token di identità Google non valido",
"error.invalid_task_id": "ID attività non valido", "error.invalid_task_id": "ID attività non valido",
"error.invalid_residence_id": "ID immobile non valido", "error.invalid_residence_id": "ID immobile non valido",
"error.invalid_contractor_id": "ID fornitore non valido", "error.invalid_contractor_id": "ID fornitore non valido",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "ID utente non valido", "error.invalid_user_id": "ID utente non valido",
"error.invalid_notification_id": "ID notifica non valido", "error.invalid_notification_id": "ID notifica non valido",
"error.invalid_device_id": "ID dispositivo non valido", "error.invalid_device_id": "ID dispositivo non valido",
"error.task_not_found": "Attività non trovata", "error.task_not_found": "Attività non trovata",
"error.residence_not_found": "Immobile non trovato", "error.residence_not_found": "Immobile non trovato",
"error.contractor_not_found": "Fornitore non trovato", "error.contractor_not_found": "Fornitore non trovato",
@@ -43,7 +41,6 @@
"error.user_not_found": "Utente non trovato", "error.user_not_found": "Utente non trovato",
"error.share_code_invalid": "Codice di condivisione non valido", "error.share_code_invalid": "Codice di condivisione non valido",
"error.share_code_expired": "Il codice di condivisione è scaduto", "error.share_code_expired": "Il codice di condivisione è scaduto",
"error.task_access_denied": "Non hai accesso a questa attività", "error.task_access_denied": "Non hai accesso a questa attività",
"error.residence_access_denied": "Non hai accesso a questo immobile", "error.residence_access_denied": "Non hai accesso a questo immobile",
"error.contractor_access_denied": "Non hai accesso a questo fornitore", "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.cannot_remove_owner": "Impossibile rimuovere il proprietario dell'immobile",
"error.user_already_member": "L'utente è già membro di questo 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.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_cancelled": "L'attività è già stata annullata",
"error.task_already_archived": "L'attività è già stata archiviata", "error.task_already_archived": "L'attività è già stata archiviata",
"error.failed_to_parse_form": "Impossibile analizzare il modulo multipart", "error.failed_to_parse_form": "Impossibile analizzare il modulo multipart",
"error.task_id_required": "task_id è obbligatorio", "error.task_id_required": "task_id è obbligatorio",
"error.invalid_task_id_value": "task_id non valido", "error.invalid_task_id_value": "task_id non valido",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id non valido", "error.invalid_residence_id_value": "residence_id non valido",
"error.title_required": "title è obbligatorio", "error.title_required": "title è obbligatorio",
"error.failed_to_upload_file": "Impossibile caricare il file", "error.failed_to_upload_file": "Impossibile caricare il file",
"message.logged_out": "Disconnessione avvenuta con successo", "message.logged_out": "Disconnessione avvenuta con successo",
"message.email_verified": "Email verificata con successo", "message.email_verified": "Email verificata con successo",
"message.verification_email_sent": "Email di verifica inviata", "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.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.reset_code_verified": "Codice verificato con successo",
"message.password_reset_success": "Password reimpostata con successo. Accedi con la tua nuova password.", "message.password_reset_success": "Password reimpostata con successo. Accedi con la tua nuova password.",
"message.task_deleted": "Attività eliminata con successo", "message.task_deleted": "Attività eliminata con successo",
"message.task_in_progress": "Attività contrassegnata come in corso", "message.task_in_progress": "Attività contrassegnata come in corso",
"message.task_cancelled": "Attività annullata", "message.task_cancelled": "Attività annullata",
@@ -79,46 +72,35 @@
"message.task_archived": "Attività archiviata", "message.task_archived": "Attività archiviata",
"message.task_unarchived": "Attività ripristinata dall'archivio", "message.task_unarchived": "Attività ripristinata dall'archivio",
"message.completion_deleted": "Completamento eliminato con successo", "message.completion_deleted": "Completamento eliminato con successo",
"message.residence_deleted": "Immobile eliminato con successo", "message.residence_deleted": "Immobile eliminato con successo",
"message.user_removed": "Utente rimosso dall'immobile", "message.user_removed": "Utente rimosso dall'immobile",
"message.tasks_report_generated": "Report attività generato con successo", "message.tasks_report_generated": "Report attività generato con successo",
"message.tasks_report_sent": "Report attività generato e inviato a {{.Email}}", "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.tasks_report_email_failed": "Report attività generato ma l'email non è stata inviata",
"message.contractor_deleted": "Fornitore eliminato con successo", "message.contractor_deleted": "Fornitore eliminato con successo",
"message.document_deleted": "Documento eliminato con successo", "message.document_deleted": "Documento eliminato con successo",
"message.document_activated": "Documento attivato", "message.document_activated": "Documento attivato",
"message.document_deactivated": "Documento disattivato", "message.document_deactivated": "Documento disattivato",
"message.notification_marked_read": "Notifica contrassegnata come letta", "message.notification_marked_read": "Notifica contrassegnata come letta",
"message.all_notifications_marked_read": "Tutte le notifiche contrassegnate come lette", "message.all_notifications_marked_read": "Tutte le notifiche contrassegnate come lette",
"message.device_removed": "Dispositivo rimosso", "message.device_removed": "Dispositivo rimosso",
"message.subscription_upgraded": "Abbonamento aggiornato con successo", "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_cancelled": "Abbonamento annullato. Manterrai i vantaggi Pro fino alla fine del periodo di fatturazione.",
"message.subscription_restored": "Abbonamento ripristinato con successo", "message.subscription_restored": "Abbonamento ripristinato con successo",
"message.file_deleted": "File eliminato con successo", "message.file_deleted": "File eliminato con successo",
"message.static_data_refreshed": "Dati statici aggiornati", "message.static_data_refreshed": "Dati statici aggiornati",
"error.notification_not_found": "Notifica non trovata", "error.notification_not_found": "Notifica non trovata",
"error.invalid_platform": "Piattaforma non valida", "error.invalid_platform": "Piattaforma non valida",
"error.upgrade_trigger_not_found": "Trigger di aggiornamento non trovato", "error.upgrade_trigger_not_found": "Trigger di aggiornamento non trovato",
"error.receipt_data_required": "receipt_data è obbligatorio per iOS", "error.receipt_data_required": "receipt_data è obbligatorio per iOS",
"error.purchase_token_required": "purchase_token è obbligatorio per Android", "error.purchase_token_required": "purchase_token è obbligatorio per Android",
"error.no_file_provided": "Nessun file fornito", "error.no_file_provided": "Nessun file fornito",
"error.failed_to_fetch_residence_types": "Impossibile recuperare i tipi di immobile", "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_categories": "Impossibile recuperare le categorie di attività",
"error.failed_to_fetch_task_priorities": "Impossibile recuperare le priorità delle 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_frequencies": "Impossibile recuperare le frequenze delle attività",
"error.failed_to_fetch_task_statuses": "Impossibile recuperare gli stati 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", "error.failed_to_fetch_contractor_specialties": "Impossibile recuperare le specializzazioni dei fornitori",
"push.task_due_soon.title": "Attività in Scadenza", "push.task_due_soon.title": "Attività in Scadenza",
"push.task_due_soon.body": "{{.TaskTitle}} scade {{.DueDate}}", "push.task_due_soon.body": "{{.TaskTitle}} scade {{.DueDate}}",
"push.task_overdue.title": "Attività Scaduta", "push.task_overdue.title": "Attività Scaduta",
@@ -129,22 +111,19 @@
"push.task_assigned.body": "Ti è stata assegnata {{.TaskTitle}}", "push.task_assigned.body": "Ti è stata assegnata {{.TaskTitle}}",
"push.residence_shared.title": "Immobile Condiviso", "push.residence_shared.title": "Immobile Condiviso",
"push.residence_shared.body": "{{.UserName}} ha condiviso {{.ResidenceName}} con te", "push.residence_shared.body": "{{.UserName}} ha condiviso {{.ResidenceName}} con te",
"email.welcome.subject": "Benvenuto su honeyDue!", "email.welcome.subject": "Benvenuto su honeyDue!",
"email.verification.subject": "Verifica la Tua Email", "email.verification.subject": "Verifica la Tua Email",
"email.password_reset.subject": "Codice di Reimpostazione Password", "email.password_reset.subject": "Codice di Reimpostazione Password",
"email.tasks_report.subject": "Report Attività per {{.ResidenceName}}", "email.tasks_report.subject": "Report Attività per {{.ResidenceName}}",
"lookup.residence_type.house": "Casa", "lookup.residence_type.house": "Casa",
"lookup.residence_type.apartment": "Appartamento", "lookup.residence_type.apartment": "Appartamento",
"lookup.residence_type.condo": "Condominio", "lookup.residence_type.condo": "Condominio",
"lookup.residence_type.townhouse": "Villetta a Schiera", "lookup.residence_type.townhouse": "Villetta a schiera",
"lookup.residence_type.mobile_home": "Casa Mobile", "lookup.residence_type.mobile_home": "Casa mobile",
"lookup.residence_type.other": "Altro", "lookup.residence_type.other": "Altro",
"lookup.task_category.plumbing": "Idraulica", "lookup.task_category.plumbing": "Idraulica",
"lookup.task_category.electrical": "Elettricità", "lookup.task_category.electrical": "Elettrico",
"lookup.task_category.hvac": "Climatizzazione", "lookup.task_category.hvac": "HVAC",
"lookup.task_category.appliances": "Elettrodomestici", "lookup.task_category.appliances": "Elettrodomestici",
"lookup.task_category.exterior": "Esterno", "lookup.task_category.exterior": "Esterno",
"lookup.task_category.interior": "Interno", "lookup.task_category.interior": "Interno",
@@ -154,38 +133,115 @@
"lookup.task_category.pest_control": "Disinfestazione", "lookup.task_category.pest_control": "Disinfestazione",
"lookup.task_category.seasonal": "Stagionale", "lookup.task_category.seasonal": "Stagionale",
"lookup.task_category.other": "Altro", "lookup.task_category.other": "Altro",
"lookup.task_priority.low": "Bassa", "lookup.task_priority.low": "Bassa",
"lookup.task_priority.medium": "Media", "lookup.task_priority.medium": "Media",
"lookup.task_priority.high": "Alta", "lookup.task_priority.high": "Alta",
"lookup.task_priority.urgent": "Urgente", "lookup.task_priority.urgent": "Urgente",
"lookup.task_status.pending": "In Attesa", "lookup.task_status.pending": "In Attesa",
"lookup.task_status.in_progress": "In Corso", "lookup.task_status.in_progress": "In Corso",
"lookup.task_status.completed": "Completata", "lookup.task_status.completed": "Completata",
"lookup.task_status.cancelled": "Annullata", "lookup.task_status.cancelled": "Annullata",
"lookup.task_status.archived": "Archiviata", "lookup.task_status.archived": "Archiviata",
"lookup.task_frequency.once": "Una volta",
"lookup.task_frequency.once": "Una Volta", "lookup.task_frequency.daily": "Giornaliero",
"lookup.task_frequency.daily": "Giornaliera",
"lookup.task_frequency.weekly": "Settimanale", "lookup.task_frequency.weekly": "Settimanale",
"lookup.task_frequency.biweekly": "Ogni 2 Settimane", "lookup.task_frequency.biweekly": "Ogni 2 Settimane",
"lookup.task_frequency.monthly": "Mensile", "lookup.task_frequency.monthly": "Mensile",
"lookup.task_frequency.quarterly": "Trimestrale", "lookup.task_frequency.quarterly": "Trimestrale",
"lookup.task_frequency.semiannually": "Ogni 6 Mesi", "lookup.task_frequency.semiannually": "Ogni 6 Mesi",
"lookup.task_frequency.annually": "Annuale", "lookup.task_frequency.annually": "Annuale",
"lookup.contractor_specialty.plumber": "Idraulico", "lookup.contractor_specialty.plumber": "Idraulico",
"lookup.contractor_specialty.electrician": "Elettricista", "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.handyman": "Tuttofare",
"lookup.contractor_specialty.landscaper": "Giardiniere", "lookup.contractor_specialty.landscaper": "Giardiniere",
"lookup.contractor_specialty.roofer": "Lattoniere", "lookup.contractor_specialty.roofer": "Conciatetti",
"lookup.contractor_specialty.painter": "Imbianchino", "lookup.contractor_specialty.painter": "Imbianchino",
"lookup.contractor_specialty.carpenter": "Falegname", "lookup.contractor_specialty.carpenter": "Falegname",
"lookup.contractor_specialty.pest_control": "Disinfestazione", "lookup.contractor_specialty.pest_control": "Disinfestazione",
"lookup.contractor_specialty.cleaning": "Pulizia", "lookup.contractor_specialty.cleaning": "Pulizia",
"lookup.contractor_specialty.pool_service": "Manutenzione Piscine", "lookup.contractor_specialty.pool_service": "Servizio piscina",
"lookup.contractor_specialty.general_contractor": "Impresa Generale", "lookup.contractor_specialty.general_contractor": "Imprenditore generale",
"lookup.contractor_specialty.other": "Altro" "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"
} }
+90 -34
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "Google サインインが設定されていません", "error.google_signin_not_configured": "Google サインインが設定されていません",
"error.google_signin_failed": "Google サインインに失敗しました", "error.google_signin_failed": "Google サインインに失敗しました",
"error.invalid_google_token": "無効な Google ID トークンです", "error.invalid_google_token": "無効な Google ID トークンです",
"error.invalid_task_id": "無効なタスクIDです", "error.invalid_task_id": "無効なタスクIDです",
"error.invalid_residence_id": "無効な物件IDです", "error.invalid_residence_id": "無効な物件IDです",
"error.invalid_contractor_id": "無効な業者IDです", "error.invalid_contractor_id": "無効な業者IDです",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "無効なユーザーIDです", "error.invalid_user_id": "無効なユーザーIDです",
"error.invalid_notification_id": "無効な通知IDです", "error.invalid_notification_id": "無効な通知IDです",
"error.invalid_device_id": "無効なデバイスIDです", "error.invalid_device_id": "無効なデバイスIDです",
"error.task_not_found": "タスクが見つかりません", "error.task_not_found": "タスクが見つかりません",
"error.residence_not_found": "物件が見つかりません", "error.residence_not_found": "物件が見つかりません",
"error.contractor_not_found": "業者が見つかりません", "error.contractor_not_found": "業者が見つかりません",
@@ -43,7 +41,6 @@
"error.user_not_found": "ユーザーが見つかりません", "error.user_not_found": "ユーザーが見つかりません",
"error.share_code_invalid": "無効な共有コードです", "error.share_code_invalid": "無効な共有コードです",
"error.share_code_expired": "共有コードの有効期限が切れています", "error.share_code_expired": "共有コードの有効期限が切れています",
"error.task_access_denied": "このタスクへのアクセス権がありません", "error.task_access_denied": "このタスクへのアクセス権がありません",
"error.residence_access_denied": "この物件へのアクセス権がありません", "error.residence_access_denied": "この物件へのアクセス権がありません",
"error.contractor_access_denied": "この業者へのアクセス権がありません", "error.contractor_access_denied": "この業者へのアクセス権がありません",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "物件のオーナーを削除することはできません", "error.cannot_remove_owner": "物件のオーナーを削除することはできません",
"error.user_already_member": "このユーザーは既にこの物件のメンバーです", "error.user_already_member": "このユーザーは既にこの物件のメンバーです",
"error.properties_limit_reached": "サブスクリプションで許可されている物件の最大数に達しました", "error.properties_limit_reached": "サブスクリプションで許可されている物件の最大数に達しました",
"error.task_already_cancelled": "タスクは既にキャンセルされています", "error.task_already_cancelled": "タスクは既にキャンセルされています",
"error.task_already_archived": "タスクは既にアーカイブされています", "error.task_already_archived": "タスクは既にアーカイブされています",
"error.failed_to_parse_form": "マルチパートフォームの解析に失敗しました", "error.failed_to_parse_form": "マルチパートフォームの解析に失敗しました",
"error.task_id_required": "task_id は必須です", "error.task_id_required": "task_id は必須です",
"error.invalid_task_id_value": "無効な task_id です", "error.invalid_task_id_value": "無効な task_id です",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "無効な residence_id です", "error.invalid_residence_id_value": "無効な residence_id です",
"error.title_required": "タイトルは必須です", "error.title_required": "タイトルは必須です",
"error.failed_to_upload_file": "ファイルのアップロードに失敗しました", "error.failed_to_upload_file": "ファイルのアップロードに失敗しました",
"message.logged_out": "ログアウトしました", "message.logged_out": "ログアウトしました",
"message.email_verified": "メールアドレスの認証が完了しました", "message.email_verified": "メールアドレスの認証が完了しました",
"message.verification_email_sent": "認証メールを送信しました", "message.verification_email_sent": "認証メールを送信しました",
"message.password_reset_email_sent": "該当するアカウントが存在する場合、パスワードリセットコードが送信されました。", "message.password_reset_email_sent": "該当するアカウントが存在する場合、パスワードリセットコードが送信されました。",
"message.reset_code_verified": "コードの認証が完了しました", "message.reset_code_verified": "コードの認証が完了しました",
"message.password_reset_success": "パスワードのリセットが完了しました。新しいパスワードでログインしてください。", "message.password_reset_success": "パスワードのリセットが完了しました。新しいパスワードでログインしてください。",
"message.task_deleted": "タスクを削除しました", "message.task_deleted": "タスクを削除しました",
"message.task_in_progress": "タスクを進行中に設定しました", "message.task_in_progress": "タスクを進行中に設定しました",
"message.task_cancelled": "タスクをキャンセルしました", "message.task_cancelled": "タスクをキャンセルしました",
@@ -79,46 +72,35 @@
"message.task_archived": "タスクをアーカイブしました", "message.task_archived": "タスクをアーカイブしました",
"message.task_unarchived": "タスクのアーカイブを解除しました", "message.task_unarchived": "タスクのアーカイブを解除しました",
"message.completion_deleted": "完了記録を削除しました", "message.completion_deleted": "完了記録を削除しました",
"message.residence_deleted": "物件を削除しました", "message.residence_deleted": "物件を削除しました",
"message.user_removed": "ユーザーを物件から削除しました", "message.user_removed": "ユーザーを物件から削除しました",
"message.tasks_report_generated": "タスクレポートを生成しました", "message.tasks_report_generated": "タスクレポートを生成しました",
"message.tasks_report_sent": "タスクレポートを生成し、{{.Email}} に送信しました", "message.tasks_report_sent": "タスクレポートを生成し、{{.Email}} に送信しました",
"message.tasks_report_email_failed": "タスクレポートは生成されましたが、メールの送信に失敗しました", "message.tasks_report_email_failed": "タスクレポートは生成されましたが、メールの送信に失敗しました",
"message.contractor_deleted": "業者を削除しました", "message.contractor_deleted": "業者を削除しました",
"message.document_deleted": "書類を削除しました", "message.document_deleted": "書類を削除しました",
"message.document_activated": "書類を有効化しました", "message.document_activated": "書類を有効化しました",
"message.document_deactivated": "書類を無効化しました", "message.document_deactivated": "書類を無効化しました",
"message.notification_marked_read": "通知を既読にしました", "message.notification_marked_read": "通知を既読にしました",
"message.all_notifications_marked_read": "すべての通知を既読にしました", "message.all_notifications_marked_read": "すべての通知を既読にしました",
"message.device_removed": "デバイスを削除しました", "message.device_removed": "デバイスを削除しました",
"message.subscription_upgraded": "サブスクリプションをアップグレードしました", "message.subscription_upgraded": "サブスクリプションをアップグレードしました",
"message.subscription_cancelled": "サブスクリプションをキャンセルしました。請求期間終了まで Pro 機能をご利用いただけます。", "message.subscription_cancelled": "サブスクリプションをキャンセルしました。請求期間終了まで Pro 機能をご利用いただけます。",
"message.subscription_restored": "サブスクリプションを復元しました", "message.subscription_restored": "サブスクリプションを復元しました",
"message.file_deleted": "ファイルを削除しました", "message.file_deleted": "ファイルを削除しました",
"message.static_data_refreshed": "静的データを更新しました", "message.static_data_refreshed": "静的データを更新しました",
"error.notification_not_found": "通知が見つかりません", "error.notification_not_found": "通知が見つかりません",
"error.invalid_platform": "無効なプラットフォームです", "error.invalid_platform": "無効なプラットフォームです",
"error.upgrade_trigger_not_found": "アップグレードトリガーが見つかりません", "error.upgrade_trigger_not_found": "アップグレードトリガーが見つかりません",
"error.receipt_data_required": "iOS の場合、receipt_data は必須です", "error.receipt_data_required": "iOS の場合、receipt_data は必須です",
"error.purchase_token_required": "Android の場合、purchase_token は必須です", "error.purchase_token_required": "Android の場合、purchase_token は必須です",
"error.no_file_provided": "ファイルが提供されていません", "error.no_file_provided": "ファイルが提供されていません",
"error.failed_to_fetch_residence_types": "物件タイプの取得に失敗しました", "error.failed_to_fetch_residence_types": "物件タイプの取得に失敗しました",
"error.failed_to_fetch_task_categories": "タスクカテゴリの取得に失敗しました", "error.failed_to_fetch_task_categories": "タスクカテゴリの取得に失敗しました",
"error.failed_to_fetch_task_priorities": "タスク優先度の取得に失敗しました", "error.failed_to_fetch_task_priorities": "タスク優先度の取得に失敗しました",
"error.failed_to_fetch_task_frequencies": "タスク頻度の取得に失敗しました", "error.failed_to_fetch_task_frequencies": "タスク頻度の取得に失敗しました",
"error.failed_to_fetch_task_statuses": "タスクステータスの取得に失敗しました", "error.failed_to_fetch_task_statuses": "タスクステータスの取得に失敗しました",
"error.failed_to_fetch_contractor_specialties": "業者専門分野の取得に失敗しました", "error.failed_to_fetch_contractor_specialties": "業者専門分野の取得に失敗しました",
"push.task_due_soon.title": "タスクの期限が近づいています", "push.task_due_soon.title": "タスクの期限が近づいています",
"push.task_due_soon.body": "{{.TaskTitle}} の期限は {{.DueDate}} です", "push.task_due_soon.body": "{{.TaskTitle}} の期限は {{.DueDate}} です",
"push.task_overdue.title": "期限切れのタスク", "push.task_overdue.title": "期限切れのタスク",
@@ -129,19 +111,16 @@
"push.task_assigned.body": "{{.TaskTitle}} に割り当てられました", "push.task_assigned.body": "{{.TaskTitle}} に割り当てられました",
"push.residence_shared.title": "物件が共有されました", "push.residence_shared.title": "物件が共有されました",
"push.residence_shared.body": "{{.UserName}} が {{.ResidenceName}} を共有しました", "push.residence_shared.body": "{{.UserName}} が {{.ResidenceName}} を共有しました",
"email.welcome.subject": "honeyDue へようこそ!", "email.welcome.subject": "honeyDue へようこそ!",
"email.verification.subject": "メールアドレスの認証", "email.verification.subject": "メールアドレスの認証",
"email.password_reset.subject": "パスワードリセットコード", "email.password_reset.subject": "パスワードリセットコード",
"email.tasks_report.subject": "{{.ResidenceName}} のタスクレポート", "email.tasks_report.subject": "{{.ResidenceName}} のタスクレポート",
"lookup.residence_type.house": "一戸建て", "lookup.residence_type.house": "一戸建て",
"lookup.residence_type.apartment": "アパート", "lookup.residence_type.apartment": "アパート",
"lookup.residence_type.condo": "マンション", "lookup.residence_type.condo": "分譲マンション",
"lookup.residence_type.townhouse": "タウンハウス", "lookup.residence_type.townhouse": "タウンハウス",
"lookup.residence_type.mobile_home": "移動式住宅", "lookup.residence_type.mobile_home": "モバイルホーム",
"lookup.residence_type.other": "その他", "lookup.residence_type.other": "その他",
"lookup.task_category.plumbing": "配管", "lookup.task_category.plumbing": "配管",
"lookup.task_category.electrical": "電気", "lookup.task_category.electrical": "電気",
"lookup.task_category.hvac": "空調", "lookup.task_category.hvac": "空調",
@@ -154,19 +133,16 @@
"lookup.task_category.pest_control": "害虫駆除", "lookup.task_category.pest_control": "害虫駆除",
"lookup.task_category.seasonal": "季節", "lookup.task_category.seasonal": "季節",
"lookup.task_category.other": "その他", "lookup.task_category.other": "その他",
"lookup.task_priority.low": "低", "lookup.task_priority.low": "低",
"lookup.task_priority.medium": "中", "lookup.task_priority.medium": "中",
"lookup.task_priority.high": "高", "lookup.task_priority.high": "高",
"lookup.task_priority.urgent": "緊急", "lookup.task_priority.urgent": "緊急",
"lookup.task_status.pending": "保留中", "lookup.task_status.pending": "保留中",
"lookup.task_status.in_progress": "進行中", "lookup.task_status.in_progress": "進行中",
"lookup.task_status.completed": "完了", "lookup.task_status.completed": "完了",
"lookup.task_status.cancelled": "キャンセル", "lookup.task_status.cancelled": "キャンセル",
"lookup.task_status.archived": "アーカイブ", "lookup.task_status.archived": "アーカイブ",
"lookup.task_frequency.once": "1回",
"lookup.task_frequency.once": "一度のみ",
"lookup.task_frequency.daily": "毎日", "lookup.task_frequency.daily": "毎日",
"lookup.task_frequency.weekly": "毎週", "lookup.task_frequency.weekly": "毎週",
"lookup.task_frequency.biweekly": "2週間ごと", "lookup.task_frequency.biweekly": "2週間ごと",
@@ -174,18 +150,98 @@
"lookup.task_frequency.quarterly": "四半期ごと", "lookup.task_frequency.quarterly": "四半期ごと",
"lookup.task_frequency.semiannually": "半年ごと", "lookup.task_frequency.semiannually": "半年ごと",
"lookup.task_frequency.annually": "毎年", "lookup.task_frequency.annually": "毎年",
"lookup.contractor_specialty.plumber": "配管工", "lookup.contractor_specialty.plumber": "配管工",
"lookup.contractor_specialty.electrician": "電気工事士", "lookup.contractor_specialty.electrician": "電気技師",
"lookup.contractor_specialty.hvac_technician": "空調技術者", "lookup.contractor_specialty.hvac_technician": "空調技",
"lookup.contractor_specialty.handyman": "便利屋", "lookup.contractor_specialty.handyman": "便利屋",
"lookup.contractor_specialty.landscaper": "造園業者", "lookup.contractor_specialty.landscaper": "造園業者",
"lookup.contractor_specialty.roofer": "屋根職人", "lookup.contractor_specialty.roofer": "屋根職人",
"lookup.contractor_specialty.painter": "塗装", "lookup.contractor_specialty.painter": "塗装業者",
"lookup.contractor_specialty.carpenter": "大工", "lookup.contractor_specialty.carpenter": "大工",
"lookup.contractor_specialty.pest_control": "害虫駆除業者", "lookup.contractor_specialty.pest_control": "害虫駆除",
"lookup.contractor_specialty.cleaning": "清掃業者", "lookup.contractor_specialty.cleaning": "清掃業者",
"lookup.contractor_specialty.pool_service": "プールサービス", "lookup.contractor_specialty.pool_service": "プールサービス",
"lookup.contractor_specialty.general_contractor": "総合建設業者", "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": "その他"
} }
+89 -33
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "Google 로그인이 설정되지 않았습니다", "error.google_signin_not_configured": "Google 로그인이 설정되지 않았습니다",
"error.google_signin_failed": "Google 로그인에 실패했습니다", "error.google_signin_failed": "Google 로그인에 실패했습니다",
"error.invalid_google_token": "유효하지 않은 Google 인증 토큰입니다", "error.invalid_google_token": "유효하지 않은 Google 인증 토큰입니다",
"error.invalid_task_id": "유효하지 않은 작업 ID입니다", "error.invalid_task_id": "유효하지 않은 작업 ID입니다",
"error.invalid_residence_id": "유효하지 않은 주거지 ID입니다", "error.invalid_residence_id": "유효하지 않은 주거지 ID입니다",
"error.invalid_contractor_id": "유효하지 않은 계약업체 ID입니다", "error.invalid_contractor_id": "유효하지 않은 계약업체 ID입니다",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "유효하지 않은 사용자 ID입니다", "error.invalid_user_id": "유효하지 않은 사용자 ID입니다",
"error.invalid_notification_id": "유효하지 않은 알림 ID입니다", "error.invalid_notification_id": "유효하지 않은 알림 ID입니다",
"error.invalid_device_id": "유효하지 않은 기기 ID입니다", "error.invalid_device_id": "유효하지 않은 기기 ID입니다",
"error.task_not_found": "작업을 찾을 수 없습니다", "error.task_not_found": "작업을 찾을 수 없습니다",
"error.residence_not_found": "주거지를 찾을 수 없습니다", "error.residence_not_found": "주거지를 찾을 수 없습니다",
"error.contractor_not_found": "계약업체를 찾을 수 없습니다", "error.contractor_not_found": "계약업체를 찾을 수 없습니다",
@@ -43,7 +41,6 @@
"error.user_not_found": "사용자를 찾을 수 없습니다", "error.user_not_found": "사용자를 찾을 수 없습니다",
"error.share_code_invalid": "유효하지 않은 공유 코드입니다", "error.share_code_invalid": "유효하지 않은 공유 코드입니다",
"error.share_code_expired": "공유 코드가 만료되었습니다", "error.share_code_expired": "공유 코드가 만료되었습니다",
"error.task_access_denied": "이 작업에 접근할 권한이 없습니다", "error.task_access_denied": "이 작업에 접근할 권한이 없습니다",
"error.residence_access_denied": "이 주거지에 접근할 권한이 없습니다", "error.residence_access_denied": "이 주거지에 접근할 권한이 없습니다",
"error.contractor_access_denied": "이 계약업체에 접근할 권한이 없습니다", "error.contractor_access_denied": "이 계약업체에 접근할 권한이 없습니다",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "주거지 소유자는 삭제할 수 없습니다", "error.cannot_remove_owner": "주거지 소유자는 삭제할 수 없습니다",
"error.user_already_member": "이미 이 주거지의 멤버입니다", "error.user_already_member": "이미 이 주거지의 멤버입니다",
"error.properties_limit_reached": "구독 플랜의 최대 주거지 수에 도달했습니다", "error.properties_limit_reached": "구독 플랜의 최대 주거지 수에 도달했습니다",
"error.task_already_cancelled": "이미 취소된 작업입니다", "error.task_already_cancelled": "이미 취소된 작업입니다",
"error.task_already_archived": "이미 보관된 작업입니다", "error.task_already_archived": "이미 보관된 작업입니다",
"error.failed_to_parse_form": "멀티파트 폼 파싱에 실패했습니다", "error.failed_to_parse_form": "멀티파트 폼 파싱에 실패했습니다",
"error.task_id_required": "task_id가 필요합니다", "error.task_id_required": "task_id가 필요합니다",
"error.invalid_task_id_value": "유효하지 않은 task_id 값입니다", "error.invalid_task_id_value": "유효하지 않은 task_id 값입니다",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "유효하지 않은 residence_id 값입니다", "error.invalid_residence_id_value": "유효하지 않은 residence_id 값입니다",
"error.title_required": "제목이 필요합니다", "error.title_required": "제목이 필요합니다",
"error.failed_to_upload_file": "파일 업로드에 실패했습니다", "error.failed_to_upload_file": "파일 업로드에 실패했습니다",
"message.logged_out": "로그아웃되었습니다", "message.logged_out": "로그아웃되었습니다",
"message.email_verified": "이메일이 인증되었습니다", "message.email_verified": "이메일이 인증되었습니다",
"message.verification_email_sent": "인증 이메일이 발송되었습니다", "message.verification_email_sent": "인증 이메일이 발송되었습니다",
"message.password_reset_email_sent": "해당 이메일로 등록된 계정이 있는 경우 비밀번호 재설정 코드가 발송되었습니다.", "message.password_reset_email_sent": "해당 이메일로 등록된 계정이 있는 경우 비밀번호 재설정 코드가 발송되었습니다.",
"message.reset_code_verified": "코드가 인증되었습니다", "message.reset_code_verified": "코드가 인증되었습니다",
"message.password_reset_success": "비밀번호가 재설정되었습니다. 새 비밀번호로 로그인해주세요.", "message.password_reset_success": "비밀번호가 재설정되었습니다. 새 비밀번호로 로그인해주세요.",
"message.task_deleted": "작업이 삭제되었습니다", "message.task_deleted": "작업이 삭제되었습니다",
"message.task_in_progress": "작업이 진행 중으로 표시되었습니다", "message.task_in_progress": "작업이 진행 중으로 표시되었습니다",
"message.task_cancelled": "작업이 취소되었습니다", "message.task_cancelled": "작업이 취소되었습니다",
@@ -79,46 +72,35 @@
"message.task_archived": "작업이 보관되었습니다", "message.task_archived": "작업이 보관되었습니다",
"message.task_unarchived": "작업 보관이 해제되었습니다", "message.task_unarchived": "작업 보관이 해제되었습니다",
"message.completion_deleted": "완료 기록이 삭제되었습니다", "message.completion_deleted": "완료 기록이 삭제되었습니다",
"message.residence_deleted": "주거지가 삭제되었습니다", "message.residence_deleted": "주거지가 삭제되었습니다",
"message.user_removed": "주거지에서 사용자가 제거되었습니다", "message.user_removed": "주거지에서 사용자가 제거되었습니다",
"message.tasks_report_generated": "작업 보고서가 생성되었습니다", "message.tasks_report_generated": "작업 보고서가 생성되었습니다",
"message.tasks_report_sent": "작업 보고서가 생성되어 {{.Email}}로 발송되었습니다", "message.tasks_report_sent": "작업 보고서가 생성되어 {{.Email}}로 발송되었습니다",
"message.tasks_report_email_failed": "작업 보고서가 생성되었지만 이메일 발송에 실패했습니다", "message.tasks_report_email_failed": "작업 보고서가 생성되었지만 이메일 발송에 실패했습니다",
"message.contractor_deleted": "계약업체가 삭제되었습니다", "message.contractor_deleted": "계약업체가 삭제되었습니다",
"message.document_deleted": "문서가 삭제되었습니다", "message.document_deleted": "문서가 삭제되었습니다",
"message.document_activated": "문서가 활성화되었습니다", "message.document_activated": "문서가 활성화되었습니다",
"message.document_deactivated": "문서가 비활성화되었습니다", "message.document_deactivated": "문서가 비활성화되었습니다",
"message.notification_marked_read": "알림이 읽음으로 표시되었습니다", "message.notification_marked_read": "알림이 읽음으로 표시되었습니다",
"message.all_notifications_marked_read": "모든 알림이 읽음으로 표시되었습니다", "message.all_notifications_marked_read": "모든 알림이 읽음으로 표시되었습니다",
"message.device_removed": "기기가 제거되었습니다", "message.device_removed": "기기가 제거되었습니다",
"message.subscription_upgraded": "구독이 업그레이드되었습니다", "message.subscription_upgraded": "구독이 업그레이드되었습니다",
"message.subscription_cancelled": "구독이 취소되었습니다. 결제 기간이 종료될 때까지 Pro 혜택을 유지하실 수 있습니다.", "message.subscription_cancelled": "구독이 취소되었습니다. 결제 기간이 종료될 때까지 Pro 혜택을 유지하실 수 있습니다.",
"message.subscription_restored": "구독이 복원되었습니다", "message.subscription_restored": "구독이 복원되었습니다",
"message.file_deleted": "파일이 삭제되었습니다", "message.file_deleted": "파일이 삭제되었습니다",
"message.static_data_refreshed": "정적 데이터가 새로고침되었습니다", "message.static_data_refreshed": "정적 데이터가 새로고침되었습니다",
"error.notification_not_found": "알림을 찾을 수 없습니다", "error.notification_not_found": "알림을 찾을 수 없습니다",
"error.invalid_platform": "유효하지 않은 플랫폼입니다", "error.invalid_platform": "유효하지 않은 플랫폼입니다",
"error.upgrade_trigger_not_found": "업그레이드 트리거를 찾을 수 없습니다", "error.upgrade_trigger_not_found": "업그레이드 트리거를 찾을 수 없습니다",
"error.receipt_data_required": "iOS의 경우 receipt_data가 필요합니다", "error.receipt_data_required": "iOS의 경우 receipt_data가 필요합니다",
"error.purchase_token_required": "Android의 경우 purchase_token이 필요합니다", "error.purchase_token_required": "Android의 경우 purchase_token이 필요합니다",
"error.no_file_provided": "파일이 제공되지 않았습니다", "error.no_file_provided": "파일이 제공되지 않았습니다",
"error.failed_to_fetch_residence_types": "주거지 유형을 가져오는데 실패했습니다", "error.failed_to_fetch_residence_types": "주거지 유형을 가져오는데 실패했습니다",
"error.failed_to_fetch_task_categories": "작업 카테고리를 가져오는데 실패했습니다", "error.failed_to_fetch_task_categories": "작업 카테고리를 가져오는데 실패했습니다",
"error.failed_to_fetch_task_priorities": "작업 우선순위를 가져오는데 실패했습니다", "error.failed_to_fetch_task_priorities": "작업 우선순위를 가져오는데 실패했습니다",
"error.failed_to_fetch_task_frequencies": "작업 빈도를 가져오는데 실패했습니다", "error.failed_to_fetch_task_frequencies": "작업 빈도를 가져오는데 실패했습니다",
"error.failed_to_fetch_task_statuses": "작업 상태를 가져오는데 실패했습니다", "error.failed_to_fetch_task_statuses": "작업 상태를 가져오는데 실패했습니다",
"error.failed_to_fetch_contractor_specialties": "계약업체 전문 분야를 가져오는데 실패했습니다", "error.failed_to_fetch_contractor_specialties": "계약업체 전문 분야를 가져오는데 실패했습니다",
"push.task_due_soon.title": "작업 마감 임박", "push.task_due_soon.title": "작업 마감 임박",
"push.task_due_soon.body": "{{.TaskTitle}}의 마감일은 {{.DueDate}}입니다", "push.task_due_soon.body": "{{.TaskTitle}}의 마감일은 {{.DueDate}}입니다",
"push.task_overdue.title": "지연된 작업", "push.task_overdue.title": "지연된 작업",
@@ -129,23 +111,20 @@
"push.task_assigned.body": "{{.TaskTitle}}이(가) 할당되었습니다", "push.task_assigned.body": "{{.TaskTitle}}이(가) 할당되었습니다",
"push.residence_shared.title": "주거지 공유", "push.residence_shared.title": "주거지 공유",
"push.residence_shared.body": "{{.UserName}}님이 {{.ResidenceName}}을(를) 공유했습니다", "push.residence_shared.body": "{{.UserName}}님이 {{.ResidenceName}}을(를) 공유했습니다",
"email.welcome.subject": "honeyDue에 오신 것을 환영합니다!", "email.welcome.subject": "honeyDue에 오신 것을 환영합니다!",
"email.verification.subject": "이메일 인증", "email.verification.subject": "이메일 인증",
"email.password_reset.subject": "비밀번호 재설정 코드", "email.password_reset.subject": "비밀번호 재설정 코드",
"email.tasks_report.subject": "{{.ResidenceName}} 작업 보고서", "email.tasks_report.subject": "{{.ResidenceName}} 작업 보고서",
"lookup.residence_type.house": "주택",
"lookup.residence_type.house": "단독주택",
"lookup.residence_type.apartment": "아파트", "lookup.residence_type.apartment": "아파트",
"lookup.residence_type.condo": "콘도", "lookup.residence_type.condo": "콘도",
"lookup.residence_type.townhouse": "타운하우스", "lookup.residence_type.townhouse": "타운하우스",
"lookup.residence_type.mobile_home": "이동식 주택", "lookup.residence_type.mobile_home": "이동식 주택",
"lookup.residence_type.other": "기타", "lookup.residence_type.other": "기타",
"lookup.task_category.plumbing": "배관", "lookup.task_category.plumbing": "배관",
"lookup.task_category.electrical": "전기", "lookup.task_category.electrical": "전기",
"lookup.task_category.hvac": "냉난방", "lookup.task_category.hvac": "냉난방",
"lookup.task_category.appliances": "가전제품", "lookup.task_category.appliances": "가전",
"lookup.task_category.exterior": "외부", "lookup.task_category.exterior": "외부",
"lookup.task_category.interior": "내부", "lookup.task_category.interior": "내부",
"lookup.task_category.landscaping": "조경", "lookup.task_category.landscaping": "조경",
@@ -154,18 +133,15 @@
"lookup.task_category.pest_control": "해충 방제", "lookup.task_category.pest_control": "해충 방제",
"lookup.task_category.seasonal": "계절별", "lookup.task_category.seasonal": "계절별",
"lookup.task_category.other": "기타", "lookup.task_category.other": "기타",
"lookup.task_priority.low": "낮음", "lookup.task_priority.low": "낮음",
"lookup.task_priority.medium": "보통", "lookup.task_priority.medium": "보통",
"lookup.task_priority.high": "높음", "lookup.task_priority.high": "높음",
"lookup.task_priority.urgent": "긴급", "lookup.task_priority.urgent": "긴급",
"lookup.task_status.pending": "대기 중", "lookup.task_status.pending": "대기 중",
"lookup.task_status.in_progress": "진행 중", "lookup.task_status.in_progress": "진행 중",
"lookup.task_status.completed": "완료", "lookup.task_status.completed": "완료",
"lookup.task_status.cancelled": "취소됨", "lookup.task_status.cancelled": "취소됨",
"lookup.task_status.archived": "보관됨", "lookup.task_status.archived": "보관됨",
"lookup.task_frequency.once": "한 번", "lookup.task_frequency.once": "한 번",
"lookup.task_frequency.daily": "매일", "lookup.task_frequency.daily": "매일",
"lookup.task_frequency.weekly": "매주", "lookup.task_frequency.weekly": "매주",
@@ -174,18 +150,98 @@
"lookup.task_frequency.quarterly": "분기별", "lookup.task_frequency.quarterly": "분기별",
"lookup.task_frequency.semiannually": "6개월마다", "lookup.task_frequency.semiannually": "6개월마다",
"lookup.task_frequency.annually": "매년", "lookup.task_frequency.annually": "매년",
"lookup.contractor_specialty.plumber": "배관공", "lookup.contractor_specialty.plumber": "배관공",
"lookup.contractor_specialty.electrician": "전기 기사", "lookup.contractor_specialty.electrician": "전기 기사",
"lookup.contractor_specialty.hvac_technician": "냉난방 기사", "lookup.contractor_specialty.hvac_technician": "냉난방 기사",
"lookup.contractor_specialty.handyman": "리공", "lookup.contractor_specialty.handyman": "리공",
"lookup.contractor_specialty.landscaper": "조경사", "lookup.contractor_specialty.landscaper": "조경사",
"lookup.contractor_specialty.roofer": "지붕", "lookup.contractor_specialty.roofer": "지붕 기술자",
"lookup.contractor_specialty.painter": "도배공", "lookup.contractor_specialty.painter": "페인터",
"lookup.contractor_specialty.carpenter": "목수", "lookup.contractor_specialty.carpenter": "목수",
"lookup.contractor_specialty.pest_control": "해충 방제", "lookup.contractor_specialty.pest_control": "해충 방제",
"lookup.contractor_specialty.cleaning": "청소", "lookup.contractor_specialty.cleaning": "청소",
"lookup.contractor_specialty.pool_service": "수영장 관리", "lookup.contractor_specialty.pool_service": "수영장 서비스",
"lookup.contractor_specialty.general_contractor": "종합 건설업", "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": "기타"
} }
+93 -37
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "Google Sign In is niet geconfigureerd", "error.google_signin_not_configured": "Google Sign In is niet geconfigureerd",
"error.google_signin_failed": "Google Sign In mislukt", "error.google_signin_failed": "Google Sign In mislukt",
"error.invalid_google_token": "Ongeldig Google identiteitstoken", "error.invalid_google_token": "Ongeldig Google identiteitstoken",
"error.invalid_task_id": "Ongeldig taak-ID", "error.invalid_task_id": "Ongeldig taak-ID",
"error.invalid_residence_id": "Ongeldig woning-ID", "error.invalid_residence_id": "Ongeldig woning-ID",
"error.invalid_contractor_id": "Ongeldig aannemer-ID", "error.invalid_contractor_id": "Ongeldig aannemer-ID",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "Ongeldig gebruikers-ID", "error.invalid_user_id": "Ongeldig gebruikers-ID",
"error.invalid_notification_id": "Ongeldig notificatie-ID", "error.invalid_notification_id": "Ongeldig notificatie-ID",
"error.invalid_device_id": "Ongeldig apparaat-ID", "error.invalid_device_id": "Ongeldig apparaat-ID",
"error.task_not_found": "Taak niet gevonden", "error.task_not_found": "Taak niet gevonden",
"error.residence_not_found": "Woning niet gevonden", "error.residence_not_found": "Woning niet gevonden",
"error.contractor_not_found": "Aannemer niet gevonden", "error.contractor_not_found": "Aannemer niet gevonden",
@@ -43,7 +41,6 @@
"error.user_not_found": "Gebruiker niet gevonden", "error.user_not_found": "Gebruiker niet gevonden",
"error.share_code_invalid": "Ongeldige deelcode", "error.share_code_invalid": "Ongeldige deelcode",
"error.share_code_expired": "Deelcode is verlopen", "error.share_code_expired": "Deelcode is verlopen",
"error.task_access_denied": "U heeft geen toegang tot deze taak", "error.task_access_denied": "U heeft geen toegang tot deze taak",
"error.residence_access_denied": "U heeft geen toegang tot deze woning", "error.residence_access_denied": "U heeft geen toegang tot deze woning",
"error.contractor_access_denied": "U heeft geen toegang tot deze aannemer", "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.cannot_remove_owner": "Kan de woningeigenaar niet verwijderen",
"error.user_already_member": "Gebruiker is al lid van deze woning", "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.properties_limit_reached": "U heeft het maximale aantal woningen voor uw abonnement bereikt",
"error.task_already_cancelled": "Taak is al geannuleerd", "error.task_already_cancelled": "Taak is al geannuleerd",
"error.task_already_archived": "Taak is al gearchiveerd", "error.task_already_archived": "Taak is al gearchiveerd",
"error.failed_to_parse_form": "Multipart formulier parsen mislukt", "error.failed_to_parse_form": "Multipart formulier parsen mislukt",
"error.task_id_required": "task_id is verplicht", "error.task_id_required": "task_id is verplicht",
"error.invalid_task_id_value": "Ongeldig task_id", "error.invalid_task_id_value": "Ongeldig task_id",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "Ongeldig residence_id", "error.invalid_residence_id_value": "Ongeldig residence_id",
"error.title_required": "titel is verplicht", "error.title_required": "titel is verplicht",
"error.failed_to_upload_file": "Bestand uploaden mislukt", "error.failed_to_upload_file": "Bestand uploaden mislukt",
"message.logged_out": "Succesvol uitgelogd", "message.logged_out": "Succesvol uitgelogd",
"message.email_verified": "E-mailadres succesvol geverifieerd", "message.email_verified": "E-mailadres succesvol geverifieerd",
"message.verification_email_sent": "Verificatie e-mail verzonden", "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.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.reset_code_verified": "Code succesvol geverifieerd",
"message.password_reset_success": "Wachtwoord succesvol gereset. Log in met uw nieuwe wachtwoord.", "message.password_reset_success": "Wachtwoord succesvol gereset. Log in met uw nieuwe wachtwoord.",
"message.task_deleted": "Taak succesvol verwijderd", "message.task_deleted": "Taak succesvol verwijderd",
"message.task_in_progress": "Taak gemarkeerd als in uitvoering", "message.task_in_progress": "Taak gemarkeerd als in uitvoering",
"message.task_cancelled": "Taak geannuleerd", "message.task_cancelled": "Taak geannuleerd",
@@ -79,46 +72,35 @@
"message.task_archived": "Taak gearchiveerd", "message.task_archived": "Taak gearchiveerd",
"message.task_unarchived": "Taak gearchiveerd ongedaan gemaakt", "message.task_unarchived": "Taak gearchiveerd ongedaan gemaakt",
"message.completion_deleted": "Voltooiing succesvol verwijderd", "message.completion_deleted": "Voltooiing succesvol verwijderd",
"message.residence_deleted": "Woning succesvol verwijderd", "message.residence_deleted": "Woning succesvol verwijderd",
"message.user_removed": "Gebruiker verwijderd van woning", "message.user_removed": "Gebruiker verwijderd van woning",
"message.tasks_report_generated": "Takenrapport succesvol gegenereerd", "message.tasks_report_generated": "Takenrapport succesvol gegenereerd",
"message.tasks_report_sent": "Takenrapport gegenereerd en verzonden naar {{.Email}}", "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.tasks_report_email_failed": "Takenrapport gegenereerd maar e-mail kon niet worden verzonden",
"message.contractor_deleted": "Aannemer succesvol verwijderd", "message.contractor_deleted": "Aannemer succesvol verwijderd",
"message.document_deleted": "Document succesvol verwijderd", "message.document_deleted": "Document succesvol verwijderd",
"message.document_activated": "Document geactiveerd", "message.document_activated": "Document geactiveerd",
"message.document_deactivated": "Document gedeactiveerd", "message.document_deactivated": "Document gedeactiveerd",
"message.notification_marked_read": "Notificatie gemarkeerd als gelezen", "message.notification_marked_read": "Notificatie gemarkeerd als gelezen",
"message.all_notifications_marked_read": "Alle notificaties gemarkeerd als gelezen", "message.all_notifications_marked_read": "Alle notificaties gemarkeerd als gelezen",
"message.device_removed": "Apparaat verwijderd", "message.device_removed": "Apparaat verwijderd",
"message.subscription_upgraded": "Abonnement succesvol geüpgraded", "message.subscription_upgraded": "Abonnement succesvol geüpgraded",
"message.subscription_cancelled": "Abonnement geannuleerd. U behoudt Pro voordelen tot het einde van uw factureringsperiode.", "message.subscription_cancelled": "Abonnement geannuleerd. U behoudt Pro voordelen tot het einde van uw factureringsperiode.",
"message.subscription_restored": "Abonnement succesvol hersteld", "message.subscription_restored": "Abonnement succesvol hersteld",
"message.file_deleted": "Bestand succesvol verwijderd", "message.file_deleted": "Bestand succesvol verwijderd",
"message.static_data_refreshed": "Statische gegevens vernieuwd", "message.static_data_refreshed": "Statische gegevens vernieuwd",
"error.notification_not_found": "Notificatie niet gevonden", "error.notification_not_found": "Notificatie niet gevonden",
"error.invalid_platform": "Ongeldig platform", "error.invalid_platform": "Ongeldig platform",
"error.upgrade_trigger_not_found": "Upgrade trigger niet gevonden", "error.upgrade_trigger_not_found": "Upgrade trigger niet gevonden",
"error.receipt_data_required": "receipt_data is verplicht voor iOS", "error.receipt_data_required": "receipt_data is verplicht voor iOS",
"error.purchase_token_required": "purchase_token is verplicht voor Android", "error.purchase_token_required": "purchase_token is verplicht voor Android",
"error.no_file_provided": "Geen bestand aangeleverd", "error.no_file_provided": "Geen bestand aangeleverd",
"error.failed_to_fetch_residence_types": "Woningtypes ophalen mislukt", "error.failed_to_fetch_residence_types": "Woningtypes ophalen mislukt",
"error.failed_to_fetch_task_categories": "Taakcategorieën 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_priorities": "Taakprioriteiten ophalen mislukt",
"error.failed_to_fetch_task_frequencies": "Taakfrequenties ophalen mislukt", "error.failed_to_fetch_task_frequencies": "Taakfrequenties ophalen mislukt",
"error.failed_to_fetch_task_statuses": "Taakstatussen ophalen mislukt", "error.failed_to_fetch_task_statuses": "Taakstatussen ophalen mislukt",
"error.failed_to_fetch_contractor_specialties": "Aannemer specialiteiten ophalen mislukt", "error.failed_to_fetch_contractor_specialties": "Aannemer specialiteiten ophalen mislukt",
"push.task_due_soon.title": "Taak Vervalt Binnenkort", "push.task_due_soon.title": "Taak Vervalt Binnenkort",
"push.task_due_soon.body": "{{.TaskTitle}} vervalt {{.DueDate}}", "push.task_due_soon.body": "{{.TaskTitle}} vervalt {{.DueDate}}",
"push.task_overdue.title": "Verlopen Taak", "push.task_overdue.title": "Verlopen Taak",
@@ -129,55 +111,48 @@
"push.task_assigned.body": "U bent toegewezen aan {{.TaskTitle}}", "push.task_assigned.body": "U bent toegewezen aan {{.TaskTitle}}",
"push.residence_shared.title": "Woning Gedeeld", "push.residence_shared.title": "Woning Gedeeld",
"push.residence_shared.body": "{{.UserName}} heeft {{.ResidenceName}} met u gedeeld", "push.residence_shared.body": "{{.UserName}} heeft {{.ResidenceName}} met u gedeeld",
"email.welcome.subject": "Welkom bij honeyDue!", "email.welcome.subject": "Welkom bij honeyDue!",
"email.verification.subject": "Verifieer Uw E-mailadres", "email.verification.subject": "Verifieer Uw E-mailadres",
"email.password_reset.subject": "Wachtwoord Resetcode", "email.password_reset.subject": "Wachtwoord Resetcode",
"email.tasks_report.subject": "Takenrapport voor {{.ResidenceName}}", "email.tasks_report.subject": "Takenrapport voor {{.ResidenceName}}",
"lookup.residence_type.house": "Huis", "lookup.residence_type.house": "Huis",
"lookup.residence_type.apartment": "Appartement", "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.townhouse": "Rijtjeshuis",
"lookup.residence_type.mobile_home": "Stacaravan", "lookup.residence_type.mobile_home": "Stacaravan",
"lookup.residence_type.other": "Anders", "lookup.residence_type.other": "Overig",
"lookup.task_category.plumbing": "Loodgieterswerk",
"lookup.task_category.plumbing": "Loodgieterij", "lookup.task_category.electrical": "Elektrisch",
"lookup.task_category.electrical": "Elektriciteit", "lookup.task_category.hvac": "HVAC",
"lookup.task_category.hvac": "Verwarming en Ventilatie",
"lookup.task_category.appliances": "Apparaten", "lookup.task_category.appliances": "Apparaten",
"lookup.task_category.exterior": "Buitenkant", "lookup.task_category.exterior": "Buiten",
"lookup.task_category.interior": "Binnenkant", "lookup.task_category.interior": "Binnen",
"lookup.task_category.landscaping": "Tuinonderhoud", "lookup.task_category.landscaping": "Tuinonderhoud",
"lookup.task_category.safety": "Veiligheid", "lookup.task_category.safety": "Veiligheid",
"lookup.task_category.cleaning": "Schoonmaak", "lookup.task_category.cleaning": "Schoonmaak",
"lookup.task_category.pest_control": "Ongediertebestrijding", "lookup.task_category.pest_control": "Ongediertebestrijding",
"lookup.task_category.seasonal": "Seizoensgebonden", "lookup.task_category.seasonal": "Seizoensgebonden",
"lookup.task_category.other": "Anders", "lookup.task_category.other": "Anders",
"lookup.task_priority.low": "Laag", "lookup.task_priority.low": "Laag",
"lookup.task_priority.medium": "Gemiddeld", "lookup.task_priority.medium": "Gemiddeld",
"lookup.task_priority.high": "Hoog", "lookup.task_priority.high": "Hoog",
"lookup.task_priority.urgent": "Urgent", "lookup.task_priority.urgent": "Urgent",
"lookup.task_status.pending": "In afwachting", "lookup.task_status.pending": "In afwachting",
"lookup.task_status.in_progress": "In uitvoering", "lookup.task_status.in_progress": "In uitvoering",
"lookup.task_status.completed": "Voltooid", "lookup.task_status.completed": "Voltooid",
"lookup.task_status.cancelled": "Geannuleerd", "lookup.task_status.cancelled": "Geannuleerd",
"lookup.task_status.archived": "Gearchiveerd", "lookup.task_status.archived": "Gearchiveerd",
"lookup.task_frequency.once": "Eenmalig", "lookup.task_frequency.once": "Eenmalig",
"lookup.task_frequency.daily": "Dagelijks", "lookup.task_frequency.daily": "Dagelijks",
"lookup.task_frequency.weekly": "Wekelijks", "lookup.task_frequency.weekly": "Wekelijks",
"lookup.task_frequency.biweekly": "Om de 2 Weken", "lookup.task_frequency.biweekly": "Om de 2 Weken",
"lookup.task_frequency.monthly": "Maandelijks", "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.semiannually": "Om de 6 Maanden",
"lookup.task_frequency.annually": "Jaarlijks", "lookup.task_frequency.annually": "Jaarlijks",
"lookup.contractor_specialty.plumber": "Loodgieter", "lookup.contractor_specialty.plumber": "Loodgieter",
"lookup.contractor_specialty.electrician": "Elektricien", "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.handyman": "Klusjesman",
"lookup.contractor_specialty.landscaper": "Hovenier", "lookup.contractor_specialty.landscaper": "Hovenier",
"lookup.contractor_specialty.roofer": "Dakdekker", "lookup.contractor_specialty.roofer": "Dakdekker",
@@ -185,7 +160,88 @@
"lookup.contractor_specialty.carpenter": "Timmerman", "lookup.contractor_specialty.carpenter": "Timmerman",
"lookup.contractor_specialty.pest_control": "Ongediertebestrijding", "lookup.contractor_specialty.pest_control": "Ongediertebestrijding",
"lookup.contractor_specialty.cleaning": "Schoonmaak", "lookup.contractor_specialty.cleaning": "Schoonmaak",
"lookup.contractor_specialty.pool_service": "Zwembadonderhoud", "lookup.contractor_specialty.pool_service": "Zwembadservice",
"lookup.contractor_specialty.general_contractor": "Algemeen Aannemer", "lookup.contractor_specialty.general_contractor": "Hoofdaannemer",
"lookup.contractor_specialty.other": "Anders" "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"
} }
+97 -41
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "O login com Google nao esta configurado", "error.google_signin_not_configured": "O login com Google nao esta configurado",
"error.google_signin_failed": "Falha no login com Google", "error.google_signin_failed": "Falha no login com Google",
"error.invalid_google_token": "Token de identidade Google invalido", "error.invalid_google_token": "Token de identidade Google invalido",
"error.invalid_task_id": "ID da tarefa invalido", "error.invalid_task_id": "ID da tarefa invalido",
"error.invalid_residence_id": "ID da propriedade invalido", "error.invalid_residence_id": "ID da propriedade invalido",
"error.invalid_contractor_id": "ID do prestador invalido", "error.invalid_contractor_id": "ID do prestador invalido",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "ID do usuario invalido", "error.invalid_user_id": "ID do usuario invalido",
"error.invalid_notification_id": "ID da notificacao invalido", "error.invalid_notification_id": "ID da notificacao invalido",
"error.invalid_device_id": "ID do dispositivo invalido", "error.invalid_device_id": "ID do dispositivo invalido",
"error.task_not_found": "Tarefa nao encontrada", "error.task_not_found": "Tarefa nao encontrada",
"error.residence_not_found": "Propriedade nao encontrada", "error.residence_not_found": "Propriedade nao encontrada",
"error.contractor_not_found": "Prestador nao encontrado", "error.contractor_not_found": "Prestador nao encontrado",
@@ -43,7 +41,6 @@
"error.user_not_found": "Usuario nao encontrado", "error.user_not_found": "Usuario nao encontrado",
"error.share_code_invalid": "Codigo de compartilhamento invalido", "error.share_code_invalid": "Codigo de compartilhamento invalido",
"error.share_code_expired": "O codigo de compartilhamento expirou", "error.share_code_expired": "O codigo de compartilhamento expirou",
"error.task_access_denied": "Voce nao tem acesso a esta tarefa", "error.task_access_denied": "Voce nao tem acesso a esta tarefa",
"error.residence_access_denied": "Voce nao tem acesso a esta propriedade", "error.residence_access_denied": "Voce nao tem acesso a esta propriedade",
"error.contractor_access_denied": "Voce nao tem acesso a este prestador", "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.cannot_remove_owner": "Nao e possivel remover o proprietario",
"error.user_already_member": "O usuario ja e membro desta propriedade", "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.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_cancelled": "A tarefa ja esta cancelada",
"error.task_already_archived": "A tarefa ja esta arquivada", "error.task_already_archived": "A tarefa ja esta arquivada",
"error.failed_to_parse_form": "Falha ao analisar o formulario", "error.failed_to_parse_form": "Falha ao analisar o formulario",
"error.task_id_required": "task_id e obrigatorio", "error.task_id_required": "task_id e obrigatorio",
"error.invalid_task_id_value": "task_id invalido", "error.invalid_task_id_value": "task_id invalido",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id invalido", "error.invalid_residence_id_value": "residence_id invalido",
"error.title_required": "Titulo e obrigatorio", "error.title_required": "Titulo e obrigatorio",
"error.failed_to_upload_file": "Falha ao enviar arquivo", "error.failed_to_upload_file": "Falha ao enviar arquivo",
"message.logged_out": "Logout realizado com sucesso", "message.logged_out": "Logout realizado com sucesso",
"message.email_verified": "Email verificado com sucesso", "message.email_verified": "Email verificado com sucesso",
"message.verification_email_sent": "Email de verificacao enviado", "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.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.reset_code_verified": "Codigo verificado com sucesso",
"message.password_reset_success": "Senha redefinida com sucesso. Por favor, faca login com sua nova senha.", "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_deleted": "Tarefa excluida com sucesso",
"message.task_in_progress": "Tarefa marcada como em andamento", "message.task_in_progress": "Tarefa marcada como em andamento",
"message.task_cancelled": "Tarefa cancelada", "message.task_cancelled": "Tarefa cancelada",
@@ -79,46 +72,35 @@
"message.task_archived": "Tarefa arquivada", "message.task_archived": "Tarefa arquivada",
"message.task_unarchived": "Tarefa desarquivada", "message.task_unarchived": "Tarefa desarquivada",
"message.completion_deleted": "Conclusao excluida com sucesso", "message.completion_deleted": "Conclusao excluida com sucesso",
"message.residence_deleted": "Propriedade excluida com sucesso", "message.residence_deleted": "Propriedade excluida com sucesso",
"message.user_removed": "Usuario removido da propriedade", "message.user_removed": "Usuario removido da propriedade",
"message.tasks_report_generated": "Relatorio de tarefas gerado com sucesso", "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_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.tasks_report_email_failed": "Relatorio de tarefas gerado mas o email nao pode ser enviado",
"message.contractor_deleted": "Prestador excluido com sucesso", "message.contractor_deleted": "Prestador excluido com sucesso",
"message.document_deleted": "Documento excluido com sucesso", "message.document_deleted": "Documento excluido com sucesso",
"message.document_activated": "Documento ativado", "message.document_activated": "Documento ativado",
"message.document_deactivated": "Documento desativado", "message.document_deactivated": "Documento desativado",
"message.notification_marked_read": "Notificação marcada como lida", "message.notification_marked_read": "Notificação marcada como lida",
"message.all_notifications_marked_read": "Todas as notificações marcadas como lidas", "message.all_notifications_marked_read": "Todas as notificações marcadas como lidas",
"message.device_removed": "Dispositivo removido", "message.device_removed": "Dispositivo removido",
"message.subscription_upgraded": "Assinatura atualizada com sucesso", "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_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.subscription_restored": "Assinatura restaurada com sucesso",
"message.file_deleted": "Arquivo excluído com sucesso", "message.file_deleted": "Arquivo excluído com sucesso",
"message.static_data_refreshed": "Dados estáticos atualizados", "message.static_data_refreshed": "Dados estáticos atualizados",
"error.notification_not_found": "Notificação não encontrada", "error.notification_not_found": "Notificação não encontrada",
"error.invalid_platform": "Plataforma inválida", "error.invalid_platform": "Plataforma inválida",
"error.upgrade_trigger_not_found": "Gatilho de atualização não encontrado", "error.upgrade_trigger_not_found": "Gatilho de atualização não encontrado",
"error.receipt_data_required": "receipt_data é obrigatório para iOS", "error.receipt_data_required": "receipt_data é obrigatório para iOS",
"error.purchase_token_required": "purchase_token é obrigatório para Android", "error.purchase_token_required": "purchase_token é obrigatório para Android",
"error.no_file_provided": "Nenhum arquivo fornecido", "error.no_file_provided": "Nenhum arquivo fornecido",
"error.failed_to_fetch_residence_types": "Falha ao buscar tipos de propriedade", "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_categories": "Falha ao buscar categorias de tarefas",
"error.failed_to_fetch_task_priorities": "Falha ao buscar prioridades 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_frequencies": "Falha ao buscar frequências de tarefas",
"error.failed_to_fetch_task_statuses": "Falha ao buscar status 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", "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.title": "Tarefa Proxima do Vencimento",
"push.task_due_soon.body": "{{.TaskTitle}} vence em {{.DueDate}}", "push.task_due_soon.body": "{{.TaskTitle}} vence em {{.DueDate}}",
"push.task_overdue.title": "Tarefa Atrasada", "push.task_overdue.title": "Tarefa Atrasada",
@@ -129,63 +111,137 @@
"push.task_assigned.body": "{{.TaskTitle}} foi atribuida a voce", "push.task_assigned.body": "{{.TaskTitle}} foi atribuida a voce",
"push.residence_shared.title": "Propriedade Compartilhada", "push.residence_shared.title": "Propriedade Compartilhada",
"push.residence_shared.body": "{{.UserName}} compartilhou {{.ResidenceName}} com voce", "push.residence_shared.body": "{{.UserName}} compartilhou {{.ResidenceName}} com voce",
"email.welcome.subject": "Bem-vindo ao honeyDue!", "email.welcome.subject": "Bem-vindo ao honeyDue!",
"email.verification.subject": "Verifique Seu Email", "email.verification.subject": "Verifique Seu Email",
"email.password_reset.subject": "Codigo de Redefinicao de Senha", "email.password_reset.subject": "Codigo de Redefinicao de Senha",
"email.tasks_report.subject": "Relatorio de Tarefas para {{.ResidenceName}}", "email.tasks_report.subject": "Relatorio de Tarefas para {{.ResidenceName}}",
"lookup.residence_type.house": "Casa", "lookup.residence_type.house": "Casa",
"lookup.residence_type.apartment": "Apartamento", "lookup.residence_type.apartment": "Apartamento",
"lookup.residence_type.condo": "Condominio", "lookup.residence_type.condo": "Condomínio",
"lookup.residence_type.townhouse": "Sobrado", "lookup.residence_type.townhouse": "Sobrado",
"lookup.residence_type.mobile_home": "Casa Movel", "lookup.residence_type.mobile_home": "Casa vel",
"lookup.residence_type.other": "Outro", "lookup.residence_type.other": "Outro",
"lookup.task_category.plumbing": "Encanamento", "lookup.task_category.plumbing": "Encanamento",
"lookup.task_category.electrical": "Eletrica", "lookup.task_category.electrical": "Elétrica",
"lookup.task_category.hvac": "Climatizacao", "lookup.task_category.hvac": "AVAC",
"lookup.task_category.appliances": "Eletrodomesticos", "lookup.task_category.appliances": "Eletrodomésticos",
"lookup.task_category.exterior": "Exterior", "lookup.task_category.exterior": "Exterior",
"lookup.task_category.interior": "Interior", "lookup.task_category.interior": "Interior",
"lookup.task_category.landscaping": "Paisagismo", "lookup.task_category.landscaping": "Paisagismo",
"lookup.task_category.safety": "Seguranca", "lookup.task_category.safety": "Segurança",
"lookup.task_category.cleaning": "Limpeza", "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.seasonal": "Sazonal",
"lookup.task_category.other": "Outro", "lookup.task_category.other": "Outro",
"lookup.task_priority.low": "Baixa", "lookup.task_priority.low": "Baixa",
"lookup.task_priority.medium": "Media", "lookup.task_priority.medium": "Média",
"lookup.task_priority.high": "Alta", "lookup.task_priority.high": "Alta",
"lookup.task_priority.urgent": "Urgente", "lookup.task_priority.urgent": "Urgente",
"lookup.task_status.pending": "Pendente", "lookup.task_status.pending": "Pendente",
"lookup.task_status.in_progress": "Em Andamento", "lookup.task_status.in_progress": "Em Andamento",
"lookup.task_status.completed": "Concluida", "lookup.task_status.completed": "Concluida",
"lookup.task_status.cancelled": "Cancelada", "lookup.task_status.cancelled": "Cancelada",
"lookup.task_status.archived": "Arquivada", "lookup.task_status.archived": "Arquivada",
"lookup.task_frequency.once": "Uma vez",
"lookup.task_frequency.once": "Uma Vez", "lookup.task_frequency.daily": "Diário",
"lookup.task_frequency.daily": "Diario",
"lookup.task_frequency.weekly": "Semanal", "lookup.task_frequency.weekly": "Semanal",
"lookup.task_frequency.biweekly": "Quinzenal", "lookup.task_frequency.biweekly": "Quinzenal",
"lookup.task_frequency.monthly": "Mensal", "lookup.task_frequency.monthly": "Mensal",
"lookup.task_frequency.quarterly": "Trimestral", "lookup.task_frequency.quarterly": "Trimestral",
"lookup.task_frequency.semiannually": "Semestral", "lookup.task_frequency.semiannually": "Semestral",
"lookup.task_frequency.annually": "Anual", "lookup.task_frequency.annually": "Anual",
"lookup.contractor_specialty.plumber": "Encanador", "lookup.contractor_specialty.plumber": "Encanador",
"lookup.contractor_specialty.electrician": "Eletricista", "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.handyman": "Faz-tudo",
"lookup.contractor_specialty.landscaper": "Paisagista", "lookup.contractor_specialty.landscaper": "Jardineiro",
"lookup.contractor_specialty.roofer": "Telhadista", "lookup.contractor_specialty.roofer": "Telhadista",
"lookup.contractor_specialty.painter": "Pintor", "lookup.contractor_specialty.painter": "Pintor",
"lookup.contractor_specialty.carpenter": "Carpinteiro", "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.cleaning": "Limpeza",
"lookup.contractor_specialty.pool_service": "Servico de Piscina", "lookup.contractor_specialty.pool_service": "Serviço de piscina",
"lookup.contractor_specialty.general_contractor": "Empreiteiro Geral", "lookup.contractor_specialty.general_contractor": "Empreiteiro geral",
"lookup.contractor_specialty.other": "Outro" "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"
} }
+85 -29
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "未配置 Google 登录", "error.google_signin_not_configured": "未配置 Google 登录",
"error.google_signin_failed": "Google 登录失败", "error.google_signin_failed": "Google 登录失败",
"error.invalid_google_token": "Google 身份令牌无效", "error.invalid_google_token": "Google 身份令牌无效",
"error.invalid_task_id": "任务 ID 无效", "error.invalid_task_id": "任务 ID 无效",
"error.invalid_residence_id": "房产 ID 无效", "error.invalid_residence_id": "房产 ID 无效",
"error.invalid_contractor_id": "承包商 ID 无效", "error.invalid_contractor_id": "承包商 ID 无效",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "用户 ID 无效", "error.invalid_user_id": "用户 ID 无效",
"error.invalid_notification_id": "通知 ID 无效", "error.invalid_notification_id": "通知 ID 无效",
"error.invalid_device_id": "设备 ID 无效", "error.invalid_device_id": "设备 ID 无效",
"error.task_not_found": "未找到任务", "error.task_not_found": "未找到任务",
"error.residence_not_found": "未找到房产", "error.residence_not_found": "未找到房产",
"error.contractor_not_found": "未找到承包商", "error.contractor_not_found": "未找到承包商",
@@ -43,7 +41,6 @@
"error.user_not_found": "未找到用户", "error.user_not_found": "未找到用户",
"error.share_code_invalid": "分享码无效", "error.share_code_invalid": "分享码无效",
"error.share_code_expired": "分享码已过期", "error.share_code_expired": "分享码已过期",
"error.task_access_denied": "您无权访问此任务", "error.task_access_denied": "您无权访问此任务",
"error.residence_access_denied": "您无权访问此房产", "error.residence_access_denied": "您无权访问此房产",
"error.contractor_access_denied": "您无权访问此承包商", "error.contractor_access_denied": "您无权访问此承包商",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "无法移除房产所有者", "error.cannot_remove_owner": "无法移除房产所有者",
"error.user_already_member": "用户已是此房产的成员", "error.user_already_member": "用户已是此房产的成员",
"error.properties_limit_reached": "您已达到订阅计划的房产数量上限", "error.properties_limit_reached": "您已达到订阅计划的房产数量上限",
"error.task_already_cancelled": "任务已取消", "error.task_already_cancelled": "任务已取消",
"error.task_already_archived": "任务已归档", "error.task_already_archived": "任务已归档",
"error.failed_to_parse_form": "解析多部分表单失败", "error.failed_to_parse_form": "解析多部分表单失败",
"error.task_id_required": "需要 task_id", "error.task_id_required": "需要 task_id",
"error.invalid_task_id_value": "task_id 无效", "error.invalid_task_id_value": "task_id 无效",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id 无效", "error.invalid_residence_id_value": "residence_id 无效",
"error.title_required": "需要标题", "error.title_required": "需要标题",
"error.failed_to_upload_file": "上传文件失败", "error.failed_to_upload_file": "上传文件失败",
"message.logged_out": "已成功退出", "message.logged_out": "已成功退出",
"message.email_verified": "邮箱验证成功", "message.email_verified": "邮箱验证成功",
"message.verification_email_sent": "验证邮件已发送", "message.verification_email_sent": "验证邮件已发送",
"message.password_reset_email_sent": "如果该邮箱存在账户,密码重置验证码已发送。", "message.password_reset_email_sent": "如果该邮箱存在账户,密码重置验证码已发送。",
"message.reset_code_verified": "验证码验证成功", "message.reset_code_verified": "验证码验证成功",
"message.password_reset_success": "密码重置成功,请使用新密码登录。", "message.password_reset_success": "密码重置成功,请使用新密码登录。",
"message.task_deleted": "任务删除成功", "message.task_deleted": "任务删除成功",
"message.task_in_progress": "任务已标记为进行中", "message.task_in_progress": "任务已标记为进行中",
"message.task_cancelled": "任务已取消", "message.task_cancelled": "任务已取消",
@@ -79,46 +72,35 @@
"message.task_archived": "任务已归档", "message.task_archived": "任务已归档",
"message.task_unarchived": "任务已取消归档", "message.task_unarchived": "任务已取消归档",
"message.completion_deleted": "完成记录删除成功", "message.completion_deleted": "完成记录删除成功",
"message.residence_deleted": "房产删除成功", "message.residence_deleted": "房产删除成功",
"message.user_removed": "用户已从房产中移除", "message.user_removed": "用户已从房产中移除",
"message.tasks_report_generated": "任务报告生成成功", "message.tasks_report_generated": "任务报告生成成功",
"message.tasks_report_sent": "任务报告已生成并发送至 {{.Email}}", "message.tasks_report_sent": "任务报告已生成并发送至 {{.Email}}",
"message.tasks_report_email_failed": "任务报告已生成但无法发送邮件", "message.tasks_report_email_failed": "任务报告已生成但无法发送邮件",
"message.contractor_deleted": "承包商删除成功", "message.contractor_deleted": "承包商删除成功",
"message.document_deleted": "文档删除成功", "message.document_deleted": "文档删除成功",
"message.document_activated": "文档已激活", "message.document_activated": "文档已激活",
"message.document_deactivated": "文档已停用", "message.document_deactivated": "文档已停用",
"message.notification_marked_read": "通知已标记为已读", "message.notification_marked_read": "通知已标记为已读",
"message.all_notifications_marked_read": "所有通知已标记为已读", "message.all_notifications_marked_read": "所有通知已标记为已读",
"message.device_removed": "设备已移除", "message.device_removed": "设备已移除",
"message.subscription_upgraded": "订阅升级成功", "message.subscription_upgraded": "订阅升级成功",
"message.subscription_cancelled": "订阅已取消。您将保留专业版权益至当前账单周期结束。", "message.subscription_cancelled": "订阅已取消。您将保留专业版权益至当前账单周期结束。",
"message.subscription_restored": "订阅恢复成功", "message.subscription_restored": "订阅恢复成功",
"message.file_deleted": "文件删除成功", "message.file_deleted": "文件删除成功",
"message.static_data_refreshed": "静态数据已刷新", "message.static_data_refreshed": "静态数据已刷新",
"error.notification_not_found": "未找到通知", "error.notification_not_found": "未找到通知",
"error.invalid_platform": "平台无效", "error.invalid_platform": "平台无效",
"error.upgrade_trigger_not_found": "未找到升级触发器", "error.upgrade_trigger_not_found": "未找到升级触发器",
"error.receipt_data_required": "iOS 需要 receipt_data", "error.receipt_data_required": "iOS 需要 receipt_data",
"error.purchase_token_required": "Android 需要 purchase_token", "error.purchase_token_required": "Android 需要 purchase_token",
"error.no_file_provided": "未提供文件", "error.no_file_provided": "未提供文件",
"error.failed_to_fetch_residence_types": "获取房产类型失败", "error.failed_to_fetch_residence_types": "获取房产类型失败",
"error.failed_to_fetch_task_categories": "获取任务分类失败", "error.failed_to_fetch_task_categories": "获取任务分类失败",
"error.failed_to_fetch_task_priorities": "获取任务优先级失败", "error.failed_to_fetch_task_priorities": "获取任务优先级失败",
"error.failed_to_fetch_task_frequencies": "获取任务频率失败", "error.failed_to_fetch_task_frequencies": "获取任务频率失败",
"error.failed_to_fetch_task_statuses": "获取任务状态失败", "error.failed_to_fetch_task_statuses": "获取任务状态失败",
"error.failed_to_fetch_contractor_specialties": "获取承包商专业类别失败", "error.failed_to_fetch_contractor_specialties": "获取承包商专业类别失败",
"push.task_due_soon.title": "任务即将到期", "push.task_due_soon.title": "任务即将到期",
"push.task_due_soon.body": "{{.TaskTitle}} 将于 {{.DueDate}} 到期", "push.task_due_soon.body": "{{.TaskTitle}} 将于 {{.DueDate}} 到期",
"push.task_overdue.title": "任务已逾期", "push.task_overdue.title": "任务已逾期",
@@ -129,19 +111,16 @@
"push.task_assigned.body": "您已被分配到 {{.TaskTitle}}", "push.task_assigned.body": "您已被分配到 {{.TaskTitle}}",
"push.residence_shared.title": "房产已分享", "push.residence_shared.title": "房产已分享",
"push.residence_shared.body": "{{.UserName}} 与您分享了 {{.ResidenceName}}", "push.residence_shared.body": "{{.UserName}} 与您分享了 {{.ResidenceName}}",
"email.welcome.subject": "欢迎使用 honeyDue", "email.welcome.subject": "欢迎使用 honeyDue",
"email.verification.subject": "验证您的邮箱", "email.verification.subject": "验证您的邮箱",
"email.password_reset.subject": "密码重置验证码", "email.password_reset.subject": "密码重置验证码",
"email.tasks_report.subject": "{{.ResidenceName}} 的任务报告", "email.tasks_report.subject": "{{.ResidenceName}} 的任务报告",
"lookup.residence_type.house": "独栋房屋",
"lookup.residence_type.house": "独立屋",
"lookup.residence_type.apartment": "公寓", "lookup.residence_type.apartment": "公寓",
"lookup.residence_type.condo": "共管公寓", "lookup.residence_type.condo": "共管公寓",
"lookup.residence_type.townhouse": "联排别墅", "lookup.residence_type.townhouse": "联排别墅",
"lookup.residence_type.mobile_home": "移动房屋", "lookup.residence_type.mobile_home": "移动房屋",
"lookup.residence_type.other": "其他", "lookup.residence_type.other": "其他",
"lookup.task_category.plumbing": "管道", "lookup.task_category.plumbing": "管道",
"lookup.task_category.electrical": "电气", "lookup.task_category.electrical": "电气",
"lookup.task_category.hvac": "暖通空调", "lookup.task_category.hvac": "暖通空调",
@@ -154,18 +133,15 @@
"lookup.task_category.pest_control": "害虫防治", "lookup.task_category.pest_control": "害虫防治",
"lookup.task_category.seasonal": "季节性", "lookup.task_category.seasonal": "季节性",
"lookup.task_category.other": "其他", "lookup.task_category.other": "其他",
"lookup.task_priority.low": "低", "lookup.task_priority.low": "低",
"lookup.task_priority.medium": "中", "lookup.task_priority.medium": "中",
"lookup.task_priority.high": "高", "lookup.task_priority.high": "高",
"lookup.task_priority.urgent": "紧急", "lookup.task_priority.urgent": "紧急",
"lookup.task_status.pending": "待处理", "lookup.task_status.pending": "待处理",
"lookup.task_status.in_progress": "进行中", "lookup.task_status.in_progress": "进行中",
"lookup.task_status.completed": "已完成", "lookup.task_status.completed": "已完成",
"lookup.task_status.cancelled": "已取消", "lookup.task_status.cancelled": "已取消",
"lookup.task_status.archived": "已归档", "lookup.task_status.archived": "已归档",
"lookup.task_frequency.once": "一次", "lookup.task_frequency.once": "一次",
"lookup.task_frequency.daily": "每天", "lookup.task_frequency.daily": "每天",
"lookup.task_frequency.weekly": "每周", "lookup.task_frequency.weekly": "每周",
@@ -174,12 +150,11 @@
"lookup.task_frequency.quarterly": "每季度", "lookup.task_frequency.quarterly": "每季度",
"lookup.task_frequency.semiannually": "每半年", "lookup.task_frequency.semiannually": "每半年",
"lookup.task_frequency.annually": "每年", "lookup.task_frequency.annually": "每年",
"lookup.contractor_specialty.plumber": "管道工",
"lookup.contractor_specialty.plumber": "水管工",
"lookup.contractor_specialty.electrician": "电工", "lookup.contractor_specialty.electrician": "电工",
"lookup.contractor_specialty.hvac_technician": "暖通空调技师", "lookup.contractor_specialty.hvac_technician": "暖通空调技师",
"lookup.contractor_specialty.handyman": "杂工", "lookup.contractor_specialty.handyman": "杂工",
"lookup.contractor_specialty.landscaper": "园林工", "lookup.contractor_specialty.landscaper": "园艺师",
"lookup.contractor_specialty.roofer": "屋顶工", "lookup.contractor_specialty.roofer": "屋顶工",
"lookup.contractor_specialty.painter": "油漆工", "lookup.contractor_specialty.painter": "油漆工",
"lookup.contractor_specialty.carpenter": "木工", "lookup.contractor_specialty.carpenter": "木工",
@@ -187,5 +162,86 @@
"lookup.contractor_specialty.cleaning": "清洁", "lookup.contractor_specialty.cleaning": "清洁",
"lookup.contractor_specialty.pool_service": "泳池服务", "lookup.contractor_specialty.pool_service": "泳池服务",
"lookup.contractor_specialty.general_contractor": "总承包商", "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": "其他"
} }
+21
View File
@@ -190,6 +190,27 @@ func shouldSkipSpecRoute(path string) bool {
if strings.HasPrefix(path, "/uploads/") || strings.HasPrefix(path, "/media/") { if strings.HasPrefix(path, "/uploads/") || strings.HasPrefix(path, "/media/") {
return true 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 return false
} }
+96 -280
View File
@@ -6,9 +6,11 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync"
"testing" "testing"
"time" "time"
"github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -17,6 +19,7 @@ import (
"github.com/treytartt/honeydue-api/internal/config" "github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/handlers" "github.com/treytartt/honeydue-api/internal/handlers"
"github.com/treytartt/honeydue-api/internal/middleware" "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/repositories"
"github.com/treytartt/honeydue-api/internal/services" "github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/testutil" "github.com/treytartt/honeydue-api/internal/testutil"
@@ -105,11 +108,40 @@ type TestApp struct {
TaskRepo *repositories.TaskRepository TaskRepo *repositories.TaskRepository
ContractorRepo *repositories.ContractorRepository ContractorRepo *repositories.ContractorRepository
AuthService *services.AuthService 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 { func setupIntegrationTest(t *testing.T) *TestApp {
// Echo does not need test mode
db := testutil.SetupTestDB(t) db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db) testutil.SeedLookupData(t, db)
@@ -123,9 +155,6 @@ func setupIntegrationTest(t *testing.T) *TestApp {
cfg := &config.Config{ cfg := &config.Config{
Security: config.SecurityConfig{ Security: config.SecurityConfig{
SecretKey: "test-secret-key-for-integration-tests", 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) taskHandler := handlers.NewTaskHandler(taskService, nil)
contractorHandler := handlers.NewContractorHandler(contractorService) contractorHandler := handlers.NewContractorHandler(contractorService)
// Create router with real middleware app := &TestApp{
e := echo.New() 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.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler e.HTTPErrorHandler = apperrors.HTTPErrorHandler
// Add timezone middleware globally so X-Timezone header is processed // Timezone middleware processes X-Timezone header
e.Use(middleware.TimezoneMiddleware()) e.Use(middleware.TimezoneMiddleware())
// Public routes // Protected routes — guarded by the fake token middleware
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)
api := e.Group("/api") api := e.Group("/api")
api.Use(authMiddleware.TokenAuth()) api.Use(app.fakeAuthMiddleware())
{ {
api.GET("/auth/me", authHandler.CurrentUser) api.GET("/auth/me", authHandler.CurrentUser)
api.POST("/auth/logout", authHandler.Logout)
residences := api.Group("/residences") residences := api.Group("/residences")
{ {
@@ -216,19 +250,7 @@ func setupIntegrationTest(t *testing.T) *TestApp {
api.GET("/contractors/by-residence/:residence_id", contractorHandler.ListContractorsByResidence) api.GET("/contractors/by-residence/:residence_id", contractorHandler.ListContractorsByResidence)
} }
return &TestApp{ return app
DB: db,
Router: e,
AuthHandler: authHandler,
ResidenceHandler: residenceHandler,
TaskHandler: taskHandler,
ContractorHandler: contractorHandler,
UserRepo: userRepo,
ResidenceRepo: residenceRepo,
TaskRepo: taskRepo,
ContractorRepo: contractorRepo,
AuthService: authService,
}
} }
// Helper to make authenticated requests // Helper to make authenticated requests
@@ -251,156 +273,16 @@ func (app *TestApp) makeAuthenticatedRequest(t *testing.T, method, path string,
return w return w
} }
// Helper to register and login a user, returns token // registerAndLogin creates a user directly in the DB and returns a synthetic token
func (app *TestApp) registerAndLogin(t *testing.T, username, email, password string) string { // that the fake auth middleware will accept. No HTTP register/login endpoints are called.
// Register func (app *TestApp) registerAndLogin(t *testing.T, username, email, _ string) string {
registerBody := map[string]string{ t.Helper()
"username": username, user := testutil.CreateTestUser(t, app.DB, username, email, "")
"email": email, tok := uuid.NewString()
"password": password, app.tokenStoreMu.Lock()
} app.tokenStore[tok] = user
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "") app.tokenStoreMu.Unlock()
require.Equal(t, http.StatusCreated, w.Code) return tok
// 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(), &registerResp)
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)
} }
// ============ Residence Flow Tests ============ // ============ Residence Flow Tests ============
@@ -827,48 +709,16 @@ func TestIntegration_ResponseStructure(t *testing.T) {
func TestIntegration_ComprehensiveE2E(t *testing.T) { func TestIntegration_ComprehensiveE2E(t *testing.T) {
app := setupIntegrationTest(t) app := setupIntegrationTest(t)
// ============ Phase 1: Authentication ============ // ============ Phase 1: User Setup ============
t.Log("Phase 1: Testing authentication flow") t.Log("Phase 1: Setting up test user")
// Register new user token := app.registerAndLogin(t, "e2e_testuser", "e2e@example.com", "")
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(), &registerResp)
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")
// Verify authenticated access // 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") require.Equal(t, http.StatusOK, w.Code, "Should access protected route with valid token")
var meResp map[string]interface{} t.Log("✓ User setup verified")
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")
// ============ Phase 2: Create 5 Residences ============ // ============ Phase 2: Create 5 Residences ============
t.Log("Phase 2: Creating 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) t.Logf("✓ All %d visible tasks verified in correct columns by ID", expectedVisibleTasks)
// ============ Phase 9: Create User B ============ // ============ 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 tokenB := app.registerAndLogin(t, "e2e_userb", "e2e_userb@example.com", "")
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")
// Verify User B can access their own profile // Verify User B can access their own profile
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, tokenB) 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 // setupContractorTest sets up a test environment including contractor routes
func setupContractorTest(t *testing.T) *TestApp { func setupContractorTest(t *testing.T) *TestApp {
// Echo does not need test mode
db := testutil.SetupTestDB(t) db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db) testutil.SeedLookupData(t, db)
@@ -1607,9 +1435,6 @@ func setupContractorTest(t *testing.T) *TestApp {
cfg := &config.Config{ cfg := &config.Config{
Security: config.SecurityConfig{ Security: config.SecurityConfig{
SecretKey: "test-secret-key-for-integration-tests", 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) taskHandler := handlers.NewTaskHandler(taskService, nil)
contractorHandler := handlers.NewContractorHandler(contractorService) contractorHandler := handlers.NewContractorHandler(contractorService)
// Create router with real middleware app := &TestApp{
e := echo.New() 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.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler e.HTTPErrorHandler = apperrors.HTTPErrorHandler
// Add timezone middleware globally so X-Timezone header is processed // Timezone middleware
e.Use(middleware.TimezoneMiddleware()) e.Use(middleware.TimezoneMiddleware())
// Public routes
auth := e.Group("/api/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
// Protected routes // Protected routes
authMiddleware := middleware.NewAuthMiddleware(db, nil)
api := e.Group("/api") 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 := api.Group("/residences")
{ {
residences.GET("", residenceHandler.ListResidences) residences.GET("", residenceHandler.ListResidences)
@@ -1680,19 +1508,7 @@ func setupContractorTest(t *testing.T) *TestApp {
} }
} }
return &TestApp{ return app
DB: db,
Router: e,
AuthHandler: authHandler,
ResidenceHandler: residenceHandler,
TaskHandler: taskHandler,
ContractorHandler: contractorHandler,
UserRepo: userRepo,
ResidenceRepo: residenceRepo,
TaskRepo: taskRepo,
ContractorRepo: contractorRepo,
AuthService: authService,
}
} }
// ============ Test 1: Recurring Task Lifecycle ============ // ============ Test 1: Recurring Task Lifecycle ============
@@ -2045,12 +1861,12 @@ func TestIntegration_MultiUserSharing(t *testing.T) {
// Phase 9: Remove User B from residence 3 // Phase 9: Remove User B from residence 3
t.Log("Phase 9: Remove User B from residence 3") t.Log("Phase 9: Remove User B from residence 3")
// Get User B's ID // Get User B's ID from the token store
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, tokenB) app.tokenStoreMu.RLock()
require.Equal(t, http.StatusOK, w.Code) userBModel := app.tokenStore[tokenB]
var userBInfo map[string]interface{} app.tokenStoreMu.RUnlock()
json.Unmarshal(w.Body.Bytes(), &userBInfo) require.NotNil(t, userBModel, "User B should be in token store")
userBID := uint(userBInfo["id"].(float64)) userBID := userBModel.ID
// Remove User B from residence 3 // Remove User B from residence 3
w = app.makeAuthenticatedRequest(t, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d", residenceIDs[2], userBID), nil, tokenA) w = app.makeAuthenticatedRequest(t, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d", residenceIDs[2], userBID), nil, tokenA)
@@ -6,9 +6,11 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync"
"testing" "testing"
"time" "time"
"github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -35,6 +37,48 @@ type SecurityTestApp struct {
Router *echo.Echo Router *echo.Echo
SubscriptionService *services.SubscriptionService SubscriptionService *services.SubscriptionService
SubscriptionRepo *repositories.SubscriptionRepository 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 { func setupSecurityTest(t *testing.T) *SecurityTestApp {
@@ -78,27 +122,25 @@ func setupSecurityTest(t *testing.T) *SecurityTestApp {
notificationHandler := handlers.NewNotificationHandler(notificationService) notificationHandler := handlers.NewNotificationHandler(notificationService)
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, nil) 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 := echo.New()
e.Validator = validator.NewCustomValidator() e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler e.HTTPErrorHandler = apperrors.HTTPErrorHandler
e.Use(middleware.TimezoneMiddleware()) e.Use(middleware.TimezoneMiddleware())
// Public routes
auth := e.Group("/api/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
// Protected routes // Protected routes
authMiddleware := middleware.NewAuthMiddleware(db, nil)
api := e.Group("/api") api := e.Group("/api")
api.Use(authMiddleware.TokenAuth()) api.Use(app.fakeAuthMiddleware())
{ {
api.GET("/auth/me", authHandler.CurrentUser) api.GET("/auth/me", authHandler.CurrentUser)
api.POST("/auth/logout", authHandler.Logout)
residences := api.Group("/residences") residences := api.Group("/residences")
{ {
@@ -146,42 +188,8 @@ func setupSecurityTest(t *testing.T) *SecurityTestApp {
} }
} }
return &SecurityTestApp{ app.Router = e
DB: db, return app
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
} }
// makeAuthReq creates and sends an HTTP request through the router. // makeAuthReq creates and sends an HTTP request through the router.
@@ -6,12 +6,15 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync"
"testing" "testing"
"time" "time"
"github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/apperrors" "github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/config" "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/services"
"github.com/treytartt/honeydue-api/internal/testutil" "github.com/treytartt/honeydue-api/internal/testutil"
"github.com/treytartt/honeydue-api/internal/validator" "github.com/treytartt/honeydue-api/internal/validator"
"gorm.io/gorm"
) )
// SubscriptionTestApp holds components for subscription integration testing // SubscriptionTestApp holds components for subscription integration testing
@@ -31,11 +33,51 @@ type SubscriptionTestApp struct {
Router *echo.Echo Router *echo.Echo
SubscriptionService *services.SubscriptionService SubscriptionService *services.SubscriptionService
SubscriptionRepo *repositories.SubscriptionRepository 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 { func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
// Echo does not need test mode
db := testutil.SetupTestDB(t) db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db) testutil.SeedLookupData(t, db)
@@ -67,22 +109,23 @@ func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil, true) residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil, true)
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, nil) 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 := echo.New()
e.Validator = validator.NewCustomValidator() e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler e.HTTPErrorHandler = apperrors.HTTPErrorHandler
// Public routes e.Use(middleware.TimezoneMiddleware())
auth := e.Group("/api/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
// Protected routes // Protected routes
authMiddleware := middleware.NewAuthMiddleware(db, nil)
api := e.Group("/api") api := e.Group("/api")
api.Use(authMiddleware.TokenAuth()) api.Use(app.fakeAuthMiddleware())
{ {
api.GET("/auth/me", authHandler.CurrentUser) api.GET("/auth/me", authHandler.CurrentUser)
@@ -98,12 +141,8 @@ func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
} }
} }
return &SubscriptionTestApp{ app.Router = e
DB: db, return app
Router: e,
SubscriptionService: subscriptionService,
SubscriptionRepo: subscriptionRepo,
}
} }
// Helper to make authenticated requests // Helper to make authenticated requests
@@ -129,39 +168,14 @@ func (app *SubscriptionTestApp) makeAuthenticatedRequest(t *testing.T, method, p
return w 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 // TestIntegration_IsFreeBypassesLimitations tests that users with IsFree=true
// see limitations_enabled=false regardless of global settings // see limitations_enabled=false regardless of global settings
func TestIntegration_IsFreeBypassesLimitations(t *testing.T) { 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) app := setupSubscriptionTest(t)
// Register and login a user // Register and login a user
@@ -280,6 +294,10 @@ func TestIntegration_IsFreeBypassesCheckLimit(t *testing.T) {
// TestIntegration_IsFreeIndependentOfTier tests that IsFree works regardless of // TestIntegration_IsFreeIndependentOfTier tests that IsFree works regardless of
// the user's subscription tier // the user's subscription tier
func TestIntegration_IsFreeIndependentOfTier(t *testing.T) { 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) app := setupSubscriptionTest(t)
// Register and login a user // Register and login a user
+245
View File
@@ -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)
}
}
-410
View File
@@ -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 + "..."
}
-165
View File
@@ -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")
}
+74
View File
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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/config"
"github.com/treytartt/honeydue-api/internal/models" "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.Equal(t, http.StatusUnauthorized, rec.Code, "query param token must be rejected")
assert.Contains(t, rec.Body.String(), "Authorization required") 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)
}
-337
View File
@@ -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)
}
+292
View File
@@ -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
}
+12 -10
View File
@@ -99,22 +99,24 @@ func parseTimezone(tz string) *time.Location {
return loc return loc
} }
// Try parsing as UTC offset (e.g., "-08:00", "+05:30") // Try parsing as a UTC offset (e.g., "-08:00", "+05:30"). Audit H8:
// We parse a reference time with the given offset to extract the offset value // reject absurd offsets — real timezones are within ±14h of UTC — so a
t, err := time.Parse("-07:00", tz) // crafted X-Timezone header cannot shift date math arbitrarily.
if err == nil { const maxOffsetSeconds = 14 * 3600
// time.Parse returns a time, we need to extract the offset if t, err := time.Parse("-07:00", tz); err == nil {
// The parsed time will have the offset embedded if _, offset := t.Zone(); offset >= -maxOffsetSeconds && offset <= maxOffsetSeconds {
_, offset := t.Zone()
return time.FixedZone(tz, offset) return time.FixedZone(tz, offset)
} }
return time.UTC
}
// Also try without colon (e.g., "-0800") // Also try without colon (e.g., "-0800")
t, err = time.Parse("-0700", tz) if t, err := time.Parse("-0700", tz); err == nil {
if err == nil { if _, offset := t.Zone(); offset >= -maxOffsetSeconds && offset <= maxOffsetSeconds {
_, offset := t.Zone()
return time.FixedZone(tz, offset) return time.FixedZone(tz, offset)
} }
return time.UTC
}
// Default to UTC // Default to UTC
return time.UTC return time.UTC
+1 -124
View File
@@ -19,7 +19,7 @@ func setupModelsTestDB(t *testing.T) *gorm.DB {
Logger: logger.Default.LogMode(logger.Silent), Logger: logger.Default.LogMode(logger.Silent),
}) })
require.NoError(t, err) require.NoError(t, err)
err = db.AutoMigrate(&User{}, &AuthToken{}, &UserProfile{}) err = db.AutoMigrate(&User{}, &UserProfile{})
require.NoError(t, err) require.NoError(t, err)
return db return db
} }
@@ -233,104 +233,6 @@ func TestNotificationType_Constants(t *testing.T) {
assert.Equal(t, NotificationType("warranty_expiring"), NotificationWarrantyExpiring) 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 === // === Task model additional tests ===
func TestTask_IsOverdue_CancelledNotOverdue(t *testing.T) { func TestTask_IsOverdue_CancelledNotOverdue(t *testing.T) {
@@ -564,31 +466,6 @@ func TestGetDefaultProLimits(t *testing.T) {
assert.Nil(t, limits.DocumentsLimit) 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 === // === BaseModel tests ===
+3
View File
@@ -43,6 +43,9 @@ type UserSubscription struct {
// In-App Purchase data (Apple / Google) // In-App Purchase data (Apple / Google)
AppleReceiptData *string `gorm:"column:apple_receipt_data;type:text" json:"-"` AppleReceiptData *string `gorm:"column:apple_receipt_data;type:text" json:"-"`
GooglePurchaseToken *string `gorm:"column:google_purchase_token;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) // Stripe data (web subscriptions)
StripeCustomerID *string `gorm:"column:stripe_customer_id;size:255" json:"-"` StripeCustomerID *string `gorm:"column:stripe_customer_id;size:255" json:"-"`
+16 -218
View File
@@ -1,64 +1,38 @@
package models package models
import ( import "time"
"crypto/rand"
"encoding/hex"
"time"
"golang.org/x/crypto/bcrypt" // User represents the auth_user table. Identity — credentials, email
"gorm.io/gorm" // 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.
// User represents the auth_user table (Django's default User model)
type User struct { type User struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Password string `gorm:"column:password;size:128;not null" json:"-"` KratosID string `gorm:"column:kratos_id;uniqueIndex;size:36" json:"-"` // Kratos identity UUID
LastLogin *time.Time `gorm:"column:last_login" json:"last_login,omitempty"` Username string `gorm:"column:username;uniqueIndex;size:150" json:"username"`
IsSuperuser bool `gorm:"column:is_superuser;default:false" json:"is_superuser"`
Username string `gorm:"column:username;uniqueIndex;size:150;not null" json:"username"`
FirstName string `gorm:"column:first_name;size:150" json:"first_name"` FirstName string `gorm:"column:first_name;size:150" json:"first_name"`
LastName string `gorm:"column:last_name;size:150" json:"last_name"` LastName string `gorm:"column:last_name;size:150" json:"last_name"`
Email string `gorm:"column:email;uniqueIndex;size:254" json:"email"` Email string `gorm:"column:email;uniqueIndex;size:254" json:"email"`
IsStaff bool `gorm:"column:is_staff;default:false" json:"is_staff"` IsStaff bool `gorm:"column:is_staff;default:false" json:"is_staff"`
IsActive bool `gorm:"column:is_active;default:true" json:"is_active"` 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"` 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"` Profile *UserProfile `gorm:"foreignKey:UserID" json:"profile,omitempty"`
AuthToken *AuthToken `gorm:"foreignKey:UserID" json:"-"`
OwnedResidences []Residence `gorm:"foreignKey:OwnerID" json:"-"` OwnedResidences []Residence `gorm:"foreignKey:OwnerID" json:"-"`
SharedResidences []Residence `gorm:"many2many:residence_residence_users;" json:"-"` SharedResidences []Residence `gorm:"many2many:residence_residence_users;" json:"-"`
NotificationPref *NotificationPreference `gorm:"foreignKey:UserID" json:"-"` NotificationPref *NotificationPreference `gorm:"foreignKey:UserID" json:"-"`
Subscription *UserSubscription `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 { func (User) TableName() string {
return "auth_user" return "auth_user"
} }
// SetPassword hashes and sets the password // GetFullName returns the user's display name.
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
func (u *User) GetFullName() string { func (u *User) GetFullName() string {
if u.FirstName != "" && u.LastName != "" { if u.FirstName != "" && u.LastName != "" {
return u.FirstName + " " + u.LastName return u.FirstName + " " + u.LastName
@@ -69,57 +43,9 @@ func (u *User) GetFullName() string {
return u.Username return u.Username
} }
// AuthToken represents the user_authtoken table // UserProfile represents the user_userprofile table — honeyDue-specific
type AuthToken struct { // profile data, keyed to a local user. Email-verification state is owned by
Key string `gorm:"column:key;primaryKey;size:40" json:"key"` // Kratos; the Verified column is a convenience mirror set at provision time.
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
type UserProfile struct { type UserProfile struct {
BaseModel BaseModel
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"` 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:"-"` User User `gorm:"foreignKey:UserID" json:"-"`
} }
// TableName returns the table name for GORM // TableName returns the table name for GORM.
func (UserProfile) TableName() string { func (UserProfile) TableName() string {
return "user_userprofile" 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"
}
-164
View File
@@ -2,45 +2,10 @@ package models
import ( import (
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "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) { func TestUser_GetFullName(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -82,136 +47,7 @@ func TestUser_TableName(t *testing.T) {
assert.Equal(t, "auth_user", user.TableName()) 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) { func TestUserProfile_TableName(t *testing.T) {
profile := UserProfile{} profile := UserProfile{}
assert.Equal(t, "user_userprofile", profile.TableName()) 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)
}
+21
View File
@@ -1,6 +1,7 @@
package prom package prom
import ( import (
"net/http"
"strconv" "strconv"
"time" "time"
@@ -54,6 +55,11 @@ var (
Help: "Duration of asynq background job execution in seconds.", 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}, Buckets: []float64{0.01, 0.05, 0.1, 0.5, 1, 5, 10, 30, 60, 300},
}, []string{"task_type", "result"}) }, []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() { func init() {
@@ -67,6 +73,7 @@ func init() {
apnsSendDuration, apnsSendDuration,
fcmSendDuration, fcmSendDuration,
asynqJobDuration, asynqJobDuration,
cacheOps,
) )
} }
@@ -77,6 +84,20 @@ func Handler() echo.HandlerFunc {
return echo.WrapHandler(h) 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, // HTTPMiddleware records http_request_duration_seconds for every request,
// labeled by Echo route pattern, method, and status code. // labeled by Echo route pattern, method, and status code.
func HTTPMiddleware() echo.MiddlewareFunc { func HTTPMiddleware() echo.MiddlewareFunc {
+55
View File
@@ -9,6 +9,7 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/models"
) )
@@ -194,6 +195,60 @@ func (r *ResidenceRepository) HasAccess(residenceID, userID uint) (bool, error)
return count > 0, nil 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 // IsOwner checks if a user is the owner of a residence
func (r *ResidenceRepository) IsOwner(residenceID, userID uint) (bool, error) { func (r *ResidenceRepository) IsOwner(residenceID, userID uint) (bool, error) {
var count int64 var count int64
@@ -151,6 +151,28 @@ func (r *SubscriptionRepository) FindByAppleReceiptContains(transactionID string
return &sub, nil 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 // FindByGoogleToken finds a subscription by Google purchase token
// Used by webhooks to find the user associated with a purchase // Used by webhooks to find the user associated with a purchase
func (r *SubscriptionRepository) FindByGoogleToken(purchaseToken string) (*models.UserSubscription, error) { func (r *SubscriptionRepository) FindByGoogleToken(purchaseToken string) (*models.UserSubscription, error) {
@@ -226,3 +226,48 @@ func TestUpdateExpiresAt(t *testing.T) {
require.NotNil(t, updated.ExpiresAt) require.NotNil(t, updated.ExpiresAt)
assert.WithinDuration(t, newExpiry, *updated.ExpiresAt, time.Second, "expires_at should be updated") 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"))
})
}
+29 -308
View File
@@ -11,18 +11,21 @@ import (
"github.com/treytartt/honeydue-api/internal/models" "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 ( var (
ErrUserNotFound = errors.New("user not found") ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("user already exists") 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 // UserRepository handles user-related database operations
@@ -63,6 +66,16 @@ func (r *UserRepository) FindByID(id uint) (*models.User, error) {
return &user, nil 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 // FindByIDWithProfile finds a user by ID with profile preloaded
func (r *UserRepository) FindByIDWithProfile(id uint) (*models.User, error) { func (r *UserRepository) FindByIDWithProfile(id uint) (*models.User, error) {
var user models.User var user models.User
@@ -145,72 +158,6 @@ func (r *UserRepository) ExistsByEmail(email string) (bool, error) {
return count > 0, nil 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 --- // --- 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 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 --- // --- Search Methods ---
@@ -537,27 +344,11 @@ func (r *UserRepository) FindProfilesInSharedResidences(userID uint) ([]models.U
return profiles, err return profiles, err
} }
// --- Auth Provider Detection --- // FindAuthProvider returns "kratos" for all Kratos-managed users (the sole
// provider after the Ory Kratos migration). Kept for compatibility with
// FindAuthProvider determines the auth provider for a user. // callers that still check the provider string.
// Returns "apple", "google", or "email". func (r *UserRepository) FindAuthProvider(_ uint) (string, error) {
func (r *UserRepository) FindAuthProvider(userID uint) (string, error) { return "kratos", nil
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
} }
// --- Account Deletion --- // --- Account Deletion ---
@@ -682,35 +473,12 @@ func (r *UserRepository) DeleteUserCascade(userID uint) ([]string, error) {
return nil, err return nil, err
} }
// 8. Social auth records // 8. User profile
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
if err := db.Where("user_id = ?", userID).Delete(&models.UserProfile{}).Error; err != nil { if err := db.Where("user_id = ?", userID).Delete(&models.UserProfile{}).Error; err != nil {
return nil, err return nil, err
} }
// 13. User // 9. User
if err := db.Where("id = ?", userID).Delete(&models.User{}).Error; err != nil { if err := db.Where("id = ?", userID).Delete(&models.User{}).Error; err != nil {
return nil, err return nil, err
} }
@@ -726,53 +494,6 @@ func (r *UserRepository) DeleteUserCascade(userID uint) ([]string, error) {
return cleanURLs, nil 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 // 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 // the supplied context. SQL emitted via this copy gets attached to ctx's trace span
@@ -2,7 +2,6 @@ package repositories
import ( import (
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -78,99 +77,25 @@ func TestUserRepository_ExistsByEmail_CaseInsensitive(t *testing.T) {
assert.True(t, exists) assert.True(t, exists)
} }
func TestUserRepository_GetOrCreateToken(t *testing.T) { func TestUserRepository_FindByKratosID(t *testing.T) {
db := testutil.SetupTestDB(t) db := testutil.SetupTestDB(t)
repo := NewUserRepository(db) repo := NewUserRepository(db)
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123") user := testutil.CreateTestUser(t, db, "kratosuser", "kratos@example.com", "")
// Create token found, err := repo.FindByKratosID(user.KratosID)
token1, err := repo.GetOrCreateToken(user.ID)
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, token1.Key) assert.Equal(t, user.ID, found.ID)
assert.Equal(t, user.KratosID, found.KratosID)
// Should return same token
token2, err := repo.GetOrCreateToken(user.ID)
require.NoError(t, err)
assert.Equal(t, token1.Key, token2.Key)
} }
func TestUserRepository_FindTokenByKey(t *testing.T) { func TestUserRepository_FindByKratosID_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t) db := testutil.SetupTestDB(t)
repo := NewUserRepository(db) repo := NewUserRepository(db)
user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "Password123") _, err := repo.FindByKratosID("nonexistent-kratos-id")
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")
assert.Error(t, err) assert.Error(t, err)
assert.ErrorIs(t, err, ErrTokenNotFound) assert.ErrorIs(t, err, ErrUserNotFound)
}
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)
} }
func TestUserRepository_UpdateLastLogin(t *testing.T) { func TestUserRepository_UpdateLastLogin(t *testing.T) {
@@ -255,54 +180,6 @@ func TestUserRepository_FindByIDWithProfile_NotFound(t *testing.T) {
assert.ErrorIs(t, err, ErrUserNotFound) 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) { func TestUserRepository_Transaction(t *testing.T) {
db := testutil.SetupTestDB(t) db := testutil.SetupTestDB(t)
repo := NewUserRepository(db) repo := NewUserRepository(db)
@@ -331,105 +208,6 @@ func TestUserRepository_DB(t *testing.T) {
assert.NotNil(t, repo.DB()) 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) { func TestUserRepository_SearchUsers(t *testing.T) {
db := testutil.SetupTestDB(t) db := testutil.SetupTestDB(t)
repo := NewUserRepository(db) repo := NewUserRepository(db)
@@ -2,7 +2,6 @@ package repositories
import ( import (
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -11,207 +10,6 @@ import (
"github.com/treytartt/honeydue-api/internal/testutil" "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 === // === FindUsersInSharedResidences ===
func TestUserRepository_FindUsersInSharedResidences(t *testing.T) { func TestUserRepository_FindUsersInSharedResidences(t *testing.T) {
@@ -301,33 +99,6 @@ func TestUserRepository_FindProfilesInSharedResidences(t *testing.T) {
assert.Len(t, profiles, 2) 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 === // === Transaction Rollback ===
func TestUserRepository_Transaction_Rollback(t *testing.T) { func TestUserRepository_Transaction_Rollback(t *testing.T) {
+3 -38
View File
@@ -19,7 +19,6 @@ func TestUserRepository_Create(t *testing.T) {
Email: "test@example.com", Email: "test@example.com",
IsActive: true, IsActive: true,
} }
user.SetPassword("Password123")
err := repo.Create(user) err := repo.Create(user)
require.NoError(t, err) require.NoError(t, err)
@@ -192,39 +191,11 @@ func TestUserRepository_FindAuthProvider(t *testing.T) {
db := testutil.SetupTestDB(t) db := testutil.SetupTestDB(t)
repo := NewUserRepository(db) 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") user := testutil.CreateTestUser(t, db, "emailuser", "email@test.com", "Password123")
provider, err := repo.FindAuthProvider(user.ID) provider, err := repo.FindAuthProvider(user.ID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "email", provider) assert.Equal(t, "kratos", provider) // All users are Kratos-managed
})
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)
}) })
} }
@@ -235,11 +206,9 @@ func TestUserRepository_DeleteUserCascade(t *testing.T) {
user := testutil.CreateTestUser(t, db, "deletebare", "deletebare@test.com", "Password123") user := testutil.CreateTestUser(t, db, "deletebare", "deletebare@test.com", "Password123")
// Create profile and token // Create profile
profile := &models.UserProfile{UserID: user.ID, Verified: true} profile := &models.UserProfile{UserID: user.ID, Verified: true}
require.NoError(t, db.Create(profile).Error) require.NoError(t, db.Create(profile).Error)
_, err := models.GetOrCreateToken(db, user.ID)
require.NoError(t, err)
var fileURLs []string var fileURLs []string
txErr := repo.Transaction(func(txRepo *UserRepository) error { txErr := repo.Transaction(func(txRepo *UserRepository) error {
@@ -261,10 +230,6 @@ func TestUserRepository_DeleteUserCascade(t *testing.T) {
// Verify profile is gone // Verify profile is gone
db.Model(&models.UserProfile{}).Where("user_id = ?", user.ID).Count(&count) db.Model(&models.UserProfile{}).Where("user_id = ?", user.ID).Count(&count)
assert.Equal(t, int64(0), 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) { t.Run("returns file URLs for cleanup", func(t *testing.T) {
+88 -66
View File
@@ -22,6 +22,7 @@ import (
"github.com/treytartt/honeydue-api/internal/dto/responses" "github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/handlers" "github.com/treytartt/honeydue-api/internal/handlers"
"github.com/treytartt/honeydue-api/internal/i18n" "github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/kratos"
custommiddleware "github.com/treytartt/honeydue-api/internal/middleware" custommiddleware "github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/monitoring" "github.com/treytartt/honeydue-api/internal/monitoring"
"github.com/treytartt/honeydue-api/internal/prom" "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/repositories"
"github.com/treytartt/honeydue-api/internal/services" "github.com/treytartt/honeydue-api/internal/services"
customvalidator "github.com/treytartt/honeydue-api/internal/validator" customvalidator "github.com/treytartt/honeydue-api/internal/validator"
"github.com/treytartt/honeydue-api/internal/worker"
"github.com/treytartt/honeydue-api/pkg/utils" "github.com/treytartt/honeydue-api/pkg/utils"
) )
@@ -44,6 +46,11 @@ type Dependencies struct {
PushClient *push.Client // Direct APNs/FCM client PushClient *push.Client // Direct APNs/FCM client
StorageService *services.StorageService StorageService *services.StorageService
MonitoringService *monitoring.Service 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 // 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. // responses are unaffected — they don't load any assets, so any CSP is fine.
// frame-ancestors stays 'none' to block clickjacking. // frame-ancestors stays 'none' to block clickjacking.
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ 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", ContentTypeNosniff: "nosniff",
XFrameOptions: "SAMEORIGIN", XFrameOptions: "SAMEORIGIN",
HSTSMaxAge: 31536000, HSTSMaxAge: 63072000, // 2 years — preload-eligible (audit L5/CODE-L3)
HSTSPreloadEnabled: true,
ReferrerPolicy: "strict-origin-when-cross-origin", ReferrerPolicy: "strict-origin-when-cross-origin",
ContentSecurityPolicy: "default-src 'self'; " + ContentSecurityPolicy: "default-src 'self'; " +
"style-src 'self' https://fonts.googleapis.com; " + "style-src 'self' https://fonts.googleapis.com; " +
@@ -86,6 +96,8 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
"img-src 'self' data:; " + "img-src 'self' data:; " +
"script-src 'self'; " + "script-src 'self'; " +
"connect-src 'self'; " + "connect-src 'self'; " +
"object-src 'none'; " + // audit L8 — disable plugins/embeds
"base-uri 'self'; " + // audit L8 — block <base> hijacking
"frame-ancestors 'none'", "frame-ancestors 'none'",
})) }))
e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{ e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{
@@ -136,9 +148,20 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
// labeled by route pattern, method, and status code. // labeled by route pattern, method, and status code.
e.Use(prom.HTTPMiddleware()) e.Use(prom.HTTPMiddleware())
// /metrics endpoint exposed for vmagent scrape. No auth — bound to // /metrics endpoint for the in-cluster vmagent scrape (audit LIVE-L1).
// the cluster network only; not exposed via Cloudflare. // vmagent scrapes api pods directly (pod-to-pod), so its requests carry
e.GET("/metrics", prom.Handler()) // 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) // Serve landing page static files (if static directory is configured)
staticDir := cfg.Server.StaticDir staticDir := cfg.Server.StaticDir
@@ -184,7 +207,7 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
// Initialize services // Initialize services
authService := services.NewAuthService(userRepo, cfg) authService := services.NewAuthService(userRepo, cfg)
authService.SetNotificationRepository(notificationRepo) // For creating notification preferences on registration authService.SetNotificationRepository(notificationRepo)
userService := services.NewUserService(userRepo) userService := services.NewUserService(userRepo)
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg) residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
residenceService.SetTaskRepository(taskRepo) // Wire up task repo for statistics residenceService.SetTaskRepository(taskRepo) // Wire up task repo for statistics
@@ -198,12 +221,20 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
taskService.SetEmailService(deps.EmailService) taskService.SetEmailService(deps.EmailService)
taskService.SetResidenceService(residenceService) // For including TotalSummary in CRUD responses taskService.SetResidenceService(residenceService) // For including TotalSummary in CRUD responses
taskService.SetStorageService(deps.StorageService) // For reading completion images for email 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) subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
residenceService.SetSubscriptionService(subscriptionService) // Wire up subscription service for tier limit enforcement residenceService.SetSubscriptionService(subscriptionService) // Wire up subscription service for tier limit enforcement
// Wire Redis cache for residence-ID lookups across the four services that // 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. // read it on the request hot path. Cache is best-effort; nil cache is OK.
if deps.Cache != nil { if deps.Cache != nil {
authService.SetCacheService(deps.Cache)
residenceService.SetCacheService(deps.Cache) residenceService.SetCacheService(deps.Cache)
taskService.SetCacheService(deps.Cache) taskService.SetCacheService(deps.Cache)
contractorService.SetCacheService(deps.Cache) contractorService.SetCacheService(deps.Cache)
@@ -227,22 +258,21 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
subscriptionWebhookHandler.SetStripeService(stripeService) subscriptionWebhookHandler.SetStripeService(stripeService)
subscriptionWebhookHandler.SetCacheService(deps.Cache) subscriptionWebhookHandler.SetCacheService(deps.Cache)
// Initialize middleware // Initialize Kratos auth middleware (replaces hand-rolled token auth).
authMiddleware := custommiddleware.NewAuthMiddlewareWithConfig(deps.DB, deps.Cache, cfg) kratosClient := kratos.NewClient(cfg.Security.KratosPublicURL, cfg.Security.KratosAdminURL)
authMiddleware := custommiddleware.NewKratosAuth(kratosClient, deps.Cache, deps.DB)
// Initialize Apple auth service authService.SetKratosClient(kratosClient) // account deletion removes the Kratos identity
appleAuthService := services.NewAppleAuthService(deps.Cache, cfg)
googleAuthService := services.NewGoogleAuthService(deps.Cache, cfg)
// Initialize audit service for security event logging // Initialize audit service for security event logging
auditService := services.NewAuditService(deps.DB) auditService := services.NewAuditService(deps.DB)
// Initialize handlers // Initialize handlers
authHandler := handlers.NewAuthHandler(authService, deps.EmailService, deps.Cache) authHandler := handlers.NewAuthHandler(authService, deps.EmailService, deps.Cache)
authHandler.SetAppleAuthService(appleAuthService)
authHandler.SetGoogleAuthService(googleAuthService)
authHandler.SetStorageService(deps.StorageService) authHandler.SetStorageService(deps.StorageService)
authHandler.SetAuditService(auditService) authHandler.SetAuditService(auditService)
if deps.TaskEnqueuer != nil {
authHandler.SetEnqueuer(deps.TaskEnqueuer)
}
userHandler := handlers.NewUserHandler(userService) userHandler := handlers.NewUserHandler(userService)
residenceHandler := handlers.NewResidenceHandler(residenceService, deps.PDFService, deps.EmailService, cfg.Features.PDFReportsEnabled) residenceHandler := handlers.NewResidenceHandler(residenceService, deps.PDFService, deps.EmailService, cfg.Features.PDFReportsEnabled)
taskHandler := handlers.NewTaskHandler(taskService, deps.StorageService) taskHandler := handlers.NewTaskHandler(taskService, deps.StorageService)
@@ -301,8 +331,13 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
// API group // API group
api := e.Group("/api") api := e.Group("/api")
{ {
// Public auth routes (no auth required) // Session lifecycle (login, logout, password reset, email verification)
setupPublicAuthRoutes(api, authHandler, cfg.Server.Debug) // 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) // Public data routes (no auth required)
setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler, taskTemplateHandler) 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) // Subscription webhook routes (no auth - called by Apple/Google servers)
setupWebhookRoutes(api, subscriptionWebhookHandler) 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 := api.Group("")
protected.Use(authMiddleware.TokenAuth()) protected.Use(authMiddleware.Authenticate())
protected.Use(custommiddleware.TimezoneMiddleware()) protected.Use(custommiddleware.TimezoneMiddleware())
{ {
// Allow-list — authenticated, may be unverified.
setupProtectedAuthRoutes(protected, authHandler) setupProtectedAuthRoutes(protected, authHandler)
setupResidenceRoutes(protected, residenceHandler)
setupTaskRoutes(protected, taskHandler) // Verified routes (authenticated AND email-verified) — the DEFAULT
setupSuggestionRoutes(protected, suggestionHandler) // for all app data and actions. RequireVerified is applied ONCE at
setupContractorRoutes(protected, contractorHandler) // the group level so verification is the default and every route
setupDocumentRoutes(protected, documentHandler) // added under here is gated automatically. (The previous per-route
setupNotificationRoutes(protected, notificationHandler) // approach left ~70 routes unverified — see the LIVE auth audit.)
setupSubscriptionRoutes(protected, subscriptionHandler) verified := protected.Group("")
setupUserRoutes(protected, userHandler) 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) // Upload routes (only if storage service is configured)
if uploadHandler != nil { if uploadHandler != nil {
setupUploadRoutes(protected, uploadHandler) setupUploadRoutes(verified, uploadHandler)
} }
// Media routes (authenticated media serving) // Media routes (verified media serving)
if mediaHandler != nil { 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 // setupPublicAuthRoutes was removed — session lifecycle (login, register,
// per-endpoint rate limiters to mitigate brute-force and credential-stuffing. // logout, password reset, Apple/Google sign-in) is delegated to Ory Kratos.
// 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")
if debug { // setupProtectedAuthRoutes configures protected auth routes.
// No rate limiters in debug/local mode // Session lifecycle (login, logout, password reset, email verification) is
auth.POST("/login/", authHandler.Login) // delegated to Ory Kratos — only profile and account-deletion routes remain.
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
func setupProtectedAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler) { func setupProtectedAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler) {
auth := api.Group("/auth") auth := api.Group("/auth")
{ {
auth.POST("/logout/", authHandler.Logout)
auth.POST("/refresh/", authHandler.RefreshToken)
auth.GET("/me/", authHandler.CurrentUser) auth.GET("/me/", authHandler.CurrentUser)
auth.PUT("/profile/", authHandler.UpdateProfile) auth.PUT("/profile/", authHandler.UpdateProfile)
auth.PATCH("/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.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.DELETE("/:id/", residenceHandler.DeleteResidence)
residences.GET("/:id/share-code/", residenceHandler.GetShareCode) 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-code/", residenceHandler.GenerateShareCode)
residences.POST("/:id/generate-share-package/", residenceHandler.GenerateSharePackage) residences.POST("/:id/generate-share-package/", residenceHandler.GenerateSharePackage)
residences.POST("/:id/generate-tasks-report/", residenceHandler.GenerateTasksReport) residences.POST("/:id/generate-tasks-report/", residenceHandler.GenerateTasksReport)
-301
View File
@@ -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