feat: clanker fixxes + postgress
This commit is contained in:
+155
-121
@@ -1,79 +1,74 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"kooltap-updateserver/internal/apk_util"
|
||||
"kooltap-updateserver/internal/auth"
|
||||
"kooltap-updateserver/internal/db"
|
||||
"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"
|
||||
)
|
||||
|
||||
func Register(r *gin.Engine) {
|
||||
// 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", CheckUpdate)
|
||||
api.GET("/releases", ListReleases)
|
||||
api.GET("/update", h.CheckUpdate)
|
||||
api.GET("/releases", h.ListReleases)
|
||||
}
|
||||
|
||||
r.GET("/download/latest", DownloadLatest)
|
||||
r.GET("/download/latest", h.DownloadLatest)
|
||||
|
||||
admin := r.Group("/admin")
|
||||
admin.Use(auth.AdminAuth())
|
||||
{
|
||||
admin.POST("/releases", UploadRelease)
|
||||
admin.DELETE("/releases/:id", DeleteRelease)
|
||||
admin.POST("/releases", h.UploadRelease)
|
||||
admin.DELETE("/releases/:id", h.DeleteRelease)
|
||||
}
|
||||
}
|
||||
|
||||
func CheckUpdate(c *gin.Context) {
|
||||
// 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) {
|
||||
|
||||
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,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
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,
|
||||
@@ -81,75 +76,105 @@ func CheckUpdate(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
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 DownloadLatest(c *gin.Context) {
|
||||
func (h *Handler) DownloadLatest(c *gin.Context) {
|
||||
|
||||
var latest models.Release
|
||||
|
||||
if err := latestRelease(&latest); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "No releases yet",
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
func latestRelease(release *models.Release) error {
|
||||
return db.DB.
|
||||
Order("version_code desc").
|
||||
First(release).Error
|
||||
// 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 ListReleases(c *gin.Context) {
|
||||
func (h *Handler) ListReleases(c *gin.Context) {
|
||||
|
||||
var releases []models.Release
|
||||
releases := []models.Release{}
|
||||
|
||||
db.DB.
|
||||
Order("created_at desc").
|
||||
Find(&releases)
|
||||
if err := h.DB.Order("created_at desc").Find(&releases).Error; err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, releases)
|
||||
}
|
||||
|
||||
func UploadRelease(c *gin.Context) {
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "APK required",
|
||||
})
|
||||
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 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(),
|
||||
})
|
||||
// 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(),
|
||||
})
|
||||
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 {
|
||||
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),
|
||||
@@ -157,42 +182,22 @@ func UploadRelease(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := "app-" + info.Version + ".apk"
|
||||
path := filepath.Join("uploads", filename)
|
||||
// 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",
|
||||
})
|
||||
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(),
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -202,41 +207,70 @@ func UploadRelease(c *gin.Context) {
|
||||
APKFile: filename,
|
||||
Notes: notes,
|
||||
Mandatory: mandatory,
|
||||
SHA256: hex.EncodeToString(hash.Sum(nil)),
|
||||
SHA256: sum,
|
||||
}
|
||||
|
||||
if err := db.DB.Create(&release).Error; err != nil {
|
||||
if err := h.DB.Create(&release).Error; err != nil {
|
||||
|
||||
// cleanup uploaded file if DB fails
|
||||
os.Remove(path)
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
// 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 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",
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
os.Remove(filepath.Join("uploads", release.APKFile))
|
||||
var release models.Release
|
||||
|
||||
db.DB.Delete(&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
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
})
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user