Files
omnex/control-plane/api/internal/downloads/handler.go
T
Xor290 d8794358c5
ci-api / test (push) Successful in 27m41s
ci-web / test (push) Successful in 14m6s
feat: add app download
2026-08-09 15:35:29 +02:00

136 lines
3.8 KiB
Go

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