// 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 }