Files
member-console/internal/server/fedwiki.go

350 lines
11 KiB
Go

package server
import (
"encoding/json"
"log/slog"
"net/http"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/fedwiki"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
"github.com/google/uuid"
"go.temporal.io/sdk/client"
)
// FedWikiHandler handles FedWiki site management API endpoints.
type FedWikiHandler struct {
DB db.Querier
Logger *slog.Logger
TemporalClient client.Client
AuthConfig *auth.Config
FedWikiAllowedDomains []string // Domains where users can create sites
FedWikiSiteScheme string // http or https
SupportURL string
}
// FedWikiHandlerConfig holds configuration for creating a FedWikiHandler.
type FedWikiHandlerConfig struct {
DB db.Querier
Logger *slog.Logger
TemporalClient client.Client
AuthConfig *auth.Config
FedWikiAllowedDomains []string
FedWikiSiteScheme string
SupportURL string
}
// NewFedWikiHandler creates a new FedWikiHandler.
func NewFedWikiHandler(cfg FedWikiHandlerConfig) *FedWikiHandler {
return &FedWikiHandler{
DB: cfg.DB,
Logger: cfg.Logger,
TemporalClient: cfg.TemporalClient,
AuthConfig: cfg.AuthConfig,
FedWikiAllowedDomains: cfg.FedWikiAllowedDomains,
FedWikiSiteScheme: cfg.FedWikiSiteScheme,
SupportURL: cfg.SupportURL,
}
}
// RegisterRoutes registers all FedWiki API routes.
func (h *FedWikiHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/fedwiki/sites", h.ListSites)
mux.HandleFunc("POST /api/fedwiki/sites", h.CreateSite)
mux.HandleFunc("DELETE /api/fedwiki/sites/{domain}", h.DeleteSite)
mux.HandleFunc("GET /api/fedwiki/quota", h.GetQuota)
}
// getUserIDFromSession extracts the user's database ID from the session.
func (h *FedWikiHandler) getUserIDFromSession(r *http.Request) (int64, error) {
session, err := h.AuthConfig.Store.Get(r, h.AuthConfig.SessionName)
if err != nil {
return 0, err
}
userID, ok := session.Values["user_db_id"].(int64)
if !ok {
return 0, err
}
return userID, nil
}
// respondJSON writes a JSON response.
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data != nil {
json.NewEncoder(w).Encode(data)
}
}
// respondError writes a JSON error response.
func respondError(w http.ResponseWriter, status int, message string, supportURL string) {
resp := map[string]interface{}{
"error": message,
"success": false,
}
if supportURL != "" {
resp["supportURL"] = supportURL
}
respondJSON(w, status, resp)
}
// ListSitesResponse is the response for listing sites.
type ListSitesResponse struct {
Success bool `json:"success"`
Sites []SiteDTO `json:"sites"`
}
// SiteDTO is the data transfer object for a site.
type SiteDTO struct {
ID int64 `json:"id"`
Domain string `json:"domain"`
IsCustomDomain bool `json:"isCustomDomain"`
CreatedAt string `json:"createdAt"`
URL string `json:"url"`
}
// ListSites handles GET /api/fedwiki/sites - returns user's sites.
func (h *FedWikiHandler) ListSites(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserIDFromSession(r)
if err != nil {
h.Logger.Error("failed to get user ID from session", slog.Any("error", err))
respondError(w, http.StatusUnauthorized, "Unauthorized", "")
return
}
sites, err := h.DB.GetSitesByUserID(r.Context(), userID)
if err != nil {
h.Logger.Error("failed to get user sites", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to retrieve sites", h.SupportURL)
return
}
siteDTOs := make([]SiteDTO, len(sites))
for i, site := range sites {
siteDTOs[i] = SiteDTO{
ID: site.ID,
Domain: site.Domain,
IsCustomDomain: site.IsCustomDomain != 0,
CreatedAt: site.CreatedAt.Format("2006-01-02T15:04:05Z"),
URL: h.buildSiteURL(site.Domain, site.IsCustomDomain != 0),
}
}
respondJSON(w, http.StatusOK, ListSitesResponse{
Success: true,
Sites: siteDTOs,
})
}
// buildSiteURL constructs the full URL for a site.
func (h *FedWikiHandler) buildSiteURL(domain string, isCustomDomain bool) string {
scheme := h.FedWikiSiteScheme
if scheme == "" {
scheme = "https"
}
// Domain is already stored as full domain (e.g., test.localtest.me or custom.example.com)
return scheme + "://" + domain
}
// CreateSiteRequest is the request body for creating a site.
type CreateSiteRequest struct {
Domain string `json:"domain"`
IsCustomDomain bool `json:"isCustomDomain"`
}
// CreateSiteResponse is the response for creating a site.
type CreateSiteResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
WorkflowID string `json:"workflowId,omitempty"`
}
// CreateSite handles POST /api/fedwiki/sites - initiates site creation workflow.
func (h *FedWikiHandler) CreateSite(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserIDFromSession(r)
if err != nil {
h.Logger.Error("failed to get user ID from session", slog.Any("error", err))
respondError(w, http.StatusUnauthorized, "Unauthorized", "")
return
}
var req CreateSiteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", "")
return
}
// Validate domain
if req.Domain == "" {
respondError(w, http.StatusBadRequest, "Domain is required", "")
return
}
// Get user info for the workflow
user, err := h.DB.GetUserByID(r.Context(), userID)
if err != nil {
h.Logger.Error("failed to get user", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to get user information", h.SupportURL)
return
}
// Check if Temporal client is available
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
respondError(w, http.StatusServiceUnavailable, "Site provisioning service is not available", h.SupportURL)
return
}
// Start the workflow
workflowID := "create-fedwiki-site-" + uuid.New().String()
workflowOptions := client.StartWorkflowOptions{
ID: workflowID,
TaskQueue: queues.Main,
}
input := fedwiki.CreateFedWikiSiteWorkflowInput{
UserID: userID,
Domain: req.Domain,
OwnerName: user.Username,
OwnerID: user.OidcSubject,
IsCustomDomain: req.IsCustomDomain,
SupportURL: h.SupportURL,
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, fedwiki.CreateFedWikiSiteWorkflow, input)
if err != nil {
h.Logger.Error("failed to start workflow",
slog.String("workflowID", workflowID),
slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to initiate site creation", h.SupportURL)
return
}
h.Logger.Info("site creation workflow started",
slog.String("workflowID", we.GetID()),
slog.String("runID", we.GetRunID()),
slog.String("domain", req.Domain))
respondJSON(w, http.StatusAccepted, CreateSiteResponse{
Success: true,
Message: "Site creation initiated. This may take a few minutes.",
WorkflowID: we.GetID(),
})
}
// DeleteSite handles DELETE /api/fedwiki/sites/{domain} - initiates site deletion workflow.
func (h *FedWikiHandler) DeleteSite(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserIDFromSession(r)
if err != nil {
h.Logger.Error("failed to get user ID from session", slog.Any("error", err))
respondError(w, http.StatusUnauthorized, "Unauthorized", "")
return
}
domain := r.PathValue("domain")
if domain == "" {
respondError(w, http.StatusBadRequest, "Domain is required", "")
return
}
// Verify the site belongs to the user
site, err := h.DB.GetSiteByDomain(r.Context(), domain)
if err != nil {
h.Logger.Error("failed to get site", slog.Any("error", err))
respondError(w, http.StatusNotFound, "Site not found", "")
return
}
if site.UserID != userID {
respondError(w, http.StatusForbidden, "You do not own this site", "")
return
}
// Check if Temporal client is available
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
respondError(w, http.StatusServiceUnavailable, "Site provisioning service is not available", h.SupportURL)
return
}
// Start the workflow
workflowID := "delete-fedwiki-site-" + uuid.New().String()
workflowOptions := client.StartWorkflowOptions{
ID: workflowID,
TaskQueue: queues.Main,
}
input := fedwiki.DeleteFedWikiSiteWorkflowInput{
UserID: userID,
Domain: domain,
SupportURL: h.SupportURL,
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, fedwiki.DeleteFedWikiSiteWorkflow, input)
if err != nil {
h.Logger.Error("failed to start workflow",
slog.String("workflowID", workflowID),
slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to initiate site deletion", h.SupportURL)
return
}
h.Logger.Info("site deletion workflow started",
slog.String("workflowID", we.GetID()),
slog.String("runID", we.GetRunID()),
slog.String("domain", domain))
respondJSON(w, http.StatusAccepted, map[string]interface{}{
"success": true,
"message": "Site deletion initiated. This may take a few minutes.",
"workflowId": we.GetID(),
})
}
// QuotaResponse is the response for getting quota information.
type QuotaResponse struct {
Success bool `json:"success"`
CurrentCount int64 `json:"currentCount"`
Quota int64 `json:"quota"`
CanCreate bool `json:"canCreate"`
IsMember bool `json:"isMember"`
}
// GetQuota handles GET /api/fedwiki/quota - returns user's quota information.
func (h *FedWikiHandler) GetQuota(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserIDFromSession(r)
if err != nil {
h.Logger.Error("failed to get user ID from session", slog.Any("error", err))
respondError(w, http.StatusUnauthorized, "Unauthorized", "")
return
}
// Get site count
count, err := h.DB.GetSiteCountByUserID(r.Context(), userID)
if err != nil {
h.Logger.Error("failed to get site count", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to retrieve quota information", h.SupportURL)
return
}
// Get membership info
membership, err := h.DB.GetUserMembership(r.Context(), userID)
if err != nil {
h.Logger.Error("failed to get user membership", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to retrieve quota information", h.SupportURL)
return
}
respondJSON(w, http.StatusOK, QuotaResponse{
Success: true,
CurrentCount: count,
Quota: membership.SitesQuota,
CanCreate: count < membership.SitesQuota,
IsMember: membership.IsMember != 0,
})
}