// Package tokens provides HTTP handlers for share token management package tokens import ( "encoding/json" "net/http" "time" "github.com/jfraeys/fetch_ml/internal/auth" "github.com/jfraeys/fetch_ml/internal/logging" "github.com/jfraeys/fetch_ml/internal/storage" ) // Handler provides share token management HTTP handlers type Handler struct { db *storage.DB logger *logging.Logger } // NewHandler creates a new share tokens handler func NewHandler(db *storage.DB, logger *logging.Logger) *Handler { return &Handler{ db: db, logger: logger, } } // CreateTokenRequest represents a request to create a new share token type CreateTokenRequest struct { TaskID *string `json:"task_id,omitempty"` ExperimentID *string `json:"experiment_id,omitempty"` ExpiresIn *int `json:"expires_in_days,omitempty"` // Number of days until expiry MaxAccesses *int `json:"max_accesses,omitempty"` // Max number of accesses allowed } // CreateTokenResponse represents the response with the created token type CreateTokenResponse struct { Token string `json:"token"` ShareLink string `json:"share_link"` } // CreateShareToken handles POST /api/tokens // Creates a new share token for a task or experiment func (h *Handler) CreateShareToken(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } // Check if user has permission to share tasks if !user.HasPermission(auth.PermissionTasksShare) { http.Error(w, "forbidden: insufficient permissions to share tasks", http.StatusForbidden) return } var req CreateTokenRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } // Must specify either task_id or experiment_id, not both if (req.TaskID == nil && req.ExperimentID == nil) || (req.TaskID != nil && req.ExperimentID != nil) { http.Error(w, "must specify exactly one of task_id or experiment_id", http.StatusBadRequest) return } // Build options opts := auth.ShareTokenOptions{} if req.ExpiresIn != nil { expiresAt := time.Now().Add(time.Duration(*req.ExpiresIn) * 24 * time.Hour) opts.ExpiresAt = &expiresAt } if req.MaxAccesses != nil { opts.MaxAccesses = req.MaxAccesses } // Generate token token, err := auth.GenerateShareToken(h.db, req.TaskID, req.ExperimentID, user.Name, opts) if err != nil { h.logger.Error("failed to generate share token", "error", err) http.Error(w, "failed to create share token", http.StatusInternalServerError) return } // Build share link var path string if req.TaskID != nil { path = "/api/tasks/" + *req.TaskID } else { path = "/api/experiments/" + *req.ExperimentID } shareLink := auth.BuildShareLink("", path, token) h.logger.Info("share token created", "token", token[:8]+"...", "created_by", user.Name, "task_id", req.TaskID, "experiment_id", req.ExperimentID, ) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(CreateTokenResponse{ Token: token, ShareLink: shareLink, }); err != nil { h.logger.Error("failed to encode response", "error", err) } } // ListShareTokens handles GET /api/tokens // Lists share tokens for a task or experiment func (h *Handler) ListShareTokens(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } taskID := r.URL.Query().Get("task_id") experimentID := r.URL.Query().Get("experiment_id") // Must specify either task_id or experiment_id if taskID == "" && experimentID == "" { http.Error(w, "must specify task_id or experiment_id", http.StatusBadRequest) return } var tokens []*storage.ShareToken var err error if taskID != "" { tokens, err = h.db.ListShareTokensForTask(taskID) } else { tokens, err = h.db.ListShareTokensForExperiment(experimentID) } if err != nil { h.logger.Error("failed to list share tokens", "error", err) http.Error(w, "failed to list share tokens", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(map[string]any{ "tokens": tokens, "count": len(tokens), }); err != nil { h.logger.Error("failed to encode response", "error", err) } } // RevokeShareToken handles DELETE /api/tokens/:token // Revokes a share token func (h *Handler) RevokeShareToken(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } token := r.PathValue("token") if token == "" { http.Error(w, "token required", http.StatusBadRequest) return } // Get token details to check ownership t, err := h.db.GetShareToken(token) if err != nil { h.logger.Error("failed to get share token", "error", err) http.Error(w, "token not found", http.StatusNotFound) return } if t == nil { http.Error(w, "token not found", http.StatusNotFound) return } // Only the creator or an admin can revoke if t.CreatedBy != user.Name && !user.Admin { http.Error(w, "forbidden: not token owner or admin", http.StatusForbidden) return } if err := h.db.DeleteShareToken(token); err != nil { h.logger.Error("failed to delete share token", "error", err) http.Error(w, "failed to revoke token", http.StatusInternalServerError) return } h.logger.Info("share token revoked", "token", token[:8]+"...", "revoked_by", user.Name) w.WriteHeader(http.StatusNoContent) }