Files
Piik/cmd/screener-server/release.go
T
AAA da47189fc3 refactor: unify Go runtime and audited frontend
Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
2026-09-06 23:26:01 +08:00

243 lines
8.7 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
// defaultReleaseAPIURL is DEFAULT_RELEASE_API_URL: the same published release
// contract the Browser update notice uses (src/client/lib/release-update.ts).
const defaultReleaseAPIURL = "https://api.github.com/repos/TNTcraftHIM/Screener/releases/latest"
// Exit codes are UPDATE_EXIT_CODES. deploy/check-release.sh forwards them, so a
// checker failure stays distinguishable from "an update exists".
const (
exitUpToDate = 0
exitUpdateAvailable = 10
exitUpdateUnavailable = 20
// exitUsage is the exit code parseArguments set when --current-file was
// missing. Without it a forgotten argument reads as an unavailable check.
exitUsage = 2
)
// releaseUsage is the usage string parseArguments threw, with this binary's
// name in place of the script's.
const releaseUsage = "Usage: screener-server --check-release --current-file <REVISION> [--api-url <URL>]"
const (
// releaseTimeout bounds the whole check; an operator runs it interactively.
releaseTimeout = 5 * time.Second
// releasePathPrefix is RELEASE_PATH_PREFIX: the only accepted release page.
releasePathPrefix = "/TNTcraftHIM/Screener/releases/tag/"
releaseHost = "github.com"
releaseAPIHost = "api.github.com"
)
// fullRevisionPattern is FULL_REVISION applied to an already lowercased value,
// which is why the JavaScript case-insensitive flag has no counterpart here.
var fullRevisionPattern = regexp.MustCompile(`^[0-9a-f]{40}$`)
// Release status values are the "status" field of the printed JSON line, which
// deploy/check-release.sh reads; each maps to one exit code above.
const (
statusUpToDate = "up-to-date"
statusUpdateAvailable = "update-available"
statusUnavailable = "unavailable"
)
// releaseResult is the single JSON line the checker prints. Field order is the
// JSON.stringify order of the ported object, and the pointers are the nulls: a
// value the check could not establish is present and null, never omitted.
type releaseResult struct {
Status string `json:"status"`
CurrentRevision *string `json:"currentRevision"`
LatestRevision *string `json:"latestRevision"`
ReleaseURL *string `json:"releaseUrl"`
}
// checkDeployedRelease is the --check-release entry point. It writes one JSON
// line and returns the process exit code. An unreadable revision file is an
// unavailable check rather than an update, which is what the ported script's
// silently swallowed read failure meant; an absent --current-file is a usage
// error, which is what parseArguments made it.
func checkDeployedRelease(ctx context.Context, currentFile, apiURL string, out io.Writer) int {
if currentFile == "" {
fmt.Fprintln(os.Stderr, releaseUsage)
return exitUsage
}
current := ""
if content, err := os.ReadFile(currentFile); err == nil {
current = string(content)
}
result := checkRelease(ctx, current, apiURL, os.Getenv("GITHUB_TOKEN"))
encoder := json.NewEncoder(out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(result); err != nil {
return exitUpdateUnavailable
}
switch result.Status {
case statusUpdateAvailable:
return exitUpdateAvailable
case statusUpToDate:
return exitUpToDate
default:
return exitUpdateUnavailable
}
}
// checkRelease ports checkRelease. Every failure - an unusable current
// revision, an unusable endpoint, a redirect, a timeout, a non-2xx reply or
// metadata that fails validation - collapses to the same unavailable result:
// the checker never guesses that an update exists.
func checkRelease(ctx context.Context, currentRevision, apiURL, token string) releaseResult {
current := normalizeRevision(currentRevision)
if current == "" {
return unavailableResult(currentRevision)
}
endpoint, err := url.Parse(apiURL)
if err != nil || (endpoint.Scheme != "https" && endpoint.Scheme != "http") {
return unavailableResult(current)
}
ctx, cancel := context.WithTimeout(ctx, releaseTimeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return unavailableResult(current)
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("X-GitHub-Api-Version", "2022-11-28")
request.Header.Set("User-Agent", "Screener-release-check")
if authorizesGitHub(endpoint, token) {
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token))
}
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
// The ported redirect: "error" policy. A moved release endpoint is not
// followed, so the Authorization header cannot travel to another origin.
return errors.New("release endpoint redirected")
}}
response, err := client.Do(request)
if err != nil {
return unavailableResult(current)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode > 299 {
return unavailableResult(current)
}
body, err := io.ReadAll(response.Body)
if err != nil {
return unavailableResult(current)
}
latest, releaseURL, ok := parseReleaseMetadata(body)
if !ok {
return unavailableResult(current)
}
status := statusUpdateAvailable
if latest == current {
status = statusUpToDate
}
return releaseResult{
Status: status,
CurrentRevision: &current,
LatestRevision: &latest,
ReleaseURL: &releaseURL,
}
}
// authorizesGitHub reports whether the operator token may be sent to endpoint.
// A fixture, mirror or plaintext endpoint must never receive it, which is why
// the scheme and the host are both part of the rule.
func authorizesGitHub(endpoint *url.URL, token string) bool {
return endpoint.Scheme == "https" &&
strings.ToLower(endpoint.Hostname()) == releaseAPIHost &&
strings.TrimSpace(token) != ""
}
// unavailableResult is unavailableResult: the current revision is reported when
// it is well formed and null otherwise.
func unavailableResult(currentRevision string) releaseResult {
return releaseResult{Status: statusUnavailable, CurrentRevision: revisionOrNull(currentRevision)}
}
func revisionOrNull(value string) *string {
revision := normalizeRevision(value)
if revision == "" {
return nil
}
return &revision
}
// normalizeRevision ports normalizeRevision, returning "" where it returned
// null: only a full 40-hex commit identifies a release.
func normalizeRevision(value string) string {
revision := strings.ToLower(strings.TrimSpace(value))
if !fullRevisionPattern.MatchString(revision) {
return ""
}
return revision
}
// releaseMetadata is the subset of the GitHub release payload that is read.
// A tag_name or html_url of another type fails to decode, which is the
// rejection the JavaScript reached through typeof. Deviation: the JavaScript
// tested `draft === true`, so it read a draft of another type as "not a
// draft"; here such a payload fails to decode and the check reports
// unavailable. Only stricter, and a release payload the checker cannot
// understand must never be reported as an update.
type releaseMetadata struct {
TagName *string `json:"tag_name"`
HTMLURL *string `json:"html_url"`
Draft *bool `json:"draft"`
Prerelease *bool `json:"prerelease"`
}
// parseReleaseMetadata ports parseReleaseMetadata. It accepts one shape only: a
// published, non-draft release whose tag is a full commit revision and whose
// page is exactly https://github.com/TNTcraftHIM/Screener/releases/tag/<that
// revision> with no credentials, port, query or fragment. The returned URL is
// rebuilt from the validated parts so nothing unverified reaches the operator.
func parseReleaseMetadata(payload []byte) (revision string, releaseURL string, ok bool) {
var metadata releaseMetadata
if err := json.Unmarshal(payload, &metadata); err != nil {
return "", "", false
}
if (metadata.Draft != nil && *metadata.Draft) ||
(metadata.Prerelease != nil && *metadata.Prerelease) {
return "", "", false
}
if metadata.TagName == nil || metadata.HTMLURL == nil {
return "", "", false
}
revision = normalizeRevision(*metadata.TagName)
if revision == "" {
return "", "", false
}
parsed, err := url.Parse(*metadata.HTMLURL)
if err != nil || parsed.Scheme != "https" ||
strings.ToLower(parsed.Hostname()) != releaseHost || parsed.User != nil ||
parsed.Port() != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", "", false
}
// The escaped path is compared, so an encoded separator such as %2F cannot
// smuggle another path past the prefix the way the decoded form would.
path := parsed.EscapedPath()
if !strings.HasPrefix(path, releasePathPrefix) {
return "", "", false
}
tag, err := url.PathUnescape(strings.TrimPrefix(path, releasePathPrefix))
if err != nil || normalizeRevision(tag) != revision {
return "", "", false
}
return revision, "https://" + releaseHost + path, true
}