diff --git a/cmd/server/main.go b/cmd/server/main.go index 1590f8e..78f9896 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,8 +1,8 @@ package main import ( - "kooltab-updateserver/internal/db" - "kooltab-updateserver/internal/routes" + "kooltap-updateserver/internal/db" + "kooltap-updateserver/internal/routes" "os" "github.com/gin-gonic/gin" @@ -28,7 +28,7 @@ func main() { }) r.Use(func(c *gin.Context) { - c.Header("Server", "KoolTab Updater") + c.Header("Server", "KoolTap Updater") c.Next() }) diff --git a/docker-compose.coolify.yaml b/docker-compose.coolify.yaml index 46dd820..2943782 100644 --- a/docker-compose.coolify.yaml +++ b/docker-compose.coolify.yaml @@ -1,5 +1,5 @@ services: - kooltab-updater: + kooltap-updater: build: context: . dockerfile: Dockerfile @@ -8,16 +8,24 @@ services: environment: GIN_MODE: release + # Generated by Coolify (see Environment Variables); needed in the X-Admin-Token header to upload releases + ADMIN_TOKEN: ${SERVICE_PASSWORD_64_ADMINTOKEN} volumes: - - kooltab_uploads:/app/uploads - - kooltab_tmp:/app/tmp - - kooltab_data:/app/data + - kooltap_uploads:/app/uploads + - kooltap_tmp:/app/tmp + - kooltap_data:/app/data expose: - "3000" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"] + interval: 15s + timeout: 5s + retries: 5 + volumes: - kooltab_uploads: - kooltab_tmp: - kooltab_data: \ No newline at end of file + kooltap_uploads: + kooltap_tmp: + kooltap_data: diff --git a/docker-compose.yaml b/docker-compose.yaml index 71214c9..6c5224b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,9 +1,9 @@ version: "3.9" services: - kooltab-updater: - image: kooltab-updater:latest - container_name: kooltab-updater + kooltap-updater: + image: kooltap-updater:latest + container_name: kooltap-updater restart: unless-stopped #ports: @@ -11,6 +11,7 @@ services: environment: GIN_MODE: release + ADMIN_TOKEN: ${ADMIN_TOKEN} volumes: - ./uploads:/app/uploads diff --git a/go.mod b/go.mod index d00f1c0..e32ea0d 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module kooltab-updateserver +module kooltap-updateserver go 1.26 diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 02bae4b..04e7abe 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,6 +1,7 @@ package auth import ( + "crypto/subtle" "net/http" "os" ) @@ -15,7 +16,9 @@ func AdminAuth() gin.HandlerFunc { return func(c *gin.Context) { - if c.GetHeader("X-Admin-Token") != token { + // Without a configured token every admin request is refused, never allowed + provided := c.GetHeader("X-Admin-Token") + if token == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "Unauthorized", }) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..e2072e2 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,45 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func status(t *testing.T, configured, provided string) int { + t.Setenv("ADMIN_TOKEN", configured) + gin.SetMode(gin.TestMode) + + r := gin.New() + r.POST("/admin", AdminAuth(), func(c *gin.Context) { c.Status(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodPost, "/admin", nil) + if provided != "" { + req.Header.Set("X-Admin-Token", provided) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w.Code +} + +func TestAdminAuth(t *testing.T) { + cases := []struct { + name, configured, provided string + want int + }{ + {"no token configured refuses requests without a header", "", "", http.StatusUnauthorized}, + {"no token configured refuses any header", "", "anything", http.StatusUnauthorized}, + {"wrong token", "secret", "nope", http.StatusUnauthorized}, + {"missing header", "secret", "", http.StatusUnauthorized}, + {"correct token", "secret", "secret", http.StatusOK}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := status(t, tc.configured, tc.provided); got != tc.want { + t.Fatalf("got %d, want %d", got, tc.want) + } + }) + } +} diff --git a/internal/db/db.go b/internal/db/db.go index bfd584a..fe0db2a 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,7 +1,7 @@ package db import ( - "kooltab-updateserver/internal/models" + "kooltap-updateserver/internal/models" "log" "github.com/glebarez/sqlite" diff --git a/internal/routes/releases.go b/internal/routes/releases.go index 3b24208..b24004b 100644 --- a/internal/routes/releases.go +++ b/internal/routes/releases.go @@ -3,11 +3,12 @@ package routes import ( "crypto/sha256" "encoding/hex" + "fmt" "io" - "kooltab-updateserver/internal/apk_util" - "kooltab-updateserver/internal/auth" - "kooltab-updateserver/internal/db" - "kooltab-updateserver/internal/models" + "kooltap-updateserver/internal/apk_util" + "kooltap-updateserver/internal/auth" + "kooltap-updateserver/internal/db" + "kooltap-updateserver/internal/models" "net/http" "os" "path/filepath" @@ -24,6 +25,8 @@ func Register(r *gin.Engine) { api.GET("/releases", ListReleases) } + r.GET("/download/latest", DownloadLatest) + admin := r.Group("/admin") admin.Use(auth.AdminAuth()) { @@ -38,9 +41,8 @@ func CheckUpdate(c *gin.Context) { var latest models.Release - if err := db.DB. - Order("created_at desc"). - First(&latest).Error; err != nil { + // 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, @@ -79,6 +81,27 @@ func CheckUpdate(c *gin.Context) { }) } +// 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 @@ -124,6 +147,16 @@ func UploadRelease(c *gin.Context) { 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)