fix: address project review findings
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 48s
ci / api (push) Successful in 51s
ci / recommender (push) Canceled after 2s
ci / ts (packages + web) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Canceled after 0s
ci / mobile (Metro-бандл Android) (push) Canceled after 0s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 48s
ci / api (push) Successful in 51s
ci / recommender (push) Canceled after 2s
ci / ts (packages + web) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Canceled after 0s
ci / mobile (Metro-бандл Android) (push) Canceled after 0s
Security and robustness: - storage-agent rejects an empty INTERNAL_TOKEN (config notEmpty + middleware) - api takes the client IP only from X-Real-IP (chi RealIP trusted True-Client-IP) - /readyz reports ok/fail and logs the cause; checks run in parallel with a timeout - storage-agent shuts down gracefully; shared httpx.NewServer/Serve with IdleTimeout - goose migrations take a Postgres advisory lock - recommender readyz: per-check timeout, redis socket timeouts Clients: - Android release allows cleartext HTTP for LAN servers (expo-build-properties) - checkServer times out after 10s; connect button is disabled while checking Deploy and CI: - install.sh hands cache/uploads/updates/models to the non-root service users - update.sh refuses downgrades unless PIPU_ALLOW_DOWNGRADE=1 - recommender/analyzer get only the env they need; required secrets fail fast - nginx security headers and CSP; strip True-Client-IP - dev compose binds ports to 127.0.0.1; postgres/redis images pinned - version.mjs fails on a malformed explicit tag instead of releasing 0.0.0-dev - contract check catches untracked generated files - final releases fail without a release keystore; changelog skips rc tags Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1324cad2af
commit
4fa1add80d
37 files changed
+503
-199
No files matched your search
@@ -23,10 +23,11 @@ type API struct {
|
||||
|
||||
// StorageAgent — конфигурация внутреннего сервиса storage-agent.
|
||||
type StorageAgent struct {
|
||||
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8090"`
|
||||
InternalToken string `env:"INTERNAL_TOKEN,required"`
|
||||
LibraryRoot string `env:"LIBRARY_ROOT" envDefault:"/library"`
|
||||
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
||||
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8090"`
|
||||
InternalToken string `env:"INTERNAL_TOKEN,required,notEmpty"`
|
||||
LibraryRoot string `env:"LIBRARY_ROOT" envDefault:"/library"`
|
||||
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
||||
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"`
|
||||
}
|
||||
|
||||
// Load заполняет структуру конфигурации T из окружения.
|
||||
|
||||
@@ -24,3 +24,10 @@ func TestLoadAPIRequiresDatabase(t *testing.T) {
|
||||
_, err := Load[API]()
|
||||
assert.ErrorContains(t, err, "DATABASE_URL")
|
||||
}
|
||||
|
||||
func TestLoadStorageAgentRejectsEmptyToken(t *testing.T) {
|
||||
t.Setenv("INTERNAL_TOKEN", "")
|
||||
|
||||
_, err := Load[StorageAgent]()
|
||||
assert.ErrorContains(t, err, "INTERNAL_TOKEN")
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/pressly/goose/v3/lock"
|
||||
|
||||
"pipu/api/migrations"
|
||||
)
|
||||
@@ -26,12 +27,17 @@ func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) {
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// Migrate применяет встроенные миграции goose.
|
||||
// Migrate применяет встроенные миграции goose. Advisory-lock в PostgreSQL не даёт
|
||||
// нескольким экземплярам api применять миграции одновременно.
|
||||
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
sqlDB := stdlib.OpenDBFromPool(pool)
|
||||
defer func(d *sql.DB) { _ = d.Close() }(sqlDB)
|
||||
|
||||
provider, err := goose.NewProvider(goose.DialectPostgres, sqlDB, migrations.FS)
|
||||
locker, err := lock.NewPostgresSessionLocker()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: migrations lock: %w", err)
|
||||
}
|
||||
provider, err := goose.NewProvider(goose.DialectPostgres, sqlDB, migrations.FS, goose.WithSessionLocker(locker))
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: migrations: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewServer создаёт http.Server с общими таймаутами. WriteTimeout не задаётся:
|
||||
// стриминг аудио и загрузки держат ответ открытым дольше любого разумного предела.
|
||||
func NewServer(addr string, h http.Handler) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: addr,
|
||||
Handler: h,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Serve запускает сервер и корректно останавливает его при отмене ctx.
|
||||
func Serve(ctx context.Context, log *slog.Logger, srv *http.Server, shutdownTimeout time.Duration) error {
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Info("listening", "addr", srv.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
log.Info("shutting down")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
// RealIP подставляет в RemoteAddr адрес клиента из X-Real-IP, который выставляет nginx.
|
||||
// В отличие от middleware.RealIP из chi, не доверяет True-Client-IP и X-Forwarded-For:
|
||||
// их клиент может прислать сам, а nginx пропускает их дальше без изменений.
|
||||
func RealIP(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); net.ParseIP(ip) != nil {
|
||||
r.RemoteAddr = ip
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRealIP(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
want string
|
||||
}{
|
||||
{"no headers", nil, "192.0.2.1:1234"},
|
||||
{"x-real-ip", map[string]string{"X-Real-IP": "203.0.113.7"}, "203.0.113.7"},
|
||||
{"invalid x-real-ip", map[string]string{"X-Real-IP": "evil"}, "192.0.2.1:1234"},
|
||||
{"spoofed true-client-ip", map[string]string{"True-Client-IP": "1.1.1.1"}, "192.0.2.1:1234"},
|
||||
{"spoofed x-forwarded-for", map[string]string{"X-Forwarded-For": "1.1.1.1"}, "192.0.2.1:1234"},
|
||||
{
|
||||
"x-real-ip wins over spoofed headers",
|
||||
map[string]string{"X-Real-IP": "203.0.113.7", "True-Client-IP": "1.1.1.1"},
|
||||
"203.0.113.7",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var got string
|
||||
h := RealIP(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { got = r.RemoteAddr }))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "192.0.2.1:1234"
|
||||
for k, v := range tc.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user