- Remove API token system entirely (model, migration, middleware) - Add migration 007 to drop api_tokens table - Add POST /api/v1/login endpoint for JSON credential auth - API routes now use session cookies (same as web UI) - Remove /api/v1/tokens endpoint - HandleAPIWhoAmI uses session auth instead of token context - APISessionAuth middleware returns JSON 401 instead of redirect - Update all API tests to use cookie-based authentication Addresses review comment on PR #74.
378 lines
10 KiB
Go
378 lines
10 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.eeqj.de/sneak/upaas/internal/models"
|
|
"git.eeqj.de/sneak/upaas/internal/service/app"
|
|
)
|
|
|
|
// apiAppResponse is the JSON representation of an app.
|
|
type apiAppResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
RepoURL string `json:"repoUrl"`
|
|
Branch string `json:"branch"`
|
|
DockerfilePath string `json:"dockerfilePath"`
|
|
Status string `json:"status"`
|
|
WebhookSecret string `json:"webhookSecret"`
|
|
SSHPublicKey string `json:"sshPublicKey"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
// apiDeploymentResponse is the JSON representation of a deployment.
|
|
type apiDeploymentResponse struct {
|
|
ID int64 `json:"id"`
|
|
AppID string `json:"appId"`
|
|
CommitSHA string `json:"commitSha,omitempty"`
|
|
Status string `json:"status"`
|
|
Duration string `json:"duration,omitempty"`
|
|
StartedAt string `json:"startedAt"`
|
|
FinishedAt string `json:"finishedAt,omitempty"`
|
|
}
|
|
|
|
func appToAPI(a *models.App) apiAppResponse {
|
|
return apiAppResponse{
|
|
ID: a.ID,
|
|
Name: a.Name,
|
|
RepoURL: a.RepoURL,
|
|
Branch: a.Branch,
|
|
DockerfilePath: a.DockerfilePath,
|
|
Status: string(a.Status),
|
|
WebhookSecret: a.WebhookSecret,
|
|
SSHPublicKey: a.SSHPublicKey,
|
|
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
}
|
|
|
|
func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
|
|
resp := apiDeploymentResponse{
|
|
ID: d.ID,
|
|
AppID: d.AppID,
|
|
Status: string(d.Status),
|
|
Duration: d.Duration(),
|
|
StartedAt: d.StartedAt.Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
|
|
if d.CommitSHA.Valid {
|
|
resp.CommitSHA = d.CommitSHA.String
|
|
}
|
|
|
|
if d.FinishedAt.Valid {
|
|
resp.FinishedAt = d.FinishedAt.Time.Format("2006-01-02T15:04:05Z")
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
|
|
// and sets a session cookie.
|
|
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
|
type loginRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type loginResponse struct {
|
|
UserID int64 `json:"userId"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
var req loginRequest
|
|
|
|
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
|
if decodeErr != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "invalid JSON body"},
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
if req.Username == "" || req.Password == "" {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "username and password are required"},
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
|
|
if authErr != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "invalid credentials"},
|
|
http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
sessionErr := h.auth.CreateSession(writer, request, user)
|
|
if sessionErr != nil {
|
|
h.log.Error("api: failed to create session", "error", sessionErr)
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to create session"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request, loginResponse{
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
}, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// HandleAPIListApps returns a handler that lists all apps as JSON.
|
|
func (h *Handlers) HandleAPIListApps() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
apps, err := h.appService.ListApps(request.Context())
|
|
if err != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to list apps"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
result := make([]apiAppResponse, 0, len(apps))
|
|
for _, a := range apps {
|
|
result = append(result, appToAPI(a))
|
|
}
|
|
|
|
h.respondJSON(writer, request, result, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// HandleAPIGetApp returns a handler that gets a single app by ID.
|
|
func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, err := h.appService.GetApp(request.Context(), appID)
|
|
if err != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "internal server error"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if application == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "app not found"},
|
|
http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request, appToAPI(application), http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// HandleAPICreateApp returns a handler that creates a new app.
|
|
func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
|
|
type createRequest struct {
|
|
Name string `json:"name"`
|
|
RepoURL string `json:"repoUrl"`
|
|
Branch string `json:"branch"`
|
|
DockerfilePath string `json:"dockerfilePath"`
|
|
DockerNetwork string `json:"dockerNetwork"`
|
|
NtfyTopic string `json:"ntfyTopic"`
|
|
SlackWebhook string `json:"slackWebhook"`
|
|
}
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
var req createRequest
|
|
|
|
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
|
if decodeErr != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "invalid JSON body"},
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
if req.Name == "" || req.RepoURL == "" {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "name and repo_url are required"},
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
nameErr := validateAppName(req.Name)
|
|
if nameErr != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "invalid app name: " + nameErr.Error()},
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{
|
|
Name: req.Name,
|
|
RepoURL: req.RepoURL,
|
|
Branch: req.Branch,
|
|
DockerfilePath: req.DockerfilePath,
|
|
DockerNetwork: req.DockerNetwork,
|
|
NtfyTopic: req.NtfyTopic,
|
|
SlackWebhook: req.SlackWebhook,
|
|
})
|
|
if createErr != nil {
|
|
h.log.Error("api: failed to create app", "error", createErr)
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to create app"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request, appToAPI(createdApp), http.StatusCreated)
|
|
}
|
|
}
|
|
|
|
// HandleAPIDeleteApp returns a handler that deletes an app.
|
|
func (h *Handlers) HandleAPIDeleteApp() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, err := h.appService.GetApp(request.Context(), appID)
|
|
if err != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "internal server error"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if application == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "app not found"},
|
|
http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
deleteErr := h.appService.DeleteApp(request.Context(), application)
|
|
if deleteErr != nil {
|
|
h.log.Error("api: failed to delete app", "error", deleteErr)
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to delete app"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"status": "deleted"}, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// deploymentsPageLimit is the default number of deployments per page.
|
|
const deploymentsPageLimit = 20
|
|
|
|
// HandleAPIListDeployments returns a handler that lists deployments for an app.
|
|
func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, err := h.appService.GetApp(request.Context(), appID)
|
|
if err != nil || application == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "app not found"},
|
|
http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
limit := deploymentsPageLimit
|
|
|
|
if l := request.URL.Query().Get("limit"); l != "" {
|
|
parsed, parseErr := strconv.Atoi(l)
|
|
if parseErr == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
deployments, deployErr := application.GetDeployments(
|
|
request.Context(), limit,
|
|
)
|
|
if deployErr != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to list deployments"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
result := make([]apiDeploymentResponse, 0, len(deployments))
|
|
for _, d := range deployments {
|
|
result = append(result, deploymentToAPI(d))
|
|
}
|
|
|
|
h.respondJSON(writer, request, result, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// HandleAPITriggerDeploy returns a handler that triggers a deployment for an app.
|
|
func (h *Handlers) HandleAPITriggerDeploy() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, err := h.appService.GetApp(request.Context(), appID)
|
|
if err != nil || application == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "app not found"},
|
|
http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
deployErr := h.deploy.Deploy(request.Context(), application, nil, true)
|
|
if deployErr != nil {
|
|
h.log.Error("api: failed to trigger deploy", "error", deployErr)
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": deployErr.Error()},
|
|
http.StatusConflict)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"status": "deploying"}, http.StatusAccepted)
|
|
}
|
|
}
|
|
|
|
// HandleAPIWhoAmI returns a handler that shows the current authenticated user.
|
|
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
|
|
type whoAmIResponse struct {
|
|
UserID int64 `json:"userId"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
user, err := h.auth.GetCurrentUser(request.Context(), request)
|
|
if err != nil || user == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "unauthorized"},
|
|
http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request, whoAmIResponse{
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
}, http.StatusOK)
|
|
}
|
|
}
|