Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
adminSetPasswordBulkLimit = 100
|
||||
adminSetPasswordReqPerMin = 5
|
||||
adminSetPasswordSendPerMin = 60
|
||||
)
|
||||
|
||||
// handleAdminListUsers / handleAdminListCompanies live in admin_orgs_handlers.go.
|
||||
|
||||
// handleAdminReadiness returns cutover hypercare counts for platform admins (P1-15).
|
||||
// GET /api/admin/readiness
|
||||
//
|
||||
// companies_without_api_keys counts tenants with zero non-revoked keys. Legacy
|
||||
// api_keys were never ETL'd — this is the reissue inventory (not a fake migration).
|
||||
func (s *Server) handleAdminReadiness(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Pool == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "database unavailable")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var mustSetPassword, withoutAdmin, withoutPlan, withoutAPIKeys int64
|
||||
|
||||
if err := s.Pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM users
|
||||
WHERE must_set_password = true AND is_active = true),
|
||||
(SELECT COUNT(*) FROM companies c
|
||||
WHERE c.id <> $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM memberships m
|
||||
WHERE m.company_id = c.id AND m.role = 'admin' AND m.status = 'active'
|
||||
)),
|
||||
(SELECT COUNT(*) FROM companies c
|
||||
WHERE c.id <> $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM company_plans cp
|
||||
WHERE cp.company_id = c.id AND cp.is_active = true
|
||||
)),
|
||||
(SELECT COUNT(*) FROM companies c
|
||||
WHERE c.id <> $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM api_keys k
|
||||
WHERE k.company_id = c.id AND k.revoked_at IS NULL
|
||||
))
|
||||
`, platformsettings.SystemCompanyID).Scan(&mustSetPassword, &withoutAdmin, &withoutPlan, &withoutAPIKeys); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "readiness counts failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"must_set_password": mustSetPassword,
|
||||
"companies_without_admin": withoutAdmin,
|
||||
"companies_without_plan": withoutPlan,
|
||||
"companies_without_api_keys": withoutAPIKeys,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListJobs(w http.ResponseWriter, r *http.Request) {
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
rows, err := s.Pool.Query(r.Context(), `
|
||||
SELECT id, company_id, status, total_products, processed_products, error, created_at, updated_at
|
||||
FROM processing_jobs
|
||||
ORDER BY created_at DESC LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type row struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CompanyID uuid.UUID `json:"company_id"`
|
||||
Status string `json:"status"`
|
||||
TotalProducts int `json:"total_products"`
|
||||
ProcessedProducts int `json:"processed_products"`
|
||||
Error *string `json:"error"`
|
||||
CreatedAt any `json:"created_at"`
|
||||
UpdatedAt any `json:"updated_at"`
|
||||
}
|
||||
out := make([]row, 0)
|
||||
for rows.Next() {
|
||||
var j row
|
||||
if err := rows.Scan(&j.ID, &j.CompanyID, &j.Status, &j.TotalProducts, &j.ProcessedProducts, &j.Error, &j.CreatedAt, &j.UpdatedAt); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "scan failed")
|
||||
return
|
||||
}
|
||||
if j.Error != nil && *j.Error != "" {
|
||||
redacted := processing.TruncateError(errors.New(*j.Error))
|
||||
j.Error = &redacted
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"jobs": out, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminStuckCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := processing.CleanupStuck(r.Context(), s.Pool)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "cleanup failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"jobs_marked_failed": res.JobsMarkedFailed,
|
||||
"products_reset": res.ProductsReset,
|
||||
"sync_jobs_marked_failed": res.SyncJobsMarkedFailed,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminOrphanProcessedReport(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := processing.ReportOrphanProcessed(r.Context(), s.Pool)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "orphan report failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminOrphanProcessedCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
confirm := r.URL.Query().Get("confirm") == "true"
|
||||
var body struct {
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
if err := DecodeJSONOptional(r, &body); err == nil && body.Confirm {
|
||||
confirm = true
|
||||
}
|
||||
res, err := processing.CleanupOrphanProcessed(r.Context(), s.Pool, confirm)
|
||||
if err != nil {
|
||||
if errors.Is(err, processing.ErrOrphanCleanupEmpty) ||
|
||||
errors.Is(err, processing.ErrOrphanCleanupA1Protected) {
|
||||
if msg, ok := processing.ClientError(err); ok {
|
||||
Error(w, http.StatusConflict, msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "orphan cleanup failed")
|
||||
return
|
||||
}
|
||||
if !confirm {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"dry_run": true,
|
||||
"deleted": 0,
|
||||
"message": "pass confirm=true (query or JSON body) to delete; report only",
|
||||
"report": res,
|
||||
})
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (s *Server) ensureAdminSetPasswordLimiters() {
|
||||
s.adminSetPasswordOnce.Do(func() {
|
||||
s.adminSetPasswordReqRL = newSlidingWindowLimiter(adminSetPasswordReqPerMin, time.Minute)
|
||||
s.adminSetPasswordSendRL = newSlidingWindowLimiter(adminSetPasswordSendPerMin, time.Minute)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Mail == nil || s.Auth == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "mailer unavailable")
|
||||
return
|
||||
}
|
||||
adminID, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
s.ensureAdminSetPasswordLimiters()
|
||||
reqKey := "admin-set-password:" + adminID.String()
|
||||
if !s.adminSetPasswordReqRL.allow(reqKey) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
Error(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
UserID *uuid.UUID `json:"user_id"`
|
||||
}
|
||||
if err := DecodeJSONOptional(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
|
||||
var targets []uuid.UUID
|
||||
if body.UserID != nil {
|
||||
targets = []uuid.UUID{*body.UserID}
|
||||
} else {
|
||||
users, err := s.Auth.ListUsersNeedingPassword(r.Context(), adminSetPasswordBulkLimit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
for _, u := range users {
|
||||
targets = append(targets, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
smtpOn := s.Mail.Enabled()
|
||||
sent := 0
|
||||
issued := 0
|
||||
skippedSynthetic := 0
|
||||
skippedIneligible := 0
|
||||
skippedRateLimited := 0
|
||||
skippedSend := 0
|
||||
var singleToken string
|
||||
singleUser := body.UserID != nil
|
||||
|
||||
for _, uid := range targets {
|
||||
sendKey := "admin-set-password-send:" + adminID.String()
|
||||
if !s.adminSetPasswordSendRL.allow(sendKey) {
|
||||
skippedRateLimited++
|
||||
if singleUser {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
Error(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
token, email, mode, err := s.issueSetPasswordDelivery(r.Context(), uid)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrSyntheticEmail):
|
||||
skippedSynthetic++
|
||||
default:
|
||||
skippedIneligible++
|
||||
}
|
||||
continue
|
||||
}
|
||||
issued++
|
||||
|
||||
var msg mail.Message
|
||||
if mode == "invite" {
|
||||
msg = mail.MigratedSetPasswordMessage(s.Config.WebOrigin, email, token)
|
||||
} else {
|
||||
msg = mail.SetPasswordMessage(s.Config.WebOrigin, email, token)
|
||||
}
|
||||
if err := s.Mail.Send(msg); err != nil {
|
||||
log.Printf("admin set-password send failed user_id=%s", uid)
|
||||
skippedSend++
|
||||
continue
|
||||
}
|
||||
if smtpOn {
|
||||
sent++
|
||||
} else if singleUser {
|
||||
// Share token only for single-user reissue when SMTP is off (no email in response).
|
||||
singleToken = token
|
||||
}
|
||||
}
|
||||
|
||||
skipped := skippedSynthetic + skippedIneligible + skippedRateLimited + skippedSend
|
||||
resp := map[string]any{
|
||||
"sent": sent,
|
||||
"issued": issued,
|
||||
"skipped": skipped,
|
||||
"skipped_synthetic": skippedSynthetic,
|
||||
"skipped_ineligible": skippedIneligible,
|
||||
"skipped_rate_limited": skippedRateLimited,
|
||||
"skipped_send": skippedSend,
|
||||
"smtp_enabled": smtpOn,
|
||||
"mode": "invite",
|
||||
}
|
||||
if singleToken != "" {
|
||||
resp["token"] = singleToken
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// issueSetPasswordDelivery prefers a durable invite; falls back to HMAC when the user
|
||||
// still needs a password but has no active membership. Never logs email or token.
|
||||
func (s *Server) issueSetPasswordDelivery(ctx context.Context, userID uuid.UUID) (token, email, mode string, err error) {
|
||||
inv, err := s.Auth.ReissueSetPasswordInvite(ctx, userID, 0)
|
||||
if err == nil {
|
||||
return inv.Token, inv.Email, "invite", nil
|
||||
}
|
||||
if errors.Is(err, auth.ErrSyntheticEmail) {
|
||||
return "", "", "", err
|
||||
}
|
||||
if !errors.Is(err, auth.ErrNotEligibleSetPassword) && !errors.Is(err, auth.ErrUserNotFound) {
|
||||
log.Printf("admin set-password invite failed user_id=%s", userID)
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
u, gerr := s.Auth.GetUser(ctx, userID)
|
||||
if gerr != nil || !u.MustSetPassword || !u.IsActive {
|
||||
return "", "", "", auth.ErrNotEligibleSetPassword
|
||||
}
|
||||
if auth.IsSyntheticLegacyEmail(u.Email) {
|
||||
return "", "", "", auth.ErrSyntheticEmail
|
||||
}
|
||||
token, terr := auth.IssueSetPasswordToken(s.Config.TokenSigningSecret, u.ID, 0)
|
||||
if terr != nil {
|
||||
log.Printf("admin hmac set-password token failed user_id=%s", userID)
|
||||
return "", "", "", auth.ErrNotEligibleSetPassword
|
||||
}
|
||||
return token, u.Email, "hmac", nil
|
||||
}
|
||||
Reference in New Issue
Block a user