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>
133 lines
4.0 KiB
Go
133 lines
4.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
|
)
|
|
|
|
// instancePublic serves unauthenticated bootstrap state to the login page.
|
|
// It intentionally exposes only what the sign-in UI needs.
|
|
func (h *HTTPHandler) instancePublic(c *gin.Context) {
|
|
users, err := h.Settings.AuthUserCount(c.Request.Context())
|
|
if err != nil {
|
|
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
|
return
|
|
}
|
|
signupEnabled := false
|
|
if users > 0 {
|
|
signupEnabled, err = h.Settings.GetBool(c.Request.Context(), "auth.signup_enabled")
|
|
if err != nil {
|
|
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
|
return
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"signup_enabled": signupEnabled,
|
|
"bootstrap_required": users == 0,
|
|
"social_providers": managedSocialProviders(),
|
|
})
|
|
}
|
|
|
|
// managedSocialProviders mirrors the auth service's managed gate: OAuth only
|
|
// exists when PRIMORA_MANAGED=true and the provider's client id is set.
|
|
func managedSocialProviders() []string {
|
|
if os.Getenv("PRIMORA_MANAGED") != "true" {
|
|
return []string{}
|
|
}
|
|
providers := []string{}
|
|
for _, p := range []struct{ name, envKey string }{
|
|
{"github", "GITHUB_CLIENT_ID"},
|
|
{"google", "GOOGLE_CLIENT_ID"},
|
|
{"discord", "DISCORD_CLIENT_ID"},
|
|
{"microsoft", "MICROSOFT_CLIENT_ID"},
|
|
} {
|
|
if os.Getenv(p.envKey) != "" {
|
|
providers = append(providers, p.name)
|
|
}
|
|
}
|
|
return providers
|
|
}
|
|
|
|
// requireInstanceAdmin gates handlers on the platform admin role (better-auth
|
|
// `user.role`), which is distinct from org/project roles.
|
|
func (h *HTTPHandler) requireInstanceAdmin(c *gin.Context) bool {
|
|
actor, ok := middleware.RequireActor(c)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if !actor.IsUser() || !h.Settings.IsAdmin(c.Request.Context(), actor.AuthSubject) {
|
|
apperrors.Abort(c, http.StatusForbidden, "admin_required", "instance admin role required")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (h *HTTPHandler) listInstanceSettings(c *gin.Context) {
|
|
if !h.requireInstanceAdmin(c) {
|
|
return
|
|
}
|
|
settings, err := h.Settings.ListResolved(c.Request.Context())
|
|
if err != nil {
|
|
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"settings": settings})
|
|
}
|
|
|
|
func (h *HTTPHandler) updateInstanceSetting(c *gin.Context) {
|
|
if !h.requireInstanceAdmin(c) {
|
|
return
|
|
}
|
|
key := c.Param("key")
|
|
if _, ok := services.SettingSpecs[key]; !ok {
|
|
apperrors.Abort(c, http.StatusNotFound, "unknown_setting", "unknown setting key")
|
|
return
|
|
}
|
|
var body struct {
|
|
Value json.RawMessage `json:"value" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Value) == 0 {
|
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "expected {\"value\": ...}")
|
|
return
|
|
}
|
|
actor, _ := middleware.ActorFromContext(c)
|
|
if err := h.Settings.Store(c.Request.Context(), key, body.Value, actor.Email); err != nil {
|
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_value", err.Error())
|
|
return
|
|
}
|
|
value, source, err := h.Settings.Resolve(c.Request.Context(), key)
|
|
if err != nil {
|
|
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
|
return
|
|
}
|
|
spec := services.SettingSpecs[key]
|
|
resp := gin.H{"key": key, "type": spec.Type, "secret": spec.Secret, "is_set": true, "source": source}
|
|
if !spec.Secret {
|
|
resp["value"] = value
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *HTTPHandler) deleteInstanceSetting(c *gin.Context) {
|
|
if !h.requireInstanceAdmin(c) {
|
|
return
|
|
}
|
|
key := c.Param("key")
|
|
if _, ok := services.SettingSpecs[key]; !ok {
|
|
apperrors.Abort(c, http.StatusNotFound, "unknown_setting", "unknown setting key")
|
|
return
|
|
}
|
|
if err := h.Settings.Delete(c.Request.Context(), key); err != nil {
|
|
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|