feat: add app download
ci-api / test (push) Successful in 27m41s
ci-web / test (push) Successful in 14m6s

This commit is contained in:
Xor290
2026-08-09 15:35:29 +02:00
parent 0609ca30d0
commit d8794358c5
13 changed files with 520 additions and 24 deletions
@@ -21,6 +21,7 @@ type Config struct {
FrontendImage string
BackendImage string
LBTelegramImage string
AppDownloadsDir string // OMNEX_APP_DOWNLOADS_DIR : répertoire des .apk téléchargeables (voir internal/downloads)
}
// Load lit la config. Fail-secure : secret JWT obligatoire ; en prod base + Redis aussi.
@@ -45,6 +46,7 @@ func Load() (Config, error) {
FrontendImage: os.Getenv("FRONTEND_IMAGE_APP"),
BackendImage: os.Getenv("BACKEND_IMAGE_APP"),
LBTelegramImage: os.Getenv("LBTELEGRAM_IMAGE_APP"),
AppDownloadsDir: getenv("OMNEX_APP_DOWNLOADS_DIR", "/app-downloads"),
}
if cfg.Env == "prod" {
@@ -0,0 +1,135 @@
// Package downloads expose les applications mobiles (.apk) téléchargeables
// par un client ayant une démo active ou un abonnement premium en cours.
// Les fichiers sont déposés manuellement (pas de build automatisé) dans le
// répertoire configuré (voir config.AppDownloadsDir) : le contenu du
// répertoire est listé dynamiquement, aucun nom de fichier n'est en dur.
package downloads
import (
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/omnex/control-plane/api/internal/auth"
"github.com/omnex/control-plane/api/internal/demos"
)
type App struct {
Name string `json:"name"`
SizeBytes int64 `json:"size_bytes"`
}
type Handler struct {
dir string
users auth.UserStore
demos *demos.Service // optionnel : nil => éligibilité basée uniquement sur l'abonnement
}
func NewHandler(dir string, users auth.UserStore, demoSvc *demos.Service) *Handler {
return &Handler{dir: dir, users: users, demos: demoSvc}
}
// eligible : démo active OU abonnement premium non expiré.
func (h *Handler) eligible(username string) (bool, error) {
if user, found := h.users.ByUsername(username); found {
if user.TypeAbo == "premium" && time.Now().UTC().Before(user.ExpiredAt) {
return true, nil
}
}
if h.demos == nil {
return false, nil
}
list, err := h.demos.ListForUser(username)
if err != nil {
return false, err
}
for _, d := range list {
if d.Status.Active() {
return true, nil
}
}
return false, nil
}
// List renvoie les apps disponibles et si le client courant a le droit de
// les télécharger — la liste reste visible même si non éligible, pour
// afficher un message explicite côté front plutôt qu'une page vide.
func (h *Handler) List(c *gin.Context) {
p := auth.PrincipalFrom(c)
if p == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
return
}
ok, err := h.eligible(p.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
return
}
apps, err := h.listFiles()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
return
}
c.JSON(http.StatusOK, gin.H{"eligible": ok, "items": apps})
}
// Download sert un fichier .apk du répertoire — revérifie l'éligibilité
// côté serveur (le flag "eligible" de List n'est qu'un affichage).
func (h *Handler) Download(c *gin.Context) {
p := auth.PrincipalFrom(c)
if p == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
return
}
ok, err := h.eligible(p.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
return
}
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "démo ou abonnement actif requis"})
return
}
name := c.Param("name")
// Un seul segment de chemin, extension .apk uniquement : exclut toute
// tentative de traversée de répertoire (pas de "/" ni "\\" autorisés).
if name == "" || strings.ContainsAny(name, "/\\") || filepath.Ext(name) != ".apk" {
c.JSON(http.StatusBadRequest, gin.H{"error": "nom de fichier invalide"})
return
}
full := filepath.Join(h.dir, name)
if _, err := os.Stat(full); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "fichier introuvable"})
return
}
c.FileAttachment(full, name)
}
func (h *Handler) listFiles() ([]App, error) {
entries, err := os.ReadDir(h.dir)
if err != nil {
if os.IsNotExist(err) {
return []App{}, nil
}
return nil, err
}
out := make([]App, 0, len(entries))
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".apk" {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, App{Name: e.Name(), SizeBytes: info.Size()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
+13 -7
View File
@@ -9,6 +9,7 @@ import (
"github.com/omnex/control-plane/api/internal/auth"
"github.com/omnex/control-plane/api/internal/config"
"github.com/omnex/control-plane/api/internal/demos"
"github.com/omnex/control-plane/api/internal/downloads"
"github.com/omnex/control-plane/api/internal/httpx"
"github.com/omnex/control-plane/api/internal/profile"
"github.com/omnex/control-plane/api/internal/session"
@@ -17,13 +18,14 @@ import (
// Deps : dépendances injectées (facilite les tests).
type Deps struct {
Cfg config.Config
Issuer *auth.Issuer
Sessions session.Manager
AuthH *auth.Handler
DemosH *demos.Handler
SubH *sub.Handler
ProfileH *profile.Handler
Cfg config.Config
Issuer *auth.Issuer
Sessions session.Manager
AuthH *auth.Handler
DemosH *demos.Handler
SubH *sub.Handler
ProfileH *profile.Handler
DownloadsH *downloads.Handler
}
// New construit l'engine Gin avec toute la chaîne de sécurité.
@@ -61,6 +63,10 @@ func New(d Deps) *gin.Engine {
if d.DemosH != nil {
client.GET("/demos/mine", d.DemosH.ListMine)
}
if d.DownloadsH != nil {
client.GET("/apps", d.DownloadsH.List)
client.GET("/apps/:name", d.DownloadsH.Download)
}
}
// Espace admin : provisioning des démos (admin uniquement).
admin := authed.Group("")