Files

633 lines
19 KiB
Go

package handlers
import "database/sql"
import "encoding/json"
import "errors"
import "fmt"
import "net/http"
import "strings"
import "codit/internal/db"
import "codit/internal/gpg"
import "codit/internal/middleware"
import "codit/internal/models"
const personalGPGKeyUploadMaxBytes int64 = 256 * 1024
// gpgActor returns the acting user from the request context.
func (api *API) gpgActor(r *http.Request) (models.User, bool) {
return middleware.UserFromContext(r.Context())
}
// canManageGPGKey reports whether the user may modify or delete the key.
// Admins may manage any key; a regular user may manage only their own
// personal keys.
func (api *API) canManageGPGKey(r *http.Request, keyID string, user models.User) bool {
var owned bool
var err error
if user.IsAdmin {
return true
}
owned, err = api.store(r).GPGKeyPersonalOwner(keyID, user.ID)
if err != nil {
return false
}
return owned
}
func (api *API) canManageProjectSigningKeys(r *http.Request, projectID string) bool {
var user models.User
var principal models.ServicePrincipal
var ok bool
var role string
var err error
user, ok = middleware.UserFromContext(r.Context())
if ok {
if user.IsAdmin { return true }
role, err = api.store(r).GetProjectRoleForUser(projectID, user.ID)
if err != nil { return false }
return roleAllows(role, models.RoleAdmin)
}
principal, ok = middleware.PrincipalFromContext(r.Context())
if !ok || principal.Disabled { return false }
if principal.IsAdmin { return true }
role, err = api.store(r).GetPrincipalProjectRole(principal.ID, projectID)
if err != nil { return false }
return roleAllows(role, models.RoleAdmin)
}
// ListMyGPGKeys returns the caller's registered personal (public-only) keys,
// used for commit/tag signature verification.
func (api *API) ListMyGPGKeys(w http.ResponseWriter, r *http.Request, params map[string]string) {
var user models.User
var ok bool
var keys []models.GPGKey
var err error
var i int
user, ok = api.gpgActor(r)
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required")
return
}
keys, err = api.store(r).ListPersonalGPGKeys(user.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if keys == nil {
keys = []models.GPGKey{}
}
for i = range keys {
keys[i].CanManage = true
}
WriteJSON(w, http.StatusOK, keys)
}
// AddMyGPGKey registers a PUBLIC key (pasted by the user) for the caller. The
// private half is never sent or stored — the user keeps it locally to sign.
func (api *API) AddMyGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
var user models.User
var ok bool
var req struct {
Name string `json:"name"`
PublicKey string `json:"public_key"`
}
var km gpg.KeyMaterial
var key models.GPGKey
var stored models.GPGKey
var err error
user, ok = api.gpgActor(r)
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required")
return
}
r.Body = http.MaxBytesReader(w, r.Body, personalGPGKeyUploadMaxBytes)
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
if strings.TrimSpace(req.PublicKey) == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "public key is required")
return
}
km, err = gpg.ParsePublic(req.PublicKey)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid public key: "+err.Error())
return
}
key = api.buildPersonalKey(user.ID, req.Name, km)
// it persists a public-only personal key, rejecting a
// duplicate fingerprint already registered by any user.
stored, err = api.store(r).CreateGPGKey(r.Context(), key, user.ID, "")
if err != nil {
if errors.Is(err, db.ErrGPGKeyAlreadyRegistered) {
WriteJSONWithErrorReason(w, r, http.StatusConflict, "this key is already registered")
return
}
if errors.Is(err, db.ErrPersonalGPGKeyLimitReached) {
WriteJSONWithErrorReason(w, r, http.StatusConflict, "personal gpg key limit reached")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
stored.CanManage = true
WriteJSON(w, http.StatusOK, stored)
}
// GenerateMyGPGKey is a convenience: it generates a keypair, registers the
// PUBLIC half for the caller, and returns the PRIVATE key ONCE for the user to
// download and import locally. The private key is never stored server-side.
func (api *API) GenerateMyGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
var user models.User
var ok bool
var req struct {
Name string `json:"name"`
Email string `json:"email"`
}
var km gpg.KeyMaterial
var key models.GPGKey
var stored models.GPGKey
var err error
user, ok = api.gpgActor(r)
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required")
return
}
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
if strings.TrimSpace(req.Name) == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required")
return
}
km, err = gpg.Generate(req.Name, req.Email)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
key = api.buildPersonalKey(user.ID, req.Name, km)
stored, err = api.store(r).CreateGPGKey(r.Context(), key, user.ID, "")
if err != nil {
if errors.Is(err, db.ErrGPGKeyAlreadyRegistered) {
WriteJSONWithErrorReason(w, r, http.StatusConflict, "this key is already registered")
return
}
if errors.Is(err, db.ErrPersonalGPGKeyLimitReached) {
WriteJSONWithErrorReason(w, r, http.StatusConflict, "personal gpg key limit reached")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
stored.CanManage = true
// Return the private key once, for the user to save and import locally.
WriteJSON(w, http.StatusOK, map[string]any{
"key": stored,
"private_key": km.PrivateArmored,
"public_key": km.PublicArmored,
})
}
// DeleteMyGPGKey removes one of the caller's registered personal keys.
func (api *API) DeleteMyGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
var user models.User
var ok bool
var owned bool
var err error
user, ok = api.gpgActor(r)
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required")
return
}
owned, err = api.store(r).GPGKeyPersonalOwner(params["id"], user.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if !owned {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "not permitted")
return
}
err = api.store(r).DeleteGPGKey(params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// buildPersonalKey assembles a public-only personal GPGKey from key material.
func (api *API) buildPersonalKey(_ string, name string, km gpg.KeyMaterial) models.GPGKey {
var key models.GPGKey
key.Scope = models.GPGKeyScopePersonal
key.Name = strings.TrimSpace(name)
if key.Name == "" {
key.Name = km.KeyID
}
key.Fingerprint = km.Fingerprint
key.KeyID = km.KeyID
key.PublicKey = km.PublicArmored
key.PrivateKey = "" // public-only: never store the private half
return key
}
// ListAllGPGKeys returns every key for admin oversight (admins may manage any).
func (api *API) ListAllGPGKeys(w http.ResponseWriter, r *http.Request, params map[string]string) {
var keys []models.GPGKey
var err error
var i int
if !api.requireAdmin(w, r) {
return
}
keys, err = api.store(r).ListGPGKeys()
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if keys == nil {
keys = []models.GPGKey{}
}
for i = range keys {
keys[i].CanManage = true
}
WriteJSON(w, http.StatusOK, keys)
}
func (api *API) GetGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
var user models.User
var ok bool
var key models.GPGKey
var err error
user, ok = api.gpgActor(r)
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required")
return
}
key, err = api.store(r).GetGPGKey(params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found")
return
}
// A user may view their own personal key; admins may view any key.
if !api.canManageGPGKey(r, key.ID, user) {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "not permitted")
return
}
key.CanManage = true
WriteJSON(w, http.StatusOK, key)
}
// createGlobalGPGKey persists an organization-wide (global) signing key.
// Personal keys are public-only and registered via /api/me/gpg-keys instead.
func (api *API) createGlobalGPGKey(w http.ResponseWriter, r *http.Request, name string, km gpg.KeyMaterial) {
var key models.GPGKey
var err error
key.Scope = models.GPGKeyScopeGlobal
key.Name = strings.TrimSpace(name)
key.Fingerprint = km.Fingerprint
key.KeyID = km.KeyID
key.PublicKey = km.PublicArmored
key.PrivateKey = km.PrivateArmored
key, err = api.store(r).CreateGPGKey(r.Context(), key, "", "")
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
key.CanManage = true
WriteJSON(w, http.StatusOK, key)
}
func (api *API) GenerateGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req struct {
Name string `json:"name"`
Email string `json:"email"`
}
var km gpg.KeyMaterial
var err error
if !api.requireAdmin(w, r) {
return
}
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
if strings.TrimSpace(req.Name) == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required")
return
}
km, err = gpg.Generate(req.Name, req.Email)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
api.createGlobalGPGKey(w, r, req.Name, km)
}
func (api *API) ImportGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req struct {
Name string `json:"name"`
PrivateKey string `json:"private_key"`
}
var km gpg.KeyMaterial
var err error
if !api.requireAdmin(w, r) {
return
}
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
if strings.TrimSpace(req.Name) == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required")
return
}
if strings.TrimSpace(req.PrivateKey) == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "private key is required")
return
}
km, err = gpg.Parse(req.PrivateKey)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid private key: "+err.Error())
return
}
api.createGlobalGPGKey(w, r, req.Name, km)
}
// ListProjectSigningKeys returns the signing keys owned by the project. Any
// project viewer may list them.
func (api *API) ListProjectSigningKeys(w http.ResponseWriter, r *http.Request, params map[string]string) {
var keys []models.GPGKey
var err error
var i int
var canManage bool
if !api.requireProjectRole(w, r, params["projectId"], models.RoleViewer) {
return
}
canManage = api.canManageProjectSigningKeys(r, params["projectId"])
keys, err = api.store(r).ListProjectGPGKeys(params["projectId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if keys == nil {
keys = []models.GPGKey{}
}
for i = range keys {
keys[i].CanManage = canManage
}
WriteJSON(w, http.StatusOK, keys)
}
func (api *API) GetProjectSigningKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
var key models.GPGKey
var belongs bool
var err error
var canManage bool
if !api.requireProjectRole(w, r, params["projectId"], models.RoleViewer) {
return
}
canManage = api.canManageProjectSigningKeys(r, params["projectId"])
belongs, err = api.store(r).GPGKeyBelongsToProject(params["keyId"], params["projectId"])
if err != nil || !belongs {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found")
return
}
key, err = api.store(r).GetGPGKey(params["keyId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found")
return
}
key.CanManage = canManage
WriteJSON(w, http.StatusOK, key)
}
// ListSelectableSigningKeys returns the keys a project may sign with: the
// project's own keys plus all org keys. Used to populate a repo's signing-key
// selector.
func (api *API) ListSelectableSigningKeys(w http.ResponseWriter, r *http.Request, params map[string]string) {
var keys []models.GPGKey
var err error
if !api.requireProjectRole(w, r, params["projectId"], models.RoleWriter) {
return
}
keys, err = api.store(r).ListSelectableGPGKeys(params["projectId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if keys == nil {
keys = []models.GPGKey{}
}
WriteJSON(w, http.StatusOK, keys)
}
// createProjectSigningKey persists a project-scoped key after the request has
// been authorized as a project admin.
func (api *API) createProjectSigningKey(w http.ResponseWriter, r *http.Request, projectID string, name string, km gpg.KeyMaterial) {
var key models.GPGKey
var err error
key = models.GPGKey{
Name: strings.TrimSpace(name),
Fingerprint: km.Fingerprint,
KeyID: km.KeyID,
PublicKey: km.PublicArmored,
PrivateKey: km.PrivateArmored,
Scope: models.GPGKeyScopeProject,
}
key, err = api.store(r).CreateGPGKey(r.Context(), key, "", projectID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
key.CanManage = true
WriteJSON(w, http.StatusOK, key)
}
func (api *API) GenerateProjectSigningKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req struct {
Name string `json:"name"`
Email string `json:"email"`
}
var km gpg.KeyMaterial
var err error
if !api.requireProjectRole(w, r, params["projectId"], models.RoleAdmin) { return }
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
if strings.TrimSpace(req.Name) == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required")
return
}
km, err = gpg.Generate(req.Name, req.Email)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
api.createProjectSigningKey(w, r, params["projectId"], req.Name, km)
}
func (api *API) ImportProjectSigningKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req struct {
Name string `json:"name"`
PrivateKey string `json:"private_key"`
}
var km gpg.KeyMaterial
var err error
if !api.requireProjectRole(w, r, params["projectId"], models.RoleAdmin) { return }
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request")
return
}
if strings.TrimSpace(req.Name) == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required")
return
}
if strings.TrimSpace(req.PrivateKey) == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "private key is required")
return
}
km, err = gpg.Parse(req.PrivateKey)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid private key: "+err.Error())
return
}
api.createProjectSigningKey(w, r, params["projectId"], req.Name, km)
}
func (api *API) DeleteProjectSigningKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
var belongs bool
var usage []models.GPGKeyRepoUsage
var err error
if !api.requireProjectRole(w, r, params["projectId"], models.RoleAdmin) { return }
belongs, err = api.store(r).GPGKeyBelongsToProject(params["keyId"], params["projectId"])
if err != nil || !belongs {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found")
return
}
usage, err = api.store(r).ListReposUsingGPGKey(params["keyId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if len(usage) > 0 {
var noun string
noun = "repository"
if len(usage) != 1 { noun = "repositories" }
WriteJSONWithErrorReason(w, r, http.StatusConflict, fmt.Sprintf("signing key is in use by %d %s; remove it from them first", len(usage), noun))
return
}
err = api.store(r).DeleteGPGKey(params["keyId"])
if err != nil {
if err == sql.ErrNoRows {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (api *API) DeleteGPGKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
var user models.User
var usage []models.GPGKeyRepoUsage
var ok bool
var err error
user, ok = api.gpgActor(r)
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required")
return
}
if !api.canManageGPGKey(r, params["id"], user) {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "not permitted")
return
}
usage, err = api.store(r).ListReposUsingGPGKey(params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if len(usage) > 0 {
var noun string
noun = "repository"
if len(usage) != 1 { noun = "repositories" }
WriteJSONWithErrorReason(w, r, http.StatusConflict, fmt.Sprintf("signing key is in use by %d %s; remove it from them first", len(usage), noun))
return
}
err = api.store(r).DeleteGPGKey(params["id"])
if err != nil {
if err == sql.ErrNoRows {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// GPGKeyUsage returns the repositories that reference this key as their signing
// key, so the admin/owner can see who's using it before deleting.
func (api *API) GPGKeyUsage(w http.ResponseWriter, r *http.Request, params map[string]string) {
var key models.GPGKey
var user models.User
var ok bool
var usage []models.GPGKeyRepoUsage
var err error
user, ok = api.gpgActor(r)
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "authentication required")
return
}
key, err = api.store(r).GetGPGKey(params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "gpg key not found")
return
}
if !api.canManageGPGKey(r, params["id"], user) {
if key.Scope != models.GPGKeyScopeProject || key.OwnerProjectPublicID == "" || !api.canManageProjectSigningKeys(r, key.OwnerProjectPublicID) {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "not permitted")
return
}
}
usage, err = api.store(r).ListReposUsingGPGKey(params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if usage == nil {
usage = []models.GPGKeyRepoUsage{}
}
WriteJSON(w, http.StatusOK, usage)
}