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

This commit is contained in:
mrleo1nid committed 2026-10-22 23:01:36 +03:00
1 parent e33abc948c
commit a29a8b18ad
49 files changed
+3604 -353

No files matched your search

+2
View File
@@ -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
+14 -3
View File
@@ -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) {
+5 -1
View File
@@ -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,
+22
View File
@@ -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++
}
+3
View File
@@ -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 }
+2
View File
@@ -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
+7 -6
View File
@@ -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
}
+8
View File
@@ -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 {
+1
View File
@@ -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,
+1
View File
@@ -35,6 +35,7 @@ type UserUpdate struct {
DisplayName **string
Role *Role
IsBlocked *bool
ManagesUpdates *bool
ReviewsUploads *bool
RevokeTokens bool
}
+148
View File
@@ -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"`
+18 -14
View File
@@ -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;