243 lines
4.8 KiB
Go
243 lines
4.8 KiB
Go
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,
|
|
})
|
|
}
|