feat(admin): manage server updates and local configuration
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
This commit is contained in:
1 parent
e33abc948c
commit
a29a8b18ad
49 files changed
+3604
-353
No files matched your search
@@ -23,6 +23,7 @@ import (
|
||||
"pipu/api/internal/buildinfo"
|
||||
"pipu/api/internal/genius"
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/management"
|
||||
"pipu/api/internal/platform/config"
|
||||
"pipu/api/internal/platform/db"
|
||||
"pipu/api/internal/platform/httpx"
|
||||
@@ -172,6 +173,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
||||
Client: &http.Client{}}, libStore, dto, log)
|
||||
wh.Warm = pb.Prewarm
|
||||
return server.NewRouter(server.Deps{
|
||||
Management: management.NewHandler(management.Mailbox{Dir: cfg.ManagementDir}),
|
||||
Genius: genius.NewHandler(geniusSvc),
|
||||
Log: log,
|
||||
System: sys,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -78,6 +78,7 @@ type CreateUserInput struct {
|
||||
Username string
|
||||
DisplayName string
|
||||
Role Role
|
||||
ManagesUpdates bool
|
||||
ReviewsUploads bool
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
|
||||
if !CanAssignRole(p.Role, in.Role) || !CanManageUser(actorOf(p), Actor{Role: in.Role}, UserCreate) {
|
||||
return User{}, "", forbidden()
|
||||
}
|
||||
if in.ReviewsUploads && p.Role != RoleAdmin {
|
||||
if (in.ReviewsUploads || in.ManagesUpdates) && p.Role != RoleAdmin {
|
||||
return User{}, "", forbidden()
|
||||
}
|
||||
username := NormalizeUsername(in.Username)
|
||||
@@ -114,7 +115,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
|
||||
}
|
||||
u := User{
|
||||
ID: s.ids.New(), Username: username, DisplayName: name, PasswordHash: hash, Role: in.Role,
|
||||
ReviewsUploads: in.ReviewsUploads, Locale: locale,
|
||||
ReviewsUploads: in.ReviewsUploads, ManagesUpdates: in.ManagesUpdates, Locale: locale,
|
||||
MustChangePassword: true, CreatedAt: s.clock.Now(),
|
||||
}
|
||||
err = s.store.WithTx(ctx, func(tx Store) error {
|
||||
@@ -123,7 +124,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
|
||||
}
|
||||
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||
ActorID: &p.UserID, Action: "user.create", TargetType: "user", TargetID: &u.ID,
|
||||
Payload: map[string]any{"username": username, "role": string(in.Role), "reviews_uploads": in.ReviewsUploads},
|
||||
Payload: map[string]any{"username": username, "role": string(in.Role), "reviews_uploads": in.ReviewsUploads, "manages_updates": in.ManagesUpdates},
|
||||
})
|
||||
})
|
||||
if errors.Is(err, ErrUsernameTaken) {
|
||||
@@ -154,6 +155,7 @@ type UserPatch struct {
|
||||
DisplayName *string
|
||||
Role *Role
|
||||
IsBlocked *bool
|
||||
ManagesUpdates *bool
|
||||
ReviewsUploads *bool
|
||||
}
|
||||
|
||||
@@ -218,6 +220,15 @@ func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, pat
|
||||
upd.ReviewsUploads = patch.ReviewsUploads
|
||||
audit("user.reviews_uploads", map[string]any{"reviews_uploads": *patch.ReviewsUploads})
|
||||
}
|
||||
if patch.ManagesUpdates != nil {
|
||||
if p.Role != RoleAdmin {
|
||||
return forbidden()
|
||||
}
|
||||
if *patch.ManagesUpdates != target.ManagesUpdates {
|
||||
upd.ManagesUpdates = patch.ManagesUpdates
|
||||
audit("user.manages_updates", map[string]any{"manages_updates": *patch.ManagesUpdates})
|
||||
}
|
||||
}
|
||||
blockChange := patch.IsBlocked != nil && *patch.IsBlocked != target.IsBlocked
|
||||
if blockChange {
|
||||
if !CanManageUser(actor, tgt, UserBlock) {
|
||||
|
||||
@@ -44,6 +44,9 @@ func (h *Handler) CreateUser(ctx context.Context, req gen.CreateUserRequestObjec
|
||||
return nil, err
|
||||
}
|
||||
in := CreateUserInput{Username: req.Body.Username, Role: Role(req.Body.Role)}
|
||||
if req.Body.ManagesUpdates != nil {
|
||||
in.ManagesUpdates = *req.Body.ManagesUpdates
|
||||
}
|
||||
if req.Body.ReviewsUploads != nil {
|
||||
in.ReviewsUploads = *req.Body.ReviewsUploads
|
||||
}
|
||||
@@ -63,7 +66,7 @@ func (h *Handler) UpdateUser(ctx context.Context, req gen.UpdateUserRequestObjec
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patch := UserPatch{Username: req.Body.Username, DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked, ReviewsUploads: req.Body.ReviewsUploads}
|
||||
patch := UserPatch{Username: req.Body.Username, DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked, ReviewsUploads: req.Body.ReviewsUploads, ManagesUpdates: req.Body.ManagesUpdates}
|
||||
if req.Body.Role != nil {
|
||||
r := Role(*req.Body.Role)
|
||||
patch.Role = &r
|
||||
@@ -151,6 +154,7 @@ func AdminUserDTO(u User) gen.AdminUser {
|
||||
DisplayName: u.DisplayName,
|
||||
Role: gen.Role(u.Role),
|
||||
ReviewsUploads: u.ReviewsUploads,
|
||||
ManagesUpdates: updatePermission(u),
|
||||
Locale: u.Locale,
|
||||
IsBlocked: u.IsBlocked,
|
||||
MustChangePassword: u.MustChangePassword,
|
||||
|
||||
@@ -448,3 +448,25 @@ func TestReviewUploadsGrant(t *testing.T) {
|
||||
me = e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod))
|
||||
assert.Equal(t, true, me.body["reviews_uploads"])
|
||||
}
|
||||
|
||||
func TestManageUpdatesGrantAndRevoke(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
target := e.addUser("mod", "mod-password-1", auth.RoleModerator)
|
||||
mod := e.signIn("mod", "mod-password-1")
|
||||
path := "/api/v1/admin/users/" + target.ID.String()
|
||||
grant := map[string]any{"manages_updates": true}
|
||||
assert.Equal(t, true, e.do(http.MethodGet, "/api/v1/me", nil, bearer(admin)).body["manages_updates"])
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, path, grant, bearer(mod)).code())
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPost, "/api/v1/admin/users", map[string]any{"username": "delegate", "role": "user", "manages_updates": true}, bearer(mod)).code())
|
||||
r := e.do(http.MethodPatch, path, grant, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||
assert.Equal(t, true, r.body["manages_updates"])
|
||||
assert.Equal(t, true, e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod)).body["manages_updates"])
|
||||
r = e.do(http.MethodPatch, path, map[string]any{"manages_updates": false}, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code)
|
||||
assert.Equal(t, false, e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod)).body["manages_updates"])
|
||||
r = e.do(http.MethodPost, "/api/v1/admin/users", map[string]any{"username": "delegate", "role": "user", "manages_updates": true}, bearer(admin))
|
||||
require.Equal(t, http.StatusCreated, r.Code, r.Body.String())
|
||||
}
|
||||
@@ -317,6 +317,9 @@ func (s *MemStore) UpdateUser(ctx context.Context, id uuid.UUID, upd auth.UserUp
|
||||
if upd.ReviewsUploads != nil {
|
||||
u.ReviewsUploads = *upd.ReviewsUploads
|
||||
}
|
||||
if upd.ManagesUpdates != nil {
|
||||
u.ManagesUpdates = *upd.ManagesUpdates
|
||||
}
|
||||
if upd.RevokeTokens {
|
||||
u.TokenVersion++
|
||||
}
|
||||
|
||||
@@ -232,6 +232,7 @@ func MeDTO(u User) gen.Me {
|
||||
DisplayName: u.DisplayName,
|
||||
Role: gen.Role(u.Role),
|
||||
ReviewsUploads: u.ReviewsUploads,
|
||||
ManagesUpdates: updatePermission(u),
|
||||
Locale: u.Locale,
|
||||
UiPrefs: uiPrefsDTO(u.UIPrefs),
|
||||
MustChangePassword: u.MustChangePassword,
|
||||
@@ -271,3 +272,5 @@ func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
|
||||
Normalize: p.Normalize,
|
||||
}
|
||||
}
|
||||
|
||||
func updatePermission(u User) *bool { v := u.ManagesUpdates || u.Role == RoleAdmin; return &v }
|
||||
@@ -55,6 +55,7 @@ type User struct {
|
||||
DisplayName *string
|
||||
PasswordHash string
|
||||
Role Role
|
||||
ManagesUpdates bool
|
||||
ReviewsUploads bool // аудит очереди на добавление; не следует из роли
|
||||
Locale string
|
||||
UIPrefs UIPrefs
|
||||
@@ -96,6 +97,7 @@ type Session struct {
|
||||
type Principal struct {
|
||||
UserID uuid.UUID
|
||||
Role Role
|
||||
ManagesUpdates bool
|
||||
ReviewsUploads bool
|
||||
SessionID uuid.UUID
|
||||
MustChangePassword bool
|
||||
|
||||
@@ -40,7 +40,7 @@ func (s *PgStore) WithTx(ctx context.Context, fn func(Store) error) error {
|
||||
|
||||
const userColumns = `id, username, display_name, password_hash, role, locale, ui_prefs, must_change_password,
|
||||
must_change_credentials, is_blocked, token_version, failed_login_count, locked_until, created_at, last_login_at,
|
||||
reviews_uploads`
|
||||
reviews_uploads, manages_updates`
|
||||
|
||||
func scanUser(row pgx.Row) (User, error) {
|
||||
var (
|
||||
@@ -50,7 +50,7 @@ func scanUser(row pgx.Row) (User, error) {
|
||||
)
|
||||
err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &role, &u.Locale, &prefs,
|
||||
&u.MustChangePassword, &u.MustChangeCredentials, &u.IsBlocked, &u.TokenVersion, &u.FailedLoginCount,
|
||||
&u.LockedUntil, &u.CreatedAt, &u.LastLoginAt, &u.ReviewsUploads)
|
||||
&u.LockedUntil, &u.CreatedAt, &u.LastLoginAt, &u.ReviewsUploads, &u.ManagesUpdates)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
@@ -82,10 +82,10 @@ func (s *PgStore) CreateUser(ctx context.Context, u User, createdBy *uuid.UUID)
|
||||
}
|
||||
_, err = s.q.Exec(ctx, `
|
||||
INSERT INTO users (id, username, display_name, password_hash, role, locale, ui_prefs,
|
||||
must_change_password, must_change_credentials, created_by, created_at, updated_at, reviews_uploads)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12)`,
|
||||
must_change_password, must_change_credentials, created_by, created_at, updated_at, reviews_uploads, manages_updates)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12, $13)`,
|
||||
u.ID, u.Username, u.DisplayName, u.PasswordHash, string(u.Role), u.Locale, prefs,
|
||||
u.MustChangePassword, u.MustChangeCredentials, createdBy, u.CreatedAt, u.ReviewsUploads)
|
||||
u.MustChangePassword, u.MustChangeCredentials, createdBy, u.CreatedAt, u.ReviewsUploads, u.ManagesUpdates)
|
||||
if isUniqueViolation(err) {
|
||||
return ErrUsernameTaken
|
||||
}
|
||||
@@ -347,10 +347,11 @@ func (s *PgStore) UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate)
|
||||
role = COALESCE($4::user_role, role),
|
||||
is_blocked = COALESCE($5, is_blocked),
|
||||
reviews_uploads = COALESCE($7, reviews_uploads),
|
||||
manages_updates = COALESCE($9, manages_updates),
|
||||
token_version = token_version + CASE WHEN $6 THEN 1 ELSE 0 END,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens, upd.ReviewsUploads, upd.Username))
|
||||
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens, upd.ReviewsUploads, upd.Username, upd.ManagesUpdates))
|
||||
if isUniqueViolation(err) {
|
||||
return User{}, ErrUsernameTaken
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
PermSuggestTracks // предлагать треки, видеть свои предложения
|
||||
PermReviewUploads // очередь, одобрение, правка метаданных; даёт флаг, не роль
|
||||
PermSystemStatus // статус рекомендательной системы, аудит
|
||||
PermManageUpdates // обновление сервера; admin или выданное право
|
||||
)
|
||||
|
||||
var permissions = map[Permission][]Role{
|
||||
@@ -25,6 +26,7 @@ var permissions = map[Permission][]Role{
|
||||
PermViewLibraryStatus: {RoleModerator, RoleAdmin},
|
||||
PermSuggestTracks: {RoleModerator, RoleAdmin},
|
||||
PermSystemStatus: {RoleAdmin},
|
||||
PermManageUpdates: {RoleAdmin},
|
||||
}
|
||||
|
||||
// Can сообщает, есть ли у роли глобальное право.
|
||||
@@ -40,6 +42,12 @@ func Can(r Role, p Permission) bool {
|
||||
|
||||
// Allowed — право конкретного пользователя. Аудит загрузок не вытекает из роли admin.
|
||||
func (p Principal) Allowed(perm Permission) bool {
|
||||
if perm == PermManageUpdates {
|
||||
return p.Role == RoleAdmin || p.ManagesUpdates
|
||||
}
|
||||
if perm == PermAdminPanel && p.ManagesUpdates {
|
||||
return true
|
||||
}
|
||||
if perm == PermReviewUploads {
|
||||
return p.ReviewsUploads
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func TestCan(t *testing.T) {
|
||||
{PermViewLibraryStatus, "library status", [3]bool{false, true, true}},
|
||||
{PermSuggestTracks, "suggest tracks", [3]bool{false, true, true}},
|
||||
{PermSystemStatus, "system status", [3]bool{false, false, true}},
|
||||
{PermManageUpdates, "manage updates", [3]bool{false, false, true}},
|
||||
}
|
||||
roles := [3]Role{RoleUser, RoleModerator, RoleAdmin}
|
||||
for _, row := range matrix {
|
||||
|
||||
@@ -369,6 +369,7 @@ func (s *Service) Authenticate(ctx context.Context, access string) (Principal, e
|
||||
UserID: u.ID,
|
||||
Role: u.Role,
|
||||
ReviewsUploads: u.ReviewsUploads,
|
||||
ManagesUpdates: u.ManagesUpdates || u.Role == RoleAdmin,
|
||||
SessionID: claims.SessionID,
|
||||
MustChangePassword: u.MustChangePassword,
|
||||
MustChangeCredentials: u.MustChangeCredentials,
|
||||
|
||||
@@ -35,6 +35,7 @@ type UserUpdate struct {
|
||||
DisplayName **string
|
||||
Role *Role
|
||||
IsBlocked *bool
|
||||
ManagesUpdates *bool
|
||||
ReviewsUploads *bool
|
||||
RevokeTokens bool
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Package management authorizes server administration and bridges fixed operations
|
||||
// to the host worker. The API never receives a Docker socket or executes a shell.
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/buildinfo"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
type Bridge interface {
|
||||
Call(context.Context, string, string, any, any) error
|
||||
}
|
||||
|
||||
type Mailbox struct{ Dir string }
|
||||
|
||||
func (m Mailbox) Call(ctx context.Context, actor, operation string, body, out any) error {
|
||||
heartbeat, err := os.ReadFile(filepath.Join(m.Dir, "heartbeat"))
|
||||
if err != nil {
|
||||
return apperr.New(503, "management.unavailable", "host agent is not installed or running")
|
||||
}
|
||||
at, err := strconv.ParseFloat(strings.TrimSpace(string(heartbeat)), 64)
|
||||
if err != nil || time.Since(time.Unix(int64(at), 0)) > 60*time.Second {
|
||||
return apperr.New(503, "management.unavailable", "host agent heartbeat expired")
|
||||
}
|
||||
id := uuid.NewString()
|
||||
request := filepath.Join(m.Dir, id+".request.json")
|
||||
response := filepath.Join(m.Dir, id+".response.json")
|
||||
raw, err := json.Marshal(map[string]any{"operation": operation, "actor": actor, "body": body, "created_at": time.Now().Unix()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(m.Dir, ".request-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err = tmp.Write(raw); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err = tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(tmp.Name(), request); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(response)
|
||||
defer os.Remove(request)
|
||||
ctx, cancel := context.WithTimeout(ctx, 40*time.Second)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return apperr.New(504, "management.timeout", "host agent did not respond; check status before retrying")
|
||||
case <-ticker.C:
|
||||
raw, err := os.ReadFile(response)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var result struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Code string `json:"code"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Code != "" {
|
||||
status := result.Status
|
||||
if status < 400 || status > 599 {
|
||||
status = 502
|
||||
}
|
||||
return apperr.New(status, result.Code, "host management operation failed")
|
||||
}
|
||||
return json.Unmarshal(result.Data, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Handler struct{ bridge Bridge }
|
||||
|
||||
func NewHandler(bridge Bridge) *Handler { return &Handler{bridge: bridge} }
|
||||
|
||||
func (h *Handler) call(ctx context.Context, admin bool, operation string, body, out any) error {
|
||||
p, ok := auth.PrincipalFrom(ctx)
|
||||
if !ok {
|
||||
return apperr.Unauthorized(auth.CodeUnauthorized, "authentication required")
|
||||
}
|
||||
if (admin && p.Role != auth.RoleAdmin) || (!admin && !p.Allowed(auth.PermManageUpdates)) {
|
||||
return apperr.Forbidden(auth.CodeForbidden, "server management permission required")
|
||||
}
|
||||
return h.bridge.Call(ctx, p.UserID.String(), operation, body, out)
|
||||
}
|
||||
|
||||
func (h *Handler) GetServerUpdates(ctx context.Context, req gen.GetServerUpdatesRequestObject) (gen.GetServerUpdatesResponseObject, error) {
|
||||
var out gen.ServerUpdates
|
||||
err := h.call(ctx, false, "updates.get", map[string]any{"source_id": req.Params.SourceId}, &out)
|
||||
out.CurrentVersion = buildinfo.Version
|
||||
return gen.GetServerUpdates200JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) SaveServerUpdateSources(ctx context.Context, req gen.SaveServerUpdateSourcesRequestObject) (gen.SaveServerUpdateSourcesResponseObject, error) {
|
||||
var out gen.ServerSources
|
||||
err := h.call(ctx, false, "sources.save", req.Body, &out)
|
||||
return gen.SaveServerUpdateSources200JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) StartServerUpdate(ctx context.Context, req gen.StartServerUpdateRequestObject) (gen.StartServerUpdateResponseObject, error) {
|
||||
var out gen.ServerJob
|
||||
err := h.call(ctx, false, "updates.start", req.Body, &out)
|
||||
return gen.StartServerUpdate202JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) GetServerConfig(ctx context.Context, _ gen.GetServerConfigRequestObject) (gen.GetServerConfigResponseObject, error) {
|
||||
var out gen.ServerConfig
|
||||
err := h.call(ctx, true, "config.get", nil, &out)
|
||||
return gen.GetServerConfig200JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) SaveServerConfig(ctx context.Context, req gen.SaveServerConfigRequestObject) (gen.SaveServerConfigResponseObject, error) {
|
||||
var out gen.ServerConfig
|
||||
err := h.call(ctx, true, "config.save", req.Body, &out)
|
||||
return gen.SaveServerConfig200JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) ApplyServerConfig(ctx context.Context, _ gen.ApplyServerConfigRequestObject) (gen.ApplyServerConfigResponseObject, error) {
|
||||
var out gen.ServerJob
|
||||
err := h.call(ctx, true, "config.apply", nil, &out)
|
||||
return gen.ApplyServerConfig202JSONResponse(out), err
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package management_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/management"
|
||||
"pipu/api/internal/server"
|
||||
)
|
||||
|
||||
type bridge struct{ calls int }
|
||||
|
||||
func (b *bridge) Call(_ context.Context, _, _ string, _, out any) error {
|
||||
b.calls++
|
||||
return json.Unmarshal([]byte(`{"sources":[],"versions":[],"current_version":"test","job":null,"source_error":"","entries":[],"revision":"r","restart_required":false}`), out)
|
||||
}
|
||||
|
||||
func TestServerManagementAuthorization(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
principal auth.Principal
|
||||
updates, config int
|
||||
}{
|
||||
{"user", auth.Principal{Role: auth.RoleUser}, 403, 403},
|
||||
{"moderator", auth.Principal{Role: auth.RoleModerator}, 403, 403},
|
||||
{"delegate", auth.Principal{Role: auth.RoleUser, ManagesUpdates: true}, 200, 403},
|
||||
{"admin", auth.Principal{Role: auth.RoleAdmin}, 200, 200},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
b := &bridge{}
|
||||
router := server.NewRouter(server.Deps{Management: management.NewHandler(b)})
|
||||
for _, op := range []struct {
|
||||
method, path, body string
|
||||
want int
|
||||
}{
|
||||
{"GET", "updates", "", tc.updates},
|
||||
{"POST", "updates", `{"source_id":"origin","channel":"main"}`, tc.updates},
|
||||
{"PUT", "update-sources", `{"sources":[]}`, tc.updates},
|
||||
{"GET", "config", "", tc.config},
|
||||
{"PATCH", "config", `{"revision":"r","values":{}}`, tc.config},
|
||||
{"POST", "config/apply", "", tc.config},
|
||||
} {
|
||||
request := httptest.NewRequest(op.method, "/api/v1/admin/server/"+op.path, strings.NewReader(op.body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request = request.WithContext(auth.WithPrincipal(request.Context(), tc.principal))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, request)
|
||||
want := op.want
|
||||
if op.method == "POST" && want == 200 {
|
||||
want = 202
|
||||
}
|
||||
require.Equal(t, want, rec.Code, rec.Body.String())
|
||||
}
|
||||
if tc.updates == 403 && tc.config == 403 {
|
||||
require.Zero(t, b.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("anonymous", func(t *testing.T) {
|
||||
router := server.NewRouter(server.Deps{Management: management.NewHandler(&bridge{})})
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/server/config", nil))
|
||||
require.Equal(t, 401, rec.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMailboxRoundTripAndUnavailable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := management.Mailbox{Dir: dir}
|
||||
var out map[string]any
|
||||
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), []byte("1"), 0600))
|
||||
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), []byte(time.Now().Format("invalid")), 0600))
|
||||
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
|
||||
raw, _ := json.Marshal(time.Now().Unix())
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), raw, 0600))
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- m.Call(context.Background(), "actor", "config.get", nil, &out) }()
|
||||
var requests []string
|
||||
require.Eventually(t, func() bool {
|
||||
requests, _ = filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
return len(requests) == 1
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
request, err := os.ReadFile(requests[0])
|
||||
require.NoError(t, err)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(request, &payload))
|
||||
require.Equal(t, "config.get", payload["operation"])
|
||||
require.Equal(t, "actor", payload["actor"])
|
||||
response := strings.Replace(requests[0], ".request.json", ".response.json", 1)
|
||||
require.NoError(t, os.WriteFile(response, []byte(`{"data":{"revision":"ok"}}`), 0600))
|
||||
require.NoError(t, <-done)
|
||||
require.Equal(t, "ok", out["revision"])
|
||||
remaining, _ := filepath.Glob(filepath.Join(dir, "*.json"))
|
||||
require.Empty(t, remaining)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
// API — конфигурация публичного сервиса api.
|
||||
type API struct {
|
||||
ManagementDir string `env:"MANAGEMENT_DIR" envDefault:"/management"`
|
||||
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"`
|
||||
DatabaseURL string `env:"DATABASE_URL,required"`
|
||||
RedisURL string `env:"REDIS_URL,required"`
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/genius"
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/management"
|
||||
"pipu/api/internal/platform/httpx"
|
||||
"pipu/api/internal/playback"
|
||||
"pipu/api/internal/search"
|
||||
@@ -23,10 +24,11 @@ import (
|
||||
|
||||
// Deps — модули, реализующие части контракта API.
|
||||
type Deps struct {
|
||||
Genius *genius.Handler
|
||||
Log *slog.Logger
|
||||
System *system.Handler
|
||||
Auth *auth.Handler
|
||||
Management *management.Handler
|
||||
Genius *genius.Handler
|
||||
Log *slog.Logger
|
||||
System *system.Handler
|
||||
Auth *auth.Handler
|
||||
// Library — хранилища, сканы и каталог.
|
||||
Library *library.Handler
|
||||
Search *search.Handler
|
||||
@@ -46,18 +48,20 @@ type Deps struct {
|
||||
// По мере появления модулей (catalog, ...) они встраиваются сюда.
|
||||
// Алиасы дают встроенным полям разные имена: у модулей одинаковое имя типа Handler.
|
||||
type (
|
||||
geniusHandler = genius.Handler
|
||||
systemHandler = system.Handler
|
||||
authHandler = auth.Handler
|
||||
libraryHandler = library.Handler
|
||||
searchHandler = search.Handler
|
||||
playbackHandler = playback.Handler
|
||||
waveHandler = wave.Handler
|
||||
appHandler = appupdate.Handler
|
||||
uploadsHandler = uploads.Handler
|
||||
managementHandler = management.Handler
|
||||
geniusHandler = genius.Handler
|
||||
systemHandler = system.Handler
|
||||
authHandler = auth.Handler
|
||||
libraryHandler = library.Handler
|
||||
searchHandler = search.Handler
|
||||
playbackHandler = playback.Handler
|
||||
waveHandler = wave.Handler
|
||||
appHandler = appupdate.Handler
|
||||
uploadsHandler = uploads.Handler
|
||||
)
|
||||
|
||||
type handlers struct {
|
||||
*managementHandler
|
||||
*geniusHandler
|
||||
*systemHandler
|
||||
*authHandler
|
||||
@@ -89,7 +93,7 @@ func NewRouter(d Deps) http.Handler {
|
||||
d.TUS.Mount(r)
|
||||
}
|
||||
|
||||
strict := gen.NewStrictHandlerWithOptions(handlers{geniusHandler: d.Genius, systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads},
|
||||
strict := gen.NewStrictHandlerWithOptions(handlers{managementHandler: d.Management, geniusHandler: d.Genius, systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads},
|
||||
[]gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{
|
||||
RequestErrorHandlerFunc: httpx.RequestError,
|
||||
ResponseErrorHandlerFunc: httpx.ResponseError(log),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE users ADD COLUMN manages_updates boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE users DROP COLUMN manages_updates;
|
||||
Reference in new issue
Block a user