feat(api): Android self-update endpoints proxied from Gitea releases (M5)
GET /api/v1/app/android/latest reads the latest Gitea release with the server token (cached for 10 minutes); the download endpoint caches the APK under /updates, verifies its SHA-256 and serves it with Range. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9fd616da57
commit
6a67c27f50
10 files changed
+940
-173
No files matched your search
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"pipu/api/internal/appupdate"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/buildinfo"
|
||||
"pipu/api/internal/library"
|
||||
@@ -144,6 +145,8 @@ func run() error {
|
||||
Playback: pb,
|
||||
Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
|
||||
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log),
|
||||
App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken,
|
||||
Dir: cfg.UpdatesDir, MinSupported: cfg.MinAndroidVersionCode}, log),
|
||||
}))
|
||||
log.Info("starting", "version", buildinfo.Version)
|
||||
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
|
||||
|
||||
@@ -253,6 +253,21 @@ type AnalysisStatus struct {
|
||||
Running int `json:"running"`
|
||||
}
|
||||
|
||||
// AndroidRelease defines model for AndroidRelease.
|
||||
type AndroidRelease struct {
|
||||
DownloadUrl string `json:"download_url"`
|
||||
|
||||
// MinSupported Версии ниже обязаны обновиться
|
||||
MinSupported int `json:"min_supported"`
|
||||
|
||||
// NotesMd Что нового (Markdown из релиза)
|
||||
NotesMd string `json:"notes_md"`
|
||||
Sha256 string `json:"sha256"`
|
||||
Size int64 `json:"size"`
|
||||
Version string `json:"version"`
|
||||
VersionCode int `json:"version_code"`
|
||||
}
|
||||
|
||||
// Artist defines model for Artist.
|
||||
type Artist struct {
|
||||
AlbumCount int `json:"album_count"`
|
||||
@@ -1148,6 +1163,12 @@ type ServerInterface interface {
|
||||
// Альбом с треками
|
||||
// (GET /api/v1/albums/{id})
|
||||
GetAlbum(w http.ResponseWriter, r *http.Request, id EntityId)
|
||||
// APK версии (кэшируется на сервере, поддерживает Range для докачки)
|
||||
// (GET /api/v1/app/android/download/{version})
|
||||
DownloadAndroidApk(w http.ResponseWriter, r *http.Request, version string)
|
||||
// Последняя версия Android-приложения (из релизов Gitea через сервер)
|
||||
// (GET /api/v1/app/android/latest)
|
||||
GetAndroidLatest(w http.ResponseWriter, r *http.Request)
|
||||
// Исполнитель
|
||||
// (GET /api/v1/artists/{id})
|
||||
GetArtist(w http.ResponseWriter, r *http.Request, id EntityId)
|
||||
@@ -1385,6 +1406,18 @@ func (_ Unimplemented) GetAlbum(w http.ResponseWriter, r *http.Request, id Entit
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// APK версии (кэшируется на сервере, поддерживает Range для докачки)
|
||||
// (GET /api/v1/app/android/download/{version})
|
||||
func (_ Unimplemented) DownloadAndroidApk(w http.ResponseWriter, r *http.Request, version string) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Последняя версия Android-приложения (из релизов Gitea через сервер)
|
||||
// (GET /api/v1/app/android/latest)
|
||||
func (_ Unimplemented) GetAndroidLatest(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// Исполнитель
|
||||
// (GET /api/v1/artists/{id})
|
||||
func (_ Unimplemented) GetArtist(w http.ResponseWriter, r *http.Request, id EntityId) {
|
||||
@@ -2248,6 +2281,57 @@ func (siw *ServerInterfaceWrapper) GetAlbum(w http.ResponseWriter, r *http.Reque
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// DownloadAndroidApk operation middleware
|
||||
func (siw *ServerInterfaceWrapper) DownloadAndroidApk(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
|
||||
// ------------- Path parameter "version" -------------
|
||||
var version string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "version", chi.URLParam(r, "version"), &version, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "version", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.DownloadAndroidApk(w, r, version)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetAndroidLatest operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetAndroidLatest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetAndroidLatest(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetArtist operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetArtist(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -3447,6 +3531,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/albums/{id}", wrapper.GetAlbum)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/app/android/download/{version}", wrapper.DownloadAndroidApk)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/app/android/latest", wrapper.GetAndroidLatest)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/api/v1/artists/{id}", wrapper.GetArtist)
|
||||
})
|
||||
@@ -4166,6 +4256,73 @@ func (response GetAlbumdefaultApplicationProblemPlusJSONResponse) VisitGetAlbumR
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type DownloadAndroidApkRequestObject struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type DownloadAndroidApkResponseObject interface {
|
||||
VisitDownloadAndroidApkResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type DownloadAndroidApk200ApplicationvndAndroidPackageArchiveResponse struct {
|
||||
Body io.Reader
|
||||
ContentLength int64
|
||||
}
|
||||
|
||||
func (response DownloadAndroidApk200ApplicationvndAndroidPackageArchiveResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
||||
if response.ContentLength != 0 {
|
||||
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
|
||||
if closer, ok := response.Body.(io.ReadCloser); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
_, err := io.Copy(w, response.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
type DownloadAndroidApkdefaultApplicationProblemPlusJSONResponse struct {
|
||||
Body Problem
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (response DownloadAndroidApkdefaultApplicationProblemPlusJSONResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type GetAndroidLatestRequestObject struct {
|
||||
}
|
||||
|
||||
type GetAndroidLatestResponseObject interface {
|
||||
VisitGetAndroidLatestResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type GetAndroidLatest200JSONResponse AndroidRelease
|
||||
|
||||
func (response GetAndroidLatest200JSONResponse) VisitGetAndroidLatestResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type GetAndroidLatestdefaultApplicationProblemPlusJSONResponse struct {
|
||||
Body Problem
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (response GetAndroidLatestdefaultApplicationProblemPlusJSONResponse) VisitGetAndroidLatestResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
|
||||
return json.NewEncoder(w).Encode(response.Body)
|
||||
}
|
||||
|
||||
type GetArtistRequestObject struct {
|
||||
Id EntityId `json:"id"`
|
||||
}
|
||||
@@ -5241,6 +5398,12 @@ type StrictServerInterface interface {
|
||||
// Альбом с треками
|
||||
// (GET /api/v1/albums/{id})
|
||||
GetAlbum(ctx context.Context, request GetAlbumRequestObject) (GetAlbumResponseObject, error)
|
||||
// APK версии (кэшируется на сервере, поддерживает Range для докачки)
|
||||
// (GET /api/v1/app/android/download/{version})
|
||||
DownloadAndroidApk(ctx context.Context, request DownloadAndroidApkRequestObject) (DownloadAndroidApkResponseObject, error)
|
||||
// Последняя версия Android-приложения (из релизов Gitea через сервер)
|
||||
// (GET /api/v1/app/android/latest)
|
||||
GetAndroidLatest(ctx context.Context, request GetAndroidLatestRequestObject) (GetAndroidLatestResponseObject, error)
|
||||
// Исполнитель
|
||||
// (GET /api/v1/artists/{id})
|
||||
GetArtist(ctx context.Context, request GetArtistRequestObject) (GetArtistResponseObject, error)
|
||||
@@ -5962,6 +6125,56 @@ func (sh *strictHandler) GetAlbum(w http.ResponseWriter, r *http.Request, id Ent
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadAndroidApk operation middleware
|
||||
func (sh *strictHandler) DownloadAndroidApk(w http.ResponseWriter, r *http.Request, version string) {
|
||||
var request DownloadAndroidApkRequestObject
|
||||
|
||||
request.Version = version
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.DownloadAndroidApk(ctx, request.(DownloadAndroidApkRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "DownloadAndroidApk")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(DownloadAndroidApkResponseObject); ok {
|
||||
if err := validResponse.VisitDownloadAndroidApkResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// GetAndroidLatest operation middleware
|
||||
func (sh *strictHandler) GetAndroidLatest(w http.ResponseWriter, r *http.Request) {
|
||||
var request GetAndroidLatestRequestObject
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.GetAndroidLatest(ctx, request.(GetAndroidLatestRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "GetAndroidLatest")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(GetAndroidLatestResponseObject); ok {
|
||||
if err := validResponse.VisitGetAndroidLatestResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// GetArtist operation middleware
|
||||
func (sh *strictHandler) GetArtist(w http.ResponseWriter, r *http.Request, id EntityId) {
|
||||
var request GetArtistRequestObject
|
||||
@@ -6925,178 +7138,186 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
// Package appupdate — самообновление Android-приложения через сервер (docs/mobile.md#самообновление):
|
||||
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и APK.
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
"pipu/api/internal/platform/httpx"
|
||||
)
|
||||
|
||||
// CodeUnavailable — обновления не настроены или релиза с APK нет.
|
||||
const CodeUnavailable = "app.update_unavailable"
|
||||
|
||||
const cacheTTL = 10 * time.Minute
|
||||
|
||||
var tagRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$`)
|
||||
|
||||
// VersionCode — versionCode по тегу; та же формула, что в scripts/version.mjs:
|
||||
// MAJOR·1 000 000 + MINOR·10 000 + PATCH·100 + (99 для релиза | N для -rc.N).
|
||||
func VersionCode(tag string) (string, int, error) {
|
||||
m := tagRe.FindStringSubmatch(strings.TrimSpace(tag))
|
||||
if m == nil {
|
||||
return "", 0, fmt.Errorf("appupdate: not a release tag: %q", tag)
|
||||
}
|
||||
n := func(s string) int { v, _ := strconv.Atoi(s); return v }
|
||||
major, minor, patch := n(m[1]), n(m[2]), n(m[3])
|
||||
if minor > 99 || patch > 99 {
|
||||
return "", 0, fmt.Errorf("appupdate: minor and patch must be <= 99: %q", tag)
|
||||
}
|
||||
rc, version := 99, fmt.Sprintf("%d.%d.%d", major, minor, patch)
|
||||
if m[4] != "" {
|
||||
rc = n(m[4])
|
||||
version += "-rc." + m[4]
|
||||
}
|
||||
return version, major*1_000_000 + minor*10_000 + patch*100 + rc, nil
|
||||
}
|
||||
|
||||
// Config — доступ к Gitea и каталог кэша.
|
||||
type Config struct {
|
||||
GiteaURL string
|
||||
Repo string // owner/name
|
||||
Token string
|
||||
Dir string // /updates
|
||||
MinSupported int
|
||||
}
|
||||
|
||||
type asset struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
URL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
type release struct {
|
||||
Tag string `json:"tag_name"`
|
||||
Body string `json:"body"`
|
||||
Assets []asset `json:"assets"`
|
||||
}
|
||||
|
||||
// Latest — сведения о последней версии.
|
||||
type Latest struct {
|
||||
Version string
|
||||
VersionCode int
|
||||
Notes string
|
||||
Size int64
|
||||
SHA256 string
|
||||
apkURL string
|
||||
}
|
||||
|
||||
// Handler реализует app-часть gen.StrictServerInterface.
|
||||
type Handler struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
cached *Latest
|
||||
cachedAt time.Time
|
||||
dl sync.Mutex // одна загрузка APK за раз
|
||||
}
|
||||
|
||||
// NewHandler создаёт обработчик.
|
||||
func NewHandler(cfg Config, log *slog.Logger) *Handler {
|
||||
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
|
||||
}
|
||||
|
||||
func (h *Handler) configured() bool {
|
||||
return h.cfg.GiteaURL != "" && h.cfg.Repo != ""
|
||||
}
|
||||
|
||||
func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.cfg.Token != "" {
|
||||
req.Header.Set("Authorization", "token "+h.cfg.Token)
|
||||
}
|
||||
res, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea: %w", err)
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
_ = res.Body.Close()
|
||||
return nil, fmt.Errorf("gitea: %s: status %d", url, res.StatusCode)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// latest — последний релиз с APK (кэш на 10 минут).
|
||||
func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||
if !h.configured() {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured")
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.cached != nil && time.Since(h.cachedAt) < cacheTTL {
|
||||
return h.cached, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
base := strings.TrimRight(h.cfg.GiteaURL, "/")
|
||||
res, err := h.get(ctx, base+"/api/v1/repos/"+h.cfg.Repo+"/releases/latest")
|
||||
if err != nil {
|
||||
h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err)
|
||||
if h.cached != nil {
|
||||
return h.cached, nil // устаревший ответ лучше, чем никакого
|
||||
}
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release is unavailable")
|
||||
}
|
||||
var rel release
|
||||
err = json.NewDecoder(res.Body).Decode(&rel)
|
||||
_ = res.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea: decode release: %w", err)
|
||||
}
|
||||
version, code, err := VersionCode(rel.Tag)
|
||||
if err != nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, err.Error())
|
||||
}
|
||||
name := "pipu-music-" + version + ".apk"
|
||||
var apk, sum *asset
|
||||
for i := range rel.Assets {
|
||||
switch rel.Assets[i].Name {
|
||||
case name:
|
||||
apk = &rel.Assets[i]
|
||||
case name + ".sha256":
|
||||
sum = &rel.Assets[i]
|
||||
}
|
||||
}
|
||||
if apk == nil || sum == nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
||||
}
|
||||
sres, err := h.get(ctx, sum.URL)
|
||||
if err != nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "checksum is unavailable")
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(sres.Body, 256))
|
||||
_ = sres.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := strings.ToLower(strings.Fields(string(raw) + " x")[0])
|
||||
if len(digest) != 64 {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "malformed checksum")
|
||||
}
|
||||
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Size: apk.Size, SHA256: digest, apkURL: apk.URL}
|
||||
h.cachedAt = time.Now()
|
||||
return h.cached, nil
|
||||
}
|
||||
|
||||
// GetAndroidLatest реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetAndroidLatest(ctx context.Context, _ gen.GetAndroidLatestRequestObject) (gen.GetAndroidLatestResponseObject, error) {
|
||||
l, err := h.latest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.GetAndroidLatest200JSONResponse{
|
||||
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: l.Size, Sha256: l.SHA256,
|
||||
DownloadUrl: "/api/v1/app/android/download/" + l.Version, MinSupported: h.cfg.MinSupported,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensure скачивает APK в кэш (если его там нет) и сверяет SHA-256 из релиза.
|
||||
func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
|
||||
dst := filepath.Join(h.cfg.Dir, "pipu-music-"+l.Version+".apk")
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return dst, nil
|
||||
}
|
||||
h.dl.Lock()
|
||||
defer h.dl.Unlock()
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return dst, nil
|
||||
}
|
||||
if err := os.MkdirAll(h.cfg.Dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := h.get(ctx, l.apkURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
tmp, err := os.CreateTemp(h.cfg.Dir, ".apk-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = os.Remove(tmp.Name()) }()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(io.MultiWriter(tmp, hash), res.Body); err != nil {
|
||||
_ = tmp.Close()
|
||||
return "", fmt.Errorf("appupdate: download: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if got := hex.EncodeToString(hash.Sum(nil)); got != l.SHA256 {
|
||||
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, l.SHA256)
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
h.cleanup(dst)
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// cleanup оставляет в кэше только текущий APK.
|
||||
func (h *Handler) cleanup(keep string) {
|
||||
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*.apk"))
|
||||
for _, m := range matches {
|
||||
if m != keep {
|
||||
_ = os.Remove(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadAndroidApk реализует gen.StrictServerInterface. Отдаётся только последняя версия.
|
||||
func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) {
|
||||
l, err := h.latest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Version != l.Version {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
|
||||
}
|
||||
path, err := h.ensure(ctx, l)
|
||||
if err != nil {
|
||||
h.log.ErrorContext(ctx, "app update: apk download failed", "err", err)
|
||||
return nil, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the APK")
|
||||
}
|
||||
return apkResponse{path: path, name: filepath.Base(path), req: httpx.RequestFrom(ctx)}, nil
|
||||
}
|
||||
|
||||
type apkResponse struct {
|
||||
path string
|
||||
name string
|
||||
req *http.Request
|
||||
}
|
||||
|
||||
// VisitDownloadAndroidApkResponse реализует gen.DownloadAndroidApkResponseObject (Range — для докачки).
|
||||
func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
|
||||
f, err := os.Open(a.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.req == nil {
|
||||
return errors.New("appupdate: request missing in context")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+a.name+`"`)
|
||||
http.ServeContent(w, a.req, a.name, info.ModTime(), f)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestVersionCode(t *testing.T) {
|
||||
cases := map[string]int{"v0.6.1": 60_199, "1.2.0-rc.3": 1_020_003, "v10.0.0": 10_000_099}
|
||||
for tag, want := range cases {
|
||||
_, got, err := VersionCode(tag)
|
||||
require.NoError(t, err, tag)
|
||||
assert.Equal(t, want, got, tag)
|
||||
}
|
||||
for _, bad := range []string{"latest", "v1.100.0", "1.2"} {
|
||||
_, _, err := VersionCode(bad)
|
||||
assert.Error(t, err, bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestAndDownload(t *testing.T) {
|
||||
apk := []byte("fake apk bytes")
|
||||
sum := sha256.Sum256(apk)
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "token secret", r.Header.Get("Authorization"))
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/o/r/releases/latest":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"tag_name": "v0.6.1", "body": "## Новое",
|
||||
"assets": []map[string]any{
|
||||
{"name": "pipu-music-0.6.1.apk", "size": len(apk), "browser_download_url": srv.URL + "/apk"},
|
||||
{"name": "pipu-music-0.6.1.apk.sha256", "size": 65, "browser_download_url": srv.URL + "/sum"},
|
||||
},
|
||||
})
|
||||
case "/apk":
|
||||
_, _ = w.Write(apk)
|
||||
case "/sum":
|
||||
_, _ = w.Write([]byte(hex.EncodeToString(sum[:]) + "\n"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
h := NewHandler(Config{GiteaURL: srv.URL, Repo: "o/r", Token: "secret", Dir: dir, MinSupported: 60_000}, slog.Default())
|
||||
l, err := h.latest(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0.6.1", l.Version)
|
||||
assert.Equal(t, 60_199, l.VersionCode)
|
||||
assert.Equal(t, hex.EncodeToString(sum[:]), l.SHA256)
|
||||
|
||||
path, err := h.ensure(context.Background(), l)
|
||||
require.NoError(t, err)
|
||||
got, _ := os.ReadFile(path)
|
||||
assert.Equal(t, apk, got)
|
||||
|
||||
// Подменённый файл не пройдёт сверку.
|
||||
require.NoError(t, os.Remove(path))
|
||||
l.SHA256 = "00" + l.SHA256[2:]
|
||||
_, err = h.ensure(context.Background(), l)
|
||||
assert.ErrorContains(t, err, "checksum mismatch")
|
||||
_, statErr := os.Stat(filepath.Join(dir, "pipu-music-0.6.1.apk"))
|
||||
assert.True(t, os.IsNotExist(statErr))
|
||||
}
|
||||
@@ -54,6 +54,12 @@ type API struct {
|
||||
|
||||
MeiliURL string `env:"MEILI_URL" envDefault:"http://meilisearch:7700"`
|
||||
MeiliMasterKey string `env:"MEILI_MASTER_KEY"`
|
||||
|
||||
// Самообновление Android: сервер читает релизы Gitea своим токеном и кэширует APK в UpdatesDir.
|
||||
GiteaURL string `env:"GITEA_URL"`
|
||||
GiteaRepo string `env:"GITEA_REPO"`
|
||||
GiteaToken string `env:"GITEA_TOKEN"`
|
||||
UpdatesDir string `env:"UPDATES_DIR" envDefault:"/updates"`
|
||||
}
|
||||
|
||||
// StorageAgent — конфигурация внутреннего сервиса storage-agent.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/appupdate"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/platform/httpx"
|
||||
@@ -30,6 +31,7 @@ type Deps struct {
|
||||
Playback *playback.Handler
|
||||
// Wave — «Моя волна».
|
||||
Wave *wave.Handler
|
||||
App *appupdate.Handler
|
||||
// AuthService проверяет access-токены в middleware. nil — только для тестов служебных эндпоинтов.
|
||||
AuthService *auth.Service
|
||||
}
|
||||
@@ -44,6 +46,7 @@ type (
|
||||
searchHandler = search.Handler
|
||||
playbackHandler = playback.Handler
|
||||
waveHandler = wave.Handler
|
||||
appHandler = appupdate.Handler
|
||||
)
|
||||
|
||||
type handlers struct {
|
||||
@@ -53,6 +56,7 @@ type handlers struct {
|
||||
*searchHandler
|
||||
*playbackHandler
|
||||
*waveHandler
|
||||
*appHandler
|
||||
}
|
||||
|
||||
var _ gen.StrictServerInterface = handlers{}
|
||||
@@ -72,7 +76,7 @@ func NewRouter(d Deps) http.Handler {
|
||||
r.NotFound(httpx.NotFound)
|
||||
r.MethodNotAllowed(httpx.MethodNotAllowed)
|
||||
|
||||
strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave},
|
||||
strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App},
|
||||
[]gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{
|
||||
RequestErrorHandlerFunc: httpx.RequestError,
|
||||
ResponseErrorHandlerFunc: httpx.ResponseError(log),
|
||||
|
||||
Reference in new issue
Block a user