init
This commit is contained in:
commit
b15b95781c
108 changed files with 14802 additions and 0 deletions
32
internal/apikeys/context.go
Normal file
32
internal/apikeys/context.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package apikeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const requestContextKey contextKey = "api-key"
|
||||
|
||||
type AuthState struct {
|
||||
APIKey db.APIKey
|
||||
}
|
||||
|
||||
func NewContext(ctx context.Context, state *AuthState) context.Context {
|
||||
if state == nil {
|
||||
return ctx
|
||||
}
|
||||
|
||||
return context.WithValue(ctx, requestContextKey, *state)
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) (*AuthState, bool) {
|
||||
state, ok := ctx.Value(requestContextKey).(AuthState)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &state, true
|
||||
}
|
||||
407
internal/apikeys/service.go
Normal file
407
internal/apikeys/service.go
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
package apikeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
const (
|
||||
rawKeyPrefix = "upsk_"
|
||||
rawKeyBytes = 32
|
||||
rawKeyPreviewLength = 17
|
||||
createRetryLimit = 4
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnauthenticated = errors.New("api key unauthenticated")
|
||||
ErrUnauthorized = errors.New("api key unauthorized")
|
||||
)
|
||||
|
||||
type Permission string
|
||||
|
||||
const (
|
||||
PermissionDownload Permission = "can_download"
|
||||
PermissionUpload Permission = "can_upload"
|
||||
PermissionDelete Permission = "can_delete"
|
||||
PermissionManageProjects Permission = "can_manage_projects"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *db.Store
|
||||
}
|
||||
|
||||
type CreateParams struct {
|
||||
Name string
|
||||
Description string
|
||||
ScopeMode db.ScopeMode
|
||||
CanDownload bool
|
||||
CanUpload bool
|
||||
CanDelete bool
|
||||
CanManageProjects bool
|
||||
ExpiresAt *time.Time
|
||||
ProjectIDs []int64
|
||||
TagIDs []int64
|
||||
CreatedByUserID *int64
|
||||
}
|
||||
|
||||
type UpdateParams struct {
|
||||
Name string
|
||||
Description string
|
||||
ScopeMode db.ScopeMode
|
||||
CanDownload bool
|
||||
CanUpload bool
|
||||
CanDelete bool
|
||||
CanManageProjects bool
|
||||
ExpiresAt *time.Time
|
||||
ProjectIDs []int64
|
||||
TagIDs []int64
|
||||
}
|
||||
|
||||
type CreateResult struct {
|
||||
APIKey *db.APIKey
|
||||
RawKey string
|
||||
}
|
||||
|
||||
func NewService(store *db.Store) *Service {
|
||||
return &Service{store: store}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, params CreateParams) (*CreateResult, error) {
|
||||
normalized, err := normalizeCreateParams(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < createRetryLimit; attempt++ {
|
||||
rawKey, keyPrefix, keyHash, err := generateAPIKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var created *db.APIKey
|
||||
err = s.store.WithTx(ctx, func(tx *db.TxStore) error {
|
||||
inserted, err := tx.APIKeys.Create(ctx, db.CreateAPIKeyParams{
|
||||
Name: normalized.Name,
|
||||
KeyPrefix: keyPrefix,
|
||||
KeyHash: keyHash,
|
||||
Description: normalized.Description,
|
||||
ScopeMode: normalized.ScopeMode,
|
||||
CanDownload: normalized.CanDownload,
|
||||
CanUpload: normalized.CanUpload,
|
||||
CanDelete: normalized.CanDelete,
|
||||
CanManageProjects: normalized.CanManageProjects,
|
||||
IsActive: true,
|
||||
ExpiresAt: normalized.ExpiresAt,
|
||||
CreatedByUserID: normalized.CreatedByUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := syncScopeAccess(ctx, tx.APIKeys, inserted.ID, normalized.ScopeMode, normalized.ProjectIDs, normalized.TagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
created = inserted
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrConflict) {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("create api key: %w", err)
|
||||
}
|
||||
|
||||
return &CreateResult{
|
||||
APIKey: created,
|
||||
RawKey: rawKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("create api key: could not generate a unique key")
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, apiKeyID int64, params UpdateParams) (*db.APIKey, error) {
|
||||
normalized, err := normalizeUpdateParams(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var updated *db.APIKey
|
||||
if err := s.store.WithTx(ctx, func(tx *db.TxStore) error {
|
||||
if _, err := tx.APIKeys.GetByID(ctx, apiKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case normalized.ScopeMode == db.ScopeModeAllProjects:
|
||||
if err := tx.APIKeys.ClearProjectAccess(ctx, apiKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.APIKeys.ClearTagAccess(ctx, apiKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
case normalized.ScopeMode.UsesProjectRules():
|
||||
if err := tx.APIKeys.ClearTagAccess(ctx, apiKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
case normalized.ScopeMode.UsesTagRules():
|
||||
if err := tx.APIKeys.ClearProjectAccess(ctx, apiKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
record, err := tx.APIKeys.Update(ctx, apiKeyID, db.UpdateAPIKeyParams{
|
||||
Name: normalized.Name,
|
||||
Description: normalized.Description,
|
||||
ScopeMode: normalized.ScopeMode,
|
||||
CanDownload: normalized.CanDownload,
|
||||
CanUpload: normalized.CanUpload,
|
||||
CanDelete: normalized.CanDelete,
|
||||
CanManageProjects: normalized.CanManageProjects,
|
||||
ExpiresAt: normalized.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := syncScopeAccess(ctx, tx.APIKeys, apiKeyID, normalized.ScopeMode, normalized.ProjectIDs, normalized.TagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated = record
|
||||
return nil
|
||||
}); err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("update api key: %w", err)
|
||||
}
|
||||
|
||||
return s.store.APIKeys.GetByID(ctx, updated.ID)
|
||||
}
|
||||
|
||||
func (s *Service) SetActive(ctx context.Context, apiKeyID int64, isActive bool) error {
|
||||
if err := s.store.APIKeys.SetActive(ctx, apiKeyID, isActive); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Authenticate(ctx context.Context, rawKey string) (*AuthState, error) {
|
||||
rawKey = strings.TrimSpace(rawKey)
|
||||
if rawKey == "" {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
|
||||
record, err := s.store.APIKeys.GetByHash(ctx, hashAPIKey(rawKey))
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("lookup api key: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if !record.IsActive || record.Expired(now) {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
|
||||
if err := s.store.APIKeys.TouchLastUsedAt(ctx, record.ID, now); err != nil {
|
||||
return nil, fmt.Errorf("touch api key last_used_at: %w", err)
|
||||
}
|
||||
|
||||
record.LastUsedAt = &now
|
||||
|
||||
return &AuthState{APIKey: *record}, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]db.APIKeyListItem, error) {
|
||||
return s.store.APIKeys.List(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(ctx context.Context, apiKeyID int64) (*db.APIKey, error) {
|
||||
return s.store.APIKeys.GetByID(ctx, apiKeyID)
|
||||
}
|
||||
|
||||
func (s *Service) ListProjectAccess(ctx context.Context, apiKeyID int64) ([]db.Project, error) {
|
||||
return s.store.APIKeys.ListProjectAccess(ctx, apiKeyID)
|
||||
}
|
||||
|
||||
func (s *Service) ListTagAccess(ctx context.Context, apiKeyID int64) ([]db.Tag, error) {
|
||||
return s.store.APIKeys.ListTagAccess(ctx, apiKeyID)
|
||||
}
|
||||
|
||||
func (s *Service) ListAccessibleProjects(ctx context.Context, apiKey db.APIKey) ([]db.Project, error) {
|
||||
return s.store.APIKeys.ListAccessibleProjects(ctx, apiKey.ID, apiKey.ScopeMode)
|
||||
}
|
||||
|
||||
func (s *Service) CanAccessProject(ctx context.Context, apiKey db.APIKey, projectID int64) (bool, error) {
|
||||
return s.store.APIKeys.HasProjectAccess(ctx, apiKey.ID, apiKey.ScopeMode, projectID)
|
||||
}
|
||||
|
||||
func HasPermission(apiKey db.APIKey, permission Permission) bool {
|
||||
switch permission {
|
||||
case PermissionDownload:
|
||||
return apiKey.CanDownload
|
||||
case PermissionUpload:
|
||||
return apiKey.CanUpload
|
||||
case PermissionDelete:
|
||||
return apiKey.CanDelete
|
||||
case PermissionManageProjects:
|
||||
return apiKey.CanManageProjects
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func generateAPIKey() (rawKey string, keyPrefix string, keyHash string, err error) {
|
||||
bytes := make([]byte, rawKeyBytes)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", "", "", fmt.Errorf("generate api key: %w", err)
|
||||
}
|
||||
|
||||
body := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
rawKey = rawKeyPrefix + body
|
||||
|
||||
keyPrefix = rawKey
|
||||
if len(keyPrefix) > rawKeyPreviewLength {
|
||||
keyPrefix = keyPrefix[:rawKeyPreviewLength]
|
||||
}
|
||||
|
||||
return rawKey, keyPrefix, hashAPIKey(rawKey), nil
|
||||
}
|
||||
|
||||
func hashAPIKey(rawKey string) string {
|
||||
sum := sha256.Sum256([]byte(rawKey))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func syncScopeAccess(ctx context.Context, repo *db.APIKeyRepository, apiKeyID int64, scopeMode db.ScopeMode, projectIDs []int64, tagIDs []int64) error {
|
||||
switch {
|
||||
case scopeMode == db.ScopeModeAllProjects:
|
||||
if err := repo.ClearProjectAccess(ctx, apiKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repo.ClearTagAccess(ctx, apiKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
case scopeMode.UsesProjectRules():
|
||||
if err := repo.ClearTagAccess(ctx, apiKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repo.ReplaceProjectAccess(ctx, apiKeyID, projectIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
case scopeMode.UsesTagRules():
|
||||
if err := repo.ClearProjectAccess(ctx, apiKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repo.ReplaceTagAccess(ctx, apiKeyID, tagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported scope mode %q", scopeMode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeCreateParams(params CreateParams) (CreateParams, error) {
|
||||
normalized := params
|
||||
normalized.Name = strings.TrimSpace(normalized.Name)
|
||||
normalized.Description = strings.TrimSpace(normalized.Description)
|
||||
|
||||
switch {
|
||||
case normalized.Name == "":
|
||||
return CreateParams{}, fmt.Errorf("api key name is required")
|
||||
case !normalized.ScopeMode.Valid():
|
||||
return CreateParams{}, fmt.Errorf("api key scope mode is required")
|
||||
case !hasAnyPermission(normalized):
|
||||
return CreateParams{}, fmt.Errorf("select at least one permission")
|
||||
}
|
||||
|
||||
normalized.ProjectIDs, normalized.TagIDs = normalizedScopeIDs(normalized.ScopeMode, normalized.ProjectIDs, normalized.TagIDs)
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeUpdateParams(params UpdateParams) (UpdateParams, error) {
|
||||
normalized := params
|
||||
normalized.Name = strings.TrimSpace(normalized.Name)
|
||||
normalized.Description = strings.TrimSpace(normalized.Description)
|
||||
|
||||
switch {
|
||||
case normalized.Name == "":
|
||||
return UpdateParams{}, fmt.Errorf("api key name is required")
|
||||
case !normalized.ScopeMode.Valid():
|
||||
return UpdateParams{}, fmt.Errorf("api key scope mode is required")
|
||||
case !hasAnyPermission(normalized):
|
||||
return UpdateParams{}, fmt.Errorf("select at least one permission")
|
||||
}
|
||||
|
||||
normalized.ProjectIDs, normalized.TagIDs = normalizedScopeIDs(normalized.ScopeMode, normalized.ProjectIDs, normalized.TagIDs)
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizedScopeIDs(scopeMode db.ScopeMode, projectIDs []int64, tagIDs []int64) ([]int64, []int64) {
|
||||
switch {
|
||||
case scopeMode.UsesProjectRules():
|
||||
return dedupeIDs(projectIDs), nil
|
||||
case scopeMode.UsesTagRules():
|
||||
return nil, dedupeIDs(tagIDs)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func dedupeIDs(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
deduped := make([]int64, 0, len(values))
|
||||
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[value] = struct{}{}
|
||||
deduped = append(deduped, value)
|
||||
}
|
||||
|
||||
return deduped
|
||||
}
|
||||
|
||||
func hasAnyPermission(params interface {
|
||||
GetCanDownload() bool
|
||||
GetCanUpload() bool
|
||||
GetCanDelete() bool
|
||||
GetCanManageProjects() bool
|
||||
}) bool {
|
||||
return params.GetCanDownload() || params.GetCanUpload() || params.GetCanDelete() || params.GetCanManageProjects()
|
||||
}
|
||||
|
||||
func (p CreateParams) GetCanDownload() bool { return p.CanDownload }
|
||||
func (p CreateParams) GetCanUpload() bool { return p.CanUpload }
|
||||
func (p CreateParams) GetCanDelete() bool { return p.CanDelete }
|
||||
func (p CreateParams) GetCanManageProjects() bool { return p.CanManageProjects }
|
||||
|
||||
func (p UpdateParams) GetCanDownload() bool { return p.CanDownload }
|
||||
func (p UpdateParams) GetCanUpload() bool { return p.CanUpload }
|
||||
func (p UpdateParams) GetCanDelete() bool { return p.CanDelete }
|
||||
func (p UpdateParams) GetCanManageProjects() bool { return p.CanManageProjects }
|
||||
316
internal/apikeys/service_test.go
Normal file
316
internal/apikeys/service_test.go
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
package apikeys_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func TestServiceCreateAuthenticateAndRejectInactiveOrExpiredKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, store := newAPIKeyTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
project := createProject(t, ctx, store, "Desktop App", "desktop-app")
|
||||
|
||||
created, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Desktop Clients",
|
||||
ScopeMode: db.ScopeModeProjectAllowList,
|
||||
CanDownload: true,
|
||||
ProjectIDs: []int64{project.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create api key: %v", err)
|
||||
}
|
||||
|
||||
stored, err := store.APIKeys.GetByID(ctx, created.APIKey.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load stored api key: %v", err)
|
||||
}
|
||||
|
||||
if stored.KeyHash == created.RawKey {
|
||||
t.Fatal("expected raw api key to be hashed before storage")
|
||||
}
|
||||
|
||||
if stored.KeyPrefix == created.RawKey {
|
||||
t.Fatal("expected only a short key prefix to be stored")
|
||||
}
|
||||
|
||||
authState, err := service.Authenticate(ctx, created.RawKey)
|
||||
if err != nil {
|
||||
t.Fatalf("authenticate api key: %v", err)
|
||||
}
|
||||
|
||||
if authState.APIKey.ID != created.APIKey.ID {
|
||||
t.Fatalf("expected authenticated api key id %d, got %d", created.APIKey.ID, authState.APIKey.ID)
|
||||
}
|
||||
|
||||
stored, err = store.APIKeys.GetByID(ctx, created.APIKey.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload stored api key: %v", err)
|
||||
}
|
||||
|
||||
if stored.LastUsedAt == nil {
|
||||
t.Fatal("expected successful authentication to update last_used_at")
|
||||
}
|
||||
|
||||
expiredAt := time.Now().UTC().Add(-time.Hour)
|
||||
expired, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Expired Clients",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
ExpiresAt: &expiredAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create expired api key: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.Authenticate(ctx, expired.RawKey); !errors.Is(err, apikeys.ErrUnauthenticated) {
|
||||
t.Fatalf("expected expired api key to be rejected, got %v", err)
|
||||
}
|
||||
|
||||
revoked, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Revoked Clients",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create revoked api key: %v", err)
|
||||
}
|
||||
|
||||
if err := service.SetActive(ctx, revoked.APIKey.ID, false); err != nil {
|
||||
t.Fatalf("revoke api key: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.Authenticate(ctx, revoked.RawKey); !errors.Is(err, apikeys.ErrUnauthenticated) {
|
||||
t.Fatalf("expected revoked api key to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListAccessibleProjectsByScopeMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, store := newAPIKeyTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
desktop := createProject(t, ctx, store, "Desktop App", "desktop-app")
|
||||
mobile := createProject(t, ctx, store, "Mobile App", "mobile-app")
|
||||
internal := createProject(t, ctx, store, "Internal App", "internal-app")
|
||||
archived := createProject(t, ctx, store, "Archived App", "archived-app")
|
||||
|
||||
windows := createTag(t, ctx, store, "Windows", "windows")
|
||||
beta := createTag(t, ctx, store, "Beta", "beta")
|
||||
|
||||
attachTag(t, ctx, store, desktop.ID, windows.ID)
|
||||
attachTag(t, ctx, store, mobile.ID, beta.ID)
|
||||
attachTag(t, ctx, store, archived.ID, windows.ID)
|
||||
|
||||
if err := store.Projects.SetActive(ctx, archived.ID, false); err != nil {
|
||||
t.Fatalf("archive project: %v", err)
|
||||
}
|
||||
|
||||
allProjectsKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "All Projects",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create all projects key: %v", err)
|
||||
}
|
||||
|
||||
projectAllowKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Project Allow",
|
||||
ScopeMode: db.ScopeModeProjectAllowList,
|
||||
CanDownload: true,
|
||||
ProjectIDs: []int64{mobile.ID, internal.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create project allow key: %v", err)
|
||||
}
|
||||
|
||||
projectDenyKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Project Deny",
|
||||
ScopeMode: db.ScopeModeProjectDenyList,
|
||||
CanDownload: true,
|
||||
ProjectIDs: []int64{mobile.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create project deny key: %v", err)
|
||||
}
|
||||
|
||||
tagAllowKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Tag Allow",
|
||||
ScopeMode: db.ScopeModeTagAllowList,
|
||||
CanDownload: true,
|
||||
TagIDs: []int64{windows.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create tag allow key: %v", err)
|
||||
}
|
||||
|
||||
tagDenyKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Tag Deny",
|
||||
ScopeMode: db.ScopeModeTagDenyList,
|
||||
CanDownload: true,
|
||||
TagIDs: []int64{windows.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create tag deny key: %v", err)
|
||||
}
|
||||
|
||||
assertAccessibleProjects(t, ctx, service, *allProjectsKey.APIKey, []string{"Desktop App", "Internal App", "Mobile App"})
|
||||
assertAccessibleProjects(t, ctx, service, *projectAllowKey.APIKey, []string{"Internal App", "Mobile App"})
|
||||
assertAccessibleProjects(t, ctx, service, *projectDenyKey.APIKey, []string{"Desktop App", "Internal App"})
|
||||
assertAccessibleProjects(t, ctx, service, *tagAllowKey.APIKey, []string{"Desktop App"})
|
||||
assertAccessibleProjects(t, ctx, service, *tagDenyKey.APIKey, []string{"Internal App", "Mobile App"})
|
||||
}
|
||||
|
||||
func TestServiceUpdateTransitionsScopeRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, store := newAPIKeyTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
project := createProject(t, ctx, store, "Desktop App", "desktop-app")
|
||||
otherProject := createProject(t, ctx, store, "Mobile App", "mobile-app")
|
||||
windows := createTag(t, ctx, store, "Windows", "windows")
|
||||
attachTag(t, ctx, store, project.ID, windows.ID)
|
||||
|
||||
created, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Transition Key",
|
||||
ScopeMode: db.ScopeModeProjectAllowList,
|
||||
CanDownload: true,
|
||||
ProjectIDs: []int64{otherProject.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create api key: %v", err)
|
||||
}
|
||||
|
||||
updated, err := service.Update(ctx, created.APIKey.ID, apikeys.UpdateParams{
|
||||
Name: "Transition Key",
|
||||
ScopeMode: db.ScopeModeTagAllowList,
|
||||
CanDownload: true,
|
||||
TagIDs: []int64{windows.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update api key scope: %v", err)
|
||||
}
|
||||
|
||||
projectAccess, err := service.ListProjectAccess(ctx, updated.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list project access: %v", err)
|
||||
}
|
||||
|
||||
if len(projectAccess) != 0 {
|
||||
t.Fatalf("expected project access rows to be cleared, got %d", len(projectAccess))
|
||||
}
|
||||
|
||||
tagAccess, err := service.ListTagAccess(ctx, updated.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list tag access: %v", err)
|
||||
}
|
||||
|
||||
if len(tagAccess) != 1 || tagAccess[0].ID != windows.ID {
|
||||
t.Fatalf("expected one retained tag access row, got %+v", tagAccess)
|
||||
}
|
||||
|
||||
assertAccessibleProjects(t, ctx, service, *updated, []string{"Desktop App"})
|
||||
}
|
||||
|
||||
func assertAccessibleProjects(t *testing.T, ctx context.Context, service *apikeys.Service, key db.APIKey, want []string) {
|
||||
t.Helper()
|
||||
|
||||
projects, err := service.ListAccessibleProjects(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("list accessible projects: %v", err)
|
||||
}
|
||||
|
||||
got := make([]string, 0, len(projects))
|
||||
for _, project := range projects {
|
||||
got = append(got, project.Name)
|
||||
}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected accessible projects %v, got %v", want, got)
|
||||
}
|
||||
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("expected accessible projects %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newAPIKeyTestService(t *testing.T) (*apikeys.Service, *db.Store) {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
sqlitePath := filepath.Join(t.TempDir(), "apikeys.sqlite")
|
||||
|
||||
database, err := db.Open(ctx, sqlitePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Migrate(ctx, database, apiKeyProjectPath(t, "migrations")); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("migrate sqlite: %v", err)
|
||||
}
|
||||
|
||||
store := db.NewStore(database)
|
||||
t.Cleanup(func() {
|
||||
_ = store.Close()
|
||||
})
|
||||
|
||||
return apikeys.NewService(store), store
|
||||
}
|
||||
|
||||
func apiKeyProjectPath(t *testing.T, parts ...string) string {
|
||||
t.Helper()
|
||||
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve caller path")
|
||||
}
|
||||
|
||||
root := filepath.Join(filepath.Dir(filename), "..", "..")
|
||||
items := append([]string{root}, parts...)
|
||||
return filepath.Join(items...)
|
||||
}
|
||||
|
||||
func createProject(t *testing.T, ctx context.Context, store *db.Store, name, slug string) *db.Project {
|
||||
t.Helper()
|
||||
|
||||
project, err := store.Projects.Create(ctx, db.CreateProjectParams{Name: name, Slug: slug})
|
||||
if err != nil {
|
||||
t.Fatalf("create project %s: %v", name, err)
|
||||
}
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
func createTag(t *testing.T, ctx context.Context, store *db.Store, name, slug string) *db.Tag {
|
||||
t.Helper()
|
||||
|
||||
tag, err := store.Tags.Create(ctx, db.CreateTagParams{Name: name, Slug: slug})
|
||||
if err != nil {
|
||||
t.Fatalf("create tag %s: %v", name, err)
|
||||
}
|
||||
|
||||
return tag
|
||||
}
|
||||
|
||||
func attachTag(t *testing.T, ctx context.Context, store *db.Store, projectID, tagID int64) {
|
||||
t.Helper()
|
||||
|
||||
if err := store.Projects.AttachTag(ctx, projectID, tagID); err != nil {
|
||||
t.Fatalf("attach tag %d to project %d: %v", tagID, projectID, err)
|
||||
}
|
||||
}
|
||||
142
internal/app/app.go
Normal file
142
internal/app/app.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/auth"
|
||||
"update_server/internal/config"
|
||||
database "update_server/internal/db"
|
||||
httpserver "update_server/internal/http"
|
||||
"update_server/internal/releases"
|
||||
"update_server/internal/storage"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
config config.Config
|
||||
logger *slog.Logger
|
||||
server *http.Server
|
||||
store *database.Store
|
||||
}
|
||||
|
||||
func New(cfg config.Config, logger *slog.Logger) (*App, error) {
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
|
||||
renderer, err := httpserver.NewRenderer(cfg.TemplatesDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create renderer: %w", err)
|
||||
}
|
||||
|
||||
sqliteDB, err := database.Open(context.Background(), cfg.SQLitePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
if err := database.Migrate(context.Background(), sqliteDB, cfg.MigrationsDir); err != nil {
|
||||
sqliteDB.Close()
|
||||
return nil, fmt.Errorf("apply migrations: %w", err)
|
||||
}
|
||||
|
||||
store := database.NewStore(sqliteDB)
|
||||
authService := auth.NewService(cfg, logger, store)
|
||||
apiKeyService := apikeys.NewService(store)
|
||||
if err := authService.EnsureBootstrapAdmin(context.Background()); err != nil {
|
||||
sqliteDB.Close()
|
||||
return nil, fmt.Errorf("bootstrap admin auth: %w", err)
|
||||
}
|
||||
|
||||
artifactStore, err := storage.NewLocal(cfg.ArtifactsDir)
|
||||
if err != nil {
|
||||
sqliteDB.Close()
|
||||
return nil, fmt.Errorf("create artifact storage: %w", err)
|
||||
}
|
||||
|
||||
releaseService := releases.NewService(store, artifactStore)
|
||||
router := httpserver.NewRouter(cfg, logger, renderer, store, authService, apiKeyService, releaseService)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: router,
|
||||
ReadTimeout: cfg.ReadTimeout,
|
||||
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
|
||||
WriteTimeout: cfg.WriteTimeout,
|
||||
IdleTimeout: cfg.IdleTimeout,
|
||||
MaxHeaderBytes: cfg.MaxHeaderBytes,
|
||||
}
|
||||
|
||||
return &App{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
server: server,
|
||||
store: store,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) Run(ctx context.Context) (runErr error) {
|
||||
defer func() {
|
||||
if a.store == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.store.Close(); err != nil {
|
||||
closeErr := fmt.Errorf("close database: %w", err)
|
||||
if runErr != nil {
|
||||
runErr = errors.Join(runErr, closeErr)
|
||||
return
|
||||
}
|
||||
|
||||
runErr = closeErr
|
||||
}
|
||||
}()
|
||||
|
||||
runCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
a.logger.Info("starting server",
|
||||
"addr", a.config.HTTPAddr,
|
||||
"base_url", a.config.BaseURL,
|
||||
"data_dir", a.config.DataDir,
|
||||
"sqlite_path", a.config.SQLitePath,
|
||||
)
|
||||
|
||||
serverErr <- a.server.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-serverErr:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
case <-runCtx.Done():
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), a.config.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
a.logger.Info("shutting down server")
|
||||
|
||||
if err := a.server.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("shutdown server: %w", err)
|
||||
}
|
||||
|
||||
err := <-serverErr
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
16
internal/auth/context.go
Normal file
16
internal/auth/context.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
type contextKey string
|
||||
|
||||
const sessionStateKey contextKey = "auth.session-state"
|
||||
|
||||
func NewContext(ctx context.Context, state *SessionState) context.Context {
|
||||
return context.WithValue(ctx, sessionStateKey, state)
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) (*SessionState, bool) {
|
||||
state, ok := ctx.Value(sessionStateKey).(*SessionState)
|
||||
return state, ok
|
||||
}
|
||||
35
internal/auth/password.go
Normal file
35
internal/auth/password.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const bcryptCost = 12
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return "", fmt.Errorf("password is required")
|
||||
}
|
||||
|
||||
if len(password) > 72 {
|
||||
return "", fmt.Errorf("password must be 72 bytes or fewer")
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
return string(hashedPassword), nil
|
||||
}
|
||||
|
||||
func ComparePassword(hash, password string) error {
|
||||
if hash == "" {
|
||||
return fmt.Errorf("password hash is required")
|
||||
}
|
||||
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
}
|
||||
259
internal/auth/service.go
Normal file
259
internal/auth/service.go
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"update_server/internal/config"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
const sessionTokenBytes = 32
|
||||
const sessionCookiePath = "/admin"
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUnauthenticated = errors.New("unauthenticated")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
config config.Config
|
||||
logger *slog.Logger
|
||||
store *db.Store
|
||||
}
|
||||
|
||||
type SessionState struct {
|
||||
User db.User
|
||||
Session db.Session
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, logger *slog.Logger, store *db.Store) *Service {
|
||||
return &Service{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) EnsureBootstrapAdmin(ctx context.Context) error {
|
||||
hasActiveAdmin, err := s.store.Users.HasActiveAdmin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check active admin users: %w", err)
|
||||
}
|
||||
|
||||
if hasActiveAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(s.config.AdminEmail)
|
||||
password := s.config.AdminPassword
|
||||
if email == "" || strings.TrimSpace(password) == "" {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("no active admin user found; set ADMIN_EMAIL and ADMIN_PASSWORD to bootstrap the first admin")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if existingUser, err := s.store.Users.GetByEmail(ctx, email); err == nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("bootstrap admin skipped because the configured email already exists", "email", existingUser.Email)
|
||||
}
|
||||
|
||||
return nil
|
||||
} else if !errors.Is(err, db.ErrNotFound) {
|
||||
return fmt.Errorf("check bootstrap admin email: %w", err)
|
||||
}
|
||||
|
||||
passwordHash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash bootstrap admin password: %w", err)
|
||||
}
|
||||
|
||||
if _, err := s.store.Users.Create(ctx, db.CreateUserParams{
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
Role: db.UserRoleAdmin,
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("create bootstrap admin user: %w", err)
|
||||
}
|
||||
|
||||
if s.logger != nil {
|
||||
s.logger.Info("bootstrapped admin user", "email", email)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Authenticate(ctx context.Context, email, password, ipAddress, userAgent string) (string, *SessionState, error) {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" || password == "" {
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
user, err := s.store.Users.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
return "", nil, fmt.Errorf("load user by email: %w", err)
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if err := ComparePassword(user.PasswordHash, password); err != nil {
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
expiresAt := now.Add(s.config.SessionTTL)
|
||||
token, tokenHash, err := generateSessionToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
var session *db.Session
|
||||
if err := s.store.WithTx(ctx, func(tx *db.TxStore) error {
|
||||
createdSession, err := tx.Sessions.Create(ctx, db.CreateSessionParams{
|
||||
UserID: user.ID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: expiresAt,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Users.UpdateLastLoginAt(ctx, user.ID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session = createdSession
|
||||
return nil
|
||||
}); err != nil {
|
||||
return "", nil, fmt.Errorf("create authenticated session: %w", err)
|
||||
}
|
||||
|
||||
user.LastLoginAt = &now
|
||||
|
||||
return token, &SessionState{
|
||||
User: *user,
|
||||
Session: *session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadSession(ctx context.Context, token string) (*SessionState, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
record, err := s.store.Sessions.GetActiveWithUserByTokenHash(ctx, hashSessionToken(token), now)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("lookup active session: %w", err)
|
||||
}
|
||||
|
||||
if err := s.store.Sessions.Touch(ctx, record.Session.ID, now); err != nil {
|
||||
return nil, fmt.Errorf("touch active session: %w", err)
|
||||
}
|
||||
|
||||
record.Session.LastSeenAt = &now
|
||||
|
||||
return &SessionState{
|
||||
User: record.User,
|
||||
Session: record.Session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) InvalidateSession(ctx context.Context, token string) error {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.store.Sessions.InvalidateByTokenHash(ctx, hashSessionToken(token), time.Now().UTC()); err != nil && !errors.Is(err, db.ErrNotFound) {
|
||||
return fmt.Errorf("invalidate session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) SessionCookie(token string, expiresAt time.Time) *http.Cookie {
|
||||
maxAge := int(time.Until(expiresAt).Seconds())
|
||||
if maxAge < 0 {
|
||||
maxAge = 0
|
||||
}
|
||||
|
||||
return &http.Cookie{
|
||||
Name: s.config.SessionCookieName,
|
||||
Value: token,
|
||||
Path: sessionCookiePath,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.config.SecureCookies,
|
||||
Expires: expiresAt.UTC(),
|
||||
MaxAge: maxAge,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ClearSessionCookie() *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: s.config.SessionCookieName,
|
||||
Value: "",
|
||||
Path: sessionCookiePath,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.config.SecureCookies,
|
||||
Expires: time.Unix(0, 0).UTC(),
|
||||
MaxAge: -1,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SessionCookieName() string {
|
||||
return s.config.SessionCookieName
|
||||
}
|
||||
|
||||
func RoleAllowed(actualRole, requiredRole db.UserRole) bool {
|
||||
ranks := map[db.UserRole]int{
|
||||
db.UserRoleViewer: 1,
|
||||
db.UserRoleEditor: 2,
|
||||
db.UserRoleAdmin: 3,
|
||||
}
|
||||
|
||||
return ranks[actualRole] >= ranks[requiredRole] && ranks[requiredRole] > 0
|
||||
}
|
||||
|
||||
func generateSessionToken() (string, string, error) {
|
||||
bytes := make([]byte, sessionTokenBytes)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", "", fmt.Errorf("generate session token: %w", err)
|
||||
}
|
||||
|
||||
token := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
return token, hashSessionToken(token), nil
|
||||
}
|
||||
|
||||
func hashSessionToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
101
internal/auth/service_test.go
Normal file
101
internal/auth/service_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"update_server/internal/auth"
|
||||
"update_server/internal/config"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func TestEnsureBootstrapAdminCreatesSingleAdminUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
defer store.Close()
|
||||
|
||||
cfg := config.Config{
|
||||
AdminEmail: "admin@example.com",
|
||||
AdminPassword: "correct horse battery staple",
|
||||
SessionCookieName: "update_server_session",
|
||||
SessionTTL: 24 * time.Hour,
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
service := auth.NewService(cfg, logger, store)
|
||||
|
||||
if err := service.EnsureBootstrapAdmin(ctx); err != nil {
|
||||
t.Fatalf("bootstrap admin: %v", err)
|
||||
}
|
||||
|
||||
if err := service.EnsureBootstrapAdmin(ctx); err != nil {
|
||||
t.Fatalf("bootstrap admin second pass: %v", err)
|
||||
}
|
||||
|
||||
user, err := store.Users.GetByEmail(ctx, cfg.AdminEmail)
|
||||
if err != nil {
|
||||
t.Fatalf("load bootstrapped admin: %v", err)
|
||||
}
|
||||
|
||||
if user.Role != db.UserRoleAdmin {
|
||||
t.Fatalf("expected admin role, got %q", user.Role)
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
t.Fatal("expected bootstrapped admin to be active")
|
||||
}
|
||||
|
||||
if user.PasswordHash == cfg.AdminPassword {
|
||||
t.Fatal("expected bootstrapped password to be hashed")
|
||||
}
|
||||
|
||||
if err := auth.ComparePassword(user.PasswordHash, cfg.AdminPassword); err != nil {
|
||||
t.Fatalf("compare hashed password: %v", err)
|
||||
}
|
||||
|
||||
var userCount int
|
||||
if err := store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
|
||||
t.Fatalf("count users: %v", err)
|
||||
}
|
||||
|
||||
if userCount != 1 {
|
||||
t.Fatalf("expected one bootstrapped user, got %d", userCount)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *db.Store {
|
||||
t.Helper()
|
||||
|
||||
sqlitePath := filepath.Join(t.TempDir(), "update-server.sqlite")
|
||||
database, err := db.Open(context.Background(), sqlitePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Migrate(context.Background(), database, testProjectPath(t, "migrations")); err != nil {
|
||||
database.Close()
|
||||
t.Fatalf("migrate sqlite: %v", err)
|
||||
}
|
||||
|
||||
return db.NewStore(database)
|
||||
}
|
||||
|
||||
func testProjectPath(t *testing.T, parts ...string) string {
|
||||
t.Helper()
|
||||
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve caller path")
|
||||
}
|
||||
|
||||
root := filepath.Join(filepath.Dir(filename), "..", "..")
|
||||
items := append([]string{root}, parts...)
|
||||
return filepath.Join(items...)
|
||||
}
|
||||
347
internal/config/config.go
Normal file
347
internal/config/config.go
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const appName = "Update Server"
|
||||
|
||||
type Config struct {
|
||||
AppName string
|
||||
HTTPAddr string
|
||||
BaseURL string
|
||||
DataDir string
|
||||
SQLitePath string
|
||||
ArtifactsDir string
|
||||
MigrationsDir string
|
||||
TemplatesDir string
|
||||
StaticDir string
|
||||
MaxUploadBytes int64
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
SessionCookieName string
|
||||
CSRFCookieName string
|
||||
SessionTTL time.Duration
|
||||
SecureCookies bool
|
||||
TrustProxyHeaders bool
|
||||
ReadTimeout time.Duration
|
||||
ReadHeaderTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
MaxHeaderBytes int
|
||||
LoginRateLimitPerMinute int
|
||||
LoginRateLimitBurst int
|
||||
ClientRateLimitPerMinute int
|
||||
ClientRateLimitBurst int
|
||||
LogLevel slog.Level
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
baseURL := getenv("APP_BASE_URL", "http://127.0.0.1:8080")
|
||||
parsedBaseURL, err := validateBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
dataDir, err := filepath.Abs(getenv("DATA_DIR", "data-dev"))
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("resolve DATA_DIR: %w", err)
|
||||
}
|
||||
|
||||
sqlitePath, err := resolvePath("SQLITE_PATH", filepath.Join(dataDir, "db.sqlite"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
artifactsDir, err := resolvePath("ARTIFACTS_DIR", filepath.Join(dataDir, "artifacts"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
migrationsDir, err := resolvePath("MIGRATIONS_DIR", "migrations")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
templatesDir, err := resolvePath("TEMPLATES_DIR", "web/templates")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
staticDir, err := resolvePath("STATIC_DIR", "web/static")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
|
||||
adminPassword := os.Getenv("ADMIN_PASSWORD")
|
||||
if adminEmail == "" && strings.TrimSpace(adminPassword) != "" {
|
||||
return Config{}, fmt.Errorf("ADMIN_PASSWORD requires ADMIN_EMAIL")
|
||||
}
|
||||
|
||||
if adminEmail != "" && strings.TrimSpace(adminPassword) == "" {
|
||||
return Config{}, fmt.Errorf("ADMIN_EMAIL requires ADMIN_PASSWORD")
|
||||
}
|
||||
|
||||
sessionCookieName := getenv("SESSION_COOKIE_NAME", "update_server_session")
|
||||
if strings.TrimSpace(sessionCookieName) == "" {
|
||||
return Config{}, fmt.Errorf("SESSION_COOKIE_NAME must not be empty")
|
||||
}
|
||||
|
||||
csrfCookieName := getenv("CSRF_COOKIE_NAME", "update_server_csrf")
|
||||
if strings.TrimSpace(csrfCookieName) == "" {
|
||||
return Config{}, fmt.Errorf("CSRF_COOKIE_NAME must not be empty")
|
||||
}
|
||||
|
||||
sessionTTL, err := parseDuration("SESSION_TTL", "24h")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if sessionTTL <= 0 {
|
||||
return Config{}, fmt.Errorf("SESSION_TTL must be greater than zero")
|
||||
}
|
||||
|
||||
maxUploadBytes, err := parseInt64("MAX_UPLOAD_BYTES", 1<<30)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if maxUploadBytes <= 0 {
|
||||
return Config{}, fmt.Errorf("MAX_UPLOAD_BYTES must be greater than zero")
|
||||
}
|
||||
|
||||
readTimeout, err := parseDuration("APP_READ_TIMEOUT", "10s")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
readHeaderTimeout, err := parseDuration("APP_READ_HEADER_TIMEOUT", "5s")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
writeTimeout, err := parseDuration("APP_WRITE_TIMEOUT", "60s")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
idleTimeout, err := parseDuration("APP_IDLE_TIMEOUT", "60s")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
shutdownTimeout, err := parseDuration("APP_SHUTDOWN_TIMEOUT", "10s")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
maxHeaderBytes, err := parseInt("APP_MAX_HEADER_BYTES", 1<<20)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if maxHeaderBytes <= 0 {
|
||||
return Config{}, fmt.Errorf("APP_MAX_HEADER_BYTES must be greater than zero")
|
||||
}
|
||||
|
||||
trustProxyHeaders, err := parseBool("TRUST_PROXY_HEADERS", false)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
loginRateLimitPerMinute, err := parseInt("APP_LOGIN_RATE_LIMIT_PER_MINUTE", 10)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
loginRateLimitBurst, err := parseInt("APP_LOGIN_RATE_LIMIT_BURST", 5)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
clientRateLimitPerMinute, err := parseInt("APP_CLIENT_RATE_LIMIT_PER_MINUTE", 120)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
clientRateLimitBurst, err := parseInt("APP_CLIENT_RATE_LIMIT_BURST", 60)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
for key, value := range map[string]int{
|
||||
"APP_LOGIN_RATE_LIMIT_PER_MINUTE": loginRateLimitPerMinute,
|
||||
"APP_LOGIN_RATE_LIMIT_BURST": loginRateLimitBurst,
|
||||
"APP_CLIENT_RATE_LIMIT_PER_MINUTE": clientRateLimitPerMinute,
|
||||
"APP_CLIENT_RATE_LIMIT_BURST": clientRateLimitBurst,
|
||||
} {
|
||||
if value <= 0 {
|
||||
return Config{}, fmt.Errorf("%s must be greater than zero", key)
|
||||
}
|
||||
}
|
||||
|
||||
logLevel, err := parseLogLevel(getenv("APP_LOG_LEVEL", "INFO"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if pathWithin(artifactsDir, staticDir) {
|
||||
return Config{}, fmt.Errorf("ARTIFACTS_DIR must be outside STATIC_DIR")
|
||||
}
|
||||
|
||||
return Config{
|
||||
AppName: appName,
|
||||
HTTPAddr: getenv("APP_ADDR", ":8080"),
|
||||
BaseURL: baseURL,
|
||||
DataDir: dataDir,
|
||||
SQLitePath: sqlitePath,
|
||||
ArtifactsDir: artifactsDir,
|
||||
MigrationsDir: migrationsDir,
|
||||
TemplatesDir: templatesDir,
|
||||
StaticDir: staticDir,
|
||||
MaxUploadBytes: maxUploadBytes,
|
||||
AdminEmail: adminEmail,
|
||||
AdminPassword: adminPassword,
|
||||
SessionCookieName: sessionCookieName,
|
||||
CSRFCookieName: csrfCookieName,
|
||||
SessionTTL: sessionTTL,
|
||||
SecureCookies: strings.EqualFold(parsedBaseURL.Scheme, "https"),
|
||||
TrustProxyHeaders: trustProxyHeaders,
|
||||
ReadTimeout: readTimeout,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
IdleTimeout: idleTimeout,
|
||||
ShutdownTimeout: shutdownTimeout,
|
||||
MaxHeaderBytes: maxHeaderBytes,
|
||||
LoginRateLimitPerMinute: loginRateLimitPerMinute,
|
||||
LoginRateLimitBurst: loginRateLimitBurst,
|
||||
ClientRateLimitPerMinute: clientRateLimitPerMinute,
|
||||
ClientRateLimitBurst: clientRateLimitBurst,
|
||||
LogLevel: logLevel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
func validateBaseURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse APP_BASE_URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("APP_BASE_URL must include scheme and host, got %q", raw)
|
||||
}
|
||||
|
||||
if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
|
||||
return nil, fmt.Errorf("APP_BASE_URL scheme must be http or https, got %q", parsed.Scheme)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func resolvePath(key, fallback string) (string, error) {
|
||||
path, err := filepath.Abs(getenv(key, fallback))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve %s: %w", key, err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func parseDuration(key, fallback string) (time.Duration, error) {
|
||||
raw := getenv(key, fallback)
|
||||
value, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse %s: %w", key, err)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseInt64(key string, fallback int64) (int64, error) {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse %s: %w", key, err)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseInt(key string, fallback int) (int, error) {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse %s: %w", key, err)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseBool(key string, fallback bool) (bool, error) {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse %s: %w", key, err)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseLogLevel(raw string) (slog.Level, error) {
|
||||
switch strings.ToUpper(strings.TrimSpace(raw)) {
|
||||
case "DEBUG":
|
||||
return slog.LevelDebug, nil
|
||||
case "INFO":
|
||||
return slog.LevelInfo, nil
|
||||
case "WARN", "WARNING":
|
||||
return slog.LevelWarn, nil
|
||||
case "ERROR":
|
||||
return slog.LevelError, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("APP_LOG_LEVEL must be one of DEBUG, INFO, WARN, ERROR")
|
||||
}
|
||||
}
|
||||
|
||||
func pathWithin(candidate, parent string) bool {
|
||||
rel, err := filepath.Rel(filepath.Clean(parent), filepath.Clean(candidate))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if rel == "." {
|
||||
return true
|
||||
}
|
||||
|
||||
rel = filepath.ToSlash(rel)
|
||||
return rel != ".." && !strings.HasPrefix(rel, "../") && path.Clean(rel) != ".."
|
||||
}
|
||||
761
internal/db/apikeys.go
Normal file
761
internal/db/apikeys.go
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateAPIKeyParams struct {
|
||||
Name string
|
||||
KeyPrefix string
|
||||
KeyHash string
|
||||
Description string
|
||||
ScopeMode ScopeMode
|
||||
CanDownload bool
|
||||
CanUpload bool
|
||||
CanDelete bool
|
||||
CanManageProjects bool
|
||||
IsActive bool
|
||||
ExpiresAt *time.Time
|
||||
CreatedByUserID *int64
|
||||
}
|
||||
|
||||
type UpdateAPIKeyParams struct {
|
||||
Name string
|
||||
Description string
|
||||
ScopeMode ScopeMode
|
||||
CanDownload bool
|
||||
CanUpload bool
|
||||
CanDelete bool
|
||||
CanManageProjects bool
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) List(ctx context.Context) ([]APIKeyListItem, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
ak.id,
|
||||
ak.name,
|
||||
ak.key_prefix,
|
||||
ak.key_hash,
|
||||
ak.description,
|
||||
ak.scope_mode,
|
||||
ak.can_download,
|
||||
ak.can_upload,
|
||||
ak.can_delete,
|
||||
ak.can_manage_projects,
|
||||
ak.is_active,
|
||||
ak.expires_at,
|
||||
ak.created_at,
|
||||
ak.updated_at,
|
||||
ak.last_used_at,
|
||||
ak.created_by_user_id,
|
||||
COUNT(DISTINCT ap.project_id) AS project_rule_count,
|
||||
COUNT(DISTINCT at.tag_id) AS tag_rule_count
|
||||
FROM api_keys AS ak
|
||||
LEFT JOIN api_key_project_access AS ap ON ap.api_key_id = ak.id
|
||||
LEFT JOIN api_key_tag_access AS at ON at.api_key_id = ak.id
|
||||
GROUP BY ak.id
|
||||
ORDER BY ak.is_active DESC, ak.updated_at DESC, ak.created_at DESC, ak.name COLLATE NOCASE`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query api keys: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]APIKeyListItem, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanAPIKeyListItem(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan api key list item: %w", err)
|
||||
}
|
||||
|
||||
items = append(items, *item)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate api keys: %w", err)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) GetByID(ctx context.Context, id int64) (*APIKey, error) {
|
||||
key, err := scanAPIKey(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
id,
|
||||
name,
|
||||
key_prefix,
|
||||
key_hash,
|
||||
description,
|
||||
scope_mode,
|
||||
can_download,
|
||||
can_upload,
|
||||
can_delete,
|
||||
can_manage_projects,
|
||||
is_active,
|
||||
expires_at,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_used_at,
|
||||
created_by_user_id
|
||||
FROM api_keys
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan api key by id: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) GetByHash(ctx context.Context, keyHash string) (*APIKey, error) {
|
||||
key, err := scanAPIKey(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
id,
|
||||
name,
|
||||
key_prefix,
|
||||
key_hash,
|
||||
description,
|
||||
scope_mode,
|
||||
can_download,
|
||||
can_upload,
|
||||
can_delete,
|
||||
can_manage_projects,
|
||||
is_active,
|
||||
expires_at,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_used_at,
|
||||
created_by_user_id
|
||||
FROM api_keys
|
||||
WHERE key_hash = ?
|
||||
LIMIT 1`,
|
||||
strings.TrimSpace(keyHash),
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan api key by hash: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) Create(ctx context.Context, params CreateAPIKeyParams) (*APIKey, error) {
|
||||
isActive := 0
|
||||
if params.IsActive {
|
||||
isActive = 1
|
||||
}
|
||||
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_keys (
|
||||
name,
|
||||
key_prefix,
|
||||
key_hash,
|
||||
description,
|
||||
scope_mode,
|
||||
can_download,
|
||||
can_upload,
|
||||
can_delete,
|
||||
can_manage_projects,
|
||||
is_active,
|
||||
expires_at,
|
||||
created_by_user_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
strings.TrimSpace(params.Name),
|
||||
strings.TrimSpace(params.KeyPrefix),
|
||||
strings.TrimSpace(params.KeyHash),
|
||||
strings.TrimSpace(params.Description),
|
||||
params.ScopeMode,
|
||||
boolToInt(params.CanDownload),
|
||||
boolToInt(params.CanUpload),
|
||||
boolToInt(params.CanDelete),
|
||||
boolToInt(params.CanManageProjects),
|
||||
isActive,
|
||||
nullableTimestampValue(params.ExpiresAt),
|
||||
params.CreatedByUserID,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.Join(ErrConflict, fmt.Errorf("insert api key: %w", err))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("insert api key: %w", err)
|
||||
}
|
||||
|
||||
apiKeyID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inserted api key id: %w", err)
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, apiKeyID)
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) Update(ctx context.Context, apiKeyID int64, params UpdateAPIKeyParams) (*APIKey, error) {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE api_keys
|
||||
SET name = ?,
|
||||
description = ?,
|
||||
scope_mode = ?,
|
||||
can_download = ?,
|
||||
can_upload = ?,
|
||||
can_delete = ?,
|
||||
can_manage_projects = ?,
|
||||
expires_at = ?
|
||||
WHERE id = ?`,
|
||||
strings.TrimSpace(params.Name),
|
||||
strings.TrimSpace(params.Description),
|
||||
params.ScopeMode,
|
||||
boolToInt(params.CanDownload),
|
||||
boolToInt(params.CanUpload),
|
||||
boolToInt(params.CanDelete),
|
||||
boolToInt(params.CanManageProjects),
|
||||
nullableTimestampValue(params.ExpiresAt),
|
||||
apiKeyID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update api key: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read updated api key rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, apiKeyID)
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) SetActive(ctx context.Context, apiKeyID int64, isActive bool) error {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE api_keys SET is_active = ? WHERE id = ?`,
|
||||
boolToInt(isActive),
|
||||
apiKeyID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update api key active state: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read updated api key rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) TouchLastUsedAt(ctx context.Context, apiKeyID int64, usedAt time.Time) error {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE api_keys SET last_used_at = ? WHERE id = ?`,
|
||||
formatTimestamp(usedAt),
|
||||
apiKeyID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update api key last_used_at: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read updated api key rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ListProjectAccess(ctx context.Context, apiKeyID int64) ([]Project, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.slug,
|
||||
p.description,
|
||||
p.is_active,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM projects AS p
|
||||
INNER JOIN api_key_project_access AS ap ON ap.project_id = p.id
|
||||
WHERE ap.api_key_id = ?
|
||||
ORDER BY p.name COLLATE NOCASE`,
|
||||
apiKeyID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query api key project access: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
projects := make([]Project, 0)
|
||||
for rows.Next() {
|
||||
project, err := scanProject(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan api key project access: %w", err)
|
||||
}
|
||||
|
||||
projects = append(projects, *project)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate api key project access: %w", err)
|
||||
}
|
||||
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ListTagAccess(ctx context.Context, apiKeyID int64) ([]Tag, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.slug,
|
||||
t.description,
|
||||
t.created_at,
|
||||
t.updated_at
|
||||
FROM tags AS t
|
||||
INNER JOIN api_key_tag_access AS at ON at.tag_id = t.id
|
||||
WHERE at.api_key_id = ?
|
||||
ORDER BY t.name COLLATE NOCASE`,
|
||||
apiKeyID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query api key tag access: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tags := make([]Tag, 0)
|
||||
for rows.Next() {
|
||||
tag, err := scanTag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan api key tag access: %w", err)
|
||||
}
|
||||
|
||||
tags = append(tags, *tag)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate api key tag access: %w", err)
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ReplaceProjectAccess(ctx context.Context, apiKeyID int64, projectIDs []int64) error {
|
||||
if err := r.ClearProjectAccess(ctx, apiKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, projectID := range normalizeIDList(projectIDs) {
|
||||
if _, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT OR IGNORE INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`,
|
||||
apiKeyID,
|
||||
projectID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert api key project access: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ReplaceTagAccess(ctx context.Context, apiKeyID int64, tagIDs []int64) error {
|
||||
if err := r.ClearTagAccess(ctx, apiKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, tagID := range normalizeIDList(tagIDs) {
|
||||
if _, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT OR IGNORE INTO api_key_tag_access (api_key_id, tag_id) VALUES (?, ?)`,
|
||||
apiKeyID,
|
||||
tagID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert api key tag access: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ClearProjectAccess(ctx context.Context, apiKeyID int64) error {
|
||||
if _, err := r.q.ExecContext(ctx, `DELETE FROM api_key_project_access WHERE api_key_id = ?`, apiKeyID); err != nil {
|
||||
return fmt.Errorf("delete api key project access: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ClearTagAccess(ctx context.Context, apiKeyID int64) error {
|
||||
if _, err := r.q.ExecContext(ctx, `DELETE FROM api_key_tag_access WHERE api_key_id = ?`, apiKeyID); err != nil {
|
||||
return fmt.Errorf("delete api key tag access: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ListAccessibleProjects(ctx context.Context, apiKeyID int64, scopeMode ScopeMode) ([]Project, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.slug,
|
||||
p.description,
|
||||
p.is_active,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM projects AS p
|
||||
WHERE p.is_active = 1 AND (
|
||||
? = 'all_projects'
|
||||
OR (
|
||||
? = 'project_allow_list'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM api_key_project_access AS ap
|
||||
WHERE ap.api_key_id = ?
|
||||
AND ap.project_id = p.id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
? = 'project_deny_list'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM api_key_project_access AS ap
|
||||
WHERE ap.api_key_id = ?
|
||||
AND ap.project_id = p.id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
? = 'tag_allow_list'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM project_tags AS pt
|
||||
INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id
|
||||
WHERE at.api_key_id = ?
|
||||
AND pt.project_id = p.id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
? = 'tag_deny_list'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM project_tags AS pt
|
||||
INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id
|
||||
WHERE at.api_key_id = ?
|
||||
AND pt.project_id = p.id
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY p.name COLLATE NOCASE`,
|
||||
scopeMode,
|
||||
scopeMode, apiKeyID,
|
||||
scopeMode, apiKeyID,
|
||||
scopeMode, apiKeyID,
|
||||
scopeMode, apiKeyID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query accessible projects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
projects := make([]Project, 0)
|
||||
for rows.Next() {
|
||||
project, err := scanProject(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan accessible project: %w", err)
|
||||
}
|
||||
|
||||
projects = append(projects, *project)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate accessible projects: %w", err)
|
||||
}
|
||||
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) HasProjectAccess(ctx context.Context, apiKeyID int64, scopeMode ScopeMode, projectID int64) (bool, error) {
|
||||
var exists int
|
||||
if err := r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM projects AS p
|
||||
WHERE p.id = ?
|
||||
AND p.is_active = 1
|
||||
AND (
|
||||
? = 'all_projects'
|
||||
OR (
|
||||
? = 'project_allow_list'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM api_key_project_access AS ap
|
||||
WHERE ap.api_key_id = ?
|
||||
AND ap.project_id = p.id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
? = 'project_deny_list'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM api_key_project_access AS ap
|
||||
WHERE ap.api_key_id = ?
|
||||
AND ap.project_id = p.id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
? = 'tag_allow_list'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM project_tags AS pt
|
||||
INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id
|
||||
WHERE at.api_key_id = ?
|
||||
AND pt.project_id = p.id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
? = 'tag_deny_list'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM project_tags AS pt
|
||||
INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id
|
||||
WHERE at.api_key_id = ?
|
||||
AND pt.project_id = p.id
|
||||
)
|
||||
)
|
||||
)
|
||||
)`,
|
||||
projectID,
|
||||
scopeMode,
|
||||
scopeMode, apiKeyID,
|
||||
scopeMode, apiKeyID,
|
||||
scopeMode, apiKeyID,
|
||||
scopeMode, apiKeyID,
|
||||
).Scan(&exists); err != nil {
|
||||
return false, fmt.Errorf("query api key project access: %w", err)
|
||||
}
|
||||
|
||||
return exists == 1, nil
|
||||
}
|
||||
|
||||
func scanAPIKey(scanner rowScanner) (*APIKey, error) {
|
||||
var (
|
||||
key APIKey
|
||||
scopeMode string
|
||||
canDownload int
|
||||
canUpload int
|
||||
canDelete int
|
||||
canManageProjects int
|
||||
isActive int
|
||||
expiresAtRaw sql.NullString
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
lastUsedAtRaw sql.NullString
|
||||
createdByUserIDRaw sql.NullInt64
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&key.ID,
|
||||
&key.Name,
|
||||
&key.KeyPrefix,
|
||||
&key.KeyHash,
|
||||
&key.Description,
|
||||
&scopeMode,
|
||||
&canDownload,
|
||||
&canUpload,
|
||||
&canDelete,
|
||||
&canManageProjects,
|
||||
&isActive,
|
||||
&expiresAtRaw,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&lastUsedAtRaw,
|
||||
&createdByUserIDRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key updated_at: %w", err)
|
||||
}
|
||||
|
||||
expiresAt, err := parseNullableTimestamp(expiresAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key expires_at: %w", err)
|
||||
}
|
||||
|
||||
lastUsedAt, err := parseNullableTimestamp(lastUsedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key last_used_at: %w", err)
|
||||
}
|
||||
|
||||
key.ScopeMode = ScopeMode(scopeMode)
|
||||
key.CanDownload = canDownload == 1
|
||||
key.CanUpload = canUpload == 1
|
||||
key.CanDelete = canDelete == 1
|
||||
key.CanManageProjects = canManageProjects == 1
|
||||
key.IsActive = isActive == 1
|
||||
key.ExpiresAt = expiresAt
|
||||
key.CreatedAt = createdAt
|
||||
key.UpdatedAt = updatedAt
|
||||
key.LastUsedAt = lastUsedAt
|
||||
if createdByUserIDRaw.Valid {
|
||||
key.CreatedByUserID = &createdByUserIDRaw.Int64
|
||||
}
|
||||
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
func scanAPIKeyListItem(scanner rowScanner) (*APIKeyListItem, error) {
|
||||
var (
|
||||
item APIKeyListItem
|
||||
scopeMode string
|
||||
canDownload int
|
||||
canUpload int
|
||||
canDelete int
|
||||
canManageProjects int
|
||||
isActive int
|
||||
expiresAtRaw sql.NullString
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
lastUsedAtRaw sql.NullString
|
||||
createdByUserIDRaw sql.NullInt64
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&item.APIKey.ID,
|
||||
&item.APIKey.Name,
|
||||
&item.APIKey.KeyPrefix,
|
||||
&item.APIKey.KeyHash,
|
||||
&item.APIKey.Description,
|
||||
&scopeMode,
|
||||
&canDownload,
|
||||
&canUpload,
|
||||
&canDelete,
|
||||
&canManageProjects,
|
||||
&isActive,
|
||||
&expiresAtRaw,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&lastUsedAtRaw,
|
||||
&createdByUserIDRaw,
|
||||
&item.ProjectRuleCount,
|
||||
&item.TagRuleCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key list created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key list updated_at: %w", err)
|
||||
}
|
||||
|
||||
expiresAt, err := parseNullableTimestamp(expiresAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key list expires_at: %w", err)
|
||||
}
|
||||
|
||||
lastUsedAt, err := parseNullableTimestamp(lastUsedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key list last_used_at: %w", err)
|
||||
}
|
||||
|
||||
item.APIKey.ScopeMode = ScopeMode(scopeMode)
|
||||
item.APIKey.CanDownload = canDownload == 1
|
||||
item.APIKey.CanUpload = canUpload == 1
|
||||
item.APIKey.CanDelete = canDelete == 1
|
||||
item.APIKey.CanManageProjects = canManageProjects == 1
|
||||
item.APIKey.IsActive = isActive == 1
|
||||
item.APIKey.ExpiresAt = expiresAt
|
||||
item.APIKey.CreatedAt = createdAt
|
||||
item.APIKey.UpdatedAt = updatedAt
|
||||
item.APIKey.LastUsedAt = lastUsedAt
|
||||
if createdByUserIDRaw.Valid {
|
||||
item.APIKey.CreatedByUserID = &createdByUserIDRaw.Int64
|
||||
}
|
||||
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func nullableTimestampValue(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return formatTimestamp(value.UTC())
|
||||
}
|
||||
|
||||
func normalizeIDList(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
normalized := make([]int64, 0, len(values))
|
||||
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[value] = struct{}{}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
|
||||
sort.Slice(normalized, func(i, j int) bool {
|
||||
return normalized[i] < normalized[j]
|
||||
})
|
||||
|
||||
return normalized
|
||||
}
|
||||
19
internal/db/errors.go
Normal file
19
internal/db/errors.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("record not found")
|
||||
ErrConflict = errors.New("record conflict")
|
||||
)
|
||||
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed")
|
||||
}
|
||||
146
internal/db/migrate.go
Normal file
146
internal/db/migrate.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const migrationsTableDDL = `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
checksum_sha256 TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
`
|
||||
|
||||
func Migrate(ctx context.Context, database *sql.DB, migrationsDir string) error {
|
||||
if migrationsDir == "" {
|
||||
return fmt.Errorf("migrations dir is required")
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(ctx, migrationsTableDDL); err != nil {
|
||||
return fmt.Errorf("ensure schema_migrations table: %w", err)
|
||||
}
|
||||
|
||||
applied, err := appliedMigrations(ctx, database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
files, err := listMigrationFiles(migrationsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, filename := range files {
|
||||
fullPath := filepath.Join(migrationsDir, filename)
|
||||
|
||||
contents, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
checksum := checksum(contents)
|
||||
if appliedChecksum, ok := applied[filename]; ok {
|
||||
if appliedChecksum != checksum {
|
||||
return fmt.Errorf("migration %s checksum mismatch: applied=%s current=%s", filename, appliedChecksum, checksum)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := applyMigration(ctx, database, filename, checksum, string(contents)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func appliedMigrations(ctx context.Context, database *sql.DB) (map[string]string, error) {
|
||||
rows, err := database.QueryContext(ctx, `SELECT name, checksum_sha256 FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load applied migrations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
applied := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var checksum string
|
||||
|
||||
if err := rows.Scan(&name, &checksum); err != nil {
|
||||
return nil, fmt.Errorf("scan applied migration: %w", err)
|
||||
}
|
||||
|
||||
applied[name] = checksum
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate applied migrations: %w", err)
|
||||
}
|
||||
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
func listMigrationFiles(migrationsDir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(migrationsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
files := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
|
||||
continue
|
||||
}
|
||||
|
||||
files = append(files, entry.Name())
|
||||
}
|
||||
|
||||
slices.Sort(files)
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, database *sql.DB, filename, checksum, sqlText string) error {
|
||||
tx, err := database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(sqlText) != "" {
|
||||
if _, err := tx.ExecContext(ctx, sqlText); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("execute migration %s: %w", filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO schema_migrations (name, checksum_sha256) VALUES (?, ?)`,
|
||||
filename,
|
||||
checksum,
|
||||
); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("record migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checksum(contents []byte) string {
|
||||
sum := sha256.Sum256(contents)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
294
internal/db/migrate_test.go
Normal file
294
internal/db/migrate_test.go
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
package db_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func TestMigrateAppliesCoreSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
sqlitePath := filepath.Join(t.TempDir(), "update-server.sqlite")
|
||||
|
||||
database, err := db.Open(ctx, sqlitePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
migrationsDir := projectMigrationsDir(t)
|
||||
if err := db.Migrate(ctx, database, migrationsDir); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Migrate(ctx, database, migrationsDir); err != nil {
|
||||
t.Fatalf("reapply migrations: %v", err)
|
||||
}
|
||||
|
||||
expectedTables := []string{
|
||||
"schema_migrations",
|
||||
"users",
|
||||
"projects",
|
||||
"tags",
|
||||
"project_tags",
|
||||
"releases",
|
||||
"api_keys",
|
||||
"api_key_project_access",
|
||||
"api_key_tag_access",
|
||||
"sessions",
|
||||
"audit_logs",
|
||||
}
|
||||
|
||||
for _, tableName := range expectedTables {
|
||||
if !tableExists(t, database, tableName) {
|
||||
t.Fatalf("expected table %q to exist", tableName)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO users (email, password_hash, role) VALUES (?, ?, ?)`,
|
||||
"admin@example.com",
|
||||
"hashed-password",
|
||||
"admin",
|
||||
); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO projects (name, slug, description) VALUES (?, ?, ?)`,
|
||||
"Desktop App",
|
||||
"desktop-app",
|
||||
"Primary desktop client",
|
||||
); err != nil {
|
||||
t.Fatalf("insert project: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO tags (name, slug, description) VALUES (?, ?, ?)`,
|
||||
"Windows",
|
||||
"windows",
|
||||
"Windows releases",
|
||||
); err != nil {
|
||||
t.Fatalf("insert tag: %v", err)
|
||||
}
|
||||
|
||||
var projectID int64
|
||||
if err := database.QueryRowContext(ctx, `SELECT id FROM projects WHERE slug = ?`, "desktop-app").Scan(&projectID); err != nil {
|
||||
t.Fatalf("load project id: %v", err)
|
||||
}
|
||||
|
||||
var tagID int64
|
||||
if err := database.QueryRowContext(ctx, `SELECT id FROM tags WHERE slug = ?`, "windows").Scan(&tagID); err != nil {
|
||||
t.Fatalf("load tag id: %v", err)
|
||||
}
|
||||
|
||||
var userID int64
|
||||
if err := database.QueryRowContext(ctx, `SELECT id FROM users WHERE email = ?`, "admin@example.com").Scan(&userID); err != nil {
|
||||
t.Fatalf("load user id: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO project_tags (project_id, tag_id) VALUES (?, ?)`,
|
||||
projectID,
|
||||
tagID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert project tag: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO releases (project_id, version, build, filename, storage_path, checksum_sha256, size_bytes, content_type, release_notes, uploaded_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
projectID,
|
||||
"1.0.0",
|
||||
"",
|
||||
"desktop-app-1.0.0.zip",
|
||||
"artifacts/desktop-app/1.0.0/desktop-app-1.0.0.zip",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
1024,
|
||||
"application/zip",
|
||||
"Initial release",
|
||||
userID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert release: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_keys (name, key_prefix, key_hash, description, scope_mode, can_download, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
"Desktop Clients",
|
||||
"updsrv_project",
|
||||
"hash-project",
|
||||
"Project-scoped desktop client access",
|
||||
"project_allow_list",
|
||||
1,
|
||||
userID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert project api key: %v", err)
|
||||
}
|
||||
|
||||
var projectAPIKeyID int64
|
||||
if err := database.QueryRowContext(ctx, `SELECT id FROM api_keys WHERE key_prefix = ?`, "updsrv_project").Scan(&projectAPIKeyID); err != nil {
|
||||
t.Fatalf("load project api key id: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`,
|
||||
projectAPIKeyID,
|
||||
projectID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert api key project access: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_keys (name, key_prefix, key_hash, description, scope_mode, can_download, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
"Tagged Clients",
|
||||
"updsrv_tag",
|
||||
"hash-tag",
|
||||
"Tag-scoped desktop client access",
|
||||
"tag_allow_list",
|
||||
1,
|
||||
userID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert tag api key: %v", err)
|
||||
}
|
||||
|
||||
var tagAPIKeyID int64
|
||||
if err := database.QueryRowContext(ctx, `SELECT id FROM api_keys WHERE key_prefix = ?`, "updsrv_tag").Scan(&tagAPIKeyID); err != nil {
|
||||
t.Fatalf("load tag api key id: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_key_tag_access (api_key_id, tag_id) VALUES (?, ?)`,
|
||||
tagAPIKeyID,
|
||||
tagID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert api key tag access: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO sessions (user_id, token_hash, expires_at, ip_address, user_agent) VALUES (?, ?, ?, ?, ?)`,
|
||||
userID,
|
||||
"session-hash",
|
||||
"2030-01-01T00:00:00Z",
|
||||
"127.0.0.1",
|
||||
"test-agent",
|
||||
); err != nil {
|
||||
t.Fatalf("insert session: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO audit_logs (actor_user_id, api_key_id, action, target_type, target_id, target_identifier, metadata_json, ip_address) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
userID,
|
||||
tagAPIKeyID,
|
||||
"api_key.created",
|
||||
"api_key",
|
||||
tagAPIKeyID,
|
||||
"updsrv_tag",
|
||||
`{"source":"test"}`,
|
||||
"127.0.0.1",
|
||||
); err != nil {
|
||||
t.Fatalf("insert audit log: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`,
|
||||
tagAPIKeyID,
|
||||
projectID,
|
||||
); err == nil {
|
||||
t.Fatal("expected project access insert for tag-scoped key to fail")
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_keys (name, key_prefix, key_hash, scope_mode) VALUES (?, ?, ?, ?)`,
|
||||
"Broken Key",
|
||||
"updsrv_invalid",
|
||||
"hash-invalid",
|
||||
"invalid_scope",
|
||||
); err == nil {
|
||||
t.Fatal("expected invalid scope_mode insert to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func projectMigrationsDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve caller path")
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(filename), "..", "..", "migrations")
|
||||
}
|
||||
|
||||
func tableExists(t *testing.T, database *sql.DB, tableName string) bool {
|
||||
t.Helper()
|
||||
|
||||
var exists int
|
||||
query := `SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?)`
|
||||
if err := database.QueryRowContext(context.Background(), query, tableName).Scan(&exists); err != nil {
|
||||
t.Fatalf("check table %s: %v", tableName, err)
|
||||
}
|
||||
|
||||
return exists == 1
|
||||
}
|
||||
|
||||
func TestMigrateRejectsEditedAppliedMigrations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
tempDir := t.TempDir()
|
||||
sqlitePath := filepath.Join(tempDir, "update-server.sqlite")
|
||||
|
||||
database, err := db.Open(ctx, sqlitePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
migrationsDir := filepath.Join(tempDir, "migrations")
|
||||
if err := os.MkdirAll(migrationsDir, 0o755); err != nil {
|
||||
t.Fatalf("create migrations dir: %v", err)
|
||||
}
|
||||
|
||||
firstMigrationPath := filepath.Join(migrationsDir, "0001_test.sql")
|
||||
if err := os.WriteFile(firstMigrationPath, []byte(`CREATE TABLE sample (id INTEGER PRIMARY KEY);`), 0o644); err != nil {
|
||||
t.Fatalf("write migration: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Migrate(ctx, database, migrationsDir); err != nil {
|
||||
t.Fatalf("apply migration: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(firstMigrationPath, []byte(`CREATE TABLE sample (id INTEGER PRIMARY KEY, name TEXT);`), 0o644); err != nil {
|
||||
t.Fatalf("rewrite migration: %v", err)
|
||||
}
|
||||
|
||||
err = db.Migrate(ctx, database, migrationsDir)
|
||||
if err == nil {
|
||||
t.Fatal("expected checksum mismatch error")
|
||||
}
|
||||
|
||||
expectedMessage := "checksum mismatch"
|
||||
if !strings.Contains(err.Error(), expectedMessage) {
|
||||
t.Fatalf("expected error containing %q, got %v", expectedMessage, err)
|
||||
}
|
||||
}
|
||||
177
internal/db/models.go
Normal file
177
internal/db/models.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
type ScopeMode string
|
||||
|
||||
const (
|
||||
ScopeModeAllProjects ScopeMode = "all_projects"
|
||||
ScopeModeProjectAllowList ScopeMode = "project_allow_list"
|
||||
ScopeModeProjectDenyList ScopeMode = "project_deny_list"
|
||||
ScopeModeTagAllowList ScopeMode = "tag_allow_list"
|
||||
ScopeModeTagDenyList ScopeMode = "tag_deny_list"
|
||||
)
|
||||
|
||||
func (m ScopeMode) Valid() bool {
|
||||
switch m {
|
||||
case ScopeModeAllProjects,
|
||||
ScopeModeProjectAllowList,
|
||||
ScopeModeProjectDenyList,
|
||||
ScopeModeTagAllowList,
|
||||
ScopeModeTagDenyList:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (m ScopeMode) UsesProjectRules() bool {
|
||||
return m == ScopeModeProjectAllowList || m == ScopeModeProjectDenyList
|
||||
}
|
||||
|
||||
func (m ScopeMode) UsesTagRules() bool {
|
||||
return m == ScopeModeTagAllowList || m == ScopeModeTagDenyList
|
||||
}
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
UserRoleAdmin UserRole = "admin"
|
||||
UserRoleEditor UserRole = "editor"
|
||||
UserRoleViewer UserRole = "viewer"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role UserRole
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LastLoginAt *time.Time
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID int64
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ProjectListItem struct {
|
||||
Project Project
|
||||
TagCount int
|
||||
ReleaseCount int
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
ID int64
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type TagListItem struct {
|
||||
Tag Tag
|
||||
ProjectCount int
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
ID int64
|
||||
ProjectID int64
|
||||
Version string
|
||||
Build string
|
||||
Filename string
|
||||
StoragePath string
|
||||
ChecksumSHA256 string
|
||||
SizeBytes int64
|
||||
ContentType string
|
||||
ReleaseNotes string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
UploadedByUserID *int64
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
type ReleaseListItem struct {
|
||||
Release Release
|
||||
UploadedByEmail string
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID int64
|
||||
Name string
|
||||
KeyPrefix string
|
||||
KeyHash string
|
||||
Description string
|
||||
ScopeMode ScopeMode
|
||||
CanDownload bool
|
||||
CanUpload bool
|
||||
CanDelete bool
|
||||
CanManageProjects bool
|
||||
IsActive bool
|
||||
ExpiresAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LastUsedAt *time.Time
|
||||
CreatedByUserID *int64
|
||||
}
|
||||
|
||||
func (k APIKey) Expired(now time.Time) bool {
|
||||
return k.ExpiresAt != nil && !k.ExpiresAt.After(now.UTC())
|
||||
}
|
||||
|
||||
type APIKeyListItem struct {
|
||||
APIKey APIKey
|
||||
ProjectRuleCount int
|
||||
TagRuleCount int
|
||||
AccessiblePreview int
|
||||
}
|
||||
|
||||
type APIKeyProjectAccess struct {
|
||||
APIKeyID int64
|
||||
ProjectID int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type APIKeyTagAccess struct {
|
||||
APIKeyID int64
|
||||
TagID int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
LastSeenAt *time.Time
|
||||
InvalidatedAt *time.Time
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type SessionWithUser struct {
|
||||
Session Session
|
||||
User User
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID int64
|
||||
ActorUserID *int64
|
||||
APIKeyID *int64
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID *int64
|
||||
TargetIdentifier string
|
||||
MetadataJSON string
|
||||
IPAddress string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
60
internal/db/open.go
Normal file
60
internal/db/open.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const sqliteDriverName = "sqlite3"
|
||||
|
||||
func Open(ctx context.Context, sqlitePath string) (*sql.DB, error) {
|
||||
if sqlitePath == "" {
|
||||
return nil, fmt.Errorf("sqlite path is required")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(sqlitePath), 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create sqlite dir: %w", err)
|
||||
}
|
||||
|
||||
database, err := sql.Open(sqliteDriverName, sqlitePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite database: %w", err)
|
||||
}
|
||||
|
||||
database.SetMaxOpenConns(1)
|
||||
database.SetMaxIdleConns(1)
|
||||
|
||||
if err := applyPragmas(ctx, database); err != nil {
|
||||
database.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := database.PingContext(ctx); err != nil {
|
||||
database.Close()
|
||||
return nil, fmt.Errorf("ping sqlite database: %w", err)
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
func applyPragmas(ctx context.Context, database *sql.DB) error {
|
||||
pragmas := []string{
|
||||
"PRAGMA foreign_keys = ON;",
|
||||
"PRAGMA journal_mode = WAL;",
|
||||
"PRAGMA busy_timeout = 5000;",
|
||||
"PRAGMA synchronous = NORMAL;",
|
||||
}
|
||||
|
||||
for _, pragma := range pragmas {
|
||||
if _, err := database.ExecContext(ctx, pragma); err != nil {
|
||||
return fmt.Errorf("apply sqlite pragma %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
326
internal/db/projects.go
Normal file
326
internal/db/projects.go
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CreateProjectParams struct {
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
}
|
||||
|
||||
type UpdateProjectParams struct {
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) List(ctx context.Context) ([]ProjectListItem, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.slug,
|
||||
p.description,
|
||||
p.is_active,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
COUNT(DISTINCT pt.tag_id) AS tag_count,
|
||||
COUNT(DISTINCT rel.id) AS release_count
|
||||
FROM projects AS p
|
||||
LEFT JOIN project_tags AS pt ON pt.project_id = p.id
|
||||
LEFT JOIN releases AS rel ON rel.project_id = p.id AND rel.is_active = 1
|
||||
GROUP BY p.id
|
||||
ORDER BY p.is_active DESC, p.updated_at DESC, p.created_at DESC, p.name COLLATE NOCASE`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query projects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
projects := make([]ProjectListItem, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanProjectListItem(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan project list item: %w", err)
|
||||
}
|
||||
|
||||
projects = append(projects, *item)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate projects: %w", err)
|
||||
}
|
||||
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) GetByID(ctx context.Context, id int64) (*Project, error) {
|
||||
project, err := scanProject(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, name, slug, description, is_active, created_at, updated_at
|
||||
FROM projects
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan project by id: %w", err)
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) GetBySlug(ctx context.Context, slug string) (*Project, error) {
|
||||
project, err := scanProject(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, name, slug, description, is_active, created_at, updated_at
|
||||
FROM projects
|
||||
WHERE slug = ?
|
||||
LIMIT 1`,
|
||||
strings.TrimSpace(slug),
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan project by slug: %w", err)
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Create(ctx context.Context, params CreateProjectParams) (*Project, error) {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO projects (name, slug, description) VALUES (?, ?, ?)`,
|
||||
strings.TrimSpace(params.Name),
|
||||
strings.TrimSpace(params.Slug),
|
||||
strings.TrimSpace(params.Description),
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.Join(ErrConflict, fmt.Errorf("insert project: %w", err))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("insert project: %w", err)
|
||||
}
|
||||
|
||||
projectID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inserted project id: %w", err)
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, projectID)
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Update(ctx context.Context, projectID int64, params UpdateProjectParams) (*Project, error) {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE projects
|
||||
SET name = ?, slug = ?, description = ?
|
||||
WHERE id = ?`,
|
||||
strings.TrimSpace(params.Name),
|
||||
strings.TrimSpace(params.Slug),
|
||||
strings.TrimSpace(params.Description),
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.Join(ErrConflict, fmt.Errorf("update project: %w", err))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("update project: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read updated project rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, projectID)
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) SetActive(ctx context.Context, projectID int64, isActive bool) error {
|
||||
activeValue := 0
|
||||
if isActive {
|
||||
activeValue = 1
|
||||
}
|
||||
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE projects
|
||||
SET is_active = ?
|
||||
WHERE id = ?`,
|
||||
activeValue,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update project active state: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read affected project rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) ListTags(ctx context.Context, projectID int64) ([]Tag, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.slug,
|
||||
t.description,
|
||||
t.created_at,
|
||||
t.updated_at
|
||||
FROM tags AS t
|
||||
INNER JOIN project_tags AS pt ON pt.tag_id = t.id
|
||||
WHERE pt.project_id = ?
|
||||
ORDER BY t.name COLLATE NOCASE`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query project tags: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tags := make([]Tag, 0)
|
||||
for rows.Next() {
|
||||
tag, err := scanTag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan project tag: %w", err)
|
||||
}
|
||||
|
||||
tags = append(tags, *tag)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate project tags: %w", err)
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) AttachTag(ctx context.Context, projectID, tagID int64) error {
|
||||
if _, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT OR IGNORE INTO project_tags (project_id, tag_id) VALUES (?, ?)`,
|
||||
projectID,
|
||||
tagID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert project tag link: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) DetachTag(ctx context.Context, projectID, tagID int64) error {
|
||||
if _, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`DELETE FROM project_tags WHERE project_id = ? AND tag_id = ?`,
|
||||
projectID,
|
||||
tagID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("delete project tag link: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanProject(scanner rowScanner) (*Project, error) {
|
||||
var (
|
||||
project Project
|
||||
isActive int
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&project.ID,
|
||||
&project.Name,
|
||||
&project.Slug,
|
||||
&project.Description,
|
||||
&isActive,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse project created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse project updated_at: %w", err)
|
||||
}
|
||||
|
||||
project.IsActive = isActive == 1
|
||||
project.CreatedAt = createdAt
|
||||
project.UpdatedAt = updatedAt
|
||||
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func scanProjectListItem(scanner rowScanner) (*ProjectListItem, error) {
|
||||
var (
|
||||
item ProjectListItem
|
||||
isActive int
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&item.Project.ID,
|
||||
&item.Project.Name,
|
||||
&item.Project.Slug,
|
||||
&item.Project.Description,
|
||||
&isActive,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&item.TagCount,
|
||||
&item.ReleaseCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse project list created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse project list updated_at: %w", err)
|
||||
}
|
||||
|
||||
item.Project.IsActive = isActive == 1
|
||||
item.Project.CreatedAt = createdAt
|
||||
item.Project.UpdatedAt = updatedAt
|
||||
|
||||
return &item, nil
|
||||
}
|
||||
285
internal/db/releases.go
Normal file
285
internal/db/releases.go
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CreateReleaseParams struct {
|
||||
ProjectID int64
|
||||
Version string
|
||||
Build string
|
||||
Filename string
|
||||
StoragePath string
|
||||
ChecksumSHA256 string
|
||||
SizeBytes int64
|
||||
ContentType string
|
||||
ReleaseNotes string
|
||||
UploadedByUserID *int64
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
func (r *ReleaseRepository) Create(ctx context.Context, params CreateReleaseParams) (*Release, error) {
|
||||
isActive := 0
|
||||
if params.IsActive {
|
||||
isActive = 1
|
||||
}
|
||||
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO releases (
|
||||
project_id,
|
||||
version,
|
||||
build,
|
||||
filename,
|
||||
storage_path,
|
||||
checksum_sha256,
|
||||
size_bytes,
|
||||
content_type,
|
||||
release_notes,
|
||||
uploaded_by_user_id,
|
||||
is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
params.ProjectID,
|
||||
strings.TrimSpace(params.Version),
|
||||
strings.TrimSpace(params.Build),
|
||||
strings.TrimSpace(params.Filename),
|
||||
strings.TrimSpace(params.StoragePath),
|
||||
strings.TrimSpace(params.ChecksumSHA256),
|
||||
params.SizeBytes,
|
||||
strings.TrimSpace(params.ContentType),
|
||||
strings.TrimSpace(params.ReleaseNotes),
|
||||
params.UploadedByUserID,
|
||||
isActive,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.Join(ErrConflict, fmt.Errorf("insert release: %w", err))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("insert release: %w", err)
|
||||
}
|
||||
|
||||
releaseID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inserted release id: %w", err)
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, releaseID)
|
||||
}
|
||||
|
||||
func (r *ReleaseRepository) GetByID(ctx context.Context, id int64) (*Release, error) {
|
||||
release, err := scanRelease(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
id,
|
||||
project_id,
|
||||
version,
|
||||
build,
|
||||
filename,
|
||||
storage_path,
|
||||
checksum_sha256,
|
||||
size_bytes,
|
||||
content_type,
|
||||
release_notes,
|
||||
created_at,
|
||||
updated_at,
|
||||
uploaded_by_user_id,
|
||||
is_active
|
||||
FROM releases
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan release by id: %w", err)
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (r *ReleaseRepository) GetLatestByProjectID(ctx context.Context, projectID int64) (*Release, error) {
|
||||
release, err := scanRelease(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
id,
|
||||
project_id,
|
||||
version,
|
||||
build,
|
||||
filename,
|
||||
storage_path,
|
||||
checksum_sha256,
|
||||
size_bytes,
|
||||
content_type,
|
||||
release_notes,
|
||||
created_at,
|
||||
updated_at,
|
||||
uploaded_by_user_id,
|
||||
is_active
|
||||
FROM releases
|
||||
WHERE project_id = ?
|
||||
AND is_active = 1
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1`,
|
||||
projectID,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan latest release by project id: %w", err)
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (r *ReleaseRepository) ListByProjectID(ctx context.Context, projectID int64) ([]ReleaseListItem, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
r.id,
|
||||
r.project_id,
|
||||
r.version,
|
||||
r.build,
|
||||
r.filename,
|
||||
r.storage_path,
|
||||
r.checksum_sha256,
|
||||
r.size_bytes,
|
||||
r.content_type,
|
||||
r.release_notes,
|
||||
r.created_at,
|
||||
r.updated_at,
|
||||
r.uploaded_by_user_id,
|
||||
r.is_active,
|
||||
COALESCE(u.email, '')
|
||||
FROM releases AS r
|
||||
LEFT JOIN users AS u ON u.id = r.uploaded_by_user_id
|
||||
WHERE r.project_id = ?
|
||||
ORDER BY r.created_at DESC, r.id DESC`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query project releases: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
releases := make([]ReleaseListItem, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanReleaseListItem(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan project release: %w", err)
|
||||
}
|
||||
|
||||
releases = append(releases, *item)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate project releases: %w", err)
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
func scanRelease(scanner rowScanner) (*Release, error) {
|
||||
var (
|
||||
release Release
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
uploadedByUserID sql.NullInt64
|
||||
isActive int
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&release.ID,
|
||||
&release.ProjectID,
|
||||
&release.Version,
|
||||
&release.Build,
|
||||
&release.Filename,
|
||||
&release.StoragePath,
|
||||
&release.ChecksumSHA256,
|
||||
&release.SizeBytes,
|
||||
&release.ContentType,
|
||||
&release.ReleaseNotes,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&uploadedByUserID,
|
||||
&isActive,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse release created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse release updated_at: %w", err)
|
||||
}
|
||||
|
||||
release.CreatedAt = createdAt
|
||||
release.UpdatedAt = updatedAt
|
||||
release.IsActive = isActive == 1
|
||||
if uploadedByUserID.Valid {
|
||||
release.UploadedByUserID = &uploadedByUserID.Int64
|
||||
}
|
||||
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
func scanReleaseListItem(scanner rowScanner) (*ReleaseListItem, error) {
|
||||
var (
|
||||
item ReleaseListItem
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
uploadedByUserID sql.NullInt64
|
||||
isActive int
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&item.Release.ID,
|
||||
&item.Release.ProjectID,
|
||||
&item.Release.Version,
|
||||
&item.Release.Build,
|
||||
&item.Release.Filename,
|
||||
&item.Release.StoragePath,
|
||||
&item.Release.ChecksumSHA256,
|
||||
&item.Release.SizeBytes,
|
||||
&item.Release.ContentType,
|
||||
&item.Release.ReleaseNotes,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&uploadedByUserID,
|
||||
&isActive,
|
||||
&item.UploadedByEmail,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse release list created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse release list updated_at: %w", err)
|
||||
}
|
||||
|
||||
item.Release.CreatedAt = createdAt
|
||||
item.Release.UpdatedAt = updatedAt
|
||||
item.Release.IsActive = isActive == 1
|
||||
if uploadedByUserID.Valid {
|
||||
item.Release.UploadedByUserID = &uploadedByUserID.Int64
|
||||
}
|
||||
|
||||
return &item, nil
|
||||
}
|
||||
284
internal/db/sessions.go
Normal file
284
internal/db/sessions.go
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateSessionParams struct {
|
||||
UserID int64
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Create(ctx context.Context, params CreateSessionParams) (*Session, error) {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO sessions (user_id, token_hash, expires_at, ip_address, user_agent) VALUES (?, ?, ?, ?, ?)`,
|
||||
params.UserID,
|
||||
params.TokenHash,
|
||||
formatTimestamp(params.ExpiresAt),
|
||||
params.IPAddress,
|
||||
params.UserAgent,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert session: %w", err)
|
||||
}
|
||||
|
||||
sessionID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inserted session id: %w", err)
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, sessionID)
|
||||
}
|
||||
|
||||
func (r *SessionRepository) GetByID(ctx context.Context, id int64) (*Session, error) {
|
||||
session, err := scanSession(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, user_id, token_hash, expires_at, last_seen_at, invalidated_at, ip_address, user_agent, created_at
|
||||
FROM sessions
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan session by id: %w", err)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) GetActiveWithUserByTokenHash(ctx context.Context, tokenHash string, now time.Time) (*SessionWithUser, error) {
|
||||
record, err := scanSessionWithUser(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
s.id,
|
||||
s.user_id,
|
||||
s.token_hash,
|
||||
s.expires_at,
|
||||
s.last_seen_at,
|
||||
s.invalidated_at,
|
||||
s.ip_address,
|
||||
s.user_agent,
|
||||
s.created_at,
|
||||
u.id,
|
||||
u.email,
|
||||
u.password_hash,
|
||||
u.role,
|
||||
u.is_active,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.last_login_at
|
||||
FROM sessions AS s
|
||||
INNER JOIN users AS u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ?
|
||||
AND s.invalidated_at IS NULL
|
||||
AND s.expires_at > ?
|
||||
AND u.is_active = 1
|
||||
LIMIT 1`,
|
||||
tokenHash,
|
||||
formatTimestamp(now),
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan active session by token hash: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Touch(ctx context.Context, sessionID int64, seenAt time.Time) error {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`,
|
||||
formatTimestamp(seenAt),
|
||||
sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update session last_seen_at: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read affected session rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) InvalidateByTokenHash(ctx context.Context, tokenHash string, invalidatedAt time.Time) error {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions
|
||||
SET invalidated_at = ?
|
||||
WHERE token_hash = ?
|
||||
AND invalidated_at IS NULL`,
|
||||
formatTimestamp(invalidatedAt),
|
||||
tokenHash,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalidate session by token hash: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read invalidated session rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanSession(scanner rowScanner) (*Session, error) {
|
||||
var (
|
||||
session Session
|
||||
expiresAtRaw string
|
||||
lastSeenAtRaw sql.NullString
|
||||
invalidatedAtRaw sql.NullString
|
||||
createdAtRaw string
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&session.ID,
|
||||
&session.UserID,
|
||||
&session.TokenHash,
|
||||
&expiresAtRaw,
|
||||
&lastSeenAtRaw,
|
||||
&invalidatedAtRaw,
|
||||
&session.IPAddress,
|
||||
&session.UserAgent,
|
||||
&createdAtRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expiresAt, err := parseTimestamp(expiresAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse session expires_at: %w", err)
|
||||
}
|
||||
|
||||
lastSeenAt, err := parseNullableTimestamp(lastSeenAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse session last_seen_at: %w", err)
|
||||
}
|
||||
|
||||
invalidatedAt, err := parseNullableTimestamp(invalidatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse session invalidated_at: %w", err)
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse session created_at: %w", err)
|
||||
}
|
||||
|
||||
session.ExpiresAt = expiresAt
|
||||
session.LastSeenAt = lastSeenAt
|
||||
session.InvalidatedAt = invalidatedAt
|
||||
session.CreatedAt = createdAt
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func scanSessionWithUser(scanner rowScanner) (*SessionWithUser, error) {
|
||||
var (
|
||||
record SessionWithUser
|
||||
sessionExpiresRaw string
|
||||
sessionLastSeenRaw sql.NullString
|
||||
sessionInvalidRaw sql.NullString
|
||||
sessionCreatedRaw string
|
||||
userRole string
|
||||
userIsActive int
|
||||
userCreatedRaw string
|
||||
userUpdatedRaw string
|
||||
userLastLoginRaw sql.NullString
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&record.Session.ID,
|
||||
&record.Session.UserID,
|
||||
&record.Session.TokenHash,
|
||||
&sessionExpiresRaw,
|
||||
&sessionLastSeenRaw,
|
||||
&sessionInvalidRaw,
|
||||
&record.Session.IPAddress,
|
||||
&record.Session.UserAgent,
|
||||
&sessionCreatedRaw,
|
||||
&record.User.ID,
|
||||
&record.User.Email,
|
||||
&record.User.PasswordHash,
|
||||
&userRole,
|
||||
&userIsActive,
|
||||
&userCreatedRaw,
|
||||
&userUpdatedRaw,
|
||||
&userLastLoginRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sessionExpiresAt, err := parseTimestamp(sessionExpiresRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined session expires_at: %w", err)
|
||||
}
|
||||
|
||||
sessionLastSeenAt, err := parseNullableTimestamp(sessionLastSeenRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined session last_seen_at: %w", err)
|
||||
}
|
||||
|
||||
sessionInvalidatedAt, err := parseNullableTimestamp(sessionInvalidRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined session invalidated_at: %w", err)
|
||||
}
|
||||
|
||||
sessionCreatedAt, err := parseTimestamp(sessionCreatedRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined session created_at: %w", err)
|
||||
}
|
||||
|
||||
userCreatedAt, err := parseTimestamp(userCreatedRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined user created_at: %w", err)
|
||||
}
|
||||
|
||||
userUpdatedAt, err := parseTimestamp(userUpdatedRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined user updated_at: %w", err)
|
||||
}
|
||||
|
||||
userLastLoginAt, err := parseNullableTimestamp(userLastLoginRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined user last_login_at: %w", err)
|
||||
}
|
||||
|
||||
record.Session.ExpiresAt = sessionExpiresAt
|
||||
record.Session.LastSeenAt = sessionLastSeenAt
|
||||
record.Session.InvalidatedAt = sessionInvalidatedAt
|
||||
record.Session.CreatedAt = sessionCreatedAt
|
||||
record.User.Role = UserRole(userRole)
|
||||
record.User.IsActive = userIsActive == 1
|
||||
record.User.CreatedAt = userCreatedAt
|
||||
record.User.UpdatedAt = userUpdatedAt
|
||||
record.User.LastLoginAt = userLastLoginAt
|
||||
|
||||
return &record, nil
|
||||
}
|
||||
131
internal/db/store.go
Normal file
131
internal/db/store.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type querier interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
DB *sql.DB
|
||||
Users *UserRepository
|
||||
Projects *ProjectRepository
|
||||
Tags *TagRepository
|
||||
Releases *ReleaseRepository
|
||||
APIKeys *APIKeyRepository
|
||||
Sessions *SessionRepository
|
||||
AuditLogs *AuditLogRepository
|
||||
}
|
||||
|
||||
type TxStore struct {
|
||||
Tx *sql.Tx
|
||||
Users *UserRepository
|
||||
Projects *ProjectRepository
|
||||
Tags *TagRepository
|
||||
Releases *ReleaseRepository
|
||||
APIKeys *APIKeyRepository
|
||||
Sessions *SessionRepository
|
||||
AuditLogs *AuditLogRepository
|
||||
}
|
||||
|
||||
type UserRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
type ProjectRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
type TagRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
type ReleaseRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
type APIKeyRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
type SessionRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
type AuditLogRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
func NewStore(database *sql.DB) *Store {
|
||||
return &Store{
|
||||
DB: database,
|
||||
Users: &UserRepository{q: database},
|
||||
Projects: &ProjectRepository{q: database},
|
||||
Tags: &TagRepository{q: database},
|
||||
Releases: &ReleaseRepository{q: database},
|
||||
APIKeys: &APIKeyRepository{q: database},
|
||||
Sessions: &SessionRepository{q: database},
|
||||
AuditLogs: &AuditLogRepository{q: database},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
if s == nil || s.DB == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.DB.Close()
|
||||
}
|
||||
|
||||
func (s *Store) HealthCheck(ctx context.Context) error {
|
||||
if s == nil || s.DB == nil {
|
||||
return fmt.Errorf("database store is not initialized")
|
||||
}
|
||||
|
||||
return s.DB.PingContext(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) WithTx(ctx context.Context, fn func(*TxStore) error) error {
|
||||
if s == nil || s.DB == nil {
|
||||
return fmt.Errorf("database store is not initialized")
|
||||
}
|
||||
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
|
||||
if err := fn(newTxStore(tx)); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||
return errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTxStore(tx *sql.Tx) *TxStore {
|
||||
return &TxStore{
|
||||
Tx: tx,
|
||||
Users: &UserRepository{q: tx},
|
||||
Projects: &ProjectRepository{q: tx},
|
||||
Tags: &TagRepository{q: tx},
|
||||
Releases: &ReleaseRepository{q: tx},
|
||||
APIKeys: &APIKeyRepository{q: tx},
|
||||
Sessions: &SessionRepository{q: tx},
|
||||
AuditLogs: &AuditLogRepository{q: tx},
|
||||
}
|
||||
}
|
||||
314
internal/db/tags.go
Normal file
314
internal/db/tags.go
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CreateTagParams struct {
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
}
|
||||
|
||||
type UpdateTagParams struct {
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
}
|
||||
|
||||
func (r *TagRepository) List(ctx context.Context) ([]TagListItem, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.slug,
|
||||
t.description,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
COUNT(DISTINCT pt.project_id) AS project_count
|
||||
FROM tags AS t
|
||||
LEFT JOIN project_tags AS pt ON pt.tag_id = t.id
|
||||
GROUP BY t.id
|
||||
ORDER BY t.name COLLATE NOCASE`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query tags: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tags := make([]TagListItem, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanTagListItem(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan tag list item: %w", err)
|
||||
}
|
||||
|
||||
tags = append(tags, *item)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate tags: %w", err)
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (r *TagRepository) ListAvailableForProject(ctx context.Context, projectID int64) ([]Tag, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.slug,
|
||||
t.description,
|
||||
t.created_at,
|
||||
t.updated_at
|
||||
FROM tags AS t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM project_tags AS pt
|
||||
WHERE pt.project_id = ?
|
||||
AND pt.tag_id = t.id
|
||||
)
|
||||
ORDER BY t.name COLLATE NOCASE`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query available project tags: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tags := make([]Tag, 0)
|
||||
for rows.Next() {
|
||||
tag, err := scanTag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan available project tag: %w", err)
|
||||
}
|
||||
|
||||
tags = append(tags, *tag)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate available project tags: %w", err)
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (r *TagRepository) GetByID(ctx context.Context, id int64) (*Tag, error) {
|
||||
tag, err := scanTag(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, name, slug, description, created_at, updated_at
|
||||
FROM tags
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan tag by id: %w", err)
|
||||
}
|
||||
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
func (r *TagRepository) Create(ctx context.Context, params CreateTagParams) (*Tag, error) {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO tags (name, slug, description) VALUES (?, ?, ?)`,
|
||||
strings.TrimSpace(params.Name),
|
||||
strings.TrimSpace(params.Slug),
|
||||
strings.TrimSpace(params.Description),
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.Join(ErrConflict, fmt.Errorf("insert tag: %w", err))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("insert tag: %w", err)
|
||||
}
|
||||
|
||||
tagID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inserted tag id: %w", err)
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, tagID)
|
||||
}
|
||||
|
||||
func (r *TagRepository) Update(ctx context.Context, tagID int64, params UpdateTagParams) (*Tag, error) {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE tags
|
||||
SET name = ?, slug = ?, description = ?
|
||||
WHERE id = ?`,
|
||||
strings.TrimSpace(params.Name),
|
||||
strings.TrimSpace(params.Slug),
|
||||
strings.TrimSpace(params.Description),
|
||||
tagID,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.Join(ErrConflict, fmt.Errorf("update tag: %w", err))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("update tag: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read updated tag rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, tagID)
|
||||
}
|
||||
|
||||
func (r *TagRepository) Delete(ctx context.Context, tagID int64) error {
|
||||
var projectCount int
|
||||
if err := r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT COUNT(1) FROM project_tags WHERE tag_id = ?`,
|
||||
tagID,
|
||||
).Scan(&projectCount); err != nil {
|
||||
return fmt.Errorf("count tag assignments: %w", err)
|
||||
}
|
||||
|
||||
if projectCount > 0 {
|
||||
return errors.Join(ErrConflict, fmt.Errorf("tag is assigned to %d project(s)", projectCount))
|
||||
}
|
||||
|
||||
result, err := r.q.ExecContext(ctx, `DELETE FROM tags WHERE id = ?`, tagID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete tag: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read deleted tag rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TagRepository) ListProjects(ctx context.Context, tagID int64) ([]Project, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.slug,
|
||||
p.description,
|
||||
p.is_active,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM projects AS p
|
||||
INNER JOIN project_tags AS pt ON pt.project_id = p.id
|
||||
WHERE pt.tag_id = ?
|
||||
ORDER BY p.name COLLATE NOCASE`,
|
||||
tagID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query tag projects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
projects := make([]Project, 0)
|
||||
for rows.Next() {
|
||||
project, err := scanProject(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan tag project: %w", err)
|
||||
}
|
||||
|
||||
projects = append(projects, *project)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate tag projects: %w", err)
|
||||
}
|
||||
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func scanTag(scanner rowScanner) (*Tag, error) {
|
||||
var (
|
||||
tag Tag
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&tag.ID,
|
||||
&tag.Name,
|
||||
&tag.Slug,
|
||||
&tag.Description,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tag created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tag updated_at: %w", err)
|
||||
}
|
||||
|
||||
tag.CreatedAt = createdAt
|
||||
tag.UpdatedAt = updatedAt
|
||||
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
func scanTagListItem(scanner rowScanner) (*TagListItem, error) {
|
||||
var (
|
||||
item TagListItem
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&item.Tag.ID,
|
||||
&item.Tag.Name,
|
||||
&item.Tag.Slug,
|
||||
&item.Tag.Description,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&item.ProjectCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tag list created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tag list updated_at: %w", err)
|
||||
}
|
||||
|
||||
item.Tag.CreatedAt = createdAt
|
||||
item.Tag.UpdatedAt = updatedAt
|
||||
|
||||
return &item, nil
|
||||
}
|
||||
38
internal/db/time.go
Normal file
38
internal/db/time.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func formatTimestamp(value time.Time) string {
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func parseTimestamp(raw string) (time.Time, error) {
|
||||
parsed, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse timestamp %q: %w", raw, err)
|
||||
}
|
||||
|
||||
return parsed.UTC(), nil
|
||||
}
|
||||
|
||||
func parseNullableTimestamp(raw sql.NullString) (*time.Time, error) {
|
||||
if !raw.Valid || strings.TrimSpace(raw.String) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parsed, err := parseTimestamp(raw.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
165
internal/db/users.go
Normal file
165
internal/db/users.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateUserParams struct {
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role UserRole
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
func (r *UserRepository) HasActiveAdmin(ctx context.Context) (bool, error) {
|
||||
var exists int
|
||||
if err := r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM users WHERE role = ? AND is_active = 1 LIMIT 1)`,
|
||||
UserRoleAdmin,
|
||||
).Scan(&exists); err != nil {
|
||||
return false, fmt.Errorf("query active admin existence: %w", err)
|
||||
}
|
||||
|
||||
return exists == 1, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByID(ctx context.Context, id int64) (*User, error) {
|
||||
user, err := scanUser(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, email, password_hash, role, is_active, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan user by id: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*User, error) {
|
||||
user, err := scanUser(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, email, password_hash, role, is_active, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE email = ? COLLATE NOCASE
|
||||
LIMIT 1`,
|
||||
strings.TrimSpace(email),
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan user by email: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(ctx context.Context, params CreateUserParams) (*User, error) {
|
||||
isActive := 0
|
||||
if params.IsActive {
|
||||
isActive = 1
|
||||
}
|
||||
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO users (email, password_hash, role, is_active) VALUES (?, ?, ?, ?)`,
|
||||
strings.TrimSpace(params.Email),
|
||||
params.PasswordHash,
|
||||
params.Role,
|
||||
isActive,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
|
||||
userID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inserted user id: %w", err)
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, userID)
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateLastLoginAt(ctx context.Context, userID int64, loggedInAt time.Time) error {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE users SET last_login_at = ? WHERE id = ?`,
|
||||
formatTimestamp(loggedInAt),
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update user last_login_at: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read affected user rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanUser(scanner rowScanner) (*User, error) {
|
||||
var (
|
||||
user User
|
||||
role string
|
||||
isActive int
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
lastLoginRaw sql.NullString
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.PasswordHash,
|
||||
&role,
|
||||
&isActive,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&lastLoginRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse user created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse user updated_at: %w", err)
|
||||
}
|
||||
|
||||
lastLoginAt, err := parseNullableTimestamp(lastLoginRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse user last_login_at: %w", err)
|
||||
}
|
||||
|
||||
user.Role = UserRole(role)
|
||||
user.IsActive = isActive == 1
|
||||
user.CreatedAt = createdAt
|
||||
user.UpdatedAt = updatedAt
|
||||
user.LastLoginAt = lastLoginAt
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
513
internal/http/admin_api_keys.go
Normal file
513
internal/http/admin_api_keys.go
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func (h *handler) adminAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := h.apiKeys.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "api key listing failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := APIKeysPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"API Keys",
|
||||
"Protected Admin",
|
||||
"API Keys",
|
||||
"Generate client credentials, assign action permissions, and control project visibility through project or tag allow and deny rules.",
|
||||
),
|
||||
APIKeys: keys,
|
||||
}
|
||||
data.Flash = apiKeyListFlash(r.URL.Query().Get("status"))
|
||||
|
||||
renderPage(w, h.renderer, "api_keys", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminAPIKeyNew(w http.ResponseWriter, r *http.Request) {
|
||||
data := APIKeyPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"New API Key",
|
||||
"Protected Admin",
|
||||
"Create API Key",
|
||||
"Generate a secure key, choose the minimum permissions it needs, and define access with one scope mode at a time.",
|
||||
),
|
||||
Form: APIKeyFormData{
|
||||
Action: "/admin/api-keys",
|
||||
SubmitLabel: "Create API key",
|
||||
ScopeMode: db.ScopeModeProjectAllowList,
|
||||
CanDownload: true,
|
||||
},
|
||||
}
|
||||
|
||||
if err := h.populateAPIKeyChoices(r, &data); err != nil {
|
||||
http.Error(w, "api key form failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
renderPage(w, h.renderer, "api_key_form", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminAPIKeyCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid api key form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form, inputErr := apiKeyFormDataFromRequest(r, "/admin/api-keys", "Create API key")
|
||||
if inputErr != "" {
|
||||
form.Error = inputErr
|
||||
h.renderAPIKeyNewPage(w, r, http.StatusBadRequest, form)
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt, err := parseAPIKeyExpiration(form.ExpiresAt)
|
||||
if err != nil {
|
||||
form.Error = "Expiration must be empty, YYYY-MM-DD, or RFC3339 UTC."
|
||||
h.renderAPIKeyNewPage(w, r, http.StatusBadRequest, form)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.apiKeys.Create(r.Context(), apikeys.CreateParams{
|
||||
Name: form.Name,
|
||||
Description: form.Description,
|
||||
ScopeMode: form.ScopeMode,
|
||||
CanDownload: form.CanDownload,
|
||||
CanUpload: form.CanUpload,
|
||||
CanDelete: form.CanDelete,
|
||||
CanManageProjects: form.CanManageProjects,
|
||||
ExpiresAt: expiresAt,
|
||||
ProjectIDs: form.SelectedProjectIDs,
|
||||
TagIDs: form.SelectedTagIDs,
|
||||
CreatedByUserID: h.currentUserID(r),
|
||||
})
|
||||
if err != nil {
|
||||
if userError := apiKeyUserError(err); userError != "" {
|
||||
form.Error = userError
|
||||
h.renderAPIKeyNewPage(w, r, http.StatusBadRequest, form)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "api key creation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
detailForm := APIKeyFormData{
|
||||
RevealKey: result.RawKey,
|
||||
}
|
||||
|
||||
h.renderAPIKeyDetailPage(w, r, result.APIKey.ID, http.StatusOK, detailForm)
|
||||
}
|
||||
|
||||
func (h *handler) adminAPIKeyDetail(w http.ResponseWriter, r *http.Request) {
|
||||
apiKeyID, err := routeID(r, "apiKeyID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.apiKeyPageData(r, apiKeyID, APIKeyFormData{})
|
||||
if err != nil {
|
||||
h.renderAPIKeyDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data.Flash = apiKeyDetailFlash(r.URL.Query().Get("status"))
|
||||
renderPage(w, h.renderer, "api_key_form", *data)
|
||||
}
|
||||
|
||||
func (h *handler) adminAPIKeyUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
apiKeyID, err := routeID(r, "apiKeyID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid api key form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form, inputErr := apiKeyFormDataFromRequest(r, fmt.Sprintf("/admin/api-keys/%d", apiKeyID), "Save API key")
|
||||
if inputErr != "" {
|
||||
form.Error = inputErr
|
||||
h.renderAPIKeyDetailPage(w, r, apiKeyID, http.StatusBadRequest, form)
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt, err := parseAPIKeyExpiration(form.ExpiresAt)
|
||||
if err != nil {
|
||||
form.Error = "Expiration must be empty, YYYY-MM-DD, or RFC3339 UTC."
|
||||
h.renderAPIKeyDetailPage(w, r, apiKeyID, http.StatusBadRequest, form)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.apiKeys.Update(r.Context(), apiKeyID, apikeys.UpdateParams{
|
||||
Name: form.Name,
|
||||
Description: form.Description,
|
||||
ScopeMode: form.ScopeMode,
|
||||
CanDownload: form.CanDownload,
|
||||
CanUpload: form.CanUpload,
|
||||
CanDelete: form.CanDelete,
|
||||
CanManageProjects: form.CanManageProjects,
|
||||
ExpiresAt: expiresAt,
|
||||
ProjectIDs: form.SelectedProjectIDs,
|
||||
TagIDs: form.SelectedTagIDs,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
http.NotFound(w, r)
|
||||
case apiKeyUserError(err) != "":
|
||||
form.Error = apiKeyUserError(err)
|
||||
h.renderAPIKeyDetailPage(w, r, apiKeyID, http.StatusBadRequest, form)
|
||||
default:
|
||||
http.Error(w, "api key update failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/api-keys/%d?status=api-key-updated", apiKeyID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminAPIKeyToggleActive(w http.ResponseWriter, r *http.Request) {
|
||||
apiKeyID, err := routeID(r, "apiKeyID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid api key lifecycle form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
isActive bool
|
||||
statusCode string
|
||||
)
|
||||
|
||||
switch strings.TrimSpace(r.FormValue("state")) {
|
||||
case "activate":
|
||||
isActive = true
|
||||
statusCode = "api-key-activated"
|
||||
case "revoke":
|
||||
isActive = false
|
||||
statusCode = "api-key-revoked"
|
||||
default:
|
||||
http.Error(w, "invalid lifecycle action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.apiKeys.SetActive(r.Context(), apiKeyID, isActive); err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "api key lifecycle update failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/api-keys/%d?status=%s", apiKeyID, statusCode), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) renderAPIKeyNewPage(w http.ResponseWriter, r *http.Request, status int, form APIKeyFormData) {
|
||||
data := APIKeyPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"New API Key",
|
||||
"Protected Admin",
|
||||
"Create API Key",
|
||||
"Generate a secure key, choose the minimum permissions it needs, and define access with one scope mode at a time.",
|
||||
),
|
||||
Form: form,
|
||||
}
|
||||
|
||||
if err := h.populateAPIKeyChoices(r, &data); err != nil {
|
||||
http.Error(w, "api key form failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "api_key_form", status, data)
|
||||
}
|
||||
|
||||
func (h *handler) renderAPIKeyDetailPage(w http.ResponseWriter, r *http.Request, apiKeyID int64, status int, form APIKeyFormData) {
|
||||
if form.RevealKey != "" {
|
||||
applyNoStoreHeaders(w)
|
||||
}
|
||||
|
||||
data, err := h.apiKeyPageData(r, apiKeyID, form)
|
||||
if err != nil {
|
||||
h.renderAPIKeyDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "api_key_form", status, *data)
|
||||
}
|
||||
|
||||
func (h *handler) renderAPIKeyDetailError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "api key page failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *handler) apiKeyPageData(r *http.Request, apiKeyID int64, form APIKeyFormData) (*APIKeyPageData, error) {
|
||||
apiKey, err := h.apiKeys.GetByID(r.Context(), apiKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
projectAccess, err := h.apiKeys.ListProjectAccess(r.Context(), apiKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tagAccess, err := h.apiKeys.ListTagAccess(r.Context(), apiKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessibleProjects, err := h.apiKeys.ListAccessibleProjects(r.Context(), *apiKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if form.Action == "" {
|
||||
form = APIKeyFormData{
|
||||
Action: fmt.Sprintf("/admin/api-keys/%d", apiKeyID),
|
||||
SubmitLabel: "Save API key",
|
||||
Name: apiKey.Name,
|
||||
Description: apiKey.Description,
|
||||
ScopeMode: apiKey.ScopeMode,
|
||||
ExpiresAt: formatOptionalTimestamp(apiKey.ExpiresAt),
|
||||
CanDownload: apiKey.CanDownload,
|
||||
CanUpload: apiKey.CanUpload,
|
||||
CanDelete: apiKey.CanDelete,
|
||||
CanManageProjects: apiKey.CanManageProjects,
|
||||
SelectedProjectIDs: projectIDs(projectAccess),
|
||||
SelectedTagIDs: tagIDs(tagAccess),
|
||||
Error: form.Error,
|
||||
RevealKey: form.RevealKey,
|
||||
}
|
||||
}
|
||||
|
||||
data := &APIKeyPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
apiKey.Name,
|
||||
"Protected Admin",
|
||||
apiKey.Name,
|
||||
"Rotate permissions, change scope mode, and preview which active projects this key can currently reach.",
|
||||
),
|
||||
APIKey: apiKey,
|
||||
Form: form,
|
||||
AccessibleProjects: accessibleProjects,
|
||||
ToggleAction: fmt.Sprintf("/admin/api-keys/%d/activate", apiKeyID),
|
||||
}
|
||||
|
||||
if apiKey.IsActive {
|
||||
data.ToggleState = "revoke"
|
||||
data.ToggleLabel = "Revoke key"
|
||||
} else {
|
||||
data.ToggleState = "activate"
|
||||
data.ToggleLabel = "Activate key"
|
||||
}
|
||||
|
||||
if err := h.populateAPIKeyChoices(r, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (h *handler) populateAPIKeyChoices(r *http.Request, data *APIKeyPageData) error {
|
||||
projectItems, err := h.store.Projects.List(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tagItems, err := h.store.Tags.List(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
selectedProjects := make(map[int64]struct{}, len(data.Form.SelectedProjectIDs))
|
||||
for _, id := range data.Form.SelectedProjectIDs {
|
||||
selectedProjects[id] = struct{}{}
|
||||
}
|
||||
|
||||
selectedTags := make(map[int64]struct{}, len(data.Form.SelectedTagIDs))
|
||||
for _, id := range data.Form.SelectedTagIDs {
|
||||
selectedTags[id] = struct{}{}
|
||||
}
|
||||
|
||||
data.ProjectChoices = make([]APIKeyProjectChoice, 0, len(projectItems))
|
||||
for _, item := range projectItems {
|
||||
_, selected := selectedProjects[item.Project.ID]
|
||||
data.ProjectChoices = append(data.ProjectChoices, APIKeyProjectChoice{
|
||||
Project: item.Project,
|
||||
Selected: selected,
|
||||
})
|
||||
}
|
||||
|
||||
data.TagChoices = make([]APIKeyTagChoice, 0, len(tagItems))
|
||||
for _, item := range tagItems {
|
||||
_, selected := selectedTags[item.Tag.ID]
|
||||
data.TagChoices = append(data.TagChoices, APIKeyTagChoice{
|
||||
Tag: item.Tag,
|
||||
Selected: selected,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiKeyFormDataFromRequest(r *http.Request, action, submitLabel string) (APIKeyFormData, string) {
|
||||
form := APIKeyFormData{
|
||||
Action: action,
|
||||
SubmitLabel: submitLabel,
|
||||
Name: strings.TrimSpace(r.FormValue("name")),
|
||||
Description: strings.TrimSpace(r.FormValue("description")),
|
||||
ScopeMode: db.ScopeMode(strings.TrimSpace(r.FormValue("scope_mode"))),
|
||||
ExpiresAt: strings.TrimSpace(r.FormValue("expires_at")),
|
||||
CanDownload: r.FormValue("can_download") != "",
|
||||
CanUpload: r.FormValue("can_upload") != "",
|
||||
CanDelete: r.FormValue("can_delete") != "",
|
||||
CanManageProjects: r.FormValue("can_manage_projects") != "",
|
||||
}
|
||||
|
||||
projectIDs, err := parseMultiIDList(r.Form["project_id"])
|
||||
if err != nil {
|
||||
return form, "Choose only valid projects."
|
||||
}
|
||||
form.SelectedProjectIDs = projectIDs
|
||||
|
||||
tagIDs, err := parseMultiIDList(r.Form["tag_id"])
|
||||
if err != nil {
|
||||
return form, "Choose only valid tags."
|
||||
}
|
||||
form.SelectedTagIDs = tagIDs
|
||||
|
||||
if form.Name == "" {
|
||||
return form, "API key name is required."
|
||||
}
|
||||
|
||||
return form, ""
|
||||
}
|
||||
|
||||
func parseMultiIDList(values []string) ([]int64, error) {
|
||||
result := make([]int64, 0, len(values))
|
||||
for _, raw := range values {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
return nil, fmt.Errorf("invalid id")
|
||||
}
|
||||
|
||||
result = append(result, value)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseAPIKeyExpiration(raw string) (*time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
utc := parsed.UTC()
|
||||
return &utc, nil
|
||||
}
|
||||
|
||||
if parsed, err := time.Parse("2006-01-02", raw); err == nil {
|
||||
utc := parsed.UTC()
|
||||
return &utc, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid expiration")
|
||||
}
|
||||
|
||||
func formatOptionalTimestamp(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func apiKeyUserError(err error) string {
|
||||
message := strings.TrimSpace(err.Error())
|
||||
lower := strings.ToLower(message)
|
||||
|
||||
switch {
|
||||
case strings.Contains(lower, "api key name is required"):
|
||||
return "API key name is required."
|
||||
case strings.Contains(lower, "scope mode is required"):
|
||||
return "Choose one scope mode."
|
||||
case strings.Contains(lower, "select at least one permission"):
|
||||
return "Select at least one permission."
|
||||
case strings.Contains(lower, "foreign key"):
|
||||
return "One of the selected projects or tags no longer exists."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func projectIDs(projects []db.Project) []int64 {
|
||||
ids := make([]int64, 0, len(projects))
|
||||
for _, project := range projects {
|
||||
ids = append(ids, project.ID)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
func tagIDs(tags []db.Tag) []int64 {
|
||||
ids := make([]int64, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
ids = append(ids, tag.ID)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
func apiKeyListFlash(code string) *FlashMessage {
|
||||
switch code {
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func apiKeyDetailFlash(code string) *FlashMessage {
|
||||
switch code {
|
||||
case "api-key-updated":
|
||||
return &FlashMessage{Kind: "success", Message: "API key updated."}
|
||||
case "api-key-activated":
|
||||
return &FlashMessage{Kind: "success", Message: "API key activated."}
|
||||
case "api-key-revoked":
|
||||
return &FlashMessage{Kind: "success", Message: "API key revoked."}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
135
internal/http/admin_common.go
Normal file
135
internal/http/admin_common.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
authservice "update_server/internal/auth"
|
||||
"update_server/internal/db"
|
||||
"update_server/internal/slug"
|
||||
)
|
||||
|
||||
func (h *handler) basePageData(r *http.Request, title, eyebrow, heading, description string) PageData {
|
||||
return PageData{
|
||||
Title: title,
|
||||
Eyebrow: eyebrow,
|
||||
Heading: heading,
|
||||
Description: description,
|
||||
BaseURL: strings.TrimRight(h.config.BaseURL, "/"),
|
||||
CSRFToken: h.csrfToken(r),
|
||||
CurrentUser: h.currentUser(r),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) currentUser(r *http.Request) *db.User {
|
||||
sessionState, ok := authservice.FromContext(r.Context())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
user := sessionState.User
|
||||
return &user
|
||||
}
|
||||
|
||||
func (h *handler) currentUserID(r *http.Request) *int64 {
|
||||
user := h.currentUser(r)
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &user.ID
|
||||
}
|
||||
|
||||
func routeID(r *http.Request, key string) (int64, error) {
|
||||
raw := strings.TrimSpace(chi.URLParam(r, key))
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("%s is required", key)
|
||||
}
|
||||
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
return 0, fmt.Errorf("invalid %s", key)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func projectFormInput(name, slugValue, description string) (db.CreateProjectParams, string) {
|
||||
name = strings.TrimSpace(name)
|
||||
slugValue = slug.Make(firstNonEmpty(slugValue, name))
|
||||
description = strings.TrimSpace(description)
|
||||
|
||||
switch {
|
||||
case name == "":
|
||||
return db.CreateProjectParams{}, "Project name is required."
|
||||
case slugValue == "":
|
||||
return db.CreateProjectParams{}, "Project slug is required."
|
||||
default:
|
||||
return db.CreateProjectParams{
|
||||
Name: name,
|
||||
Slug: slugValue,
|
||||
Description: description,
|
||||
}, ""
|
||||
}
|
||||
}
|
||||
|
||||
func tagFormInput(name, slugValue, description string) (db.CreateTagParams, string) {
|
||||
name = strings.TrimSpace(name)
|
||||
slugValue = slug.Make(firstNonEmpty(slugValue, name))
|
||||
description = strings.TrimSpace(description)
|
||||
|
||||
switch {
|
||||
case name == "":
|
||||
return db.CreateTagParams{}, "Tag name is required."
|
||||
case slugValue == "":
|
||||
return db.CreateTagParams{}, "Tag slug is required."
|
||||
default:
|
||||
return db.CreateTagParams{
|
||||
Name: name,
|
||||
Slug: slugValue,
|
||||
Description: description,
|
||||
}, ""
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func maxUploadMegabytes(bytes int64) int64 {
|
||||
if bytes <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
const mebibyte = 1024 * 1024
|
||||
value := bytes / mebibyte
|
||||
if bytes%mebibyte != 0 {
|
||||
value++
|
||||
}
|
||||
if value == 0 {
|
||||
value = 1
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func maxUploadRequestLimit(fileLimit int64) int64 {
|
||||
const multipartOverhead = 1 << 20
|
||||
return fileLimit + multipartOverhead
|
||||
}
|
||||
|
||||
func isMaxBytesError(err error) bool {
|
||||
var maxErr *http.MaxBytesError
|
||||
return errors.As(err, &maxErr)
|
||||
}
|
||||
485
internal/http/admin_projects.go
Normal file
485
internal/http/admin_projects.go
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"update_server/internal/db"
|
||||
"update_server/internal/releases"
|
||||
)
|
||||
|
||||
func (h *handler) adminProjects(w http.ResponseWriter, r *http.Request) {
|
||||
projects, err := h.store.Projects.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "project listing failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := ProjectsPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"Projects",
|
||||
"Protected Admin",
|
||||
"Projects",
|
||||
"Create projects, update their metadata, archive them when needed, and open each project to manage tags and release uploads.",
|
||||
),
|
||||
Projects: projects,
|
||||
}
|
||||
data.Flash = projectListFlash(r.URL.Query().Get("status"))
|
||||
|
||||
renderPage(w, h.renderer, "projects", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectNew(w http.ResponseWriter, r *http.Request) {
|
||||
data := ProjectFormPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"New Project",
|
||||
"Protected Admin",
|
||||
"Create Project",
|
||||
"Add a new update stream with a stable slug so future releases and API access rules can target it reliably.",
|
||||
),
|
||||
Form: ProjectFormData{
|
||||
Action: "/admin/projects",
|
||||
SubmitLabel: "Create project",
|
||||
},
|
||||
}
|
||||
|
||||
renderPage(w, h.renderer, "project_form", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid project form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form := ProjectFormData{
|
||||
Action: "/admin/projects",
|
||||
SubmitLabel: "Create project",
|
||||
Name: r.FormValue("name"),
|
||||
Slug: r.FormValue("slug"),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
|
||||
params, validationError := projectFormInput(form.Name, form.Slug, form.Description)
|
||||
form.Name = params.Name
|
||||
form.Slug = params.Slug
|
||||
form.Description = params.Description
|
||||
if validationError != "" {
|
||||
form.Error = validationError
|
||||
h.renderProjectFormPage(w, r, http.StatusBadRequest, form)
|
||||
return
|
||||
}
|
||||
|
||||
project, err := h.store.Projects.Create(r.Context(), params)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrConflict) {
|
||||
form.Error = "A project with that slug already exists."
|
||||
h.renderProjectFormPage(w, r, http.StatusConflict, form)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "project creation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=project-created", project.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectDetail(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.projectDetailPageData(r, projectID, ProjectFormData{}, ReleaseUploadData{})
|
||||
if err != nil {
|
||||
h.renderProjectDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data.Flash = projectDetailFlash(r.URL.Query().Get("status"))
|
||||
renderPage(w, h.renderer, "project_detail", *data)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid project form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form := ProjectFormData{
|
||||
Action: fmt.Sprintf("/admin/projects/%d", projectID),
|
||||
SubmitLabel: "Save project",
|
||||
Name: r.FormValue("name"),
|
||||
Slug: r.FormValue("slug"),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
|
||||
params, validationError := projectFormInput(form.Name, form.Slug, form.Description)
|
||||
form.Name = params.Name
|
||||
form.Slug = params.Slug
|
||||
form.Description = params.Description
|
||||
if validationError != "" {
|
||||
form.Error = validationError
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, form, ReleaseUploadData{})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.store.Projects.Update(r.Context(), projectID, db.UpdateProjectParams(params))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
http.NotFound(w, r)
|
||||
case errors.Is(err, db.ErrConflict):
|
||||
form.Error = "A project with that slug already exists."
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusConflict, form, ReleaseUploadData{})
|
||||
default:
|
||||
http.Error(w, "project update failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=project-updated", projectID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectArchive(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid archive form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
state := r.FormValue("state")
|
||||
var (
|
||||
setActive bool
|
||||
statusCode string
|
||||
)
|
||||
|
||||
switch state {
|
||||
case "archive":
|
||||
setActive = false
|
||||
statusCode = "project-archived"
|
||||
case "restore":
|
||||
setActive = true
|
||||
statusCode = "project-restored"
|
||||
default:
|
||||
http.Error(w, "invalid archive action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.Projects.SetActive(r.Context(), projectID, setActive); err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "project archive failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=%s", projectID, statusCode), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectAttachTag(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid tag assignment form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := strconv.ParseInt(r.FormValue("tag_id"), 10, 64)
|
||||
if err != nil || tagID <= 0 {
|
||||
h.renderProjectDetailPageWithFlash(w, r, projectID, http.StatusBadRequest, "error", "Choose a valid tag to attach.")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.store.Tags.GetByID(r.Context(), tagID); err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
h.renderProjectDetailPageWithFlash(w, r, projectID, http.StatusBadRequest, "error", "Selected tag no longer exists.")
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "tag lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.Projects.AttachTag(r.Context(), projectID, tagID); err != nil {
|
||||
http.Error(w, "tag assignment failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=tag-attached", projectID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectDetachTag(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := routeID(r, "tagID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.Projects.DetachTag(r.Context(), projectID, tagID); err != nil {
|
||||
http.Error(w, "tag detach failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=tag-detached", projectID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectUploadRelease(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadRequestLimit(h.config.MaxUploadBytes))
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
upload := ReleaseUploadData{
|
||||
Version: r.FormValue("version"),
|
||||
Build: r.FormValue("build"),
|
||||
ReleaseNotes: r.FormValue("release_notes"),
|
||||
}
|
||||
|
||||
if isMaxBytesError(err) {
|
||||
upload.Error = fmt.Sprintf("Upload exceeds the configured %d MB limit.", maxUploadMegabytes(h.config.MaxUploadBytes))
|
||||
} else {
|
||||
upload.Error = "Upload form could not be read."
|
||||
}
|
||||
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, ProjectFormData{}, upload)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r.MultipartForm != nil {
|
||||
_ = r.MultipartForm.RemoveAll()
|
||||
}
|
||||
}()
|
||||
|
||||
upload := ReleaseUploadData{
|
||||
Version: r.FormValue("version"),
|
||||
Build: r.FormValue("build"),
|
||||
ReleaseNotes: r.FormValue("release_notes"),
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("artifact")
|
||||
if err != nil {
|
||||
upload.Error = "Choose an artifact file to upload."
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, ProjectFormData{}, upload)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := h.handleReleaseUpload(r, projectID, file, header, upload); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
http.NotFound(w, r)
|
||||
case errors.Is(err, db.ErrConflict):
|
||||
upload.Error = "A release with that version and build already exists for this project."
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusConflict, ProjectFormData{}, upload)
|
||||
default:
|
||||
upload.Error = err.Error()
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, ProjectFormData{}, upload)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=release-uploaded", projectID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) handleReleaseUpload(r *http.Request, projectID int64, file multipart.File, header *multipart.FileHeader, upload ReleaseUploadData) error {
|
||||
_, err := h.releases.Upload(r.Context(), releases.UploadParams{
|
||||
ProjectID: projectID,
|
||||
Version: upload.Version,
|
||||
Build: upload.Build,
|
||||
ReleaseNotes: upload.ReleaseNotes,
|
||||
OriginalFilename: header.Filename,
|
||||
DeclaredType: header.Header.Get("Content-Type"),
|
||||
Reader: file,
|
||||
UploadedByUserID: h.currentUserID(r),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) renderProjectFormPage(w http.ResponseWriter, r *http.Request, status int, form ProjectFormData) {
|
||||
data := ProjectFormPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"New Project",
|
||||
"Protected Admin",
|
||||
"Create Project",
|
||||
"Add a new update stream with a stable slug so future releases and API access rules can target it reliably.",
|
||||
),
|
||||
Form: form,
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "project_form", status, data)
|
||||
}
|
||||
|
||||
func (h *handler) renderProjectDetailPage(w http.ResponseWriter, r *http.Request, projectID int64, status int, form ProjectFormData, upload ReleaseUploadData) {
|
||||
data, err := h.projectDetailPageData(r, projectID, form, upload)
|
||||
if err != nil {
|
||||
h.renderProjectDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "project_detail", status, *data)
|
||||
}
|
||||
|
||||
func (h *handler) renderProjectDetailPageWithFlash(w http.ResponseWriter, r *http.Request, projectID int64, status int, kind, message string) {
|
||||
data, err := h.projectDetailPageData(r, projectID, ProjectFormData{}, ReleaseUploadData{})
|
||||
if err != nil {
|
||||
h.renderProjectDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data.Flash = &FlashMessage{Kind: kind, Message: message}
|
||||
renderPageStatus(w, h.renderer, "project_detail", status, *data)
|
||||
}
|
||||
|
||||
func (h *handler) renderProjectDetailError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "project page failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *handler) projectDetailPageData(r *http.Request, projectID int64, form ProjectFormData, upload ReleaseUploadData) (*ProjectDetailPageData, error) {
|
||||
project, err := h.store.Projects.GetByID(r.Context(), projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := h.store.Projects.ListTags(r.Context(), projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
availableTags, err := h.store.Tags.ListAvailableForProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
releasesList, err := h.store.Releases.ListByProjectID(r.Context(), projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var latestRelease *db.Release
|
||||
if len(releasesList) > 0 {
|
||||
release := releasesList[0].Release
|
||||
latestRelease = &release
|
||||
}
|
||||
|
||||
if form.Action == "" {
|
||||
form = ProjectFormData{
|
||||
Action: fmt.Sprintf("/admin/projects/%d", projectID),
|
||||
SubmitLabel: "Save project",
|
||||
Name: project.Name,
|
||||
Slug: project.Slug,
|
||||
Description: project.Description,
|
||||
Error: form.Error,
|
||||
}
|
||||
}
|
||||
|
||||
if upload.Action == "" {
|
||||
upload.Action = fmt.Sprintf("/admin/projects/%d/releases", projectID)
|
||||
}
|
||||
if upload.MaxUploadMB == 0 {
|
||||
upload.MaxUploadMB = maxUploadMegabytes(h.config.MaxUploadBytes)
|
||||
}
|
||||
|
||||
archiveState := "archive"
|
||||
archiveLabel := "Archive project"
|
||||
if !project.IsActive {
|
||||
archiveState = "restore"
|
||||
archiveLabel = "Restore project"
|
||||
}
|
||||
|
||||
data := &ProjectDetailPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
project.Name,
|
||||
"Protected Admin",
|
||||
project.Name,
|
||||
"Edit project metadata, manage tag assignments, and upload releases with checksums stored in SQLite.",
|
||||
),
|
||||
Project: *project,
|
||||
Form: form,
|
||||
Tags: tags,
|
||||
AvailableTags: availableTags,
|
||||
Releases: releasesList,
|
||||
LatestRelease: latestRelease,
|
||||
AttachTagAction: fmt.Sprintf("/admin/projects/%d/tags", projectID),
|
||||
ArchiveAction: fmt.Sprintf("/admin/projects/%d/archive", projectID),
|
||||
ArchiveState: archiveState,
|
||||
ArchiveLabel: archiveLabel,
|
||||
Upload: upload,
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func projectListFlash(code string) *FlashMessage {
|
||||
switch code {
|
||||
case "":
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func projectDetailFlash(code string) *FlashMessage {
|
||||
switch code {
|
||||
case "project-created":
|
||||
return &FlashMessage{Kind: "success", Message: "Project created. You can now attach tags and upload releases."}
|
||||
case "project-updated":
|
||||
return &FlashMessage{Kind: "success", Message: "Project details updated."}
|
||||
case "project-archived":
|
||||
return &FlashMessage{Kind: "success", Message: "Project archived."}
|
||||
case "project-restored":
|
||||
return &FlashMessage{Kind: "success", Message: "Project restored and active again."}
|
||||
case "tag-attached":
|
||||
return &FlashMessage{Kind: "success", Message: "Tag attached to the project."}
|
||||
case "tag-detached":
|
||||
return &FlashMessage{Kind: "success", Message: "Tag detached from the project."}
|
||||
case "release-uploaded":
|
||||
return &FlashMessage{Kind: "success", Message: "Release uploaded. Metadata, checksum, and storage path were saved."}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
274
internal/http/admin_tags.go
Normal file
274
internal/http/admin_tags.go
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func (h *handler) adminTags(w http.ResponseWriter, r *http.Request) {
|
||||
tags, err := h.store.Tags.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "tag listing failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := TagsPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"Tags",
|
||||
"Protected Admin",
|
||||
"Tags",
|
||||
"Create reusable tags now so projects can be grouped for future access rules and release discovery endpoints.",
|
||||
),
|
||||
Tags: tags,
|
||||
}
|
||||
data.Flash = tagListFlash(r.URL.Query().Get("status"))
|
||||
|
||||
renderPage(w, h.renderer, "tags", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminTagNew(w http.ResponseWriter, r *http.Request) {
|
||||
data := TagFormPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"New Tag",
|
||||
"Protected Admin",
|
||||
"Create Tag",
|
||||
"Add a reusable label that can be attached to projects from day one and reused later in API key access rules.",
|
||||
),
|
||||
Form: TagFormData{
|
||||
Action: "/admin/tags",
|
||||
SubmitLabel: "Create tag",
|
||||
},
|
||||
}
|
||||
|
||||
renderPage(w, h.renderer, "tag_form", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminTagCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid tag form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form := TagFormData{
|
||||
Action: "/admin/tags",
|
||||
SubmitLabel: "Create tag",
|
||||
Name: r.FormValue("name"),
|
||||
Slug: r.FormValue("slug"),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
|
||||
params, validationError := tagFormInput(form.Name, form.Slug, form.Description)
|
||||
form.Name = params.Name
|
||||
form.Slug = params.Slug
|
||||
form.Description = params.Description
|
||||
if validationError != "" {
|
||||
form.Error = validationError
|
||||
h.renderTagFormPage(w, r, http.StatusBadRequest, form)
|
||||
return
|
||||
}
|
||||
|
||||
tag, err := h.store.Tags.Create(r.Context(), params)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrConflict) {
|
||||
form.Error = "A tag with that slug already exists."
|
||||
h.renderTagFormPage(w, r, http.StatusConflict, form)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "tag creation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/tags/%d?status=tag-created", tag.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminTagDetail(w http.ResponseWriter, r *http.Request) {
|
||||
tagID, err := routeID(r, "tagID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.tagDetailPageData(r, tagID, TagFormData{})
|
||||
if err != nil {
|
||||
h.renderTagDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data.Flash = tagDetailFlash(r.URL.Query().Get("status"), r.URL.Query().Get("error"))
|
||||
renderPage(w, h.renderer, "tag_form", *data)
|
||||
}
|
||||
|
||||
func (h *handler) adminTagUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
tagID, err := routeID(r, "tagID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid tag form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form := TagFormData{
|
||||
Action: fmt.Sprintf("/admin/tags/%d", tagID),
|
||||
SubmitLabel: "Save tag",
|
||||
Name: r.FormValue("name"),
|
||||
Slug: r.FormValue("slug"),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
|
||||
params, validationError := tagFormInput(form.Name, form.Slug, form.Description)
|
||||
form.Name = params.Name
|
||||
form.Slug = params.Slug
|
||||
form.Description = params.Description
|
||||
if validationError != "" {
|
||||
form.Error = validationError
|
||||
h.renderTagDetailPage(w, r, tagID, http.StatusBadRequest, form)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.store.Tags.Update(r.Context(), tagID, db.UpdateTagParams(params))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
http.NotFound(w, r)
|
||||
case errors.Is(err, db.ErrConflict):
|
||||
form.Error = "A tag with that slug already exists."
|
||||
h.renderTagDetailPage(w, r, tagID, http.StatusConflict, form)
|
||||
default:
|
||||
http.Error(w, "tag update failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/tags/%d?status=tag-updated", tagID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminTagDelete(w http.ResponseWriter, r *http.Request) {
|
||||
tagID, err := routeID(r, "tagID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.Tags.Delete(r.Context(), tagID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
http.NotFound(w, r)
|
||||
case errors.Is(err, db.ErrConflict):
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/tags/%d?error=tag-in-use", tagID), http.StatusSeeOther)
|
||||
default:
|
||||
http.Error(w, "tag delete failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin/tags?status=tag-deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) renderTagFormPage(w http.ResponseWriter, r *http.Request, status int, form TagFormData) {
|
||||
data := TagFormPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"New Tag",
|
||||
"Protected Admin",
|
||||
"Create Tag",
|
||||
"Add a reusable label that can be attached to projects from day one and reused later in API key access rules.",
|
||||
),
|
||||
Form: form,
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "tag_form", status, data)
|
||||
}
|
||||
|
||||
func (h *handler) renderTagDetailPage(w http.ResponseWriter, r *http.Request, tagID int64, status int, form TagFormData) {
|
||||
data, err := h.tagDetailPageData(r, tagID, form)
|
||||
if err != nil {
|
||||
h.renderTagDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "tag_form", status, *data)
|
||||
}
|
||||
|
||||
func (h *handler) renderTagDetailError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "tag page failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *handler) tagDetailPageData(r *http.Request, tagID int64, form TagFormData) (*TagFormPageData, error) {
|
||||
tag, err := h.store.Tags.GetByID(r.Context(), tagID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
projects, err := h.store.Tags.ListProjects(r.Context(), tagID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if form.Action == "" {
|
||||
form = TagFormData{
|
||||
Action: fmt.Sprintf("/admin/tags/%d", tagID),
|
||||
SubmitLabel: "Save tag",
|
||||
Name: tag.Name,
|
||||
Slug: tag.Slug,
|
||||
Description: tag.Description,
|
||||
Error: form.Error,
|
||||
}
|
||||
}
|
||||
form.DeleteAction = fmt.Sprintf("/admin/tags/%d/delete", tagID)
|
||||
form.CanDelete = len(projects) == 0
|
||||
|
||||
data := &TagFormPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
tag.Name,
|
||||
"Protected Admin",
|
||||
tag.Name,
|
||||
"Edit this tag and review which projects already use it before preparing future access rules.",
|
||||
),
|
||||
Form: form,
|
||||
Tag: tag,
|
||||
Projects: projects,
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func tagListFlash(code string) *FlashMessage {
|
||||
switch code {
|
||||
case "tag-deleted":
|
||||
return &FlashMessage{Kind: "success", Message: "Tag deleted."}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func tagDetailFlash(statusCode, errorCode string) *FlashMessage {
|
||||
switch statusCode {
|
||||
case "tag-created":
|
||||
return &FlashMessage{Kind: "success", Message: "Tag created. You can now attach it to projects."}
|
||||
case "tag-updated":
|
||||
return &FlashMessage{Kind: "success", Message: "Tag details updated."}
|
||||
}
|
||||
|
||||
switch errorCode {
|
||||
case "tag-in-use":
|
||||
return &FlashMessage{Kind: "error", Message: "This tag is still attached to one or more projects, so it cannot be deleted yet."}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
111
internal/http/api_key_middleware.go
Normal file
111
internal/http/api_key_middleware.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
)
|
||||
|
||||
func (h *handler) requireAPIKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if h.apiKeys == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "api key auth is unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
token, ok := bearerToken(r.Header.Get("Authorization"))
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "missing bearer api key")
|
||||
return
|
||||
}
|
||||
|
||||
state, err := h.apiKeys.Authenticate(r.Context(), token)
|
||||
if err != nil {
|
||||
if errors.Is(err, apikeys.ErrUnauthenticated) {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "invalid api key")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "api key lookup failed"})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(apikeys.NewContext(r.Context(), state)))
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) requireAPIKeyPermission(permission apikeys.Permission) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := apikeys.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if !apikeys.HasPermission(state.APIKey, permission) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{"error": "api key permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) requireAPIKeyProjectAccess(routeParam string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := apikeys.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
projectID, err := routeID(r, routeParam)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := h.apiKeys.CanAccessProject(r.Context(), state.APIKey, projectID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "project access lookup failed"})
|
||||
return
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{"error": "api key cannot access this project"})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func bearerToken(header string) (string, bool) {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
parts := strings.Fields(header)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(parts[1])
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return token, true
|
||||
}
|
||||
|
||||
func writeAPIKeyAuthError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="update-server"`)
|
||||
writeJSON(w, status, map[string]any{"error": message})
|
||||
}
|
||||
162
internal/http/api_key_middleware_test.go
Normal file
162
internal/http/api_key_middleware_test.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func TestAPIKeyMiddlewareEnforcesAuthPermissionAndProjectScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, service, store := newAPIKeyMiddlewareTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
allowedProject, err := store.Projects.Create(ctx, db.CreateProjectParams{Name: "Desktop App", Slug: "desktop-app"})
|
||||
if err != nil {
|
||||
t.Fatalf("create allowed project: %v", err)
|
||||
}
|
||||
|
||||
blockedProject, err := store.Projects.Create(ctx, db.CreateProjectParams{Name: "Mobile App", Slug: "mobile-app"})
|
||||
if err != nil {
|
||||
t.Fatalf("create blocked project: %v", err)
|
||||
}
|
||||
|
||||
validKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Download Clients",
|
||||
ScopeMode: db.ScopeModeProjectAllowList,
|
||||
CanDownload: true,
|
||||
ProjectIDs: []int64{allowedProject.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create valid api key: %v", err)
|
||||
}
|
||||
|
||||
noPermissionKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "No Permission",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanUpload: true,
|
||||
ProjectIDs: nil,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create no-permission api key: %v", err)
|
||||
}
|
||||
|
||||
expiredAt := time.Now().UTC().Add(-time.Hour)
|
||||
expiredKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Expired Key",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
ExpiresAt: &expiredAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create expired api key: %v", err)
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Route("/api", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(h.requireAPIKey)
|
||||
r.Use(h.requireAPIKeyPermission(apikeys.PermissionDownload))
|
||||
r.With(h.requireAPIKeyProjectAccess("projectID")).Get("/projects/{projectID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
recorder := performMiddlewareRequest(router, "/api/projects/1", "")
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected missing auth to return 401, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if header := recorder.Header().Get("WWW-Authenticate"); header == "" {
|
||||
t.Fatal("expected missing auth to include WWW-Authenticate header")
|
||||
}
|
||||
|
||||
recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, allowedProject.ID), "Bearer "+noPermissionKey.RawKey)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected missing permission to return 403, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, blockedProject.ID), "Bearer "+validKey.RawKey)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected blocked project to return 403, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, allowedProject.ID), "Bearer "+expiredKey.RawKey)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected expired key to return 401, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, allowedProject.ID), "Bearer "+validKey.RawKey)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected allowed project to return 200, got %d with body %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func newAPIKeyMiddlewareTestHandler(t *testing.T) (*handler, *apikeys.Service, *db.Store) {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
sqlitePath := filepath.Join(t.TempDir(), "http-api-keys.sqlite")
|
||||
database, err := db.Open(ctx, sqlitePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Migrate(ctx, database, middlewareProjectPath(t, "migrations")); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("migrate sqlite: %v", err)
|
||||
}
|
||||
|
||||
store := db.NewStore(database)
|
||||
t.Cleanup(func() {
|
||||
_ = store.Close()
|
||||
})
|
||||
|
||||
service := apikeys.NewService(store)
|
||||
return &handler{
|
||||
store: store,
|
||||
apiKeys: service,
|
||||
}, service, store
|
||||
}
|
||||
|
||||
func middlewareProjectPath(t *testing.T, parts ...string) string {
|
||||
t.Helper()
|
||||
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve caller path")
|
||||
}
|
||||
|
||||
root := filepath.Join(filepath.Dir(filename), "..", "..")
|
||||
items := append([]string{root}, parts...)
|
||||
return filepath.Join(items...)
|
||||
}
|
||||
|
||||
func performMiddlewareRequest(router http.Handler, target, authorization string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func itoa(t *testing.T, value int64) string {
|
||||
t.Helper()
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
213
internal/http/api_keys_integration_test.go
Normal file
213
internal/http/api_keys_integration_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package httpserver_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminAPIKeyFlowRevealsRawKeyOnceAndSupportsRevocation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, store := newTestRouterWithStore(t)
|
||||
sessionCookie := loginAsAdmin(t, router)
|
||||
|
||||
projectLocation := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
projectID := extractResourceID(t, projectLocation, "/admin/projects/")
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Mobile App"},
|
||||
"slug": {"mobile-app"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
tagLocation := submitForm(t, router, http.MethodPost, "/admin/tags", url.Values{
|
||||
"name": {"Windows"},
|
||||
"slug": {"windows"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
tagID := extractResourceID(t, tagLocation, "/admin/tags/")
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID+"/tags", url.Values{
|
||||
"tag_id": {tagID},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router, sessionCookie)
|
||||
recorder := performRequest(t, router, http.MethodPost, "/admin/api-keys", url.Values{
|
||||
"name": {"Windows Clients"},
|
||||
"description": {"Download access for Windows builds."},
|
||||
"scope_mode": {"tag_allow_list"},
|
||||
"can_download": {"1"},
|
||||
"can_manage_projects": {"1"},
|
||||
"tag_id": {tagID},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}, sessionCookie, csrfCookie)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected create api key page, got %d with body %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
assertHeaderContains(t, recorder, "Cache-Control", "no-store")
|
||||
assertHeaderEquals(t, recorder, "Pragma", "no-cache")
|
||||
assertHeaderEquals(t, recorder, "Expires", "0")
|
||||
|
||||
body := recorder.Body.String()
|
||||
rawKey := findRawAPIKey(t, body)
|
||||
if !strings.Contains(body, rawKey) {
|
||||
t.Fatal("expected raw api key to be shown on creation response")
|
||||
}
|
||||
|
||||
assertBodyContains(t, body, `value="Windows Clients"`)
|
||||
assertBodyContains(t, body, "Download access for Windows builds.")
|
||||
assertBodyContains(t, body, `option value="tag_allow_list" selected`)
|
||||
assertBodyContains(t, body, `name="can_download" value="1" checked`)
|
||||
assertBodyContains(t, body, `name="can_manage_projects" value="1" checked`)
|
||||
assertBodyContains(t, body, `name="tag_id" value="`+tagID+`" checked`)
|
||||
assertBodyContains(t, body, "/api/v1/projects")
|
||||
assertBodyContains(t, body, "/api/v1/projects/desktop-app/releases/latest")
|
||||
assertBodyContains(t, body, "Authorization: Bearer")
|
||||
|
||||
keys, err := store.APIKeys.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list api keys: %v", err)
|
||||
}
|
||||
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("expected one api key, got %d", len(keys))
|
||||
}
|
||||
|
||||
keyID := keys[0].APIKey.ID
|
||||
storedKey, err := store.APIKeys.GetByID(context.Background(), keyID)
|
||||
if err != nil {
|
||||
t.Fatalf("load stored api key: %v", err)
|
||||
}
|
||||
|
||||
if storedKey.KeyHash == rawKey {
|
||||
t.Fatal("expected stored api key hash to differ from the raw key")
|
||||
}
|
||||
|
||||
accessibleProjects, err := store.APIKeys.ListAccessibleProjects(context.Background(), keyID, storedKey.ScopeMode)
|
||||
if err != nil {
|
||||
t.Fatalf("list accessible projects: %v", err)
|
||||
}
|
||||
|
||||
if len(accessibleProjects) != 1 || accessibleProjects[0].Name != "Desktop App" {
|
||||
t.Fatalf("expected only Desktop App to be accessible, got %+v", accessibleProjects)
|
||||
}
|
||||
|
||||
recorder = performRequest(t, router, http.MethodGet, "/admin/api-keys/"+itoa64(keyID), nil, sessionCookie)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected api key detail page to load, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if strings.Contains(recorder.Body.String(), rawKey) {
|
||||
t.Fatal("expected raw api key to disappear after the creation response")
|
||||
}
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/api-keys/"+itoa64(keyID)+"/activate", url.Values{
|
||||
"state": {"revoke"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
storedKey, err = store.APIKeys.GetByID(context.Background(), keyID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload revoked api key: %v", err)
|
||||
}
|
||||
|
||||
if storedKey.IsActive {
|
||||
t.Fatal("expected api key to be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIKeyCreateResponseShowsPersistedProjectRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, _ := newTestRouterWithStore(t)
|
||||
sessionCookie := loginAsAdmin(t, router)
|
||||
|
||||
projectLocation := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
projectID := extractResourceID(t, projectLocation, "/admin/projects/")
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Mobile App"},
|
||||
"slug": {"mobile-app"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router, sessionCookie)
|
||||
recorder := performRequest(t, router, http.MethodPost, "/admin/api-keys", url.Values{
|
||||
"name": {"Desktop Only"},
|
||||
"description": {"Project allow-list clients."},
|
||||
"scope_mode": {"project_allow_list"},
|
||||
"can_download": {"1"},
|
||||
"project_id": {projectID},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}, sessionCookie, csrfCookie)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected create api key page, got %d with body %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
body := recorder.Body.String()
|
||||
assertBodyContains(t, body, `value="Desktop Only"`)
|
||||
assertBodyContains(t, body, "Project allow-list clients.")
|
||||
assertBodyContains(t, body, `option value="project_allow_list" selected`)
|
||||
assertBodyContains(t, body, `name="project_id" value="`+projectID+`" checked`)
|
||||
assertBodyContains(t, body, "/api/v1/projects")
|
||||
assertBodyContains(t, body, "/api/v1/projects/desktop-app/releases/latest")
|
||||
assertHeaderContains(t, recorder, "Cache-Control", "no-store")
|
||||
}
|
||||
|
||||
func findRawAPIKey(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
|
||||
re := regexp.MustCompile(`upsk_[A-Za-z0-9_-]+`)
|
||||
matches := re.FindAllString(body, -1)
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected raw api key in response body")
|
||||
}
|
||||
|
||||
longest := matches[0]
|
||||
for _, match := range matches[1:] {
|
||||
if len(match) > len(longest) {
|
||||
longest = match
|
||||
}
|
||||
}
|
||||
|
||||
return longest
|
||||
}
|
||||
|
||||
func itoa64(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
|
||||
func assertBodyContains(t *testing.T, body, fragment string) {
|
||||
t.Helper()
|
||||
|
||||
if !strings.Contains(body, fragment) {
|
||||
t.Fatalf("expected response body to contain %q", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHeaderContains(t *testing.T, recorder *httptest.ResponseRecorder, key, fragment string) {
|
||||
t.Helper()
|
||||
|
||||
if value := recorder.Header().Get(key); !strings.Contains(value, fragment) {
|
||||
t.Fatalf("expected %s header to contain %q, got %q", key, fragment, value)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHeaderEquals(t *testing.T, recorder *httptest.ResponseRecorder, key, want string) {
|
||||
t.Helper()
|
||||
|
||||
if value := recorder.Header().Get(key); value != want {
|
||||
t.Fatalf("expected %s header %q, got %q", key, want, value)
|
||||
}
|
||||
}
|
||||
154
internal/http/auth_handlers.go
Normal file
154
internal/http/auth_handlers.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
authservice "update_server/internal/auth"
|
||||
)
|
||||
|
||||
const defaultAdminRedirect = "/admin"
|
||||
|
||||
func (h *handler) adminLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
if redirected, err := h.redirectAuthenticatedAdmin(w, r); err != nil {
|
||||
http.Error(w, "session lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
} else if redirected {
|
||||
return
|
||||
}
|
||||
|
||||
h.renderLoginPage(w, r, http.StatusOK, "", safeNextPath(r.URL.Query().Get("next")), "")
|
||||
}
|
||||
|
||||
func (h *handler) adminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid login form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
nextPath := safeNextPath(r.FormValue("next"))
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
token, sessionState, err := h.auth.Authenticate(r.Context(), email, password, clientIP(r), r.UserAgent())
|
||||
if err != nil {
|
||||
if errors.Is(err, authservice.ErrInvalidCredentials) {
|
||||
h.renderLoginPage(w, r, http.StatusUnauthorized, email, nextPath, "Invalid email or password.")
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "login failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, h.auth.SessionCookie(token, sessionState.Session.ExpiresAt))
|
||||
if _, err := h.issueCSRFCookie(w); err != nil {
|
||||
http.Error(w, "login failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, nextPath, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(h.auth.SessionCookieName()); err == nil {
|
||||
if err := h.auth.InvalidateSession(r.Context(), cookie.Value); err != nil {
|
||||
http.Error(w, "logout failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.SetCookie(w, h.auth.ClearSessionCookie())
|
||||
http.SetCookie(w, h.clearCSRFCookie())
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) renderLoginPage(w http.ResponseWriter, r *http.Request, status int, email, nextPath, errorMessage string) {
|
||||
hasActiveAdmin, err := h.store.Users.HasActiveAdmin(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "admin status lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
setupHint := ""
|
||||
if !hasActiveAdmin {
|
||||
setupHint = "No active admin user exists yet. Set ADMIN_EMAIL and ADMIN_PASSWORD, then restart the server once to bootstrap the first admin account."
|
||||
}
|
||||
|
||||
loginData := LoginPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"Admin Login",
|
||||
"Secure Sign-In",
|
||||
"Admin Login",
|
||||
"Use your admin credentials to open the protected server-rendered dashboard.",
|
||||
),
|
||||
Login: LoginFormData{
|
||||
Action: "/admin/login",
|
||||
Email: email,
|
||||
Next: nextPath,
|
||||
Error: errorMessage,
|
||||
SetupHint: setupHint,
|
||||
},
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "login", status, loginData)
|
||||
}
|
||||
|
||||
func (h *handler) redirectAuthenticatedAdmin(w http.ResponseWriter, r *http.Request) (bool, error) {
|
||||
cookie, err := r.Cookie(h.auth.SessionCookieName())
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
sessionState, err := h.auth.LoadSession(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
if errors.Is(err, authservice.ErrUnauthenticated) {
|
||||
http.SetCookie(w, h.auth.ClearSessionCookie())
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
http.Redirect(w, r, safeNextPath(r.URL.Query().Get("next")), http.StatusSeeOther)
|
||||
_ = sessionState
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func safeNextPath(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return defaultAdminRedirect
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
|
||||
return defaultAdminRedirect
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
func loginRedirectPath(nextPath string) string {
|
||||
values := url.Values{}
|
||||
if nextPath = safeNextPath(nextPath); nextPath != defaultAdminRedirect {
|
||||
values.Set("next", nextPath)
|
||||
}
|
||||
|
||||
if encoded := values.Encode(); encoded != "" {
|
||||
return "/admin/login?" + encoded
|
||||
}
|
||||
|
||||
return "/admin/login"
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
|
||||
return r.RemoteAddr
|
||||
}
|
||||
269
internal/http/auth_integration_test.go
Normal file
269
internal/http/auth_integration_test.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package httpserver_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/auth"
|
||||
"update_server/internal/config"
|
||||
"update_server/internal/db"
|
||||
httpserver "update_server/internal/http"
|
||||
"update_server/internal/releases"
|
||||
"update_server/internal/storage"
|
||||
)
|
||||
|
||||
const testCSRFCookieName = "update_server_csrf"
|
||||
|
||||
func TestAdminLoginLogoutFlowProtectsRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, cfg := newTestRouter(t)
|
||||
recorder := performRequest(t, router, http.MethodGet, "/admin", nil)
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect for unauthenticated admin route, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if location := recorder.Header().Get("Location"); location != "/admin/login" {
|
||||
t.Fatalf("expected login redirect, got %q", location)
|
||||
}
|
||||
|
||||
loginForm := url.Values{
|
||||
"email": {"admin@example.com"},
|
||||
"password": {"correct horse battery staple"},
|
||||
"next": {"/admin"},
|
||||
}
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router)
|
||||
loginForm.Set("csrf_token", csrfCookie.Value)
|
||||
recorder = performRequest(t, router, http.MethodPost, "/admin/login", loginForm, csrfCookie)
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected login redirect, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if location := recorder.Header().Get("Location"); location != "/admin" {
|
||||
t.Fatalf("expected admin redirect after login, got %q", location)
|
||||
}
|
||||
|
||||
adminCookies := recorder.Result().Cookies()
|
||||
var sessionCookie *http.Cookie
|
||||
for _, cookie := range adminCookies {
|
||||
if cookie.Name == cfg.SessionCookieName {
|
||||
sessionCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if sessionCookie == nil || sessionCookie.Value == "" {
|
||||
t.Fatal("expected session cookie after successful login")
|
||||
}
|
||||
|
||||
recorder = performRequest(t, router, http.MethodGet, "/admin", nil, sessionCookie)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected authenticated admin dashboard, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(recorder.Body.String(), "Admin Dashboard") {
|
||||
t.Fatal("expected admin dashboard content in response body")
|
||||
}
|
||||
|
||||
if !strings.Contains(recorder.Body.String(), "admin@example.com") {
|
||||
t.Fatal("expected authenticated admin email in dashboard response")
|
||||
}
|
||||
|
||||
logoutCSRFCookie := ensureCSRFCookie(t, router, sessionCookie)
|
||||
recorder = performRequest(t, router, http.MethodPost, "/admin/logout", url.Values{
|
||||
"csrf_token": {logoutCSRFCookie.Value},
|
||||
}, sessionCookie, logoutCSRFCookie)
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected logout redirect, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if location := recorder.Header().Get("Location"); location != "/admin/login" {
|
||||
t.Fatalf("expected login redirect after logout, got %q", location)
|
||||
}
|
||||
|
||||
recorder = performRequest(t, router, http.MethodGet, "/admin", nil, sessionCookie)
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected invalidated session cookie to be rejected, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if location := recorder.Header().Get("Location"); location != "/admin/login" {
|
||||
t.Fatalf("expected invalidated session redirect, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginRejectsInvalidCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _ := newTestRouter(t)
|
||||
csrfCookie := ensureCSRFCookie(t, router)
|
||||
recorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{
|
||||
"email": {"admin@example.com"},
|
||||
"password": {"definitely-wrong"},
|
||||
"next": {"/admin"},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}, csrfCookie)
|
||||
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized login response, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(recorder.Body.String(), "Invalid email or password.") {
|
||||
t.Fatal("expected invalid login message in response body")
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T) (http.Handler, config.Config) {
|
||||
t.Helper()
|
||||
|
||||
router, cfg, _ := newTestRouterWithStore(t)
|
||||
return router, cfg
|
||||
}
|
||||
|
||||
func newTestRouterWithStore(t *testing.T) (http.Handler, config.Config, *db.Store) {
|
||||
return newTestRouterWithConfig(t, nil)
|
||||
}
|
||||
|
||||
func newTestRouterWithConfig(t *testing.T, mutate func(*config.Config)) (http.Handler, config.Config, *db.Store) {
|
||||
t.Helper()
|
||||
|
||||
store := newHTTPTestStore(t)
|
||||
t.Cleanup(func() {
|
||||
_ = store.Close()
|
||||
})
|
||||
|
||||
artifactsDir := filepath.Join(t.TempDir(), "artifacts")
|
||||
cfg := config.Config{
|
||||
AppName: "Update Server",
|
||||
BaseURL: "http://127.0.0.1:8080",
|
||||
ArtifactsDir: artifactsDir,
|
||||
TemplatesDir: httpProjectPath(t, "web", "templates"),
|
||||
StaticDir: httpProjectPath(t, "web", "static"),
|
||||
AdminEmail: "admin@example.com",
|
||||
AdminPassword: "correct horse battery staple",
|
||||
MaxUploadBytes: 8 << 20,
|
||||
SessionCookieName: "update_server_session",
|
||||
CSRFCookieName: testCSRFCookieName,
|
||||
SessionTTL: 24 * time.Hour,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
LoginRateLimitPerMinute: 10,
|
||||
LoginRateLimitBurst: 5,
|
||||
ClientRateLimitPerMinute: 120,
|
||||
ClientRateLimitBurst: 60,
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(&cfg)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
renderer, err := httpserver.NewRenderer(cfg.TemplatesDir)
|
||||
if err != nil {
|
||||
t.Fatalf("create renderer: %v", err)
|
||||
}
|
||||
|
||||
authService := auth.NewService(cfg, logger, store)
|
||||
apiKeyService := apikeys.NewService(store)
|
||||
if err := authService.EnsureBootstrapAdmin(context.Background()); err != nil {
|
||||
t.Fatalf("bootstrap admin: %v", err)
|
||||
}
|
||||
|
||||
artifactStore, err := storage.NewLocal(cfg.ArtifactsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("create artifact storage: %v", err)
|
||||
}
|
||||
|
||||
releaseService := releases.NewService(store, artifactStore)
|
||||
return httpserver.NewRouter(cfg, logger, renderer, store, authService, apiKeyService, releaseService), cfg, store
|
||||
}
|
||||
|
||||
func newHTTPTestStore(t *testing.T) *db.Store {
|
||||
t.Helper()
|
||||
|
||||
sqlitePath := filepath.Join(t.TempDir(), "update-server.sqlite")
|
||||
database, err := db.Open(context.Background(), sqlitePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Migrate(context.Background(), database, httpProjectPath(t, "migrations")); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("migrate sqlite: %v", err)
|
||||
}
|
||||
|
||||
return db.NewStore(database)
|
||||
}
|
||||
|
||||
func httpProjectPath(t *testing.T, parts ...string) string {
|
||||
t.Helper()
|
||||
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve caller path")
|
||||
}
|
||||
|
||||
root := filepath.Join(filepath.Dir(filename), "..", "..")
|
||||
items := append([]string{root}, parts...)
|
||||
return filepath.Join(items...)
|
||||
}
|
||||
|
||||
func performRequest(t *testing.T, handler http.Handler, method, target string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
var body io.Reader
|
||||
if form != nil {
|
||||
body = strings.NewReader(form.Encode())
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(method, target, body)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
if form != nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
for _, cookie := range cookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func ensureCSRFCookie(t *testing.T, handler http.Handler, cookies ...*http.Cookie) *http.Cookie {
|
||||
t.Helper()
|
||||
|
||||
for _, cookie := range cookies {
|
||||
if cookie != nil && cookie.Name == testCSRFCookieName && cookie.Value != "" {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
|
||||
recorder := performRequest(t, handler, http.MethodGet, "/admin/login", nil, cookies...)
|
||||
if recorder.Code != http.StatusOK && recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected csrf bootstrap request to succeed, got %d with body %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
for _, cookie := range recorder.Result().Cookies() {
|
||||
if cookie.Name == testCSRFCookieName && cookie.Value != "" {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatal("expected csrf cookie from admin bootstrap request")
|
||||
return nil
|
||||
}
|
||||
52
internal/http/auth_middleware.go
Normal file
52
internal/http/auth_middleware.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
authservice "update_server/internal/auth"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func (h *handler) requireAuthenticatedSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(h.auth.SessionCookieName())
|
||||
if err != nil {
|
||||
http.Redirect(w, r, loginRedirectPath(r.URL.RequestURI()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
sessionState, err := h.auth.LoadSession(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
if errors.Is(err, authservice.ErrUnauthenticated) {
|
||||
http.SetCookie(w, h.auth.ClearSessionCookie())
|
||||
http.Redirect(w, r, loginRedirectPath(r.URL.RequestURI()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "session lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(authservice.NewContext(r.Context(), sessionState)))
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) requireRole(requiredRole db.UserRole) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sessionState, ok := authservice.FromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !authservice.RoleAllowed(sessionState.User.Role, requiredRole) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
247
internal/http/client_api.go
Normal file
247
internal/http/client_api.go
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
var errInvalidReleaseID = errors.New("invalid release id")
|
||||
|
||||
type apiProjectResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
LatestReleaseURL string `json:"latest_release_url"`
|
||||
}
|
||||
|
||||
type apiReleaseResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Build string `json:"build"`
|
||||
Filename string `json:"filename"`
|
||||
ChecksumSHA256 string `json:"checksum_sha256"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ContentType string `json:"content_type"`
|
||||
ReleaseNotes string `json:"release_notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
MetadataURL string `json:"metadata_url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
}
|
||||
|
||||
func (h *handler) apiAccessibleProjects(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := apikeys.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
projects, err := h.apiKeys.ListAccessibleProjects(r.Context(), state.APIKey)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "project listing failed"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]apiProjectResponse, 0, len(projects))
|
||||
for _, project := range projects {
|
||||
items = append(items, apiProjectPayload(project))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"projects": items})
|
||||
}
|
||||
|
||||
func (h *handler) apiLatestRelease(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := apikeys.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
project, err := h.accessibleProjectBySlug(r, state.APIKey)
|
||||
if err != nil {
|
||||
h.writeClientResourceError(w, err, "project lookup failed")
|
||||
return
|
||||
}
|
||||
|
||||
release, err := h.store.Releases.GetLatestByProjectID(r.Context(), project.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "release not found"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "release lookup failed"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"project": apiProjectPayload(*project),
|
||||
"release": apiReleasePayload(*release),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) apiReleaseMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := apikeys.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
project, release, err := h.accessibleReleaseByID(r, state.APIKey)
|
||||
if err != nil {
|
||||
h.writeClientResourceError(w, err, "release lookup failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"project": apiProjectPayload(*project),
|
||||
"release": apiReleasePayload(*release),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) apiReleaseDownload(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := apikeys.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
_, release, err := h.accessibleReleaseByID(r, state.APIKey)
|
||||
if err != nil {
|
||||
h.writeClientResourceError(w, err, "release lookup failed")
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.releases.Artifact(release.StoragePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "release not found"})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("artifact open failed", "release_id", release.ID, "storage_path", release.StoragePath, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "artifact download failed"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": release.Filename})
|
||||
if disposition != "" {
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
}
|
||||
w.Header().Set("Content-Type", release.ContentType)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
http.ServeContent(w, r, release.Filename, release.UpdatedAt, file)
|
||||
}
|
||||
|
||||
func (h *handler) accessibleProjectBySlug(r *http.Request, apiKey db.APIKey) (*db.Project, error) {
|
||||
projectSlug := strings.TrimSpace(chi.URLParam(r, "projectSlug"))
|
||||
if projectSlug == "" {
|
||||
return nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
project, err := h.store.Projects.GetBySlug(r.Context(), projectSlug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !project.IsActive {
|
||||
return nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
allowed, err := h.apiKeys.CanAccessProject(r.Context(), apiKey, project.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check project access: %w", err)
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
return nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func (h *handler) accessibleReleaseByID(r *http.Request, apiKey db.APIKey) (*db.Project, *db.Release, error) {
|
||||
releaseID, err := routeID(r, "releaseID")
|
||||
if err != nil {
|
||||
return nil, nil, errInvalidReleaseID
|
||||
}
|
||||
|
||||
release, err := h.store.Releases.GetByID(r.Context(), releaseID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !release.IsActive {
|
||||
return nil, nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
project, err := h.store.Projects.GetByID(r.Context(), release.ProjectID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !project.IsActive {
|
||||
return nil, nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
allowed, err := h.apiKeys.CanAccessProject(r.Context(), apiKey, project.ID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("check release project access: %w", err)
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
return nil, nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
return project, release, nil
|
||||
}
|
||||
|
||||
func (h *handler) writeClientResourceError(w http.ResponseWriter, err error, message string) {
|
||||
switch {
|
||||
case err == nil:
|
||||
return
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "resource not found"})
|
||||
case errors.Is(err, errInvalidReleaseID):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid release id"})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": message})
|
||||
}
|
||||
}
|
||||
|
||||
func apiProjectPayload(project db.Project) apiProjectResponse {
|
||||
return apiProjectResponse{
|
||||
ID: project.ID,
|
||||
Name: project.Name,
|
||||
Slug: project.Slug,
|
||||
Description: project.Description,
|
||||
LatestReleaseURL: fmt.Sprintf("/api/v1/projects/%s/releases/latest", project.Slug),
|
||||
}
|
||||
}
|
||||
|
||||
func apiReleasePayload(release db.Release) apiReleaseResponse {
|
||||
return apiReleaseResponse{
|
||||
ID: release.ID,
|
||||
Version: release.Version,
|
||||
Build: release.Build,
|
||||
Filename: release.Filename,
|
||||
ChecksumSHA256: release.ChecksumSHA256,
|
||||
SizeBytes: release.SizeBytes,
|
||||
ContentType: release.ContentType,
|
||||
ReleaseNotes: release.ReleaseNotes,
|
||||
CreatedAt: release.CreatedAt,
|
||||
MetadataURL: fmt.Sprintf("/api/v1/releases/%d", release.ID),
|
||||
DownloadURL: fmt.Sprintf("/api/v1/releases/%d/download", release.ID),
|
||||
}
|
||||
}
|
||||
290
internal/http/client_api_integration_test.go
Normal file
290
internal/http/client_api_integration_test.go
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
package httpserver_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func TestClientAPIListsAuthorizedProjectsReturnsLatestMetadataAndStreamsDownloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, store := newTestRouterWithStore(t)
|
||||
sessionCookie := loginAsAdmin(t, router)
|
||||
|
||||
desktopProjectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
"description": {"Primary desktop updater stream."},
|
||||
}, http.StatusSeeOther, sessionCookie), "/admin/projects/")
|
||||
|
||||
mobileProjectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Mobile App"},
|
||||
"slug": {"mobile-app"},
|
||||
}, http.StatusSeeOther, sessionCookie), "/admin/projects/")
|
||||
|
||||
legacyProjectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Legacy App"},
|
||||
"slug": {"legacy-app"},
|
||||
}, http.StatusSeeOther, sessionCookie), "/admin/projects/")
|
||||
|
||||
tagID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/tags", url.Values{
|
||||
"name": {"Windows"},
|
||||
"slug": {"windows"},
|
||||
}, http.StatusSeeOther, sessionCookie), "/admin/tags/")
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+desktopProjectID+"/tags", url.Values{"tag_id": {tagID}}, http.StatusSeeOther, sessionCookie)
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+legacyProjectID+"/tags", url.Values{"tag_id": {tagID}}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
submitMultipartForm(t, router, "/admin/projects/"+desktopProjectID+"/releases", map[string]string{
|
||||
"version": "1.0.0",
|
||||
"build": "build-1",
|
||||
"release_notes": "Initial desktop rollout.",
|
||||
}, "artifact", "Desktop-App-1.0.0.zip", []byte("desktop-release-1.0.0"), sessionCookie)
|
||||
|
||||
submitMultipartForm(t, router, "/admin/projects/"+desktopProjectID+"/releases", map[string]string{
|
||||
"version": "1.1.0",
|
||||
"build": "build-2",
|
||||
"release_notes": "Improved desktop rollout.",
|
||||
}, "artifact", "Desktop-App-1.1.0.zip", []byte("desktop-release-1.1.0"), sessionCookie)
|
||||
|
||||
submitMultipartForm(t, router, "/admin/projects/"+mobileProjectID+"/releases", map[string]string{
|
||||
"version": "2.0.0",
|
||||
"build": "mobile-1",
|
||||
"release_notes": "Mobile rollout.",
|
||||
}, "artifact", "mobile-app-2.0.0.apk", []byte("mobile-release-2.0.0"), sessionCookie)
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+legacyProjectID+"/archive", url.Values{
|
||||
"state": {"archive"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "Windows Clients",
|
||||
ScopeMode: db.ScopeModeTagAllowList,
|
||||
CanDownload: true,
|
||||
TagIDs: []int64{mustParseInt64(t, tagID)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create client api key: %v", err)
|
||||
}
|
||||
|
||||
projectsRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey)
|
||||
if projectsRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected project list to return 200, got %d with body %s", projectsRecorder.Code, projectsRecorder.Body.String())
|
||||
}
|
||||
|
||||
var projectsPayload struct {
|
||||
Projects []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
LatestReleaseURL string `json:"latest_release_url"`
|
||||
} `json:"projects"`
|
||||
}
|
||||
decodeJSONBody(t, projectsRecorder, &projectsPayload)
|
||||
|
||||
if len(projectsPayload.Projects) != 1 {
|
||||
t.Fatalf("expected exactly one accessible active project, got %+v", projectsPayload.Projects)
|
||||
}
|
||||
|
||||
if projectsPayload.Projects[0].Slug != "desktop-app" {
|
||||
t.Fatalf("expected desktop-app in accessible projects, got %+v", projectsPayload.Projects[0])
|
||||
}
|
||||
|
||||
if projectsPayload.Projects[0].LatestReleaseURL != "/api/v1/projects/desktop-app/releases/latest" {
|
||||
t.Fatalf("unexpected latest release url %q", projectsPayload.Projects[0].LatestReleaseURL)
|
||||
}
|
||||
|
||||
latestRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects/desktop-app/releases/latest", "Bearer "+keyResult.RawKey)
|
||||
if latestRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected latest release metadata to return 200, got %d with body %s", latestRecorder.Code, latestRecorder.Body.String())
|
||||
}
|
||||
|
||||
var latestPayload struct {
|
||||
Project struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"project"`
|
||||
Release struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Build string `json:"build"`
|
||||
MetadataURL string `json:"metadata_url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
} `json:"release"`
|
||||
}
|
||||
decodeJSONBody(t, latestRecorder, &latestPayload)
|
||||
|
||||
if latestPayload.Project.Slug != "desktop-app" {
|
||||
t.Fatalf("expected desktop-app project payload, got %+v", latestPayload.Project)
|
||||
}
|
||||
|
||||
if latestPayload.Release.Version != "1.1.0" || latestPayload.Release.Build != "build-2" {
|
||||
t.Fatalf("expected latest release 1.1.0/build-2, got %+v", latestPayload.Release)
|
||||
}
|
||||
|
||||
if latestPayload.Release.MetadataURL != "/api/v1/releases/2" {
|
||||
t.Fatalf("unexpected metadata url %q", latestPayload.Release.MetadataURL)
|
||||
}
|
||||
|
||||
if latestPayload.Release.DownloadURL != "/api/v1/releases/2/download" {
|
||||
t.Fatalf("unexpected download url %q", latestPayload.Release.DownloadURL)
|
||||
}
|
||||
|
||||
metadataRecorder := performAPIRequest(t, router, http.MethodGet, latestPayload.Release.MetadataURL, "Bearer "+keyResult.RawKey)
|
||||
if metadataRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected release metadata to return 200, got %d with body %s", metadataRecorder.Code, metadataRecorder.Body.String())
|
||||
}
|
||||
|
||||
var metadataPayload struct {
|
||||
Project struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"project"`
|
||||
Release struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Filename string `json:"filename"`
|
||||
} `json:"release"`
|
||||
}
|
||||
decodeJSONBody(t, metadataRecorder, &metadataPayload)
|
||||
|
||||
if metadataPayload.Release.ID != latestPayload.Release.ID || metadataPayload.Release.Version != "1.1.0" {
|
||||
t.Fatalf("expected matching release metadata payload, got %+v", metadataPayload.Release)
|
||||
}
|
||||
|
||||
blockedLatestRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects/mobile-app/releases/latest", "Bearer "+keyResult.RawKey)
|
||||
if blockedLatestRecorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected blocked project latest lookup to return 404, got %d with body %s", blockedLatestRecorder.Code, blockedLatestRecorder.Body.String())
|
||||
}
|
||||
|
||||
blockedMetadataRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/releases/3", "Bearer "+keyResult.RawKey)
|
||||
if blockedMetadataRecorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected blocked release metadata to return 404, got %d with body %s", blockedMetadataRecorder.Code, blockedMetadataRecorder.Body.String())
|
||||
}
|
||||
|
||||
downloadRecorder := performAPIRequest(t, router, http.MethodGet, latestPayload.Release.DownloadURL, "Bearer "+keyResult.RawKey)
|
||||
if downloadRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected release download to return 200, got %d with body %s", downloadRecorder.Code, downloadRecorder.Body.String())
|
||||
}
|
||||
|
||||
if body := downloadRecorder.Body.String(); body != "desktop-release-1.1.0" {
|
||||
t.Fatalf("unexpected download body %q", body)
|
||||
}
|
||||
|
||||
if value := downloadRecorder.Header().Get("Content-Disposition"); !strings.Contains(value, "attachment") || !strings.Contains(value, "desktop-app-1.1.0.zip") {
|
||||
t.Fatalf("expected attachment content disposition, got %q", value)
|
||||
}
|
||||
|
||||
if value := downloadRecorder.Header().Get("Content-Type"); !strings.Contains(value, "text/plain") {
|
||||
t.Fatalf("expected sniffed text content type, got %q", value)
|
||||
}
|
||||
|
||||
blockedDownloadRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/releases/3/download", "Bearer "+keyResult.RawKey)
|
||||
if blockedDownloadRecorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected blocked release download to return 404, got %d with body %s", blockedDownloadRecorder.Code, blockedDownloadRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAPIRejectsMissingPermissionDisabledAndExpiredKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, store := newTestRouterWithStore(t)
|
||||
sessionCookie := loginAsAdmin(t, router)
|
||||
|
||||
projectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
}, http.StatusSeeOther, sessionCookie), "/admin/projects/")
|
||||
|
||||
submitMultipartForm(t, router, "/admin/projects/"+projectID+"/releases", map[string]string{
|
||||
"version": "1.0.0",
|
||||
"build": "build-1",
|
||||
"release_notes": "Initial rollout.",
|
||||
}, "artifact", "desktop-app-1.0.0.zip", []byte("desktop-release-1.0.0"), sessionCookie)
|
||||
|
||||
service := apikeys.NewService(store)
|
||||
|
||||
noPermissionKey, err := service.Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "No Download",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanUpload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create no-permission key: %v", err)
|
||||
}
|
||||
|
||||
disabledKey, err := service.Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "Disabled",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create disabled key: %v", err)
|
||||
}
|
||||
if err := service.SetActive(t.Context(), disabledKey.APIKey.ID, false); err != nil {
|
||||
t.Fatalf("disable api key: %v", err)
|
||||
}
|
||||
|
||||
expiredAt := time.Now().UTC().Add(-time.Hour)
|
||||
expiredKey, err := service.Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "Expired",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
ExpiresAt: &expiredAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create expired key: %v", err)
|
||||
}
|
||||
|
||||
missingAuthRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "")
|
||||
if missingAuthRecorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected missing auth to return 401, got %d", missingAuthRecorder.Code)
|
||||
}
|
||||
assertHeaderContains(t, missingAuthRecorder, "WWW-Authenticate", "Bearer")
|
||||
|
||||
noPermissionRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+noPermissionKey.RawKey)
|
||||
if noPermissionRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected missing permission to return 403, got %d with body %s", noPermissionRecorder.Code, noPermissionRecorder.Body.String())
|
||||
}
|
||||
|
||||
disabledRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+disabledKey.RawKey)
|
||||
if disabledRecorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected disabled key to return 401, got %d with body %s", disabledRecorder.Code, disabledRecorder.Body.String())
|
||||
}
|
||||
assertHeaderContains(t, disabledRecorder, "WWW-Authenticate", "Bearer")
|
||||
|
||||
expiredRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+expiredKey.RawKey)
|
||||
if expiredRecorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected expired key to return 401, got %d with body %s", expiredRecorder.Code, expiredRecorder.Body.String())
|
||||
}
|
||||
assertHeaderContains(t, expiredRecorder, "WWW-Authenticate", "Bearer")
|
||||
}
|
||||
|
||||
func performAPIRequest(t *testing.T, handler http.Handler, method, target, authorization string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func decodeJSONBody(t *testing.T, recorder *httptest.ResponseRecorder, target any) {
|
||||
t.Helper()
|
||||
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), target); err != nil {
|
||||
t.Fatalf("decode json response: %v", err)
|
||||
}
|
||||
}
|
||||
156
internal/http/csrf.go
Normal file
156
internal/http/csrf.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
adminCookiePath = "/admin"
|
||||
csrfFormField = "csrf_token"
|
||||
csrfHeaderName = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
type csrfTokenContextKey struct{}
|
||||
|
||||
func (h *handler) adminCSRF(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, cookie, err := h.ensureCSRFCookie(r)
|
||||
if err != nil {
|
||||
http.Error(w, "csrf setup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if cookie != nil {
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
r = r.WithContext(context.WithValue(r.Context(), csrfTokenContextKey{}, token))
|
||||
|
||||
if requiresCSRFProtection(r.Method) {
|
||||
submittedToken, err := h.submittedCSRFToken(w, r)
|
||||
if err != nil || subtle.ConstantTimeCompare([]byte(token), []byte(submittedToken)) != 1 {
|
||||
http.Error(w, "csrf validation failed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) csrfToken(r *http.Request) string {
|
||||
if token, ok := r.Context().Value(csrfTokenContextKey{}).(string); ok {
|
||||
return token
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(h.config.CSRFCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !validCSRFCookieToken(cookie.Value) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
func (h *handler) issueCSRFCookie(w http.ResponseWriter) (string, error) {
|
||||
token, err := generateCSRFToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
http.SetCookie(w, h.csrfCookie(token))
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (h *handler) clearCSRFCookie() *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: h.config.CSRFCookieName,
|
||||
Value: "",
|
||||
Path: adminCookiePath,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: h.config.SecureCookies,
|
||||
Expires: time.Unix(0, 0).UTC(),
|
||||
MaxAge: -1,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) ensureCSRFCookie(r *http.Request) (string, *http.Cookie, error) {
|
||||
cookie, err := r.Cookie(h.config.CSRFCookieName)
|
||||
if err == nil && validCSRFCookieToken(cookie.Value) {
|
||||
return cookie.Value, nil, nil
|
||||
}
|
||||
|
||||
token, err := generateCSRFToken()
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("generate csrf token: %w", err)
|
||||
}
|
||||
|
||||
return token, h.csrfCookie(token), nil
|
||||
}
|
||||
|
||||
func (h *handler) csrfCookie(token string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: h.config.CSRFCookieName,
|
||||
Value: token,
|
||||
Path: adminCookiePath,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: h.config.SecureCookies,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) submittedCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||
if token := strings.TrimSpace(r.Header.Get(csrfHeaderName)); token != "" {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
contentType := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type")))
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadRequestLimit(h.config.MaxUploadBytes))
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(r.FormValue(csrfFormField)), nil
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(r.FormValue(csrfFormField)), nil
|
||||
}
|
||||
|
||||
func generateCSRFToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func requiresCSRFProtection(method string) bool {
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func validCSRFCookieToken(token string) bool {
|
||||
token = strings.TrimSpace(token)
|
||||
return len(token) >= 32
|
||||
}
|
||||
149
internal/http/handlers.go
Normal file
149
internal/http/handlers.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (h *handler) health(w http.ResponseWriter, r *http.Request) {
|
||||
payload := map[string]any{
|
||||
"status": "ok",
|
||||
"database": "ok",
|
||||
"service": h.config.AppName,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if h.store == nil {
|
||||
payload["status"] = "degraded"
|
||||
payload["database"] = "unavailable"
|
||||
payload["error"] = "database store is not initialized"
|
||||
writeJSON(w, http.StatusServiceUnavailable, payload)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.HealthCheck(r.Context()); err != nil {
|
||||
payload["status"] = "degraded"
|
||||
payload["database"] = "unavailable"
|
||||
payload["error"] = "database ping failed"
|
||||
writeJSON(w, http.StatusServiceUnavailable, payload)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (h *handler) home(w http.ResponseWriter, r *http.Request) {
|
||||
data := HomePageData{
|
||||
PageData: PageData{
|
||||
Title: "Client Update API Ready",
|
||||
Eyebrow: "Usable Product Flow",
|
||||
Heading: h.config.AppName,
|
||||
BaseURL: h.config.BaseURL,
|
||||
Description: "Protected admin flows now connect all the way through to bearer-authenticated client endpoints for project discovery, latest-release metadata, release metadata, and private artifact downloads.",
|
||||
},
|
||||
Links: []PageLink{
|
||||
{Label: "Health check", Href: "/healthz", Description: "JSON readiness endpoint covering both the HTTP server and SQLite connectivity."},
|
||||
{Label: "Admin login", Href: "/admin/login", Description: "Server-rendered login form for the protected admin workspace."},
|
||||
{Label: "Projects", Href: "/admin/projects", Description: "Create projects, attach tags, upload releases, and inspect the client-facing metadata paths from the protected admin UI."},
|
||||
{Label: "Tags", Href: "/admin/tags", Description: "Manage reusable tags that projects can use from day one."},
|
||||
{Label: "API keys", Href: "/admin/api-keys", Description: "Generate hashed API keys, reveal the raw key once, preview accessible projects, and copy the client API quick-start flow."},
|
||||
{Label: "Client API", Href: "/api/v1", Description: "Versioned JSON API entrypoint documenting the bearer-authenticated project, metadata, and download endpoints."},
|
||||
},
|
||||
}
|
||||
|
||||
renderPage(w, h.renderer, "home", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminHome(w http.ResponseWriter, r *http.Request) {
|
||||
projects, err := h.store.Projects.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "project summary lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tags, err := h.store.Tags.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "tag summary lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
apiKeys, err := h.store.APIKeys.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "api key summary lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
totalReleases := 0
|
||||
for _, item := range projects {
|
||||
totalReleases += item.ReleaseCount
|
||||
}
|
||||
|
||||
data := DashboardPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"Admin Dashboard",
|
||||
"Protected Admin",
|
||||
"Admin Dashboard",
|
||||
"Projects, tags, releases, and API keys now feed the client update API so admins can complete the end-to-end product flow in one workspace.",
|
||||
),
|
||||
Metrics: []DashboardMetric{
|
||||
{Label: "Projects", Value: strconv.Itoa(len(projects)), Description: "Active and archived projects available for management."},
|
||||
{Label: "Tags", Value: strconv.Itoa(len(tags)), Description: "Reusable labels ready for project assignment and future access scopes."},
|
||||
{Label: "Releases", Value: strconv.Itoa(totalReleases), Description: "Stored release records with checksum and disk path metadata."},
|
||||
{Label: "API Keys", Value: strconv.Itoa(len(apiKeys)), Description: "Hashed client credentials with permission flags and scope rules."},
|
||||
},
|
||||
Links: []PageLink{
|
||||
{Label: "Manage projects", Href: "/admin/projects", Description: "Open project detail pages to edit metadata, attach tags, upload releases, and inspect client metadata or download paths."},
|
||||
{Label: "Manage tags", Href: "/admin/tags", Description: "Create tags once and reuse them across projects from the same protected UI."},
|
||||
{Label: "Manage API keys", Href: "/admin/api-keys", Description: "Generate, revoke, and scope client credentials, then copy the bearer-authenticated client API flow."},
|
||||
{Label: "Client API", Href: "/api/v1", Description: "Public JSON index for accessible projects, latest release metadata, release metadata, and authenticated downloads."},
|
||||
},
|
||||
}
|
||||
|
||||
renderPage(w, h.renderer, "admin", data)
|
||||
}
|
||||
|
||||
func (h *handler) apiIndex(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"service": h.config.AppName,
|
||||
"version": "v1",
|
||||
"status": "client-api-ready",
|
||||
"auth": map[string]any{
|
||||
"type": "bearer",
|
||||
"header": "Authorization: Bearer <api_key>",
|
||||
},
|
||||
"routes": []map[string]string{
|
||||
{
|
||||
"method": http.MethodGet,
|
||||
"path": "/api/v1/projects",
|
||||
"description": "List active projects accessible to the API key.",
|
||||
},
|
||||
{
|
||||
"method": http.MethodGet,
|
||||
"path": "/api/v1/projects/{projectSlug}/releases/latest",
|
||||
"description": "Get the latest active release metadata for an accessible project.",
|
||||
},
|
||||
{
|
||||
"method": http.MethodGet,
|
||||
"path": "/api/v1/releases/{releaseID}",
|
||||
"description": "Get release metadata for an accessible release.",
|
||||
},
|
||||
{
|
||||
"method": http.MethodGet,
|
||||
"path": "/api/v1/releases/{releaseID}/download",
|
||||
"description": "Download the private artifact for an accessible release.",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func renderPage(w http.ResponseWriter, renderer *Renderer, name string, data any) {
|
||||
renderPageStatus(w, renderer, name, http.StatusOK, data)
|
||||
}
|
||||
|
||||
func renderPageStatus(w http.ResponseWriter, renderer *Renderer, name string, status int, data any) {
|
||||
if err := renderer.Render(w, name, status, data); err != nil {
|
||||
http.Error(w, "template rendering failed", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
29
internal/http/middleware.go
Normal file
29
internal/http/middleware.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
startedAt := time.Now()
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
logger.Info("request completed",
|
||||
"request_id", middleware.GetReqID(r.Context()),
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.Status(),
|
||||
"bytes", ww.BytesWritten(),
|
||||
"duration", time.Since(startedAt).String(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
323
internal/http/projects_integration_test.go
Normal file
323
internal/http/projects_integration_test.go
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
package httpserver_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminProjectTagAndReleaseFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, cfg, store := newTestRouterWithStore(t)
|
||||
|
||||
sessionCookie := loginAsAdmin(t, router)
|
||||
|
||||
projectLocation := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {""},
|
||||
"description": {"Primary desktop updater stream."},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
projectID := extractResourceID(t, projectLocation, "/admin/projects/")
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID, url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
"description": {"Primary desktop updater stream with release uploads."},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
tagLocation := submitForm(t, router, http.MethodPost, "/admin/tags", url.Values{
|
||||
"name": {"Windows Stable"},
|
||||
"slug": {""},
|
||||
"description": {"Windows production releases."},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
tagID := extractResourceID(t, tagLocation, "/admin/tags/")
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID+"/tags", url.Values{
|
||||
"tag_id": {tagID},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
artifactBody := []byte("release-payload-1.0.0")
|
||||
uploadLocation := submitMultipartForm(t, router, "/admin/projects/"+projectID+"/releases", map[string]string{
|
||||
"version": "1.0.0",
|
||||
"build": "build-42",
|
||||
"release_notes": "Initial Windows stable rollout.",
|
||||
}, "artifact", "..\\Desktop App 1.0.0.zip", artifactBody, sessionCookie)
|
||||
|
||||
if !strings.Contains(uploadLocation, "status=release-uploaded") {
|
||||
t.Fatalf("expected release upload redirect status, got %q", uploadLocation)
|
||||
}
|
||||
|
||||
projectIDInt := mustParseInt64(t, projectID)
|
||||
project, err := store.Projects.GetByID(context.Background(), projectIDInt)
|
||||
if err != nil {
|
||||
t.Fatalf("load project: %v", err)
|
||||
}
|
||||
|
||||
if project.Slug != "desktop-app" {
|
||||
t.Fatalf("expected auto-generated project slug to persist, got %q", project.Slug)
|
||||
}
|
||||
|
||||
if !project.IsActive {
|
||||
t.Fatal("expected project to remain active until the archive action runs")
|
||||
}
|
||||
|
||||
projectTags, err := store.Projects.ListTags(context.Background(), project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load project tags: %v", err)
|
||||
}
|
||||
|
||||
if len(projectTags) != 1 || projectTags[0].Slug != "windows-stable" {
|
||||
t.Fatalf("expected one attached tag, got %+v", projectTags)
|
||||
}
|
||||
|
||||
releases, err := store.Releases.ListByProjectID(context.Background(), project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load releases: %v", err)
|
||||
}
|
||||
|
||||
if len(releases) != 1 {
|
||||
t.Fatalf("expected one release, got %d", len(releases))
|
||||
}
|
||||
|
||||
release := releases[0].Release
|
||||
if release.Filename != "desktop-app-1.0.0.zip" {
|
||||
t.Fatalf("expected sanitized filename, got %q", release.Filename)
|
||||
}
|
||||
|
||||
expectedChecksum := sha256.Sum256(artifactBody)
|
||||
if release.ChecksumSHA256 != hex.EncodeToString(expectedChecksum[:]) {
|
||||
t.Fatalf("expected checksum %s, got %s", hex.EncodeToString(expectedChecksum[:]), release.ChecksumSHA256)
|
||||
}
|
||||
|
||||
if release.StoragePath != "desktop-app/1.0.0/build-42/desktop-app-1.0.0.zip" {
|
||||
t.Fatalf("unexpected storage path %q", release.StoragePath)
|
||||
}
|
||||
|
||||
artifactPath := filepath.Join(cfg.ArtifactsDir, filepath.FromSlash(release.StoragePath))
|
||||
if strings.HasPrefix(artifactPath, cfg.StaticDir) {
|
||||
t.Fatalf("artifact path %q should not be inside static dir %q", artifactPath, cfg.StaticDir)
|
||||
}
|
||||
|
||||
storedArtifact, err := os.ReadFile(artifactPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read artifact from disk: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(storedArtifact, artifactBody) {
|
||||
t.Fatal("stored artifact body did not match uploaded payload")
|
||||
}
|
||||
|
||||
recorder := performRequest(t, router, http.MethodGet, "/admin/projects/"+projectID, nil, sessionCookie)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected project detail page to load, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
projectBody := recorder.Body.String()
|
||||
if !strings.Contains(projectBody, "/api/v1/projects/desktop-app/releases/latest") {
|
||||
t.Fatal("expected project detail page to show latest release metadata endpoint")
|
||||
}
|
||||
|
||||
if !strings.Contains(projectBody, "/api/v1/releases/1/download") {
|
||||
t.Fatal("expected project detail page to show release download endpoint")
|
||||
}
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID+"/archive", url.Values{
|
||||
"state": {"archive"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
project, err = store.Projects.GetByID(context.Background(), projectIDInt)
|
||||
if err != nil {
|
||||
t.Fatalf("reload project after archive: %v", err)
|
||||
}
|
||||
|
||||
if project.IsActive {
|
||||
t.Fatal("expected archived project to be inactive")
|
||||
}
|
||||
|
||||
recorder = performRequest(t, router, http.MethodGet, "/admin/projects", nil, sessionCookie)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected projects page to load, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(recorder.Body.String(), "Desktop App") {
|
||||
t.Fatal("expected projects page to list the created project")
|
||||
}
|
||||
|
||||
recorder = performRequest(t, router, http.MethodGet, "/admin/tags", nil, sessionCookie)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected tags page to load, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(recorder.Body.String(), "Windows Stable") {
|
||||
t.Fatal("expected tags page to list the created tag")
|
||||
}
|
||||
}
|
||||
|
||||
func loginAsAdmin(t *testing.T, router http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router)
|
||||
recorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{
|
||||
"email": {"admin@example.com"},
|
||||
"password": {"correct horse battery staple"},
|
||||
"next": {"/admin"},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}, csrfCookie)
|
||||
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected login redirect, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
for _, cookie := range recorder.Result().Cookies() {
|
||||
if cookie.Name == "update_server_session" {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatal("expected session cookie after login")
|
||||
return nil
|
||||
}
|
||||
|
||||
func submitForm(t *testing.T, router http.Handler, method, target string, form url.Values, expectedStatus int, cookies ...*http.Cookie) string {
|
||||
t.Helper()
|
||||
|
||||
if strings.HasPrefix(target, "/admin/") {
|
||||
csrfCookie := ensureCSRFCookie(t, router, cookies...)
|
||||
cookies = upsertCookie(cookies, csrfCookie)
|
||||
if form == nil {
|
||||
form = url.Values{}
|
||||
}
|
||||
if form.Get("csrf_token") == "" {
|
||||
form.Set("csrf_token", csrfCookie.Value)
|
||||
}
|
||||
}
|
||||
|
||||
recorder := performRequest(t, router, method, target, form, cookies...)
|
||||
if recorder.Code != expectedStatus {
|
||||
t.Fatalf("expected %d for %s %s, got %d with body %s", expectedStatus, method, target, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
return recorder.Header().Get("Location")
|
||||
}
|
||||
|
||||
func submitMultipartForm(t *testing.T, router http.Handler, target string, fields map[string]string, fileField, filename string, body []byte, cookies ...*http.Cookie) string {
|
||||
t.Helper()
|
||||
|
||||
if strings.HasPrefix(target, "/admin/") {
|
||||
csrfCookie := ensureCSRFCookie(t, router, cookies...)
|
||||
cookies = upsertCookie(cookies, csrfCookie)
|
||||
if fields == nil {
|
||||
fields = map[string]string{}
|
||||
}
|
||||
if fields["csrf_token"] == "" {
|
||||
fields["csrf_token"] = csrfCookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
var requestBody bytes.Buffer
|
||||
writer := multipart.NewWriter(&requestBody)
|
||||
for key, value := range fields {
|
||||
if err := writer.WriteField(key, value); err != nil {
|
||||
t.Fatalf("write multipart field %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
part, err := writer.CreateFormFile(fileField, filename)
|
||||
if err != nil {
|
||||
t.Fatalf("create multipart file part: %v", err)
|
||||
}
|
||||
|
||||
if _, err := part.Write(body); err != nil {
|
||||
t.Fatalf("write multipart file body: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, target, &requestBody)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
for _, cookie := range cookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected multipart upload redirect, got %d with body %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
return recorder.Header().Get("Location")
|
||||
}
|
||||
|
||||
func upsertCookie(cookies []*http.Cookie, cookie *http.Cookie) []*http.Cookie {
|
||||
if cookie == nil {
|
||||
return cookies
|
||||
}
|
||||
|
||||
updated := make([]*http.Cookie, 0, len(cookies)+1)
|
||||
replaced := false
|
||||
for _, existing := range cookies {
|
||||
if existing == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if existing.Name == cookie.Name {
|
||||
updated = append(updated, cookie)
|
||||
replaced = true
|
||||
continue
|
||||
}
|
||||
|
||||
updated = append(updated, existing)
|
||||
}
|
||||
|
||||
if !replaced {
|
||||
updated = append(updated, cookie)
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
func extractResourceID(t *testing.T, location, prefix string) string {
|
||||
t.Helper()
|
||||
|
||||
if !strings.HasPrefix(location, prefix) {
|
||||
t.Fatalf("expected redirect location with prefix %q, got %q", prefix, location)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimPrefix(location, prefix)
|
||||
parts := strings.SplitN(trimmed, "?", 2)
|
||||
if parts[0] == "" {
|
||||
t.Fatalf("could not extract resource id from %q", location)
|
||||
}
|
||||
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func mustParseInt64(t *testing.T, raw string) int64 {
|
||||
t.Helper()
|
||||
|
||||
value, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unescape id %q: %v", raw, err)
|
||||
}
|
||||
|
||||
var parsed int64
|
||||
if _, err := fmt.Sscan(value, &parsed); err != nil {
|
||||
t.Fatalf("parse id %q: %v", raw, err)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
139
internal/http/rate_limit.go
Normal file
139
internal/http/rate_limit.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rateLimitStore struct {
|
||||
mu sync.Mutex
|
||||
ratePerSec float64
|
||||
burst float64
|
||||
lastCleanup time.Time
|
||||
entries map[string]*rateLimitEntry
|
||||
}
|
||||
|
||||
type rateLimitEntry struct {
|
||||
tokens float64
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
func newRateLimitStore(perMinute, burst int) *rateLimitStore {
|
||||
return &rateLimitStore{
|
||||
ratePerSec: float64(perMinute) / 60,
|
||||
burst: float64(burst),
|
||||
entries: make(map[string]*rateLimitEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *rateLimitStore) Allow(key string, now time.Time) (bool, time.Duration) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
key = "global"
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if entry, ok := s.entries[key]; ok {
|
||||
elapsed := now.Sub(entry.lastSeen).Seconds()
|
||||
entry.tokens = math.Min(s.burst, entry.tokens+(elapsed*s.ratePerSec))
|
||||
entry.lastSeen = now
|
||||
if entry.tokens >= 1 {
|
||||
entry.tokens--
|
||||
s.cleanup(now)
|
||||
return true, 0
|
||||
}
|
||||
|
||||
s.cleanup(now)
|
||||
return false, retryAfter(entry.tokens, s.ratePerSec)
|
||||
}
|
||||
|
||||
s.entries[key] = &rateLimitEntry{
|
||||
tokens: s.burst - 1,
|
||||
lastSeen: now,
|
||||
}
|
||||
s.cleanup(now)
|
||||
return true, 0
|
||||
}
|
||||
|
||||
func (s *rateLimitStore) cleanup(now time.Time) {
|
||||
if !s.lastCleanup.IsZero() && now.Sub(s.lastCleanup) < 5*time.Minute {
|
||||
return
|
||||
}
|
||||
|
||||
for key, entry := range s.entries {
|
||||
if now.Sub(entry.lastSeen) > 15*time.Minute {
|
||||
delete(s.entries, key)
|
||||
}
|
||||
}
|
||||
|
||||
s.lastCleanup = now
|
||||
}
|
||||
|
||||
func retryAfter(tokens, ratePerSec float64) time.Duration {
|
||||
if ratePerSec <= 0 {
|
||||
return time.Minute
|
||||
}
|
||||
|
||||
if tokens < 0 {
|
||||
tokens = 0
|
||||
}
|
||||
|
||||
seconds := (1 - tokens) / ratePerSec
|
||||
if seconds < 1 {
|
||||
seconds = 1
|
||||
}
|
||||
|
||||
return time.Duration(math.Ceil(seconds * float64(time.Second)))
|
||||
}
|
||||
|
||||
func retryAfterHeader(delay time.Duration) string {
|
||||
seconds := int(math.Ceil(delay.Seconds()))
|
||||
if seconds < 1 {
|
||||
seconds = 1
|
||||
}
|
||||
|
||||
return strconv.Itoa(seconds)
|
||||
}
|
||||
|
||||
func (h *handler) loginRateLimit(next http.Handler) http.Handler {
|
||||
return h.rateLimit(h.loginRateLimiter, func(r *http.Request) string {
|
||||
return clientIP(r)
|
||||
}, func(w http.ResponseWriter, r *http.Request, delay time.Duration) {
|
||||
w.Header().Set("Retry-After", retryAfterHeader(delay))
|
||||
http.Error(w, "too many login attempts", http.StatusTooManyRequests)
|
||||
})(next)
|
||||
}
|
||||
|
||||
func (h *handler) clientAPIRateLimit(next http.Handler) http.Handler {
|
||||
return h.rateLimit(h.clientRateLimiter, func(r *http.Request) string {
|
||||
return clientIP(r)
|
||||
}, func(w http.ResponseWriter, r *http.Request, delay time.Duration) {
|
||||
w.Header().Set("Retry-After", retryAfterHeader(delay))
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]any{"error": "rate limit exceeded"})
|
||||
})(next)
|
||||
}
|
||||
|
||||
func (h *handler) rateLimit(store *rateLimitStore, keyFn func(*http.Request) string, reject func(http.ResponseWriter, *http.Request, time.Duration)) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := clientIP(r)
|
||||
if keyFn != nil {
|
||||
key = keyFn(r)
|
||||
}
|
||||
|
||||
allowed, delay := store.Allow(key, time.Now().UTC())
|
||||
if !allowed {
|
||||
reject(w, r, delay)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
78
internal/http/render.go
Normal file
78
internal/http/render.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
func NewRenderer(templatesDir string) (*Renderer, error) {
|
||||
layouts, err := filepath.Glob(filepath.Join(templatesDir, "layouts", "*.gohtml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find layouts: %w", err)
|
||||
}
|
||||
|
||||
if len(layouts) == 0 {
|
||||
return nil, fmt.Errorf("no layout templates found in %s", filepath.Join(templatesDir, "layouts"))
|
||||
}
|
||||
|
||||
partials, err := filepath.Glob(filepath.Join(templatesDir, "partials", "*.gohtml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find partials: %w", err)
|
||||
}
|
||||
|
||||
pages, err := filepath.Glob(filepath.Join(templatesDir, "pages", "*.gohtml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find pages: %w", err)
|
||||
}
|
||||
|
||||
if len(pages) == 0 {
|
||||
return nil, fmt.Errorf("no page templates found in %s", filepath.Join(templatesDir, "pages"))
|
||||
}
|
||||
|
||||
renderer := &Renderer{templates: make(map[string]*template.Template, len(pages))}
|
||||
|
||||
for _, page := range pages {
|
||||
files := append([]string{}, layouts...)
|
||||
files = append(files, partials...)
|
||||
files = append(files, page)
|
||||
|
||||
tmpl, err := template.ParseFiles(files...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse template set for %s: %w", page, err)
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(filepath.Base(page), filepath.Ext(page))
|
||||
renderer.templates[name] = tmpl
|
||||
}
|
||||
|
||||
return renderer, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) Render(w http.ResponseWriter, name string, status int, data any) error {
|
||||
tmpl, ok := r.templates[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown template %q", name)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&output, "base", data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if status <= 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
|
||||
_, err := output.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
15
internal/http/response.go
Normal file
15
internal/http/response.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
http.Error(w, "json encoding failed", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
113
internal/http/router.go
Normal file
113
internal/http/router.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
authservice "update_server/internal/auth"
|
||||
"update_server/internal/config"
|
||||
"update_server/internal/db"
|
||||
"update_server/internal/releases"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
config config.Config
|
||||
store *db.Store
|
||||
logger *slog.Logger
|
||||
renderer *Renderer
|
||||
auth *authservice.Service
|
||||
apiKeys *apikeys.Service
|
||||
releases *releases.Service
|
||||
loginRateLimiter *rateLimitStore
|
||||
clientRateLimiter *rateLimitStore
|
||||
}
|
||||
|
||||
func NewRouter(cfg config.Config, logger *slog.Logger, renderer *Renderer, store *db.Store, auth *authservice.Service, apiKeyService *apikeys.Service, releaseService *releases.Service) http.Handler {
|
||||
h := &handler{
|
||||
config: cfg,
|
||||
store: store,
|
||||
logger: logger,
|
||||
renderer: renderer,
|
||||
auth: auth,
|
||||
apiKeys: apiKeyService,
|
||||
releases: releaseService,
|
||||
loginRateLimiter: newRateLimitStore(cfg.LoginRateLimitPerMinute, cfg.LoginRateLimitBurst),
|
||||
clientRateLimiter: newRateLimitStore(cfg.ClientRateLimitPerMinute, cfg.ClientRateLimitBurst),
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.RequestID)
|
||||
if cfg.TrustProxyHeaders {
|
||||
router.Use(middleware.RealIP)
|
||||
}
|
||||
router.Use(requestLogger(logger))
|
||||
router.Use(middleware.Recoverer)
|
||||
router.Use(middleware.StripSlashes)
|
||||
router.Use(securityHeaders(cfg))
|
||||
|
||||
router.Get("/healthz", h.health)
|
||||
router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir(cfg.StaticDir))))
|
||||
router.Get("/", h.home)
|
||||
|
||||
router.Route("/admin", func(r chi.Router) {
|
||||
r.Use(adminResponseHeaders)
|
||||
r.Use(h.adminCSRF)
|
||||
|
||||
r.Get("/login", h.adminLoginForm)
|
||||
r.With(h.loginRateLimit).Post("/login", h.adminLogin)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(h.requireAuthenticatedSession)
|
||||
r.Use(h.requireRole(db.UserRoleAdmin))
|
||||
|
||||
r.Get("/", h.adminHome)
|
||||
r.Post("/logout", h.adminLogout)
|
||||
r.Get("/projects", h.adminProjects)
|
||||
r.Get("/projects/new", h.adminProjectNew)
|
||||
r.Post("/projects", h.adminProjectCreate)
|
||||
r.Get("/projects/{projectID}", h.adminProjectDetail)
|
||||
r.Post("/projects/{projectID}", h.adminProjectUpdate)
|
||||
r.Post("/projects/{projectID}/archive", h.adminProjectArchive)
|
||||
r.Post("/projects/{projectID}/tags", h.adminProjectAttachTag)
|
||||
r.Post("/projects/{projectID}/tags/{tagID}/detach", h.adminProjectDetachTag)
|
||||
r.Post("/projects/{projectID}/releases", h.adminProjectUploadRelease)
|
||||
r.Get("/tags", h.adminTags)
|
||||
r.Get("/tags/new", h.adminTagNew)
|
||||
r.Post("/tags", h.adminTagCreate)
|
||||
r.Get("/tags/{tagID}", h.adminTagDetail)
|
||||
r.Post("/tags/{tagID}", h.adminTagUpdate)
|
||||
r.Post("/tags/{tagID}/delete", h.adminTagDelete)
|
||||
r.Get("/api-keys", h.adminAPIKeys)
|
||||
r.Get("/api-keys/new", h.adminAPIKeyNew)
|
||||
r.Post("/api-keys", h.adminAPIKeyCreate)
|
||||
r.Get("/api-keys/{apiKeyID}", h.adminAPIKeyDetail)
|
||||
r.Post("/api-keys/{apiKeyID}", h.adminAPIKeyUpdate)
|
||||
r.Post("/api-keys/{apiKeyID}/activate", h.adminAPIKeyToggleActive)
|
||||
})
|
||||
})
|
||||
|
||||
router.Route("/api", func(r chi.Router) {
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Use(apiResponseHeaders)
|
||||
r.Get("/", h.apiIndex)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(protectedAPIResponseHeaders)
|
||||
r.Use(h.clientAPIRateLimit)
|
||||
r.Use(h.requireAPIKey)
|
||||
r.Use(h.requireAPIKeyPermission(apikeys.PermissionDownload))
|
||||
|
||||
r.Get("/projects", h.apiAccessibleProjects)
|
||||
r.Get("/projects/{projectSlug}/releases/latest", h.apiLatestRelease)
|
||||
r.Get("/releases/{releaseID}", h.apiReleaseMetadata)
|
||||
r.Get("/releases/{releaseID}/download", h.apiReleaseDownload)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
80
internal/http/security.go
Normal file
80
internal/http/security.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"update_server/internal/config"
|
||||
)
|
||||
|
||||
const contentSecurityPolicy = "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'"
|
||||
|
||||
func securityHeaders(cfg config.Config) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
headers := w.Header()
|
||||
headers.Set("Content-Security-Policy", contentSecurityPolicy)
|
||||
headers.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
headers.Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||
headers.Set("Permissions-Policy", "camera=(), geolocation=(), microphone=()")
|
||||
headers.Set("Referrer-Policy", "no-referrer")
|
||||
headers.Set("X-Content-Type-Options", "nosniff")
|
||||
headers.Set("X-Frame-Options", "DENY")
|
||||
|
||||
if cfg.SecureCookies {
|
||||
headers.Set("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func adminResponseHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
applyNoStoreHeaders(w)
|
||||
addVaryHeader(w, "Cookie")
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func apiResponseHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func protectedAPIResponseHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
applyNoStoreHeaders(w)
|
||||
addVaryHeader(w, "Authorization")
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func applyNoStoreHeaders(w http.ResponseWriter) {
|
||||
w.Header().Set("Cache-Control", "no-store, private, max-age=0")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
}
|
||||
|
||||
func addVaryHeader(w http.ResponseWriter, value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
|
||||
current := w.Header().Values("Vary")
|
||||
for _, entry := range current {
|
||||
for _, existing := range strings.Split(entry, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(existing), value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Vary", value)
|
||||
}
|
||||
194
internal/http/security_integration_test.go
Normal file
194
internal/http/security_integration_test.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package httpserver_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/config"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func TestAdminPOSTRejectsMissingOrInvalidCSRFToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, _ := newTestRouterWithStore(t)
|
||||
sessionCookie := loginAsAdmin(t, router)
|
||||
|
||||
missingRecorder := performRequest(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
}, sessionCookie)
|
||||
if missingRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected missing csrf token to return 403, got %d with body %s", missingRecorder.Code, missingRecorder.Body.String())
|
||||
}
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router, sessionCookie)
|
||||
invalidRecorder := performRequest(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
"csrf_token": {"definitely-wrong"},
|
||||
}, sessionCookie, csrfCookie)
|
||||
if invalidRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected invalid csrf token to return 403, got %d with body %s", invalidRecorder.Code, invalidRecorder.Body.String())
|
||||
}
|
||||
|
||||
location := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
if location == "" {
|
||||
t.Fatal("expected valid csrf-protected form to redirect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersAndCookieDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, cfg, store := newTestRouterWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.BaseURL = "https://updates.example.com"
|
||||
cfg.SecureCookies = true
|
||||
})
|
||||
|
||||
loginPageRecorder := performRequest(t, router, http.MethodGet, "/admin/login", nil)
|
||||
if loginPageRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected login page to load, got %d", loginPageRecorder.Code)
|
||||
}
|
||||
|
||||
assertHeaderContains(t, loginPageRecorder, "Cache-Control", "no-store")
|
||||
assertHeaderEquals(t, loginPageRecorder, "X-Frame-Options", "DENY")
|
||||
assertHeaderEquals(t, loginPageRecorder, "X-Content-Type-Options", "nosniff")
|
||||
assertHeaderEquals(t, loginPageRecorder, "Referrer-Policy", "no-referrer")
|
||||
assertHeaderContains(t, loginPageRecorder, "Content-Security-Policy", "frame-ancestors 'none'")
|
||||
assertHeaderContains(t, loginPageRecorder, "Strict-Transport-Security", "max-age=31536000")
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router)
|
||||
if csrfCookie.Path != "/admin" {
|
||||
t.Fatalf("expected csrf cookie path /admin, got %q", csrfCookie.Path)
|
||||
}
|
||||
if !csrfCookie.HttpOnly {
|
||||
t.Fatal("expected csrf cookie to be HttpOnly")
|
||||
}
|
||||
if !csrfCookie.Secure {
|
||||
t.Fatal("expected csrf cookie to be Secure when APP_BASE_URL is https")
|
||||
}
|
||||
if csrfCookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("expected csrf cookie SameSite=Strict, got %v", csrfCookie.SameSite)
|
||||
}
|
||||
|
||||
loginRecorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{
|
||||
"email": {"admin@example.com"},
|
||||
"password": {"correct horse battery staple"},
|
||||
"next": {"/admin"},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}, csrfCookie)
|
||||
if loginRecorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected login to redirect, got %d with body %s", loginRecorder.Code, loginRecorder.Body.String())
|
||||
}
|
||||
|
||||
var sessionCookie *http.Cookie
|
||||
for _, cookie := range loginRecorder.Result().Cookies() {
|
||||
if cookie.Name == cfg.SessionCookieName {
|
||||
sessionCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("expected session cookie after login")
|
||||
}
|
||||
if sessionCookie.Path != "/admin" {
|
||||
t.Fatalf("expected session cookie path /admin, got %q", sessionCookie.Path)
|
||||
}
|
||||
if !sessionCookie.HttpOnly {
|
||||
t.Fatal("expected session cookie to be HttpOnly")
|
||||
}
|
||||
if !sessionCookie.Secure {
|
||||
t.Fatal("expected session cookie to be Secure")
|
||||
}
|
||||
if sessionCookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("expected session cookie SameSite=Lax, got %v", sessionCookie.SameSite)
|
||||
}
|
||||
|
||||
keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "Clients",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create api key: %v", err)
|
||||
}
|
||||
|
||||
apiRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey)
|
||||
if apiRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected api project listing to succeed, got %d with body %s", apiRecorder.Code, apiRecorder.Body.String())
|
||||
}
|
||||
|
||||
assertHeaderContains(t, apiRecorder, "Cache-Control", "no-store")
|
||||
assertHeaderContains(t, apiRecorder, "Vary", "Authorization")
|
||||
assertHeaderEquals(t, apiRecorder, "X-Content-Type-Options", "nosniff")
|
||||
assertHeaderEquals(t, apiRecorder, "X-Frame-Options", "DENY")
|
||||
assertHeaderContains(t, apiRecorder, "X-Robots-Tag", "noindex")
|
||||
}
|
||||
|
||||
func TestLoginRateLimitReturnsTooManyRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, _ := newTestRouterWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.LoginRateLimitPerMinute = 60
|
||||
cfg.LoginRateLimitBurst = 2
|
||||
})
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router)
|
||||
form := url.Values{
|
||||
"email": {"admin@example.com"},
|
||||
"password": {"wrong-password"},
|
||||
"next": {"/admin"},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
recorder := performRequest(t, router, http.MethodPost, "/admin/login", form, csrfCookie)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected attempt %d to return 401, got %d with body %s", attempt+1, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
limitedRecorder := performRequest(t, router, http.MethodPost, "/admin/login", form, csrfCookie)
|
||||
if limitedRecorder.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected limited login to return 429, got %d with body %s", limitedRecorder.Code, limitedRecorder.Body.String())
|
||||
}
|
||||
assertHeaderContains(t, limitedRecorder, "Retry-After", "1")
|
||||
}
|
||||
|
||||
func TestClientAPIRateLimitReturnsTooManyRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, store := newTestRouterWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.ClientRateLimitPerMinute = 60
|
||||
cfg.ClientRateLimitBurst = 2
|
||||
})
|
||||
|
||||
keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "Clients",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create api key: %v", err)
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
recorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected api attempt %d to return 200, got %d with body %s", attempt+1, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
limitedRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey)
|
||||
if limitedRecorder.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected api rate limit to return 429, got %d with body %s", limitedRecorder.Code, limitedRecorder.Body.String())
|
||||
}
|
||||
assertHeaderContains(t, limitedRecorder, "Retry-After", "1")
|
||||
}
|
||||
165
internal/http/view_data.go
Normal file
165
internal/http/view_data.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package httpserver
|
||||
|
||||
import "update_server/internal/db"
|
||||
|
||||
type PageData struct {
|
||||
Title string
|
||||
Eyebrow string
|
||||
Heading string
|
||||
Description string
|
||||
BaseURL string
|
||||
CSRFToken string
|
||||
CurrentUser *db.User
|
||||
Flash *FlashMessage
|
||||
}
|
||||
|
||||
type FlashMessage struct {
|
||||
Kind string
|
||||
Message string
|
||||
}
|
||||
|
||||
type PageLink struct {
|
||||
Label string
|
||||
Href string
|
||||
Description string
|
||||
}
|
||||
|
||||
type HomePageData struct {
|
||||
PageData
|
||||
Links []PageLink
|
||||
}
|
||||
|
||||
type DashboardMetric struct {
|
||||
Label string
|
||||
Value string
|
||||
Description string
|
||||
}
|
||||
|
||||
type DashboardPageData struct {
|
||||
PageData
|
||||
Links []PageLink
|
||||
Metrics []DashboardMetric
|
||||
}
|
||||
|
||||
type LoginFormData struct {
|
||||
Action string
|
||||
Email string
|
||||
Next string
|
||||
Error string
|
||||
SetupHint string
|
||||
}
|
||||
|
||||
type LoginPageData struct {
|
||||
PageData
|
||||
Login LoginFormData
|
||||
}
|
||||
|
||||
type ProjectsPageData struct {
|
||||
PageData
|
||||
Projects []db.ProjectListItem
|
||||
}
|
||||
|
||||
type ProjectFormData struct {
|
||||
Action string
|
||||
SubmitLabel string
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
Error string
|
||||
}
|
||||
|
||||
type ReleaseUploadData struct {
|
||||
Action string
|
||||
Version string
|
||||
Build string
|
||||
ReleaseNotes string
|
||||
Error string
|
||||
MaxUploadMB int64
|
||||
}
|
||||
|
||||
type ProjectFormPageData struct {
|
||||
PageData
|
||||
Form ProjectFormData
|
||||
}
|
||||
|
||||
type ProjectDetailPageData struct {
|
||||
PageData
|
||||
Project db.Project
|
||||
Form ProjectFormData
|
||||
Tags []db.Tag
|
||||
AvailableTags []db.Tag
|
||||
Releases []db.ReleaseListItem
|
||||
LatestRelease *db.Release
|
||||
AttachTagAction string
|
||||
ArchiveAction string
|
||||
ArchiveState string
|
||||
ArchiveLabel string
|
||||
Upload ReleaseUploadData
|
||||
}
|
||||
|
||||
type TagsPageData struct {
|
||||
PageData
|
||||
Tags []db.TagListItem
|
||||
}
|
||||
|
||||
type TagFormData struct {
|
||||
Action string
|
||||
SubmitLabel string
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
Error string
|
||||
DeleteAction string
|
||||
CanDelete bool
|
||||
}
|
||||
|
||||
type TagFormPageData struct {
|
||||
PageData
|
||||
Form TagFormData
|
||||
Tag *db.Tag
|
||||
Projects []db.Project
|
||||
}
|
||||
|
||||
type APIKeyProjectChoice struct {
|
||||
Project db.Project
|
||||
Selected bool
|
||||
}
|
||||
|
||||
type APIKeyTagChoice struct {
|
||||
Tag db.Tag
|
||||
Selected bool
|
||||
}
|
||||
|
||||
type APIKeysPageData struct {
|
||||
PageData
|
||||
APIKeys []db.APIKeyListItem
|
||||
}
|
||||
|
||||
type APIKeyFormData struct {
|
||||
Action string
|
||||
SubmitLabel string
|
||||
Name string
|
||||
Description string
|
||||
ScopeMode db.ScopeMode
|
||||
ExpiresAt string
|
||||
CanDownload bool
|
||||
CanUpload bool
|
||||
CanDelete bool
|
||||
CanManageProjects bool
|
||||
SelectedProjectIDs []int64
|
||||
SelectedTagIDs []int64
|
||||
Error string
|
||||
RevealKey string
|
||||
}
|
||||
|
||||
type APIKeyPageData struct {
|
||||
PageData
|
||||
APIKey *db.APIKey
|
||||
Form APIKeyFormData
|
||||
ProjectChoices []APIKeyProjectChoice
|
||||
TagChoices []APIKeyTagChoice
|
||||
AccessibleProjects []db.Project
|
||||
ToggleAction string
|
||||
ToggleState string
|
||||
ToggleLabel string
|
||||
}
|
||||
270
internal/releases/service.go
Normal file
270
internal/releases/service.go
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
package releases
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"update_server/internal/db"
|
||||
"update_server/internal/storage"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *db.Store
|
||||
artifacts *storage.LocalStore
|
||||
}
|
||||
|
||||
type UploadParams struct {
|
||||
ProjectID int64
|
||||
Version string
|
||||
Build string
|
||||
ReleaseNotes string
|
||||
OriginalFilename string
|
||||
DeclaredType string
|
||||
Reader io.Reader
|
||||
UploadedByUserID *int64
|
||||
}
|
||||
|
||||
type UploadResult struct {
|
||||
Project *db.Project
|
||||
Release *db.Release
|
||||
}
|
||||
|
||||
func NewService(store *db.Store, artifacts *storage.LocalStore) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
artifacts: artifacts,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Artifact(storagePath string) (*os.File, error) {
|
||||
file, err := s.artifacts.Open(storagePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("open artifact: %w", err)
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (s *Service) Upload(ctx context.Context, params UploadParams) (*UploadResult, error) {
|
||||
project, err := s.store.Projects.GetByID(ctx, params.ProjectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load upload project: %w", err)
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(params.Version)
|
||||
if version == "" {
|
||||
return nil, fmt.Errorf("release version is required")
|
||||
}
|
||||
|
||||
sanitizedFilename := sanitizeFilename(params.OriginalFilename)
|
||||
if sanitizedFilename == "" {
|
||||
return nil, fmt.Errorf("uploaded file name is invalid")
|
||||
}
|
||||
|
||||
tempFile, tempPath, err := s.artifacts.CreateTemp("upload-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp artifact: %w", err)
|
||||
}
|
||||
|
||||
removeTemp := func() {
|
||||
if tempFile != nil {
|
||||
_ = tempFile.Close()
|
||||
}
|
||||
_ = s.artifacts.RemoveTemp(tempPath)
|
||||
}
|
||||
|
||||
hash := sha256.New()
|
||||
multiWriter := io.MultiWriter(tempFile, hash)
|
||||
|
||||
sniffBuffer := make([]byte, 0, 512)
|
||||
copyBuffer := make([]byte, 32*1024)
|
||||
var sizeBytes int64
|
||||
|
||||
for {
|
||||
n, readErr := params.Reader.Read(copyBuffer)
|
||||
if n > 0 {
|
||||
chunk := copyBuffer[:n]
|
||||
if len(sniffBuffer) < 512 {
|
||||
remaining := 512 - len(sniffBuffer)
|
||||
if remaining > n {
|
||||
remaining = n
|
||||
}
|
||||
sniffBuffer = append(sniffBuffer, chunk[:remaining]...)
|
||||
}
|
||||
|
||||
written, writeErr := multiWriter.Write(chunk)
|
||||
sizeBytes += int64(written)
|
||||
if writeErr != nil {
|
||||
removeTemp()
|
||||
return nil, fmt.Errorf("write temp artifact: %w", writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
if readErr != nil {
|
||||
removeTemp()
|
||||
return nil, fmt.Errorf("read upload stream: %w", readErr)
|
||||
}
|
||||
}
|
||||
|
||||
if sizeBytes == 0 {
|
||||
removeTemp()
|
||||
return nil, fmt.Errorf("uploaded artifact is empty")
|
||||
}
|
||||
|
||||
if err := tempFile.Close(); err != nil {
|
||||
removeTemp()
|
||||
return nil, fmt.Errorf("close temp artifact: %w", err)
|
||||
}
|
||||
tempFile = nil
|
||||
|
||||
contentType := detectContentType(sniffBuffer, params.DeclaredType)
|
||||
storagePath := buildStoragePath(project.Slug, version, params.Build, sanitizedFilename)
|
||||
|
||||
if err := s.artifacts.CommitTemp(tempPath, storagePath); err != nil {
|
||||
removeTemp()
|
||||
return nil, fmt.Errorf("store artifact: %w", err)
|
||||
}
|
||||
|
||||
cleanupFinal := func() {
|
||||
_ = s.artifacts.Remove(storagePath)
|
||||
}
|
||||
|
||||
release, err := s.store.Releases.Create(ctx, db.CreateReleaseParams{
|
||||
ProjectID: project.ID,
|
||||
Version: version,
|
||||
Build: strings.TrimSpace(params.Build),
|
||||
Filename: sanitizedFilename,
|
||||
StoragePath: storagePath,
|
||||
ChecksumSHA256: hex.EncodeToString(hash.Sum(nil)),
|
||||
SizeBytes: sizeBytes,
|
||||
ContentType: contentType,
|
||||
ReleaseNotes: strings.TrimSpace(params.ReleaseNotes),
|
||||
UploadedByUserID: params.UploadedByUserID,
|
||||
IsActive: true,
|
||||
})
|
||||
if err != nil {
|
||||
cleanupFinal()
|
||||
return nil, fmt.Errorf("create release metadata: %w", err)
|
||||
}
|
||||
|
||||
return &UploadResult{
|
||||
Project: project,
|
||||
Release: release,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sanitizeFilename(raw string) string {
|
||||
filename := filepath.Base(strings.ReplaceAll(strings.TrimSpace(raw), "\\", "/"))
|
||||
if filename == "." || filename == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ext := filepath.Ext(filename)
|
||||
name := strings.TrimSuffix(filename, ext)
|
||||
name = sanitizePathSegment(name)
|
||||
if name == "" {
|
||||
name = "artifact"
|
||||
}
|
||||
|
||||
ext = sanitizeExtension(ext)
|
||||
return strings.Trim(name+ext, ".")
|
||||
}
|
||||
|
||||
func sanitizeExtension(ext string) string {
|
||||
if ext == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ext = strings.ToLower(ext)
|
||||
var builder strings.Builder
|
||||
for _, r := range ext {
|
||||
switch {
|
||||
case r == '.':
|
||||
builder.WriteRune(r)
|
||||
case unicode.IsLetter(r), unicode.IsDigit(r):
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
if builder.Len() <= 1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func sanitizePathSegment(raw string) string {
|
||||
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||
var builder strings.Builder
|
||||
lastDash := false
|
||||
|
||||
for _, r := range raw {
|
||||
switch {
|
||||
case unicode.IsLetter(r), unicode.IsDigit(r):
|
||||
builder.WriteRune(r)
|
||||
lastDash = false
|
||||
case r == '.', r == '_', r == '-':
|
||||
builder.WriteRune(r)
|
||||
lastDash = false
|
||||
default:
|
||||
if !lastDash && builder.Len() > 0 {
|
||||
builder.WriteRune('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value := strings.Trim(builder.String(), "-._")
|
||||
if len(value) > 160 {
|
||||
value = strings.Trim(value[:160], "-._")
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func buildStoragePath(projectSlug, version, build, filename string) string {
|
||||
versionSegment := sanitizePathSegment(version)
|
||||
if versionSegment == "" {
|
||||
versionSegment = "release"
|
||||
}
|
||||
|
||||
parts := []string{sanitizePathSegment(projectSlug), versionSegment}
|
||||
if build = sanitizePathSegment(build); build != "" {
|
||||
parts = append(parts, build)
|
||||
}
|
||||
parts = append(parts, filename)
|
||||
|
||||
return filepath.ToSlash(filepath.Join(parts...))
|
||||
}
|
||||
|
||||
func detectContentType(sniff []byte, declared string) string {
|
||||
if detected := http.DetectContentType(sniff); detected != "" && detected != "application/octet-stream" {
|
||||
return detected
|
||||
}
|
||||
|
||||
if declared = strings.TrimSpace(declared); declared != "" {
|
||||
if mediaType, _, err := mime.ParseMediaType(declared); err == nil && mediaType != "" {
|
||||
return mediaType
|
||||
}
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
}
|
||||
27
internal/slug/slug.go
Normal file
27
internal/slug/slug.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package slug
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func Make(raw string) string {
|
||||
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||
var builder strings.Builder
|
||||
lastDash := false
|
||||
|
||||
for _, r := range raw {
|
||||
switch {
|
||||
case unicode.IsLetter(r), unicode.IsDigit(r):
|
||||
builder.WriteRune(r)
|
||||
lastDash = false
|
||||
default:
|
||||
if !lastDash && builder.Len() > 0 {
|
||||
builder.WriteRune('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Trim(builder.String(), "-")
|
||||
}
|
||||
127
internal/storage/local.go
Normal file
127
internal/storage/local.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LocalStore struct {
|
||||
rootDir string
|
||||
tempDir string
|
||||
}
|
||||
|
||||
func NewLocal(rootDir string) (*LocalStore, error) {
|
||||
rootDir = filepath.Clean(rootDir)
|
||||
tempDir := filepath.Join(rootDir, ".tmp")
|
||||
|
||||
for _, dir := range []string{rootDir, tempDir} {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create artifact directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &LocalStore{
|
||||
rootDir: rootDir,
|
||||
tempDir: tempDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *LocalStore) RootDir() string {
|
||||
return s.rootDir
|
||||
}
|
||||
|
||||
func (s *LocalStore) CreateTemp(pattern string) (*os.File, string, error) {
|
||||
file, err := os.CreateTemp(s.tempDir, pattern)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
|
||||
return file, file.Name(), nil
|
||||
}
|
||||
|
||||
func (s *LocalStore) CommitTemp(tempPath, relativePath string) error {
|
||||
destination, err := s.absolutePath(relativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil {
|
||||
return fmt.Errorf("create artifact parent directory: %w", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(destination); err == nil {
|
||||
return fmt.Errorf("artifact already exists at %s", relativePath)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("check artifact destination: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tempPath, destination); err != nil {
|
||||
return fmt.Errorf("move artifact into place: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LocalStore) Remove(relativePath string) error {
|
||||
destination, err := s.absolutePath(relativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Remove(destination); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove artifact: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LocalStore) RemoveTemp(tempPath string) error {
|
||||
if strings.TrimSpace(tempPath) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.Remove(tempPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove temp artifact: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LocalStore) Open(relativePath string) (*os.File, error) {
|
||||
destination, err := s.absolutePath(relativePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Open(destination)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open artifact: %w", err)
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (s *LocalStore) absolutePath(relativePath string) (string, error) {
|
||||
relativePath = filepath.Clean(strings.TrimSpace(relativePath))
|
||||
if relativePath == "." || relativePath == "" {
|
||||
return "", fmt.Errorf("artifact path is required")
|
||||
}
|
||||
|
||||
if filepath.IsAbs(relativePath) {
|
||||
return "", fmt.Errorf("artifact path must be relative")
|
||||
}
|
||||
|
||||
joined := filepath.Join(s.rootDir, relativePath)
|
||||
rel, err := filepath.Rel(s.rootDir, joined)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve artifact path: %w", err)
|
||||
}
|
||||
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("artifact path escapes storage root")
|
||||
}
|
||||
|
||||
return joined, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue