3bb4ab494e
- Added `internal/db/store.go` to define the Store interface for database operations, including user, organization, app, channel, release, patch, and artifact management. - Introduced `internal/models/models.go` changes to reflect updated organization and artifact response structures. - Created `internal/storage/factory.go`, `local.go`, and `s3.go` to implement storage backends for local filesystem and S3-compatible services. - Removed deprecated `internal/storage/storage.go` and refactored storage interface into `internal/storage/store.go`. - Updated frontend JavaScript in `web/js/app.js` to display server health information, including storage and database backend details.
289 lines
7.7 KiB
Go
289 lines
7.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/shorebird-server/internal/api/middleware"
|
|
"github.com/shorebird-server/internal/db"
|
|
"github.com/shorebird-server/internal/models"
|
|
)
|
|
|
|
// strPtr returns a pointer to a string.
|
|
func strPtr(s string) *string {
|
|
return &s
|
|
}
|
|
|
|
// intPtr returns a pointer to an int.
|
|
func intPtr(i int) *int {
|
|
return &i
|
|
}
|
|
|
|
// UserHandler handles user-related API endpoints.
|
|
type UserHandler struct {
|
|
DB db.Store
|
|
}
|
|
|
|
// GetCurrentUser handles GET /api/v1/users/me
|
|
func (h *UserHandler) GetCurrentUser(w http.ResponseWriter, r *http.Request) {
|
|
claims := middleware.GetClaims(r)
|
|
if claims == nil {
|
|
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
|
return
|
|
}
|
|
|
|
user, err := h.DB.GetUserByID(r.Context(), claims.UserID)
|
|
if err != nil {
|
|
respondError(w, http.StatusNotFound, "User not found", nil)
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]interface{}{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"display_name": user.Name,
|
|
"jwt_issuer": "http://localhost:8080/auth",
|
|
"has_active_subscription": true,
|
|
"stripe_customer_id": nil,
|
|
"patch_overage_limit": nil,
|
|
})
|
|
}
|
|
|
|
// CreateUser handles POST /api/v1/users
|
|
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|
claims := middleware.GetClaims(r)
|
|
if claims == nil {
|
|
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
|
return
|
|
}
|
|
|
|
var req models.CreateUserRequest
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
|
return
|
|
}
|
|
|
|
// In the Shorebird protocol, user creation during login flow
|
|
// is handled by the auth server. For API completeness, this
|
|
// endpoint updates the user's name if they exist.
|
|
user, err := h.DB.GetUserByEmail(r.Context(), claims.Email)
|
|
if err != nil {
|
|
respondError(w, http.StatusNotFound, "User not found", nil)
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]interface{}{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
"created_at": user.CreatedAt,
|
|
})
|
|
}
|
|
|
|
// AppHandler handles app-related API endpoints.
|
|
type AppHandler struct {
|
|
DB db.Store
|
|
}
|
|
|
|
// GetApps handles GET /api/v1/apps
|
|
func (h *AppHandler) GetApps(w http.ResponseWriter, r *http.Request) {
|
|
claims := middleware.GetClaims(r)
|
|
if claims == nil {
|
|
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
|
return
|
|
}
|
|
|
|
apps, err := h.DB.GetAppsByUserID(r.Context(), claims.UserID)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to fetch apps", nil)
|
|
return
|
|
}
|
|
|
|
var result []models.AppMetadata
|
|
for _, a := range apps {
|
|
result = append(result, models.AppMetadata{
|
|
AppID: a.ID.String(),
|
|
DisplayName: a.DisplayName,
|
|
CreatedAt: a.CreatedAt.Time,
|
|
UpdatedAt: a.UpdatedAt.Time,
|
|
Platforms: []string{},
|
|
LatestReleases: map[string]models.LatestRelease{},
|
|
PendingReleases: map[string]models.PendingRelease{},
|
|
})
|
|
}
|
|
if result == nil {
|
|
result = []models.AppMetadata{}
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]interface{}{"apps": result})
|
|
}
|
|
|
|
// CreateApp handles POST /api/v1/apps
|
|
func (h *AppHandler) CreateApp(w http.ResponseWriter, r *http.Request) {
|
|
claims := middleware.GetClaims(r)
|
|
if claims == nil {
|
|
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
|
return
|
|
}
|
|
|
|
var req models.CreateAppRequest
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
|
return
|
|
}
|
|
|
|
if req.OrganizationID == 0 {
|
|
// Default to user's first organization
|
|
orgs, err := h.DB.GetOrganizationsByUserID(r.Context(), claims.UserID)
|
|
if err != nil || len(orgs) == 0 {
|
|
// Create a default org
|
|
orgID, err := h.DB.CreateOrganization(r.Context(), claims.Email+"'s Org", "team")
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to create organization", nil)
|
|
return
|
|
}
|
|
h.DB.AddUserToOrganization(r.Context(), claims.UserID, orgID, "admin")
|
|
req.OrganizationID = orgID
|
|
} else {
|
|
req.OrganizationID = orgs[0].OrgID
|
|
}
|
|
}
|
|
|
|
app, err := h.DB.CreateApp(r.Context(), req.OrganizationID, req.DisplayName)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to create app", strPtr(err.Error()))
|
|
return
|
|
}
|
|
|
|
// Auto-create default "stable" channel
|
|
_, err = h.DB.CreateChannel(r.Context(), app.ID, "stable")
|
|
if err != nil {
|
|
// Log but don't fail; channel creation is best-effort for compat
|
|
}
|
|
|
|
respondJSON(w, http.StatusCreated, map[string]interface{}{
|
|
"id": app.ID.String(),
|
|
"display_name": app.DisplayName,
|
|
"created_at": app.CreatedAt,
|
|
"updated_at": app.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
// DeleteApp handles DELETE /api/v1/apps/{appId}
|
|
func (h *AppHandler) DeleteApp(w http.ResponseWriter, r *http.Request) {
|
|
appID := chi.URLParam(r, "appId")
|
|
id, err := parseUUID(appID)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
|
return
|
|
}
|
|
|
|
if err := h.DB.DeleteApp(r.Context(), id); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to delete app", nil)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ChannelHandler handles channel-related API endpoints.
|
|
type ChannelHandler struct {
|
|
DB db.Store
|
|
}
|
|
|
|
// GetChannels handles GET /api/v1/apps/{appId}/channels
|
|
func (h *ChannelHandler) GetChannels(w http.ResponseWriter, r *http.Request) {
|
|
appID := chi.URLParam(r, "appId")
|
|
id, err := parseUUID(appID)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
|
return
|
|
}
|
|
|
|
channels, err := h.DB.GetChannelsByAppID(r.Context(), id)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to fetch channels", nil)
|
|
return
|
|
}
|
|
|
|
var result []models.Channel
|
|
for _, c := range channels {
|
|
result = append(result, models.Channel{
|
|
ID: c.ID,
|
|
AppID: c.AppID,
|
|
Name: c.Name,
|
|
})
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// CreateChannel handles POST /api/v1/apps/{appId}/channels
|
|
func (h *ChannelHandler) CreateChannel(w http.ResponseWriter, r *http.Request) {
|
|
appID := chi.URLParam(r, "appId")
|
|
id, err := parseUUID(appID)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
|
return
|
|
}
|
|
|
|
var req models.CreateChannelRequest
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
|
return
|
|
}
|
|
|
|
channel, err := h.DB.CreateChannel(r.Context(), id, req.Channel)
|
|
if err != nil {
|
|
respondError(w, http.StatusConflict, "Channel already exists", strPtr(err.Error()))
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusCreated, models.Channel{
|
|
ID: channel.ID,
|
|
AppID: channel.AppID,
|
|
Name: channel.Name,
|
|
})
|
|
}
|
|
|
|
// OrganizationHandler handles organization-related API endpoints.
|
|
type OrganizationHandler struct {
|
|
DB db.Store
|
|
}
|
|
|
|
// GetOrganizations handles GET /api/v1/organizations
|
|
func (h *OrganizationHandler) GetOrganizations(w http.ResponseWriter, r *http.Request) {
|
|
claims := middleware.GetClaims(r)
|
|
if claims == nil {
|
|
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
|
return
|
|
}
|
|
|
|
orgs, err := h.DB.GetOrganizationsByUserID(r.Context(), claims.UserID)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to fetch organizations", nil)
|
|
return
|
|
}
|
|
|
|
var result []models.OrganizationMembership
|
|
for _, o := range orgs {
|
|
now := time.Now().UTC()
|
|
result = append(result, models.OrganizationMembership{
|
|
Organization: models.Organization{
|
|
ID: o.OrgID,
|
|
Name: o.OrgName,
|
|
OrganizationType: o.OrgType,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
Role: o.Role,
|
|
})
|
|
}
|
|
if result == nil {
|
|
result = []models.OrganizationMembership{}
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]interface{}{"organizations": result})
|
|
}
|