package routes import ( "errors" "fmt" "io/fs" "kooltap-updateserver/internal/apk_util" "kooltap-updateserver/internal/auth" "kooltap-updateserver/internal/models" "log" "net/http" "os" "path/filepath" "regexp" "strconv" "github.com/Masterminds/semver/v3" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // Handler serves the release API. type Handler struct { DB *gorm.DB UploadDir string // published APKs, served under /downloads TmpDir string // incoming uploads while they are inspected MaxUpload int64 // maximum request body size in bytes for uploads } func (h *Handler) Register(r *gin.Engine) { api := r.Group("/api") { api.GET("/update", h.CheckUpdate) api.GET("/releases", h.ListReleases) } r.GET("/download/latest", h.DownloadLatest) admin := r.Group("/admin") admin.Use(auth.AdminAuth()) { admin.POST("/releases", h.UploadRelease) admin.DELETE("/releases/:id", h.DeleteRelease) } } // CheckUpdate tells a tablet whether a newer release exists. Tablets send their // versionName as "version" and, optionally, their versionCode as "code"; the // code is the most reliable comparison, so it wins when present. func (h *Handler) CheckUpdate(c *gin.Context) { latest, err := h.latestRelease() if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusOK, gin.H{"update": false}) return } if err != nil { internalError(c, err) return } if !isNewer(latest, c.Query("version"), c.Query("code")) { c.JSON(http.StatusOK, gin.H{"update": false}) return } c.JSON(http.StatusOK, gin.H{ "update": true, "version": latest.Version, "code": latest.VersionCode, "notes": latest.Notes, "mandatory": latest.Mandatory, "sha256": latest.SHA256, "download": "/downloads/" + latest.APKFile, }) } func isNewer(latest *models.Release, version, code string) bool { if code != "" { if cc, err := strconv.ParseInt(code, 10, 64); err == nil { return latest.VersionCode > cc } } cv, err1 := semver.NewVersion(version) lv, err2 := semver.NewVersion(latest.Version) if err1 != nil || err2 != nil { return latest.Version != version } return lv.GreaterThan(cv) } // DownloadLatest redirects to the newest APK, for installing on a new tablet from a browser func (h *Handler) DownloadLatest(c *gin.Context) { latest, err := h.latestRelease() if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "No releases yet"}) return } if err != nil { internalError(c, err) return } c.Redirect(http.StatusFound, "/downloads/"+latest.APKFile) } // latestRelease returns the release with the highest Android version code, // which is the latest build regardless of upload order. func (h *Handler) latestRelease() (*models.Release, error) { var release models.Release err := h.DB.Order("version_code desc").First(&release).Error if err != nil { return nil, err } return &release, nil } func (h *Handler) ListReleases(c *gin.Context) { releases := []models.Release{} if err := h.DB.Order("created_at desc").Find(&releases).Error; err != nil { internalError(c, err) return } c.JSON(http.StatusOK, releases) } func (h *Handler) UploadRelease(c *gin.Context) { if h.MaxUpload > 0 { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.MaxUpload) } notes := c.PostForm("notes") mandatory := c.PostForm("mandatory") == "true" file, err := c.FormFile("apk") if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "APK too large"}) return } c.JSON(http.StatusBadRequest, gin.H{"error": "APK required"}) return } // Save to a private temp file first because we need to inspect the APK; // the hash is computed while copying so the file is only read once. tempPath, sum, err := saveTemp(file, h.TmpDir) if err != nil { internalError(c, err) return } defer os.Remove(tempPath) // Extract version information from APK info, err := apk_util.Read(tempPath) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid APK: " + err.Error()}) return } // Tablets only see the release with the highest version code, so an older or equal one would never be offered latest, err := h.latestRelease() if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { internalError(c, err) return } if latest != nil && int64(info.VersionCode) <= latest.VersionCode { c.JSON(http.StatusConflict, gin.H{ "error": fmt.Sprintf("version code %d must be higher than the latest release (%s, code %d)", info.VersionCode, latest.Version, latest.VersionCode), }) return } // versionName comes from the uploaded file, so keep it from escaping the upload directory filename := "app-" + safeName(info.Version) + ".apk" path := filepath.Join(h.UploadDir, filename) // Prevent overwriting an existing release if _, err := os.Stat(path); err == nil { c.JSON(http.StatusConflict, gin.H{"error": "version already exists"}) return } if err := moveFile(tempPath, path); err != nil { if errors.Is(err, fs.ErrExist) { c.JSON(http.StatusConflict, gin.H{"error": "version already exists"}) return } internalError(c, err) return } release := models.Release{ Version: info.Version, VersionCode: int64(info.VersionCode), APKFile: filename, Notes: notes, Mandatory: mandatory, SHA256: sum, } if err := h.DB.Create(&release).Error; err != nil { // cleanup uploaded file if DB fails os.Remove(path) // A concurrent upload of the same version won the race if errors.Is(err, gorm.ErrDuplicatedKey) { c.JSON(http.StatusConflict, gin.H{"error": "version already exists"}) return } internalError(c, err) return } c.JSON(http.StatusOK, release) } func (h *Handler) DeleteRelease(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) return } var release models.Release if err := h.DB.First(&release, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) return } internalError(c, err) return } // Remove the row first: a leftover file is harmless, a row pointing at a missing file is not if err := h.DB.Delete(&release).Error; err != nil { internalError(c, err) return } if err := os.Remove(filepath.Join(h.UploadDir, release.APKFile)); err != nil && !errors.Is(err, os.ErrNotExist) { log.Printf("release %d deleted but removing %s failed: %v", release.ID, release.APKFile, err) } c.JSON(http.StatusOK, gin.H{"success": true}) } func internalError(c *gin.Context, err error) { log.Printf("%s %s: %v", c.Request.Method, c.Request.URL.Path, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) } var unsafeChars = regexp.MustCompile(`[^A-Za-z0-9._+-]`) func safeName(s string) string { s = unsafeChars.ReplaceAllString(s, "_") if s == "" || s == "." || s == ".." { s = "_" } return s }