mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
Self-hosted instances are now email/password only — OAuth providers (GitHub/Google/Discord/Microsoft) activate solely when PRIMORA_MANAGED=true, reserved for the future hosted tier. Sign-up lifecycle: the first public sign-up becomes the instance admin; after bootstrap, sign-up closes by default. A better-auth databaseHook gates only /sign-up/email, so admin-created users and OAuth flows bypass it. The login page reads GET /instance/public to hide the sign-up tab, show the bootstrap notice, and render only the providers the instance offers. Instance settings live in core.settings (migration 00010) and resolve in-app > env > built-in default, cached 15s. Platform admins (better-auth user.role) manage sign-up, mail transport, and rate limits via GET/PUT/DELETE /instance/settings or the new Settings → Instance tab. Secret values are AES-256-GCM encrypted at rest with PRIMORA_ENCRYPTION_KEY, never returned by the API, and decrypted by the auth service at send time. Auth service reads the same settings table for the sign-up gate and mail config, so in-app changes take effect without a restart. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
346 lines
10 KiB
Go
346 lines
10 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
|
"github.com/tdvorak/primora/apps/backend/internal/repositories"
|
|
"github.com/tdvorak/primora/apps/backend/internal/secrets"
|
|
)
|
|
|
|
// SettingSpec describes a settable instance key: value type, the env var used
|
|
// as fallback when no DB row exists, and a built-in default.
|
|
type SettingSpec struct {
|
|
Key string
|
|
Type string // "bool" | "int" | "string"
|
|
Secret bool
|
|
EnvKey string
|
|
Default string
|
|
}
|
|
|
|
// SettingSpecs is the registry of keys manageable in-app. Env vars stay as
|
|
// defaults — an app value wins when present.
|
|
var SettingSpecs = map[string]SettingSpec{
|
|
"auth.signup_enabled": {Key: "auth.signup_enabled", Type: "bool", EnvKey: "SIGNUP_ENABLED", Default: "false"},
|
|
"mail.from": {Key: "mail.from", Type: "string", EnvKey: "MAIL_FROM", Default: "Primora <[email protected]>"},
|
|
"mail.resend_api_key": {Key: "mail.resend_api_key", Type: "string", Secret: true, EnvKey: "RESEND_API_KEY"},
|
|
"mail.smtp_host": {Key: "mail.smtp_host", Type: "string", EnvKey: "SMTP_HOST"},
|
|
"mail.smtp_port": {Key: "mail.smtp_port", Type: "int", EnvKey: "SMTP_PORT", Default: "1025"},
|
|
"mail.smtp_user": {Key: "mail.smtp_user", Type: "string", EnvKey: "SMTP_USER"},
|
|
"mail.smtp_password": {Key: "mail.smtp_password", Type: "string", Secret: true, EnvKey: "SMTP_PASSWORD"},
|
|
"mail.smtp_secure": {Key: "mail.smtp_secure", Type: "bool", EnvKey: "SMTP_SECURE", Default: "false"},
|
|
"ratelimit.user_per_minute": {Key: "ratelimit.user_per_minute", Type: "int", EnvKey: "USER_RATE_LIMIT_PER_MINUTE", Default: "240"},
|
|
"ratelimit.api_key_per_minute": {Key: "ratelimit.api_key_per_minute", Type: "int", EnvKey: "API_KEY_RATE_LIMIT_PER_MINUTE", Default: "600"},
|
|
}
|
|
|
|
const settingsCacheTTL = 15 * time.Second
|
|
|
|
// resolvedSetting holds the stored string form: plaintext for normal keys,
|
|
// base64 ciphertext for secrets — Resolve() decrypts on read.
|
|
type resolvedSetting struct {
|
|
value string
|
|
secret bool
|
|
}
|
|
|
|
// SettingsService resolves instance settings: DB row > env var > default.
|
|
// Values cache briefly so per-request consumers (rate limits) stay cheap.
|
|
type SettingsService struct {
|
|
repo *repositories.CoreRepository
|
|
encryptor *secrets.Encryptor
|
|
|
|
mu sync.Mutex
|
|
cache map[string]resolvedSetting
|
|
cacheAt time.Time
|
|
}
|
|
|
|
func NewSettingsService(repo *repositories.CoreRepository, encryptor *secrets.Encryptor) *SettingsService {
|
|
return &SettingsService{repo: repo, encryptor: encryptor}
|
|
}
|
|
|
|
// Resolve returns the effective string value plus its source
|
|
// ("app" | "env" | "default"). Secret DB values are decrypted.
|
|
func (s *SettingsService) Resolve(ctx context.Context, key string) (value, source string, err error) {
|
|
spec, ok := SettingSpecs[key]
|
|
if !ok {
|
|
return "", "", fmt.Errorf("unknown setting: %s", key)
|
|
}
|
|
row, err := s.stored(ctx, key)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if row != nil {
|
|
raw := row.value
|
|
if row.secret {
|
|
raw, err = s.decrypt(raw)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("decrypt %s: %w", key, err)
|
|
}
|
|
}
|
|
return raw, "app", nil
|
|
}
|
|
if env := os.Getenv(spec.EnvKey); env != "" {
|
|
return env, "env", nil
|
|
}
|
|
return spec.Default, "default", nil
|
|
}
|
|
|
|
func (s *SettingsService) GetBool(ctx context.Context, key string) (bool, error) {
|
|
raw, _, err := s.Resolve(ctx, key)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
v, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return false, fmt.Errorf("setting %s is not a bool: %q", key, raw)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func (s *SettingsService) GetInt(ctx context.Context, key string) (int, error) {
|
|
raw, _, err := s.Resolve(ctx, key)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
v, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("setting %s is not an int: %q", key, raw)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// RateLimitPerMinute is the hot-path helper for the auth middleware.
|
|
func (s *SettingsService) RateLimitPerMinute(ctx context.Context, scope string) int {
|
|
key := "ratelimit.user_per_minute"
|
|
if scope == "api_key" {
|
|
key = "ratelimit.api_key_per_minute"
|
|
}
|
|
v, err := s.GetInt(ctx, key)
|
|
if err != nil {
|
|
spec := SettingSpecs[key]
|
|
fallback, _ := strconv.Atoi(spec.Default)
|
|
return fallback
|
|
}
|
|
return v
|
|
}
|
|
|
|
// ValidateAndStore type-checks the raw JSON value against the registry and
|
|
// persists it. Secret values are encrypted before they touch the DB.
|
|
func (s *SettingsService) Store(ctx context.Context, key string, raw json.RawMessage, updatedBy string) error {
|
|
spec, ok := SettingSpecs[key]
|
|
if !ok {
|
|
return fmt.Errorf("unknown setting: %s", key)
|
|
}
|
|
plain, err := normalizeSetting(spec, raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
value := []byte(strconv.Quote(plain))
|
|
if spec.Secret {
|
|
ciphertext, err := s.encryptor.Encrypt([]byte(plain))
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt %s: %w", key, err)
|
|
}
|
|
value = []byte(strconv.Quote(base64.StdEncoding.EncodeToString(ciphertext)))
|
|
}
|
|
_, err = s.repo.UpsertSetting(ctx, db.UpsertSettingParams{
|
|
Key: key,
|
|
Column2: value,
|
|
Secret: spec.Secret,
|
|
UpdatedBy: &updatedBy,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.invalidate()
|
|
return nil
|
|
}
|
|
|
|
func (s *SettingsService) Delete(ctx context.Context, key string) error {
|
|
if _, ok := SettingSpecs[key]; !ok {
|
|
return fmt.Errorf("unknown setting: %s", key)
|
|
}
|
|
if err := s.repo.DeleteSetting(ctx, key); err != nil {
|
|
return err
|
|
}
|
|
s.invalidate()
|
|
return nil
|
|
}
|
|
|
|
// IsAdmin reports whether the actor holds the platform admin role
|
|
// (better-auth `user.role`), not an org/project role.
|
|
func (s *SettingsService) IsAdmin(ctx context.Context, authSubject string) bool {
|
|
role, err := s.repo.GetAuthUserRole(ctx, authSubject)
|
|
return err == nil && role == "admin"
|
|
}
|
|
|
|
func (s *SettingsService) AuthUserCount(ctx context.Context) (int64, error) {
|
|
return s.repo.CountAuthUsers(ctx)
|
|
}
|
|
|
|
// ListResolved returns every registered key with its effective state.
|
|
// Secret values are masked — never returned.
|
|
func (s *SettingsService) ListResolved(ctx context.Context) ([]ResolvedSetting, error) {
|
|
stored, err := s.loadStored(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]ResolvedSetting, 0, len(SettingSpecs))
|
|
for _, key := range sortedSettingKeys() {
|
|
spec := SettingSpecs[key]
|
|
entry := ResolvedSetting{Key: key, Secret: spec.Secret, Type: spec.Type}
|
|
if row, ok := stored[key]; ok {
|
|
entry.IsSet = true
|
|
entry.Source = "app"
|
|
if !spec.Secret {
|
|
entry.Value = json.RawMessage(strconv.Quote(row.value))
|
|
}
|
|
} else if env := os.Getenv(spec.EnvKey); env != "" {
|
|
entry.Source = "env"
|
|
if !spec.Secret {
|
|
entry.Value = json.RawMessage(strconv.Quote(env))
|
|
}
|
|
} else {
|
|
entry.Source = "default"
|
|
if !spec.Secret {
|
|
entry.Value = json.RawMessage(strconv.Quote(spec.Default))
|
|
}
|
|
}
|
|
out = append(out, entry)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type ResolvedSetting struct {
|
|
Key string `json:"key"`
|
|
Type string `json:"type"`
|
|
Secret bool `json:"secret"`
|
|
IsSet bool `json:"is_set"`
|
|
Source string `json:"source"`
|
|
Value json.RawMessage `json:"value,omitempty"`
|
|
}
|
|
|
|
func sortedSettingKeys() []string {
|
|
keys := make([]string, 0, len(SettingSpecs))
|
|
for k := range SettingSpecs {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func (s *SettingsService) loadStored(ctx context.Context) (map[string]resolvedSetting, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.cache != nil && time.Since(s.cacheAt) < settingsCacheTTL {
|
|
return s.cache, nil
|
|
}
|
|
rows, err := s.repo.ListSettings(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fresh := make(map[string]resolvedSetting, len(rows))
|
|
for _, r := range rows {
|
|
encoded, err := unwrapSettingValue(r.Value)
|
|
if err != nil {
|
|
continue // unreadable row — treat as unset rather than break consumers
|
|
}
|
|
fresh[r.Key] = resolvedSetting{value: encoded, secret: r.Secret}
|
|
}
|
|
s.cache = fresh
|
|
s.cacheAt = time.Now()
|
|
return fresh, nil
|
|
}
|
|
|
|
func (s *SettingsService) stored(ctx context.Context, key string) (*resolvedSetting, error) {
|
|
all, err := s.loadStored(ctx)
|
|
if err != nil {
|
|
// cold-cache fallback: direct lookup
|
|
row, err := s.repo.GetSetting(ctx, key)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
encoded, err := unwrapSettingValue(row.Value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &resolvedSetting{value: encoded, secret: row.Secret}, nil
|
|
}
|
|
if row, ok := all[key]; ok {
|
|
return &row, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *SettingsService) decrypt(ciphertextB64 string) (string, error) {
|
|
raw, err := base64.StdEncoding.DecodeString(ciphertextB64)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
plain, err := s.encryptor.Decrypt(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(plain), nil
|
|
}
|
|
|
|
func (s *SettingsService) invalidate() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.cache = nil
|
|
}
|
|
|
|
// unwrapSettingValue decodes the JSONB column: every stored value is a JSON
|
|
// string — the plaintext for normal keys, base64 ciphertext for secrets.
|
|
func unwrapSettingValue(raw []byte) (string, error) {
|
|
var encoded string
|
|
if err := json.Unmarshal(raw, &encoded); err != nil {
|
|
return "", err
|
|
}
|
|
return encoded, nil
|
|
}
|
|
|
|
// normalizeSetting validates raw JSON against the spec type and returns the
|
|
// canonical string form stored/compared internally.
|
|
func normalizeSetting(spec SettingSpec, raw json.RawMessage) (string, error) {
|
|
switch spec.Type {
|
|
case "bool":
|
|
var v bool
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return "", fmt.Errorf("%s expects a boolean", spec.Key)
|
|
}
|
|
return strconv.FormatBool(v), nil
|
|
case "int":
|
|
var v int
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return "", fmt.Errorf("%s expects an integer", spec.Key)
|
|
}
|
|
if v < 0 {
|
|
return "", fmt.Errorf("%s must be >= 0", spec.Key)
|
|
}
|
|
return strconv.Itoa(v), nil
|
|
default:
|
|
var v string
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return "", fmt.Errorf("%s expects a string", spec.Key)
|
|
}
|
|
if len(v) > 512 {
|
|
return "", fmt.Errorf("%s exceeds 512 chars", spec.Key)
|
|
}
|
|
return v, nil
|
|
}
|
|
}
|