This commit is contained in:
delete 2026-06-10 20:51:17 +03:00
commit b15b95781c
108 changed files with 14802 additions and 0 deletions

View 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
View 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 }

View 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
View 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
View 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
View 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
View 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[:])
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
}
}

View 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)
}

View 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
View 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
}
}

View 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})
}

View 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)
}

View 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)
}
}

View 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
}

View 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
}

View 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
View 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),
}
}

View 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
View 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
View 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)
}
}

View 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(),
)
})
}
}

View 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
View 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
View 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
View 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
View 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
View 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)
}

View 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
View 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
}

View 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
View 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
View 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
}