package routes import ( "crypto/sha256" "encoding/hex" "fmt" "io" "kooltap-updateserver/internal/apk_util" "kooltap-updateserver/internal/auth" "kooltap-updateserver/internal/db" "kooltap-updateserver/internal/models" "net/http" "os" "path/filepath" "github.com/Masterminds/semver/v3" "github.com/gin-gonic/gin" ) func Register(r *gin.Engine) { api := r.Group("/api") { api.GET("/update", CheckUpdate) api.GET("/releases", ListReleases) } r.GET("/download/latest", DownloadLatest) admin := r.Group("/admin") admin.Use(auth.AdminAuth()) { admin.POST("/releases", UploadRelease) admin.DELETE("/releases/:id", DeleteRelease) } } func CheckUpdate(c *gin.Context) { current := c.Query("version") var latest models.Release // The highest Android version code is the latest build, regardless of upload order if err := latestRelease(&latest); err != nil { c.JSON(http.StatusOK, gin.H{ "update": false, }) return } cv, err1 := semver.NewVersion(current) lv, err2 := semver.NewVersion(latest.Version) if err1 != nil || err2 != nil { c.JSON(http.StatusOK, gin.H{ "update": latest.Version != current, }) return } if !lv.GreaterThan(cv) { c.JSON(http.StatusOK, gin.H{ "update": false, }) return } c.JSON(http.StatusOK, gin.H{ "update": true, "version": latest.Version, "notes": latest.Notes, "mandatory": latest.Mandatory, "sha256": latest.SHA256, "download": "/downloads/" + latest.APKFile, }) } // DownloadLatest redirects to the newest APK, for installing on a new tablet from a browser func DownloadLatest(c *gin.Context) { var latest models.Release if err := latestRelease(&latest); err != nil { c.JSON(http.StatusNotFound, gin.H{ "error": "No releases yet", }) return } c.Redirect(http.StatusFound, "/downloads/"+latest.APKFile) } func latestRelease(release *models.Release) error { return db.DB. Order("version_code desc"). First(release).Error } func ListReleases(c *gin.Context) { var releases []models.Release db.DB. Order("created_at desc"). Find(&releases) c.JSON(http.StatusOK, releases) } func UploadRelease(c *gin.Context) { notes := c.PostForm("notes") mandatory := c.PostForm("mandatory") == "true" file, err := c.FormFile("apk") if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": "APK required", }) return } // Save temporarily first because we need to inspect the APK tempPath := filepath.Join("uploads", "temp-"+file.Filename) if err := c.SaveUploadedFile(file, tempPath); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) 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 var latest models.Release if err := latestRelease(&latest); err == 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 } filename := "app-" + info.Version + ".apk" path := filepath.Join("uploads", filename) // Prevent overwriting an existing release if _, err := os.Stat(path); err == nil { c.JSON(http.StatusConflict, gin.H{ "error": "version already exists", }) return } // Move temp APK to final filename if err := os.Rename(tempPath, path); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) return } // Calculate SHA256 hash := sha256.New() f, err := os.Open(path) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) return } defer f.Close() if _, err := io.Copy(hash, f); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) return } release := models.Release{ Version: info.Version, VersionCode: int64(info.VersionCode), APKFile: filename, Notes: notes, Mandatory: mandatory, SHA256: hex.EncodeToString(hash.Sum(nil)), } if err := db.DB.Create(&release).Error; err != nil { // cleanup uploaded file if DB fails os.Remove(path) c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) return } c.JSON(http.StatusOK, release) } func DeleteRelease(c *gin.Context) { var release models.Release if err := db.DB.First(&release, c.Param("id")).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{ "error": "Not found", }) return } os.Remove(filepath.Join("uploads", release.APKFile)) db.DB.Delete(&release) c.JSON(http.StatusOK, gin.H{ "success": true, }) }