diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml deleted file mode 100644 index f0a9d5c..0000000 --- a/.github/workflows/docker-image.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Docker - -on: - push: - branches: [ "main" ] - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install cosign - if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@v3.5.0 - with: - cosign-release: 'v2.1.1' - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - platforms: linux/amd64,linux/arm64 - - - name: Log into registry ${{ env.REGISTRY }} - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - - name: Build and push Docker image - id: build-and-push - uses: docker/build-push-action@v5 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - platforms: linux/amd64,linux/arm64 - - - name: Sign the published Docker image - if: ${{ github.event_name != 'pull_request' }} - env: - DIGEST: ${{ steps.build-and-push.outputs.digest }} - run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..248db1f --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,48 @@ +name: release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + packages: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + cache: true + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e05fb40..9fc1007 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.22.3' + go-version: '1.26.1' - name: Build run: go build -v ./... diff --git a/.gitignore b/.gitignore index d0f7eee..6df1f31 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ -.env** -data/** +.env +data/ +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..4017c88 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,67 @@ +version: 2 + +before: + hooks: + - go mod tidy + +snapshot: + version_template: "{{ .Version }}" + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + flags: + - -trimpath + +archives: + - format: tar.gz + name_template: >- + {{ .ProjectName }}_ + {{- .Version }}_ + {{- .Os }}_ + {{- .Arch }} + +dockers: + - image_templates: + - "ghcr.io/skidoodle/{{ .ProjectName }}:{{ .Tag }}-amd64" + - "ghcr.io/skidoodle/{{ .ProjectName }}:latest-amd64" + dockerfile: Dockerfile.release + use: buildx + goos: linux + goarch: amd64 + extra_files: + - web/ + + - image_templates: + - "ghcr.io/skidoodle/{{ .ProjectName }}:{{ .Tag }}-arm64" + - "ghcr.io/skidoodle/{{ .ProjectName }}:latest-arm64" + dockerfile: Dockerfile.release + use: buildx + goos: linux + goarch: arm64 + extra_files: + - web/ + +docker_manifests: + - name_template: "ghcr.io/skidoodle/{{ .ProjectName }}:{{ .Tag }}" + image_templates: + - "ghcr.io/skidoodle/{{ .ProjectName }}:{{ .Tag }}-amd64" + - "ghcr.io/skidoodle/{{ .ProjectName }}:{{ .Tag }}-arm64" + - name_template: "ghcr.io/skidoodle/{{ .ProjectName }}:latest" + image_templates: + - "ghcr.io/skidoodle/{{ .ProjectName }}:latest-amd64" + - "ghcr.io/skidoodle/{{ .ProjectName }}:latest-arm64" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/Dockerfile b/Dockerfile index c856520..fa58661 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,31 @@ -FROM golang:alpine AS builder +FROM golang:1.26.1-alpine AS builder + +RUN apk update && apk add --no-cache git ca-certificates tzdata + +RUN addgroup -S -g 10001 appgroup && \ + adduser -S -u 10001 -G appgroup appuser + WORKDIR /build COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ncore-stats . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o ncore-stats . + +RUN mkdir -p /app/data && chown -R 10001:10001 /app/data + +FROM scratch + +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/group /etc/group +COPY --from=builder --chown=10001:10001 /build/ncore-stats /app/ncore-stats +COPY --from=builder --chown=10001:10001 /build/web /app/web +COPY --from=builder --chown=10001:10001 /app/data /app/data -FROM alpine:3 -RUN apk add --no-cache ca-certificates WORKDIR /app -COPY --from=builder /out/ncore-stats . -COPY web ./web +USER 10001 EXPOSE 3000 CMD ["./ncore-stats"] diff --git a/Dockerfile.release b/Dockerfile.release new file mode 100644 index 0000000..0b33f9b --- /dev/null +++ b/Dockerfile.release @@ -0,0 +1,26 @@ +FROM alpine:latest AS sys-context + +RUN apk add --no-cache ca-certificates tzdata + +RUN echo "appuser:x:10001:10001:appuser:/:/sbin/nologin" > /etc/passwd_app \ + && echo "appuser:x:10001:appuser" > /etc/group_app + +RUN mkdir -p /app/data && chown -R 10001:10001 /app + +FROM scratch + +COPY --from=sys-context /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=sys-context /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=sys-context /etc/passwd_app /etc/passwd +COPY --from=sys-context /etc/group_app /etc/group +COPY --from=sys-context --chown=10001:10001 /app /app + +# Binaries and static files provided by goreleaser +COPY --chown=10001:10001 ncore-stats /app/ncore-stats +COPY --chown=10001:10001 web /app/web + +WORKDIR /app +USER 10001 +EXPOSE 3000 + +ENTRYPOINT ["/app/ncore-stats"] diff --git a/docker-compose.dev.yaml b/compose.dev.yaml similarity index 100% rename from docker-compose.dev.yaml rename to compose.dev.yaml diff --git a/docker-compose.yaml b/compose.yaml similarity index 82% rename from docker-compose.yaml rename to compose.yaml index 19aceb0..7460dc4 100644 --- a/docker-compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ services: ncore-stats: - image: ghcr.io/skidoodle/ncore-stats:main + image: ghcr.io/skidoodle/ncore-stats:latest container_name: ncore-stats restart: unless-stopped ports: @@ -10,5 +10,6 @@ services: environment: - NICK=${NICK} - PASSWORD=${PASS} + volumes: data: diff --git a/database.go b/database.go index 5f8828b..c06c1ff 100644 --- a/database.go +++ b/database.go @@ -19,16 +19,82 @@ func initDB(cfg *Configuration) *sql.DB { schemas := []string{ `CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, display_name TEXT UNIQUE, profile_id TEXT);`, - `CREATE TABLE IF NOT EXISTS profile_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, timestamp DATETIME, rank INTEGER, upload TEXT, current_upload TEXT, current_download TEXT, points INTEGER, seeding_count INTEGER, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE);`, + `CREATE TABLE IF NOT EXISTS profile_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + timestamp DATETIME, + rank INTEGER, + upload TEXT, + upload_bytes INTEGER, + current_upload TEXT, + current_download TEXT, + points INTEGER, + seeding_count INTEGER, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + );`, + `CREATE INDEX IF NOT EXISTS idx_history_user_ts ON profile_history(user_id, timestamp);`, } for _, s := range schemas { if _, err := db.Exec(s); err != nil { logrus.Fatalf("Schema error: %v", err) } } + + migrate(db) + return db } +func migrate(db *sql.DB) { + var columnExists bool + _ = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('profile_history') WHERE name='upload_bytes'").Scan(&columnExists) + if !columnExists { + logrus.Info("Migrating: Adding upload_bytes column...") + _, err := db.Exec("ALTER TABLE profile_history ADD COLUMN upload_bytes INTEGER") + if err != nil { + logrus.Errorf("Migration failed (add column): %v", err) + return + } + + logrus.Info("Migrating: Backfilling upload_bytes from existing strings...") + + type updateRow struct { + id int64 + bytes int64 + } + var updates []updateRow + + rows, err := db.Query("SELECT id, upload FROM profile_history WHERE upload_bytes IS NULL") + if err == nil { + for rows.Next() { + var id int64 + var upload string + if err := rows.Scan(&id, &upload); err == nil { + updates = append(updates, updateRow{id: id, bytes: parseToBytes(upload)}) + } + } + rows.Close() + } + + if len(updates) > 0 { + tx, err := db.Begin() + if err != nil { + logrus.Errorf("Transaction start failed: %v", err) + return + } + stmt, _ := tx.Prepare("UPDATE profile_history SET upload_bytes = ? WHERE id = ?") + for _, up := range updates { + _, _ = stmt.Exec(up.bytes, up.id) + } + stmt.Close() + if err := tx.Commit(); err != nil { + logrus.Errorf("Transaction commit failed: %v", err) + } + } + logrus.Info("Migration complete.") + } +} + func (s *State) getLatest() ([]ProfileData, error) { query := ` SELECT u.display_name, ph.timestamp, ph.rank, ph.upload, ph.current_upload, ph.current_download, ph.points, ph.seeding_count diff --git a/handlers.go b/handlers.go index 4102183..ce615fa 100644 --- a/handlers.go +++ b/handlers.go @@ -5,6 +5,7 @@ import ( "fmt" "html/template" "net/http" + "time" "github.com/sirupsen/logrus" ) @@ -27,7 +28,7 @@ func (s *State) historyHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Owner required", http.StatusBadRequest) return } - rows, err := s.db.Query(`SELECT u.display_name, ph.timestamp, ph.rank, ph.upload, ph.current_upload, ph.current_download, ph.points, ph.seeding_count FROM profile_history ph JOIN users u ON ph.user_id = u.id WHERE u.display_name = ? ORDER BY ph.timestamp ASC`, owner) + rows, err := s.db.Query(`SELECT ph.timestamp, ph.rank, ph.upload, ph.points, ph.seeding_count FROM profile_history ph JOIN users u ON ph.user_id = u.id WHERE u.display_name = ? ORDER BY ph.timestamp ASC`, owner) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return @@ -37,8 +38,7 @@ func (s *State) historyHandler(w http.ResponseWriter, r *http.Request) { var history []ProfileData for rows.Next() { var p ProfileData - if err := rows.Scan(&p.Owner, &p.Timestamp, &p.Rank, &p.Upload, &p.CurrentUpload, &p.CurrentDownload, &p.Points, &p.SeedingCount); err != nil { - logrus.Errorf("Scan history failed: %v", err) + if err := rows.Scan(&p.Timestamp, &p.Rank, &p.Upload, &p.Points, &p.SeedingCount); err != nil { continue } history = append(history, p) @@ -53,9 +53,47 @@ func (s *State) historyModalHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Owner required", http.StatusBadRequest) return } - fmt.Fprintf(w, `
`, owner, owner) -} + rows, err := s.db.Query(`SELECT ph.timestamp, ph.rank, ph.upload_bytes, ph.points, ph.seeding_count + FROM profile_history ph + JOIN users u ON ph.user_id = u.id + WHERE u.display_name = ? + ORDER BY ph.timestamp ASC`, owner) + if err != nil { + http.Error(w, "DB Error", http.StatusInternalServerError) + return + } + defer rows.Close() + + res := CompactHistory{Owner: owner} + for rows.Next() { + var ( + ts time.Time + rank int + uploadBytes int64 + points int + seeding int + ) + if err := rows.Scan(&ts, &rank, &uploadBytes, &points, &seeding); err == nil { + res.Timestamp = append(res.Timestamp, ts.Unix()*1000) + res.Rank = append(res.Rank, rank) + + tib := float64(uploadBytes) / (1024 * 1024 * 1024 * 1024) + res.Upload = append(res.Upload, tib) + + res.Points = append(res.Points, points) + res.Seeding = append(res.Seeding, seeding) + } + } + + dataJSON, _ := json.Marshal(res) + + fmt.Fprintf(w, ` +No history available.
'; return; } - const parseUploadValue = (value) => { - if (typeof value !== 'string') return 0; - const num = parseFloat(value.replace(/,/g, '').replace(/TiB|GiB|MiB|KiB|B/i, '').trim()); - if (isNaN(num)) return 0; - const lowerVal = value.toLowerCase(); - if (lowerVal.includes('tib')) return num; - if (lowerVal.includes('gib')) return num / 1024; - if (lowerVal.includes('mib')) return num / 1024 / 1024; - if (lowerVal.includes('kib')) return num / 1024 / 1024 / 1024; - return num / 1024 / 1024 / 1024 / 1024; - }; const series = [ { name: 'Upload', - data: historyData.map(r => ({ x: new Date(r.timestamp).getTime(), y: parseUploadValue(r.upload) })) + data: data.t.map((ts, i) => ({ x: ts, y: data.u[i] })) }, { name: 'Rank', - data: historyData.map(r => ({ x: new Date(r.timestamp).getTime(), y: r.rank })) + data: data.t.map((ts, i) => ({ x: ts, y: data.r[i] })) }, { name: 'Points', - data: historyData.map(r => ({ x: new Date(r.timestamp).getTime(), y: r.points })) + data: data.t.map((ts, i) => ({ x: ts, y: data.p[i] })) }, { name: 'Seeding', - data: historyData.map(r => ({ x: new Date(r.timestamp).getTime(), y: r.seeding_count })) + data: data.t.map((ts, i) => ({ x: ts, y: data.s[i] })) } ]; - root.innerHTML = ''; - const options = { series: series, chart: { @@ -61,7 +45,7 @@ async function renderChart(owner) { foreColor: '#71717a', fontFamily: 'Inter, system-ui, sans-serif', toolbar: { show: false }, - animations: { enabled: true, easing: 'easeinout', speed: 800 } + animations: { enabled: false } }, responsive: [ { @@ -166,6 +150,7 @@ async function renderChart(owner) { currentChart.render(); } catch (e) { + console.error(e); root.innerHTML = `Error: ${e.message}