diff --git a/api/openapi.yaml b/api/openapi.yaml index 12073be..a99b836 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -18,6 +18,7 @@ tags: - name: playback - name: collection - name: wave + - name: app security: - bearerAuth: [] paths: @@ -1062,6 +1063,45 @@ paths: $ref: "#/components/schemas/WaveBatch" default: $ref: "#/components/responses/Problem" + /api/v1/app/android/latest: + get: + operationId: getAndroidLatest + tags: [app] + summary: Последняя версия Android-приложения (из релизов Gitea через сервер) + description: | + Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению + не нужен доступ к Gitea. Нет настроек Gitea или релиза с APK — `404 app.update_unavailable`. + responses: + "200": + description: Последний релиз + content: + application/json: + schema: + $ref: "#/components/schemas/AndroidRelease" + default: + $ref: "#/components/responses/Problem" + /api/v1/app/android/download/{version}: + get: + operationId: downloadAndroidApk + tags: [app] + summary: APK версии (кэшируется на сервере, поддерживает Range для докачки) + parameters: + - name: version + in: path + required: true + schema: + type: string + pattern: '^\d+\.\d+\.\d+(-rc\.\d+)?$' + responses: + "200": + description: APK + content: + application/vnd.android.package-archive: + schema: + type: string + format: binary + default: + $ref: "#/components/responses/Problem" components: parameters: SourceId: @@ -2254,6 +2294,28 @@ components: description: Лайков на 100 треков sessions: type: integer + AndroidRelease: + type: object + required: [version, version_code, notes_md, size, sha256, download_url, min_supported] + properties: + version: + type: string + example: 0.6.1 + version_code: + type: integer + notes_md: + type: string + description: Что нового (Markdown из релиза) + size: + type: integer + format: int64 + sha256: + type: string + download_url: + type: string + min_supported: + type: integer + description: Версии ниже обязаны обновиться Session: type: object required: [id, device_name, client_type, created_at, last_used_at, current] diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index 1f12590..52e83dd 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -882,6 +882,44 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/app/android/latest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Последняя версия Android-приложения (из релизов Gitea через сервер) + * @description Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению + * не нужен доступ к Gitea. Нет настроек Gitea или релиза с APK — `404 app.update_unavailable`. + */ + get: operations["getAndroidLatest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/app/android/download/{version}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** APK версии (кэшируется на сервере, поддерживает Range для докачки) */ + get: operations["downloadAndroidApk"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1446,6 +1484,19 @@ export interface components { like_rate: number | null; sessions: number; }; + AndroidRelease: { + /** @example 0.6.1 */ + version: string; + version_code: number; + /** @description Что нового (Markdown из релиза) */ + notes_md: string; + /** Format: int64 */ + size: number; + sha256: string; + download_url: string; + /** @description Версии ниже обязаны обновиться */ + min_supported: number; + }; Session: { /** Format: uuid */ id: string; @@ -2841,4 +2892,48 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + getAndroidLatest: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Последний релиз */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AndroidRelease"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + downloadAndroidApk: { + parameters: { + query?: never; + header?: never; + path: { + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description APK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/vnd.android.package-archive": string; + }; + }; + default: components["responses"]["Problem"]; + }; + }; } diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index 8ee6899..76d0a54 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -71,5 +71,8 @@ }, "wave": { "session_not_found": "The wave session has ended" + }, + "app": { + "update_unavailable": "Update is unavailable" } } diff --git a/packages/i18n/locales/ru/errors.json b/packages/i18n/locales/ru/errors.json index ea44cea..d47ae6c 100644 --- a/packages/i18n/locales/ru/errors.json +++ b/packages/i18n/locales/ru/errors.json @@ -71,5 +71,8 @@ }, "wave": { "session_not_found": "Сессия волны завершена" + }, + "app": { + "update_unavailable": "Обновление недоступно" } } diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go index b0867d7..4777958 100644 --- a/services/api/cmd/api/main.go +++ b/services/api/cmd/api/main.go @@ -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) diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index 14ae5bb..609e758 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -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 diff --git a/services/api/internal/appupdate/appupdate.go b/services/api/internal/appupdate/appupdate.go new file mode 100644 index 0000000..d2faf8e --- /dev/null +++ b/services/api/internal/appupdate/appupdate.go @@ -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 +} diff --git a/services/api/internal/appupdate/appupdate_test.go b/services/api/internal/appupdate/appupdate_test.go new file mode 100644 index 0000000..835a367 --- /dev/null +++ b/services/api/internal/appupdate/appupdate_test.go @@ -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)) +} diff --git a/services/api/internal/platform/config/config.go b/services/api/internal/platform/config/config.go index 55e04bb..8e7d717 100644 --- a/services/api/internal/platform/config/config.go +++ b/services/api/internal/platform/config/config.go @@ -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. diff --git a/services/api/internal/server/router.go b/services/api/internal/server/router.go index 93919fe..e594548 100644 --- a/services/api/internal/server/router.go +++ b/services/api/internal/server/router.go @@ -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),