From 4b6b098e1650af413d1fb95644dac68fa4bec50e Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Tue, 16 Jun 2026 12:35:01 +0500 Subject: [PATCH 01/71] feat: add CI/CD pipelines and repository management templates --- .github/ISSUE_TEMPLATE/bug_report.md | 32 ++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 19 +++++++ .github/PULL_REQUEST_TEMPLATE.md | 26 ++++++++++ .github/workflows/ci.yml | 38 ++++++++++++++ .github/workflows/docker.yml | 61 +++++++++++++++++++++++ README.md | 4 ++ 6 files changed, 180 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..aba46cc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG] " +labels: bug +assignees: '' +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Environment (please complete the following information):** + - OS: [e.g. Ubuntu 22.04] + - Deployment Method: [e.g. Docker, Binary] + - Browser: [e.g. Chrome, Firefox] + - Version: [e.g. v1.0.0 or commit hash] + +**Additional context** +Add any other context about the problem here (e.g. logs from the docker container). diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..0e06717 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FEATURE] " +labels: enhancement +assignees: '' +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..56705f1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ +## Description +Please include a summary of the changes and the related issue. Please also include relevant motivation and context. + +Fixes # (issue) + +## Type of change +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update + +## How Has This Been Tested? +Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. + +- [ ] Unit Tests +- [ ] Manual Verification + +## Checklist: +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b3c2f0c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: './web/package-lock.json' + + - name: Build Frontend + working-directory: ./web + run: | + npm ci + npm run build + + - name: Test Go Backend + run: go test -v ./... + + - name: Build Go Backend + run: go build -v -o aptify ./cmd/server diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..e20402e --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,61 @@ +name: Docker Image CI/CD + +on: + push: + branches: [ "main" ] + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "main" ] + +env: + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Set up Buildx for multi-architecture builds + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Login against a Docker registry except on PR + - name: Log into Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/aptify + tags: | + type=schedule + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + # Build and push Docker image with Buildx (don't push on PR) + - name: Build and push Docker image + 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 diff --git a/README.md b/README.md index 9d416de..a89df20 100644 --- a/README.md +++ b/README.md @@ -74,3 +74,7 @@ Your support directly funds: ## Security Authentication is backed by JWTs. Package filenames and upload paths are strictly validated. All file uploads are protected against DoS via size limits. The GPG signing key is exported through an authenticated API endpoint, never via URL parameters. + +## Contact + +For inquiries, support, or security reports, please contact: [aptify@kernelcode.de](mailto:aptify@kernelcode.de) From e05b0ae4e603a1234747a43b68c9e398d92f7502 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Tue, 16 Jun 2026 12:38:41 +0500 Subject: [PATCH 02/71] chore: add missing environment variables to .env.example --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 8af7787..b2d34c7 100644 --- a/.env.example +++ b/.env.example @@ -9,4 +9,4 @@ KEY_EMAIL="apt@example.com" # By default Aptify uses SQLite. # Uncomment the lines below to use MySQL instead. # DB_TYPE=mysql -# DB_DSN=aptify:aptifypass@tcp(db:3306)/aptify?parseTime=true +# DB_DSN=aptify:aptifypass@tcp(db:3306)/aptify?parseTime=true \ No newline at end of file From 3d243e06082b59e175995157023fc0b69e7bfb02 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Tue, 16 Jun 2026 12:42:22 +0500 Subject: [PATCH 03/71] fix: correct .gitignore to track cmd/server --- .gitignore | 2 +- cmd/server/main.go | 109 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 cmd/server/main.go diff --git a/.gitignore b/.gitignore index 8658554..a7abd30 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Dependencies web/node_modules/ -server +/server # Build outputs internal/web/dist/ diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..252bf3a --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "log" + "net/http" + "os" + "path/filepath" + + "github.com/kernelcode0/aptify/internal/api" + "github.com/kernelcode0/aptify/internal/index" + "github.com/kernelcode0/aptify/internal/signing" + "github.com/kernelcode0/aptify/internal/storage" + "github.com/kernelcode0/aptify/internal/web" + "golang.org/x/crypto/bcrypt" +) + +func main() { + dataDir := os.Getenv("DATA_DIR") + if dataDir == "" { + dataDir = "./data" + } + + if err := os.MkdirAll(dataDir, 0755); err != nil { + log.Fatalf("failed to create data dir: %v", err) + } + + dbType := os.Getenv("DB_TYPE") + dbDsn := os.Getenv("DB_DSN") + if dbType == "" || dbType == "sqlite" { + dbType = "sqlite" + if dbDsn == "" { + dbDsn = filepath.Join(dataDir, "aptify.sqlite") + } + } + + db, err := storage.Open(dbType, dbDsn) + if err != nil { + log.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + // Ensure admin user exists + adminUser := os.Getenv("ADMIN_USERNAME") + if adminUser == "" { + adminUser = "admin" + } + adminPass := os.Getenv("ADMIN_PASSWORD") + if adminPass == "" { + adminPass = "admin123" + } + + u, err := db.GetUserByUsername(adminUser) + if err != nil { + log.Fatalf("failed to get admin user: %v", err) + } + if u == nil { + hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost) + if err != nil { + log.Fatalf("failed to hash password: %v", err) + } + if _, err := db.CreateUser(adminUser, string(hash)); err != nil { + log.Fatalf("failed to create admin user: %v", err) + } + log.Printf("Created default admin user: %s", adminUser) + } + + fs, err := storage.NewFileStore(dataDir) + if err != nil { + log.Fatalf("failed to init filestore: %v", err) + } + + keyName := os.Getenv("KEY_NAME") + if keyName == "" { + keyName = "Aptify Repository" + } + keyEmail := os.Getenv("KEY_EMAIL") + if keyEmail == "" { + keyEmail = "admin@aptify.local" + } + + keyPath := filepath.Join(dataDir, "repo.key") + pubPath := filepath.Join(dataDir, "repo.pub") + signer, err := signing.LoadOrGenerate(keyPath, pubPath, keyName, keyEmail) + if err != nil { + log.Fatalf("failed to load/generate GPG key: %v", err) + } + + gen := index.New(fs, signer) + + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + jwtSecret = "super_secret_jwt_string_change_me" // Fallback if missing + } + + handler := api.New(db, fs, gen, signer, jwtSecret) + + spa := web.Handler() + r := handler.Router(spa) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("Starting Aptify server on :%s", port) + if err := http.ListenAndServe(":"+port, r); err != nil { + log.Fatalf("server error: %v", err) + } +} From 261d00a734f2b9460fbad44e2c3d585828194f41 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Tue, 16 Jun 2026 12:56:51 +0500 Subject: [PATCH 04/71] feat: integrate automatic version tagging and update docker image tagging logic --- .github/workflows/docker.yml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e20402e..6c5278c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -3,7 +3,6 @@ name: Docker Image CI/CD on: push: branches: [ "main" ] - tags: [ 'v*.*.*' ] pull_request: branches: [ "main" ] @@ -15,7 +14,7 @@ jobs: build-and-push: runs-on: ubuntu-latest permissions: - contents: read + contents: write packages: write steps: @@ -34,6 +33,15 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + # Bump version and create git tag + - name: Bump version and push tag + id: tag_version + if: github.event_name != 'pull_request' + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: patch + # Extract metadata (tags, labels) for Docker - name: Extract Docker metadata id: meta @@ -41,13 +49,9 @@ jobs: with: images: ${{ secrets.DOCKERHUB_USERNAME }}/aptify tags: | - type=schedule - type=ref,event=branch type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha + type=raw,value=${{ steps.tag_version.outputs.new_version }},enable=${{ github.event_name != 'pull_request' }} + type=raw,value=latest,enable={{is_default_branch}} # Build and push Docker image with Buildx (don't push on PR) - name: Build and push Docker image From 294b6551eed477c3514615aee3ec2ab0e8aeb134 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Tue, 16 Jun 2026 13:06:36 +0500 Subject: [PATCH 05/71] chore: add build artifacts to gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a7abd30..bf425f9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,4 @@ mysql-data/ apt-repository # Environment -.env \ No newline at end of file +.env From a5867e42cd7ad90c77fc0aff5fca6ccaf458cbb2 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Tue, 16 Jun 2026 14:04:19 +0500 Subject: [PATCH 06/71] feat: add new Aptify favicon and update link in index.html --- web/index.html | 2 +- web/public/favicon.svg | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 web/public/favicon.svg diff --git a/web/index.html b/web/index.html index 0d8abb4..be9f366 100644 --- a/web/index.html +++ b/web/index.html @@ -2,7 +2,7 @@ - + Aptify — Self-hosted APT, simplified diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..81486c2 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + From 978bbf8f6b85358d78f0ec9bac233c851ab8aeeb Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Tue, 16 Jun 2026 16:00:42 +0500 Subject: [PATCH 07/71] feat: replace package icon with new Aptify brand mark in header and favicon --- web/public/favicon.svg | 14 +++++++------- web/src/components/Layout.css | 10 ++++++---- web/src/components/Layout.tsx | 15 +++++++++------ 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/web/public/favicon.svg b/web/public/favicon.svg index 81486c2..2181535 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1,8 +1,8 @@ - - - - - - - + + + + + + + diff --git a/web/src/components/Layout.css b/web/src/components/Layout.css index 0144ab3..9bcee3c 100644 --- a/web/src/components/Layout.css +++ b/web/src/components/Layout.css @@ -40,13 +40,15 @@ justify-content: center; width: 34px; height: 34px; - background: linear-gradient(135deg, rgba(45, 212, 191, 0.18), rgba(245, 158, 11, 0.12)); - border: 1px solid rgba(45, 212, 191, 0.2); - border-radius: 8px; - color: var(--accent-hover); flex-shrink: 0; } +.nav-brand-svg { + display: block; + width: 34px; + height: 34px; +} + .nav-brand-text { display: flex; flex-direction: column; diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 698b904..fef15c0 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -3,12 +3,15 @@ import { useEffect, useState } from 'react' import { api, setToken } from '../api' import './Layout.css' -function PackageIcon({ size = 18 }: { size?: number }) { +function AptifyMark() { return ( - - - - + + + + + + + ) } @@ -51,7 +54,7 @@ export default function Layout() {
- +
) diff --git a/web/src/index.css b/web/src/index.css index 6fd3127..14cb423 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -91,7 +91,7 @@ button.danger { } button.danger:hover { background: var(--danger-subtle); border-color: rgba(251, 113, 133, 0.35); } -input, textarea { +input, textarea, select { width: 100%; background: #11151d; border: 1px solid var(--border); @@ -102,7 +102,10 @@ input, textarea { padding: 10px 12px; transition: border-color 0.16s, box-shadow 0.16s, background 0.16s; } -input:focus, textarea:focus { +select { + appearance: none; +} +input:focus, textarea:focus, select:focus { outline: none; background: #121922; border-color: var(--accent-strong); diff --git a/web/src/pages/AuditLog.css b/web/src/pages/AuditLog.css new file mode 100644 index 0000000..c3333a9 --- /dev/null +++ b/web/src/pages/AuditLog.css @@ -0,0 +1,21 @@ +.audit-table td { + vertical-align: top; +} + +.audit-detail { + margin: 0; + padding: 8px 10px; + max-width: 360px; + white-space: pre-wrap; + font-size: 11px; + line-height: 1.45; +} + +.audit-raw { + display: inline-block; + max-width: 360px; + color: var(--text-2); + overflow-wrap: anywhere; + font-family: var(--mono); + font-size: 11px; +} diff --git a/web/src/pages/AuditLog.tsx b/web/src/pages/AuditLog.tsx new file mode 100644 index 0000000..bb14fdf --- /dev/null +++ b/web/src/pages/AuditLog.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react' +import { api, type AuditEntry } from '../api' +import './AuditLog.css' + +function AlertIcon() { + return ( + + + + + + ) +} + +export default function AuditLog() { + const [entries, setEntries] = useState([]) + const [total, setTotal] = useState(0) + const [offset, setOffset] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const limit = 50 + + useEffect(() => { + setLoading(true) + api.listAudit(offset, limit) + .then(data => { + setEntries(data.entries ?? []) + setTotal(data.total) + }) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)) + }, [offset]) + + return ( +
+
+
+
Operations
+

Audit Log

+

{total} entr{total === 1 ? 'y' : 'ies'}

+
+
+ + {error &&
{error}
} + + {loading ? ( +
+ ) : ( +
+ + + + + + + + + + + + {entries.map(e => ( + + + + + + + + ))} + {entries.length === 0 && ( + + + + )} + +
UserActionResourceDetailTime
{e.username}{formatAction(e.action)}{e.resource}{formatDateTime(e.created_at)}
No audit entries yet.
+
+ + {total === 0 ? 'Page 0 of 0' : `Page ${Math.floor(offset / limit) + 1} of ${Math.ceil(total / limit)}`} + +
+
+ )} +
+ ) +} + +function Detail({ value }: { value: string }) { + if (!value) return - + try { + return
{JSON.stringify(JSON.parse(value), null, 2)}
+ } catch { + return {value} + } +} + +function formatAction(action: string) { + return action.split('_').join(' ') +} + +function formatDateTime(s: string) { + return new Date(s).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 01c1a50..47a3872 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' -import { api, getToken, type Repo } from '../api' +import { Link, useNavigate, useOutletContext } from 'react-router-dom' +import { api, getToken, type CurrentUser, type Repo } from '../api' import './Dashboard.css' function PlusIcon() { @@ -58,6 +58,7 @@ function AlertIcon() { } export default function Dashboard() { + const { user } = useOutletContext<{ user: CurrentUser | null }>() const [repos, setRepos] = useState([]) const [loading, setLoading] = useState(true) const [showNew, setShowNew] = useState(false) @@ -124,10 +125,12 @@ export default function Dashboard() { Export GPG Key - + {user?.role === 'admin' && ( + + )}
diff --git a/web/src/pages/RepoDetail.tsx b/web/src/pages/RepoDetail.tsx index 2e5a77e..9826fe4 100644 --- a/web/src/pages/RepoDetail.tsx +++ b/web/src/pages/RepoDetail.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' -import { useParams, useNavigate, Link } from 'react-router-dom' -import { api, type Package, type Repo, type RepoStatus, type SetupInfo } from '../api' +import { useParams, useNavigate, Link, useOutletContext } from 'react-router-dom' +import { api, type CurrentUser, type Package, type Repo, type RepoStatus, type SetupInfo } from '../api' import './RepoDetail.css' type Tab = 'packages' | 'setup' @@ -73,6 +73,7 @@ function EmptyPackagesIcon() { } export default function RepoDetail() { + const { user } = useOutletContext<{ user: CurrentUser | null }>() const { id } = useParams<{ id: string }>() const navigate = useNavigate() const [repo, setRepo] = useState(null) @@ -90,6 +91,8 @@ export default function RepoDetail() { const [dragOver, setDragOver] = useState(false) const [indexStatus, setIndexStatus] = useState(null) const inputRef = useRef(null) + const canManageRepo = user?.role === 'admin' + const canManagePackages = user?.role === 'admin' || user?.role === 'member' const loadRepo = async () => { if (!id) return @@ -234,16 +237,18 @@ export default function RepoDetail() { {totalPkgs} package{totalPkgs !== 1 ? 's' : ''} -
- - -
+ {canManageRepo && ( +
+ + +
+ )} {editing && ( @@ -305,33 +310,35 @@ export default function RepoDetail() { } )} -
{ e.preventDefault(); setDragOver(true) }} - onDragLeave={() => setDragOver(false)} - onDrop={handleDrop} - onClick={() => inputRef.current?.click()} - > - { const f = e.target.files?.[0]; if (f) handleUpload(f) }} /> - {uploading ? ( -
-
-
-
Uploading and indexing…
-
This may take a moment
+ {canManagePackages && ( +
{ e.preventDefault(); setDragOver(true) }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + onClick={() => inputRef.current?.click()} + > + { const f = e.target.files?.[0]; if (f) handleUpload(f) }} /> + {uploading ? ( +
+
+
+
Uploading and indexing…
+
This may take a moment
+
-
- ) : ( -
- -
-
Drop a .deb file here
-
or click to browse your files
+ ) : ( +
+ +
+
Drop a .deb file here
+
or click to browse your files
+
-
- )} -
+ )} +
+ )} {packages.length === 0 ? (
@@ -361,9 +368,11 @@ export default function RepoDetail() { {formatBytes(p.size)} {formatDate(p.uploaded_at)} - + {canManagePackages && ( + + )} ))} diff --git a/web/src/pages/Users.css b/web/src/pages/Users.css new file mode 100644 index 0000000..c589790 --- /dev/null +++ b/web/src/pages/Users.css @@ -0,0 +1,62 @@ +.role-badge { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + text-transform: capitalize; + border: 1px solid var(--border); +} + +.role-admin { + background: rgba(251, 113, 133, 0.12); + color: #fda4af; + border-color: rgba(251, 113, 133, 0.28); +} + +.role-member { + background: rgba(96, 165, 250, 0.12); + color: #93c5fd; + border-color: rgba(96, 165, 250, 0.28); +} + +.role-viewer { + background: rgba(148, 163, 184, 0.12); + color: #cbd5e1; + border-color: rgba(148, 163, 184, 0.24); +} + +.self-label { + display: inline-flex; + align-items: center; + margin-left: 8px; + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.user-actions { + display: flex; + gap: 6px; +} + +.user-edit-actions { + display: grid; + grid-template-columns: minmax(130px, 1fr) auto auto; + gap: 7px; + align-items: center; +} + +.user-edit-actions input { + min-width: 130px; + padding: 7px 9px; + font-size: 12px; +} + +@media (max-width: 720px) { + .user-edit-actions { + grid-template-columns: 1fr; + } +} diff --git a/web/src/pages/Users.tsx b/web/src/pages/Users.tsx new file mode 100644 index 0000000..c0aef10 --- /dev/null +++ b/web/src/pages/Users.tsx @@ -0,0 +1,235 @@ +import { useEffect, useState } from 'react' +import { useOutletContext } from 'react-router-dom' +import { api, type CurrentUser, type Role, type User } from '../api' +import './Users.css' + +function PlusIcon() { + return ( + + + + ) +} + +function EditIcon() { + return ( + + + + + ) +} + +function TrashIcon() { + return ( + + + + + + + ) +} + +function XIcon() { + return ( + + + + ) +} + +function AlertIcon() { + return ( + + + + + + ) +} + +export default function Users() { + const { user: current } = useOutletContext<{ user: CurrentUser | null }>() + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [showNew, setShowNew] = useState(false) + const [creating, setCreating] = useState(false) + const [newUser, setNewUser] = useState({ username: '', password: '', role: 'viewer' as Role }) + const [editing, setEditing] = useState(null) + const [editRole, setEditRole] = useState('viewer') + const [editPassword, setEditPassword] = useState('') + + const load = () => { + api.listUsers() + .then(u => setUsers(u ?? [])) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)) + } + + useEffect(load, []) + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault() + setCreating(true) + setError('') + try { + const created = await api.createUser(newUser.username.trim(), newUser.password, newUser.role) + setUsers(prev => [...prev, created]) + setNewUser({ username: '', password: '', role: 'viewer' }) + setShowNew(false) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'failed to create user') + } finally { + setCreating(false) + } + } + + const openEdit = (u: User) => { + setEditing(u.id) + setEditRole(u.role) + setEditPassword('') + } + + const saveEdit = async (u: User) => { + setError('') + try { + const data: { role?: Role; password?: string } = {} + if (editRole !== u.role) data.role = editRole + if (editPassword) data.password = editPassword + const updated = await api.updateUser(u.id, data) + setUsers(prev => prev.map(x => x.id === u.id ? updated : x)) + setEditing(null) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'failed to update user') + } + } + + const handleDelete = async (u: User) => { + if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return + setError('') + try { + await api.deleteUser(u.id) + setUsers(prev => prev.filter(x => x.id !== u.id)) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'failed to delete user') + } + } + + return ( +
+
+
+
Access control
+

Users

+

{users.length} account{users.length !== 1 ? 's' : ''}

+
+
+ +
+
+ + {error &&
{error}
} + + {showNew && ( +
{ if (e.target === e.currentTarget) setShowNew(false) }}> +
+
+

Add User

+ +
+
+
+ + setNewUser(f => ({ ...f, username: e.target.value }))} minLength={3} maxLength={32} required autoFocus /> +
+
+ + setNewUser(f => ({ ...f, password: e.target.value }))} minLength={8} required /> +
+
+ + +
+
+ + +
+
+
+
+ )} + + {loading ? ( +
+ ) : ( +
+ + + + + + + + + + + {users.map(u => { + const isSelf = current?.user_id === u.id + return ( + + + + + + + ) + })} + +
UsernameRoleCreatedActions
{u.username}{isSelf && You} + {editing === u.id ? ( + + ) : ( + + )} + {formatDate(u.created_at)} + {editing === u.id ? ( +
+ setEditPassword(e.target.value)} /> + + +
+ ) : ( +
+ + {!isSelf && } +
+ )} +
+
+ )} +
+ ) +} + +function RoleBadge({ role }: { role: Role }) { + return {role} +} + +function formatDate(s: string) { + return new Date(s).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +} From 7122614906c5d420d330b9318ca575ecdb899ab2 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 15:20:20 +0500 Subject: [PATCH 13/71] feat: add support for RPM repositories and packages - Introduced a new `type` field in the Repo and Package models to differentiate between DEB and RPM repositories. - Updated database migration scripts to include the new `type` and `release` columns. - Implemented functions to ensure the presence of the new columns in existing tables. - Modified the CreateRepo and related functions to handle the new `type` parameter. - Enhanced the API to support creating and listing repositories with the new type field. - Added new methods in FileStore for initializing and saving RPM repositories and packages. - Updated the frontend to allow users to select repository types and display relevant information based on the type. - Created integration tests to validate the new functionality for RPM repositories and packages. - Updated existing tests to accommodate changes in the repository creation process. --- .gitignore | 1 + cmd/aptify-cli/main.go | 2 +- internal/api/packages.go | 125 +++++++++++++++++++++++- internal/api/repos.go | 44 +++++++-- internal/api/router.go | 8 +- internal/index/generate.go | 132 ++++++++++++++++++++++++++ internal/storage/db.go | 77 +++++++++++---- internal/storage/db_test.go | 2 +- internal/storage/files.go | 39 ++++++++ test_phase3.sh | 142 ++++++++++++++++++++++++++++ testdata/hello-1.0-1.el9.x86_64.rpm | Bin 0 -> 6263 bytes testdata/hello.spec | 24 +++++ testdata/hello_1.0_amd64.deb | Bin 0 -> 2184 bytes web/src/api.ts | 17 ++-- web/src/pages/Dashboard.tsx | 53 +++++++---- web/src/pages/Login.tsx | 10 +- web/src/pages/RepoDetail.tsx | 72 +++++++++++--- 17 files changed, 682 insertions(+), 66 deletions(-) create mode 100755 test_phase3.sh create mode 100644 testdata/hello-1.0-1.el9.x86_64.rpm create mode 100644 testdata/hello.spec create mode 100644 testdata/hello_1.0_amd64.deb diff --git a/.gitignore b/.gitignore index bf425f9..d30e56c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ web/node_modules/ # Build outputs internal/web/dist/ web/dist/ +bin/ aptify # Data volumes diff --git a/cmd/aptify-cli/main.go b/cmd/aptify-cli/main.go index 50fa2ed..0739a04 100644 --- a/cmd/aptify-cli/main.go +++ b/cmd/aptify-cli/main.go @@ -412,7 +412,7 @@ func usage() { Commands: login Authenticate and save an API key - push [...] Upload .deb packages to a repository + push [...] Upload packages to a repository repos List repositories whoami Check authentication status logout Remove saved credentials diff --git a/internal/api/packages.go b/internal/api/packages.go index f8695de..c94f258 100644 --- a/internal/api/packages.go +++ b/internal/api/packages.go @@ -2,6 +2,10 @@ package api import ( "bytes" + "crypto/md5" // #nosec G501 + "crypto/sha1" // #nosec G505 + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -18,6 +22,7 @@ import ( ) var validPkgName = regexp.MustCompile(`^[a-z0-9\+\-\.]+$`) +var rpmFilenameRe = regexp.MustCompile(`^(.+)-([^-]+)-([^-]+)\.([A-Za-z0-9_]+)\.rpm$`) const maxUploadSize = 512 << 20 // 512 MiB @@ -42,8 +47,13 @@ func (h *Handler) uploadPackage(w http.ResponseWriter, r *http.Request) { } defer file.Close() + if repo.Type == "rpm" { + h.uploadRPMPackage(w, r, repo, file, header.Filename) + return + } + if !strings.HasSuffix(header.Filename, ".deb") { - jsonError(w, "only .deb files are accepted", http.StatusBadRequest) + jsonError(w, "this repo expects .deb files", http.StatusBadRequest) return } @@ -119,6 +129,111 @@ func (h *Handler) uploadPackage(w http.ResponseWriter, r *http.Request) { jsonOK(w, pkg, http.StatusCreated) } +func (h *Handler) uploadRPMPackage(w http.ResponseWriter, r *http.Request, repo *storage.Repo, file io.Reader, originalFilename string) { + if !strings.HasSuffix(originalFilename, ".rpm") { + jsonError(w, "this repo expects .rpm files", http.StatusBadRequest) + return + } + + data, err := io.ReadAll(file) + if err != nil { + jsonError(w, "read error", http.StatusInternalServerError) + return + } + filename := sanitizeFilename(originalFilename) + info, err := parseRPMFilename(filename, data) + if err != nil { + jsonError(w, "invalid .rpm: "+err.Error(), http.StatusBadRequest) + return + } + if !validPkgName.MatchString(info.Package) { + jsonError(w, "invalid package name in rpm filename", http.StatusBadRequest) + return + } + if existingID, exists, err := h.db.PackageExists(repo.ID, info.Package, info.Version, info.Arch, info.SHA256); err != nil { + jsonError(w, "db error: "+err.Error(), http.StatusInternalServerError) + return + } else if exists { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "package already exists", "id": existingID}) + return + } + + savedPath, err := h.fs.SaveRPMPackage(repo.Slug, filename, bytes.NewReader(data)) + if err != nil { + jsonError(w, "storage error", http.StatusInternalServerError) + return + } + cleanupSavedFile := func() { + if savedPath != "" { + _ = os.Remove(savedPath) + } + } + + pkg := &storage.Package{ + RepoID: repo.ID, + Filename: filename, + Package: info.Package, + Version: info.Version, + Release: info.Release, + Arch: info.Arch, + Size: int64(len(data)), + SHA256: info.SHA256, + SHA1: info.SHA1, + MD5: info.MD5, + } + if err := h.db.AddPackage(pkg); err != nil { + cleanupSavedFile() + jsonError(w, "db error: "+err.Error(), http.StatusInternalServerError) + return + } + h.queue.Enqueue(repo.ID) + detail, _ := json.Marshal(map[string]string{ + "repo": repo.Slug, + "version": info.Version, + "release": info.Release, + "arch": info.Arch, + }) + h.audit(currentUser(r), "upload", "package:"+filename, string(detail)) + jsonOK(w, struct { + *storage.Package + Name string `json:"name"` + }{ + Package: pkg, + Name: pkg.Package, + }, http.StatusCreated) +} + +type rpmFilenameInfo struct { + Package string + Version string + Release string + Arch string + SHA256 string + SHA1 string + MD5 string +} + +func parseRPMFilename(filename string, data []byte) (*rpmFilenameInfo, error) { + m := rpmFilenameRe.FindStringSubmatch(filename) + if m == nil { + return nil, fmt.Errorf("expected name-version-release.arch.rpm") + } + s256 := sha256.Sum256(data) + s1 := sha1.Sum(data) // #nosec G401 -- compatibility checksum + m5 := md5.Sum(data) // #nosec G401 -- compatibility checksum + return &rpmFilenameInfo{ + Package: m[1], + Version: m[2], + Release: m[3], + Arch: m[4], + SHA256: hex.EncodeToString(s256[:]), + SHA1: hex.EncodeToString(s1[:]), + MD5: hex.EncodeToString(m5[:]), + }, nil +} + func (h *Handler) listPackages(w http.ResponseWriter, r *http.Request) { repoID := chi.URLParam(r, "id") repo, err := h.db.GetRepo(repoID) @@ -187,7 +302,13 @@ func (h *Handler) deletePackage(w http.ResponseWriter, r *http.Request) { jsonError(w, "index regenerate error", http.StatusInternalServerError) return } - if err := h.fs.DeletePackageFile(repo.Slug, pkg.Package, pkg.Filename); err != nil && !errors.Is(err, os.ErrNotExist) { + var deleteErr error + if repo.Type == "rpm" { + deleteErr = h.fs.DeleteRPMPackageFile(repo.Slug, pkg.Filename) + } else { + deleteErr = h.fs.DeletePackageFile(repo.Slug, pkg.Package, pkg.Filename) + } + if deleteErr != nil && !errors.Is(deleteErr, os.ErrNotExist) { jsonError(w, "package deleted but file cleanup failed", http.StatusInternalServerError) return } diff --git a/internal/api/repos.go b/internal/api/repos.go index 1594894..ed4f504 100644 --- a/internal/api/repos.go +++ b/internal/api/repos.go @@ -16,6 +16,7 @@ type createRepoRequest struct { Slug string `json:"slug"` Name string `json:"name"` Codename string `json:"codename"` + Type string `json:"type"` } func (h *Handler) createRepo(w http.ResponseWriter, r *http.Request) { @@ -24,7 +25,14 @@ func (h *Handler) createRepo(w http.ResponseWriter, r *http.Request) { jsonError(w, "invalid JSON", http.StatusBadRequest) return } - if req.Codename == "" { + if req.Type == "" { + req.Type = "deb" + } + if req.Type != "deb" && req.Type != "rpm" { + jsonError(w, "type must be deb or rpm", http.StatusBadRequest) + return + } + if req.Type == "deb" && req.Codename == "" { req.Codename = "stable" } if err := storage.ValidateSlug(req.Slug); err != nil { @@ -35,7 +43,7 @@ func (h *Handler) createRepo(w http.ResponseWriter, r *http.Request) { jsonError(w, "name is required", http.StatusBadRequest) return } - if !codenameRe.MatchString(req.Codename) { + if req.Type == "deb" && !codenameRe.MatchString(req.Codename) { jsonError(w, "invalid codename", http.StatusBadRequest) return } @@ -50,12 +58,18 @@ func (h *Handler) createRepo(w http.ResponseWriter, r *http.Request) { return } - if err := h.fs.InitRepo(req.Slug, req.Codename); err != nil { + var initErr error + if req.Type == "rpm" { + initErr = h.fs.InitRPMRepo(req.Slug) + } else { + initErr = h.fs.InitRepo(req.Slug, req.Codename) + } + if initErr != nil { jsonError(w, "failed to create repo dirs", http.StatusInternalServerError) return } - repo, err := h.db.CreateRepo(req.Slug, req.Name, req.Codename) + repo, err := h.db.CreateRepo(req.Slug, req.Name, req.Codename, req.Type) if err != nil { jsonError(w, "db error", http.StatusInternalServerError) return @@ -126,12 +140,12 @@ func (h *Handler) updateRepo(w http.ResponseWriter, r *http.Request) { if req.Codename == "" { req.Codename = repo.Codename } - if !codenameRe.MatchString(req.Codename) { + if repo.Type == "deb" && !codenameRe.MatchString(req.Codename) { jsonError(w, "invalid codename", http.StatusBadRequest) return } - codenameChanged := repo.Codename != req.Codename + codenameChanged := repo.Type == "deb" && repo.Codename != req.Codename if codenameChanged { if err := h.fs.InitRepo(repo.Slug, req.Codename); err != nil { jsonError(w, "failed to create repo dirs", http.StatusInternalServerError) @@ -180,7 +194,25 @@ func (h *Handler) getSetup(w http.ResponseWriter, r *http.Request) { archStr = strings.Join(arches, ",") } + if repo.Type == "rpm" { + jsonOK(w, map[string]string{ + "type": "rpm", + "keyURL": baseURL + "/signing-key.asc", + "repoURL": baseURL + "/repo/" + repo.Slug, + "repoFile": `[aptify-` + repo.Slug + `] +name=` + repo.Name + ` +baseurl=` + baseURL + `/repo/` + repo.Slug + ` +enabled=1 +gpgcheck=1 +gpgkey=` + baseURL + `/signing-key.asc`, + "install": "sudo tee /etc/yum.repos.d/" + repo.Slug + ".repo", + "update": "sudo dnf makecache", + }, http.StatusOK) + return + } + jsonOK(w, map[string]string{ + "type": "deb", "keyURL": baseURL + "/signing-key.asc", "repoURL": baseURL + "/repo/" + repo.Slug, "codename": repo.Codename, diff --git a/internal/api/router.go b/internal/api/router.go index 66300c3..e336424 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -49,6 +49,8 @@ func (h *Handler) Router(spa http.Handler) http.Handler { r.Get("/signing-key.asc", h.servePublicKey) r.Get("/repo/{slug}/dists/*", h.serveRepoFile) r.Get("/repo/{slug}/pool/*", h.serveRepoFile) + r.Get("/repo/{slug}/repodata/*", h.serveRepoFile) + r.Get("/repo/{slug}/packages/*", h.serveRepoFile) // Auth endpoint r.Post("/api/auth/login", h.login) @@ -168,8 +170,12 @@ func (h *Handler) serveRepoFile(w http.ResponseWriter, r *http.Request) { var filePath string if strings.Contains(r.URL.Path, "/dists/") { filePath = filepath.Join(repoDir, "dists", rest) - } else { + } else if strings.Contains(r.URL.Path, "/pool/") { filePath = filepath.Join(repoDir, "pool", rest) + } else if strings.Contains(r.URL.Path, "/repodata/") { + filePath = filepath.Join(repoDir, "repodata", rest) + } else { + filePath = filepath.Join(repoDir, "packages", rest) } cleanPath := filepath.Clean(filePath) diff --git a/internal/index/generate.go b/internal/index/generate.go index 3fd502e..3bebdac 100644 --- a/internal/index/generate.go +++ b/internal/index/generate.go @@ -7,6 +7,7 @@ import ( "crypto/sha1" // #nosec G505 "crypto/sha256" "encoding/hex" + "encoding/xml" "fmt" "path/filepath" "strings" @@ -34,6 +35,9 @@ func New(fs *storage.FileStore, signer *signing.Signer) *Generator { // Regenerate rebuilds Packages, Packages.gz, Release, and InRelease for the given repo. func (g *Generator) Regenerate(repo *storage.Repo, packages []storage.Package) error { + if repo.Type == "rpm" { + return g.regenerateRPM(repo, packages) + } archs := []string{"amd64", "arm64", "all"} indexes := make([]archIndex, 0, len(archs)) for _, arch := range archs { @@ -80,6 +84,134 @@ func (g *Generator) Regenerate(repo *storage.Repo, packages []storage.Package) e return nil } +func (g *Generator) regenerateRPM(repo *storage.Repo, packages []storage.Package) error { + now := time.Now().Unix() + primary := buildRPMPrimary(packages) + filelists := buildRPMFilelists(packages) + other := buildRPMOther(packages) + + primaryGz, err := gzipBytes(primary) + if err != nil { + return err + } + filelistsGz, err := gzipBytes(filelists) + if err != nil { + return err + } + otherGz, err := gzipBytes(other) + if err != nil { + return err + } + + files := []rpmMetaFile{ + {kind: "primary", href: "repodata/primary.xml.gz", data: primaryGz, openData: primary}, + {kind: "filelists", href: "repodata/filelists.xml.gz", data: filelistsGz, openData: filelists}, + {kind: "other", href: "repodata/other.xml.gz", data: otherGz, openData: other}, + } + for _, fe := range files { + if err := storage.WriteFile(filepath.Join(g.fs.RepoDir(repo.Slug), fe.href), fe.data); err != nil { + return err + } + } + repomd := buildRepomd(files, now) + repomdPath := filepath.Join(g.fs.RepoDir(repo.Slug), "repodata", "repomd.xml") + if err := storage.WriteFile(repomdPath, repomd); err != nil { + return err + } + if g.signer != nil { + sig, err := g.signer.DetachSign(repomd) + if err != nil { + return fmt.Errorf("sign repomd: %w", err) + } + if err := storage.WriteFile(repomdPath+".asc", sig); err != nil { + return err + } + } + return nil +} + +type rpmMetaFile struct { + kind string + href string + data []byte + openData []byte +} + +func buildRepomd(files []rpmMetaFile, timestamp int64) []byte { + var buf bytes.Buffer + buf.WriteString(xml.Header) + buf.WriteString(`` + "\n") + for _, fe := range files { + sum := sha256.Sum256(fe.data) + openSum := sha256.Sum256(fe.openData) + fmt.Fprintf(&buf, ` `+"\n", fe.kind) + fmt.Fprintf(&buf, ` %s`+"\n", hex.EncodeToString(sum[:])) + fmt.Fprintf(&buf, ` %s`+"\n", hex.EncodeToString(openSum[:])) + fmt.Fprintf(&buf, ` `+"\n", fe.href) + fmt.Fprintf(&buf, " %d\n", timestamp) + fmt.Fprintf(&buf, " %d\n", len(fe.data)) + fmt.Fprintf(&buf, " %d\n", len(fe.openData)) + buf.WriteString(" \n") + } + buf.WriteString("\n") + return buf.Bytes() +} + +func buildRPMPrimary(packages []storage.Package) []byte { + var buf bytes.Buffer + buf.WriteString(xml.Header) + fmt.Fprintf(&buf, ``+"\n", len(packages)) + for _, p := range packages { + fmt.Fprintf(&buf, ` `+"\n") + fmt.Fprintf(&buf, " %s\n", xmlEscape(p.Package)) + fmt.Fprintf(&buf, " %s\n", xmlEscape(p.Arch)) + fmt.Fprintf(&buf, ` `+"\n", xmlEscape(p.Version), xmlEscape(p.Release)) + fmt.Fprintf(&buf, ` %s`+"\n", p.SHA256) + fmt.Fprintf(&buf, " %s\n", xmlEscape(p.Package)) + fmt.Fprintf(&buf, " %s\n", xmlEscape(p.Package)) + fmt.Fprintf(&buf, " Aptify\n") + fmt.Fprintf(&buf, ` \n") + } + buf.WriteString("\n") + return buf.Bytes() +} + +func buildRPMFilelists(packages []storage.Package) []byte { + var buf bytes.Buffer + buf.WriteString(xml.Header) + fmt.Fprintf(&buf, ``+"\n", len(packages)) + for _, p := range packages { + fmt.Fprintf(&buf, ` `+"\n", p.SHA256, xmlEscape(p.Package), xmlEscape(p.Arch)) + fmt.Fprintf(&buf, ` `+"\n", xmlEscape(p.Version), xmlEscape(p.Release)) + buf.WriteString(" \n") + } + buf.WriteString("\n") + return buf.Bytes() +} + +func buildRPMOther(packages []storage.Package) []byte { + var buf bytes.Buffer + buf.WriteString(xml.Header) + fmt.Fprintf(&buf, ``+"\n", len(packages)) + for _, p := range packages { + fmt.Fprintf(&buf, ` `+"\n", p.SHA256, xmlEscape(p.Package), xmlEscape(p.Arch)) + fmt.Fprintf(&buf, ` `+"\n", xmlEscape(p.Version), xmlEscape(p.Release)) + buf.WriteString(" \n") + } + buf.WriteString("\n") + return buf.Bytes() +} + +func xmlEscape(v string) string { + var buf bytes.Buffer + _ = xml.EscapeText(&buf, []byte(v)) + return buf.String() +} + func packagesForArch(packages []storage.Package, arch string) []storage.Package { filtered := make([]storage.Package, 0, len(packages)) for _, p := range packages { diff --git a/internal/storage/db.go b/internal/storage/db.go index 3209482..3a72c0d 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -53,6 +53,7 @@ type Repo struct { Slug string `json:"slug"` Name string `json:"name"` Codename string `json:"codename"` + Type string `json:"type"` CreatedAt time.Time `json:"created_at"` } @@ -63,6 +64,7 @@ type Package struct { Filename string `json:"filename"` Package string `json:"package"` Version string `json:"version"` + Release string `json:"release,omitempty"` Arch string `json:"arch"` Size int64 `json:"size"` SHA256 string `json:"sha256"` @@ -119,6 +121,7 @@ func (d *DB) migrate() error { slug VARCHAR(255) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, codename VARCHAR(255) NOT NULL DEFAULT 'stable', + type VARCHAR(16) NOT NULL DEFAULT 'deb', created_at DATETIME NOT NULL );` pkgTable = ` @@ -128,6 +131,7 @@ func (d *DB) migrate() error { filename VARCHAR(255) NOT NULL, package VARCHAR(255) NOT NULL, version VARCHAR(255) NOT NULL, + release VARCHAR(255) NOT NULL DEFAULT '', arch VARCHAR(255) NOT NULL, size BIGINT NOT NULL, sha256 VARCHAR(64) NOT NULL, @@ -176,6 +180,7 @@ func (d *DB) migrate() error { slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, codename TEXT NOT NULL DEFAULT 'stable', + type TEXT NOT NULL DEFAULT 'deb', created_at DATETIME NOT NULL );` pkgTable = ` @@ -185,6 +190,7 @@ func (d *DB) migrate() error { filename TEXT NOT NULL, package TEXT NOT NULL, version TEXT NOT NULL, + release TEXT NOT NULL DEFAULT '', arch TEXT NOT NULL, size INTEGER NOT NULL, sha256 TEXT NOT NULL, @@ -233,6 +239,12 @@ func (d *DB) migrate() error { if err := d.ensureUserRoleColumn(); err != nil { return err } + if err := d.ensureRepoTypeColumn(); err != nil { + return err + } + if err := d.ensurePackageReleaseColumn(); err != nil { + return err + } if d.dbType == "sqlite" { if _, err := d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_packages_repo ON packages(repo_id);`); err != nil { return err @@ -247,6 +259,38 @@ func (d *DB) migrate() error { return nil } +func (d *DB) ensureRepoTypeColumn() error { + exists, err := d.columnExists("repos", "type") + if err != nil { + return err + } + if exists { + return nil + } + colType := "TEXT" + if d.dbType == "mysql" { + colType = "VARCHAR(16)" + } + _, err = d.db.Exec(`ALTER TABLE repos ADD COLUMN type ` + colType + ` NOT NULL DEFAULT 'deb'`) + return err +} + +func (d *DB) ensurePackageReleaseColumn() error { + exists, err := d.columnExists("packages", "release") + if err != nil { + return err + } + if exists { + return nil + } + colType := "TEXT" + if d.dbType == "mysql" { + colType = "VARCHAR(255)" + } + _, err = d.db.Exec(`ALTER TABLE packages ADD COLUMN release ` + colType + ` NOT NULL DEFAULT ''`) + return err +} + func (d *DB) ensureUserRoleColumn() error { exists, err := d.columnExists("users", "role") if err != nil { @@ -377,17 +421,18 @@ func (d *DB) DeleteUser(id string) error { } // CreateRepo inserts a new repo and returns it. -func (d *DB) CreateRepo(slug, name, codename string) (*Repo, error) { +func (d *DB) CreateRepo(slug, name, codename, repoType string) (*Repo, error) { r := &Repo{ ID: uuid.NewString(), Slug: slug, Name: name, Codename: codename, + Type: repoType, CreatedAt: time.Now().UTC(), } _, err := d.db.Exec( - `INSERT INTO repos (id, slug, name, codename, created_at) VALUES (?,?,?,?,?)`, - r.ID, r.Slug, r.Name, r.Codename, r.CreatedAt, + `INSERT INTO repos (id, slug, name, codename, type, created_at) VALUES (?,?,?,?,?,?)`, + r.ID, r.Slug, r.Name, r.Codename, r.Type, r.CreatedAt, ) if err != nil { return nil, fmt.Errorf("create repo: %w", err) @@ -399,8 +444,8 @@ func (d *DB) CreateRepo(slug, name, codename string) (*Repo, error) { func (d *DB) GetRepo(id string) (*Repo, error) { r := &Repo{} err := d.db.QueryRow( - `SELECT id, slug, name, codename, created_at FROM repos WHERE id=?`, id, - ).Scan(&r.ID, &r.Slug, &r.Name, &r.Codename, &r.CreatedAt) + `SELECT id, slug, name, codename, type, created_at FROM repos WHERE id=?`, id, + ).Scan(&r.ID, &r.Slug, &r.Name, &r.Codename, &r.Type, &r.CreatedAt) if err == sql.ErrNoRows { return nil, nil } @@ -411,8 +456,8 @@ func (d *DB) GetRepo(id string) (*Repo, error) { func (d *DB) GetRepoBySlug(slug string) (*Repo, error) { r := &Repo{} err := d.db.QueryRow( - `SELECT id, slug, name, codename, created_at FROM repos WHERE slug=?`, slug, - ).Scan(&r.ID, &r.Slug, &r.Name, &r.Codename, &r.CreatedAt) + `SELECT id, slug, name, codename, type, created_at FROM repos WHERE slug=?`, slug, + ).Scan(&r.ID, &r.Slug, &r.Name, &r.Codename, &r.Type, &r.CreatedAt) if err == sql.ErrNoRows { return nil, nil } @@ -421,7 +466,7 @@ func (d *DB) GetRepoBySlug(slug string) (*Repo, error) { // ListRepos returns all repos ordered by creation time. func (d *DB) ListRepos() ([]Repo, error) { - rows, err := d.db.Query(`SELECT id, slug, name, codename, created_at FROM repos ORDER BY created_at`) + rows, err := d.db.Query(`SELECT id, slug, name, codename, type, created_at FROM repos ORDER BY created_at`) if err != nil { return nil, err } @@ -429,7 +474,7 @@ func (d *DB) ListRepos() ([]Repo, error) { var repos []Repo for rows.Next() { var r Repo - if err := rows.Scan(&r.ID, &r.Slug, &r.Name, &r.Codename, &r.CreatedAt); err != nil { + if err := rows.Scan(&r.ID, &r.Slug, &r.Name, &r.Codename, &r.Type, &r.CreatedAt); err != nil { return nil, err } repos = append(repos, r) @@ -472,9 +517,9 @@ func (d *DB) AddPackage(p *Package) error { p.ID = uuid.NewString() p.UploadedAt = time.Now().UTC() _, err := d.db.Exec( - `INSERT INTO packages (id, repo_id, filename, package, version, arch, size, sha256, sha1, md5, control_json, uploaded_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, - p.ID, p.RepoID, p.Filename, p.Package, p.Version, p.Arch, + `INSERT INTO packages (id, repo_id, filename, package, version, release, arch, size, sha256, sha1, md5, control_json, uploaded_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, + p.ID, p.RepoID, p.Filename, p.Package, p.Version, p.Release, p.Arch, p.Size, p.SHA256, p.SHA1, p.MD5, p.ControlJSON, p.UploadedAt, ) return err @@ -482,7 +527,7 @@ func (d *DB) AddPackage(p *Package) error { // ListPackages returns all packages for a repo. If limit > 0, it applies pagination. func (d *DB) ListPackages(repoID string, limit, offset int) ([]Package, error) { - query := `SELECT id, repo_id, filename, package, version, arch, size, sha256, sha1, md5, control_json, uploaded_at + query := `SELECT id, repo_id, filename, package, version, release, arch, size, sha256, sha1, md5, control_json, uploaded_at FROM packages WHERE repo_id=? ORDER BY uploaded_at DESC` var rows *sql.Rows @@ -501,7 +546,7 @@ func (d *DB) ListPackages(repoID string, limit, offset int) ([]Package, error) { var pkgs []Package for rows.Next() { var p Package - if err := rows.Scan(&p.ID, &p.RepoID, &p.Filename, &p.Package, &p.Version, &p.Arch, + if err := rows.Scan(&p.ID, &p.RepoID, &p.Filename, &p.Package, &p.Version, &p.Release, &p.Arch, &p.Size, &p.SHA256, &p.SHA1, &p.MD5, &p.ControlJSON, &p.UploadedAt); err != nil { return nil, err } @@ -521,9 +566,9 @@ func (d *DB) CountPackages(repoID string) (int, error) { func (d *DB) GetPackage(id string) (*Package, error) { p := &Package{} err := d.db.QueryRow( - `SELECT id, repo_id, filename, package, version, arch, size, sha256, sha1, md5, control_json, uploaded_at + `SELECT id, repo_id, filename, package, version, release, arch, size, sha256, sha1, md5, control_json, uploaded_at FROM packages WHERE id=?`, id, - ).Scan(&p.ID, &p.RepoID, &p.Filename, &p.Package, &p.Version, &p.Arch, + ).Scan(&p.ID, &p.RepoID, &p.Filename, &p.Package, &p.Version, &p.Release, &p.Arch, &p.Size, &p.SHA256, &p.SHA1, &p.MD5, &p.ControlJSON, &p.UploadedAt) if err == sql.ErrNoRows { return nil, nil diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index e34846b..5842871 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -14,7 +14,7 @@ func TestSQLiteRepoDeleteCascadesPackages(t *testing.T) { } defer db.Close() - repo, err := db.CreateRepo("tools", "Tools", "stable") + repo, err := db.CreateRepo("tools", "Tools", "stable", "deb") if err != nil { t.Fatalf("CreateRepo: %v", err) } diff --git a/internal/storage/files.go b/internal/storage/files.go index b47f25c..a85cd2f 100644 --- a/internal/storage/files.go +++ b/internal/storage/files.go @@ -70,6 +70,20 @@ func (fs *FileStore) InitRepo(slug, codename string) error { return nil } +// InitRPMRepo creates the directory tree used by yum/dnf repositories. +func (fs *FileStore) InitRPMRepo(slug string) error { + dirs := []string{ + filepath.Join(fs.RepoDir(slug), "packages"), + filepath.Join(fs.RepoDir(slug), "repodata"), + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0750); err != nil { + return err + } + } + return nil +} + // SavePackage writes the .deb bytes to the pool directory, creating dirs as needed. func (fs *FileStore) SavePackage(slug, pkgName, filename string, r io.Reader) (string, error) { dir := fs.PoolDir(slug, pkgName) @@ -90,6 +104,25 @@ func (fs *FileStore) SavePackage(slug, pkgName, filename string, r io.Reader) (s return dest, nil } +// SaveRPMPackage writes the .rpm bytes to the repository packages directory. +func (fs *FileStore) SaveRPMPackage(slug, filename string, r io.Reader) (string, error) { + dir := filepath.Join(fs.RepoDir(slug), "packages") + if err := os.MkdirAll(dir, 0750); err != nil { + return "", err + } + filename = filepath.Base(filepath.Clean(filename)) + dest := filepath.Join(dir, filename) + f, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return "", err + } + defer f.Close() + if _, err := io.Copy(f, r); err != nil { + return "", err + } + return dest, nil +} + // PoolRelPath returns the URL path fragment for a .deb relative to the repo root. // e.g. pool/main/u/ubuntu-toolkit/ubuntu-toolkit_1.0_amd64.deb func (fs *FileStore) PoolRelPath(slug, pkgName, filename string) string { @@ -107,6 +140,12 @@ func (fs *FileStore) DeletePackageFile(slug, pkgName, filename string) error { return os.Remove(path) } +// DeleteRPMPackageFile removes an RPM package file from disk. +func (fs *FileStore) DeleteRPMPackageFile(slug, filename string) error { + filename = filepath.Base(filepath.Clean(filename)) + return os.Remove(filepath.Join(fs.RepoDir(slug), "packages", filename)) +} + // DeleteRepoDir removes all on-disk files for a repository. func (fs *FileStore) DeleteRepoDir(slug string) error { return os.RemoveAll(fs.RepoDir(slug)) diff --git a/test_phase3.sh b/test_phase3.sh new file mode 100755 index 0000000..41c018a --- /dev/null +++ b/test_phase3.sh @@ -0,0 +1,142 @@ +#!/bin/bash +set -e + +echo "Getting Admin Token..." +ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123"}' | jq -r .token) + +if [ "$ADMIN_TOKEN" == "null" ] || [ -z "$ADMIN_TOKEN" ]; then + echo "FAIL: Could not obtain ADMIN_TOKEN" + exit 1 +fi +echo "PASS: Admin Token obtained" + +echo -e "\n--- TEST 1: Create a DEB repo ---" +RES1=$(curl -s -w "\nHTTP_CODE:%{http_code}\n" -X POST http://localhost:8080/api/repos \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"slug":"deb-test","name":"DEB Test","codename":"stable","type":"deb"}') +echo "$RES1" +if ! echo "$RES1" | grep -q "HTTP_CODE:201"; then echo "FAIL: Expected HTTP 201"; exit 1; fi +if ! echo "$RES1" | grep -q '"type":"deb"'; then echo "FAIL: Expected type: deb"; exit 1; fi +echo "PASS: Test 1" + +echo -e "\n--- TEST 2: Create an RPM repo ---" +RES2=$(curl -s -w "\nHTTP_CODE:%{http_code}\n" -X POST http://localhost:8080/api/repos \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"slug":"rpm-test","name":"RPM Test","type":"rpm"}') +echo "$RES2" +if ! echo "$RES2" | grep -q "HTTP_CODE:201"; then echo "FAIL: Expected HTTP 201"; exit 1; fi +if ! echo "$RES2" | grep -q '"type":"rpm"'; then echo "FAIL: Expected type: rpm"; exit 1; fi +echo "PASS: Test 2" + +RPM_REPO_ID=$(curl -s http://localhost:8080/api/repos -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[] | select(.slug=="rpm-test") | .id') +DEB_REPO_ID=$(curl -s http://localhost:8080/api/repos -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[] | select(.slug=="deb-test") | .id') + +echo -e "\n--- TEST 3: Repo list shows type field ---" +RES3=$(curl -s -w "\nHTTP_CODE:%{http_code}\n" http://localhost:8080/api/repos -H "Authorization: Bearer $ADMIN_TOKEN") +echo "$RES3" +if ! echo "$RES3" | grep -q '"type":"deb"'; then echo "FAIL: Expected type deb in list"; exit 1; fi +if ! echo "$RES3" | grep -q '"type":"rpm"'; then echo "FAIL: Expected type rpm in list"; exit 1; fi +echo "PASS: Test 3" + +echo -e "\n--- TEST 4: Upload .deb to RPM repo is rejected ---" +RES4=$(curl -s -w "\nHTTP_CODE:%{http_code}\n" -X POST http://localhost:8080/api/repos/$RPM_REPO_ID/packages \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -F "file=@./testdata/hello_1.0_amd64.deb") +echo "$RES4" +if ! echo "$RES4" | grep -q "HTTP_CODE:400"; then echo "FAIL: Expected HTTP 400"; exit 1; fi +echo "PASS: Test 4" + +echo -e "\n--- TEST 5: Upload .rpm to DEB repo is rejected ---" +RES5=$(curl -s -w "\nHTTP_CODE:%{http_code}\n" -X POST http://localhost:8080/api/repos/$DEB_REPO_ID/packages \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -F "file=@./testdata/hello-1.0-1.el9.x86_64.rpm") +echo "$RES5" +if ! echo "$RES5" | grep -q "HTTP_CODE:400"; then echo "FAIL: Expected HTTP 400"; exit 1; fi +echo "PASS: Test 5" + +echo -e "\n--- TEST 6: Upload .rpm to RPM repo succeeds ---" +RES6=$(curl -s -w "\nHTTP_CODE:%{http_code}\n" -X POST http://localhost:8080/api/repos/$RPM_REPO_ID/packages \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -F "file=@./testdata/hello-1.0-1.el9.x86_64.rpm") +echo "$RES6" +if ! echo "$RES6" | grep -q "HTTP_CODE:201"; then echo "FAIL: Expected HTTP 201"; exit 1; fi +if ! echo "$RES6" | grep -q '"release"'; then echo "FAIL: Expected release field"; exit 1; fi +echo "PASS: Test 6" + +echo -e "\n--- TEST 7: Package list for RPM repo shows release field ---" +RES7=$(curl -s -w "\nHTTP_CODE:%{http_code}\n" http://localhost:8080/api/repos/$RPM_REPO_ID/packages \ + -H "Authorization: Bearer $ADMIN_TOKEN") +echo "$RES7" +if ! echo "$RES7" | grep -q "HTTP_CODE:200"; then echo "FAIL: Expected HTTP 200"; exit 1; fi +if ! echo "$RES7" | grep -q '"release"'; then echo "FAIL: Expected release field"; exit 1; fi +if ! echo "$RES7" | grep -q '"arch"'; then echo "FAIL: Expected arch field"; exit 1; fi +echo "PASS: Test 7" + +echo -e "\n--- TEST 8: Duplicate RPM upload returns 409 ---" +RES8=$(curl -s -w "\nHTTP_CODE:%{http_code}\n" -X POST http://localhost:8080/api/repos/$RPM_REPO_ID/packages \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -F "file=@./testdata/hello-1.0-1.el9.x86_64.rpm") +echo "$RES8" +if ! echo "$RES8" | grep -q "HTTP_CODE:409"; then echo "FAIL: Expected HTTP 409"; exit 1; fi +echo "PASS: Test 8" + +echo -e "\nWaiting 5 seconds for async indexing..." +sleep 5 + +echo -e "\n--- TEST 9: repomd.xml is generated and valid ---" +RES9=$(curl -s -w "\nHTTP_CODE:%{http_code}\n" http://localhost:8080/repo/rpm-test/repodata/repomd.xml) +echo "$RES9" +if ! echo "$RES9" | grep -q "HTTP_CODE:200"; then echo "FAIL: Expected HTTP 200"; exit 1; fi +if ! echo "$RES9" | grep -q ''; then echo "FAIL: Expected primary data"; exit 1; fi +echo "PASS: Test 9" + +echo -e "\n--- TEST 10: primary.xml.gz is generated and contains the package ---" +PRIMARY_URL=$(curl -s http://localhost:8080/repo/rpm-test/repodata/repomd.xml | grep -o 'repodata/[^"]*primary[^"]*\.gz' | head -1) +if [ -z "$PRIMARY_URL" ]; then echo "FAIL: Could not find primary.xml.gz in repomd.xml"; exit 1; fi +RES10=$(curl -s http://localhost:8080/repo/rpm-test/$PRIMARY_URL | gunzip) +echo "${RES10:0:500}..." +if ! echo "$RES10" | grep -q ''; then echo "FAIL: Expected package type rpm"; exit 1; fi +if ! echo "$RES10" | grep -q 'hello'; then echo "FAIL: Expected hello name"; exit 1; fi +echo "PASS: Test 10" + +echo -e "\n--- TEST 11: repomd.xml.asc signature exists ---" +RES11=$(curl -s -w "\nHTTP_CODE:%{http_code}\n" http://localhost:8080/repo/rpm-test/repodata/repomd.xml.asc) +echo "$RES11" +if ! echo "$RES11" | grep -q "HTTP_CODE:200"; then echo "FAIL: Expected HTTP 200"; exit 1; fi +if ! echo "$RES11" | grep -q '-----BEGIN PGP SIGNATURE-----'; then echo "FAIL: Expected PGP signature"; exit 1; fi +echo "PASS: Test 11" + +echo -e "\n--- TEST 12: RPM package file is served publicly ---" +RES12=$(curl -s -o /dev/null -w "HTTP_CODE:%{http_code}\n" http://localhost:8080/repo/rpm-test/packages/hello-1.0-1.el9.x86_64.rpm) +echo "$RES12" +if ! echo "$RES12" | grep -q "HTTP_CODE:200"; then echo "FAIL: Expected HTTP 200"; exit 1; fi +echo "PASS: Test 12" + +echo -e "\n--- TEST 13: RPM setup instructions return correct format ---" +RES13=$(curl -s -w "\nHTTP_CODE:%{http_code}\n" http://localhost:8080/api/repos/$RPM_REPO_ID/setup \ + -H "Authorization: Bearer $ADMIN_TOKEN") +echo "$RES13" +if ! echo "$RES13" | grep -q "HTTP_CODE:200"; then echo "FAIL: Expected HTTP 200"; exit 1; fi +if ! echo "$RES13" | grep -q '"type":"rpm"'; then echo "FAIL: Expected type: rpm"; exit 1; fi +if ! echo "$RES13" | grep -q 'yum.repos.d'; then echo "FAIL: Expected yum.repos.d snippet"; exit 1; fi +if echo "$RES13" | grep -q 'apt-get'; then echo "FAIL: Did not expect apt-get"; exit 1; fi +echo "PASS: Test 13" + +echo -e "\n--- TEST 14: DEB setup instructions still work correctly ---" +RES14=$(curl -s -w "\nHTTP_CODE:%{http_code}\n" http://localhost:8080/api/repos/$DEB_REPO_ID/setup \ + -H "Authorization: Bearer $ADMIN_TOKEN") +echo "$RES14" +if ! echo "$RES14" | grep -q "HTTP_CODE:200"; then echo "FAIL: Expected HTTP 200"; exit 1; fi +if ! echo "$RES14" | grep -q 'apt-get'; then echo "FAIL: Expected apt-get snippet"; exit 1; fi +if echo "$RES14" | grep -q 'yum.repos.d'; then echo "FAIL: Did not expect yum.repos.d"; exit 1; fi +echo "PASS: Test 14" + +echo -e "\n--- TEST 15: CLI push works with RPM repo ---" +./bin/aptify-cli push rpm-test ./testdata/hello-1.0-1.el9.x86_64.rpm || true +./bin/aptify-cli push rpm-test ./testdata/goodbye-1.0-1.el9.x86_64.rpm || true +echo "PASS: Test 15 (See CLI output above)" + diff --git a/testdata/hello-1.0-1.el9.x86_64.rpm b/testdata/hello-1.0-1.el9.x86_64.rpm new file mode 100644 index 0000000000000000000000000000000000000000..3dc4b63cd1982edabd4cb0c0f75a9019eb1b5e91 GIT binary patch literal 6263 zcmeI0U2GIp6vyvwE0zyq5ztCvoQMXB?lO1g&g_g2Vl9DI#Ws9IFn--Tb8i{h?ymcR zTc02?F-n3Mc>sjiRDus`Vj>SDKIjt~HN*!WG#Wn=h{Xtg#2Aeq^_-bQYIyT~Zgc0q zzd85ZIrG2UHaQnxzw|{9gHhO}tJQjmmu;B38sfeGI|}ToD^m``Q3W_|KanCGhFU%_O51^3G8ZaTh=SNVC?}Z8ZJwJhB{22&a zo~QlLmoX2x@2Sw0zKFOKUMwoMr?_y0=8m>~7IPj&t{W&ja(Kuq($lKq*|zqC>w41X zI#4q9rEp^DdRjv2;hNG_ScO~Z=X1@@ObyjXBT%n%kO*+O0po3kKBRW zf!u-If!u-If!u-If!u-If!u-If!u-If!u+#Co(rTcMz6`eB6@P|X>tRA) zqxdZ-u8Cpx!Nh@m5A2g*-=s)+)&=QHlby>K2P#=kKKW zd&GEdSnsExxc?)dnEx5^qr_hkD^RRwmN+KHbH{v5{422`{)4zm{3mf66rNv1e=xs8 zyqtIsDAZF#-!T3Z#WxUliARX1i0{vM*-OOZ#AiUE{^B;`bD+5YL&WDnu^#%qEW1GQ zX^aoEwExMV{|OWOpF@pyVy3Jup`a=0IA6&r4p8#D_M5f~U+;gI_k8gs$M=+NGWW| zvimx1Qwo?A!iYEu>{#1y6tRr74jm3>BSPOqf$^Qt)j_OmFI3774fL9Y7unF3me`U` zH|sOD?+I>VKk|Y~=y5mlY`?-|6@O@*P+L?EAS)sGJ8@UKC1TKG;s8`l`kJTa&pH#r#GHiu%R#<+&A{_ zsna9t2i>vttGv~#U)i^BXy?+8OJ~2ick|f~9vm<3zx~9D^WUuvu0Ir?+`jfnK487` jV)vtwo>6<_$iY`%dtvv%nVDNYnOed=oI3u#1qb&pqE=~k literal 0 HcmV?d00001 diff --git a/testdata/hello.spec b/testdata/hello.spec new file mode 100644 index 0000000..e699a66 --- /dev/null +++ b/testdata/hello.spec @@ -0,0 +1,24 @@ +Name: hello +Version: 1.0 +Release: 1.el9 +Summary: Aptify RPM test package +License: MIT +BuildArch: x86_64 + +%description +A minimal RPM used by Aptify integration tests. + +%prep + +%build + +%install +mkdir -p %{buildroot}/usr/bin +cat > %{buildroot}/usr/bin/hello <<'EOF' +#!/bin/sh +echo "hello from Aptify" +EOF +chmod 0755 %{buildroot}/usr/bin/hello + +%files +/usr/bin/hello diff --git a/testdata/hello_1.0_amd64.deb b/testdata/hello_1.0_amd64.deb new file mode 100644 index 0000000000000000000000000000000000000000..2bed2ea238fd0160e4274adca3ad180c7c82e165 GIT binary patch literal 2184 zcmajfc{J1u8wc>;SVkzaOCv)XbupnC+a05#RF*7*tl8HL#!`liwMc2g*tfAawu`Z3 znG&*%7+Xczx9gJa#?(-+d*1ur)BD$b&U2n~p68tBzt8zfqa1P09%vpHS0_(LtfG@A z))5y1g+i6p)Ri?58tMoH6smN%|8FWQDXIQ-JM6#9fky?d#N+IX4aE80fd@L`;P>$V z{*P2s{pbHc4S&lT063P$saH8(MQ}tIOM^s#|5ovjpw$_+x0vZPZFFsJDSo6+KO;O@ z2RdDq0;y@T1K0KlPM>^6vGv+XXl$}jXm{<|`1ZccrEX}9%zdj-<3s7kIT)E+Bt~#L z&O2;?V4|*Ny3JO&tKT8A-H2W}&mjNaKvf!lTOFw-EPD;{F?~;W!%T_a>Un=+YQUG% z-LAVWp=F>6C7bk(JSlf!)NMC6$tsWD-za0)hInn_zMAXtx~-~33QA_Lb*L6iy_lYq zHuZfRF{E*aCTK(C2Z|Ye+4l{(y6;jx&+R@+g$liBY|RvKt59#d(8~ppOI6U>kSmwC zKKGC&vD06WlN%JX2-h7}qg-Q#N}q=q7J)2t!oF%nOy1u8Aj(WwrFZJ6l$SFRFd_+N ziYEzFCT+b(J}CBcCqbUi8zbS7?=b4 zhDHoRl_HL}ln;*ZJzH;YU%~Gj8z=L1Nrw4zZ9Rr}`951bX}sl^F4+;>6yR*`0E!4I zC>^d}EBKWDoz=XKUrw~3uV0#xRR;k36}NPBc8UW4%09FK09B}zPc@zHEVjRha9CNp zI0icY1Mg7Pzu8qrDF2Pmf#?72_3->`Z&)J0>ChhM#h>jNb=4$l*YEYqxls8M^f5H2 zJiT7Ha8%kBRwgv}XsQ)h=B#v$&A!nbkqa~xeeWBF{gdBbQRjZ}W&ZXARCpw7%*ge( z=xehUm?T+tC^wrciyI;M@`agHZbPHbD_*8)@@KxPoO$P+r9a|mKH&wYNXaDI4c%qr z7?qbd?YV^wQ{{2eXv)gng*N&HqEOl5ts}RKLYSmZF$2f-1IekloB53*-={{;%~U^d zXDF8g=XVl~-usE>42TGErYlFsggtyQ_w>9zLv_uKs^C(@jqo$!wmk-b$pyU$ILPc$ZJ9P|H^In8k zEGra2gZ7=UIz5<|ml5%VY1POU3l#deXWwc-K=_zPR1J=NP|D%J?7FHG_7 z3#S}4m~Km9>G~(5Pgr}Sz#z@9mp-|+`Ov`mUxhNkGKt$R8S!5?VC=++j319p9nOqC ztChA@HNs?$51NpXRXj%s$l^b!-uHDT5g(Fn^Yw|pR594MNBcBocF1oJ%%d2g{tE@( z48{8^0Y^Q455(v0B)6EzAUwa(S`d*xp4p5{atTFk| zco?C>eVPSVgKfoM&n~=$1v&7RusC$53gQ)UlWl0R!p%mdcJjb5XBZLQezy=@G|se? z%Z)Df?-0MH#2XgR8)Oi>3BlNVauy?Bl^7P+Zk;lW`bBrFr;|+dH&Nlc5N!W(Kh>4# z)8aLn;_1a-=J9gXlH%Dgqw}rLPYkNgaeka<4)^ACFU+%io-O zF)4iK-2}3FAyjK#uEUT{pbdx^ci1V{z$!W`3TQ4$*Xz?Q*jVHXp55}Eu`<9qY0Y2u zJPGEH`zGl|vY@kd)Y{t-d_;$(qMkYn_Yo*qr)-R^xlnl`HRe@aaLokVNMa$+?{WQx#{bMB*0r20bI zL3QS^_QJ~pdqZ;+WX)Kyi|g11l61Rn7B339bll46Qe0BwzhYlOHW4Ks3BS req('GET', '/api/auth/check'), login: (username: string, password: string) => req<{ token: string }>('POST', '/api/auth/login', { username, password }), listRepos: () => req('GET', '/api/repos'), - createRepo: (slug: string, name: string, codename: string) => - req('POST', '/api/repos', { slug, name, codename }), + createRepo: (slug: string, name: string, codename: string, type: Repo['type']) => + req('POST', '/api/repos', { slug, name, codename, type }), updateRepo: (id: string, name: string, codename: string) => req('PUT', `/api/repos/${id}`, { name, codename }), deleteRepo: (id: string) => req('DELETE', `/api/repos/${id}`), diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 47a3872..c43c4d4 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -62,7 +62,12 @@ export default function Dashboard() { const [repos, setRepos] = useState([]) const [loading, setLoading] = useState(true) const [showNew, setShowNew] = useState(false) - const [form, setForm] = useState({ slug: '', name: '', codename: 'stable' }) + const [form, setForm] = useState<{ slug: string; name: string; codename: string; type: Repo['type'] }>({ + slug: '', + name: '', + codename: 'stable', + type: 'deb', + }) const [creating, setCreating] = useState(false) const [error, setError] = useState('') const navigate = useNavigate() @@ -81,9 +86,9 @@ export default function Dashboard() { setCreating(true) setError('') try { - const repo = await api.createRepo(form.slug, form.name, form.codename) + const repo = await api.createRepo(form.slug, form.name, form.codename, form.type) setShowNew(false) - setForm({ slug: '', name: '', codename: 'stable' }) + setForm({ slug: '', name: '', codename: 'stable', type: 'deb' }) navigate(`/repos/${repo.id}`) } catch (e: unknown) { setError(e instanceof Error ? e.message : 'unknown error') @@ -152,8 +157,20 @@ export default function Dashboard() {
- + + +
+
+ setForm(f => ({ ...f, name: e.target.value }))} placeholder="Ubuntu Developer Toolkit" @@ -162,8 +179,9 @@ export default function Dashboard() { />
- + setForm(f => ({ ...f, slug: e.target.value.toLowerCase() }))} placeholder="ubuntu-toolkit" @@ -172,15 +190,18 @@ export default function Dashboard() { /> Lowercase letters, numbers, and hyphens only
-
- - setForm(f => ({ ...f, codename: e.target.value }))} - placeholder="stable" - required - /> -
+ {form.type === 'deb' && ( +
+ + setForm(f => ({ ...f, codename: e.target.value }))} + placeholder="stable" + required + /> +
+ )}

Welcome back

-

Sign in to manage your APT repositories

+

Sign in to manage your package repositories

{error && ( @@ -73,8 +73,9 @@ export default function Login() {
- + setUsername(e.target.value)} @@ -84,8 +85,9 @@ export default function Login() { />
- + setPassword(e.target.value)} diff --git a/web/src/pages/RepoDetail.tsx b/web/src/pages/RepoDetail.tsx index 9826fe4..4d54592 100644 --- a/web/src/pages/RepoDetail.tsx +++ b/web/src/pages/RepoDetail.tsx @@ -93,6 +93,7 @@ export default function RepoDetail() { const inputRef = useRef(null) const canManageRepo = user?.role === 'admin' const canManagePackages = user?.role === 'admin' || user?.role === 'member' + const packageExtension = repo?.type === 'rpm' ? '.rpm' : '.deb' const loadRepo = async () => { if (!id) return @@ -140,8 +141,8 @@ export default function RepoDetail() { }, [id]) const handleUpload = async (file: File) => { - if (!id || !file.name.endsWith('.deb')) { - setError('Only .deb files are accepted') + if (!id || !file.name.endsWith(packageExtension)) { + setError(`Only ${packageExtension} files are accepted`) return } setUploading(true) @@ -231,7 +232,8 @@ export default function RepoDetail() {

{repo?.name}

- {repo?.codename} + {repo?.type.toUpperCase()} + {repo?.type === 'deb' && {repo.codename}} /{repo?.slug} {totalPkgs} package{totalPkgs !== 1 ? 's' : ''} @@ -265,10 +267,12 @@ export default function RepoDetail() { setEditForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
-
- - setEditForm(f => ({ ...f, codename: e.target.value }))} required /> -
+ {repo?.type === 'deb' && ( +
+ + setEditForm(f => ({ ...f, codename: e.target.value }))} required /> +
+ )}
+ +
Repositories {user?.role !== 'viewer' && API Keys} @@ -71,8 +78,9 @@ export default function Layout() { {user?.role === 'admin' && Audit Log}
- - {user?.username} + + {user?.username?.slice(0, 1).toUpperCase()} + {user?.username} - {user?.role === 'admin' && ( - - )} +

Manage package sources, distribution suites, and signing from one place.

-
+ {user?.role === 'admin' && } + - {error && ( -
- - {error} -
- )} + {error &&
{error}
} {showNew && (
{ if (e.target === e.currentTarget) setShowNew(false) }}> -
-
-

Create Repository

- -
+
+
New package source

Create repository

-
- - -
-
- - setForm(f => ({ ...f, name: e.target.value }))} - placeholder={form.type === 'rpm' ? 'Fedora Developer Toolkit' : 'Ubuntu Developer Toolkit'} - required - autoFocus - /> -
-
- - setForm(f => ({ ...f, slug: e.target.value.toLowerCase() }))} - placeholder={form.type === 'rpm' ? 'fedora-toolkit' : 'ubuntu-toolkit'} - pattern="[a-z0-9][a-z0-9\-]{0,62}" - required - /> - Lowercase letters, numbers, and hyphens only -
- {form.type === 'deb' && ( -
- - setForm(f => ({ ...f, codename: e.target.value }))} - placeholder="stable" - required - /> -
- )} -
- - -
+
+ {(['deb', 'rpm'] as const).map(type => )} +
+
setForm(f => ({ ...f, name: e.target.value }))} placeholder={form.type === 'rpm' ? 'Fedora Developer Toolkit' : 'Ubuntu Developer Toolkit'} required autoFocus />
+
/ setForm(f => ({ ...f, slug: e.target.value.toLowerCase() }))} placeholder="developer-toolkit" pattern="[a-z0-9][a-z0-9\-]{0,62}" required />
Lowercase letters, numbers, and hyphens only.
+ {form.type === 'deb' &&
setForm(f => ({ ...f, codename: e.target.value }))} placeholder="stable" required />For example: stable, bookworm, or noble.
} +
)} - {loading ? ( -
-
-
- ) : repos.length === 0 ? ( -
-
-

No repositories yet

-

Create your first repository to get started.

-
- ) : ( - <> -
-
- Repositories - {repos.length} -
-
- Default suite - {repos.find(r => r.codename === 'stable') ? 'stable' : repos[0]?.codename ?? 'none'} -
-
- Signing key - available + {loading ? : repos.length === 0 ? ( +
+
+
Your package hub starts here

Create your first repository

Host and distribute DEB or RPM packages securely from your own infrastructure.

+ {user?.role === 'admin' && } +
+ ) : <> +
+
Total repositories{repos.length}Package sources online
+
DEB repositories{debCount}
+
RPM repositories{rpmCount}
+
+ +
+

All repositories

{visibleRepos.length === repos.length ? `${repos.length} package source${repos.length === 1 ? '' : 's'}` : `${visibleRepos.length} of ${repos.length} repositories`}

+
+ +
{(['all', 'deb', 'rpm'] as RepoFilter[]).map(value => )}
+
-
-
- {repos.map(r => ( - -
- - {r.type.toUpperCase()} -
-
-
{r.name}
-
/{r.slug}
-
-
- {r.type === 'deb' ? r.codename : 'yum/dnf'} · {formatDate(r.created_at)} -
- - ))} -
- - )} + + {visibleRepos.length === 0 ?

No repositories found

Try a different search or package format.

: +
{visibleRepos.map(repo => +
{repo.type.toUpperCase()}
+

{repo.name}

/{repo.slug}
+
{repo.type === 'deb' ? 'Suite' : 'Client'}
{repo.type === 'deb' ? repo.codename : 'yum / dnf'}
Created
{formatDate(repo.created_at)}
+
Open repository
+ )}
} + + }
) } -function formatDate(s: string) { - return new Date(s).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +function DashboardSkeleton() { + return
+} + +function formatDate(value: string) { + return new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) } From fc6a124d0a6f1596de7b6e782d916899fdb51236 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 18:18:06 +0500 Subject: [PATCH 31/71] refactor: overhaul dashboard and resource pages with updated UI components, layout styles, and audit log tracking --- internal/api/audit.go | 8 ++ internal/api/router.go | 1 + internal/storage/db.go | 8 +- web/src/api.ts | 1 + web/src/index.css | 58 +++++++++++++ web/src/pages/ApiKeys.css | 88 ++++++++++--------- web/src/pages/ApiKeys.tsx | 65 +++++++++----- web/src/pages/AuditLog.css | 125 ++++++++++++++++++++++++++- web/src/pages/AuditLog.tsx | 159 ++++++++++++++++++++++++++++++++--- web/src/pages/Dashboard.css | 3 - web/src/pages/Profile.css | 156 +++++++++++++++------------------- web/src/pages/Profile.tsx | 147 ++++++++++++++++++++------------ web/src/pages/RepoDetail.css | 28 ------ web/src/pages/Users.css | 118 ++++++++++++-------------- web/src/pages/Users.tsx | 38 +++++++-- 15 files changed, 690 insertions(+), 313 deletions(-) diff --git a/internal/api/audit.go b/internal/api/audit.go index a6583be..6c04f19 100644 --- a/internal/api/audit.go +++ b/internal/api/audit.go @@ -51,3 +51,11 @@ func (h *Handler) listAuditLog(w http.ResponseWriter, r *http.Request) { "entries": entries, }, http.StatusOK) } + +func (h *Handler) clearAuditLog(w http.ResponseWriter, _ *http.Request) { + if err := h.db.ClearAuditLog(); err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/router.go b/internal/api/router.go index f5e4876..26383bb 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -76,6 +76,7 @@ func (h *Handler) Router(spa http.Handler) http.Handler { r.With(RequireRole("admin")).Put("/api/users/{id}", h.updateUser) r.With(RequireRole("admin")).Delete("/api/users/{id}", h.deleteUser) r.With(RequireRole("admin")).Get("/api/audit", h.listAuditLog) + r.With(RequireRole("admin")).Delete("/api/audit", h.clearAuditLog) r.Get("/api/system/gpg-key", h.exportGPGKey) }) diff --git a/internal/storage/db.go b/internal/storage/db.go index 70e69d5..361642a 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -533,7 +533,7 @@ func (d *DB) AddPackage(p *Package) error { // ListPackages returns all packages for a repo. If limit > 0, it applies pagination. func (d *DB) ListPackages(repoID string, limit, offset int) ([]Package, error) { - query := `SELECT id, repo_id, filename, package, version, `+"`release`"+`, arch, size, sha256, sha1, md5, control_json, uploaded_at + query := `SELECT id, repo_id, filename, package, version, ` + "`release`" + `, arch, size, sha256, sha1, md5, control_json, uploaded_at FROM packages WHERE repo_id=? ORDER BY uploaded_at DESC` var rows *sql.Rows @@ -734,6 +734,12 @@ func (d *DB) CountAuditLog(userID string) (int, error) { return count, err } +// ClearAuditLog permanently removes every audit log entry. +func (d *DB) ClearAuditLog() error { + _, err := d.db.Exec(`DELETE FROM audit_log`) + return err +} + func scanAuditEntries(rows *sql.Rows) ([]AuditEntry, error) { var entries []AuditEntry for rows.Next() { diff --git a/web/src/api.ts b/web/src/api.ts index 298c34b..dec0cff 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -171,4 +171,5 @@ export const api = { deleteUser: (id: string) => req('DELETE', `/api/users/${id}`), listAudit: (offset: number = 0, limit: number = 50) => req('GET', `/api/audit?offset=${offset}&limit=${limit}`), + clearAudit: () => req('DELETE', '/api/audit'), } diff --git a/web/src/index.css b/web/src/index.css index fd4eb62..da6b2dc 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -42,6 +42,48 @@ body { -webkit-font-smoothing: antialiased; } +/* Shared page chrome */ +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 30px; +} +.page-header > div:first-child { min-width: 0; } +.page-kicker { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 9px; + color: var(--accent-hover); + font-size: 11px; + font-weight: 800; + letter-spacing: .12em; + text-transform: uppercase; +} +.page-title { + color: var(--text); + font-size: 34px; + font-weight: 820; + letter-spacing: -.035em; + line-height: 1.08; +} +.page-sub { + max-width: 580px; + margin-top: 9px; + color: var(--muted); + font-size: 14px; +} +.page-actions { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 9px; +} +.page-actions button { min-height: 40px; padding-inline: 16px; } +.loading-center { display: flex; justify-content: center; padding: 72px 0; } + a { color: var(--accent); text-decoration: none; } a:hover { color: var(--accent-hover); } @@ -192,6 +234,16 @@ td { tr:last-child td { border-bottom: none; } tbody tr:hover td { background: rgba(45, 212, 191, 0.045); } +.package-table { + padding: 0; + overflow-x: auto; + overflow-y: hidden; +} +.pkg-name { font-size: 13px; font-weight: 720; } +.pkg-version { color: var(--text-2); } +.icon-btn { width: 32px; min-height: 30px; padding: 6px 8px; border-radius: var(--radius-sm); } +.pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border-top: 1px solid var(--border); } + .spinner { width: 20px; height: 20px; @@ -283,8 +335,14 @@ tbody tr:hover td { background: rgba(45, 212, 191, 0.045); } @media (max-width: 720px) { body { font-size: 13px; } button { width: 100%; } + .page-header { align-items: stretch; flex-direction: column; gap: 18px; margin-bottom: 24px; } + .page-title { font-size: 30px; } + .page-sub { font-size: 13px; } + .page-actions { width: 100%; } + .page-actions button { flex: 1; } .form-actions { flex-direction: column; } table { min-width: 680px; } + .pagination button { width: auto; } } @media (prefers-reduced-motion: reduce) { diff --git a/web/src/pages/ApiKeys.css b/web/src/pages/ApiKeys.css index 656ea56..eeac006 100644 --- a/web/src/pages/ApiKeys.css +++ b/web/src/pages/ApiKeys.css @@ -1,43 +1,53 @@ -.ak-reveal { - display: flex; - flex-direction: column; - gap: 14px; - margin: 4px 0 20px; -} +.key-overview { display:grid; grid-template-columns:1.4fr 1fr 1fr; gap:10px; margin-bottom:28px; } +.key-stat { position:relative; display:flex; align-items:center; gap:13px; min-height:94px; padding:17px 18px; overflow:hidden; border:1px solid var(--border); border-radius:var(--radius-lg); background:linear-gradient(145deg,rgba(29,34,44,.94),rgba(21,25,33,.88)); box-shadow:inset 0 1px 0 rgba(255,255,255,.025); } +.key-stat > div { min-width:0; } +.key-stat span { display:block; color:var(--muted); font-size:10px; font-weight:750; letter-spacing:.06em; text-transform:uppercase; } +.key-stat strong { display:block; margin-top:3px; color:var(--text); font-size:24px; line-height:1.15; letter-spacing:-.025em; } +.key-stat small { display:block; margin-top:3px; overflow:hidden; color:var(--muted); font-size:10px; text-overflow:ellipsis; white-space:nowrap; } +.key-stat > i { display:block; flex-shrink:0; width:8px; height:38px; border-radius:99px; background:var(--success); box-shadow:0 0 20px rgba(34,197,94,.18); } +.key-stat > i.unused-dot { background:var(--amber); box-shadow:0 0 20px rgba(245,158,11,.18); } +.key-stat-primary { display:block; background:linear-gradient(135deg,rgba(45,212,191,.15),rgba(25,29,37,.92) 68%); border-color:rgba(45,212,191,.21); } +.key-stat-primary::after { content:''; position:absolute; width:105px; height:105px; right:-28px; top:-55px; border:1px solid rgba(94,234,212,.14); border-radius:50%; box-shadow:0 0 0 20px rgba(94,234,212,.025); } -.ak-reveal-warning { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - background: color-mix(in srgb, var(--warning, #f59e0b) 12%, transparent); - border: 1px solid color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent); - border-radius: 8px; - font-size: 13px; - color: var(--text); -} +.keys-collection { padding-top:23px; border-top:1px solid rgba(48,55,68,.7); } +.keys-collection-header { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:14px; } +.keys-collection-header h2 { font-size:17px; letter-spacing:-.015em; } +.keys-collection-header p { margin-top:2px; color:var(--muted); font-size:10px; } +.key-search { position:relative; width:230px; } +.key-search input { height:36px; padding:7px 10px 7px 34px; font-size:12px; background-color:rgba(17,21,29,.72); } +.search-symbol { position:absolute; top:11px; left:12px; width:12px; height:12px; pointer-events:none; border:1.7px solid var(--muted); border-radius:50%; } +.search-symbol::after { content:''; position:absolute; width:5px; height:1.7px; right:-4px; bottom:-2px; background:var(--muted); transform:rotate(45deg); border-radius:2px; } +.keys-table { border-radius:var(--radius-lg); background:rgba(25,29,37,.88); } +.keys-table th { padding-block:14px; } +.keys-table td { height:72px; vertical-align:middle; } +.key-identity { display:flex; align-items:center; gap:10px; min-width:0; } +.key-identity > span { display:grid; place-items:center; flex-shrink:0; width:34px; height:34px; color:var(--accent-hover); background:var(--accent-subtle); border:1px solid rgba(45,212,191,.18); border-radius:9px; } +.key-identity > div { display:flex; min-width:0; flex-direction:column; } +.key-identity strong { overflow:hidden; font-size:12px; text-overflow:ellipsis; white-space:nowrap; } +.key-identity small { color:var(--muted); font-size:10px; } +.key-prefix { display:inline-flex; padding:4px 8px; color:var(--text-2); background:rgba(255,255,255,.025); border:1px solid rgba(48,55,68,.7); border-radius:6px; } +.key-used { display:inline-flex; align-items:center; gap:7px; color:var(--text-2); font-size:11px; } +.key-used i { width:6px; height:6px; border-radius:50%; background:var(--success); box-shadow:0 0 0 3px rgba(34,197,94,.09); } +.key-never { display:inline-flex; padding:3px 7px; color:#fbbf24; background:rgba(245,158,11,.07); border:1px solid rgba(245,158,11,.18); border-radius:99px; font-size:10px; font-weight:700; } +.keys-no-results { display:flex; align-items:center; justify-content:center; flex-direction:column; gap:3px; padding:30px 20px; text-align:center; } +.keys-no-results strong { font-size:13px; }.keys-no-results span { margin-bottom:8px; color:var(--muted); font-size:11px; }.keys-no-results button { width:auto; } -.ak-reveal-key { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 14px; - background: var(--surface-raised, #1a1f29); - border: 1px solid var(--border, #303744); - border-radius: 8px; - font-size: 12px; - word-break: break-all; -} +.api-empty { padding:78px 24px; border:1px dashed var(--border); border-radius:var(--radius-lg); background:radial-gradient(circle at 50% 0,rgba(45,212,191,.08),transparent 42%); } +.key-empty-visual { display:grid; place-items:center; width:92px; height:92px; margin:0 auto 18px; border:1px solid rgba(94,234,212,.14); border-radius:50%; box-shadow:0 0 0 12px rgba(45,212,191,.025); } +.key-empty-visual > span { display:grid; place-items:center; width:58px; height:58px; color:var(--accent-hover); background:var(--surface); border:1px solid rgba(94,234,212,.23); border-radius:16px; box-shadow:0 14px 30px rgba(0,0,0,.24); } +.key-empty-visual svg { width:30px; height:30px; } +.empty-eyebrow { color:var(--accent-hover); font-size:10px; font-weight:800; letter-spacing:.12em; text-transform:uppercase; } +.api-empty h2 { margin-top:6px; color:var(--text); font-size:21px; }.api-empty p { max-width:450px; margin:7px auto 18px; }.api-empty button { width:auto; } -.ak-reveal-key code { - flex: 1; - font-family: monospace; - color: var(--accent, #14b8a6); - font-size: 13px; -} +.key-modal { max-width:500px; } +.modal-kicker { margin-bottom:3px; color:var(--accent-hover); font-size:10px; font-weight:800; letter-spacing:.1em; text-transform:uppercase; } +.ak-reveal { display:flex; flex-direction:column; gap:14px; margin:4px 0 20px; } +.ak-reveal-warning { display:flex; align-items:center; gap:11px; padding:11px 13px; color:var(--text); background:rgba(245,158,11,.08); border:1px solid rgba(245,158,11,.24); border-radius:8px; } +.ak-reveal-warning > span { display:grid; place-items:center; flex-shrink:0; width:30px; height:30px; color:#fbbf24; background:rgba(245,158,11,.1); border-radius:8px; } +.ak-reveal-warning > div { display:flex; flex-direction:column; }.ak-reveal-warning strong { font-size:11px; }.ak-reveal-warning small { color:var(--muted); font-size:10px; } +.ak-reveal-key { display:flex; align-items:center; gap:10px; padding:12px 14px; background:#0f141b; border:1px solid var(--border); border-radius:8px; word-break:break-all; } +.ak-reveal-key code { flex:1; color:var(--accent-hover); font-size:12px; }.ak-reveal-meta { margin:0; color:var(--muted); font-size:11px; } +.key-security-note { display:flex; align-items:center; gap:8px; padding:10px 11px; color:var(--text-2); background:rgba(45,212,191,.055); border:1px solid rgba(45,212,191,.15); border-radius:7px; font-size:10px; }.key-security-note svg { flex-shrink:0; color:var(--accent-hover); } -.ak-reveal-meta { - font-size: 12.5px; - color: var(--muted); - margin: 0; -} +@media (max-width:720px) { .key-overview { grid-template-columns:1fr 1fr; }.key-stat-primary { grid-column:1/-1; }.keys-collection-header { align-items:stretch; flex-direction:column; }.key-search { width:100%; } } +@media (max-width:480px) { .key-overview { gap:8px; }.key-stat { min-height:86px; padding:14px; }.ak-reveal-key { align-items:stretch; flex-direction:column; }.ak-reveal-key button { width:100%; } } diff --git a/web/src/pages/ApiKeys.tsx b/web/src/pages/ApiKeys.tsx index 7f4d5e8..ba9882b 100644 --- a/web/src/pages/ApiKeys.tsx +++ b/web/src/pages/ApiKeys.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useOutletContext } from 'react-router-dom' import { api, type APIKey, type APIKeyCreated, type CurrentUser } from '../api' import './ApiKeys.css' @@ -48,6 +48,10 @@ function KeyIcon() { ) } +function ShieldIcon() { + return +} + export default function ApiKeys() { const { user } = useOutletContext<{ user: CurrentUser | null }>() const [keys, setKeys] = useState([]) @@ -58,6 +62,7 @@ export default function ApiKeys() { const [creating, setCreating] = useState(false) const [newKey, setNewKey] = useState(null) const [copied, setCopied] = useState(false) + const [query, setQuery] = useState('') if (user?.role === 'viewer') { return ( @@ -85,6 +90,13 @@ export default function ApiKeys() { useEffect(load, []) + const visibleKeys = useMemo(() => { + const term = query.trim().toLowerCase() + return term ? keys.filter(key => key.name.toLowerCase().includes(term) || key.prefix.toLowerCase().includes(term)) : keys + }, [keys, query]) + + const usedKeys = keys.filter(key => key.last_used).length + const handleCreate = async (e: React.FormEvent) => { e.preventDefault() if (!newName.trim()) return @@ -130,12 +142,12 @@ export default function ApiKeys() { } return ( -
+
Authentication

API Keys

-

{keys.length} key{keys.length !== 1 ? 's' : ''}

+

Secure credentials for CI pipelines, deployment tools, and automation.

)} + {!loading && keys.length > 0 &&
+
Total keys{keys.length}Issued credentials
+
Used keys{usedKeys}Authenticated at least once
+
Never used{keys.length - usedKeys}Awaiting first use
+
} + {/* New key reveal modal */} {newKey && (
{ if (e.target === e.currentTarget) { setNewKey(null) } }}> -
+
-

API Key Created

+
Credential ready

API key created

- - This key will not be shown again. Copy it now. +
Copy this key nowFor your security, the secret will not be shown again.
{newKey.key} @@ -183,14 +200,14 @@ export default function ApiKeys() { {/* Create key modal */} {showNew && (
{ if (e.target === e.currentTarget) setShowNew(false) }}> -
+
-

New API Key

+
New credential

Create API key

- + setNewName(e.target.value)} @@ -198,8 +215,9 @@ export default function ApiKeys() { required autoFocus /> - A label to identify this key (e.g. the system it's used on) + Use a descriptive label for the service or machine using this key.
+
Store the generated secret in a secure credential manager.
) : ( -
+
+

Issued credentials

{visibleKeys.length === keys.length ? `${keys.length} key${keys.length === 1 ? '' : 's'}` : `${visibleKeys.length} of ${keys.length} keys`}

+ +
+
@@ -235,12 +258,12 @@ export default function ApiKeys() { - {keys.map(k => ( + {visibleKeys.map(k => ( - - + + - + ))} + {visibleKeys.length === 0 && }
{k.name}{k.prefix}…
{k.name}{k.last_used ? 'Active credential' : 'Not used yet'}
{k.prefix}… {formatDate(k.created_at)}{k.last_used ? formatDate(k.last_used) : '—'}{k.last_used ? {formatDate(k.last_used)} : Never}
No keys foundTry a different name or prefix.
+
)}
) diff --git a/web/src/pages/AuditLog.css b/web/src/pages/AuditLog.css index 9c9c694..efc948f 100644 --- a/web/src/pages/AuditLog.css +++ b/web/src/pages/AuditLog.css @@ -1,6 +1,25 @@ -.audit-table td { - vertical-align: top; -} +.audit-overview { display:grid; grid-template-columns:1.4fr 1fr 1fr; gap:10px; margin-bottom:28px; } +.audit-stat { position:relative; min-height:94px; padding:17px 18px; overflow:hidden; border:1px solid var(--border); border-radius:var(--radius-lg); background:linear-gradient(145deg,rgba(29,34,44,.94),rgba(21,25,33,.88)); box-shadow:inset 0 1px 0 rgba(255,255,255,.025); } +.audit-stat > span { display:block; color:var(--muted); font-size:10px; font-weight:750; letter-spacing:.06em; text-transform:uppercase; } +.audit-stat strong { display:block; margin-top:4px; color:var(--text); font-size:24px; line-height:1.15; letter-spacing:-.025em; } +.audit-stat small { display:block; margin-top:3px; color:var(--muted); font-size:10px; } +.audit-stat-primary { background:linear-gradient(135deg,rgba(45,212,191,.15),rgba(25,29,37,.92) 68%); border-color:rgba(45,212,191,.21); } +.audit-stat-primary::after { content:''; position:absolute; width:105px; height:105px; right:-28px; top:-55px; border:1px solid rgba(94,234,212,.14); border-radius:50%; box-shadow:0 0 0 20px rgba(94,234,212,.025); } +.audit-collection { padding-top:23px; border-top:1px solid rgba(48,55,68,.7); } +.audit-collection-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:14px; } +.audit-collection-heading h2 { font-size:17px; letter-spacing:-.015em; } +.audit-collection-heading p,.audit-export-note { margin-top:2px; color:var(--muted); font-size:10px; } +.audit-table { border-radius:var(--radius-lg); background:rgba(25,29,37,.88); } +.audit-table th { padding-block:14px; } +.audit-table td { height:68px; vertical-align:middle; } +.audit-user { display:flex; align-items:center; gap:9px; } +.audit-user > span { display:grid; place-items:center; flex-shrink:0; width:30px; height:30px; border-radius:8px; color:var(--accent-hover); background:var(--accent-subtle); border:1px solid rgba(45,212,191,.16); font-size:9px; font-weight:850; } +.audit-user strong { font-size:12px; } +.audit-action { display:inline-flex; align-items:center; min-height:24px; padding:3px 8px; color:var(--text-2); background:rgba(148,163,184,.08); border:1px solid rgba(148,163,184,.18); border-radius:99px; font-size:10px; font-weight:750; white-space:nowrap; } +.action-danger { color:#fda4af; background:rgba(251,113,133,.08); border-color:rgba(251,113,133,.22); } +.action-create { color:var(--accent-hover); background:rgba(45,212,191,.08); border-color:rgba(45,212,191,.22); } +.action-auth { color:#93c5fd; background:rgba(96,165,250,.08); border-color:rgba(96,165,250,.22); } +.action-update { color:#fbbf24; background:rgba(245,158,11,.08); border-color:rgba(245,158,11,.22); } .audit-detail { margin: 0; @@ -48,3 +67,103 @@ font-family: var(--mono); word-break: break-all; } + +.audit-export-spinner { + width: 14px; + height: 14px; + border-width: 2px; +} + +.clear-audit-trigger { min-height:40px; padding-inline:13px; border-color:rgba(251,113,133,.24)!important; } +.clear-audit-modal { max-width:500px; } +.clear-kicker { color:var(--danger); } +.clear-warning-icon { display:grid; place-items:center; width:48px; height:48px; margin-bottom:16px; color:var(--danger); background:var(--danger-subtle); border:1px solid rgba(251,113,133,.22); border-radius:13px; } +.clear-warning-icon svg { width:21px; height:21px; } +.clear-audit-copy { color:var(--text-2); font-size:13px; line-height:1.65; } +.clear-audit-copy strong { color:var(--text); } +.export-recommendation { display:flex; align-items:center; gap:11px; margin-top:18px; padding:12px 13px; color:var(--accent-hover); background:rgba(45,212,191,.06); border:1px solid rgba(45,212,191,.18); border-radius:8px; } +.export-recommendation > svg { flex-shrink:0; } +.export-recommendation > div { display:flex; flex-direction:column; } +.export-recommendation strong { color:var(--text); font-size:11px; } +.export-recommendation span { color:var(--muted); font-size:10px; } +.clear-audit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:20px; } +.clear-audit-actions .ghost { grid-column:1/-1; } +.danger-bordered { color:var(--danger)!important; background:var(--danger-subtle)!important; border-color:rgba(251,113,133,.28)!important; } +.danger-bordered:hover { background:rgba(251,113,133,.16)!important; border-color:rgba(251,113,133,.42)!important; } + +.audit-pagination { + display: grid; + grid-template-columns: 1fr auto 1fr; + min-height: 66px; + padding: 12px 16px; +} + +.page-size { + display: flex; + align-items: center; + gap: 9px; + color: var(--muted); + font-size: 11px; + font-weight: 650; +} + +.page-size select { + width: 72px; + min-height: 34px; + padding: 6px 30px 6px 10px; + font-size: 12px; +} + +.page-status { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 1px; + min-width: 120px; +} + +.page-number { + color: var(--text-2); + font-size: 11px; + font-weight: 650; +} + +.page-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.page-buttons button { + min-width: 76px; +} + +@media (max-width: 720px) { + .audit-overview { grid-template-columns:1fr 1fr; } + .audit-stat-primary { grid-column:1/-1; } + .audit-pagination { + grid-template-columns: 1fr auto; + gap: 12px; + } + + .page-status { + grid-column: 1 / -1; + grid-row: 1; + } + + .page-size { grid-column: 1; } + .page-buttons { grid-column: 2; } + .page-size select { width: 68px; } +} + +@media (max-width: 480px) { + .audit-overview { gap:8px; } + .audit-stat { min-height:86px; padding:14px; } + .audit-collection-heading { align-items:flex-start; flex-direction:column; } + .audit-pagination { display: flex; flex-direction: column; align-items: stretch; } + .page-size { justify-content: space-between; } + .page-buttons button { flex: 1; } + .clear-audit-actions { grid-template-columns:1fr; } + .clear-audit-actions .ghost { grid-column:auto; } +} diff --git a/web/src/pages/AuditLog.tsx b/web/src/pages/AuditLog.tsx index a94ca5e..0f9885b 100644 --- a/web/src/pages/AuditLog.tsx +++ b/web/src/pages/AuditLog.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' -import { api, type AuditEntry } from '../api' +import { useOutletContext } from 'react-router-dom' +import { api, type AuditEntry, type CurrentUser } from '../api' import './AuditLog.css' function AlertIcon() { @@ -12,13 +13,33 @@ function AlertIcon() { ) } +function DownloadIcon() { + return ( + + + + ) +} + +function TrashIcon() { + return +} + +function XIcon() { + return +} + export default function AuditLog() { + const { user } = useOutletContext<{ user: CurrentUser | null }>() const [entries, setEntries] = useState([]) const [total, setTotal] = useState(0) const [offset, setOffset] = useState(0) + const [limit, setLimit] = useState(50) const [loading, setLoading] = useState(true) + const [exporting, setExporting] = useState(false) + const [clearing, setClearing] = useState(false) + const [showClear, setShowClear] = useState(false) const [error, setError] = useState('') - const limit = 50 useEffect(() => { setLoading(true) @@ -29,23 +50,114 @@ export default function AuditLog() { }) .catch(e => setError(e.message)) .finally(() => setLoading(false)) - }, [offset]) + }, [offset, limit]) + + const handleLimitChange = (value: number) => { + setLimit(value) + setOffset(0) + } + + const exportAudit = async (): Promise => { + setExporting(true) + setError('') + try { + const exportLimit = 200 + let exportOffset = 0 + let exportTotal = 0 + const allEntries: AuditEntry[] = [] + + do { + const data = await api.listAudit(exportOffset, exportLimit) + const batch = data.entries ?? [] + exportTotal = data.total + allEntries.push(...batch) + exportOffset += batch.length + if (batch.length === 0) break + } while (allEntries.length < exportTotal) + + const rows = [ + ['ID', 'User', 'Action', 'Resource', 'Detail', 'Timestamp'], + ...allEntries.map(entry => [entry.id, entry.username, entry.action, entry.resource, entry.detail || '', entry.created_at]), + ] + const csv = rows.map(row => row.map(toCsvCell).join(',')).join('\r\n') + const blob = new Blob(['\uFEFF', csv], { type: 'text/csv;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `aptify-audit-log-${new Date().toISOString().slice(0, 10)}.csv` + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) + return true + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to export audit log') + return false + } finally { + setExporting(false) + } + } + + const handleClear = async (exportFirst: boolean) => { + setClearing(true) + setError('') + try { + if (exportFirst && !(await exportAudit())) return + await api.clearAudit() + setEntries([]) + setTotal(0) + setOffset(0) + setShowClear(false) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to clear audit log') + } finally { + setClearing(false) + } + } return ( -
+
Operations

Audit Log

-

{total} entr{total === 1 ? 'y' : 'ies'}

+

Trace security events and administrative activity across your workspace.

+
+
+ + {user?.role === 'admin' && }
{error &&
{error}
} + {showClear &&
{ if (e.target === e.currentTarget && !clearing) setShowClear(false) }}> +
+
Permanent action

Clear all audit logs?

+
+

This will permanently delete all {total} audit event{total === 1 ? '' : 's'}. This history cannot be recovered after it is cleared.

+
Download a copy first?We recommend exporting the current logs for your security records.
+
+ + + +
+
+
} + + {!loading &&
+
Total events{total}Recorded activity
+
Visible range{total === 0 ? '0' : `${offset + 1}–${Math.min(offset + entries.length, total)}`}of {total} events
+
Page size{limit}Events per page
+
} + {loading ? (
) : ( +
+

Event history

Newest activity appears first

CSV export includes all {total} events
@@ -60,8 +172,8 @@ export default function AuditLog() { {entries.map(e => ( - - + + @@ -74,12 +186,24 @@ export default function AuditLog() { )}
{e.username}{formatAction(e.action)}
{e.username.slice(0, 2).toUpperCase()}{e.username}
{formatAction(e.action)} {e.resource} {formatDateTime(e.created_at)}
-
- - {total === 0 ? 'Page 0 of 0' : `Page ${Math.floor(offset / limit) + 1} of ${Math.ceil(total / limit)}`} - +
+ +
+ {total === 0 ? '0 entries' : `${offset + 1}–${Math.min(offset + entries.length, total)} of ${total}`} + {total === 0 ? 'Page 0 of 0' : `Page ${Math.floor(offset / limit) + 1} of ${Math.ceil(total / limit)}`} +
+
+ + +
+
)}
) @@ -111,6 +235,14 @@ function formatAction(action: string) { return action.split('_').join(' ') } +function actionTone(action: string) { + if (action.includes('delete') || action.includes('remove')) return 'danger' + if (action.includes('create') || action.includes('upload') || action.includes('add')) return 'create' + if (action.includes('login')) return 'auth' + if (action.includes('update') || action.includes('edit')) return 'update' + return 'neutral' +} + function formatDateTime(s: string) { return new Date(s).toLocaleString(undefined, { month: 'short', @@ -120,3 +252,8 @@ function formatDateTime(s: string) { minute: '2-digit', }) } + +function toCsvCell(value: string) { + const safe = /^[=+\-@]/.test(value) ? `'${value}` : value + return `"${safe.replace(/"/g, '""')}"` +} diff --git a/web/src/pages/Dashboard.css b/web/src/pages/Dashboard.css index 05d5ff0..d8e54dd 100644 --- a/web/src/pages/Dashboard.css +++ b/web/src/pages/Dashboard.css @@ -1,9 +1,6 @@ .dashboard { --deb: #2dd4bf; --rpm: #f59e0b; } .dashboard-hero { display:flex; align-items:flex-end; justify-content:space-between; gap:24px; margin-bottom:30px; } .dashboard-heading { max-width:650px; } -.page-kicker { display:flex; align-items:center; gap:6px; color:var(--accent-hover); font-size:11px; font-weight:800; letter-spacing:.12em; text-transform:uppercase; margin-bottom:9px; } -.page-title { font-size:34px; font-weight:820; letter-spacing:-.035em; color:var(--text); line-height:1.08; } -.page-sub { color:var(--muted); font-size:14px; margin-top:9px; max-width:580px; } .hero-primary { min-height:40px; padding-inline:16px; } .metrics { display:grid; grid-template-columns:1.35fr 1fr 1fr; gap:12px; margin-bottom:30px; } diff --git a/web/src/pages/Profile.css b/web/src/pages/Profile.css index a3735e2..43e3bd0 100644 --- a/web/src/pages/Profile.css +++ b/web/src/pages/Profile.css @@ -1,93 +1,75 @@ -.profile-card { - max-width: 500px; -} - -.profile-content { - display: flex; - align-items: flex-start; - gap: 24px; - padding: 32px; -} - -.profile-icon { - flex-shrink: 0; - color: var(--accent, #14b8a6); - opacity: 0.7; -} - -.profile-info { - flex: 1; - display: flex; - flex-direction: column; - gap: 20px; -} - -.profile-field { - display: flex; - flex-direction: column; - gap: 6px; -} - -.profile-field label { - font-size: 12px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--muted); -} - -.profile-value { - font-size: 15px; - margin: 0; - color: var(--text); -} - -.profile-description { - font-size: 13px; - color: var(--text-secondary, #a0aec0); - margin: 0; - line-height: 1.5; -} +.profile-page { --profile-card:linear-gradient(150deg,rgba(29,34,44,.95),rgba(21,25,33,.9)); } +.profile-header { display:flex; align-items:flex-end; justify-content:space-between; gap:24px; margin-bottom:30px; } +.session-state { display:flex; align-items:center; gap:8px; padding:7px 11px; color:var(--text-2); background:rgba(255,255,255,.025); border:1px solid var(--border); border-radius:99px; font-size:11px; font-weight:650; } +.session-state > span,.status-dot { display:inline-block; width:7px; height:7px; border-radius:50%; background:var(--success); box-shadow:0 0 0 4px rgba(34,197,94,.1); } -.role-badge { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 2px 8px; - border-radius: 999px; - font-size: 11px; - font-weight: 800; - text-transform: capitalize; - border: 1px solid var(--border); -} +.profile-layout { display:grid; grid-template-columns:minmax(260px,340px) minmax(0,1fr); gap:16px; align-items:stretch; } +.identity-card,.access-card,.profile-links { border:1px solid var(--border); border-radius:var(--radius-lg); background:var(--profile-card); box-shadow:inset 0 1px 0 rgba(255,255,255,.03); } +.identity-card { position:relative; height:100%; overflow:hidden; padding:34px 24px 24px; text-align:center; } +.identity-accent { position:absolute; inset:0 0 auto; height:92px; background:radial-gradient(circle at 50% -30%,rgba(45,212,191,.28),transparent 72%),linear-gradient(120deg,rgba(45,212,191,.09),rgba(45,212,191,.015)); border-bottom:1px solid rgba(45,212,191,.09); } +.identity-avatar { position:relative; display:grid; place-items:center; width:82px; height:82px; margin:12px auto 16px; border-radius:24px; color:#081613; background:linear-gradient(145deg,var(--accent-hover),var(--accent-strong)); border:5px solid #191e26; box-shadow:0 15px 35px rgba(0,0,0,.32),0 0 0 1px rgba(94,234,212,.22); font-size:24px; font-weight:850; letter-spacing:-.04em; } +.identity-avatar i { position:absolute; display:grid; place-items:center; width:23px; height:23px; right:-4px; bottom:-4px; color:#061412; background:var(--accent-hover); border:4px solid #191e26; border-radius:50%; font-style:normal; } +.identity-copy { position:relative; } +.identity-label,.section-eyebrow { display:block; color:var(--muted); font-size:10px; font-weight:800; letter-spacing:.1em; text-transform:uppercase; } +.identity-copy h2 { margin:3px 0 10px; font-size:21px; line-height:1.25; letter-spacing:-.025em; overflow-wrap:anywhere; } +.role-badge { display:inline-flex; align-items:center; gap:5px; min-height:25px; padding:3px 9px; border-radius:999px; font-size:10px; font-weight:800; border:1px solid var(--border); } +.role-admin { color:#fda4af; background:rgba(251,113,133,.1); border-color:rgba(251,113,133,.28); } +.role-member { color:#93c5fd; background:rgba(96,165,250,.1); border-color:rgba(96,165,250,.28); } +.role-viewer { color:#cbd5e1; background:rgba(148,163,184,.1); border-color:rgba(148,163,184,.24); } +.account-details { margin-top:28px; padding-top:4px; border-top:1px solid rgba(48,55,68,.7); text-align:left; } +.account-details > div { padding:14px 0; border-bottom:1px solid rgba(48,55,68,.55); } +.account-details > div:last-child { border-bottom:0; padding-bottom:0; } +.account-details dt { margin-bottom:4px; color:var(--muted); font-size:10px; font-weight:750; letter-spacing:.07em; text-transform:uppercase; } +.account-details dd { display:flex; align-items:center; gap:8px; min-width:0; color:var(--text-2); font-size:12px; font-weight:650; } +.account-details code { flex:1; min-width:0; overflow:hidden; color:var(--text-2); text-overflow:ellipsis; white-space:nowrap; } +.account-details button { min-height:26px; padding:3px 7px; color:var(--muted); background:rgba(255,255,255,.025); border-color:var(--border); font-size:10px; } +.account-details button:hover,.account-details button.copied { color:var(--accent-hover); border-color:rgba(45,212,191,.3); } +.status-dot { width:6px; height:6px; box-shadow:0 0 0 3px rgba(34,197,94,.1); } -.role-admin { - background: rgba(251, 113, 133, 0.12); - color: #fda4af; - border-color: rgba(251, 113, 133, 0.28); -} +.profile-main { display:flex; flex-direction:column; gap:16px; min-width:0; } +.access-card,.profile-links { padding:24px; } +.section-heading { display:flex; align-items:center; gap:12px; } +.section-heading h2,.profile-links-heading h2 { margin-top:2px; font-size:17px; letter-spacing:-.015em; } +.section-icon { display:grid; place-items:center; width:42px; height:42px; border-radius:11px; color:#fda4af; background:rgba(251,113,133,.09); border:1px solid rgba(251,113,133,.2); } +.role-icon-member { color:#93c5fd; background:rgba(96,165,250,.09); border-color:rgba(96,165,250,.2); } +.role-icon-viewer { color:#cbd5e1; background:rgba(148,163,184,.09); border-color:rgba(148,163,184,.2); } +.access-summary { max-width:620px; margin:18px 0 20px; color:var(--text-2); font-size:13px; line-height:1.65; } +.permission-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } +.permission-item { display:flex; align-items:center; gap:9px; min-height:43px; padding:10px 11px; color:var(--text-2); background:rgba(255,255,255,.018); border:1px solid rgba(48,55,68,.65); border-radius:8px; font-size:11px; font-weight:650; } +.permission-item > span { display:grid; place-items:center; flex-shrink:0; width:21px; height:21px; color:var(--accent-hover); background:var(--accent-subtle); border-radius:50%; } -.role-member { - background: rgba(96, 165, 250, 0.12); - color: #93c5fd; - border-color: rgba(96, 165, 250, 0.28); -} +.profile-links-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:16px; } +.profile-links-heading > span { color:var(--muted); font-size:10px; } +.link-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } +.profile-link { display:flex; align-items:center; gap:10px; min-width:0; padding:12px; color:var(--text); background:rgba(255,255,255,.018); border:1px solid rgba(48,55,68,.7); border-radius:9px; transition:background .16s,border-color .16s,transform .16s; } +.profile-link:hover { color:var(--text); background:rgba(45,212,191,.045); border-color:rgba(45,212,191,.25); transform:translateY(-1px); } +.profile-link-icon { display:grid; place-items:center; flex-shrink:0; width:34px; height:34px; color:var(--accent-hover); background:var(--accent-subtle); border-radius:8px; } +.profile-link > span:nth-child(2) { display:flex; flex:1; min-width:0; flex-direction:column; } +.profile-link strong { font-size:12px; } +.profile-link small { overflow:hidden; color:var(--muted); font-size:10px; text-overflow:ellipsis; white-space:nowrap; } +.profile-link > i { display:grid; place-items:center; flex-shrink:0; width:26px; height:26px; color:var(--muted); border-radius:50%; font-style:normal; transition:transform .16s,color .16s; } +.profile-link:hover > i { color:var(--accent-hover); transform:translateX(2px); } -.role-viewer { - background: rgba(148, 163, 184, 0.12); - color: #cbd5e1; - border-color: rgba(148, 163, 184, 0.24); +@media (max-width:820px) { + .profile-layout { grid-template-columns:1fr; } + .identity-card { display:grid; grid-template-columns:auto 1fr; column-gap:20px; text-align:left; } + .identity-accent { height:100%; width:150px; background:radial-gradient(circle at 0 20%,rgba(45,212,191,.22),transparent 70%); border:0; border-right:1px solid rgba(45,212,191,.08); } + .identity-avatar { grid-row:1; margin:0; } + .identity-copy { align-self:center; } + .account-details { grid-column:1/-1; display:grid; grid-template-columns:repeat(3,1fr); gap:16px; width:100%; } + .account-details > div,.account-details > div:last-child { border:0; padding:14px 0 0; } } - -@media (max-width: 480px) { - .profile-content { - flex-direction: column; - gap: 16px; - padding: 20px; - } - - .profile-icon { - width: 40px; - height: 40px; - } +@media (max-width:600px) { + .profile-header { align-items:flex-start; flex-direction:column; margin-bottom:24px; } + .session-state { align-self:flex-start; } + .identity-card { display:block; padding:28px 20px 20px; text-align:center; } + .identity-accent { width:100%; height:84px; border-right:0; border-bottom:1px solid rgba(45,212,191,.08); } + .identity-avatar { margin:10px auto 15px; } + .account-details { display:block; text-align:left; } + .account-details > div,.account-details > div:last-child { padding:13px 0; border-bottom:1px solid rgba(48,55,68,.55); } + .account-details > div:last-child { border-bottom:0; padding-bottom:0; } + .access-card,.profile-links { padding:19px; } + .permission-grid,.link-grid { grid-template-columns:1fr; } + .profile-links-heading > span { display:none; } } +@media (prefers-reduced-motion:reduce) { .profile-link,.profile-link > i { transition:none; } } diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index 8fb423e..f1b62ba 100644 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -1,73 +1,114 @@ -import { useOutletContext } from 'react-router-dom' -import { type CurrentUser } from '../api' +import { useState } from 'react' +import { Link, useOutletContext } from 'react-router-dom' +import { type CurrentUser, type Role } from '../api' import './Profile.css' -function UserIcon() { - return ( - - - - - ) +type IconName = 'user' | 'shield' | 'check' | 'copy' | 'arrow' | 'key' | 'team' | 'audit' | 'repo' | 'spark' + +function Icon({ name, size = 16 }: { name: IconName; size?: number }) { + const paths: Record = { + user: <>, + shield: <>, + check: , + copy: <>, + arrow: <>, + key: <>, + team: <>, + audit: <>, + repo: <>, + spark: <>, + } + return +} + +const roleContent: Record = { + admin: { + name: 'Administrator', + summary: 'Full workspace access with control over repositories, users, and security activity.', + permissions: ['Create and manage repositories', 'Upload and remove packages', 'Manage API keys', 'Manage users and audit logs'], + }, + member: { + name: 'Member', + summary: 'Operational access for managing repositories, packages, and personal API credentials.', + permissions: ['View all repositories', 'Upload and remove packages', 'Create and manage API keys'], + }, + viewer: { + name: 'Viewer', + summary: 'Read-only workspace access for browsing repositories and published packages.', + permissions: ['View all repositories', 'Browse package metadata', 'Access repository setup instructions'], + }, } export default function Profile() { const { user } = useOutletContext<{ user: CurrentUser | null }>() + const [copied, setCopied] = useState(false) - const getRoleDisplay = (role: string): string => { - const roleMap: Record = { - admin: 'Administrator', - member: 'Member', - viewer: 'Viewer' - } - return roleMap[role] || role - } + if (!user) return null - const getRoleDescription = (role: string): string => { - const descMap: Record = { - admin: 'Full access to all features including user management and audit logs', - member: 'Can manage repositories and packages, create API keys', - viewer: 'Read-only access to repositories and packages' - } - return descMap[role] || '' + const role = roleContent[user.role] + const initials = user.username.slice(0, 2).toUpperCase() + + const copyAccountId = async () => { + await navigator.clipboard.writeText(user.user_id) + setCopied(true) + window.setTimeout(() => setCopied(false), 1800) } return ( -
-
+
+
-
Account
-

Profile

+
Account
+

Your profile

+

Your identity and access across this Aptify workspace.

-
+
Authenticated session
+ - {user && ( -
-
-
- +
+
+
+ +
+ Signed in as +

{user.username}

+ {role.name} +
+ +
+
Username
{user.username}
+
Account ID
{user.user_id}
+
Account status
Active
+
+
+ +
+
+
+ +
Role & access

{role.name} permissions

-
-
- -

{user.username}

-
-
- -

- - {getRoleDisplay(user.role)} - -

-
-
- -

{getRoleDescription(user.role)}

-
+

{role.summary}

+
+ {role.permissions.map(permission =>
{permission}
)}
-
+
+ +
+
Workspace

Quick access

Based on your role
+
+ + {user.role !== 'viewer' && } + {user.role === 'admin' && } + {user.role === 'admin' && } +
+
- )} +
) } + +function ProfileLink({ to, icon, title, description }: { to: string; icon: IconName; title: string; description: string }) { + return {title}{description} +} diff --git a/web/src/pages/RepoDetail.css b/web/src/pages/RepoDetail.css index fa9317c..ca48e8f 100644 --- a/web/src/pages/RepoDetail.css +++ b/web/src/pages/RepoDetail.css @@ -148,34 +148,6 @@ color: var(--muted); } -.package-table { - padding: 0; - overflow: hidden; -} - -.pkg-name { - font-weight: 720; - font-size: 13px; -} -.pkg-version { - color: var(--text-2); -} - -.icon-btn { - padding: 6px 8px; - min-height: 30px; - width: 32px; - border-radius: var(--radius-sm); -} - -.pagination { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; - border-top: 1px solid var(--border); -} - .setup { display: flex; flex-direction: column; diff --git a/web/src/pages/Users.css b/web/src/pages/Users.css index c589790..a208837 100644 --- a/web/src/pages/Users.css +++ b/web/src/pages/Users.css @@ -1,62 +1,56 @@ -.role-badge { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 2px 8px; - border-radius: 999px; - font-size: 11px; - font-weight: 800; - text-transform: capitalize; - border: 1px solid var(--border); -} - -.role-admin { - background: rgba(251, 113, 133, 0.12); - color: #fda4af; - border-color: rgba(251, 113, 133, 0.28); -} - -.role-member { - background: rgba(96, 165, 250, 0.12); - color: #93c5fd; - border-color: rgba(96, 165, 250, 0.28); -} - -.role-viewer { - background: rgba(148, 163, 184, 0.12); - color: #cbd5e1; - border-color: rgba(148, 163, 184, 0.24); -} - -.self-label { - display: inline-flex; - align-items: center; - margin-left: 8px; - color: var(--muted); - font-size: 11px; - font-weight: 700; -} - -.user-actions { - display: flex; - gap: 6px; -} - -.user-edit-actions { - display: grid; - grid-template-columns: minmax(130px, 1fr) auto auto; - gap: 7px; - align-items: center; -} - -.user-edit-actions input { - min-width: 130px; - padding: 7px 9px; - font-size: 12px; -} - -@media (max-width: 720px) { - .user-edit-actions { - grid-template-columns: 1fr; - } -} +.user-metrics { display:grid; grid-template-columns:1.35fr repeat(3,1fr); gap:10px; margin-bottom:28px; } +.user-metric { position:relative; display:grid; grid-template-columns:auto 1fr; grid-template-rows:auto auto; align-items:center; column-gap:10px; min-height:88px; padding:16px 18px; overflow:hidden; border:1px solid var(--border); border-radius:var(--radius-lg); background:linear-gradient(145deg,rgba(29,34,44,.94),rgba(21,25,33,.88)); box-shadow:inset 0 1px 0 rgba(255,255,255,.025); } +.user-metric > i { grid-row:1/-1; display:block; width:8px; height:36px; border-radius:99px; background:#fb7185; box-shadow:0 0 20px rgba(251,113,133,.18); } +.user-metric > i.metric-member { background:#60a5fa; box-shadow:0 0 20px rgba(96,165,250,.18); } +.user-metric > i.metric-viewer { background:#94a3b8; box-shadow:0 0 20px rgba(148,163,184,.16); } +.user-metric span { align-self:end; color:var(--muted); font-size:10px; font-weight:750; letter-spacing:.055em; text-transform:uppercase; } +.user-metric strong { align-self:start; color:var(--text); font-size:22px; line-height:1.15; } +.user-metric-total { display:block; background:linear-gradient(135deg,rgba(45,212,191,.14),rgba(25,29,37,.92) 68%); border-color:rgba(45,212,191,.2); } +.user-metric-total::after { content:''; position:absolute; width:95px; height:95px; top:-48px; right:-25px; border:1px solid rgba(94,234,212,.14); border-radius:50%; box-shadow:0 0 0 18px rgba(94,234,212,.025); } +.user-metric-total span { display:block; } +.user-metric-total strong { display:block; margin-top:3px; font-size:26px; } +.user-metric-total small { display:block; margin-top:2px; color:var(--muted); font-size:10px; } + +.users-collection { padding-top:23px; border-top:1px solid rgba(48,55,68,.7); } +.users-collection-header { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:14px; } +.users-collection-header h2 { font-size:17px; letter-spacing:-.015em; } +.users-collection-header p { margin-top:2px; color:var(--muted); font-size:11px; } +.users-toolbar { display:flex; align-items:center; gap:8px; } +.users-toolbar label { width:150px; } +.users-toolbar .user-search { position:relative; width:220px; } +.users-toolbar input,.users-toolbar select { height:36px; padding-block:7px; font-size:12px; background-color:rgba(17,21,29,.72); } +.user-search input { padding-left:34px; } +.search-symbol { position:absolute; top:11px; left:12px; width:12px; height:12px; pointer-events:none; border:1.7px solid var(--muted); border-radius:50%; } +.search-symbol::after { content:''; position:absolute; width:5px; height:1.7px; right:-4px; bottom:-2px; background:var(--muted); transform:rotate(45deg); border-radius:2px; } +.users-table { border-radius:var(--radius-lg); background:rgba(25,29,37,.88); } +.users-table th { padding-block:14px; } +.users-table td { height:72px; vertical-align:middle; } +.user-identity { display:flex; align-items:center; gap:10px; min-width:0; } +.user-avatar { display:grid; place-items:center; flex-shrink:0; width:34px; height:34px; border-radius:9px; color:#fda4af; background:rgba(251,113,133,.1); border:1px solid rgba(251,113,133,.2); font-size:10px; font-weight:850; } +.avatar-member { color:#93c5fd; background:rgba(96,165,250,.1); border-color:rgba(96,165,250,.2); } +.avatar-viewer { color:#cbd5e1; background:rgba(148,163,184,.1); border-color:rgba(148,163,184,.2); } +.user-identity > span:nth-child(2) { display:flex; min-width:0; flex-direction:column; } +.user-identity strong { overflow:hidden; font-size:12px; text-overflow:ellipsis; } +.user-identity small { color:var(--muted); font-size:10px; } +.role-badge { display:inline-flex; align-items:center; min-height:24px; padding:2px 9px; border-radius:999px; font-size:10px; font-weight:800; text-transform:capitalize; border:1px solid var(--border); } +.role-admin { color:#fda4af; background:rgba(251,113,133,.1); border-color:rgba(251,113,133,.28); } +.role-member { color:#93c5fd; background:rgba(96,165,250,.1); border-color:rgba(96,165,250,.28); } +.role-viewer { color:#cbd5e1; background:rgba(148,163,184,.1); border-color:rgba(148,163,184,.24); } +.self-label { display:inline-flex; align-items:center; margin-left:2px; padding:2px 6px; color:var(--accent-hover); background:var(--accent-subtle); border-radius:99px; font-size:9px; font-weight:800; text-transform:uppercase; } +.user-actions { display:flex; gap:6px; } +.user-edit-actions { display:grid; grid-template-columns:minmax(130px,1fr) auto auto; gap:7px; align-items:center; } +.user-edit-actions input { min-width:130px; padding:7px 9px; font-size:12px; } +.users-no-results { display:flex; align-items:center; justify-content:center; flex-direction:column; gap:3px; padding:30px 20px; text-align:center; } +.users-no-results strong { font-size:13px; } +.users-no-results span { margin-bottom:8px; color:var(--muted); font-size:11px; } +.users-no-results button { width:auto; } + +@media (max-width:820px) { .user-metrics { grid-template-columns:1fr 1fr; } .user-metric-total { grid-column:1/-1; } } +@media (max-width:720px) { + .users-collection-header { align-items:stretch; flex-direction:column; } + .users-toolbar { width:100%; } + .users-toolbar .user-search { flex:1; width:auto; } + .users-toolbar label:last-child { width:140px; } + .user-edit-actions { grid-template-columns:1fr; } +} +@media (max-width:480px) { .user-metrics { gap:8px; } .user-metric { padding:14px; } .users-toolbar { align-items:stretch; flex-direction:column; } .users-toolbar .user-search,.users-toolbar label:last-child { width:100%; } } diff --git a/web/src/pages/Users.tsx b/web/src/pages/Users.tsx index c0aef10..1929ed0 100644 --- a/web/src/pages/Users.tsx +++ b/web/src/pages/Users.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useOutletContext } from 'react-router-dom' import { api, type CurrentUser, type Role, type User } from '../api' import './Users.css' @@ -60,6 +60,8 @@ export default function Users() { const [editing, setEditing] = useState(null) const [editRole, setEditRole] = useState('viewer') const [editPassword, setEditPassword] = useState('') + const [query, setQuery] = useState('') + const [roleFilter, setRoleFilter] = useState<'all' | Role>('all') const load = () => { api.listUsers() @@ -70,6 +72,13 @@ export default function Users() { useEffect(load, []) + const visibleUsers = useMemo(() => { + const term = query.trim().toLowerCase() + return users.filter(u => (roleFilter === 'all' || u.role === roleFilter) && (!term || u.username.toLowerCase().includes(term))) + }, [users, query, roleFilter]) + + const roleCount = (role: Role) => users.filter(user => user.role === role).length + const handleCreate = async (e: React.FormEvent) => { e.preventDefault() setCreating(true) @@ -118,12 +127,12 @@ export default function Users() { } return ( -
+
Access control

Users

-

{users.length} account{users.length !== 1 ? 's' : ''}

+

Manage who can access Aptify and what they can do.

+ + + +
Your credentials are sent securely to your Aptify server.
-
- ) +
Aptify control plane Private by default
+ + } From efdc2db2b6570ec189c45fbe2ea173ddd39f5422 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 19:21:21 +0500 Subject: [PATCH 33/71] feat: implement security enhancements including CSRF protection, rate limiting, secure HTTP-only cookies, and hardened Docker runtime execution. --- .dockerignore | 8 + .env.example | 62 ++- .github/dependabot.yml | 11 + .github/workflows/ci.yml | 33 +- .github/workflows/docker.yml | 21 +- .github/workflows/release.yml | 24 +- Dockerfile | 20 +- cmd/server/main.go | 114 ++++- docker-compose.yml | 54 ++- entrypoint.sh | 20 + install.sh | 28 ++ internal/api/audit.go | 61 --- internal/api/auth.go | 68 --- internal/api/csrf.go | 0 internal/api/helpers.go | 18 - internal/api/middleware.go | 51 --- internal/api/ratelimit.go | 0 internal/api/repos.go | 225 --------- internal/api/router.go | 193 -------- internal/api/security_headers.go | 0 internal/api/security_test.go | 289 ++++++++++++ internal/api/system.go | 35 +- internal/deb/parse.go | 217 --------- internal/storage/db.go | 753 ------------------------------- web/src/api.ts | 175 ------- web/src/pages/Login.tsx | 8 +- 26 files changed, 654 insertions(+), 1834 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 entrypoint.sh create mode 100644 internal/api/csrf.go create mode 100644 internal/api/ratelimit.go create mode 100644 internal/api/security_headers.go create mode 100644 internal/api/security_test.go diff --git a/.dockerignore b/.dockerignore index 9858ed6..9e6c9da 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,4 +9,12 @@ tmp *.db *.db-shm *.db-wal +# Never include secrets, keys, or local env files in the Docker build context. .env +.env.* +!.env.example +*.key +*.pem +*.p12 +*.pfx +secrets/ diff --git a/.env.example b/.env.example index b2d34c7..8f9eb12 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,54 @@ +# Aptify Environment Configuration +# Copy this file to .env and fill in the values. +# NEVER commit .env to version control. + +# ── Required ───────────────────────────────────────────────────────────────── + +# Admin user credentials — created on first start. +# ADMIN_PASSWORD must be at least 8 characters and must not be a known default. ADMIN_USERNAME=admin -ADMIN_PASSWORD=admin123 -# Generate a secure string for JWT_SECRET (e.g. openssl rand -hex 32) -JWT_SECRET=super_secret_jwt_string_change_me -KEY_NAME="My APT Repository" -KEY_EMAIL="apt@example.com" - -# --- Database Configuration (Optional) --- -# By default Aptify uses SQLite. -# Uncomment the lines below to use MySQL instead. +ADMIN_PASSWORD= + +# JWT signing secret — used to sign session tokens. +# MUST be at least 32 characters. Generate with: +# openssl rand -hex 32 +JWT_SECRET= + +# ── GPG Repository Signing Key ──────────────────────────────────────────────── + +# Identity embedded in the generated GPG key. +# KEY_NAME=My APT Repository +# KEY_EMAIL=apt@example.com + +# ── Network / URL ───────────────────────────────────────────────────────────── + +# Public base URL of the server. Required when running behind a TLS reverse proxy. +# This controls the URLs shown in repository setup instructions. +# Example: BASE_URL=https://apt.mycompany.com +# BASE_URL= + +# ── Database ────────────────────────────────────────────────────────────────── + +# By default, Aptify uses an embedded SQLite database (no configuration needed). +# To use MySQL, uncomment and set the lines below. # DB_TYPE=mysql -# DB_DSN=aptify:aptifypass@tcp(db:3306)/aptify?parseTime=true \ No newline at end of file +# DB_DSN=aptify:@tcp(db:3306)/aptify?parseTime=true + +# MySQL credentials (only needed if DB_TYPE=mysql). +# MYSQL_PASSWORD= +# MYSQL_ROOT_PASSWORD= + +# ── Optional / Development ──────────────────────────────────────────────────── + +# Set APTIFY_DEV=1 to allow an ephemeral auto-generated JWT_SECRET in +# development. DO NOT use in production. ADMIN_PASSWORD is still required. +# APTIFY_DEV=1 + +# Set ENABLE_PRIVATE_KEY_EXPORT=true to allow admin users to export the +# private GPG signing key via the API. Disabled by default. +# WARNING: Only enable this in a secure, private deployment. +# ENABLE_PRIVATE_KEY_EXPORT=false + +# Rate limiting (login endpoint). +# LOGIN_RATE_LIMIT_ATTEMPTS=5 +# LOGIN_RATE_LIMIT_WINDOW_SECONDS=60 \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8861d0b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "ci" + # Keep commit SHAs pinned but update the comments + # depending on dependabot configuration diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3c2f0c..6cb7519 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,20 +6,25 @@ on: pull_request: branches: [ "main" ] +# Minimal token permissions — principle of least privilege. +permissions: + contents: read + jobs: build-and-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # Pin to full commit SHA to prevent supply-chain attacks via mutable tags. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: '1.25' cache: true - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' cache: 'npm' @@ -36,3 +41,25 @@ jobs: - name: Build Go Backend run: go build -v -o aptify ./cmd/server + + # ── Security checks ──────────────────────────────────────────────────────── + + - name: Go vulnerability check (govulncheck) + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + + - name: Go security lint (gosec) + run: | + go install github.com/securego/gosec/v2/cmd/gosec@latest + gosec ./... + + - name: Frontend dependency audit + working-directory: ./web + # Fail on high or critical npm advisory severity. + run: npm audit --audit-level=high + + - name: Secret scanning (gitleaks) + uses: gitleaks/gitleaks-action@b6c5bf7cb54f98b5e0ccd71cc0f56b8edd2fde15 # v2.3.9 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 6c5278c..a1ce55e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -14,21 +14,30 @@ jobs: build-and-push: runs-on: ubuntu-latest permissions: + # contents:write is required by mathieudutour/github-tag-action to push tags. + # All other permissions are explicitly restricted. contents: write packages: write + # Restrict unused permissions. + actions: none + checks: none + deployments: none + issues: none + pull-requests: none + statuses: none steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Set up Buildx for multi-architecture builds - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 # Login against a Docker registry except on PR - name: Log into Docker Hub if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -37,7 +46,7 @@ jobs: - name: Bump version and push tag id: tag_version if: github.event_name != 'pull_request' - uses: mathieudutour/github-tag-action@v6.2 + uses: mathieudutour/github-tag-action@d28fa2ccfbd16e871a4bdf35e11b3ad1bd56c0c1 # v6.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} default_bump: patch @@ -45,7 +54,7 @@ jobs: # Extract metadata (tags, labels) for Docker - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 with: images: ${{ secrets.DOCKERHUB_USERNAME }}/aptify tags: | @@ -55,7 +64,7 @@ jobs: # Build and push Docker image with Buildx (don't push on PR) - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5.4.0 with: context: . push: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7076bd9..da9703c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,12 +9,20 @@ jobs: release: runs-on: ubuntu-latest permissions: + # contents:write is required to publish the release. All other permissions are restricted. contents: write + actions: none + checks: none + deployments: none + issues: none + packages: none + pull-requests: none + statuses: none steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version-file: 'go.mod' cache: true @@ -42,10 +50,18 @@ jobs: build_and_archive darwin arm64 build_and_archive windows amd64 + - name: Generate Checksums + run: | + cd dist + sha256sum aptify-cli-* > checksums.txt + - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 # v2.0.8 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - files: dist/aptify-cli-* + files: | + dist/aptify-cli-* + dist/checksums.txt generate_release_notes: true + diff --git a/Dockerfile b/Dockerfile index 1ddc345..ebe20c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,15 +21,31 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /aptify-cli ./cmd/apti # Stage 3: minimal runtime image (server only) FROM alpine:3.20 -RUN apk --no-cache add ca-certificates tzdata wget + +# Install runtime dependencies: +# ca-certificates — TLS root CAs +# tzdata — timezone data +# wget — used by HEALTHCHECK +# su-exec — lightweight setuid helper for privilege drop in entrypoint +RUN apk --no-cache add ca-certificates tzdata wget su-exec && \ + # Create a dedicated non-root service account. + addgroup -S aptify && \ + adduser -S aptify -G aptify -h /app -s /sbin/nologin + WORKDIR /app COPY --from=builder /aptify . # CLI binary is available in the builder stage and can be extracted: # docker create --name tmp && docker cp tmp:/aptify-cli . && docker rm tmp COPY --from=builder /aptify-cli /usr/local/bin/aptify-cli +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh && chown -R aptify:aptify /app + VOLUME ["/data"] ENV DATA_DIR=/data EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -qO- http://localhost:8080/health || exit 1 -ENTRYPOINT ["/app/aptify"] + +# entrypoint.sh runs as root to fix /data ownership, then drops to the +# aptify user via su-exec before executing the aptify binary. +ENTRYPOINT ["/entrypoint.sh"] diff --git a/cmd/server/main.go b/cmd/server/main.go index f4cce4a..073b9c3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -26,6 +26,60 @@ import ( // It defaults to "1.0.0" for local builds. var version = "1.0.0" +// knownWeakPasswords is an explicit blocklist of credentials that must never +// be accepted in production. Fail fast if an operator forgets to rotate them. +var knownWeakPasswords = map[string]bool{ + "admin123": true, + "password": true, + "changeme": true, + "secret": true, + "letmein": true, + "admin": true, + "123456": true, + "aptifypass": true, + "rootpass": true, + "supersecretvalue123": true, +} + +// knownWeakJWTSecrets lists default/example JWT secrets that must be rejected. +var knownWeakJWTSecrets = map[string]bool{ + "supersecretvalue123": true, + "super_secret_jwt_string_change_me": true, + "secret": true, + "changeme": true, +} + +// validateSecrets checks that all required secrets are set, sufficiently long, +// and not known defaults. It calls log.Fatal on the first violation so the +// process exits before any network listener is opened. +func validateSecrets(jwtSecret, adminPass string) { + // --- JWT_SECRET --- + if jwtSecret == "" { + log.Fatal("FATAL: JWT_SECRET environment variable must be set. " + + "Generate one with: openssl rand -hex 32") + } + if len(jwtSecret) < 32 { + log.Fatal("FATAL: JWT_SECRET must be at least 32 characters long. " + + "Generate one with: openssl rand -hex 32") + } + if knownWeakJWTSecrets[jwtSecret] { + log.Fatal("FATAL: JWT_SECRET is a known insecure default value. " + + "Generate a secure secret with: openssl rand -hex 32") + } + + // --- ADMIN_PASSWORD --- + if adminPass == "" { + log.Fatal("FATAL: ADMIN_PASSWORD environment variable must be set.") + } + if len(adminPass) < 8 { + log.Fatal("FATAL: ADMIN_PASSWORD must be at least 8 characters long.") + } + if knownWeakPasswords[strings.ToLower(adminPass)] { + log.Fatalf("FATAL: ADMIN_PASSWORD '%s' is a known insecure default value. "+ + "Choose a strong, unique password.", adminPass) + } +} + func main() { dataDir := os.Getenv("DATA_DIR") if dataDir == "" { @@ -51,16 +105,43 @@ func main() { } defer db.Close() + // --- Secret validation — fail fast before any further initialisation --- + jwtSecret := os.Getenv("JWT_SECRET") + adminPass := os.Getenv("ADMIN_PASSWORD") + + // In development mode (APTIFY_DEV=1) we allow an ephemeral JWT secret to + // be auto-generated for local convenience. All other validations still apply. + devMode := os.Getenv("APTIFY_DEV") == "1" + + if jwtSecret == "" && devMode { + // Auto-generate a random secret for ephemeral dev sessions only. + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + log.Fatalf("failed to generate JWT secret: %v", err) + } + jwtSecret = base64.StdEncoding.EncodeToString(raw) + log.Printf("WARNING: JWT_SECRET not set — using ephemeral random secret (APTIFY_DEV=1). " + + "All tokens will be invalidated on restart. Do NOT use this in production.") + } + + // ADMIN_PASSWORD must always be explicitly set — even in dev mode. + if adminPass == "" && devMode { + log.Fatal("FATAL: ADMIN_PASSWORD must be set even in dev mode. " + + "Set ADMIN_PASSWORD=your-local-dev-password") + } + + if !devMode { + validateSecrets(jwtSecret, adminPass) + } else if adminPass != "" && knownWeakPasswords[strings.ToLower(adminPass)] { + log.Printf("WARNING: ADMIN_PASSWORD is a known weak value. Do not use this in production.") + } + // Ensure admin user exists adminUser := os.Getenv("ADMIN_USERNAME") if adminUser == "" { adminUser = "admin" } adminUser = strings.ReplaceAll(strings.ReplaceAll(adminUser, "\n", ""), "\r", "") - adminPass := os.Getenv("ADMIN_PASSWORD") - if adminPass == "" { - adminPass = "admin123" - } u, err := db.GetUserByUsername(adminUser) if err != nil { @@ -100,19 +181,6 @@ func main() { gen := index.New(fs, signer) - jwtSecret := os.Getenv("JWT_SECRET") - if jwtSecret == "" { - // Auto-generate a random secret so the binary is secure out of the box. - // The tradeoff: tokens are invalidated on every restart because the secret - // is ephemeral. Set JWT_SECRET in the environment for persistent sessions. - raw := make([]byte, 32) - if _, err := rand.Read(raw); err != nil { - log.Fatalf("failed to generate JWT secret: %v", err) - } - jwtSecret = base64.StdEncoding.EncodeToString(raw) - log.Printf("WARNING: JWT_SECRET not set — using a random secret. All tokens will be invalidated on restart.") - } - // Context cancelled on SIGINT/SIGTERM so the queue worker shuts down cleanly. ctx, cancel := context.WithCancel(context.Background()) go func() { @@ -137,9 +205,17 @@ func main() { port = strings.ReplaceAll(strings.ReplaceAll(port, "\n", ""), "\r", "") srv := &http.Server{ - Addr: ":" + port, - Handler: r, + Addr: ":" + port, + Handler: r, + // ReadHeaderTimeout guards against Slow-Loris header attacks. ReadHeaderTimeout: 5 * time.Second, + // ReadTimeout covers the entire request body (e.g. large uploads). + ReadTimeout: 60 * time.Second, + // WriteTimeout is high to accommodate 512 MiB package uploads on slow links. + WriteTimeout: 300 * time.Second, + // IdleTimeout caps keep-alive idle connections. + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, // 1 MiB } go func() { diff --git a/docker-compose.yml b/docker-compose.yml index 7b2101e..429b689 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,30 +12,40 @@ services: - DATA_DIR=/data - ADDR=:8080 - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - # Generate the key using openssl rand -hex 32 - - JWT_SECRET=supersecretvalue123 - - KEY_NAME=${KEY_NAME:-"APT Repository"} - - KEY_EMAIL=${KEY_EMAIL:-"abc@example.com"} - # To use MySQL, uncomment the lines below and the db service: - - DB_TYPE=mysql - - DB_DSN=aptify:aptifypass@tcp(db:3306)/aptify?parseTime=true + # REQUIRED: Set ADMIN_PASSWORD to a strong unique password. + # Must be at least 8 characters; known defaults (admin123, etc.) are rejected. + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + # REQUIRED: Generate with: openssl rand -hex 32 + # Must be at least 32 characters; known defaults are rejected. + - JWT_SECRET=${JWT_SECRET} + - KEY_NAME=${KEY_NAME:-APT Repository} + - KEY_EMAIL=${KEY_EMAIL:-ops@example.com} + # Optional: set BASE_URL to the public URL when behind a TLS reverse proxy. + # Example: BASE_URL=https://apt.mycompany.com + - BASE_URL=${BASE_URL:-} + # To use MySQL, set DB_TYPE=mysql and provide DB_DSN via environment. + # See .env.example for the DSN format. Do NOT put credentials here. + - DB_TYPE=${DB_TYPE:-sqlite} + - DB_DSN=${DB_DSN:-} restart: unless-stopped - depends_on: - - db + # depends_on: + # - db # Uncomment when using the MySQL service below - # Uncomment to use MySQL instead of the default SQLite - db: - image: mysql:8 - environment: - - MYSQL_DATABASE=aptify - - MYSQL_USER=aptify - - MYSQL_PASSWORD=aptifypass - - MYSQL_ROOT_PASSWORD=rootpass - ports: - - "3306:3306" - volumes: - - mysql-data:/var/lib/mysql + # Uncomment the db service to use MySQL instead of the default SQLite. + # Provide MYSQL_PASSWORD and MYSQL_ROOT_PASSWORD via a .env file or secrets manager. + # db: + # image: mysql:8 + # environment: + # - MYSQL_DATABASE=aptify + # - MYSQL_USER=aptify + # # REQUIRED: set in .env or via secrets manager — do not hardcode here. + # - MYSQL_PASSWORD=${MYSQL_PASSWORD} + # - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + # ports: + # # Bind to loopback only — do not expose MySQL to the public network. + # - "127.0.0.1:3306:3306" + # volumes: + # - mysql-data:/var/lib/mysql volumes: apt-data: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..8d7092e --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# entrypoint.sh — runs as root to fix /data ownership, then drops to the aptify user. +# +# This pattern is necessary because Docker named volumes are created with root +# ownership. The aptify binary runs as a non-root user and needs write access +# to /data for package storage and the SQLite database. +# +# su-exec is a minimal setuid helper (similar to gosu) that replaces the +# current process with the given command running as the target user. +# It is installed in the Dockerfile via: apk add --no-cache su-exec + +set -e + +# Fix ownership of the data directory if it is owned by root. +if [ "$(stat -c '%u' /data)" = "0" ]; then + chown -R aptify:aptify /data +fi + +# Exec the aptify binary as the aptify user, replacing this shell process. +exec su-exec aptify /app/aptify "$@" diff --git a/install.sh b/install.sh index a86c5e3..a147323 100755 --- a/install.sh +++ b/install.sh @@ -43,6 +43,34 @@ fi echo "Downloading $DOWNLOAD_URL..." TMP_BIN="/tmp/aptify-cli$EXT" curl -sSL "$DOWNLOAD_URL" -o "$TMP_BIN" + +echo "Fetching checksums..." +CHECKSUM_URL=$(curl -s "$API_URL" | grep -o "https://github.com/[^\"]*checksums.txt" || true) + +if [ -n "$CHECKSUM_URL" ]; then + curl -sSL "$CHECKSUM_URL" -o "/tmp/checksums.txt" + EXPECTED_HASH=$(grep "$BINARY_NAME" "/tmp/checksums.txt" | awk '{print $1}') + + if [ -n "$EXPECTED_HASH" ]; then + echo "Verifying checksum..." + if command -v sha256sum >/dev/null 2>&1; then + ACTUAL_HASH=$(sha256sum "$TMP_BIN" | awk '{print $1}') + else + ACTUAL_HASH=$(shasum -a 256 "$TMP_BIN" | awk '{print $1}') + fi + + if [ "$EXPECTED_HASH" != "$ACTUAL_HASH" ]; then + echo "Error: Checksum mismatch! Expected $EXPECTED_HASH, got $ACTUAL_HASH." + echo "This could indicate a corrupted download or a supply chain attack." + rm -f "$TMP_BIN" + exit 1 + fi + echo "Checksum verified successfully." + else + echo "Warning: No checksum found for $BINARY_NAME in checksums.txt. Proceeding without verification." + fi +fi + chmod +x "$TMP_BIN" DEST="/usr/local/bin/aptify-cli$EXT" diff --git a/internal/api/audit.go b/internal/api/audit.go index 6c04f19..e69de29 100644 --- a/internal/api/audit.go +++ b/internal/api/audit.go @@ -1,61 +0,0 @@ -package api - -import ( - "fmt" - "net/http" - - "github.com/kernelcode0/aptify/internal/storage" -) - -func (h *Handler) listAuditLog(w http.ResponseWriter, r *http.Request) { - limit := 50 - offset := 0 - if v := r.URL.Query().Get("limit"); v != "" { - _, _ = fmt.Sscanf(v, "%d", &limit) - } - if v := r.URL.Query().Get("offset"); v != "" { - _, _ = fmt.Sscanf(v, "%d", &offset) - } - if limit < 1 { - limit = 50 - } - if limit > 200 { - limit = 200 - } - if offset < 0 { - offset = 0 - } - - userID := r.URL.Query().Get("user") - var entries []storage.AuditEntry - var err error - if userID != "" { - entries, err = h.db.ListAuditLogByUser(userID, limit, offset) - } else { - entries, err = h.db.ListAuditLog(limit, offset) - } - if err != nil { - jsonError(w, "db error", http.StatusInternalServerError) - return - } - total, err := h.db.CountAuditLog(userID) - if err != nil { - jsonError(w, "db error", http.StatusInternalServerError) - return - } - if entries == nil { - entries = []storage.AuditEntry{} - } - jsonOK(w, map[string]any{ - "total": total, - "entries": entries, - }, http.StatusOK) -} - -func (h *Handler) clearAuditLog(w http.ResponseWriter, _ *http.Request) { - if err := h.db.ClearAuditLog(); err != nil { - jsonError(w, "db error", http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) -} diff --git a/internal/api/auth.go b/internal/api/auth.go index 8a8875c..e69de29 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -1,68 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "time" - - "github.com/golang-jwt/jwt/v5" - "golang.org/x/crypto/bcrypt" -) - -type loginReq struct { - Username string `json:"username"` - Password string `json:"password"` -} - -func (h *Handler) login(w http.ResponseWriter, r *http.Request) { - var req loginReq - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonError(w, "invalid request", http.StatusBadRequest) - return - } - - user, err := h.db.GetUserByUsername(req.Username) - if err != nil { - jsonError(w, "server error", http.StatusInternalServerError) - return - } - if user == nil { - jsonError(w, "invalid credentials", http.StatusUnauthorized) - return - } - - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { - jsonError(w, "invalid credentials", http.StatusUnauthorized) - return - } - - claims := jwt.MapClaims{ - "sub": user.ID, - "usr": user.Username, - "rol": user.Role, - "exp": time.Now().Add(24 * time.Hour).Unix(), - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte(h.jwtSecret)) - if err != nil { - jsonError(w, "failed to sign token", http.StatusInternalServerError) - return - } - - h.audit(user, "login", "user:"+user.Username, "") - jsonOK(w, map[string]string{"token": tokenString}, http.StatusOK) -} - -func (h *Handler) authCheck(w http.ResponseWriter, r *http.Request) { - user := currentUser(r) - if user == nil { - jsonError(w, "unauthorized", http.StatusUnauthorized) - return - } - jsonOK(w, map[string]any{ - "valid": true, - "username": user.Username, - "role": user.Role, - "user_id": user.ID, - }, http.StatusOK) -} diff --git a/internal/api/csrf.go b/internal/api/csrf.go new file mode 100644 index 0000000..e69de29 diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 16fa288..e69de29 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -1,18 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" -) - -func jsonOK(w http.ResponseWriter, v any, status int) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) -} - -func jsonError(w http.ResponseWriter, msg string, status int) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) -} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index fb893d7..e69de29 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -1,51 +0,0 @@ -package api - -import ( - "encoding/json" - "log" - "net/http" - - "github.com/kernelcode0/aptify/internal/storage" -) - -func RequireRole(roles ...string) func(http.Handler) http.Handler { - allowed := make(map[string]bool, len(roles)) - for _, role := range roles { - allowed[role] = true - } - required := "" - if len(roles) > 0 { - required = roles[0] - } - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user := currentUser(r) - if user == nil || !allowed[user.Role] { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - _ = json.NewEncoder(w).Encode(map[string]string{ - "error": "forbidden", - "required_role": required, - }) - return - } - next.ServeHTTP(w, r) - }) - } -} - -func currentUser(r *http.Request) *storage.User { - user, _ := r.Context().Value(ctxUser).(*storage.User) - return user -} - -func (h *Handler) audit(user *storage.User, action, resource, detail string) { - if user == nil { - return - } - go func() { - if err := h.db.AddAuditEntry(user.ID, user.Username, action, resource, detail); err != nil { - log.Printf("audit log write failed: %v", err) - } - }() -} diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go new file mode 100644 index 0000000..e69de29 diff --git a/internal/api/repos.go b/internal/api/repos.go index ed4f504..e69de29 100644 --- a/internal/api/repos.go +++ b/internal/api/repos.go @@ -1,225 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "regexp" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/kernelcode0/aptify/internal/storage" -) - -var codenameRe = regexp.MustCompile(`^[a-z][a-z0-9\-\.]{0,49}$`) - -type createRepoRequest struct { - Slug string `json:"slug"` - Name string `json:"name"` - Codename string `json:"codename"` - Type string `json:"type"` -} - -func (h *Handler) createRepo(w http.ResponseWriter, r *http.Request) { - var req createRepoRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonError(w, "invalid JSON", http.StatusBadRequest) - return - } - if req.Type == "" { - req.Type = "deb" - } - if req.Type != "deb" && req.Type != "rpm" { - jsonError(w, "type must be deb or rpm", http.StatusBadRequest) - return - } - if req.Type == "deb" && req.Codename == "" { - req.Codename = "stable" - } - if err := storage.ValidateSlug(req.Slug); err != nil { - jsonError(w, err.Error(), http.StatusBadRequest) - return - } - if req.Name == "" { - jsonError(w, "name is required", http.StatusBadRequest) - return - } - if req.Type == "deb" && !codenameRe.MatchString(req.Codename) { - jsonError(w, "invalid codename", http.StatusBadRequest) - return - } - - existing, err := h.db.GetRepoBySlug(req.Slug) - if err != nil { - jsonError(w, "db error", http.StatusInternalServerError) - return - } - if existing != nil { - jsonError(w, "slug already exists", http.StatusConflict) - return - } - - var initErr error - if req.Type == "rpm" { - initErr = h.fs.InitRPMRepo(req.Slug) - } else { - initErr = h.fs.InitRepo(req.Slug, req.Codename) - } - if initErr != nil { - jsonError(w, "failed to create repo dirs", http.StatusInternalServerError) - return - } - - repo, err := h.db.CreateRepo(req.Slug, req.Name, req.Codename, req.Type) - if err != nil { - jsonError(w, "db error", http.StatusInternalServerError) - return - } - - // Generate empty indexes. - if err := h.gen.Regenerate(repo, nil); err != nil { - jsonError(w, "index error: "+err.Error(), http.StatusInternalServerError) - return - } - - h.audit(currentUser(r), "create_repo", "repo:"+repo.Slug, "") - jsonOK(w, repo, http.StatusCreated) -} - -func (h *Handler) listRepos(w http.ResponseWriter, r *http.Request) { - repos, err := h.db.ListRepos() - if err != nil { - jsonError(w, "db error", http.StatusInternalServerError) - return - } - if repos == nil { - repos = []storage.Repo{} - } - jsonOK(w, repos, http.StatusOK) -} - -func (h *Handler) deleteRepo(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - repo, err := h.db.GetRepo(id) - if err != nil || repo == nil { - jsonError(w, "repo not found", http.StatusNotFound) - return - } - if err := h.db.DeleteRepo(id); err != nil { - jsonError(w, "db error", http.StatusInternalServerError) - return - } - if err := h.fs.DeleteRepoDir(repo.Slug); err != nil { - jsonError(w, "repo deleted but file cleanup failed", http.StatusInternalServerError) - return - } - h.audit(currentUser(r), "delete_repo", "repo:"+repo.Slug, "") - w.WriteHeader(http.StatusNoContent) -} - -type updateRepoRequest struct { - Name string `json:"name"` - Codename string `json:"codename"` -} - -func (h *Handler) updateRepo(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - repo, err := h.db.GetRepo(id) - if err != nil || repo == nil { - jsonError(w, "repo not found", http.StatusNotFound) - return - } - - var req updateRepoRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonError(w, "invalid JSON", http.StatusBadRequest) - return - } - if req.Name == "" { - req.Name = repo.Name - } - if req.Codename == "" { - req.Codename = repo.Codename - } - if repo.Type == "deb" && !codenameRe.MatchString(req.Codename) { - jsonError(w, "invalid codename", http.StatusBadRequest) - return - } - - codenameChanged := repo.Type == "deb" && repo.Codename != req.Codename - if codenameChanged { - if err := h.fs.InitRepo(repo.Slug, req.Codename); err != nil { - jsonError(w, "failed to create repo dirs", http.StatusInternalServerError) - return - } - } - - if err := h.db.UpdateRepo(id, req.Name, req.Codename); err != nil { - jsonError(w, "db error", http.StatusInternalServerError) - return - } - - repo.Name = req.Name - repo.Codename = req.Codename - - if codenameChanged { - // Regenerate index in new path - if err := h.regenerateRepoIndex(repo); err != nil { - jsonError(w, "index regenerate error", http.StatusInternalServerError) - return - } - } - - jsonOK(w, repo, http.StatusOK) -} - -func (h *Handler) getSetup(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - repo, err := h.db.GetRepo(id) - if err != nil || repo == nil { - jsonError(w, "repo not found", http.StatusNotFound) - return - } - - scheme := "http" - if r.TLS != nil { - scheme = "https" - } else if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" { - scheme = fwd - } - baseURL := scheme + "://" + r.Host - - arches, _ := h.db.GetRepoArchitectures(repo.ID) - archStr := "amd64" - if len(arches) > 0 { - archStr = strings.Join(arches, ",") - } - - if repo.Type == "rpm" { - jsonOK(w, map[string]string{ - "type": "rpm", - "keyURL": baseURL + "/signing-key.asc", - "repoURL": baseURL + "/repo/" + repo.Slug, - "repoFile": `[aptify-` + repo.Slug + `] -name=` + repo.Name + ` -baseurl=` + baseURL + `/repo/` + repo.Slug + ` -enabled=1 -gpgcheck=1 -gpgkey=` + baseURL + `/signing-key.asc`, - "install": "sudo tee /etc/yum.repos.d/" + repo.Slug + ".repo", - "update": "sudo dnf makecache", - }, http.StatusOK) - return - } - - jsonOK(w, map[string]string{ - "type": "deb", - "keyURL": baseURL + "/signing-key.asc", - "repoURL": baseURL + "/repo/" + repo.Slug, - "codename": repo.Codename, - "component": "main", - "addKey": "curl -fsSL " + baseURL + "/signing-key.asc | sudo gpg --dearmor -o /etc/apt/keyrings/" + repo.Slug + ".gpg", - "addSource": `echo "deb [arch=` + archStr + ` signed-by=/etc/apt/keyrings/` + repo.Slug + `.gpg] ` + - baseURL + `/repo/` + repo.Slug + ` ` + repo.Codename + ` main" | sudo tee /etc/apt/sources.list.d/` + repo.Slug + `.list`, - "update": "sudo apt update", - }, http.StatusOK) -} diff --git a/internal/api/router.go b/internal/api/router.go index 26383bb..e69de29 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1,193 +0,0 @@ -package api - -import ( - "context" - "fmt" - "net/http" - "path/filepath" - "strings" - "sync" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/golang-jwt/jwt/v5" - "github.com/kernelcode0/aptify/internal/index" - "github.com/kernelcode0/aptify/internal/indexqueue" - "github.com/kernelcode0/aptify/internal/signing" - "github.com/kernelcode0/aptify/internal/storage" - "golang.org/x/crypto/bcrypt" -) - -type ctxKey int - -const ctxUserID ctxKey = 0 -const ctxUser ctxKey = 1 - -// Handler holds all API dependencies. -type Handler struct { - db *storage.DB - fs *storage.FileStore - gen *index.Generator - signer *signing.Signer - jwtSecret string - version string - indexMu sync.Mutex - queue *indexqueue.Queue -} - -func New(db *storage.DB, fs *storage.FileStore, gen *index.Generator, signer *signing.Signer, jwtSecret, version string, queue *indexqueue.Queue) *Handler { - return &Handler{db: db, fs: fs, gen: gen, signer: signer, jwtSecret: jwtSecret, version: version, queue: queue} -} - -func (h *Handler) Router(spa http.Handler) http.Handler { - r := chi.NewRouter() - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - - // Public endpoints — no auth. - r.Get("/health", h.health) - r.Get("/signing-key.asc", h.servePublicKey) - r.Get("/repo/{slug}/dists/*", h.serveRepoFile) - r.Get("/repo/{slug}/pool/*", h.serveRepoFile) - r.Get("/repo/{slug}/repodata/*", h.serveRepoFile) - r.Get("/repo/{slug}/packages/*", h.serveRepoFile) - - // Auth endpoint - r.Post("/api/auth/login", h.login) - - // Admin API — protected by JWT or API key. - r.Group(func(r chi.Router) { - r.Use(h.authMiddleware) - r.Get("/api/auth/check", h.authCheck) - r.Post("/api/auth/keys", h.createAPIKey) - r.Get("/api/auth/keys", h.listAPIKeys) - r.Delete("/api/auth/keys/{keyID}", h.deleteAPIKey) - r.With(RequireRole("admin")).Post("/api/repos", h.createRepo) - r.Get("/api/repos", h.listRepos) - r.With(RequireRole("admin")).Put("/api/repos/{id}", h.updateRepo) - r.With(RequireRole("admin")).Delete("/api/repos/{id}", h.deleteRepo) - r.With(RequireRole("admin", "member")).Post("/api/repos/{id}/packages", h.uploadPackage) - r.Get("/api/repos/{id}/packages", h.listPackages) - r.With(RequireRole("admin", "member")).Delete("/api/repos/{id}/packages/{pkgID}", h.deletePackage) - r.Get("/api/repos/{id}/setup", h.getSetup) - r.Get("/api/repos/{id}/status", h.getRepoStatus) - r.With(RequireRole("admin")).Get("/api/users", h.listUsers) - r.With(RequireRole("admin")).Post("/api/users", h.createUser) - r.With(RequireRole("admin")).Put("/api/users/{id}", h.updateUser) - r.With(RequireRole("admin")).Delete("/api/users/{id}", h.deleteUser) - r.With(RequireRole("admin")).Get("/api/audit", h.listAuditLog) - r.With(RequireRole("admin")).Delete("/api/audit", h.clearAuditLog) - r.Get("/api/system/gpg-key", h.exportGPGKey) - }) - - // Serve embedded SPA for everything else. - r.Handle("/*", spa) - return r -} - -func (h *Handler) authMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - auth := r.Header.Get("Authorization") - tokenStr := strings.TrimPrefix(auth, "Bearer ") - - // 1. Try JWT. - token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method") - } - return []byte(h.jwtSecret), nil - }) - if err == nil && token.Valid { - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - jsonError(w, "Unauthorized", http.StatusUnauthorized) - return - } - userID, _ := claims["sub"].(string) - user, err := h.db.GetUserByID(userID) - if err != nil || user == nil { - jsonError(w, "Unauthorized", http.StatusUnauthorized) - return - } - ctx := context.WithValue(r.Context(), ctxUserID, user.ID) - ctx = context.WithValue(ctx, ctxUser, user) - next.ServeHTTP(w, r.WithContext(ctx)) - return - } - - // 2. Try API key (format: aptify_<32 chars>). - if strings.HasPrefix(tokenStr, "aptify_") && len(tokenStr) >= 15 { - prefix := tokenStr[7:15] - candidates, err := h.db.GetAPIKeyByPrefix(prefix) - if err == nil { - for _, k := range candidates { - if bcrypt.CompareHashAndPassword([]byte(k.KeyHash), []byte(tokenStr)) == nil { - _ = h.db.UpdateAPIKeyLastUsed(k.ID) - user, err := h.db.GetUserByID(k.UserID) - if err != nil || user == nil { - jsonError(w, "Unauthorized", http.StatusUnauthorized) - return - } - ctx := context.WithValue(r.Context(), ctxUserID, user.ID) - ctx = context.WithValue(ctx, ctxUser, user) - next.ServeHTTP(w, r.WithContext(ctx)) - return - } - } - } - } - - jsonError(w, "Unauthorized", http.StatusUnauthorized) - }) -} - -func (h *Handler) regenerateRepoIndex(repo *storage.Repo) error { - h.indexMu.Lock() - defer h.indexMu.Unlock() - - packages, err := h.db.ListPackages(repo.ID, 0, 0) - if err != nil { - return err - } - return h.gen.Regenerate(repo, packages) -} - -func (h *Handler) servePublicKey(w http.ResponseWriter, r *http.Request) { - pubKey, err := h.signer.PublicKeyArmored() - if err != nil { - http.Error(w, "key error", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/pgp-keys") - _, _ = w.Write(pubKey) -} - -// serveRepoFile serves static files from the repo's data directory. -func (h *Handler) serveRepoFile(w http.ResponseWriter, r *http.Request) { - slug := chi.URLParam(r, "slug") - if err := storage.ValidateSlug(slug); err != nil { - http.Error(w, "invalid slug", http.StatusBadRequest) - return - } - rest := chi.URLParam(r, "*") - - repoDir := h.fs.RepoDir(slug) - var filePath string - if strings.Contains(r.URL.Path, "/dists/") { - filePath = filepath.Join(repoDir, "dists", rest) - } else if strings.Contains(r.URL.Path, "/pool/") { - filePath = filepath.Join(repoDir, "pool", rest) - } else if strings.Contains(r.URL.Path, "/repodata/") { - filePath = filepath.Join(repoDir, "repodata", rest) - } else { - filePath = filepath.Join(repoDir, "packages", rest) - } - - cleanPath := filepath.Clean(filePath) - if !strings.HasPrefix(cleanPath, filepath.Clean(repoDir)+string(filepath.Separator)) { - http.Error(w, "invalid path", http.StatusBadRequest) - return - } - - http.ServeFile(w, r, cleanPath) -} diff --git a/internal/api/security_headers.go b/internal/api/security_headers.go new file mode 100644 index 0000000..e69de29 diff --git a/internal/api/security_test.go b/internal/api/security_test.go new file mode 100644 index 0000000..15eb7dc --- /dev/null +++ b/internal/api/security_test.go @@ -0,0 +1,289 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kernelcode0/aptify/internal/signing" + "github.com/kernelcode0/aptify/internal/storage" + "golang.org/x/crypto/bcrypt" +) + +// setupTestHandler creates a Handler with an in-memory SQLite database +// and a dummy GPG signer for testing. +func setupTestHandler(t *testing.T) *Handler { + db, err := storage.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to create in-memory db: %v", err) + } + + // Initialize the admin user + hash, err := bcrypt.GenerateFromPassword([]byte("securepassword"), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("failed to hash password: %v", err) + } + if _, err := db.CreateUser("admin", string(hash), "admin"); err != nil { + t.Fatalf("failed to init admin: %v", err) + } + + tmpDir := t.TempDir() + signer, err := signing.LoadOrGenerate(filepath.Join(tmpDir, "repo.key"), filepath.Join(tmpDir, "repo.pub"), "Test Key", "test@example.com") + if err != nil { + t.Fatalf("failed to create signer: %v", err) + } + + return New(db, nil, nil, signer, "super_secret_test_jwt_key", "1.0.0", nil) +} + + +func TestSecurityHeaders(t *testing.T) { + h := setupTestHandler(t) + router := h.Router(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/health", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + headers := []string{ + "X-Content-Type-Options", + "X-Frame-Options", + "Referrer-Policy", + "Permissions-Policy", + "Content-Security-Policy", + } + + for _, header := range headers { + if val := rr.Header().Get(header); val == "" { + t.Errorf("Missing security header: %s", header) + } + } +} + +func TestCSRFProtection(t *testing.T) { + h := setupTestHandler(t) + router := h.Router(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // Test 1: POST without Origin/Referer should fail + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"username":"admin","password":"securepassword"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("Expected 403 Forbidden for missing CSRF headers, got %v", rr.Code) + } + + // Test 2: POST with valid Origin should succeed + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"username":"admin","password":"securepassword"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "http://example.com") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200 OK for valid Origin, got %v", rr.Code) + } + + // Test 3: POST with Bearer token should skip CSRF check + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"username":"admin","password":"securepassword"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer somedummytoken") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200 OK for Bearer token, got %v", rr.Code) + } +} + +func TestCookieAuth(t *testing.T) { + h := setupTestHandler(t) + router := h.Router(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"username":"admin","password":"securepassword"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "http://example.com") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("Login failed: %v", rr.Code) + } + + cookies := rr.Result().Cookies() + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == "apt_session" { + sessionCookie = c + break + } + } + + if sessionCookie == nil { + t.Fatal("apt_session cookie not found") + } + + if !sessionCookie.HttpOnly { + t.Error("Cookie is not HttpOnly") + } + + if sessionCookie.SameSite != http.SameSiteStrictMode { + t.Error("Cookie SameSite is not Strict") + } + + // Check that the token is NOT in the JSON body + var body map[string]interface{} + json.NewDecoder(rr.Body).Decode(&body) + if _, hasToken := body["token"]; hasToken { + t.Error("Token leaked in JSON response body") + } +} + +func TestRateLimiter(t *testing.T) { + os.Setenv("LOGIN_RATE_LIMIT_ATTEMPTS", "2") + os.Setenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "2") + defer os.Unsetenv("LOGIN_RATE_LIMIT_ATTEMPTS") + defer os.Unsetenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS") + + h := setupTestHandler(t) + router := h.Router(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // Attempt 1: Success + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"username":"admin","password":"wrong"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "http://example.com") + req.RemoteAddr = "1.2.3.4:1234" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { // Because password is wrong + t.Errorf("Attempt 1: Expected 401, got %v", rr.Code) + } + + // Attempt 2: Success + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"username":"admin","password":"wrong"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "http://example.com") + req.RemoteAddr = "1.2.3.4:5678" // Different port, same IP + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("Attempt 2: Expected 401, got %v", rr.Code) + } + + // Attempt 3: Rate Limited + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"username":"admin","password":"wrong"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "http://example.com") + req.RemoteAddr = "1.2.3.4:9012" + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusTooManyRequests { + t.Errorf("Attempt 3: Expected 429 Too Many Requests, got %v", rr.Code) + } +} + +func TestGPGKeyExportDisabled(t *testing.T) { + os.Setenv("ENABLE_PRIVATE_KEY_EXPORT", "false") + h := setupTestHandler(t) + + // Bypass the router to test the handler directly to easily inject an admin user + req := httptest.NewRequest("GET", "/api/system/gpg-key", nil) + rr := httptest.NewRecorder() + + // Direct call to exportGPGKey + h.exportGPGKey(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("Expected 403 Forbidden for disabled GPG key export, got %v", rr.Code) + } + + // Enable and test + os.Setenv("ENABLE_PRIVATE_KEY_EXPORT", "true") + defer os.Unsetenv("ENABLE_PRIVATE_KEY_EXPORT") + rr2 := httptest.NewRecorder() + h.exportGPGKey(rr2, req) + + if rr2.Code != http.StatusOK { + t.Errorf("Expected 200 OK for enabled GPG key export, got %v", rr2.Code) + } +} + +func TestClearAuditLogExcept(t *testing.T) { + h := setupTestHandler(t) + db := h.db + router := h.Router(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // 1. Perform login to get session cookie + loginReq := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"username":"admin","password":"securepassword"}`)) + loginReq.Header.Set("Content-Type", "application/json") + loginReq.Header.Set("Origin", "http://example.com") + loginRR := httptest.NewRecorder() + router.ServeHTTP(loginRR, loginReq) + + if loginRR.Code != http.StatusOK { + t.Fatalf("Login failed during clear audit test: %v", loginRR.Code) + } + + var sessionCookie *http.Cookie + for _, c := range loginRR.Result().Cookies() { + if c.Name == "apt_session" { + sessionCookie = c + break + } + } + if sessionCookie == nil { + t.Fatalf("No apt_session cookie returned") + } + + // Wait a moment so timestamps don't exactly collide + time.Sleep(10 * time.Millisecond) + + // 2. Insert some dummy audit logs + db.AddAuditEntry("user1", "admin", "create_repo", "repo", "repo created") + + // Verify we have entries (login + create_repo) + total, err := db.CountAuditLog("") + if err != nil || total < 2 { + t.Fatalf("Expected at least 2 audit logs, got %v", total) + } + + // 3. Call clear audit log endpoint with cookie and CSRF headers + req := httptest.NewRequest("DELETE", "/api/audit", nil) + req.AddCookie(sessionCookie) + req.Header.Set("Origin", "http://example.com") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Errorf("Expected 204 No Content for clearAuditLog, got %v", rr.Code) + } + + // 4. Verify we have exactly 1 entry left (the marker) + total, err = db.CountAuditLog("") + if err != nil || total != 1 { + t.Fatalf("Expected exactly 1 audit log after clear, got %v", total) + } + + // Verify it's the marker + entries, _ := db.ListAuditLog(10, 0) + if len(entries) != 1 || entries[0].Action != "clear_audit_log" { + t.Errorf("Expected remaining log to be clear_audit_log marker, got: %v", entries) + } +} + diff --git a/internal/api/system.go b/internal/api/system.go index 445139c..c1e09a2 100644 --- a/internal/api/system.go +++ b/internal/api/system.go @@ -2,27 +2,56 @@ package api import ( "net/http" + "os" ) +// health returns a minimal liveness/readiness response. +// The application version is intentionally omitted to avoid version +// fingerprinting by unauthenticated clients. func (h *Handler) health(w http.ResponseWriter, r *http.Request) { dbStatus := "ok" if err := h.db.Ping(); err != nil { dbStatus = "error" } jsonOK(w, map[string]string{ - "status": "ok", - "version": h.version, - "db": dbStatus, + "status": "ok", + "db": dbStatus, }, http.StatusOK) } +// exportGPGKey exports the repository's private GPG signing key. +// +// This endpoint is DISABLED by default. Exposing a private key over HTTP +// is a supply-chain risk: any user with a valid token can exfiltrate the +// key and sign malicious packages that downstream clients will accept. +// +// To enable: set ENABLE_PRIVATE_KEY_EXPORT=true AND ensure the route is +// protected by RequireRole("admin") (see router.go). +// +// Recommendation: retrieve the private key directly from the server filesystem +// (DATA_DIR/repo.key) rather than enabling this endpoint in production. func (h *Handler) exportGPGKey(w http.ResponseWriter, r *http.Request) { + if os.Getenv("ENABLE_PRIVATE_KEY_EXPORT") != "true" { + jsonError(w, + "private key export is disabled. Set ENABLE_PRIVATE_KEY_EXPORT=true to enable (admin only).", + http.StatusForbidden) + return + } + privKey, err := h.signer.PrivateKeyArmored() if err != nil { jsonError(w, "failed to export key", http.StatusInternalServerError) return } + + // Audit synchronously — this is a high-value destructive-information event. + if user := currentUser(r); user != nil { + _ = h.db.AddAuditEntry(user.ID, user.Username, "export_private_gpg_key", "gpg:private", + "private key exported via HTTP API") + } + w.Header().Set("Content-Type", "application/pgp-keys") w.Header().Set("Content-Disposition", `attachment; filename="private-key.asc"`) _, _ = w.Write(privKey) } + diff --git a/internal/deb/parse.go b/internal/deb/parse.go index 87ae1c2..e69de29 100644 --- a/internal/deb/parse.go +++ b/internal/deb/parse.go @@ -1,217 +0,0 @@ -package deb - -import ( - "archive/tar" - "bufio" - "bytes" - "compress/bzip2" - "compress/gzip" - "crypto/md5" // #nosec G501 - "crypto/sha1" // #nosec G505 - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "strings" - - "github.com/klauspost/compress/zstd" - "github.com/ulikunitz/xz" -) - -// Info holds the parsed metadata from a .deb control file. -type Info struct { - Package string - Version string - Architecture string - Maintainer string - Description string - Depends string - Section string - Priority string - Homepage string - // Extra holds any additional control fields not captured above. - Extra map[string]string - - // File properties computed from the raw bytes. - Size int64 - SHA256 string - SHA1 string - MD5 string - - // Raw control paragraph (used verbatim in Packages index). - ControlBlock string -} - -// Parse reads a .deb file from r and returns its metadata. -// It also computes checksums and size over the raw bytes. -func Parse(r io.Reader) (*Info, error) { - raw, err := io.ReadAll(r) - if err != nil { - return nil, fmt.Errorf("read deb: %w", err) - } - - info := &Info{ - Extra: make(map[string]string), - Size: int64(len(raw)), - } - - // Compute checksums. - s256 := sha256.Sum256(raw) - s1 := sha1.Sum(raw) // #nosec G401 -- Required by APT format - m5 := md5.Sum(raw) // #nosec G401 -- Required by APT format - info.SHA256 = hex.EncodeToString(s256[:]) - info.SHA1 = hex.EncodeToString(s1[:]) - info.MD5 = hex.EncodeToString(m5[:]) - - control, err := extractControl(bytes.NewReader(raw)) - if err != nil { - return nil, fmt.Errorf("extract control: %w", err) - } - info.ControlBlock = control - if err := parseControl(info, control); err != nil { - return nil, err - } - return info, nil -} - -// extractControl reads the ar archive and returns the raw content of the -// control file found inside control.tar.{gz,bz2,xz}. -func extractControl(r io.ReaderAt) (string, error) { - size, err := r.(interface{ Seek(int64, int) (int64, error) }).Seek(0, io.SeekEnd) - if err != nil { - // Fall back: read all into buffer to determine size. - return "", fmt.Errorf("seek: %w", err) - } - - ar := newArReader(io.NewSectionReader(r, 0, size)) - for { - name, rc, err := ar.Next() - if err == io.EOF { - break - } - if err != nil { - return "", fmt.Errorf("ar: %w", err) - } - if strings.HasPrefix(name, "control.tar") { - ctrl, err := extractControlFromTar(name, rc) - _ = rc.Close() - return ctrl, err - } - _ = rc.Close() - } - return "", fmt.Errorf("control.tar not found in .deb archive") -} - -func extractControlFromTar(name string, r io.Reader) (string, error) { - var tr *tar.Reader - switch { - case strings.HasSuffix(name, ".gz"): - gz, err := gzip.NewReader(r) - if err != nil { - return "", err - } - defer gz.Close() - tr = tar.NewReader(gz) - case strings.HasSuffix(name, ".bz2"): - tr = tar.NewReader(bzip2.NewReader(r)) - case strings.HasSuffix(name, ".xz"): - xr, err := xz.NewReader(r) - if err != nil { - return "", err - } - tr = tar.NewReader(xr) - case strings.HasSuffix(name, ".zst"): - zr, err := zstd.NewReader(r) - if err != nil { - return "", err - } - defer zr.Close() - tr = tar.NewReader(zr) - default: - // uncompressed - tr = tar.NewReader(r) - } - - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return "", err - } - base := strings.TrimPrefix(hdr.Name, "./") - if base == "control" { - data, err := io.ReadAll(tr) - if err != nil { - return "", err - } - return string(data), nil - } - } - return "", fmt.Errorf("control file not found in control.tar") -} - -func parseControl(info *Info, block string) error { - scanner := bufio.NewScanner(strings.NewReader(block)) - var currentKey string - var currentVal strings.Builder - - flush := func() { - if currentKey == "" { - return - } - val := strings.TrimSpace(currentVal.String()) - switch strings.ToLower(currentKey) { - case "package": - info.Package = val - case "version": - info.Version = val - case "architecture": - info.Architecture = val - case "maintainer": - info.Maintainer = val - case "description": - info.Description = val - case "depends": - info.Depends = val - case "section": - info.Section = val - case "priority": - info.Priority = val - case "homepage": - info.Homepage = val - default: - info.Extra[currentKey] = val - } - currentKey = "" - currentVal.Reset() - } - - for scanner.Scan() { - line := scanner.Text() - if line == "" { - flush() - continue - } - if line[0] == ' ' || line[0] == '\t' { - // Continuation line. - currentVal.WriteByte('\n') - currentVal.WriteString(line) - continue - } - flush() - idx := strings.IndexByte(line, ':') - if idx < 0 { - continue - } - currentKey = strings.TrimSpace(line[:idx]) - currentVal.WriteString(strings.TrimSpace(line[idx+1:])) - } - flush() - - if info.Package == "" { - return fmt.Errorf("control file missing Package field") - } - return nil -} diff --git a/internal/storage/db.go b/internal/storage/db.go index 361642a..e69de29 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -1,753 +0,0 @@ -package storage - -import ( - "database/sql" - "fmt" - "time" - - _ "github.com/go-sql-driver/mysql" - "github.com/google/uuid" - _ "modernc.org/sqlite" -) - -// DB wraps a database connection. -type DB struct { - db *sql.DB - dbType string -} - -// APIKey represents an API key record. -type APIKey struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Name string `json:"name"` - Prefix string `json:"prefix"` - CreatedAt time.Time `json:"created_at"` - LastUsed *time.Time `json:"last_used"` - KeyHash string `json:"-"` // only populated for auth lookups -} - -// User represents an administrator user. -type User struct { - ID string `json:"id"` - Username string `json:"username"` - Role string `json:"role"` - PasswordHash string `json:"-"` - CreatedAt time.Time `json:"created_at"` -} - -// AuditEntry represents one immutable audit log record. -type AuditEntry struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Username string `json:"username"` - Action string `json:"action"` - Resource string `json:"resource"` - Detail string `json:"detail"` - CreatedAt time.Time `json:"created_at"` -} - -// Repo represents a repository record. -type Repo struct { - ID string `json:"id"` - Slug string `json:"slug"` - Name string `json:"name"` - Codename string `json:"codename"` - Type string `json:"type"` - CreatedAt time.Time `json:"created_at"` -} - -// Package represents a package record. -type Package struct { - ID string `json:"id"` - RepoID string `json:"repo_id"` - Filename string `json:"filename"` - Package string `json:"package"` - Version string `json:"version"` - Release string `json:"release,omitempty"` - Arch string `json:"arch"` - Size int64 `json:"size"` - SHA256 string `json:"sha256"` - SHA1 string `json:"sha1"` - MD5 string `json:"md5"` - ControlJSON string `json:"-"` - UploadedAt time.Time `json:"uploaded_at"` -} - -func Open(dbType, dsn string) (*DB, error) { - if dbType == "" { - dbType = "sqlite" - } - var db *sql.DB - var err error - if dbType == "sqlite" { - db, err = sql.Open("sqlite", dsn+"?_journal=WAL&_busy_timeout=5000") - } else if dbType == "mysql" { - db, err = sql.Open("mysql", dsn) - } else { - return nil, fmt.Errorf("unsupported dbType: %s", dbType) - } - if err != nil { - return nil, fmt.Errorf("open db: %w", err) - } - if dbType == "sqlite" { - db.SetMaxOpenConns(1) - if _, err := db.Exec(`PRAGMA foreign_keys = ON;`); err != nil { - _ = db.Close() - return nil, fmt.Errorf("enable sqlite foreign keys: %w", err) - } - } - d := &DB{db: db, dbType: dbType} - if err := d.migrate(); err != nil { - return nil, err - } - return d, nil -} - -func (d *DB) migrate() error { - var repoTable, pkgTable, userTable, apiKeyTable, auditLogTable string - if d.dbType == "mysql" { - userTable = ` - CREATE TABLE IF NOT EXISTS users ( - id VARCHAR(36) PRIMARY KEY, - username VARCHAR(255) UNIQUE NOT NULL, - role VARCHAR(32) NOT NULL DEFAULT 'viewer', - password_hash VARCHAR(255) NOT NULL, - created_at DATETIME NOT NULL - );` - repoTable = ` - CREATE TABLE IF NOT EXISTS repos ( - id VARCHAR(36) PRIMARY KEY, - slug VARCHAR(255) UNIQUE NOT NULL, - name VARCHAR(255) NOT NULL, - codename VARCHAR(255) NOT NULL DEFAULT 'stable', - type VARCHAR(16) NOT NULL DEFAULT 'deb', - created_at DATETIME NOT NULL - );` - pkgTable = ` - CREATE TABLE IF NOT EXISTS packages ( - id VARCHAR(36) PRIMARY KEY, - repo_id VARCHAR(36) NOT NULL, - filename VARCHAR(255) NOT NULL, - package VARCHAR(255) NOT NULL, - version VARCHAR(255) NOT NULL, - ` + "`release`" + ` VARCHAR(255) NOT NULL DEFAULT '', - ` + "`arch`" + ` VARCHAR(255) NOT NULL, - size BIGINT NOT NULL, - sha256 VARCHAR(64) NOT NULL, - sha1 VARCHAR(40) NOT NULL, - md5 VARCHAR(32) NOT NULL, - control_json TEXT NOT NULL, - uploaded_at DATETIME NOT NULL, - FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE, - INDEX idx_packages_repo (repo_id) - );` - apiKeyTable = ` - CREATE TABLE IF NOT EXISTS api_keys ( - id VARCHAR(36) PRIMARY KEY, - user_id VARCHAR(36) NOT NULL, - name VARCHAR(255) NOT NULL, - key_hash VARCHAR(255) NOT NULL UNIQUE, - prefix VARCHAR(8) NOT NULL, - created_at DATETIME NOT NULL, - last_used DATETIME, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - );` - auditLogTable = ` - CREATE TABLE IF NOT EXISTS audit_log ( - id VARCHAR(36) PRIMARY KEY, - user_id VARCHAR(36) NOT NULL, - username VARCHAR(255) NOT NULL, - action VARCHAR(64) NOT NULL, - resource VARCHAR(255) NOT NULL, - detail TEXT, - created_at DATETIME NOT NULL, - INDEX idx_audit_created (created_at), - INDEX idx_audit_user (user_id) - );` - } else { - userTable = ` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT UNIQUE NOT NULL, - role TEXT NOT NULL DEFAULT 'viewer', - password_hash TEXT NOT NULL, - created_at DATETIME NOT NULL - );` - repoTable = ` - CREATE TABLE IF NOT EXISTS repos ( - id TEXT PRIMARY KEY, - slug TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - codename TEXT NOT NULL DEFAULT 'stable', - type TEXT NOT NULL DEFAULT 'deb', - created_at DATETIME NOT NULL - );` - pkgTable = ` - CREATE TABLE IF NOT EXISTS packages ( - id TEXT PRIMARY KEY, - repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, - filename TEXT NOT NULL, - package TEXT NOT NULL, - version TEXT NOT NULL, - release TEXT NOT NULL DEFAULT '', - arch TEXT NOT NULL, - size INTEGER NOT NULL, - sha256 TEXT NOT NULL, - sha1 TEXT NOT NULL, - md5 TEXT NOT NULL, - control_json TEXT NOT NULL DEFAULT '{}', - uploaded_at DATETIME NOT NULL - );` - apiKeyTable = ` - CREATE TABLE IF NOT EXISTS api_keys ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - key_hash TEXT NOT NULL UNIQUE, - prefix TEXT NOT NULL, - created_at DATETIME NOT NULL, - last_used DATETIME - );` - auditLogTable = ` - CREATE TABLE IF NOT EXISTS audit_log ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - username TEXT NOT NULL, - action TEXT NOT NULL, - resource TEXT NOT NULL, - detail TEXT, - created_at DATETIME NOT NULL - );` - } - - if _, err := d.db.Exec(userTable); err != nil { - return err - } - if _, err := d.db.Exec(repoTable); err != nil { - return err - } - if _, err := d.db.Exec(pkgTable); err != nil { - return err - } - if _, err := d.db.Exec(apiKeyTable); err != nil { - return err - } - if _, err := d.db.Exec(auditLogTable); err != nil { - return err - } - if err := d.ensureUserRoleColumn(); err != nil { - return err - } - if err := d.ensureRepoTypeColumn(); err != nil { - return err - } - if err := d.ensurePackageReleaseColumn(); err != nil { - return err - } - if d.dbType == "sqlite" { - if _, err := d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_packages_repo ON packages(repo_id);`); err != nil { - return err - } - if _, err := d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);`); err != nil { - return err - } - if _, err := d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);`); err != nil { - return err - } - } - return nil -} - -func (d *DB) ensureRepoTypeColumn() error { - exists, err := d.columnExists("repos", "type") - if err != nil { - return err - } - if exists { - return nil - } - colType := "TEXT" - if d.dbType == "mysql" { - colType = "VARCHAR(16)" - } - _, err = d.db.Exec(`ALTER TABLE repos ADD COLUMN type ` + colType + ` NOT NULL DEFAULT 'deb'`) - return err -} - -func (d *DB) ensurePackageReleaseColumn() error { - exists, err := d.columnExists("packages", "release") - if err != nil { - return err - } - if exists { - return nil - } - if d.dbType == "mysql" { - _, err = d.db.Exec("ALTER TABLE packages ADD COLUMN `release` VARCHAR(255) NOT NULL DEFAULT ''") - } else { - _, err = d.db.Exec("ALTER TABLE packages ADD COLUMN release TEXT NOT NULL DEFAULT ''") - } - return err -} - -func (d *DB) ensureUserRoleColumn() error { - exists, err := d.columnExists("users", "role") - if err != nil { - return err - } - if exists { - return nil - } - roleType := "TEXT" - if d.dbType == "mysql" { - roleType = "VARCHAR(32)" - } - if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN role ` + roleType + ` NOT NULL DEFAULT 'viewer'`); err != nil { - return err - } - _, err = d.db.Exec(`UPDATE users SET role=?`, "admin") - return err -} - -func (d *DB) columnExists(table, column string) (bool, error) { - // Whitelist table names to prevent SQL injection via string concatenation - validTables := map[string]bool{"users": true, "repos": true, "packages": true, "api_keys": true, "audit_log": true} - if !validTables[table] { - return false, fmt.Errorf("invalid table name") - } - - var rows *sql.Rows - var err error - if d.dbType == "mysql" { - rows, err = d.db.Query("SHOW COLUMNS FROM "+table+" WHERE Field = ?", column) // #nosec G202 - } else { - rows, err = d.db.Query(`PRAGMA table_info(` + table + `)`) // #nosec G202 - } - if err != nil { - return false, err - } - defer rows.Close() - if d.dbType == "mysql" { - return rows.Next(), rows.Err() - } - for rows.Next() { - var cid int - var name, colType string - var notNull int - var defaultValue any - var pk int - if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &pk); err != nil { - return false, err - } - if name == column { - return true, nil - } - } - return false, rows.Err() -} - -// GetUserByUsername retrieves a user by their username. -func (d *DB) GetUserByUsername(username string) (*User, error) { - u := &User{} - err := d.db.QueryRow( - `SELECT id, username, role, password_hash, created_at FROM users WHERE username=?`, username, - ).Scan(&u.ID, &u.Username, &u.Role, &u.PasswordHash, &u.CreatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - return u, err -} - -// CreateUser inserts a new user. -func (d *DB) CreateUser(username, passwordHash, role string) (*User, error) { - u := &User{ - ID: uuid.NewString(), - Username: username, - Role: role, - PasswordHash: passwordHash, - CreatedAt: time.Now().UTC(), - } - _, err := d.db.Exec( - `INSERT INTO users (id, username, role, password_hash, created_at) VALUES (?,?,?,?,?)`, - u.ID, u.Username, u.Role, u.PasswordHash, u.CreatedAt, - ) - if err != nil { - return nil, fmt.Errorf("create user: %w", err) - } - return u, nil -} - -// ListUsers returns all users ordered by creation time. -func (d *DB) ListUsers() ([]User, error) { - rows, err := d.db.Query(`SELECT id, username, role, created_at FROM users ORDER BY created_at`) - if err != nil { - return nil, err - } - defer rows.Close() - var users []User - for rows.Next() { - var u User - if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil { - return nil, err - } - users = append(users, u) - } - return users, rows.Err() -} - -// GetUserByID retrieves a user by ID. -func (d *DB) GetUserByID(id string) (*User, error) { - u := &User{} - err := d.db.QueryRow( - `SELECT id, username, role, password_hash, created_at FROM users WHERE id=?`, id, - ).Scan(&u.ID, &u.Username, &u.Role, &u.PasswordHash, &u.CreatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - return u, err -} - -// UpdateUserRole updates a user's role. -func (d *DB) UpdateUserRole(id, role string) error { - _, err := d.db.Exec(`UPDATE users SET role=? WHERE id=?`, role, id) - return err -} - -// UpdateUserPassword updates a user's password hash. -func (d *DB) UpdateUserPassword(id, passwordHash string) error { - _, err := d.db.Exec(`UPDATE users SET password_hash=? WHERE id=?`, passwordHash, id) - return err -} - -// DeleteUser removes a user and cascades their API keys. -func (d *DB) DeleteUser(id string) error { - _, err := d.db.Exec(`DELETE FROM users WHERE id=?`, id) - return err -} - -// CreateRepo inserts a new repo and returns it. -func (d *DB) CreateRepo(slug, name, codename, repoType string) (*Repo, error) { - r := &Repo{ - ID: uuid.NewString(), - Slug: slug, - Name: name, - Codename: codename, - Type: repoType, - CreatedAt: time.Now().UTC(), - } - _, err := d.db.Exec( - `INSERT INTO repos (id, slug, name, codename, type, created_at) VALUES (?,?,?,?,?,?)`, - r.ID, r.Slug, r.Name, r.Codename, r.Type, r.CreatedAt, - ) - if err != nil { - return nil, fmt.Errorf("create repo: %w", err) - } - return r, nil -} - -// GetRepo fetches a repo by ID. -func (d *DB) GetRepo(id string) (*Repo, error) { - r := &Repo{} - err := d.db.QueryRow( - `SELECT id, slug, name, codename, type, created_at FROM repos WHERE id=?`, id, - ).Scan(&r.ID, &r.Slug, &r.Name, &r.Codename, &r.Type, &r.CreatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - return r, err -} - -// GetRepoBySlug fetches a repo by slug. -func (d *DB) GetRepoBySlug(slug string) (*Repo, error) { - r := &Repo{} - err := d.db.QueryRow( - `SELECT id, slug, name, codename, type, created_at FROM repos WHERE slug=?`, slug, - ).Scan(&r.ID, &r.Slug, &r.Name, &r.Codename, &r.Type, &r.CreatedAt) - if err == sql.ErrNoRows { - return nil, nil - } - return r, err -} - -// ListRepos returns all repos ordered by creation time. -func (d *DB) ListRepos() ([]Repo, error) { - rows, err := d.db.Query(`SELECT id, slug, name, codename, type, created_at FROM repos ORDER BY created_at`) - if err != nil { - return nil, err - } - defer rows.Close() - var repos []Repo - for rows.Next() { - var r Repo - if err := rows.Scan(&r.ID, &r.Slug, &r.Name, &r.Codename, &r.Type, &r.CreatedAt); err != nil { - return nil, err - } - repos = append(repos, r) - } - return repos, rows.Err() -} - -// DeleteRepo removes a repo (packages cascade via FK). -func (d *DB) DeleteRepo(id string) error { - _, err := d.db.Exec(`DELETE FROM repos WHERE id=?`, id) - return err -} - -// UpdateRepo updates the repo's name and codename. -func (d *DB) UpdateRepo(id, name, codename string) error { - _, err := d.db.Exec(`UPDATE repos SET name=?, codename=? WHERE id=?`, name, codename, id) - return err -} - -// GetRepoArchitectures fetches unique architectures for a repo. -func (d *DB) GetRepoArchitectures(repoID string) ([]string, error) { - rows, err := d.db.Query(`SELECT DISTINCT arch FROM packages WHERE repo_id=?`, repoID) - if err != nil { - return nil, err - } - defer rows.Close() - var arches []string - for rows.Next() { - var arch string - if err := rows.Scan(&arch); err != nil { - return nil, err - } - arches = append(arches, arch) - } - return arches, rows.Err() -} - -// AddPackage inserts a package record. -func (d *DB) AddPackage(p *Package) error { - p.ID = uuid.NewString() - p.UploadedAt = time.Now().UTC() - _, err := d.db.Exec( - `INSERT INTO packages (id, repo_id, filename, package, version, `+"`release`"+`, arch, size, sha256, sha1, md5, control_json, uploaded_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, - p.ID, p.RepoID, p.Filename, p.Package, p.Version, p.Release, p.Arch, - p.Size, p.SHA256, p.SHA1, p.MD5, p.ControlJSON, p.UploadedAt, - ) - return err -} - -// ListPackages returns all packages for a repo. If limit > 0, it applies pagination. -func (d *DB) ListPackages(repoID string, limit, offset int) ([]Package, error) { - query := `SELECT id, repo_id, filename, package, version, ` + "`release`" + `, arch, size, sha256, sha1, md5, control_json, uploaded_at - FROM packages WHERE repo_id=? ORDER BY uploaded_at DESC` - - var rows *sql.Rows - var err error - if limit > 0 { - query += ` LIMIT ? OFFSET ?` - rows, err = d.db.Query(query, repoID, limit, offset) - } else { - rows, err = d.db.Query(query, repoID) - } - - if err != nil { - return nil, err - } - defer rows.Close() - var pkgs []Package - for rows.Next() { - var p Package - if err := rows.Scan(&p.ID, &p.RepoID, &p.Filename, &p.Package, &p.Version, &p.Release, &p.Arch, - &p.Size, &p.SHA256, &p.SHA1, &p.MD5, &p.ControlJSON, &p.UploadedAt); err != nil { - return nil, err - } - pkgs = append(pkgs, p) - } - return pkgs, rows.Err() -} - -// CountPackages returns the total number of packages for a repo. -func (d *DB) CountPackages(repoID string) (int, error) { - var count int - err := d.db.QueryRow(`SELECT COUNT(*) FROM packages WHERE repo_id=?`, repoID).Scan(&count) - return count, err -} - -// GetPackage fetches a single package by ID. -func (d *DB) GetPackage(id string) (*Package, error) { - p := &Package{} - err := d.db.QueryRow( - `SELECT id, repo_id, filename, package, version, `+"`release`"+`, arch, size, sha256, sha1, md5, control_json, uploaded_at - FROM packages WHERE id=?`, id, - ).Scan(&p.ID, &p.RepoID, &p.Filename, &p.Package, &p.Version, &p.Release, &p.Arch, - &p.Size, &p.SHA256, &p.SHA1, &p.MD5, &p.ControlJSON, &p.UploadedAt) - if err == sql.ErrNoRows { - return nil, nil - } - return p, err -} - -// DeletePackage removes a package record by ID. -func (d *DB) DeletePackage(id string) error { - _, err := d.db.Exec(`DELETE FROM packages WHERE id=?`, id) - return err -} - -// Ping checks that the database connection is alive. -func (d *DB) Ping() error { - _, err := d.db.Exec(`SELECT 1`) - return err -} - -// PackageFilenameExists checks whether a package with the same filename already exists in the given repo. -func (d *DB) PackageFilenameExists(repoID, filename string) (string, bool, error) { - var id string - err := d.db.QueryRow( - `SELECT id FROM packages WHERE repo_id=? AND filename=? LIMIT 1`, - repoID, filename, - ).Scan(&id) - if err == sql.ErrNoRows { - return "", false, nil - } - if err != nil { - return "", false, err - } - return id, true, nil -} - -func (d *DB) Close() error { return d.db.Close() } - -// CreateAPIKey inserts a new API key record. -func (d *DB) CreateAPIKey(userID, name, keyHash, prefix string) (*APIKey, error) { - k := &APIKey{ - ID: uuid.NewString(), - UserID: userID, - Name: name, - Prefix: prefix, - CreatedAt: time.Now().UTC(), - } - _, err := d.db.Exec( - `INSERT INTO api_keys (id, user_id, name, key_hash, prefix, created_at) VALUES (?,?,?,?,?,?)`, - k.ID, k.UserID, k.Name, keyHash, k.Prefix, k.CreatedAt, - ) - if err != nil { - return nil, fmt.Errorf("create api key: %w", err) - } - return k, nil -} - -// ListAPIKeys returns all API keys for a user. -func (d *DB) ListAPIKeys(userID string) ([]APIKey, error) { - rows, err := d.db.Query( - `SELECT id, user_id, name, prefix, created_at, last_used FROM api_keys WHERE user_id=? ORDER BY created_at`, - userID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var keys []APIKey - for rows.Next() { - var k APIKey - if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.Prefix, &k.CreatedAt, &k.LastUsed); err != nil { - return nil, err - } - keys = append(keys, k) - } - return keys, rows.Err() -} - -// DeleteAPIKey removes an API key by ID and owner. -func (d *DB) DeleteAPIKey(id, userID string) error { - _, err := d.db.Exec(`DELETE FROM api_keys WHERE id=? AND user_id=?`, id, userID) - return err -} - -// GetAPIKeyByPrefix returns all keys matching the given prefix for bcrypt comparison. -func (d *DB) GetAPIKeyByPrefix(prefix string) ([]APIKey, error) { - rows, err := d.db.Query( - `SELECT id, user_id, name, key_hash, prefix, created_at, last_used FROM api_keys WHERE prefix=?`, - prefix, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var keys []APIKey - for rows.Next() { - var k APIKey - if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyHash, &k.Prefix, &k.CreatedAt, &k.LastUsed); err != nil { - return nil, err - } - keys = append(keys, k) - } - return keys, rows.Err() -} - -// UpdateAPIKeyLastUsed sets last_used to now for the given key. -func (d *DB) UpdateAPIKeyLastUsed(id string) error { - _, err := d.db.Exec(`UPDATE api_keys SET last_used=? WHERE id=?`, time.Now().UTC(), id) - return err -} - -// AddAuditEntry inserts an audit log entry. -func (d *DB) AddAuditEntry(userID, username, action, resource, detail string) error { - _, err := d.db.Exec( - `INSERT INTO audit_log (id, user_id, username, action, resource, detail, created_at) VALUES (?,?,?,?,?,?,?)`, - uuid.NewString(), userID, username, action, resource, detail, time.Now().UTC(), - ) - return err -} - -// ListAuditLog returns audit entries ordered newest first. -func (d *DB) ListAuditLog(limit, offset int) ([]AuditEntry, error) { - rows, err := d.db.Query( - `SELECT id, user_id, username, action, resource, COALESCE(detail, ''), created_at - FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?`, - limit, offset, - ) - if err != nil { - return nil, err - } - defer rows.Close() - return scanAuditEntries(rows) -} - -// ListAuditLogByUser returns audit entries for one user ordered newest first. -func (d *DB) ListAuditLogByUser(userID string, limit, offset int) ([]AuditEntry, error) { - rows, err := d.db.Query( - `SELECT id, user_id, username, action, resource, COALESCE(detail, ''), created_at - FROM audit_log WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?`, - userID, limit, offset, - ) - if err != nil { - return nil, err - } - defer rows.Close() - return scanAuditEntries(rows) -} - -// CountAuditLog returns the number of audit entries, optionally filtered by user. -func (d *DB) CountAuditLog(userID string) (int, error) { - var count int - var err error - if userID == "" { - err = d.db.QueryRow(`SELECT COUNT(*) FROM audit_log`).Scan(&count) - } else { - err = d.db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE user_id=?`, userID).Scan(&count) - } - return count, err -} - -// ClearAuditLog permanently removes every audit log entry. -func (d *DB) ClearAuditLog() error { - _, err := d.db.Exec(`DELETE FROM audit_log`) - return err -} - -func scanAuditEntries(rows *sql.Rows) ([]AuditEntry, error) { - var entries []AuditEntry - for rows.Next() { - var e AuditEntry - if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Resource, &e.Detail, &e.CreatedAt); err != nil { - return nil, err - } - entries = append(entries, e) - } - return entries, rows.Err() -} diff --git a/web/src/api.ts b/web/src/api.ts index dec0cff..e69de29 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,175 +0,0 @@ -const TOKEN_KEY = 'apt_admin_token' - -export function getToken(): string { - return localStorage.getItem(TOKEN_KEY) ?? '' -} - -export function setToken(t: string) { - localStorage.setItem(TOKEN_KEY, t) -} - -async function req(method: string, path: string, body?: unknown): Promise { - const token = getToken() - const headers: Record = {} - if (token) headers['Authorization'] = `Bearer ${token}` - if (body) headers['Content-Type'] = 'application/json' - - const res = await fetch(path, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }) - if (!res.ok) { - if (res.status === 401 && window.location.pathname !== '/login') { - setToken('') - window.location.href = '/login' - } - const err = await res.json().catch(() => ({ error: res.statusText })) - if (res.status === 403) { - throw new Error('You do not have permission to perform this action.') - } - throw new Error(err.error ?? res.statusText) - } - if (res.status === 204) return undefined as unknown as T - return res.json() -} - -export interface Repo { - id: string - slug: string - name: string - codename: string - type: 'deb' | 'rpm' - created_at: string -} - -export interface Package { - id: string - repo_id: string - filename: string - package: string - version: string - release?: string - arch: string - size: number - sha256: string - uploaded_at: string -} - -export interface SetupInfo { - type: 'deb' | 'rpm' - keyURL: string - repoURL: string - codename?: string - component?: string - addKey?: string - addSource?: string - install?: string - repoFile?: string - update: string -} - -export interface PackageList { - packages: Package[] - total: number -} - -export interface APIKey { - id: string - name: string - prefix: string - created_at: string - last_used: string | null -} - -export interface APIKeyCreated extends APIKey { - key: string // only present on creation response -} - -export interface RepoStatus { - id: string - indexing: boolean - last_indexed: string | null -} - -export type Role = 'admin' | 'member' | 'viewer' - -export interface CurrentUser { - valid: boolean - user_id: string - username: string - role: Role -} - -export interface User { - id: string - username: string - role: Role - created_at: string -} - -export interface AuditEntry { - id: string - user_id: string - username: string - action: string - resource: string - detail: string - created_at: string -} - -export interface AuditList { - total: number - entries: AuditEntry[] -} - -export const api = { - checkAuth: () => req('GET', '/api/auth/check'), - login: (username: string, password: string) => req<{ token: string }>('POST', '/api/auth/login', { username, password }), - listRepos: () => req('GET', '/api/repos'), - createRepo: (slug: string, name: string, codename: string, type: Repo['type']) => - req('POST', '/api/repos', { slug, name, codename, type }), - updateRepo: (id: string, name: string, codename: string) => - req('PUT', `/api/repos/${id}`, { name, codename }), - deleteRepo: (id: string) => req('DELETE', `/api/repos/${id}`), - listPackages: (repoId: string, page: number = 1, limit: number = 50) => - req('GET', `/api/repos/${repoId}/packages?page=${page}&limit=${limit}`), - deletePackage: (repoId: string, pkgId: string) => - req('DELETE', `/api/repos/${repoId}/packages/${pkgId}`), - getSetup: (repoId: string) => req('GET', `/api/repos/${repoId}/setup`), - getRepoStatus: (repoId: string) => req('GET', `/api/repos/${repoId}/status`), - uploadPackage: async (repoId: string, file: File) => { - const token = getToken() - const form = new FormData() - form.append('file', file) - const res = await fetch(`/api/repos/${repoId}/packages`, { - method: 'POST', - headers: token ? { Authorization: `Bearer ${token}` } : {}, - body: form, - }) - if (!res.ok) { - if (res.status === 401 && window.location.pathname !== '/login') { - setToken('') - window.location.href = '/login' - } - const err = await res.json().catch(() => ({ error: res.statusText })) - if (res.status === 403) { - throw new Error('You do not have permission to perform this action.') - } - throw new Error(err.error ?? res.statusText) - } - return res.json() as Promise - }, - listAPIKeys: () => req('GET', '/api/auth/keys'), - createAPIKey: (name: string) => req('POST', '/api/auth/keys', { name }), - deleteAPIKey: (id: string) => req('DELETE', `/api/auth/keys/${id}`), - listUsers: () => req('GET', '/api/users'), - createUser: (username: string, password: string, role: Role) => - req('POST', '/api/users', { username, password, role }), - updateUser: (id: string, data: { role?: Role; password?: string }) => - req('PUT', `/api/users/${id}`, data), - deleteUser: (id: string) => req('DELETE', `/api/users/${id}`), - listAudit: (offset: number = 0, limit: number = 50) => - req('GET', `/api/audit?offset=${offset}&limit=${limit}`), - clearAudit: () => req('DELETE', '/api/audit'), -} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index f876d82..8131afc 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { setToken, api } from '../api' +import { api } from '../api' import './Login.css' type IconName = 'user' | 'lock' | 'eye' | 'eyeOff' | 'arrow' | 'alert' | 'shield' | 'package' | 'check' @@ -38,12 +38,12 @@ export default function Login() { setLoading(true) setError('') try { - const res = await api.login(username, password) - setToken(res.token) + // The server sets an HttpOnly session cookie on successful login. + // No token is stored client-side. + await api.login(username, password) const from = (location.state as { from?: { pathname?: string } })?.from?.pathname || '/' navigate(from, { replace: true }) } catch { - setToken('') setError('The username or password you entered is incorrect.') } finally { setLoading(false) From 074b4b4d86bb0d67003f27e6de58f9250e3e9f2a Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 19:30:42 +0500 Subject: [PATCH 34/71] feat: implement comprehensive backend API architecture including repository management, rate limiting, and secure authentication flows. --- .github/workflows/ci.yml | 2 +- internal/api/audit.go | 72 +++ internal/api/auth.go | 78 ++++ internal/api/csrf.go | 44 ++ internal/api/helpers.go | 18 + internal/api/middleware.go | 51 ++ internal/api/ratelimit.go | 68 +++ internal/api/repos.go | 232 ++++++++++ internal/api/router.go | 211 +++++++++ internal/api/security_headers.go | 15 + internal/api/system.go | 35 +- internal/deb/parse.go | 221 +++++++++ internal/storage/db.go | 770 +++++++++++++++++++++++++++++++ web/src/api.ts | 175 +++++++ 14 files changed, 1959 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cb7519..524cc8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,6 @@ jobs: run: npm audit --audit-level=high - name: Secret scanning (gitleaks) - uses: gitleaks/gitleaks-action@b6c5bf7cb54f98b5e0ccd71cc0f56b8edd2fde15 # v2.3.9 + uses: gitleaks/gitleaks-action@44c470ffc35caa8b1eb3e8012ca53c2f9bea4eb5 # v2.3.6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/internal/api/audit.go b/internal/api/audit.go index e69de29..90cec4b 100644 --- a/internal/api/audit.go +++ b/internal/api/audit.go @@ -0,0 +1,72 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/kernelcode0/aptify/internal/storage" +) + +func (h *Handler) listAuditLog(w http.ResponseWriter, r *http.Request) { + limit := 50 + offset := 0 + if v := r.URL.Query().Get("limit"); v != "" { + _, _ = fmt.Sscanf(v, "%d", &limit) + } + if v := r.URL.Query().Get("offset"); v != "" { + _, _ = fmt.Sscanf(v, "%d", &offset) + } + if limit < 1 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + if offset < 0 { + offset = 0 + } + + userID := r.URL.Query().Get("user") + var entries []storage.AuditEntry + var err error + if userID != "" { + entries, err = h.db.ListAuditLogByUser(userID, limit, offset) + } else { + entries, err = h.db.ListAuditLog(limit, offset) + } + if err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + total, err := h.db.CountAuditLog(userID) + if err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + if entries == nil { + entries = []storage.AuditEntry{} + } + jsonOK(w, map[string]any{ + "total": total, + "entries": entries, + }, http.StatusOK) +} + +func (h *Handler) clearAuditLog(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value(ctxUser).(*storage.User) + + // Forensic Marker Strategy: Insert a clear event FIRST, then delete all other events EXCEPT the new clear event. + clearEventID, err := h.db.LogAuditWithID(user.ID, user.Username, "CLEAR", "audit_log", "Audit log truncated by admin") + if err != nil { + jsonError(w, "Failed to create forensic marker", http.StatusInternalServerError) + return + } + + err = h.db.ClearAuditLogExcept(r.Context(), clearEventID) + if err != nil { + jsonError(w, "Failed to clear audit log", http.StatusInternalServerError) + return + } + + jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) +} diff --git a/internal/api/auth.go b/internal/api/auth.go index e69de29..d54ff02 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -0,0 +1,78 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +type loginReq struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (h *Handler) login(w http.ResponseWriter, r *http.Request) { + var req loginReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid request", http.StatusBadRequest) + return + } + + user, err := h.db.GetUserByUsername(req.Username) + if err != nil { + jsonError(w, "server error", http.StatusInternalServerError) + return + } + if user == nil { + jsonError(w, "invalid credentials", http.StatusUnauthorized) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + jsonError(w, "invalid credentials", http.StatusUnauthorized) + return + } + + claims := jwt.MapClaims{ + "sub": user.ID, + "usr": user.Username, + "rol": user.Role, + "exp": time.Now().Add(2 * time.Hour).Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(h.jwtSecret)) + if err != nil { + jsonError(w, "failed to sign token", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "apt_session", + Value: tokenString, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: 7200, // 2 hours + }) + + h.audit(user, "login", "user:"+user.Username, "") + jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) +} + +func (h *Handler) authCheck(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + if user == nil { + jsonError(w, "unauthorized", http.StatusUnauthorized) + return + } + jsonOK(w, map[string]any{ + "valid": true, + "username": user.Username, + "role": user.Role, + "user_id": user.ID, + }, http.StatusOK) +} diff --git a/internal/api/csrf.go b/internal/api/csrf.go index e69de29..ae656ab 100644 --- a/internal/api/csrf.go +++ b/internal/api/csrf.go @@ -0,0 +1,44 @@ +package api + +import ( + "crypto/rand" + "encoding/base64" + "net/http" +) + +func CSRFProtection(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" { + // Generate CSRF token if not present + _, err := r.Cookie("csrf_token") + if err != nil { + b := make([]byte, 32) + rand.Read(b) + token := base64.RawURLEncoding.EncodeToString(b) + http.SetCookie(w, &http.Cookie{ + Name: "csrf_token", + Value: token, + Path: "/", + HttpOnly: false, // Must be readable by frontend to send in header + SameSite: http.SameSiteStrictMode, + }) + } + next.ServeHTTP(w, r) + return + } + + // Verify CSRF token for state-changing methods + cookie, err := r.Cookie("csrf_token") + if err != nil { + http.Error(w, "Missing CSRF token cookie", http.StatusForbidden) + return + } + header := r.Header.Get("X-CSRF-Token") + if header == "" || header != cookie.Value { + http.Error(w, "Invalid CSRF token", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/api/helpers.go b/internal/api/helpers.go index e69de29..16fa288 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -0,0 +1,18 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +func jsonOK(w http.ResponseWriter, v any, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func jsonError(w http.ResponseWriter, msg string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index e69de29..fb893d7 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -0,0 +1,51 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/kernelcode0/aptify/internal/storage" +) + +func RequireRole(roles ...string) func(http.Handler) http.Handler { + allowed := make(map[string]bool, len(roles)) + for _, role := range roles { + allowed[role] = true + } + required := "" + if len(roles) > 0 { + required = roles[0] + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + if user == nil || !allowed[user.Role] { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "forbidden", + "required_role": required, + }) + return + } + next.ServeHTTP(w, r) + }) + } +} + +func currentUser(r *http.Request) *storage.User { + user, _ := r.Context().Value(ctxUser).(*storage.User) + return user +} + +func (h *Handler) audit(user *storage.User, action, resource, detail string) { + if user == nil { + return + } + go func() { + if err := h.db.AddAuditEntry(user.ID, user.Username, action, resource, detail); err != nil { + log.Printf("audit log write failed: %v", err) + } + }() +} diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go index e69de29..1cc9021 100644 --- a/internal/api/ratelimit.go +++ b/internal/api/ratelimit.go @@ -0,0 +1,68 @@ +package api + +import ( + "net" + "net/http" + "sync" + "time" +) + +type rateLimiter struct { + mu sync.Mutex + clients map[string]*clientData +} + +type clientData struct { + tokens int + last time.Time +} + +func newRateLimiter() *rateLimiter { + rl := &rateLimiter{ + clients: make(map[string]*clientData), + } + go func() { + for { + time.Sleep(time.Minute) + rl.mu.Lock() + for ip, client := range rl.clients { + if time.Since(client.last) > 3*time.Minute { + delete(rl.clients, ip) + } + } + rl.mu.Unlock() + } + }() + return rl +} + +func (rl *rateLimiter) RateLimit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + ip = r.RemoteAddr + } + + rl.mu.Lock() + client, exists := rl.clients[ip] + if !exists { + client = &clientData{tokens: 5, last: time.Now()} + rl.clients[ip] = client + } + + if time.Since(client.last) > time.Minute { + client.tokens = 5 + } + client.last = time.Now() + + if client.tokens <= 0 { + rl.mu.Unlock() + http.Error(w, "Too many requests", http.StatusTooManyRequests) + return + } + client.tokens-- + rl.mu.Unlock() + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/api/repos.go b/internal/api/repos.go index e69de29..8bffa69 100644 --- a/internal/api/repos.go +++ b/internal/api/repos.go @@ -0,0 +1,232 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "regexp" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/kernelcode0/aptify/internal/storage" +) + +var codenameRe = regexp.MustCompile(`^[a-z][a-z0-9\-\.]{0,49}$`) + +type createRepoRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` + Codename string `json:"codename"` + Type string `json:"type"` +} + +func (h *Handler) createRepo(w http.ResponseWriter, r *http.Request) { + var req createRepoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid JSON", http.StatusBadRequest) + return + } + if req.Type == "" { + req.Type = "deb" + } + if req.Type != "deb" && req.Type != "rpm" { + jsonError(w, "type must be deb or rpm", http.StatusBadRequest) + return + } + if req.Type == "deb" && req.Codename == "" { + req.Codename = "stable" + } + if err := storage.ValidateSlug(req.Slug); err != nil { + jsonError(w, err.Error(), http.StatusBadRequest) + return + } + if req.Name == "" { + jsonError(w, "name is required", http.StatusBadRequest) + return + } + if req.Type == "deb" && !codenameRe.MatchString(req.Codename) { + jsonError(w, "invalid codename", http.StatusBadRequest) + return + } + + existing, err := h.db.GetRepoBySlug(req.Slug) + if err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + if existing != nil { + jsonError(w, "slug already exists", http.StatusConflict) + return + } + + var initErr error + if req.Type == "rpm" { + initErr = h.fs.InitRPMRepo(req.Slug) + } else { + initErr = h.fs.InitRepo(req.Slug, req.Codename) + } + if initErr != nil { + jsonError(w, "failed to create repo dirs", http.StatusInternalServerError) + return + } + + repo, err := h.db.CreateRepo(req.Slug, req.Name, req.Codename, req.Type) + if err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + + // Generate empty indexes. + if err := h.gen.Regenerate(repo, nil); err != nil { + jsonError(w, "index error: "+err.Error(), http.StatusInternalServerError) + return + } + + h.audit(currentUser(r), "create_repo", "repo:"+repo.Slug, "") + jsonOK(w, repo, http.StatusCreated) +} + +func (h *Handler) listRepos(w http.ResponseWriter, r *http.Request) { + repos, err := h.db.ListRepos() + if err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + if repos == nil { + repos = []storage.Repo{} + } + jsonOK(w, repos, http.StatusOK) +} + +func (h *Handler) deleteRepo(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + repo, err := h.db.GetRepo(id) + if err != nil || repo == nil { + jsonError(w, "repo not found", http.StatusNotFound) + return + } + if err := h.db.DeleteRepo(id); err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + if err := h.fs.DeleteRepoDir(repo.Slug); err != nil { + jsonError(w, "repo deleted but file cleanup failed", http.StatusInternalServerError) + return + } + h.audit(currentUser(r), "delete_repo", "repo:"+repo.Slug, "") + w.WriteHeader(http.StatusNoContent) +} + +type updateRepoRequest struct { + Name string `json:"name"` + Codename string `json:"codename"` +} + +func (h *Handler) updateRepo(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + repo, err := h.db.GetRepo(id) + if err != nil || repo == nil { + jsonError(w, "repo not found", http.StatusNotFound) + return + } + + var req updateRepoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid JSON", http.StatusBadRequest) + return + } + if req.Name == "" { + req.Name = repo.Name + } + if req.Codename == "" { + req.Codename = repo.Codename + } + if repo.Type == "deb" && !codenameRe.MatchString(req.Codename) { + jsonError(w, "invalid codename", http.StatusBadRequest) + return + } + + codenameChanged := repo.Type == "deb" && repo.Codename != req.Codename + if codenameChanged { + if err := h.fs.InitRepo(repo.Slug, req.Codename); err != nil { + jsonError(w, "failed to create repo dirs", http.StatusInternalServerError) + return + } + } + + if err := h.db.UpdateRepo(id, req.Name, req.Codename); err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + + repo.Name = req.Name + repo.Codename = req.Codename + + if codenameChanged { + // Regenerate index in new path + if err := h.regenerateRepoIndex(repo); err != nil { + jsonError(w, "index regenerate error", http.StatusInternalServerError) + return + } + } + + jsonOK(w, repo, http.StatusOK) +} + +func baseURLFromRequest(r *http.Request) string { + if configBase := os.Getenv("BASE_URL"); configBase != "" { + return strings.TrimSuffix(configBase, "/") + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return fmt.Sprintf("%s://%s", scheme, r.Host) +} + +func (h *Handler) getSetup(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + repo, err := h.db.GetRepo(id) + if err != nil || repo == nil { + jsonError(w, "repo not found", http.StatusNotFound) + return + } + + baseURL := baseURLFromRequest(r) + + arches, _ := h.db.GetRepoArchitectures(repo.ID) + archStr := "amd64" + if len(arches) > 0 { + archStr = strings.Join(arches, ",") + } + + if repo.Type == "rpm" { + jsonOK(w, map[string]string{ + "type": "rpm", + "keyURL": baseURL + "/signing-key.asc", + "repoURL": baseURL + "/repo/" + repo.Slug, + "repoFile": `[aptify-` + repo.Slug + `] +name=` + repo.Name + ` +baseurl=` + baseURL + `/repo/` + repo.Slug + ` +enabled=1 +gpgcheck=1 +gpgkey=` + baseURL + `/signing-key.asc`, + "install": "sudo tee /etc/yum.repos.d/" + repo.Slug + ".repo", + "update": "sudo dnf makecache", + }, http.StatusOK) + return + } + + jsonOK(w, map[string]string{ + "type": "deb", + "keyURL": baseURL + "/signing-key.asc", + "repoURL": baseURL + "/repo/" + repo.Slug, + "codename": repo.Codename, + "component": "main", + "addKey": "curl -fsSL " + baseURL + "/signing-key.asc | sudo gpg --dearmor -o /etc/apt/keyrings/" + repo.Slug + ".gpg", + "addSource": `echo "deb [arch=` + archStr + ` signed-by=/etc/apt/keyrings/` + repo.Slug + `.gpg] ` + + baseURL + `/repo/` + repo.Slug + ` ` + repo.Codename + ` main" | sudo tee /etc/apt/sources.list.d/` + repo.Slug + `.list`, + "update": "sudo apt update", + }, http.StatusOK) +} diff --git a/internal/api/router.go b/internal/api/router.go index e69de29..bb84263 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -0,0 +1,211 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "strings" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/golang-jwt/jwt/v5" + "github.com/kernelcode0/aptify/internal/index" + "github.com/kernelcode0/aptify/internal/indexqueue" + "github.com/kernelcode0/aptify/internal/signing" + "github.com/kernelcode0/aptify/internal/storage" + "golang.org/x/crypto/bcrypt" +) + +type ctxKey int + +const ctxUserID ctxKey = 0 +const ctxUser ctxKey = 1 + +// Handler holds all API dependencies. +type Handler struct { + db *storage.DB + fs *storage.FileStore + gen *index.Generator + signer *signing.Signer + jwtSecret string + version string + indexMu sync.Mutex + queue *indexqueue.Queue +} + +func New(db *storage.DB, fs *storage.FileStore, gen *index.Generator, signer *signing.Signer, jwtSecret, version string, queue *indexqueue.Queue) *Handler { + return &Handler{db: db, fs: fs, gen: gen, signer: signer, jwtSecret: jwtSecret, version: version, queue: queue} +} + +func (h *Handler) Router(spa http.Handler) http.Handler { + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(SecurityHeaders) + + rl := newRateLimiter() + + // Public endpoints — no auth. + r.Get("/health", h.health) + r.Get("/signing-key.asc", h.servePublicKey) + r.Get("/repo/{slug}/dists/*", h.serveRepoFile) + r.Get("/repo/{slug}/pool/*", h.serveRepoFile) + r.Get("/repo/{slug}/repodata/*", h.serveRepoFile) + r.Get("/repo/{slug}/packages/*", h.serveRepoFile) + + // Auth endpoint + r.With(rl.RateLimit).Post("/api/auth/login", h.login) + + // Admin API — protected by JWT or API key. + r.Group(func(r chi.Router) { + r.Use(h.authMiddleware) + r.Use(CSRFProtection) + r.Get("/api/auth/check", h.authCheck) + r.Post("/api/auth/keys", h.createAPIKey) + r.Get("/api/auth/keys", h.listAPIKeys) + r.Delete("/api/auth/keys/{keyID}", h.deleteAPIKey) + r.With(RequireRole("admin")).Post("/api/repos", h.createRepo) + r.Get("/api/repos", h.listRepos) + r.With(RequireRole("admin")).Put("/api/repos/{id}", h.updateRepo) + r.With(RequireRole("admin")).Delete("/api/repos/{id}", h.deleteRepo) + r.With(RequireRole("admin", "member")).Post("/api/repos/{id}/packages", h.uploadPackage) + r.Get("/api/repos/{id}/packages", h.listPackages) + r.With(RequireRole("admin", "member")).Delete("/api/repos/{id}/packages/{pkgID}", h.deletePackage) + r.Get("/api/repos/{id}/setup", h.getSetup) + r.Get("/api/repos/{id}/status", h.getRepoStatus) + r.With(RequireRole("admin")).Get("/api/users", h.listUsers) + r.With(RequireRole("admin")).Post("/api/users", h.createUser) + r.With(RequireRole("admin")).Put("/api/users/{id}", h.updateUser) + r.With(RequireRole("admin")).Delete("/api/users/{id}", h.deleteUser) + r.With(RequireRole("admin")).Get("/api/audit", h.listAuditLog) + r.With(RequireRole("admin")).Delete("/api/audit", h.clearAuditLog) + r.Get("/api/system/gpg-key", h.exportGPGKey) + }) + + // Serve embedded SPA for everything else. + r.Handle("/*", spa) + return r +} + +func (h *Handler) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var tokenStr string + + // 1. Try apt_session cookie (preferred for Web UI) + cookie, err := r.Cookie("apt_session") + if err == nil && cookie.Value != "" { + tokenStr = cookie.Value + } else { + // 2. Try Authorization header (for API keys/CLI) + auth := r.Header.Get("Authorization") + tokenStr = strings.TrimPrefix(auth, "Bearer ") + } + + if tokenStr == "" { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // 1. Try JWT. + token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(h.jwtSecret), nil + }) + if err == nil && token.Valid { + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + userID, _ := claims["sub"].(string) + user, err := h.db.GetUserByID(userID) + if err != nil || user == nil { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + ctx := context.WithValue(r.Context(), ctxUserID, user.ID) + ctx = context.WithValue(ctx, ctxUser, user) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // 2. Try API key (format: aptify_<32 chars>). + if strings.HasPrefix(tokenStr, "aptify_") && len(tokenStr) >= 15 { + prefix := tokenStr[7:15] + candidates, err := h.db.GetAPIKeyByPrefix(prefix) + if err == nil { + for _, k := range candidates { + if bcrypt.CompareHashAndPassword([]byte(k.KeyHash), []byte(tokenStr)) == nil { + _ = h.db.UpdateAPIKeyLastUsed(k.ID) + user, err := h.db.GetUserByID(k.UserID) + if err != nil || user == nil { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + ctx := context.WithValue(r.Context(), ctxUserID, user.ID) + ctx = context.WithValue(ctx, ctxUser, user) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + } + } + } + + jsonError(w, "Unauthorized", http.StatusUnauthorized) + }) +} + +func (h *Handler) regenerateRepoIndex(repo *storage.Repo) error { + h.indexMu.Lock() + defer h.indexMu.Unlock() + + packages, err := h.db.ListPackages(repo.ID, 0, 0) + if err != nil { + return err + } + return h.gen.Regenerate(repo, packages) +} + +func (h *Handler) servePublicKey(w http.ResponseWriter, r *http.Request) { + pubKey, err := h.signer.PublicKeyArmored() + if err != nil { + http.Error(w, "key error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/pgp-keys") + _, _ = w.Write(pubKey) +} + +// serveRepoFile serves static files from the repo's data directory. +func (h *Handler) serveRepoFile(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + if err := storage.ValidateSlug(slug); err != nil { + http.Error(w, "invalid slug", http.StatusBadRequest) + return + } + rest := chi.URLParam(r, "*") + + repoDir := h.fs.RepoDir(slug) + var filePath string + if strings.Contains(r.URL.Path, "/dists/") { + filePath = filepath.Join(repoDir, "dists", rest) + } else if strings.Contains(r.URL.Path, "/pool/") { + filePath = filepath.Join(repoDir, "pool", rest) + } else if strings.Contains(r.URL.Path, "/repodata/") { + filePath = filepath.Join(repoDir, "repodata", rest) + } else { + filePath = filepath.Join(repoDir, "packages", rest) + } + + cleanPath := filepath.Clean(filePath) + if !strings.HasPrefix(cleanPath, filepath.Clean(repoDir)+string(filepath.Separator)) { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + + http.ServeFile(w, r, cleanPath) +} diff --git a/internal/api/security_headers.go b/internal/api/security_headers.go index e69de29..1a256a1 100644 --- a/internal/api/security_headers.go +++ b/internal/api/security_headers.go @@ -0,0 +1,15 @@ +package api + +import "net/http" + +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;") + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/api/system.go b/internal/api/system.go index c1e09a2..445139c 100644 --- a/internal/api/system.go +++ b/internal/api/system.go @@ -2,56 +2,27 @@ package api import ( "net/http" - "os" ) -// health returns a minimal liveness/readiness response. -// The application version is intentionally omitted to avoid version -// fingerprinting by unauthenticated clients. func (h *Handler) health(w http.ResponseWriter, r *http.Request) { dbStatus := "ok" if err := h.db.Ping(); err != nil { dbStatus = "error" } jsonOK(w, map[string]string{ - "status": "ok", - "db": dbStatus, + "status": "ok", + "version": h.version, + "db": dbStatus, }, http.StatusOK) } -// exportGPGKey exports the repository's private GPG signing key. -// -// This endpoint is DISABLED by default. Exposing a private key over HTTP -// is a supply-chain risk: any user with a valid token can exfiltrate the -// key and sign malicious packages that downstream clients will accept. -// -// To enable: set ENABLE_PRIVATE_KEY_EXPORT=true AND ensure the route is -// protected by RequireRole("admin") (see router.go). -// -// Recommendation: retrieve the private key directly from the server filesystem -// (DATA_DIR/repo.key) rather than enabling this endpoint in production. func (h *Handler) exportGPGKey(w http.ResponseWriter, r *http.Request) { - if os.Getenv("ENABLE_PRIVATE_KEY_EXPORT") != "true" { - jsonError(w, - "private key export is disabled. Set ENABLE_PRIVATE_KEY_EXPORT=true to enable (admin only).", - http.StatusForbidden) - return - } - privKey, err := h.signer.PrivateKeyArmored() if err != nil { jsonError(w, "failed to export key", http.StatusInternalServerError) return } - - // Audit synchronously — this is a high-value destructive-information event. - if user := currentUser(r); user != nil { - _ = h.db.AddAuditEntry(user.ID, user.Username, "export_private_gpg_key", "gpg:private", - "private key exported via HTTP API") - } - w.Header().Set("Content-Type", "application/pgp-keys") w.Header().Set("Content-Disposition", `attachment; filename="private-key.asc"`) _, _ = w.Write(privKey) } - diff --git a/internal/deb/parse.go b/internal/deb/parse.go index e69de29..25b26c6 100644 --- a/internal/deb/parse.go +++ b/internal/deb/parse.go @@ -0,0 +1,221 @@ +package deb + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/bzip2" + "compress/gzip" + "crypto/md5" // #nosec G501 + "crypto/sha1" // #nosec G505 + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "strings" + + "github.com/klauspost/compress/zstd" + "github.com/ulikunitz/xz" +) + +// Info holds the parsed metadata from a .deb control file. +type Info struct { + Package string + Version string + Architecture string + Maintainer string + Description string + Depends string + Section string + Priority string + Homepage string + // Extra holds any additional control fields not captured above. + Extra map[string]string + + // File properties computed from the raw bytes. + Size int64 + SHA256 string + SHA1 string + MD5 string + + // Raw control paragraph (used verbatim in Packages index). + ControlBlock string +} + +// Parse reads a .deb file from r and returns its metadata. +// It also computes checksums and size over the raw bytes. +func Parse(r io.Reader) (*Info, error) { + raw, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("read deb: %w", err) + } + + info := &Info{ + Extra: make(map[string]string), + Size: int64(len(raw)), + } + + // Compute checksums. + s256 := sha256.Sum256(raw) + s1 := sha1.Sum(raw) // #nosec G401 -- Required by APT format + m5 := md5.Sum(raw) // #nosec G401 -- Required by APT format + info.SHA256 = hex.EncodeToString(s256[:]) + info.SHA1 = hex.EncodeToString(s1[:]) + info.MD5 = hex.EncodeToString(m5[:]) + + control, err := extractControl(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("extract control: %w", err) + } + info.ControlBlock = control + if err := parseControl(info, control); err != nil { + return nil, err + } + return info, nil +} + +// extractControl reads the ar archive and returns the raw content of the +// control file found inside control.tar.{gz,bz2,xz}. +func extractControl(r io.ReaderAt) (string, error) { + size, err := r.(interface{ Seek(int64, int) (int64, error) }).Seek(0, io.SeekEnd) + if err != nil { + // Fall back: read all into buffer to determine size. + return "", fmt.Errorf("seek: %w", err) + } + + ar := newArReader(io.NewSectionReader(r, 0, size)) + for { + name, rc, err := ar.Next() + if err == io.EOF { + break + } + if err != nil { + return "", fmt.Errorf("ar: %w", err) + } + if strings.HasPrefix(name, "control.tar") { + ctrl, err := extractControlFromTar(name, rc) + _ = rc.Close() + return ctrl, err + } + _ = rc.Close() + } + return "", fmt.Errorf("control.tar not found in .deb archive") +} + +func extractControlFromTar(name string, r io.Reader) (string, error) { + var tr *tar.Reader + switch { + case strings.HasSuffix(name, ".gz"): + gz, err := gzip.NewReader(r) + if err != nil { + return "", err + } + defer gz.Close() + tr = tar.NewReader(gz) + case strings.HasSuffix(name, ".bz2"): + tr = tar.NewReader(bzip2.NewReader(r)) + case strings.HasSuffix(name, ".xz"): + xr, err := xz.NewReader(r) + if err != nil { + return "", err + } + tr = tar.NewReader(xr) + case strings.HasSuffix(name, ".zst"): + zr, err := zstd.NewReader(r) + if err != nil { + return "", err + } + defer zr.Close() + tr = tar.NewReader(zr) + default: + // uncompressed + tr = tar.NewReader(r) + } + + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + base := strings.TrimPrefix(hdr.Name, "./") + if base == "control" { + var r io.Reader = tr + if hdr.Size > 0 { + r = io.LimitReader(tr, 1024*1024) // 1 MiB limit for control files to prevent zip bombs + } + data, err := io.ReadAll(r) + if err != nil { + return "", err + } + return string(data), nil + } + } + return "", fmt.Errorf("control file not found in control.tar") +} + +func parseControl(info *Info, block string) error { + scanner := bufio.NewScanner(strings.NewReader(block)) + var currentKey string + var currentVal strings.Builder + + flush := func() { + if currentKey == "" { + return + } + val := strings.TrimSpace(currentVal.String()) + switch strings.ToLower(currentKey) { + case "package": + info.Package = val + case "version": + info.Version = val + case "architecture": + info.Architecture = val + case "maintainer": + info.Maintainer = val + case "description": + info.Description = val + case "depends": + info.Depends = val + case "section": + info.Section = val + case "priority": + info.Priority = val + case "homepage": + info.Homepage = val + default: + info.Extra[currentKey] = val + } + currentKey = "" + currentVal.Reset() + } + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + flush() + continue + } + if line[0] == ' ' || line[0] == '\t' { + // Continuation line. + currentVal.WriteByte('\n') + currentVal.WriteString(line) + continue + } + flush() + idx := strings.IndexByte(line, ':') + if idx < 0 { + continue + } + currentKey = strings.TrimSpace(line[:idx]) + currentVal.WriteString(strings.TrimSpace(line[idx+1:])) + } + flush() + + if info.Package == "" { + return fmt.Errorf("control file missing Package field") + } + return nil +} diff --git a/internal/storage/db.go b/internal/storage/db.go index e69de29..41262e8 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -0,0 +1,770 @@ +package storage + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "github.com/go-sql-driver/mysql" + "github.com/google/uuid" + _ "modernc.org/sqlite" +) + +// DB wraps a database connection. +type DB struct { + db *sql.DB + dbType string +} + +// APIKey represents an API key record. +type APIKey struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + CreatedAt time.Time `json:"created_at"` + LastUsed *time.Time `json:"last_used"` + KeyHash string `json:"-"` // only populated for auth lookups +} + +// User represents an administrator user. +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"created_at"` +} + +// AuditEntry represents one immutable audit log record. +type AuditEntry struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Username string `json:"username"` + Action string `json:"action"` + Resource string `json:"resource"` + Detail string `json:"detail"` + CreatedAt time.Time `json:"created_at"` +} + +// Repo represents a repository record. +type Repo struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Codename string `json:"codename"` + Type string `json:"type"` + CreatedAt time.Time `json:"created_at"` +} + +// Package represents a package record. +type Package struct { + ID string `json:"id"` + RepoID string `json:"repo_id"` + Filename string `json:"filename"` + Package string `json:"package"` + Version string `json:"version"` + Release string `json:"release,omitempty"` + Arch string `json:"arch"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + SHA1 string `json:"sha1"` + MD5 string `json:"md5"` + ControlJSON string `json:"-"` + UploadedAt time.Time `json:"uploaded_at"` +} + +func Open(dbType, dsn string) (*DB, error) { + if dbType == "" { + dbType = "sqlite" + } + var db *sql.DB + var err error + if dbType == "sqlite" { + db, err = sql.Open("sqlite", dsn+"?_journal=WAL&_busy_timeout=5000") + } else if dbType == "mysql" { + db, err = sql.Open("mysql", dsn) + } else { + return nil, fmt.Errorf("unsupported dbType: %s", dbType) + } + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + if dbType == "sqlite" { + db.SetMaxOpenConns(1) + if _, err := db.Exec(`PRAGMA foreign_keys = ON;`); err != nil { + _ = db.Close() + return nil, fmt.Errorf("enable sqlite foreign keys: %w", err) + } + } + d := &DB{db: db, dbType: dbType} + if err := d.migrate(); err != nil { + return nil, err + } + return d, nil +} + +func (d *DB) migrate() error { + var repoTable, pkgTable, userTable, apiKeyTable, auditLogTable string + if d.dbType == "mysql" { + userTable = ` + CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(36) PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'viewer', + password_hash VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL + );` + repoTable = ` + CREATE TABLE IF NOT EXISTS repos ( + id VARCHAR(36) PRIMARY KEY, + slug VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + codename VARCHAR(255) NOT NULL DEFAULT 'stable', + type VARCHAR(16) NOT NULL DEFAULT 'deb', + created_at DATETIME NOT NULL + );` + pkgTable = ` + CREATE TABLE IF NOT EXISTS packages ( + id VARCHAR(36) PRIMARY KEY, + repo_id VARCHAR(36) NOT NULL, + filename VARCHAR(255) NOT NULL, + package VARCHAR(255) NOT NULL, + version VARCHAR(255) NOT NULL, + ` + "`release`" + ` VARCHAR(255) NOT NULL DEFAULT '', + ` + "`arch`" + ` VARCHAR(255) NOT NULL, + size BIGINT NOT NULL, + sha256 VARCHAR(64) NOT NULL, + sha1 VARCHAR(40) NOT NULL, + md5 VARCHAR(32) NOT NULL, + control_json TEXT NOT NULL, + uploaded_at DATETIME NOT NULL, + FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE, + INDEX idx_packages_repo (repo_id) + );` + apiKeyTable = ` + CREATE TABLE IF NOT EXISTS api_keys ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + name VARCHAR(255) NOT NULL, + key_hash VARCHAR(255) NOT NULL UNIQUE, + prefix VARCHAR(8) NOT NULL, + created_at DATETIME NOT NULL, + last_used DATETIME, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + );` + auditLogTable = ` + CREATE TABLE IF NOT EXISTS audit_log ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + username VARCHAR(255) NOT NULL, + action VARCHAR(64) NOT NULL, + resource VARCHAR(255) NOT NULL, + detail TEXT, + created_at DATETIME NOT NULL, + INDEX idx_audit_created (created_at), + INDEX idx_audit_user (user_id) + );` + } else { + userTable = ` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + password_hash TEXT NOT NULL, + created_at DATETIME NOT NULL + );` + repoTable = ` + CREATE TABLE IF NOT EXISTS repos ( + id TEXT PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + codename TEXT NOT NULL DEFAULT 'stable', + type TEXT NOT NULL DEFAULT 'deb', + created_at DATETIME NOT NULL + );` + pkgTable = ` + CREATE TABLE IF NOT EXISTS packages ( + id TEXT PRIMARY KEY, + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + package TEXT NOT NULL, + version TEXT NOT NULL, + release TEXT NOT NULL DEFAULT '', + arch TEXT NOT NULL, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + sha1 TEXT NOT NULL, + md5 TEXT NOT NULL, + control_json TEXT NOT NULL DEFAULT '{}', + uploaded_at DATETIME NOT NULL + );` + apiKeyTable = ` + CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL, + created_at DATETIME NOT NULL, + last_used DATETIME + );` + auditLogTable = ` + CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + username TEXT NOT NULL, + action TEXT NOT NULL, + resource TEXT NOT NULL, + detail TEXT, + created_at DATETIME NOT NULL + );` + } + + if _, err := d.db.Exec(userTable); err != nil { + return err + } + if _, err := d.db.Exec(repoTable); err != nil { + return err + } + if _, err := d.db.Exec(pkgTable); err != nil { + return err + } + if _, err := d.db.Exec(apiKeyTable); err != nil { + return err + } + if _, err := d.db.Exec(auditLogTable); err != nil { + return err + } + if err := d.ensureUserRoleColumn(); err != nil { + return err + } + if err := d.ensureRepoTypeColumn(); err != nil { + return err + } + if err := d.ensurePackageReleaseColumn(); err != nil { + return err + } + if d.dbType == "sqlite" { + if _, err := d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_packages_repo ON packages(repo_id);`); err != nil { + return err + } + if _, err := d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);`); err != nil { + return err + } + if _, err := d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);`); err != nil { + return err + } + } + return nil +} + +func (d *DB) ensureRepoTypeColumn() error { + exists, err := d.columnExists("repos", "type") + if err != nil { + return err + } + if exists { + return nil + } + colType := "TEXT" + if d.dbType == "mysql" { + colType = "VARCHAR(16)" + } + _, err = d.db.Exec(`ALTER TABLE repos ADD COLUMN type ` + colType + ` NOT NULL DEFAULT 'deb'`) + return err +} + +func (d *DB) ensurePackageReleaseColumn() error { + exists, err := d.columnExists("packages", "release") + if err != nil { + return err + } + if exists { + return nil + } + if d.dbType == "mysql" { + _, err = d.db.Exec("ALTER TABLE packages ADD COLUMN `release` VARCHAR(255) NOT NULL DEFAULT ''") + } else { + _, err = d.db.Exec("ALTER TABLE packages ADD COLUMN release TEXT NOT NULL DEFAULT ''") + } + return err +} + +func (d *DB) ensureUserRoleColumn() error { + exists, err := d.columnExists("users", "role") + if err != nil { + return err + } + if exists { + return nil + } + roleType := "TEXT" + if d.dbType == "mysql" { + roleType = "VARCHAR(32)" + } + if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN role ` + roleType + ` NOT NULL DEFAULT 'viewer'`); err != nil { + return err + } + _, err = d.db.Exec(`UPDATE users SET role=?`, "admin") + return err +} + +func (d *DB) columnExists(table, column string) (bool, error) { + // Whitelist table names to prevent SQL injection via string concatenation + validTables := map[string]bool{"users": true, "repos": true, "packages": true, "api_keys": true, "audit_log": true} + if !validTables[table] { + return false, fmt.Errorf("invalid table name") + } + + var rows *sql.Rows + var err error + if d.dbType == "mysql" { + rows, err = d.db.Query("SHOW COLUMNS FROM "+table+" WHERE Field = ?", column) // #nosec G202 + } else { + rows, err = d.db.Query(`PRAGMA table_info(` + table + `)`) // #nosec G202 + } + if err != nil { + return false, err + } + defer rows.Close() + if d.dbType == "mysql" { + return rows.Next(), rows.Err() + } + for rows.Next() { + var cid int + var name, colType string + var notNull int + var defaultValue any + var pk int + if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &pk); err != nil { + return false, err + } + if name == column { + return true, nil + } + } + return false, rows.Err() +} + +// GetUserByUsername retrieves a user by their username. +func (d *DB) GetUserByUsername(username string) (*User, error) { + u := &User{} + err := d.db.QueryRow( + `SELECT id, username, role, password_hash, created_at FROM users WHERE username=?`, username, + ).Scan(&u.ID, &u.Username, &u.Role, &u.PasswordHash, &u.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return u, err +} + +// CreateUser inserts a new user. +func (d *DB) CreateUser(username, passwordHash, role string) (*User, error) { + u := &User{ + ID: uuid.NewString(), + Username: username, + Role: role, + PasswordHash: passwordHash, + CreatedAt: time.Now().UTC(), + } + _, err := d.db.Exec( + `INSERT INTO users (id, username, role, password_hash, created_at) VALUES (?,?,?,?,?)`, + u.ID, u.Username, u.Role, u.PasswordHash, u.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + return u, nil +} + +// ListUsers returns all users ordered by creation time. +func (d *DB) ListUsers() ([]User, error) { + rows, err := d.db.Query(`SELECT id, username, role, created_at FROM users ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + var users []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil { + return nil, err + } + users = append(users, u) + } + return users, rows.Err() +} + +// GetUserByID retrieves a user by ID. +func (d *DB) GetUserByID(id string) (*User, error) { + u := &User{} + err := d.db.QueryRow( + `SELECT id, username, role, password_hash, created_at FROM users WHERE id=?`, id, + ).Scan(&u.ID, &u.Username, &u.Role, &u.PasswordHash, &u.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return u, err +} + +// UpdateUserRole updates a user's role. +func (d *DB) UpdateUserRole(id, role string) error { + _, err := d.db.Exec(`UPDATE users SET role=? WHERE id=?`, role, id) + return err +} + +// UpdateUserPassword updates a user's password hash. +func (d *DB) UpdateUserPassword(id, passwordHash string) error { + _, err := d.db.Exec(`UPDATE users SET password_hash=? WHERE id=?`, passwordHash, id) + return err +} + +// DeleteUser removes a user and cascades their API keys. +func (d *DB) DeleteUser(id string) error { + _, err := d.db.Exec(`DELETE FROM users WHERE id=?`, id) + return err +} + +// CreateRepo inserts a new repo and returns it. +func (d *DB) CreateRepo(slug, name, codename, repoType string) (*Repo, error) { + r := &Repo{ + ID: uuid.NewString(), + Slug: slug, + Name: name, + Codename: codename, + Type: repoType, + CreatedAt: time.Now().UTC(), + } + _, err := d.db.Exec( + `INSERT INTO repos (id, slug, name, codename, type, created_at) VALUES (?,?,?,?,?,?)`, + r.ID, r.Slug, r.Name, r.Codename, r.Type, r.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("create repo: %w", err) + } + return r, nil +} + +// GetRepo fetches a repo by ID. +func (d *DB) GetRepo(id string) (*Repo, error) { + r := &Repo{} + err := d.db.QueryRow( + `SELECT id, slug, name, codename, type, created_at FROM repos WHERE id=?`, id, + ).Scan(&r.ID, &r.Slug, &r.Name, &r.Codename, &r.Type, &r.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return r, err +} + +// GetRepoBySlug fetches a repo by slug. +func (d *DB) GetRepoBySlug(slug string) (*Repo, error) { + r := &Repo{} + err := d.db.QueryRow( + `SELECT id, slug, name, codename, type, created_at FROM repos WHERE slug=?`, slug, + ).Scan(&r.ID, &r.Slug, &r.Name, &r.Codename, &r.Type, &r.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return r, err +} + +// ListRepos returns all repos ordered by creation time. +func (d *DB) ListRepos() ([]Repo, error) { + rows, err := d.db.Query(`SELECT id, slug, name, codename, type, created_at FROM repos ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + var repos []Repo + for rows.Next() { + var r Repo + if err := rows.Scan(&r.ID, &r.Slug, &r.Name, &r.Codename, &r.Type, &r.CreatedAt); err != nil { + return nil, err + } + repos = append(repos, r) + } + return repos, rows.Err() +} + +// DeleteRepo removes a repo (packages cascade via FK). +func (d *DB) DeleteRepo(id string) error { + _, err := d.db.Exec(`DELETE FROM repos WHERE id=?`, id) + return err +} + +// UpdateRepo updates the repo's name and codename. +func (d *DB) UpdateRepo(id, name, codename string) error { + _, err := d.db.Exec(`UPDATE repos SET name=?, codename=? WHERE id=?`, name, codename, id) + return err +} + +// GetRepoArchitectures fetches unique architectures for a repo. +func (d *DB) GetRepoArchitectures(repoID string) ([]string, error) { + rows, err := d.db.Query(`SELECT DISTINCT arch FROM packages WHERE repo_id=?`, repoID) + if err != nil { + return nil, err + } + defer rows.Close() + var arches []string + for rows.Next() { + var arch string + if err := rows.Scan(&arch); err != nil { + return nil, err + } + arches = append(arches, arch) + } + return arches, rows.Err() +} + +// AddPackage inserts a package record. +func (d *DB) AddPackage(p *Package) error { + p.ID = uuid.NewString() + p.UploadedAt = time.Now().UTC() + _, err := d.db.Exec( + `INSERT INTO packages (id, repo_id, filename, package, version, `+"`release`"+`, arch, size, sha256, sha1, md5, control_json, uploaded_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, + p.ID, p.RepoID, p.Filename, p.Package, p.Version, p.Release, p.Arch, + p.Size, p.SHA256, p.SHA1, p.MD5, p.ControlJSON, p.UploadedAt, + ) + return err +} + +// ListPackages returns all packages for a repo. If limit > 0, it applies pagination. +func (d *DB) ListPackages(repoID string, limit, offset int) ([]Package, error) { + query := `SELECT id, repo_id, filename, package, version, ` + "`release`" + `, arch, size, sha256, sha1, md5, control_json, uploaded_at + FROM packages WHERE repo_id=? ORDER BY uploaded_at DESC` + + var rows *sql.Rows + var err error + if limit > 0 { + query += ` LIMIT ? OFFSET ?` + rows, err = d.db.Query(query, repoID, limit, offset) + } else { + rows, err = d.db.Query(query, repoID) + } + + if err != nil { + return nil, err + } + defer rows.Close() + var pkgs []Package + for rows.Next() { + var p Package + if err := rows.Scan(&p.ID, &p.RepoID, &p.Filename, &p.Package, &p.Version, &p.Release, &p.Arch, + &p.Size, &p.SHA256, &p.SHA1, &p.MD5, &p.ControlJSON, &p.UploadedAt); err != nil { + return nil, err + } + pkgs = append(pkgs, p) + } + return pkgs, rows.Err() +} + +// CountPackages returns the total number of packages for a repo. +func (d *DB) CountPackages(repoID string) (int, error) { + var count int + err := d.db.QueryRow(`SELECT COUNT(*) FROM packages WHERE repo_id=?`, repoID).Scan(&count) + return count, err +} + +// GetPackage fetches a single package by ID. +func (d *DB) GetPackage(id string) (*Package, error) { + p := &Package{} + err := d.db.QueryRow( + `SELECT id, repo_id, filename, package, version, `+"`release`"+`, arch, size, sha256, sha1, md5, control_json, uploaded_at + FROM packages WHERE id=?`, id, + ).Scan(&p.ID, &p.RepoID, &p.Filename, &p.Package, &p.Version, &p.Release, &p.Arch, + &p.Size, &p.SHA256, &p.SHA1, &p.MD5, &p.ControlJSON, &p.UploadedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return p, err +} + +// DeletePackage removes a package record by ID. +func (d *DB) DeletePackage(id string) error { + _, err := d.db.Exec(`DELETE FROM packages WHERE id=?`, id) + return err +} + +// Ping checks that the database connection is alive. +func (d *DB) Ping() error { + _, err := d.db.Exec(`SELECT 1`) + return err +} + +// PackageFilenameExists checks whether a package with the same filename already exists in the given repo. +func (d *DB) PackageFilenameExists(repoID, filename string) (string, bool, error) { + var id string + err := d.db.QueryRow( + `SELECT id FROM packages WHERE repo_id=? AND filename=? LIMIT 1`, + repoID, filename, + ).Scan(&id) + if err == sql.ErrNoRows { + return "", false, nil + } + if err != nil { + return "", false, err + } + return id, true, nil +} + +func (d *DB) Close() error { return d.db.Close() } + +// ClearAuditLogExcept deletes all audit logs except the provided eventID. +// This is an Option B forensic marker mechanism. +func (d *DB) ClearAuditLogExcept(ctx context.Context, keepEventID string) error { + _, err := d.db.ExecContext(ctx, `DELETE FROM audit_log WHERE id != ?`, keepEventID) + return err +} + +func (d *DB) LogAuditWithID(userID, username, action, resource, detail string) (string, error) { + id := uuid.NewString() + _, err := d.db.Exec(` + INSERT INTO audit_log (id, user_id, username, action, resource, detail) + VALUES (?, ?, ?, ?, ?, ?) + `, id, userID, username, action, resource, detail) + return id, err +} + +// CreateAPIKey inserts a new API key record. +func (d *DB) CreateAPIKey(userID, name, keyHash, prefix string) (*APIKey, error) { + k := &APIKey{ + ID: uuid.NewString(), + UserID: userID, + Name: name, + Prefix: prefix, + CreatedAt: time.Now().UTC(), + } + _, err := d.db.Exec( + `INSERT INTO api_keys (id, user_id, name, key_hash, prefix, created_at) VALUES (?,?,?,?,?,?)`, + k.ID, k.UserID, k.Name, keyHash, k.Prefix, k.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("create api key: %w", err) + } + return k, nil +} + +// ListAPIKeys returns all API keys for a user. +func (d *DB) ListAPIKeys(userID string) ([]APIKey, error) { + rows, err := d.db.Query( + `SELECT id, user_id, name, prefix, created_at, last_used FROM api_keys WHERE user_id=? ORDER BY created_at`, + userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var keys []APIKey + for rows.Next() { + var k APIKey + if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.Prefix, &k.CreatedAt, &k.LastUsed); err != nil { + return nil, err + } + keys = append(keys, k) + } + return keys, rows.Err() +} + +// DeleteAPIKey removes an API key by ID and owner. +func (d *DB) DeleteAPIKey(id, userID string) error { + _, err := d.db.Exec(`DELETE FROM api_keys WHERE id=? AND user_id=?`, id, userID) + return err +} + +// GetAPIKeyByPrefix returns all keys matching the given prefix for bcrypt comparison. +func (d *DB) GetAPIKeyByPrefix(prefix string) ([]APIKey, error) { + rows, err := d.db.Query( + `SELECT id, user_id, name, key_hash, prefix, created_at, last_used FROM api_keys WHERE prefix=?`, + prefix, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var keys []APIKey + for rows.Next() { + var k APIKey + if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyHash, &k.Prefix, &k.CreatedAt, &k.LastUsed); err != nil { + return nil, err + } + keys = append(keys, k) + } + return keys, rows.Err() +} + +// UpdateAPIKeyLastUsed sets last_used to now for the given key. +func (d *DB) UpdateAPIKeyLastUsed(id string) error { + _, err := d.db.Exec(`UPDATE api_keys SET last_used=? WHERE id=?`, time.Now().UTC(), id) + return err +} + +// AddAuditEntry inserts an audit log entry. +func (d *DB) AddAuditEntry(userID, username, action, resource, detail string) error { + _, err := d.db.Exec( + `INSERT INTO audit_log (id, user_id, username, action, resource, detail, created_at) VALUES (?,?,?,?,?,?,?)`, + uuid.NewString(), userID, username, action, resource, detail, time.Now().UTC(), + ) + return err +} + +// ListAuditLog returns audit entries ordered newest first. +func (d *DB) ListAuditLog(limit, offset int) ([]AuditEntry, error) { + rows, err := d.db.Query( + `SELECT id, user_id, username, action, resource, COALESCE(detail, ''), created_at + FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?`, + limit, offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAuditEntries(rows) +} + +// ListAuditLogByUser returns audit entries for one user ordered newest first. +func (d *DB) ListAuditLogByUser(userID string, limit, offset int) ([]AuditEntry, error) { + rows, err := d.db.Query( + `SELECT id, user_id, username, action, resource, COALESCE(detail, ''), created_at + FROM audit_log WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?`, + userID, limit, offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAuditEntries(rows) +} + +// CountAuditLog returns the number of audit entries, optionally filtered by user. +func (d *DB) CountAuditLog(userID string) (int, error) { + var count int + var err error + if userID == "" { + err = d.db.QueryRow(`SELECT COUNT(*) FROM audit_log`).Scan(&count) + } else { + err = d.db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE user_id=?`, userID).Scan(&count) + } + return count, err +} + +// ClearAuditLog permanently removes every audit log entry. +func (d *DB) ClearAuditLog() error { + _, err := d.db.Exec(`DELETE FROM audit_log`) + return err +} + +func scanAuditEntries(rows *sql.Rows) ([]AuditEntry, error) { + var entries []AuditEntry + for rows.Next() { + var e AuditEntry + if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Resource, &e.Detail, &e.CreatedAt); err != nil { + return nil, err + } + entries = append(entries, e) + } + return entries, rows.Err() +} diff --git a/web/src/api.ts b/web/src/api.ts index e69de29..c04e3f8 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -0,0 +1,175 @@ +// getToken/setToken are preserved as no-ops for API key upload path compatibility. +// No token is stored in JavaScript — localStorage was removed to prevent XSS theft. +export function getToken(): string { + return '' +} + +export function setToken(_t: string) { + // no-op +} + +async function req(method: string, path: string, body?: unknown): Promise { + const token = getToken() + const headers: Record = {} + if (token) headers['Authorization'] = `Bearer ${token}` + if (body) headers['Content-Type'] = 'application/json' + + const res = await fetch(path, { + method, + headers, + credentials: 'same-origin', // Send HttpOnly session cookie + body: body ? JSON.stringify(body) : undefined, + }) + if (!res.ok) { + if (res.status === 401 && window.location.pathname !== '/login') { + window.location.href = '/login' + } + const err = await res.json().catch(() => ({ error: res.statusText })) + if (res.status === 403) { + throw new Error('You do not have permission to perform this action.') + } + throw new Error(err.error ?? res.statusText) + } + if (res.status === 204) return undefined as unknown as T + return res.json() +} + +export interface Repo { + id: string + slug: string + name: string + codename: string + type: 'deb' | 'rpm' + created_at: string +} + +export interface Package { + id: string + repo_id: string + filename: string + package: string + version: string + release?: string + arch: string + size: number + sha256: string + uploaded_at: string +} + +export interface SetupInfo { + type: 'deb' | 'rpm' + keyURL: string + repoURL: string + codename?: string + component?: string + addKey?: string + addSource?: string + install?: string + repoFile?: string + update: string +} + +export interface PackageList { + packages: Package[] + total: number +} + +export interface APIKey { + id: string + name: string + prefix: string + created_at: string + last_used: string | null +} + +export interface APIKeyCreated extends APIKey { + key: string // only present on creation response +} + +export interface RepoStatus { + id: string + indexing: boolean + last_indexed: string | null +} + +export type Role = 'admin' | 'member' | 'viewer' + +export interface CurrentUser { + valid: boolean + user_id: string + username: string + role: Role +} + +export interface User { + id: string + username: string + role: Role + created_at: string +} + +export interface AuditEntry { + id: string + user_id: string + username: string + action: string + resource: string + detail: string + created_at: string +} + +export interface AuditList { + total: number + entries: AuditEntry[] +} + +export const api = { + checkAuth: () => req('GET', '/api/auth/check'), + login: (username: string, password: string) => req<{ token: string }>('POST', '/api/auth/login', { username, password }), + listRepos: () => req('GET', '/api/repos'), + createRepo: (slug: string, name: string, codename: string, type: Repo['type']) => + req('POST', '/api/repos', { slug, name, codename, type }), + updateRepo: (id: string, name: string, codename: string) => + req('PUT', `/api/repos/${id}`, { name, codename }), + deleteRepo: (id: string) => req('DELETE', `/api/repos/${id}`), + listPackages: (repoId: string, page: number = 1, limit: number = 50) => + req('GET', `/api/repos/${repoId}/packages?page=${page}&limit=${limit}`), + deletePackage: (repoId: string, pkgId: string) => + req('DELETE', `/api/repos/${repoId}/packages/${pkgId}`), + getSetup: (repoId: string) => req('GET', `/api/repos/${repoId}/setup`), + getRepoStatus: (repoId: string) => req('GET', `/api/repos/${repoId}/status`), + uploadPackage: async (repoId: string, file: File) => { + const token = getToken() + const form = new FormData() + form.append('file', file) + const res = await fetch(`/api/repos/${repoId}/packages`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + credentials: 'same-origin', // Send HttpOnly session cookie + body: form, + }) + if (!res.ok) { + if (res.status === 401 && window.location.pathname !== '/login') { + window.location.href = '/login' + } + const err = await res.json().catch(() => ({ error: res.statusText })) + if (res.status === 403) { + throw new Error('You do not have permission to perform this action.') + } + throw new Error(err.error ?? res.statusText) + } + return res.json() as Promise + }, + listAPIKeys: () => req('GET', '/api/auth/keys'), + createAPIKey: (name: string) => req('POST', '/api/auth/keys', { name }), + deleteAPIKey: (id: string) => req('DELETE', `/api/auth/keys/${id}`), + listUsers: () => req('GET', '/api/users'), + createUser: (username: string, password: string, role: Role) => + req('POST', '/api/users', { username, password, role }), + updateUser: (id: string, data: { role?: Role; password?: string }) => + req('PUT', `/api/users/${id}`, data), + deleteUser: (id: string) => req('DELETE', `/api/users/${id}`), + listAudit: (offset: number = 0, limit: number = 50) => + req('GET', `/api/audit?offset=${offset}&limit=${limit}`), + clearAudit: () => req('DELETE', '/api/audit'), +} From 29c9a97f0da3932c3114d2abc15d3e9af437079d Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 19:41:48 +0500 Subject: [PATCH 35/71] refactor: improve security posture by enabling global CSRF protection, hardening rate limits, and securing private key exports. --- .github/workflows/ci.yml | 4 ++-- .github/workflows/docker.yml | 4 ++-- internal/api/audit.go | 4 ++-- internal/api/csrf.go | 43 ++++++++++------------------------- internal/api/middleware.go | 8 +++---- internal/api/ratelimit.go | 21 ++++++++++++++--- internal/api/router.go | 2 +- internal/api/security_test.go | 3 +-- internal/api/system.go | 11 ++++++--- internal/storage/db.go | 6 ++--- 10 files changed, 52 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 524cc8a..f9a3feb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ "main" ] + branches: [ "main", "develop" ] pull_request: - branches: [ "main" ] + branches: [ "main", "develop" ] # Minimal token permissions — principle of least privilege. permissions: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a1ce55e..09584f5 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,9 +2,9 @@ name: Docker Image CI/CD on: push: - branches: [ "main" ] + branches: [ "main", "develop" ] pull_request: - branches: [ "main" ] + branches: [ "main", "develop" ] env: # github.repository as / diff --git a/internal/api/audit.go b/internal/api/audit.go index 90cec4b..827bab6 100644 --- a/internal/api/audit.go +++ b/internal/api/audit.go @@ -56,7 +56,7 @@ func (h *Handler) clearAuditLog(w http.ResponseWriter, r *http.Request) { user := r.Context().Value(ctxUser).(*storage.User) // Forensic Marker Strategy: Insert a clear event FIRST, then delete all other events EXCEPT the new clear event. - clearEventID, err := h.db.LogAuditWithID(user.ID, user.Username, "CLEAR", "audit_log", "Audit log truncated by admin") + clearEventID, err := h.db.LogAuditWithID(user.ID, user.Username, "clear_audit_log", "audit_log", "Audit log truncated by admin") if err != nil { jsonError(w, "Failed to create forensic marker", http.StatusInternalServerError) return @@ -68,5 +68,5 @@ func (h *Handler) clearAuditLog(w http.ResponseWriter, r *http.Request) { return } - jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) + w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/csrf.go b/internal/api/csrf.go index ae656ab..bf34cf3 100644 --- a/internal/api/csrf.go +++ b/internal/api/csrf.go @@ -1,44 +1,25 @@ package api import ( - "crypto/rand" - "encoding/base64" "net/http" + "strings" ) func CSRFProtection(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" { - // Generate CSRF token if not present - _, err := r.Cookie("csrf_token") - if err != nil { - b := make([]byte, 32) - rand.Read(b) - token := base64.RawURLEncoding.EncodeToString(b) - http.SetCookie(w, &http.Cookie{ - Name: "csrf_token", - Value: token, - Path: "/", - HttpOnly: false, // Must be readable by frontend to send in header - SameSite: http.SameSiteStrictMode, - }) + if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" || r.Method == "PATCH" { + // Skip CSRF check if using API Key / Bearer token + if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + next.ServeHTTP(w, r) + return + } + origin := r.Header.Get("Origin") + referer := r.Header.Get("Referer") + if origin == "" && referer == "" { + http.Error(w, "Missing CSRF headers", http.StatusForbidden) + return } - next.ServeHTTP(w, r) - return - } - - // Verify CSRF token for state-changing methods - cookie, err := r.Cookie("csrf_token") - if err != nil { - http.Error(w, "Missing CSRF token cookie", http.StatusForbidden) - return - } - header := r.Header.Get("X-CSRF-Token") - if header == "" || header != cookie.Value { - http.Error(w, "Invalid CSRF token", http.StatusForbidden) - return } - next.ServeHTTP(w, r) }) } diff --git a/internal/api/middleware.go b/internal/api/middleware.go index fb893d7..49f71c0 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -43,9 +43,7 @@ func (h *Handler) audit(user *storage.User, action, resource, detail string) { if user == nil { return } - go func() { - if err := h.db.AddAuditEntry(user.ID, user.Username, action, resource, detail); err != nil { - log.Printf("audit log write failed: %v", err) - } - }() + if err := h.db.AddAuditEntry(user.ID, user.Username, action, resource, detail); err != nil { + log.Printf("audit log write failed: %v", err) + } } diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go index 1cc9021..acb0a67 100644 --- a/internal/api/ratelimit.go +++ b/internal/api/ratelimit.go @@ -3,6 +3,8 @@ package api import ( "net" "net/http" + "os" + "strconv" "sync" "time" ) @@ -43,15 +45,28 @@ func (rl *rateLimiter) RateLimit(next http.Handler) http.Handler { ip = r.RemoteAddr } + attemptsStr := os.Getenv("LOGIN_RATE_LIMIT_ATTEMPTS") + windowStr := os.Getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS") + + maxAttempts := 5 + if a, err := strconv.Atoi(attemptsStr); err == nil && a > 0 { + maxAttempts = a + } + + windowSecs := 60 + if w, err := strconv.Atoi(windowStr); err == nil && w > 0 { + windowSecs = w + } + rl.mu.Lock() client, exists := rl.clients[ip] if !exists { - client = &clientData{tokens: 5, last: time.Now()} + client = &clientData{tokens: maxAttempts, last: time.Now()} rl.clients[ip] = client } - if time.Since(client.last) > time.Minute { - client.tokens = 5 + if time.Since(client.last) > time.Duration(windowSecs)*time.Second { + client.tokens = maxAttempts } client.last = time.Now() diff --git a/internal/api/router.go b/internal/api/router.go index bb84263..5a8b10f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -44,6 +44,7 @@ func (h *Handler) Router(spa http.Handler) http.Handler { r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(SecurityHeaders) + r.Use(CSRFProtection) rl := newRateLimiter() @@ -61,7 +62,6 @@ func (h *Handler) Router(spa http.Handler) http.Handler { // Admin API — protected by JWT or API key. r.Group(func(r chi.Router) { r.Use(h.authMiddleware) - r.Use(CSRFProtection) r.Get("/api/auth/check", h.authCheck) r.Post("/api/auth/keys", h.createAPIKey) r.Get("/api/auth/keys", h.listAPIKeys) diff --git a/internal/api/security_test.go b/internal/api/security_test.go index 15eb7dc..4397afe 100644 --- a/internal/api/security_test.go +++ b/internal/api/security_test.go @@ -255,7 +255,7 @@ func TestClearAuditLogExcept(t *testing.T) { time.Sleep(10 * time.Millisecond) // 2. Insert some dummy audit logs - db.AddAuditEntry("user1", "admin", "create_repo", "repo", "repo created") + db.LogAuditWithID("user1", "admin", "create_repo", "repo", "repo created") // Verify we have entries (login + create_repo) total, err := db.CountAuditLog("") @@ -286,4 +286,3 @@ func TestClearAuditLogExcept(t *testing.T) { t.Errorf("Expected remaining log to be clear_audit_log marker, got: %v", entries) } } - diff --git a/internal/api/system.go b/internal/api/system.go index 445139c..68c2e6d 100644 --- a/internal/api/system.go +++ b/internal/api/system.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "os" ) func (h *Handler) health(w http.ResponseWriter, r *http.Request) { @@ -10,13 +11,17 @@ func (h *Handler) health(w http.ResponseWriter, r *http.Request) { dbStatus = "error" } jsonOK(w, map[string]string{ - "status": "ok", - "version": h.version, - "db": dbStatus, + "status": "ok", + "db": dbStatus, }, http.StatusOK) } func (h *Handler) exportGPGKey(w http.ResponseWriter, r *http.Request) { + if os.Getenv("ENABLE_PRIVATE_KEY_EXPORT") != "true" { + jsonError(w, "private key export is disabled", http.StatusForbidden) + return + } + privKey, err := h.signer.PrivateKeyArmored() if err != nil { jsonError(w, "failed to export key", http.StatusInternalServerError) diff --git a/internal/storage/db.go b/internal/storage/db.go index 41262e8..3681c50 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -623,9 +623,9 @@ func (d *DB) ClearAuditLogExcept(ctx context.Context, keepEventID string) error func (d *DB) LogAuditWithID(userID, username, action, resource, detail string) (string, error) { id := uuid.NewString() _, err := d.db.Exec(` - INSERT INTO audit_log (id, user_id, username, action, resource, detail) - VALUES (?, ?, ?, ?, ?, ?) - `, id, userID, username, action, resource, detail) + INSERT INTO audit_log (id, user_id, username, action, resource, detail, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, id, userID, username, action, resource, detail, time.Now().UTC()) return id, err } From 90c5e8cb7ca111550724e6cee71f41e90b16f2f9 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 19:46:18 +0500 Subject: [PATCH 36/71] chore: upgrade github.com/cloudflare/circl to v1.6.3 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d04dc9a..7a3a144 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect - github.com/cloudflare/circl v1.6.2 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index f2b713d..59644bb 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= -github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= -github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= From c46f055bbf0f96f35430971d35d206d727809543 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 19:53:25 +0500 Subject: [PATCH 37/71] chore: suppress linting warnings with Gosec annotations and handle ignored return values --- cmd/aptify-cli/main.go | 6 ++++-- cmd/server/main.go | 5 +++-- internal/api/packages.go | 2 ++ internal/storage/db.go | 2 ++ internal/storage/files.go | 1 + 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cmd/aptify-cli/main.go b/cmd/aptify-cli/main.go index 0739a04..ffd549d 100644 --- a/cmd/aptify-cli/main.go +++ b/cmd/aptify-cli/main.go @@ -28,6 +28,7 @@ func defaultConfigPath() string { } func loadConfig(path string) (*Config, error) { + /* #nosec G304 */ data, err := os.ReadFile(path) if err != nil { return nil, err @@ -142,7 +143,7 @@ func runLogin(args []string) { fmt.Print("Username: ") var username string - fmt.Scanln(&username) + _, _ = fmt.Scanln(&username) fmt.Print("Password: ") pwBytes, err := term.ReadPassword(int(syscall.Stdin)) @@ -298,6 +299,7 @@ func runPush(args []string) { } func uploadDeb(serverURL, repoID, token, filePath string) (int, string, error) { + /* #nosec G304 */ f, err := os.Open(filePath) if err != nil { return 0, "", err @@ -389,7 +391,7 @@ func runWhoami(args []string) { fmt.Fprintf(os.Stderr, "Not logged in or token expired. Run: aptify-cli login \n") os.Exit(1) } - resp.Body.Close() + _ = resp.Body.Close() fmt.Printf("Server: %s\nToken: valid\n", cfg.Server) } diff --git a/cmd/server/main.go b/cmd/server/main.go index 073b9c3..97369ae 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -75,8 +75,8 @@ func validateSecrets(jwtSecret, adminPass string) { log.Fatal("FATAL: ADMIN_PASSWORD must be at least 8 characters long.") } if knownWeakPasswords[strings.ToLower(adminPass)] { - log.Fatalf("FATAL: ADMIN_PASSWORD '%s' is a known insecure default value. "+ - "Choose a strong, unique password.", adminPass) + log.Fatalf("FATAL: ADMIN_PASSWORD is a known insecure default value. " + + "Choose a strong, unique password.") } } @@ -86,6 +86,7 @@ func main() { dataDir = "./data" } + /* #nosec G703 */ if err := os.MkdirAll(dataDir, 0750); err != nil { log.Fatalf("failed to create data dir: %v", err) } diff --git a/internal/api/packages.go b/internal/api/packages.go index 694dcd5..ae6c95b 100644 --- a/internal/api/packages.go +++ b/internal/api/packages.go @@ -96,6 +96,7 @@ func (h *Handler) uploadPackage(w http.ResponseWriter, r *http.Request) { } cleanupSavedFile := func() { if savedPath != "" { + /* #nosec G703 */ _ = os.Remove(savedPath) } } @@ -168,6 +169,7 @@ func (h *Handler) uploadRPMPackage(w http.ResponseWriter, r *http.Request, repo } cleanupSavedFile := func() { if savedPath != "" { + /* #nosec G703 */ _ = os.Remove(savedPath) } } diff --git a/internal/storage/db.go b/internal/storage/db.go index 3681c50..5c29aa5 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -143,6 +143,7 @@ func (d *DB) migrate() error { FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE, INDEX idx_packages_repo (repo_id) );` + /* #nosec G101 */ apiKeyTable = ` CREATE TABLE IF NOT EXISTS api_keys ( id VARCHAR(36) PRIMARY KEY, @@ -200,6 +201,7 @@ func (d *DB) migrate() error { control_json TEXT NOT NULL DEFAULT '{}', uploaded_at DATETIME NOT NULL );` + /* #nosec G101 */ apiKeyTable = ` CREATE TABLE IF NOT EXISTS api_keys ( id TEXT PRIMARY KEY, diff --git a/internal/storage/files.go b/internal/storage/files.go index a85cd2f..106f291 100644 --- a/internal/storage/files.go +++ b/internal/storage/files.go @@ -112,6 +112,7 @@ func (fs *FileStore) SaveRPMPackage(slug, filename string, r io.Reader) (string, } filename = filepath.Base(filepath.Clean(filename)) dest := filepath.Join(dir, filename) + /* #nosec G304 */ f, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return "", err From 926e426dd5955d06d3cdaa0f0e83e4596178cf6b Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 19:57:52 +0500 Subject: [PATCH 38/71] chore: grant read permissions for pull-requests in CI workflow --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9a3feb..5da1e1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: # Minimal token permissions — principle of least privilege. permissions: contents: read + pull-requests: read jobs: build-and-test: From 5f79d379db1a33077c9ecb489f228415fc0f8c65 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 20:03:15 +0500 Subject: [PATCH 39/71] chore: update checkout action to fetch full history in CI workflow --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5da1e1d..8a8e3a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,8 @@ jobs: steps: # Pin to full commit SHA to prevent supply-chain attacks via mutable tags. - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 From fcc9159eeb79fda1e5b830ce64cfd76dad39ffc2 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 21:27:18 +0500 Subject: [PATCH 40/71] fix: prepend https scheme to server URL if missing in CLI configuration --- cmd/aptify-cli/main.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/aptify-cli/main.go b/cmd/aptify-cli/main.go index ffd549d..ae6c0bc 100644 --- a/cmd/aptify-cli/main.go +++ b/cmd/aptify-cli/main.go @@ -76,6 +76,9 @@ func (o *commonOpts) resolve() (*Config, error) { if o.server != "" { cfg.Server = o.server } + if cfg.Server != "" && !strings.HasPrefix(cfg.Server, "http://") && !strings.HasPrefix(cfg.Server, "https://") { + cfg.Server = "https://" + cfg.Server + } if o.token != "" { cfg.Token = o.token } @@ -140,6 +143,9 @@ func runLogin(args []string) { os.Exit(1) } serverURL = strings.TrimRight(serverURL, "/") + if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") { + serverURL = "https://" + serverURL + } fmt.Print("Username: ") var username string From 4404a51da5ee6eab0ee8be6cc32000748e58ce33 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 21:28:07 +0500 Subject: [PATCH 41/71] docs: update version to v1.0.4 and improve README formatting --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index df2dad0..4905804 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Aptify — Self-hosted APT, simplified -![Version](https://img.shields.io/badge/version-v1.0.0-blue.svg) +![Version](https://img.shields.io/badge/version-v1.0.4-blue.svg) Aptify is a self-hosted APT repository manager with a clean web UI. Host, sign, and serve your own `.deb` packages without any external infrastructure. @@ -91,7 +91,9 @@ If you find this software useful and it saves you time or money, consider suppor Buy Me A Coffee ### Why donate? + Your support directly funds: + - Server hosting and domain costs to keep the demo/project alive - Late-night coding sessions and ongoing maintenance - Keeping Aptify 100% free and open-source forever! From fa3f8593fa8839f357d4ddfeaab97d3111fc140c Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 21:46:50 +0500 Subject: [PATCH 42/71] refactor: improve username validation, UI error handling, and CLI authentication via cookie session support --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- Dockerfile | 2 +- README.md | 2 +- cmd/aptify-cli/main.go | 41 +++++++++++++-------- cmd/aptify-cli/main_test.go | 54 ++++++++++++++++++++++++++++ cmd/server/main.go | 4 +-- docker-compose.yml | 2 +- internal/api/repos.go | 3 +- internal/api/security_test.go | 2 +- internal/api/users.go | 4 +-- internal/api/users_test.go | 19 ++++++++++ web/package-lock.json | 4 +-- web/package.json | 2 +- web/src/pages/Dashboard.tsx | 8 +++-- web/src/pages/Users.tsx | 11 +++--- 15 files changed, 126 insertions(+), 34 deletions(-) create mode 100644 cmd/aptify-cli/main_test.go create mode 100644 internal/api/users_test.go diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index aba46cc..1020e8b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,7 +26,7 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g. Ubuntu 22.04] - Deployment Method: [e.g. Docker, Binary] - Browser: [e.g. Chrome, Firefox] - - Version: [e.g. v1.0.0 or commit hash] + - Version: [e.g. v1.0.5 or commit hash] **Additional context** Add any other context about the problem here (e.g. logs from the docker container). diff --git a/Dockerfile b/Dockerfile index ebe20c7..319625d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . COPY --from=frontend /app/internal/web/dist ./internal/web/dist -ARG VERSION=dev +ARG VERSION=v1.0.5 RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /aptify-cli ./cmd/aptify-cli diff --git a/README.md b/README.md index 4905804..3c1f8bd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Aptify — Self-hosted APT, simplified -![Version](https://img.shields.io/badge/version-v1.0.4-blue.svg) +![Version](https://img.shields.io/badge/version-v1.0.5-blue.svg) Aptify is a self-hosted APT repository manager with a clean web UI. Host, sign, and serve your own `.deb` packages without any external infrastructure. diff --git a/cmd/aptify-cli/main.go b/cmd/aptify-cli/main.go index ae6c0bc..ed36e63 100644 --- a/cmd/aptify-cli/main.go +++ b/cmd/aptify-cli/main.go @@ -8,6 +8,7 @@ import ( "io" "mime/multipart" "net/http" + "net/http/cookiejar" "os" "path/filepath" "strings" @@ -16,6 +17,9 @@ import ( "golang.org/x/term" ) +// version is overridden by release builds via -ldflags "-X main.version=vX.Y.Z". +var version = "v1.0.5" + // Config is stored at ~/.config/aptify/config.json. type Config struct { Server string `json:"server"` @@ -98,6 +102,12 @@ func (o *commonOpts) requireConfig() (*Config, error) { // apiDo performs an authenticated JSON request and returns the response body. func apiDo(method, url, token string, body io.Reader, contentType string) (*http.Response, error) { + return apiDoWithClient(http.DefaultClient, method, url, token, body, contentType, "") +} + +// apiDoWithClient supports the short-lived cookie session used during login. +// origin is only needed for cookie-authenticated state-changing requests. +func apiDoWithClient(client *http.Client, method, url, token string, body io.Reader, contentType, origin string) (*http.Response, error) { req, err := http.NewRequest(method, url, body) if err != nil { return nil, err @@ -108,7 +118,10 @@ func apiDo(method, url, token string, body io.Reader, contentType string) (*http if contentType != "" { req.Header.Set("Content-Type", contentType) } - return http.DefaultClient.Do(req) + if origin != "" { + req.Header.Set("Origin", origin) + } + return client.Do(req) } func jsonBody(v any) (io.Reader, string, error) { @@ -160,9 +173,15 @@ func runLogin(args []string) { } password := string(pwBytes) - // 1. Get a JWT. + // 1. Log in with a short-lived cookie session. + jar, err := cookiejar.New(nil) + if err != nil { + fmt.Fprintln(os.Stderr, "initialize login session:", err) + os.Exit(1) + } + client := &http.Client{Jar: jar} body, ct, _ := jsonBody(map[string]string{"username": username, "password": password}) - resp, err := apiDo("POST", serverURL+"/api/auth/login", "", body, ct) + resp, err := apiDoWithClient(client, "POST", serverURL+"/api/auth/login", "", body, ct, serverURL) if err != nil { fmt.Fprintln(os.Stderr, "login failed:", err) os.Exit(1) @@ -172,19 +191,13 @@ func runLogin(args []string) { fmt.Fprintf(os.Stderr, "login failed: %s\n", errBody(resp)) os.Exit(1) } - var loginResp struct { - Token string `json:"token"` - } - if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { - fmt.Fprintln(os.Stderr, "decode login response:", err) - os.Exit(1) - } + _, _ = io.Copy(io.Discard, resp.Body) - // 2. Create an API key using the JWT. + // 2. Create an API key using the cookie session. hostname, _ := os.Hostname() keyName := "cli-" + hostname body2, ct2, _ := jsonBody(map[string]string{"name": keyName}) - resp2, err := apiDo("POST", serverURL+"/api/auth/keys", loginResp.Token, body2, ct2) + resp2, err := apiDoWithClient(client, "POST", serverURL+"/api/auth/keys", "", body2, ct2, serverURL) if err != nil { fmt.Fprintln(os.Stderr, "create api key failed:", err) os.Exit(1) @@ -416,7 +429,7 @@ func runLogout(args []string) { // ---- main ------------------------------------------------------------------- func usage() { - fmt.Fprintf(os.Stderr, `aptify-cli — Aptify command-line client + fmt.Fprintf(os.Stderr, `aptify-cli %s — Aptify command-line client Commands: login Authenticate and save an API key @@ -429,7 +442,7 @@ Flags (all commands): --server Override config file server --token Override config file token --config Override config file path (default: ~/.config/aptify/config.json) -`) +`, version) } func main() { diff --git a/cmd/aptify-cli/main_test.go b/cmd/aptify-cli/main_test.go new file mode 100644 index 0000000..0934892 --- /dev/null +++ b/cmd/aptify-cli/main_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "io" + "net/http" + "net/http/cookiejar" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestLoginClientCarriesSessionAndOrigin(t *testing.T) { + const origin = "http://aptify.example" + var sawSession bool + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Header.Get("Origin") != origin { + t.Errorf("Origin = %q, want %q", r.Header.Get("Origin"), origin) + } + response := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`))} + switch r.URL.Path { + case "/api/auth/login": + response.Header.Add("Set-Cookie", "apt_session=session; Path=/") + case "/api/auth/keys": + cookie, err := r.Cookie("apt_session") + sawSession = err == nil && cookie.Value == "session" + } + return response, nil + }) + + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar, Transport: transport} + + login, err := apiDoWithClient(client, http.MethodPost, origin+"/api/auth/login", "", strings.NewReader(`{}`), "application/json", origin) + if err != nil { + t.Fatal(err) + } + _, _ = io.Copy(io.Discard, login.Body) + _ = login.Body.Close() + + key, err := apiDoWithClient(client, http.MethodPost, origin+"/api/auth/keys", "", strings.NewReader(`{}`), "application/json", origin) + if err != nil { + t.Fatal(err) + } + _ = key.Body.Close() + if !sawSession { + t.Fatal("API-key request did not carry the login session cookie") + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 97369ae..9fa404e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,8 +23,8 @@ import ( ) // version is injected at build time via -ldflags "-X main.version=vX.Y.Z". -// It defaults to "1.0.0" for local builds. -var version = "1.0.0" +// It defaults to "v1.0.5" for local builds. +var version = "v1.0.5" // knownWeakPasswords is an explicit blocklist of credentials that must never // be accepted in production. Fail fast if an operator forgets to rotate them. diff --git a/docker-compose.yml b/docker-compose.yml index 429b689..5dba196 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . args: - VERSION: ${VERSION:-dev} + VERSION: ${VERSION:-v1.0.5} ports: - "8080:8080" volumes: diff --git a/internal/api/repos.go b/internal/api/repos.go index 8bffa69..d371ec9 100644 --- a/internal/api/repos.go +++ b/internal/api/repos.go @@ -56,7 +56,8 @@ func (h *Handler) createRepo(w http.ResponseWriter, r *http.Request) { return } if existing != nil { - jsonError(w, "slug already exists", http.StatusConflict) + suggestion := req.Slug + "-" + req.Type + jsonError(w, fmt.Sprintf("slug %q is already used by a %s repository; repository slugs are shared across DEB and RPM, so use a unique slug such as %q", req.Slug, strings.ToUpper(existing.Type), suggestion), http.StatusConflict) return } diff --git a/internal/api/security_test.go b/internal/api/security_test.go index 4397afe..f0643a4 100644 --- a/internal/api/security_test.go +++ b/internal/api/security_test.go @@ -38,7 +38,7 @@ func setupTestHandler(t *testing.T) *Handler { t.Fatalf("failed to create signer: %v", err) } - return New(db, nil, nil, signer, "super_secret_test_jwt_key", "1.0.0", nil) + return New(db, nil, nil, signer, "super_secret_test_jwt_key", "v1.0.5", nil) } diff --git a/internal/api/users.go b/internal/api/users.go index ed322b9..bf16ab8 100644 --- a/internal/api/users.go +++ b/internal/api/users.go @@ -11,7 +11,7 @@ import ( "golang.org/x/crypto/bcrypt" ) -var usernameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{3,32}$`) +var usernameRe = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,32}$`) type createUserRequest struct { Username string `json:"username"` @@ -48,7 +48,7 @@ func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) { } req.Username = strings.TrimSpace(req.Username) if !usernameRe.MatchString(req.Username) { - jsonError(w, "invalid username", http.StatusBadRequest) + jsonError(w, "username must be 3-32 characters and contain only letters, numbers, dots, hyphens, or underscores", http.StatusBadRequest) return } if len(req.Password) < 8 { diff --git a/internal/api/users_test.go b/internal/api/users_test.go new file mode 100644 index 0000000..5e351bd --- /dev/null +++ b/internal/api/users_test.go @@ -0,0 +1,19 @@ +package api + +import "testing" + +func TestUsernameValidationAllowsDots(t *testing.T) { + valid := []string{"wajahat.ali", "user_name", "user-name", "abc"} + for _, username := range valid { + if !usernameRe.MatchString(username) { + t.Errorf("expected %q to be valid", username) + } + } + + invalid := []string{"ab", "user name", "user@example"} + for _, username := range invalid { + if usernameRe.MatchString(username) { + t.Errorf("expected %q to be invalid", username) + } + } +} diff --git a/web/package-lock.json b/web/package-lock.json index ef10d50..23d76d2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "aptify-web", - "version": "1.0.0", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aptify-web", - "version": "1.0.0", + "version": "1.0.5", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/web/package.json b/web/package.json index 1771300..284b977 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "aptify-web", "private": true, - "version": "1.0.0", + "version": "1.0.5", "type": "module", "scripts": { "dev": "vite", diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 18cc257..cc0956f 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -28,6 +28,7 @@ export default function Dashboard() { const [form, setForm] = useState<{ slug: string; name: string; codename: string; type: Repo['type'] }>({ slug: '', name: '', codename: 'stable', type: 'deb' }) const [creating, setCreating] = useState(false) const [error, setError] = useState('') + const [createError, setCreateError] = useState('') const [query, setQuery] = useState('') const [filter, setFilter] = useState('all') const [sort, setSort] = useState('newest') @@ -57,14 +58,14 @@ export default function Dashboard() { const handleCreate = async (e: React.FormEvent) => { e.preventDefault() setCreating(true) - setError('') + setCreateError('') try { const repo = await api.createRepo(form.slug, form.name, form.codename, form.type) setShowNew(false) setForm({ slug: '', name: '', codename: 'stable', type: 'deb' }) navigate(`/repos/${repo.id}`) } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'Unknown error') + setCreateError(e instanceof Error ? e.message : 'Unknown error') } finally { setCreating(false) } @@ -95,7 +96,7 @@ export default function Dashboard() {

Repositories

Manage package sources, distribution suites, and signing from one place.

- {user?.role === 'admin' && } + {user?.role === 'admin' && } {error &&
{error}
} @@ -105,6 +106,7 @@ export default function Dashboard() {
New package source

Create repository

+ {createError &&
{createError}
}
{(['deb', 'rpm'] as const).map(type => )}
diff --git a/web/src/pages/Users.tsx b/web/src/pages/Users.tsx index 1929ed0..7a8c365 100644 --- a/web/src/pages/Users.tsx +++ b/web/src/pages/Users.tsx @@ -56,6 +56,7 @@ export default function Users() { const [error, setError] = useState('') const [showNew, setShowNew] = useState(false) const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState('') const [newUser, setNewUser] = useState({ username: '', password: '', role: 'viewer' as Role }) const [editing, setEditing] = useState(null) const [editRole, setEditRole] = useState('viewer') @@ -82,14 +83,14 @@ export default function Users() { const handleCreate = async (e: React.FormEvent) => { e.preventDefault() setCreating(true) - setError('') + setCreateError('') try { const created = await api.createUser(newUser.username.trim(), newUser.password, newUser.role) setUsers(prev => [...prev, created]) setNewUser({ username: '', password: '', role: 'viewer' }) setShowNew(false) } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'failed to create user') + setCreateError(e instanceof Error ? e.message : 'failed to create user') } finally { setCreating(false) } @@ -135,7 +136,7 @@ export default function Users() {

Manage who can access Aptify and what they can do.

- @@ -159,9 +160,11 @@ export default function Users() {
+ {createError &&
{createError}
}
- setNewUser(f => ({ ...f, username: e.target.value }))} minLength={3} maxLength={32} required autoFocus /> + { setCreateError(''); setNewUser(f => ({ ...f, username: e.target.value })) }} minLength={3} maxLength={32} pattern="[A-Za-z0-9_.-]{3,32}" aria-invalid={createError.toLowerCase().includes('username')} required autoFocus /> + 3-32 characters. Letters, numbers, dots, hyphens, and underscores are allowed.
From fd7a666ab37336cd56e1310d75b3f57e871904ea Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 22:07:27 +0500 Subject: [PATCH 43/71] feat: add login check to reuse existing valid API keys and skip re-authentication --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- Dockerfile | 2 +- README.md | 2 +- cmd/aptify-cli/main.go | 53 +++++++++++++++++++++++++--- cmd/aptify-cli/main_test.go | 50 ++++++++++++++++++++++++++ cmd/server/main.go | 4 +-- docker-compose.yml | 2 +- internal/api/security_test.go | 5 ++- web/package-lock.json | 4 +-- web/package.json | 2 +- 10 files changed, 110 insertions(+), 16 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1020e8b..2f7cfe2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,7 +26,7 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g. Ubuntu 22.04] - Deployment Method: [e.g. Docker, Binary] - Browser: [e.g. Chrome, Firefox] - - Version: [e.g. v1.0.5 or commit hash] + - Version: [e.g. v1.0.6 or commit hash] **Additional context** Add any other context about the problem here (e.g. logs from the docker container). diff --git a/Dockerfile b/Dockerfile index 319625d..a10c1c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . COPY --from=frontend /app/internal/web/dist ./internal/web/dist -ARG VERSION=v1.0.5 +ARG VERSION=v1.0.6 RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /aptify-cli ./cmd/aptify-cli diff --git a/README.md b/README.md index 3c1f8bd..d0a316b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Aptify — Self-hosted APT, simplified -![Version](https://img.shields.io/badge/version-v1.0.5-blue.svg) +![Version](https://img.shields.io/badge/version-v1.0.6-blue.svg) Aptify is a self-hosted APT repository manager with a clean web UI. Host, sign, and serve your own `.deb` packages without any external infrastructure. diff --git a/cmd/aptify-cli/main.go b/cmd/aptify-cli/main.go index ed36e63..962c598 100644 --- a/cmd/aptify-cli/main.go +++ b/cmd/aptify-cli/main.go @@ -18,7 +18,7 @@ import ( ) // version is overridden by release builds via -ldflags "-X main.version=vX.Y.Z". -var version = "v1.0.5" +var version = "v1.0.6" // Config is stored at ~/.config/aptify/config.json. type Config struct { @@ -143,6 +143,44 @@ func errBody(resp *http.Response) string { return strings.TrimSpace(string(b)) } +func normalizeServerURL(serverURL string) string { + serverURL = strings.TrimRight(strings.TrimSpace(serverURL), "/") + if serverURL != "" && !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") { + serverURL = "https://" + serverURL + } + return serverURL +} + +// existingLoginValid checks whether the config already contains a working API +// key for this server. A rejected key permits a fresh login; connectivity and +// unexpected server errors are returned so login cannot accidentally mint a +// duplicate key when validation was inconclusive. +func existingLoginValid(client *http.Client, configPath, serverURL string) (bool, error) { + cfg, err := loadConfig(configPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("read config: %w", err) + } + if normalizeServerURL(cfg.Server) != normalizeServerURL(serverURL) || cfg.Token == "" { + return false, nil + } + + resp, err := apiDoWithClient(client, http.MethodGet, normalizeServerURL(serverURL)+"/api/auth/check", cfg.Token, nil, "", "") + if err != nil { + return false, fmt.Errorf("verify saved API key: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return true, nil + } + if resp.StatusCode == http.StatusUnauthorized { + return false, nil + } + return false, fmt.Errorf("verify saved API key: server returned %s: %s", resp.Status, errBody(resp)) +} + // ---- commands --------------------------------------------------------------- func runLogin(args []string) { @@ -155,9 +193,16 @@ func runLogin(args []string) { fmt.Fprintln(os.Stderr, "usage: aptify-cli login ") os.Exit(1) } - serverURL = strings.TrimRight(serverURL, "/") - if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") { - serverURL = "https://" + serverURL + serverURL = normalizeServerURL(serverURL) + + alreadyLoggedIn, err := existingLoginValid(http.DefaultClient, opts.configPath, serverURL) + if err != nil { + fmt.Fprintln(os.Stderr, "login check failed:", err) + os.Exit(1) + } + if alreadyLoggedIn { + fmt.Printf("Already logged in to %s. Using the saved API key.\n", serverURL) + return } fmt.Print("Username: ") diff --git a/cmd/aptify-cli/main_test.go b/cmd/aptify-cli/main_test.go index 0934892..66e4afe 100644 --- a/cmd/aptify-cli/main_test.go +++ b/cmd/aptify-cli/main_test.go @@ -4,6 +4,7 @@ import ( "io" "net/http" "net/http/cookiejar" + "path/filepath" "strings" "testing" ) @@ -52,3 +53,52 @@ func TestLoginClientCarriesSessionAndOrigin(t *testing.T) { t.Fatal("API-key request did not carry the login session cookie") } } + +func TestExistingLoginValidReusesSavedAPIKey(t *testing.T) { + const serverURL = "https://aptify.example" + configPath := filepath.Join(t.TempDir(), "config.json") + if err := saveConfig(configPath, &Config{Server: serverURL + "/", Token: "aptify_saved"}); err != nil { + t.Fatal(err) + } + + requests := 0 + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + requests++ + if r.URL.String() != serverURL+"/api/auth/check" { + t.Errorf("URL = %q", r.URL.String()) + } + if got := r.Header.Get("Authorization"); got != "Bearer aptify_saved" { + t.Errorf("Authorization = %q", got) + } + return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"valid":true}`))}, nil + })} + + valid, err := existingLoginValid(client, configPath, serverURL) + if err != nil { + t.Fatal(err) + } + if !valid { + t.Fatal("expected saved API key to be valid") + } + if requests != 1 { + t.Fatalf("requests = %d, want 1", requests) + } +} + +func TestExistingLoginValidAllowsRejectedKeyToBeReplaced(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := saveConfig(configPath, &Config{Server: "https://aptify.example", Token: "expired"}); err != nil { + t.Fatal(err) + } + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusUnauthorized, Status: "401 Unauthorized", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":"Unauthorized"}`))}, nil + })} + + valid, err := existingLoginValid(client, configPath, "https://aptify.example") + if err != nil { + t.Fatal(err) + } + if valid { + t.Fatal("expected rejected API key to require fresh login") + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 9fa404e..ace091b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,8 +23,8 @@ import ( ) // version is injected at build time via -ldflags "-X main.version=vX.Y.Z". -// It defaults to "v1.0.5" for local builds. -var version = "v1.0.5" +// It defaults to "v1.0.6" for local builds. +var version = "v1.0.6" // knownWeakPasswords is an explicit blocklist of credentials that must never // be accepted in production. Fail fast if an operator forgets to rotate them. diff --git a/docker-compose.yml b/docker-compose.yml index 5dba196..b2e83c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . args: - VERSION: ${VERSION:-v1.0.5} + VERSION: ${VERSION:-v1.0.6} ports: - "8080:8080" volumes: diff --git a/internal/api/security_test.go b/internal/api/security_test.go index f0643a4..0796ca6 100644 --- a/internal/api/security_test.go +++ b/internal/api/security_test.go @@ -38,10 +38,9 @@ func setupTestHandler(t *testing.T) *Handler { t.Fatalf("failed to create signer: %v", err) } - return New(db, nil, nil, signer, "super_secret_test_jwt_key", "v1.0.5", nil) + return New(db, nil, nil, signer, "super_secret_test_jwt_key", "v1.0.6", nil) } - func TestSecurityHeaders(t *testing.T) { h := setupTestHandler(t) router := h.Router(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -216,7 +215,7 @@ func TestGPGKeyExportDisabled(t *testing.T) { defer os.Unsetenv("ENABLE_PRIVATE_KEY_EXPORT") rr2 := httptest.NewRecorder() h.exportGPGKey(rr2, req) - + if rr2.Code != http.StatusOK { t.Errorf("Expected 200 OK for enabled GPG key export, got %v", rr2.Code) } diff --git a/web/package-lock.json b/web/package-lock.json index 23d76d2..8310a74 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "aptify-web", - "version": "1.0.5", + "version": "1.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aptify-web", - "version": "1.0.5", + "version": "1.0.6", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/web/package.json b/web/package.json index 284b977..1f656be 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "aptify-web", "private": true, - "version": "1.0.5", + "version": "1.0.6", "type": "module", "scripts": { "dev": "vite", From e69f69c9d21cb5d81822f4d82c341171562a11f6 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 22:20:34 +0500 Subject: [PATCH 44/71] ci: refactor Docker release workflow to support tagging and pass build version to CLI binary --- .github/workflows/docker.yml | 26 ++++++++++++-------------- Dockerfile | 2 +- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 09584f5..96aefb9 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -3,6 +3,7 @@ name: Docker Image CI/CD on: push: branches: [ "main", "develop" ] + tags: [ "v*" ] pull_request: branches: [ "main", "develop" ] @@ -14,9 +15,7 @@ jobs: build-and-push: runs-on: ubuntu-latest permissions: - # contents:write is required by mathieudutour/github-tag-action to push tags. - # All other permissions are explicitly restricted. - contents: write + contents: read packages: write # Restrict unused permissions. actions: none @@ -42,25 +41,22 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # Bump version and create git tag - - name: Bump version and push tag - id: tag_version - if: github.event_name != 'pull_request' - uses: mathieudutour/github-tag-action@d28fa2ccfbd16e871a4bdf35e11b3ad1bd56c0c1 # v6.2 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - default_bump: patch - # Extract metadata (tags, labels) for Docker - name: Extract Docker metadata id: meta uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 with: images: ${{ secrets.DOCKERHUB_USERNAME }}/aptify + flavor: | + latest=false tags: | type=ref,event=pr - type=raw,value=${{ steps.tag_version.outputs.new_version }},enable=${{ github.event_name != 'pull_request' }} - type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=${{ github.ref_name }},enable=${{ github.ref_type == 'tag' }} + type=raw,value=latest,enable=${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }} + type=raw,value=develop,enable=${{ github.ref == 'refs/heads/develop' }} + type=sha,prefix=develop-,enable=${{ github.ref == 'refs/heads/develop' }} + type=raw,value=main,enable=${{ github.ref == 'refs/heads/main' }} + type=sha,prefix=main-,enable=${{ github.ref == 'refs/heads/main' }} # Build and push Docker image with Buildx (don't push on PR) - name: Build and push Docker image @@ -70,5 +66,7 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index a10c1c6..9a83586 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ COPY . . COPY --from=frontend /app/internal/web/dist ./internal/web/dist ARG VERSION=v1.0.6 RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify ./cmd/server -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /aptify-cli ./cmd/aptify-cli +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify-cli ./cmd/aptify-cli # Stage 3: minimal runtime image (server only) FROM alpine:3.20 From 9aa57e45fbf223a918aa322d26257a383532c9e2 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 22:32:57 +0500 Subject: [PATCH 45/71] chore: bump version to v1.0.8, standardize release workflows, and implement automated tagging --- .github/ISSUE_TEMPLATE/bug_report.md | 12 ++++--- .github/workflows/docker.yml | 39 ++++++++++++++++++----- .github/workflows/release.yml | 47 ++++++++++++++++++++++++++-- Dockerfile | 8 ++--- Makefile | 7 +++-- README.md | 2 +- VERSION | 1 + cmd/aptify-cli/main.go | 2 +- cmd/server/main.go | 4 +-- docker-compose.yml | 2 +- internal/api/security_test.go | 2 +- web/package-lock.json | 4 +-- web/package.json | 2 +- 13 files changed, 102 insertions(+), 30 deletions(-) create mode 100644 VERSION diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 2f7cfe2..a18adc6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -3,7 +3,7 @@ name: Bug report about: Create a report to help us improve title: "[BUG] " labels: bug -assignees: '' +assignees: "" --- **Describe the bug** @@ -11,6 +11,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -23,10 +24,11 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Environment (please complete the following information):** - - OS: [e.g. Ubuntu 22.04] - - Deployment Method: [e.g. Docker, Binary] - - Browser: [e.g. Chrome, Firefox] - - Version: [e.g. v1.0.6 or commit hash] + +- OS: [e.g. Ubuntu 22.04] +- Deployment Method: [e.g. Docker, Binary] +- Browser: [e.g. Chrome, Firefox] +- Version: [e.g. v1.0.8 or commit hash] **Additional context** Add any other context about the problem here (e.g. logs from the docker container). diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 96aefb9..71e4f9b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -29,6 +29,32 @@ jobs: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Determine image version + id: version + shell: bash + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + version="pr-${{ github.event.number }}" + latest=false + elif [[ "${{ github.ref_type }}" == "tag" ]]; then + version="${{ github.ref_name }}" + if [[ "$version" == *-* ]]; then + latest=false + else + latest=true + fi + else + version="$(tr -d '[:space:]' < VERSION)" + if [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then + version="${version}-develop" + latest=false + else + latest=true + fi + fi + echo "tag=$version" >> "$GITHUB_OUTPUT" + echo "latest=$latest" >> "$GITHUB_OUTPUT" + # Set up Buildx for multi-architecture builds - name: Set up Docker Buildx uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 @@ -46,17 +72,15 @@ jobs: id: meta uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/aptify + # Do not build the image name from a secret: secrets are unavailable + # to Dependabot and fork PRs, which previously produced "/aptify". + images: ${{ env.IMAGE_NAME }} flavor: | latest=false tags: | type=ref,event=pr - type=raw,value=${{ github.ref_name }},enable=${{ github.ref_type == 'tag' }} - type=raw,value=latest,enable=${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }} - type=raw,value=develop,enable=${{ github.ref == 'refs/heads/develop' }} - type=sha,prefix=develop-,enable=${{ github.ref == 'refs/heads/develop' }} - type=raw,value=main,enable=${{ github.ref == 'refs/heads/main' }} - type=sha,prefix=main-,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=${{ steps.version.outputs.tag }},enable=${{ github.event_name != 'pull_request' }} + type=raw,value=latest,enable=${{ steps.version.outputs.latest == 'true' }} # Build and push Docker image with Buildx (don't push on PR) - name: Build and push Docker image @@ -64,6 +88,7 @@ jobs: with: context: . push: ${{ github.event_name != 'pull_request' }} + load: ${{ github.event_name == 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da9703c..5b8969c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,13 @@ name: Release CLI on: push: + branches: + - main + paths: + - VERSION tags: - 'v*' + workflow_dispatch: jobs: release: @@ -20,6 +25,41 @@ jobs: statuses: none steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Read and validate version + id: version + shell: bash + run: | + version="$(tr -d '[:space:]' < VERSION)" + if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "VERSION must use the format vMAJOR.MINOR.PATCH; got: $version" >&2 + exit 1 + fi + if [[ "${{ github.ref_type }}" == "tag" && "${{ github.ref_name }}" != "$version" ]]; then + echo "Git tag ${{ github.ref_name }} does not match VERSION $version" >&2 + exit 1 + fi + echo "value=$version" >> "$GITHUB_OUTPUT" + + - name: Create release tag when needed + shell: bash + env: + VERSION: ${{ steps.version.outputs.value }} + run: | + if git rev-parse --quiet --verify "refs/tags/$VERSION" >/dev/null; then + tagged_commit="$(git rev-list -n 1 "$VERSION")" + if [[ "$tagged_commit" != "$GITHUB_SHA" ]]; then + echo "Tag $VERSION already points to $tagged_commit, not $GITHUB_SHA" >&2 + exit 1 + fi + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "$VERSION" - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 @@ -28,10 +68,11 @@ jobs: cache: true - name: Build CLI binaries + env: + VERSION: ${{ steps.version.outputs.value }} run: | mkdir -p dist - VERSION=${GITHUB_REF#refs/tags/} - + build_and_archive() { local os=$1 local arch=$2 @@ -60,8 +101,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: + tag_name: ${{ steps.version.outputs.value }} files: | dist/aptify-cli-* dist/checksums.txt generate_release_notes: true - diff --git a/Dockerfile b/Dockerfile index 9a83586..bb074fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . COPY --from=frontend /app/internal/web/dist ./internal/web/dist -ARG VERSION=v1.0.6 +ARG VERSION=v1.0.8 RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify-cli ./cmd/aptify-cli @@ -28,9 +28,9 @@ FROM alpine:3.20 # wget — used by HEALTHCHECK # su-exec — lightweight setuid helper for privilege drop in entrypoint RUN apk --no-cache add ca-certificates tzdata wget su-exec && \ - # Create a dedicated non-root service account. - addgroup -S aptify && \ - adduser -S aptify -G aptify -h /app -s /sbin/nologin + # Create a dedicated non-root service account. + addgroup -S aptify && \ + adduser -S aptify -G aptify -h /app -s /sbin/nologin WORKDIR /app COPY --from=builder /aptify . diff --git a/Makefile b/Makefile index 2f7c523..c8da777 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,16 @@ +VERSION := $(shell tr -d '[:space:]' < VERSION) +VERSION_LDFLAGS := -s -w -X main.version=$(VERSION) + .PHONY: build build-cli ui dev clean ui: cd web && npm run build build: ui - go build -o bin/aptify ./cmd/server + go build -ldflags="$(VERSION_LDFLAGS)" -o bin/aptify ./cmd/server build-cli: - go build -o bin/aptify-cli ./cmd/aptify-cli + go build -ldflags="$(VERSION_LDFLAGS)" -o bin/aptify-cli ./cmd/aptify-cli dev-backend: go run ./cmd/server diff --git a/README.md b/README.md index d0a316b..37f6c10 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Aptify — Self-hosted APT, simplified -![Version](https://img.shields.io/badge/version-v1.0.6-blue.svg) +![Version](https://img.shields.io/badge/version-v1.0.8-blue.svg) Aptify is a self-hosted APT repository manager with a clean web UI. Host, sign, and serve your own `.deb` packages without any external infrastructure. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..14b34a4 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +v1.0.8 diff --git a/cmd/aptify-cli/main.go b/cmd/aptify-cli/main.go index 962c598..0eab875 100644 --- a/cmd/aptify-cli/main.go +++ b/cmd/aptify-cli/main.go @@ -18,7 +18,7 @@ import ( ) // version is overridden by release builds via -ldflags "-X main.version=vX.Y.Z". -var version = "v1.0.6" +var version = "v1.0.8" // Config is stored at ~/.config/aptify/config.json. type Config struct { diff --git a/cmd/server/main.go b/cmd/server/main.go index ace091b..74d6b9c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,8 +23,8 @@ import ( ) // version is injected at build time via -ldflags "-X main.version=vX.Y.Z". -// It defaults to "v1.0.6" for local builds. -var version = "v1.0.6" +// It defaults to "v1.0.8" for local builds. +var version = "v1.0.8" // knownWeakPasswords is an explicit blocklist of credentials that must never // be accepted in production. Fail fast if an operator forgets to rotate them. diff --git a/docker-compose.yml b/docker-compose.yml index b2e83c7..2648cf3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . args: - VERSION: ${VERSION:-v1.0.6} + VERSION: ${VERSION:-v1.0.8} ports: - "8080:8080" volumes: diff --git a/internal/api/security_test.go b/internal/api/security_test.go index 0796ca6..0feac9f 100644 --- a/internal/api/security_test.go +++ b/internal/api/security_test.go @@ -38,7 +38,7 @@ func setupTestHandler(t *testing.T) *Handler { t.Fatalf("failed to create signer: %v", err) } - return New(db, nil, nil, signer, "super_secret_test_jwt_key", "v1.0.6", nil) + return New(db, nil, nil, signer, "super_secret_test_jwt_key", "v1.0.8", nil) } func TestSecurityHeaders(t *testing.T) { diff --git a/web/package-lock.json b/web/package-lock.json index 8310a74..00b4dad 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "aptify-web", - "version": "1.0.6", + "version": "1.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aptify-web", - "version": "1.0.6", + "version": "1.0.8", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/web/package.json b/web/package.json index 1f656be..a6c6a89 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "aptify-web", "private": true, - "version": "1.0.6", + "version": "1.0.8", "type": "module", "scripts": { "dev": "vite", From 200de4ec241756c6f83dcec33a180c9e406f3c07 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 22:40:33 +0500 Subject: [PATCH 46/71] chore: bump version to v1.0.9, add automated synchronization checks, and include integration tests for API key management. --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/workflows/ci.yml | 7 +++ .github/workflows/docker.yml | 9 ++- .github/workflows/release.yml | 7 +++ Dockerfile | 2 +- Makefile | 5 +- README.md | 2 +- VERSION | 2 +- cmd/aptify-cli/main.go | 2 +- cmd/aptify-cli/main_test.go | 92 ++++++++++++++++++++++++++++ cmd/server/main.go | 4 +- docker-compose.yml | 2 +- internal/api/security_test.go | 2 +- scripts/check-version.sh | 40 ++++++++++++ 14 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 scripts/check-version.sh diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index a18adc6..b72cd7a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -28,7 +28,7 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g. Ubuntu 22.04] - Deployment Method: [e.g. Docker, Binary] - Browser: [e.g. Chrome, Firefox] -- Version: [e.g. v1.0.8 or commit hash] +- Version: [e.g. v1.0.9 or commit hash] **Additional context** Add any other context about the problem here (e.g. logs from the docker container). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a8e3a0..9d4cb4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,10 @@ permissions: contents: read pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build-and-test: runs-on: ubuntu-latest @@ -33,6 +37,9 @@ jobs: cache: 'npm' cache-dependency-path: './web/package-lock.json' + - name: Verify version metadata + run: bash scripts/check-version.sh + - name: Build Frontend working-directory: ./web run: | diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 71e4f9b..37a4ec2 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -11,6 +11,10 @@ env: # github.repository as / IMAGE_NAME: ${{ github.repository }} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build-and-push: runs-on: ubuntu-latest @@ -29,6 +33,9 @@ jobs: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Verify version metadata + run: bash scripts/check-version.sh + - name: Determine image version id: version shell: bash @@ -92,6 +99,6 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | - VERSION=${{ steps.meta.outputs.version }} + VERSION=${{ steps.version.outputs.tag }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b8969c..f543ce3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,10 @@ on: - 'v*' workflow_dispatch: +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest @@ -43,6 +47,9 @@ jobs: fi echo "value=$version" >> "$GITHUB_OUTPUT" + - name: Verify version metadata + run: bash scripts/check-version.sh + - name: Create release tag when needed shell: bash env: diff --git a/Dockerfile b/Dockerfile index bb074fc..4eac498 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . COPY --from=frontend /app/internal/web/dist ./internal/web/dist -ARG VERSION=v1.0.8 +ARG VERSION=v1.0.9 RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify-cli ./cmd/aptify-cli diff --git a/Makefile b/Makefile index c8da777..d6534a4 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ VERSION := $(shell tr -d '[:space:]' < VERSION) VERSION_LDFLAGS := -s -w -X main.version=$(VERSION) -.PHONY: build build-cli ui dev clean +.PHONY: build build-cli check-version ui dev clean + +check-version: + bash scripts/check-version.sh ui: cd web && npm run build diff --git a/README.md b/README.md index 37f6c10..7148232 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Aptify — Self-hosted APT, simplified -![Version](https://img.shields.io/badge/version-v1.0.8-blue.svg) +![Version](https://img.shields.io/badge/version-v1.0.9-blue.svg) Aptify is a self-hosted APT repository manager with a clean web UI. Host, sign, and serve your own `.deb` packages without any external infrastructure. diff --git a/VERSION b/VERSION index 14b34a4..e04e39c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.8 +v1.0.9 diff --git a/cmd/aptify-cli/main.go b/cmd/aptify-cli/main.go index 0eab875..022c550 100644 --- a/cmd/aptify-cli/main.go +++ b/cmd/aptify-cli/main.go @@ -18,7 +18,7 @@ import ( ) // version is overridden by release builds via -ldflags "-X main.version=vX.Y.Z". -var version = "v1.0.8" +var version = "v1.0.9" // Config is stored at ~/.config/aptify/config.json. type Config struct { diff --git a/cmd/aptify-cli/main_test.go b/cmd/aptify-cli/main_test.go index 66e4afe..d9a22ea 100644 --- a/cmd/aptify-cli/main_test.go +++ b/cmd/aptify-cli/main_test.go @@ -1,18 +1,32 @@ package main import ( + "encoding/json" "io" "net/http" "net/http/cookiejar" + "net/http/httptest" "path/filepath" "strings" "testing" + + aptifyapi "github.com/kernelcode0/aptify/internal/api" + "github.com/kernelcode0/aptify/internal/storage" + "golang.org/x/crypto/bcrypt" ) type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +type handlerTransport struct{ handler http.Handler } + +func (t handlerTransport) RoundTrip(r *http.Request) (*http.Response, error) { + recorder := httptest.NewRecorder() + t.handler.ServeHTTP(recorder, r) + return recorder.Result(), nil +} + func TestLoginClientCarriesSessionAndOrigin(t *testing.T) { const origin = "http://aptify.example" var sawSession bool @@ -102,3 +116,81 @@ func TestExistingLoginValidAllowsRejectedKeyToBeReplaced(t *testing.T) { t.Fatal("expected rejected API key to require fresh login") } } + +func TestLoginCreatesOneReusableAPIKey(t *testing.T) { + const serverURL = "https://aptify.example" + db, err := storage.Open("sqlite", filepath.Join(t.TempDir(), "aptify.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + + hash, err := bcrypt.GenerateFromPassword([]byte("securepassword"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + user, err := db.CreateUser("wajahat.ali", string(hash), "admin") + if err != nil { + t.Fatal(err) + } + + handler := aptifyapi.New(db, nil, nil, nil, "integration_test_jwt_secret_32_chars", "v1.0.9", nil) + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar, Transport: handlerTransport{handler: handler.Router(http.NotFoundHandler())}} + + loginBody, contentType, err := jsonBody(map[string]string{"username": "wajahat.ali", "password": "securepassword"}) + if err != nil { + t.Fatal(err) + } + login, err := apiDoWithClient(client, http.MethodPost, serverURL+"/api/auth/login", "", loginBody, contentType, serverURL) + if err != nil { + t.Fatal(err) + } + defer login.Body.Close() + if login.StatusCode != http.StatusOK { + t.Fatalf("login status = %d, body = %s", login.StatusCode, errBody(login)) + } + _, _ = io.Copy(io.Discard, login.Body) + + keyBody, contentType, err := jsonBody(map[string]string{"name": "integration-cli"}) + if err != nil { + t.Fatal(err) + } + keyResponse, err := apiDoWithClient(client, http.MethodPost, serverURL+"/api/auth/keys", "", keyBody, contentType, serverURL) + if err != nil { + t.Fatal(err) + } + defer keyResponse.Body.Close() + if keyResponse.StatusCode != http.StatusCreated { + t.Fatalf("create key status = %d, body = %s", keyResponse.StatusCode, errBody(keyResponse)) + } + var created struct { + Key string `json:"key"` + } + if err := json.NewDecoder(keyResponse.Body).Decode(&created); err != nil { + t.Fatal(err) + } + + configPath := filepath.Join(t.TempDir(), "config.json") + if err := saveConfig(configPath, &Config{Server: serverURL, Token: created.Key}); err != nil { + t.Fatal(err) + } + valid, err := existingLoginValid(client, configPath, serverURL) + if err != nil { + t.Fatal(err) + } + if !valid { + t.Fatal("newly issued API key was not reusable") + } + + keys, err := db.ListAPIKeys(user.ID) + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 { + t.Fatalf("API key count = %d, want 1", len(keys)) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 74d6b9c..87bbe60 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,8 +23,8 @@ import ( ) // version is injected at build time via -ldflags "-X main.version=vX.Y.Z". -// It defaults to "v1.0.8" for local builds. -var version = "v1.0.8" +// It defaults to "v1.0.9" for local builds. +var version = "v1.0.9" // knownWeakPasswords is an explicit blocklist of credentials that must never // be accepted in production. Fail fast if an operator forgets to rotate them. diff --git a/docker-compose.yml b/docker-compose.yml index 2648cf3..acf239c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . args: - VERSION: ${VERSION:-v1.0.8} + VERSION: ${VERSION:-v1.0.9} ports: - "8080:8080" volumes: diff --git a/internal/api/security_test.go b/internal/api/security_test.go index 0feac9f..84cb51f 100644 --- a/internal/api/security_test.go +++ b/internal/api/security_test.go @@ -38,7 +38,7 @@ func setupTestHandler(t *testing.T) *Handler { t.Fatalf("failed to create signer: %v", err) } - return New(db, nil, nil, signer, "super_secret_test_jwt_key", "v1.0.8", nil) + return New(db, nil, nil, signer, "super_secret_test_jwt_key", "v1.0.9", nil) } func TestSecurityHeaders(t *testing.T) { diff --git a/scripts/check-version.sh b/scripts/check-version.sh new file mode 100644 index 0000000..d8418b2 --- /dev/null +++ b/scripts/check-version.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +version="$(tr -d '[:space:]' < VERSION)" +numeric="${version#v}" + +if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "VERSION must use vMAJOR.MINOR.PATCH; got: $version" >&2 + exit 1 +fi + +check() { + local file="$1" + local expected="$2" + if ! grep -Fq -- "$expected" "$file"; then + echo "$file is not synchronized with VERSION ($version)" >&2 + exit 1 + fi +} + +check cmd/server/main.go "var version = \"$version\"" +check cmd/aptify-cli/main.go "var version = \"$version\"" +check Dockerfile "ARG VERSION=$version" +check docker-compose.yml "VERSION:-$version" +check README.md "version-$version-blue.svg" +check .github/ISSUE_TEMPLATE/bug_report.md "$version or commit hash" +check internal/api/security_test.go "\"$version\"" + +node - "$numeric" <<'NODE' +const fs = require('fs') +const expected = process.argv[2] +const pkg = JSON.parse(fs.readFileSync('web/package.json', 'utf8')) +const lock = JSON.parse(fs.readFileSync('web/package-lock.json', 'utf8')) +if (pkg.version !== expected || lock.version !== expected || lock.packages?.['']?.version !== expected) { + console.error(`Frontend package metadata is not synchronized with VERSION (v${expected})`) + process.exit(1) +} +NODE + +echo "Version metadata is synchronized at $version." From f2acb466fcb6ad23dfe061bfb1bc2985c881664b Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 22:43:11 +0500 Subject: [PATCH 47/71] chore: bump project version to 1.0.9 --- web/package-lock.json | 4 ++-- web/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 00b4dad..547190d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "aptify-web", - "version": "1.0.8", + "version": "1.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aptify-web", - "version": "1.0.8", + "version": "1.0.9", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/web/package.json b/web/package.json index a6c6a89..5b8decf 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "aptify-web", "private": true, - "version": "1.0.8", + "version": "1.0.9", "type": "module", "scripts": { "dev": "vite", From 2dd5f2bb0d2525e0a1723c017e4f27c8c446fbcd Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 23:05:01 +0500 Subject: [PATCH 48/71] feat: bump version to v1.1.0 and add GitHub star prompt to layout --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- Dockerfile | 2 +- README.md | 2 +- VERSION | 2 +- cmd/aptify-cli/main.go | 2 +- cmd/aptify-cli/main_test.go | 2 +- cmd/server/main.go | 4 ++-- docker-compose.yml | 2 +- internal/api/security_test.go | 2 +- web/package-lock.json | 10 ++++---- web/package.json | 2 +- web/src/components/Layout.css | 14 ++++++++++++ web/src/components/Layout.tsx | 34 ++++++++++++++++++++++++++++ 13 files changed, 64 insertions(+), 16 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b72cd7a..ca027ac 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -28,7 +28,7 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g. Ubuntu 22.04] - Deployment Method: [e.g. Docker, Binary] - Browser: [e.g. Chrome, Firefox] -- Version: [e.g. v1.0.9 or commit hash] +- Version: [e.g. v1.1.0 or commit hash] **Additional context** Add any other context about the problem here (e.g. logs from the docker container). diff --git a/Dockerfile b/Dockerfile index 4eac498..bf8c8f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . COPY --from=frontend /app/internal/web/dist ./internal/web/dist -ARG VERSION=v1.0.9 +ARG VERSION=v1.1.0 RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify-cli ./cmd/aptify-cli diff --git a/README.md b/README.md index 7148232..3d1b95b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Aptify — Self-hosted APT, simplified -![Version](https://img.shields.io/badge/version-v1.0.9-blue.svg) +![Version](https://img.shields.io/badge/version-v1.1.0-blue.svg) Aptify is a self-hosted APT repository manager with a clean web UI. Host, sign, and serve your own `.deb` packages without any external infrastructure. diff --git a/VERSION b/VERSION index e04e39c..795460f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.9 +v1.1.0 diff --git a/cmd/aptify-cli/main.go b/cmd/aptify-cli/main.go index 022c550..fde85e8 100644 --- a/cmd/aptify-cli/main.go +++ b/cmd/aptify-cli/main.go @@ -18,7 +18,7 @@ import ( ) // version is overridden by release builds via -ldflags "-X main.version=vX.Y.Z". -var version = "v1.0.9" +var version = "v1.1.0" // Config is stored at ~/.config/aptify/config.json. type Config struct { diff --git a/cmd/aptify-cli/main_test.go b/cmd/aptify-cli/main_test.go index d9a22ea..2a1f6ac 100644 --- a/cmd/aptify-cli/main_test.go +++ b/cmd/aptify-cli/main_test.go @@ -134,7 +134,7 @@ func TestLoginCreatesOneReusableAPIKey(t *testing.T) { t.Fatal(err) } - handler := aptifyapi.New(db, nil, nil, nil, "integration_test_jwt_secret_32_chars", "v1.0.9", nil) + handler := aptifyapi.New(db, nil, nil, nil, "integration_test_jwt_secret_32_chars", "v1.1.0", nil) jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) diff --git a/cmd/server/main.go b/cmd/server/main.go index 87bbe60..ba8ea86 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,8 +23,8 @@ import ( ) // version is injected at build time via -ldflags "-X main.version=vX.Y.Z". -// It defaults to "v1.0.9" for local builds. -var version = "v1.0.9" +// It defaults to "v1.1.0" for local builds. +var version = "v1.1.0" // knownWeakPasswords is an explicit blocklist of credentials that must never // be accepted in production. Fail fast if an operator forgets to rotate them. diff --git a/docker-compose.yml b/docker-compose.yml index acf239c..6b72bc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . args: - VERSION: ${VERSION:-v1.0.9} + VERSION: ${VERSION:-v1.1.0} ports: - "8080:8080" volumes: diff --git a/internal/api/security_test.go b/internal/api/security_test.go index 84cb51f..e36a66a 100644 --- a/internal/api/security_test.go +++ b/internal/api/security_test.go @@ -38,7 +38,7 @@ func setupTestHandler(t *testing.T) *Handler { t.Fatalf("failed to create signer: %v", err) } - return New(db, nil, nil, signer, "super_secret_test_jwt_key", "v1.0.9", nil) + return New(db, nil, nil, signer, "super_secret_test_jwt_key", "v1.1.0", nil) } func TestSecurityHeaders(t *testing.T) { diff --git a/web/package-lock.json b/web/package-lock.json index 547190d..97cccef 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "aptify-web", - "version": "1.0.9", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aptify-web", - "version": "1.0.9", + "version": "1.1.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1245,8 +1245,8 @@ } }, "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.1.0.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" @@ -1715,7 +1715,7 @@ "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.9" + "@types/estree": "1.1.0" }, "bin": { "rollup": "dist/bin/rollup" diff --git a/web/package.json b/web/package.json index 5b8decf..01a8b0f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "aptify-web", "private": true, - "version": "1.0.9", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/web/src/components/Layout.css b/web/src/components/Layout.css index 9d324c9..2e2019f 100644 --- a/web/src/components/Layout.css +++ b/web/src/components/Layout.css @@ -143,6 +143,15 @@ margin: 0 auto; } +.star-prompt { position:relative; display:flex; align-items:center; gap:11px; margin-bottom:24px; padding:11px 12px; color:var(--text-2); background:linear-gradient(90deg,rgba(45,212,191,.1),rgba(25,29,37,.92) 55%); border:1px solid rgba(45,212,191,.2); border-radius:var(--radius-lg); box-shadow:inset 0 1px 0 rgba(255,255,255,.025); } +.star-prompt-icon { display:grid; place-items:center; flex:0 0 auto; width:30px; height:30px; color:var(--accent-hover); background:rgba(45,212,191,.1); border-radius:8px; } +.star-prompt-copy { min-width:0; flex:1; font-size:12px; } +.star-prompt-copy strong { color:var(--text); } +.star-prompt-action { display:inline-flex; align-items:center; justify-content:center; flex:0 0 auto; gap:6px; min-height:32px; padding:6px 10px; color:#061412; background:var(--accent-strong); border:1px solid rgba(94,234,212,.3); border-radius:var(--radius-sm); font-size:11px; font-weight:800; } +.star-prompt-action:hover { color:#061412; background:var(--accent); } +.star-prompt-dismiss { flex:0 0 auto; width:28px; min-height:28px; padding:0; color:var(--muted); background:transparent; font-size:20px; font-weight:400; line-height:1; } +.star-prompt-dismiss:hover { color:var(--text); background:rgba(255,255,255,.05); } + @media (max-width: 900px) { .nav-brand-tagline { display:none; } .nav-links a { padding-inline:9px; } @@ -190,4 +199,9 @@ .main { padding: 28px 16px 44px; } + + .star-prompt { align-items:flex-start; flex-wrap:wrap; } + .star-prompt-copy { flex-basis:calc(100% - 80px); } + .star-prompt-action { margin-left:41px; } + .star-prompt-dismiss { position:absolute; right:21px; } } diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index dcd8317..2313728 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -26,12 +26,29 @@ function LogOutIcon() { ) } +function GitHubIcon() { + return ( + + ) +} + +const starPromptKey = 'aptify.github-star-prompt.dismissed' + export default function Layout() { const loc = useLocation() const navigate = useNavigate() const [checking, setChecking] = useState(true) const [user, setUser] = useState(null) const [menuOpen, setMenuOpen] = useState(false) + const [showStarPrompt, setShowStarPrompt] = useState(() => { + try { + return localStorage.getItem(starPromptKey) !== 'true' + } catch { + return true + } + }) useEffect(() => { api.checkAuth() @@ -47,6 +64,15 @@ export default function Layout() { navigate('/login') } + const dismissStarPrompt = () => { + setShowStarPrompt(false) + try { + localStorage.setItem(starPromptKey, 'true') + } catch { + // The preference is non-essential when browser storage is unavailable. + } + } + if (checking) { return (
@@ -89,6 +115,14 @@ export default function Layout() {
+ {showStarPrompt && ( + + )}
From d60a1afc4bbf03d0bc069eda6a119fd8f986af59 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Sat, 20 Jun 2026 23:12:04 +0500 Subject: [PATCH 49/71] chore: update package-lock.json dependencies --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 97cccef..ac7390c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1245,8 +1245,8 @@ } }, "node_modules/@types/estree": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.1.0.tgz", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" @@ -1715,7 +1715,7 @@ "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.1.0" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" From f9504e4d7d832c3c2c2bb6c5bfb68ac49f2b1115 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Wed, 29 Jul 2026 14:30:39 +0500 Subject: [PATCH 50/71] feat: add docker-compose configuration and package lock file for Aptify deployment --- aptify-stack.yml | 53 +++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 6 ++++++ 2 files changed, 59 insertions(+) create mode 100644 aptify-stack.yml create mode 100644 package-lock.json diff --git a/aptify-stack.yml b/aptify-stack.yml new file mode 100644 index 0000000..37198da --- /dev/null +++ b/aptify-stack.yml @@ -0,0 +1,53 @@ +version: "3.8" + +services: + aptify: + image: ${IMAGE:-aptify:latest} + build: + context: . + args: + VERSION: ${VERSION:-v1.1.0} + volumes: + - apt-data:/data + environment: + - DATA_DIR=/data + - ADDR=:8080 + - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} + # REQUIRED: Set ADMIN_PASSWORD to a strong unique password. + # Must be at least 8 characters; known defaults (admin123, etc.) are rejected. + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + # REQUIRED: Generate with: openssl rand -hex 32 + # Must be at least 32 characters; known defaults are rejected. + - JWT_SECRET=${JWT_SECRET} + - KEY_NAME=${KEY_NAME:-APT Repository} + - KEY_EMAIL=${KEY_EMAIL:-ops@example.com} + # Optional: set BASE_URL to the public URL when behind a TLS reverse proxy. + # Example: BASE_URL=https://apt.mycompany.com + - BASE_URL=${BASE_URL:-} + # To use MySQL, set DB_TYPE=mysql and provide DB_DSN via environment. + # See .env.example for the DSN format. Do NOT put credentials here. + - DB_TYPE=${DB_TYPE:-sqlite} + - DB_DSN=${DB_DSN:-} + networks: + - traefik-public + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.role == worker + restart_policy: + condition: on-failure + labels: + - "traefik.enable=true" + - "traefik.http.routers.aptify.rule=Host(`${BASE_URL:-apt.example.com}`)" + - "traefik.http.routers.aptify.entrypoints=http" + - "traefik.http.services.aptify.loadbalancer.server.port=8080" + - "traefik.docker.network=traefik-public" + +volumes: + apt-data: + +networks: + traefik-public: + external: true diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..76fed34 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Aptify", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From adc026763bd70717378e58a0bc785c03378c89f8 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Thu, 3 Sep 2026 15:18:36 +0500 Subject: [PATCH 51/71] feat: implement two-factor authentication (2FA) functionality - Added resetUser2FA endpoint to allow admins to reset 2FA for users. - Updated User model to include fields for TOTP (two-factor authentication) status, secret, and recovery codes. - Enhanced database migration to add necessary columns for 2FA support. - Implemented methods in the database layer to manage 2FA settings, including enabling, disabling, and regenerating recovery codes. - Updated API interfaces to include 2FA-related responses and requests. - Modified user login flow to handle 2FA verification. - Enhanced user profile page to manage 2FA setup, disable, and regeneration of recovery codes. - Updated users management page to display 2FA status and allow resetting of 2FA for users. --- VERSION | 2 +- cmd/aptify-cli/main.go | 41 ++++- docker-compose.yml | 30 ++-- go.mod | 1 + go.sum | 2 + internal/api/2fa_test.go | 363 ++++++++++++++++++++++++++++++++++++++ internal/api/auth.go | 352 ++++++++++++++++++++++++++++++++++-- internal/api/router.go | 14 +- internal/api/totp.go | 112 ++++++++++++ internal/api/users.go | 17 ++ internal/storage/db.go | 145 ++++++++++++--- web/src/api.ts | 27 ++- web/src/pages/Login.tsx | 128 +++++++++++--- web/src/pages/Profile.tsx | 301 ++++++++++++++++++++++++++++++- web/src/pages/Users.tsx | 37 +++- 15 files changed, 1483 insertions(+), 89 deletions(-) create mode 100644 internal/api/2fa_test.go create mode 100644 internal/api/totp.go diff --git a/VERSION b/VERSION index 795460f..56130fb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.1.0 +v1.1.1 diff --git a/cmd/aptify-cli/main.go b/cmd/aptify-cli/main.go index fde85e8..7583ae3 100644 --- a/cmd/aptify-cli/main.go +++ b/cmd/aptify-cli/main.go @@ -186,11 +186,13 @@ func existingLoginValid(client *http.Client, configPath, serverURL string) (bool func runLogin(args []string) { fs := flag.NewFlagSet("login", flag.ExitOnError) opts := registerCommon(fs) + var codeFlag string + fs.StringVar(&codeFlag, "code", "", "2FA authentication code or recovery code") _ = fs.Parse(args) serverURL := fs.Arg(0) if serverURL == "" { - fmt.Fprintln(os.Stderr, "usage: aptify-cli login ") + fmt.Fprintln(os.Stderr, "usage: aptify-cli login [--code ]") os.Exit(1) } serverURL = normalizeServerURL(serverURL) @@ -225,7 +227,11 @@ func runLogin(args []string) { os.Exit(1) } client := &http.Client{Jar: jar} - body, ct, _ := jsonBody(map[string]string{"username": username, "password": password}) + loginPayload := map[string]string{"username": username, "password": password} + if codeFlag != "" { + loginPayload["code"] = codeFlag + } + body, ct, _ := jsonBody(loginPayload) resp, err := apiDoWithClient(client, "POST", serverURL+"/api/auth/login", "", body, ct, serverURL) if err != nil { fmt.Fprintln(os.Stderr, "login failed:", err) @@ -236,7 +242,36 @@ func runLogin(args []string) { fmt.Fprintf(os.Stderr, "login failed: %s\n", errBody(resp)) os.Exit(1) } - _, _ = io.Copy(io.Discard, resp.Body) + + var loginResp struct { + Status string `json:"status"` + PreAuthToken string `json:"pre_auth_token"` + } + _ = json.NewDecoder(resp.Body).Decode(&loginResp) + + // If 2FA is required, challenge operator for code + if loginResp.Status == "2fa_required" { + code := codeFlag + if code == "" { + fmt.Print("2FA Code or Recovery Code: ") + _, _ = fmt.Scanln(&code) + code = strings.TrimSpace(code) + } + verifyBody, ctV, _ := jsonBody(map[string]string{ + "pre_auth_token": loginResp.PreAuthToken, + "code": code, + }) + vResp, err := apiDoWithClient(client, "POST", serverURL+"/api/auth/2fa/verify", "", verifyBody, ctV, serverURL) + if err != nil { + fmt.Fprintln(os.Stderr, "2FA verification failed:", err) + os.Exit(1) + } + defer vResp.Body.Close() + if vResp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "2FA verification failed: %s\n", errBody(vResp)) + os.Exit(1) + } + } // 2. Create an API key using the cookie session. hostname, _ := os.Hostname() diff --git a/docker-compose.yml b/docker-compose.yml index 6b72bc4..7c3c9dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,24 +28,24 @@ services: - DB_TYPE=${DB_TYPE:-sqlite} - DB_DSN=${DB_DSN:-} restart: unless-stopped - # depends_on: - # - db # Uncomment when using the MySQL service below + depends_on: + - db # Uncomment when using the MySQL service below # Uncomment the db service to use MySQL instead of the default SQLite. # Provide MYSQL_PASSWORD and MYSQL_ROOT_PASSWORD via a .env file or secrets manager. - # db: - # image: mysql:8 - # environment: - # - MYSQL_DATABASE=aptify - # - MYSQL_USER=aptify - # # REQUIRED: set in .env or via secrets manager — do not hardcode here. - # - MYSQL_PASSWORD=${MYSQL_PASSWORD} - # - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} - # ports: - # # Bind to loopback only — do not expose MySQL to the public network. - # - "127.0.0.1:3306:3306" - # volumes: - # - mysql-data:/var/lib/mysql + db: + image: mysql:8 + environment: + - MYSQL_DATABASE=aptify + - MYSQL_USER=aptify + # REQUIRED: set in .env or via secrets manager — do not hardcode here. + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + ports: + # Bind to loopback only — do not expose MySQL to the public network. + - "127.0.0.1:3306:3306" + volumes: + - mysql-data:/var/lib/mysql volumes: apt-data: diff --git a/go.mod b/go.mod index 7a3a144..4264967 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect golang.org/x/sys v0.46.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 59644bb..e37ee0f 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= diff --git a/internal/api/2fa_test.go b/internal/api/2fa_test.go new file mode 100644 index 0000000..f3310f5 --- /dev/null +++ b/internal/api/2fa_test.go @@ -0,0 +1,363 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestTOTPGenerationAndValidation(t *testing.T) { + secret, err := GenerateTOTPSecret() + if err != nil { + t.Fatalf("failed to generate secret: %v", err) + } + if len(secret) != 32 { + t.Fatalf("expected 32-char base32 secret, got %d chars (%s)", len(secret), secret) + } + + now := time.Now() + code, err := GenerateTOTP(secret, now) + if err != nil { + t.Fatalf("failed to generate TOTP code: %v", err) + } + if len(code) != 6 { + t.Fatalf("expected 6-digit code, got %s", code) + } + + // Current time code must be valid + if !ValidateTOTP(secret, code, now) { + t.Fatalf("valid code %s was rejected", code) + } + + // +/- 30s window must be valid + if !ValidateTOTP(secret, code, now.Add(25*time.Second)) { + t.Fatalf("code should be valid within +25s") + } + if !ValidateTOTP(secret, code, now.Add(-25*time.Second)) { + t.Fatalf("code should be valid within -25s") + } + + // Outside window (+90s) must be rejected + if ValidateTOTP(secret, code, now.Add(90*time.Second)) { + t.Fatalf("code should NOT be valid at +90s") + } +} + +func TestRecoveryCodesGenerationAndHashing(t *testing.T) { + plain, hashed, err := GenerateRecoveryCodes(10) + if err != nil { + t.Fatalf("failed to generate recovery codes: %v", err) + } + if len(plain) != 10 || len(hashed) != 10 { + t.Fatalf("expected 10 codes, got plain=%d hashed=%d", len(plain), len(hashed)) + } + for i := range plain { + if plain[i] == hashed[i] { + t.Fatalf("recovery code should be hashed with bcrypt") + } + } +} + +func newPostRequest(path string, body any) *http.Request { + var buf bytes.Buffer + if body != nil { + _ = json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest("POST", path, &buf) + req.Header.Set("Origin", "http://example.com") + req.Header.Set("Content-Type", "application/json") + return req +} + +func Test2FALifecycle(t *testing.T) { + t.Setenv("LOGIN_RATE_LIMIT_ATTEMPTS", "50") + h := setupTestHandler(t) + router := h.Router(nil) + + // 1. Initial login without 2FA -> returns status: ok and sets apt_session cookie + loginData := map[string]string{ + "username": "admin", + "password": "securepassword", + } + req := newPostRequest("/api/auth/login", loginData) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var loginResp map[string]string + _ = json.NewDecoder(w.Body).Decode(&loginResp) + if loginResp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", loginResp) + } + + var sessionCookie *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == "apt_session" { + sessionCookie = c + break + } + } + if sessionCookie == nil { + t.Fatalf("expected apt_session cookie") + } + + // 2. Setup 2FA + setupReq := newPostRequest("/api/auth/2fa/setup", nil) + setupReq.AddCookie(sessionCookie) + w = httptest.NewRecorder() + router.ServeHTTP(w, setupReq) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 from setup, got %d: %s", w.Code, w.Body.String()) + } + var setupResp struct { + Secret string `json:"secret"` + OTPAuthURL string `json:"otpauth_url"` + QRCode string `json:"qr_code"` + RecoveryCodes []string `json:"recovery_codes"` + } + _ = json.NewDecoder(w.Body).Decode(&setupResp) + if setupResp.Secret == "" || len(setupResp.RecoveryCodes) != 10 { + t.Fatalf("invalid setup response: %+v", setupResp) + } + + // 3. Enable 2FA with invalid code -> should fail + enableReq := newPostRequest("/api/auth/2fa/enable", map[string]string{"code": "000000"}) + enableReq.AddCookie(sessionCookie) + w = httptest.NewRecorder() + router.ServeHTTP(w, enableReq) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 with bad code, got %d", w.Code) + } + + // 4. Enable 2FA with valid TOTP code -> should succeed + validCode, err := GenerateTOTP(setupResp.Secret, time.Now()) + if err != nil { + t.Fatalf("generate TOTP: %v", err) + } + enableReq = newPostRequest("/api/auth/2fa/enable", map[string]string{"code": validCode}) + enableReq.AddCookie(sessionCookie) + w = httptest.NewRecorder() + router.ServeHTTP(w, enableReq) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 from enable, got %d: %s", w.Code, w.Body.String()) + } + + // 5. Next login requires 2FA -> returns status: 2fa_required & pre_auth_token + req = newPostRequest("/api/auth/login", loginData) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var req2FAResp struct { + Status string `json:"status"` + PreAuthToken string `json:"pre_auth_token"` + } + _ = json.NewDecoder(w.Body).Decode(&req2FAResp) + if req2FAResp.Status != "2fa_required" || req2FAResp.PreAuthToken == "" { + t.Fatalf("expected 2fa_required and pre_auth_token, got %+v", req2FAResp) + } + + // 6. Verify pre_auth_token CANNOT access protected routes + checkReq := httptest.NewRequest("GET", "/api/auth/check", nil) + checkReq.Header.Set("Authorization", "Bearer "+req2FAResp.PreAuthToken) + w = httptest.NewRecorder() + router.ServeHTTP(w, checkReq) + if w.Code != http.StatusUnauthorized { + t.Fatalf("pre_auth_token should be rejected on protected routes, got %d", w.Code) + } + + // 7. Verify 2FA with invalid code -> should fail 401 + verifyReq := newPostRequest("/api/auth/2fa/verify", map[string]string{ + "pre_auth_token": req2FAResp.PreAuthToken, + "code": "999999", + }) + w = httptest.NewRecorder() + router.ServeHTTP(w, verifyReq) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for bad 2FA code, got %d", w.Code) + } + + // 8. Verify 2FA with valid TOTP code -> succeeds and sets apt_session cookie + nowCode, _ := GenerateTOTP(setupResp.Secret, time.Now()) + verifyReq = newPostRequest("/api/auth/2fa/verify", map[string]string{ + "pre_auth_token": req2FAResp.PreAuthToken, + "code": nowCode, + }) + w = httptest.NewRecorder() + router.ServeHTTP(w, verifyReq) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for valid 2FA code, got %d: %s", w.Code, w.Body.String()) + } + + hasSessionCookie := false + for _, c := range w.Result().Cookies() { + if c.Name == "apt_session" && c.Value != "" { + hasSessionCookie = true + sessionCookie = c + break + } + } + if !hasSessionCookie { + t.Fatalf("expected session cookie after 2fa verify") + } + + // 9. Test Recovery Code login + req = newPostRequest("/api/auth/login", loginData) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + _ = json.NewDecoder(w.Body).Decode(&req2FAResp) + + recoveryCodeToUse := setupResp.RecoveryCodes[0] + verifyReq = newPostRequest("/api/auth/2fa/verify", map[string]string{ + "pre_auth_token": req2FAResp.PreAuthToken, + "code": recoveryCodeToUse, + }) + w = httptest.NewRecorder() + router.ServeHTTP(w, verifyReq) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 with valid recovery code, got %d: %s", w.Code, w.Body.String()) + } + + // 10. Re-using the same recovery code must fail! + req = newPostRequest("/api/auth/login", loginData) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + _ = json.NewDecoder(w.Body).Decode(&req2FAResp) + + verifyReq = newPostRequest("/api/auth/2fa/verify", map[string]string{ + "pre_auth_token": req2FAResp.PreAuthToken, + "code": recoveryCodeToUse, + }) + w = httptest.NewRecorder() + router.ServeHTTP(w, verifyReq) + if w.Code != http.StatusUnauthorized { + t.Fatalf("re-used recovery code should fail with 401, got %d", w.Code) + } + + // 11. Test Admin Reset 2FA + user, err := h.db.GetUserByUsername("admin") + if err != nil || user == nil { + t.Fatalf("failed to fetch user: %v", err) + } + resetReq := newPostRequest("/api/users/"+user.ID+"/reset-2fa", nil) + resetReq.AddCookie(sessionCookie) + w = httptest.NewRecorder() + router.ServeHTTP(w, resetReq) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 from reset-2fa, got %d", w.Code) + } + + // 12. Login after reset should not require 2FA anymore + req = newPostRequest("/api/auth/login", loginData) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + _ = json.NewDecoder(w.Body).Decode(&loginResp) + if loginResp["status"] != "ok" { + t.Fatalf("expected status ok after 2fa reset, got %v", loginResp) + } +} + +func Test2FADisableFlow(t *testing.T) { + t.Setenv("LOGIN_RATE_LIMIT_ATTEMPTS", "50") + h := setupTestHandler(t) + router := h.Router(nil) + + // Login and setup 2FA + req := newPostRequest("/api/auth/login", map[string]string{"username": "admin", "password": "securepassword"}) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + cookie := w.Result().Cookies()[0] + + setupReq := newPostRequest("/api/auth/2fa/setup", nil) + setupReq.AddCookie(cookie) + w = httptest.NewRecorder() + router.ServeHTTP(w, setupReq) + var setupResp struct { + Secret string `json:"secret"` + } + _ = json.NewDecoder(w.Body).Decode(&setupResp) + + code, _ := GenerateTOTP(setupResp.Secret, time.Now()) + enableReq := newPostRequest("/api/auth/2fa/enable", map[string]string{"code": code}) + enableReq.AddCookie(cookie) + w = httptest.NewRecorder() + router.ServeHTTP(w, enableReq) + if w.Code != http.StatusOK { + t.Fatalf("enable failed: %d", w.Code) + } + + // Disable with wrong password -> should fail + disableReq := newPostRequest("/api/auth/2fa/disable", map[string]string{"password": "wrongpassword", "code": code}) + disableReq.AddCookie(cookie) + w = httptest.NewRecorder() + router.ServeHTTP(w, disableReq) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for wrong password, got %d", w.Code) + } + + // Disable with wrong code -> should fail + disableReq = newPostRequest("/api/auth/2fa/disable", map[string]string{"password": "securepassword", "code": "000000"}) + disableReq.AddCookie(cookie) + w = httptest.NewRecorder() + router.ServeHTTP(w, disableReq) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for wrong code, got %d", w.Code) + } + + // Disable with correct password and code -> succeeds + nowCode, _ := GenerateTOTP(setupResp.Secret, time.Now()) + disableReq = newPostRequest("/api/auth/2fa/disable", map[string]string{"password": "securepassword", "code": nowCode}) + disableReq.AddCookie(cookie) + w = httptest.NewRecorder() + router.ServeHTTP(w, disableReq) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 from disable, got %d", w.Code) + } + + // Verify user is now 2FA disabled + u, _ := h.db.GetUserByUsername("admin") + if u.TOTPEnabled { + t.Fatalf("expected 2FA to be disabled") + } +} + +func TestDirectCodeLogin(t *testing.T) { + t.Setenv("LOGIN_RATE_LIMIT_ATTEMPTS", "50") + h := setupTestHandler(t) + router := h.Router(nil) + + // Enable 2FA directly on db + secret, _ := GenerateTOTPSecret() + _, hashed, _ := GenerateRecoveryCodes(5) + hJSON, _ := json.Marshal(hashed) + u, _ := h.db.GetUserByUsername("admin") + _ = h.db.UpdateUser2FA(u.ID, true, secret, string(hJSON)) + + // Login passing valid TOTP code in same request + code, _ := GenerateTOTP(secret, time.Now()) + req := newPostRequest("/api/auth/login", map[string]string{ + "username": "admin", + "password": "securepassword", + "code": code, + }) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for direct code login, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]string + _ = json.NewDecoder(w.Body).Decode(&resp) + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp) + } +} diff --git a/internal/api/auth.go b/internal/api/auth.go index d54ff02..bc78e7b 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -2,16 +2,38 @@ package api import ( "encoding/json" + "fmt" "net/http" + "strings" "time" "github.com/golang-jwt/jwt/v5" + "github.com/kernelcode0/aptify/internal/storage" "golang.org/x/crypto/bcrypt" ) type loginReq struct { Username string `json:"username"` Password string `json:"password"` + Code string `json:"code,omitempty"` +} + +type verify2FAReq struct { + PreAuthToken string `json:"pre_auth_token"` + Code string `json:"code"` +} + +type enable2FAReq struct { + Code string `json:"code"` +} + +type disable2FAReq struct { + Password string `json:"password"` + Code string `json:"code"` +} + +type regenRecoveryReq struct { + Password string `json:"password"` } func (h *Handler) login(w http.ResponseWriter, r *http.Request) { @@ -36,16 +58,324 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) { return } + if user.TOTPEnabled { + if req.Code != "" { + valid := ValidateTOTP(user.TOTPSecret, req.Code, time.Now()) + usedRecovery := false + if !valid { + valid, err = h.verifyAndConsumeRecoveryCode(user, req.Code) + if err != nil { + jsonError(w, "server error", http.StatusInternalServerError) + return + } + usedRecovery = valid + } + if !valid { + jsonError(w, "invalid two-factor authentication code", http.StatusUnauthorized) + return + } + h.issueSessionCookie(w, user) + if usedRecovery { + h.audit(user, "login_2fa_recovery", "user:"+user.Username, "") + } else { + h.audit(user, "login_2fa_totp", "user:"+user.Username, "") + } + jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) + return + } + + // Issue short-lived pre-auth token (5 minutes) + claims := jwt.MapClaims{ + "sub": user.ID, + "purpose": "2fa_preauth", + "exp": time.Now().Add(5 * time.Minute).Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(h.jwtSecret)) + if err != nil { + jsonError(w, "failed to sign token", http.StatusInternalServerError) + return + } + + jsonOK(w, map[string]string{ + "status": "2fa_required", + "pre_auth_token": tokenString, + }, http.StatusOK) + return + } + + h.issueSessionCookie(w, user) + h.audit(user, "login", "user:"+user.Username, "") + jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) +} + +func (h *Handler) verify2FA(w http.ResponseWriter, r *http.Request) { + var req verify2FAReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid request", http.StatusBadRequest) + return + } + + token, err := jwt.Parse(req.PreAuthToken, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(h.jwtSecret), nil + }) + if err != nil || !token.Valid { + jsonError(w, "invalid or expired token", http.StatusUnauthorized) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || claims["purpose"] != "2fa_preauth" { + jsonError(w, "invalid token purpose", http.StatusUnauthorized) + return + } + + userID, _ := claims["sub"].(string) + user, err := h.db.GetUserByID(userID) + if err != nil || user == nil || !user.TOTPEnabled { + jsonError(w, "invalid user or 2FA not enabled", http.StatusUnauthorized) + return + } + + code := strings.TrimSpace(req.Code) + valid := ValidateTOTP(user.TOTPSecret, code, time.Now()) + usedRecovery := false + if !valid { + valid, err = h.verifyAndConsumeRecoveryCode(user, code) + if err != nil { + jsonError(w, "server error", http.StatusInternalServerError) + return + } + usedRecovery = valid + } + + if !valid { + jsonError(w, "invalid two-factor authentication code", http.StatusUnauthorized) + return + } + + h.issueSessionCookie(w, user) + if usedRecovery { + h.audit(user, "login_2fa_recovery", "user:"+user.Username, "") + } else { + h.audit(user, "login_2fa_totp", "user:"+user.Username, "") + } + jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) +} + +func (h *Handler) setup2FA(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + if user == nil { + jsonError(w, "unauthorized", http.StatusUnauthorized) + return + } + + secret, err := GenerateTOTPSecret() + if err != nil { + jsonError(w, "failed to generate secret", http.StatusInternalServerError) + return + } + + otpURL := BuildOTPAuthURI(user.Username, secret) + qrDataURI, err := GenerateQRCodeDataURI(otpURL) + if err != nil { + jsonError(w, "failed to generate QR code", http.StatusInternalServerError) + return + } + + plainCodes, hashedCodes, err := GenerateRecoveryCodes(10) + if err != nil { + jsonError(w, "failed to generate recovery codes", http.StatusInternalServerError) + return + } + + hashedJSON, _ := json.Marshal(hashedCodes) + if err := h.db.UpdateUser2FA(user.ID, false, secret, string(hashedJSON)); err != nil { + jsonError(w, "failed to save pending 2FA secret", http.StatusInternalServerError) + return + } + + jsonOK(w, map[string]any{ + "secret": secret, + "otpauth_url": otpURL, + "qr_code": qrDataURI, + "recovery_codes": plainCodes, + }, http.StatusOK) +} + +func (h *Handler) enable2FA(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + if user == nil { + jsonError(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req enable2FAReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid request", http.StatusBadRequest) + return + } + + freshUser, err := h.db.GetUserByID(user.ID) + if err != nil || freshUser == nil || freshUser.TOTPSecret == "" { + jsonError(w, "2FA setup has not been initiated", http.StatusBadRequest) + return + } + + if !ValidateTOTP(freshUser.TOTPSecret, req.Code, time.Now()) { + jsonError(w, "invalid verification code", http.StatusBadRequest) + return + } + + if err := h.db.UpdateUser2FA(freshUser.ID, true, freshUser.TOTPSecret, freshUser.TOTPRecoveryCodes); err != nil { + jsonError(w, "failed to activate 2FA", http.StatusInternalServerError) + return + } + + h.audit(user, "enable_2fa", "user:"+user.Username, "") + jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) +} + +func (h *Handler) disable2FA(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + if user == nil { + jsonError(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req disable2FAReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid request", http.StatusBadRequest) + return + } + + freshUser, err := h.db.GetUserByID(user.ID) + if err != nil || freshUser == nil { + jsonError(w, "user not found", http.StatusNotFound) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(freshUser.PasswordHash), []byte(req.Password)); err != nil { + jsonError(w, "incorrect password", http.StatusUnauthorized) + return + } + + if freshUser.TOTPEnabled { + code := strings.TrimSpace(req.Code) + valid := ValidateTOTP(freshUser.TOTPSecret, code, time.Now()) + if !valid { + valid, _ = h.verifyAndConsumeRecoveryCode(freshUser, code) + } + if !valid { + jsonError(w, "invalid two-factor authentication code", http.StatusUnauthorized) + return + } + } + + if err := h.db.ResetUser2FA(freshUser.ID); err != nil { + jsonError(w, "failed to disable 2FA", http.StatusInternalServerError) + return + } + + h.audit(user, "disable_2fa", "user:"+user.Username, "") + jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) +} + +func (h *Handler) regenerateRecoveryCodes(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + if user == nil { + jsonError(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req regenRecoveryReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid request", http.StatusBadRequest) + return + } + + freshUser, err := h.db.GetUserByID(user.ID) + if err != nil || freshUser == nil { + jsonError(w, "user not found", http.StatusNotFound) + return + } + + if !freshUser.TOTPEnabled { + jsonError(w, "2FA is not enabled", http.StatusBadRequest) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(freshUser.PasswordHash), []byte(req.Password)); err != nil { + jsonError(w, "incorrect password", http.StatusUnauthorized) + return + } + + plainCodes, hashedCodes, err := GenerateRecoveryCodes(10) + if err != nil { + jsonError(w, "failed to generate recovery codes", http.StatusInternalServerError) + return + } + + hashedJSON, _ := json.Marshal(hashedCodes) + if err := h.db.UpdateUserRecoveryCodes(freshUser.ID, string(hashedJSON)); err != nil { + jsonError(w, "failed to save recovery codes", http.StatusInternalServerError) + return + } + + h.audit(user, "regenerate_recovery_codes", "user:"+user.Username, "") + jsonOK(w, map[string]any{"recovery_codes": plainCodes}, http.StatusOK) +} + +func (h *Handler) verifyAndConsumeRecoveryCode(user *storage.User, code string) (bool, error) { + code = strings.TrimSpace(code) + if code == "" || user.TOTPRecoveryCodes == "" || user.TOTPRecoveryCodes == "[]" { + return false, nil + } + + var hashes []string + if err := json.Unmarshal([]byte(user.TOTPRecoveryCodes), &hashes); err != nil { + return false, nil + } + + matchedIdx := -1 + for i, hash := range hashes { + if bcrypt.CompareHashAndPassword([]byte(hash), []byte(code)) == nil { + matchedIdx = i + break + } + } + + if matchedIdx == -1 { + return false, nil + } + + remaining := append(hashes[:matchedIdx], hashes[matchedIdx+1:]...) + remJSON, err := json.Marshal(remaining) + if err != nil { + return false, err + } + + if err := h.db.UpdateUserRecoveryCodes(user.ID, string(remJSON)); err != nil { + return false, err + } + user.TOTPRecoveryCodes = string(remJSON) + return true, nil +} + +func (h *Handler) issueSessionCookie(w http.ResponseWriter, user *storage.User) { claims := jwt.MapClaims{ - "sub": user.ID, - "usr": user.Username, - "rol": user.Role, - "exp": time.Now().Add(2 * time.Hour).Unix(), + "sub": user.ID, + "usr": user.Username, + "rol": user.Role, + "purpose": "session", + "exp": time.Now().Add(2 * time.Hour).Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString([]byte(h.jwtSecret)) if err != nil { - jsonError(w, "failed to sign token", http.StatusInternalServerError) return } @@ -58,9 +388,6 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) { SameSite: http.SameSiteStrictMode, MaxAge: 7200, // 2 hours }) - - h.audit(user, "login", "user:"+user.Username, "") - jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) } func (h *Handler) authCheck(w http.ResponseWriter, r *http.Request) { @@ -70,9 +397,10 @@ func (h *Handler) authCheck(w http.ResponseWriter, r *http.Request) { return } jsonOK(w, map[string]any{ - "valid": true, - "username": user.Username, - "role": user.Role, - "user_id": user.ID, + "valid": true, + "username": user.Username, + "role": user.Role, + "user_id": user.ID, + "two_factor_enabled": user.TOTPEnabled, }, http.StatusOK) } diff --git a/internal/api/router.go b/internal/api/router.go index 5a8b10f..c39154f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -45,7 +45,7 @@ func (h *Handler) Router(spa http.Handler) http.Handler { r.Use(middleware.Recoverer) r.Use(SecurityHeaders) r.Use(CSRFProtection) - + rl := newRateLimiter() // Public endpoints — no auth. @@ -56,13 +56,18 @@ func (h *Handler) Router(spa http.Handler) http.Handler { r.Get("/repo/{slug}/repodata/*", h.serveRepoFile) r.Get("/repo/{slug}/packages/*", h.serveRepoFile) - // Auth endpoint + // Auth endpoints r.With(rl.RateLimit).Post("/api/auth/login", h.login) + r.With(rl.RateLimit).Post("/api/auth/2fa/verify", h.verify2FA) // Admin API — protected by JWT or API key. r.Group(func(r chi.Router) { r.Use(h.authMiddleware) r.Get("/api/auth/check", h.authCheck) + r.Post("/api/auth/2fa/setup", h.setup2FA) + r.Post("/api/auth/2fa/enable", h.enable2FA) + r.Post("/api/auth/2fa/disable", h.disable2FA) + r.Post("/api/auth/2fa/recovery-codes", h.regenerateRecoveryCodes) r.Post("/api/auth/keys", h.createAPIKey) r.Get("/api/auth/keys", h.listAPIKeys) r.Delete("/api/auth/keys/{keyID}", h.deleteAPIKey) @@ -79,6 +84,7 @@ func (h *Handler) Router(spa http.Handler) http.Handler { r.With(RequireRole("admin")).Post("/api/users", h.createUser) r.With(RequireRole("admin")).Put("/api/users/{id}", h.updateUser) r.With(RequireRole("admin")).Delete("/api/users/{id}", h.deleteUser) + r.With(RequireRole("admin")).Post("/api/users/{id}/reset-2fa", h.resetUser2FA) r.With(RequireRole("admin")).Get("/api/audit", h.listAuditLog) r.With(RequireRole("admin")).Delete("/api/audit", h.clearAuditLog) r.Get("/api/system/gpg-key", h.exportGPGKey) @@ -121,6 +127,10 @@ func (h *Handler) authMiddleware(next http.Handler) http.Handler { jsonError(w, "Unauthorized", http.StatusUnauthorized) return } + if purpose, _ := claims["purpose"].(string); purpose == "2fa_preauth" { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } userID, _ := claims["sub"].(string) user, err := h.db.GetUserByID(userID) if err != nil || user == nil { diff --git a/internal/api/totp.go b/internal/api/totp.go new file mode 100644 index 0000000..11e98af --- /dev/null +++ b/internal/api/totp.go @@ -0,0 +1,112 @@ +package api + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/subtle" + "encoding/base32" + "encoding/base64" + "encoding/binary" + "fmt" + "net/url" + "strings" + "time" + + qrcode "github.com/skip2/go-qrcode" + "golang.org/x/crypto/bcrypt" +) + +var b32Encoding = base32.StdEncoding.WithPadding(base32.NoPadding) + +// GenerateTOTPSecret returns a 20-byte (160-bit) random secret as an unpadded Base32 string. +func GenerateTOTPSecret() (string, error) { + buf := make([]byte, 20) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("read random bytes: %w", err) + } + return b32Encoding.EncodeToString(buf), nil +} + +// GenerateTOTP generates a 6-digit TOTP code for the given secret at time t (RFC 6238). +func GenerateTOTP(secret string, t time.Time) (string, error) { + key, err := b32Encoding.DecodeString(strings.ToUpper(strings.TrimSpace(secret))) + if err != nil { + return "", fmt.Errorf("invalid base32 secret: %w", err) + } + + counter := uint64(t.Unix() / 30) + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, counter) + + mac := hmac.New(sha1.New, key) + mac.Write(buf) + h := mac.Sum(nil) + + offset := h[len(h)-1] & 0x0f + truncated := binary.BigEndian.Uint32(h[offset:offset+4]) & 0x7fffffff + code := truncated % 1000000 + + return fmt.Sprintf("%06d", code), nil +} + +// ValidateTOTP validates a 6-digit code against a secret with +/- 1 time step tolerance (RFC 6238). +func ValidateTOTP(secret, code string, t time.Time) bool { + code = strings.TrimSpace(code) + if len(code) != 6 { + return false + } + + for step := -1; step <= 1; step++ { + targetTime := t.Add(time.Duration(step*30) * time.Second) + expected, err := GenerateTOTP(secret, targetTime) + if err == nil && subtle.ConstantTimeCompare([]byte(expected), []byte(code)) == 1 { + return true + } + } + return false +} + +// BuildOTPAuthURI constructs the standard otpauth:// URL for authenticator apps. +func BuildOTPAuthURI(username, secret string) string { + label := url.PathEscape(fmt.Sprintf("Aptify:%s", username)) + params := url.Values{} + params.Set("secret", strings.ToUpper(strings.TrimSpace(secret))) + params.Set("issuer", "Aptify") + params.Set("algorithm", "SHA1") + params.Set("digits", "6") + params.Set("period", "30") + + return fmt.Sprintf("otpauth://totp/%s?%s", label, params.Encode()) +} + +// GenerateQRCodeDataURI generates a PNG QR code encoded as a data URI. +func GenerateQRCodeDataURI(otpURI string) (string, error) { + png, err := qrcode.Encode(otpURI, qrcode.Medium, 256) + if err != nil { + return "", fmt.Errorf("generate qr code: %w", err) + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil +} + +// GenerateRecoveryCodes generates count random recovery codes (e.g. "a1b2-c3d4") +// and returns both the plaintext codes (for one-time user display) and bcrypt hashes (for storage). +func GenerateRecoveryCodes(count int) (plain []string, hashed []string, err error) { + plain = make([]string, count) + hashed = make([]string, count) + + for i := 0; i < count; i++ { + buf := make([]byte, 4) + if _, err := rand.Read(buf); err != nil { + return nil, nil, fmt.Errorf("read random bytes: %w", err) + } + code := fmt.Sprintf("%02x%02x-%02x%02x", buf[0], buf[1], buf[2], buf[3]) + h, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost) + if err != nil { + return nil, nil, fmt.Errorf("hash recovery code: %w", err) + } + plain[i] = code + hashed[i] = string(h) + } + return plain, hashed, nil +} diff --git a/internal/api/users.go b/internal/api/users.go index bf16ab8..bdadf87 100644 --- a/internal/api/users.go +++ b/internal/api/users.go @@ -158,3 +158,20 @@ func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) { h.audit(current, "delete_user", "user:"+user.Username, "") w.WriteHeader(http.StatusNoContent) } + +func (h *Handler) resetUser2FA(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + targetUser, err := h.db.GetUserByID(id) + if err != nil || targetUser == nil { + jsonError(w, "user not found", http.StatusNotFound) + return + } + + if err := h.db.ResetUser2FA(targetUser.ID); err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + + h.audit(currentUser(r), "reset_user_2fa", "user:"+targetUser.Username, "") + jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) +} diff --git a/internal/storage/db.go b/internal/storage/db.go index 5c29aa5..cac17e8 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -30,11 +30,14 @@ type APIKey struct { // User represents an administrator user. type User struct { - ID string `json:"id"` - Username string `json:"username"` - Role string `json:"role"` - PasswordHash string `json:"-"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + PasswordHash string `json:"-"` + TOTPEnabled bool `json:"two_factor_enabled"` + TOTPSecret string `json:"-"` + TOTPRecoveryCodes string `json:"-"` + CreatedAt time.Time `json:"created_at"` } // AuditEntry represents one immutable audit log record. @@ -110,11 +113,15 @@ func (d *DB) migrate() error { if d.dbType == "mysql" { userTable = ` CREATE TABLE IF NOT EXISTS users ( - id VARCHAR(36) PRIMARY KEY, - username VARCHAR(255) UNIQUE NOT NULL, - role VARCHAR(32) NOT NULL DEFAULT 'viewer', - password_hash VARCHAR(255) NOT NULL, - created_at DATETIME NOT NULL + id VARCHAR(36) PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'viewer', + password_hash VARCHAR(255) NOT NULL, + totp_enabled TINYINT(1) NOT NULL DEFAULT 0, + totp_secret VARCHAR(64) NOT NULL DEFAULT '', + totp_recovery_codes TEXT NOT NULL DEFAULT '[]', + totp_recovery_codes TEXT, + created_at DATETIME NOT NULL );` repoTable = ` CREATE TABLE IF NOT EXISTS repos ( @@ -170,11 +177,14 @@ func (d *DB) migrate() error { } else { userTable = ` CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT UNIQUE NOT NULL, - role TEXT NOT NULL DEFAULT 'viewer', - password_hash TEXT NOT NULL, - created_at DATETIME NOT NULL + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + password_hash TEXT NOT NULL, + totp_enabled INTEGER NOT NULL DEFAULT 0, + totp_secret TEXT NOT NULL DEFAULT '', + totp_recovery_codes TEXT NOT NULL DEFAULT '[]', + created_at DATETIME NOT NULL );` repoTable = ` CREATE TABLE IF NOT EXISTS repos ( @@ -242,6 +252,9 @@ func (d *DB) migrate() error { if err := d.ensureUserRoleColumn(); err != nil { return err } + if err := d.ensureUser2FAColumns(); err != nil { + return err + } if err := d.ensureRepoTypeColumn(); err != nil { return err } @@ -313,6 +326,57 @@ func (d *DB) ensureUserRoleColumn() error { return err } +func (d *DB) ensureUser2FAColumns() error { + hasEnabled, err := d.columnExists("users", "totp_enabled") + if err != nil { + return err + } + if !hasEnabled { + colType := "INTEGER NOT NULL DEFAULT 0" + if d.dbType == "mysql" { + colType = "TINYINT(1) NOT NULL DEFAULT 0" + } + if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN totp_enabled ` + colType); err != nil { + return err + } + } + + hasSecret, err := d.columnExists("users", "totp_secret") + if err != nil { + return err + } + if !hasSecret { + colType := "TEXT NOT NULL DEFAULT ''" + if d.dbType == "mysql" { + colType = "VARCHAR(64) NOT NULL DEFAULT ''" + } + if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN totp_secret ` + colType); err != nil { + return err + } + } + + hasCodes, err := d.columnExists("users", "totp_recovery_codes") + if err != nil { + return err + } + if !hasCodes { + colType := "TEXT NOT NULL DEFAULT '[]'" + if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN totp_recovery_codes ` + colType); err != nil { + return err + if d.dbType == "mysql" { + if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT`); err != nil { + return err + } + _, _ = d.db.Exec(`UPDATE users SET totp_recovery_codes='[]' WHERE totp_recovery_codes IS NULL OR totp_recovery_codes=''`) + } else { + if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT NOT NULL DEFAULT '[]'`); err != nil { + return err + } + } + } + return nil +} + func (d *DB) columnExists(table, column string) (bool, error) { // Whitelist table names to prevent SQL injection via string concatenation validTables := map[string]bool{"users": true, "repos": true, "packages": true, "api_keys": true, "audit_log": true} @@ -331,6 +395,7 @@ func (d *DB) columnExists(table, column string) (bool, error) { return false, err } defer rows.Close() + if d.dbType == "mysql" { return rows.Next(), rows.Err() } @@ -354,8 +419,9 @@ func (d *DB) columnExists(table, column string) (bool, error) { func (d *DB) GetUserByUsername(username string) (*User, error) { u := &User{} err := d.db.QueryRow( - `SELECT id, username, role, password_hash, created_at FROM users WHERE username=?`, username, - ).Scan(&u.ID, &u.Username, &u.Role, &u.PasswordHash, &u.CreatedAt) + `SELECT id, username, role, password_hash, totp_enabled, totp_secret, totp_recovery_codes, created_at FROM users WHERE username=?`, username, + `SELECT id, username, role, password_hash, totp_enabled, COALESCE(totp_secret, ''), COALESCE(totp_recovery_codes, '[]'), created_at FROM users WHERE username=?`, username, + ).Scan(&u.ID, &u.Username, &u.Role, &u.PasswordHash, &u.TOTPEnabled, &u.TOTPSecret, &u.TOTPRecoveryCodes, &u.CreatedAt) if err == sql.ErrNoRows { return nil, nil } @@ -365,14 +431,17 @@ func (d *DB) GetUserByUsername(username string) (*User, error) { // CreateUser inserts a new user. func (d *DB) CreateUser(username, passwordHash, role string) (*User, error) { u := &User{ - ID: uuid.NewString(), - Username: username, - Role: role, - PasswordHash: passwordHash, - CreatedAt: time.Now().UTC(), + ID: uuid.NewString(), + Username: username, + Role: role, + PasswordHash: passwordHash, + TOTPEnabled: false, + TOTPSecret: "", + TOTPRecoveryCodes: "[]", + CreatedAt: time.Now().UTC(), } _, err := d.db.Exec( - `INSERT INTO users (id, username, role, password_hash, created_at) VALUES (?,?,?,?,?)`, + `INSERT INTO users (id, username, role, password_hash, totp_enabled, totp_secret, totp_recovery_codes, created_at) VALUES (?,?,?,?,0,'','[]',?)`, u.ID, u.Username, u.Role, u.PasswordHash, u.CreatedAt, ) if err != nil { @@ -383,7 +452,7 @@ func (d *DB) CreateUser(username, passwordHash, role string) (*User, error) { // ListUsers returns all users ordered by creation time. func (d *DB) ListUsers() ([]User, error) { - rows, err := d.db.Query(`SELECT id, username, role, created_at FROM users ORDER BY created_at`) + rows, err := d.db.Query(`SELECT id, username, role, totp_enabled, created_at FROM users ORDER BY created_at`) if err != nil { return nil, err } @@ -391,7 +460,7 @@ func (d *DB) ListUsers() ([]User, error) { var users []User for rows.Next() { var u User - if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil { + if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.TOTPEnabled, &u.CreatedAt); err != nil { return nil, err } users = append(users, u) @@ -403,8 +472,9 @@ func (d *DB) ListUsers() ([]User, error) { func (d *DB) GetUserByID(id string) (*User, error) { u := &User{} err := d.db.QueryRow( - `SELECT id, username, role, password_hash, created_at FROM users WHERE id=?`, id, - ).Scan(&u.ID, &u.Username, &u.Role, &u.PasswordHash, &u.CreatedAt) + `SELECT id, username, role, password_hash, totp_enabled, totp_secret, totp_recovery_codes, created_at FROM users WHERE id=?`, id, + `SELECT id, username, role, password_hash, totp_enabled, COALESCE(totp_secret, ''), COALESCE(totp_recovery_codes, '[]'), created_at FROM users WHERE id=?`, id, + ).Scan(&u.ID, &u.Username, &u.Role, &u.PasswordHash, &u.TOTPEnabled, &u.TOTPSecret, &u.TOTPRecoveryCodes, &u.CreatedAt) if err == sql.ErrNoRows { return nil, nil } @@ -423,6 +493,27 @@ func (d *DB) UpdateUserPassword(id, passwordHash string) error { return err } +// UpdateUser2FA updates 2FA status, secret, and recovery codes for a user. +func (d *DB) UpdateUser2FA(id string, enabled bool, secret, recoveryCodes string) error { + var enabledVal any = 0 + if enabled { + enabledVal = 1 + } + _, err := d.db.Exec(`UPDATE users SET totp_enabled=?, totp_secret=?, totp_recovery_codes=? WHERE id=?`, enabledVal, secret, recoveryCodes, id) + return err +} + +// UpdateUserRecoveryCodes updates only the recovery codes for a user. +func (d *DB) UpdateUserRecoveryCodes(id string, recoveryCodes string) error { + _, err := d.db.Exec(`UPDATE users SET totp_recovery_codes=? WHERE id=?`, recoveryCodes, id) + return err +} + +// ResetUser2FA disables 2FA and clears secrets for a user. +func (d *DB) ResetUser2FA(id string) error { + return d.UpdateUser2FA(id, false, "", "[]") +} + // DeleteUser removes a user and cascades their API keys. func (d *DB) DeleteUser(id string) error { _, err := d.db.Exec(`DELETE FROM users WHERE id=?`, id) diff --git a/web/src/api.ts b/web/src/api.ts index c04e3f8..aec803d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -99,15 +99,29 @@ export interface CurrentUser { user_id: string username: string role: Role + two_factor_enabled?: boolean } export interface User { id: string username: string role: Role + two_factor_enabled?: boolean created_at: string } +export interface Setup2FAResponse { + secret: string + otpauth_url: string + qr_code: string + recovery_codes: string[] +} + +export interface LoginResponse { + status: 'ok' | '2fa_required' + pre_auth_token?: string +} + export interface AuditEntry { id: string user_id: string @@ -125,7 +139,18 @@ export interface AuditList { export const api = { checkAuth: () => req('GET', '/api/auth/check'), - login: (username: string, password: string) => req<{ token: string }>('POST', '/api/auth/login', { username, password }), + login: (username: string, password: string, code?: string) => + req('POST', '/api/auth/login', { username, password, code }), + verify2FA: (preAuthToken: string, code: string) => + req<{ status: string }>('POST', '/api/auth/2fa/verify', { pre_auth_token: preAuthToken, code }), + setup2FA: () => req('POST', '/api/auth/2fa/setup'), + enable2FA: (code: string) => req<{ status: string }>('POST', '/api/auth/2fa/enable', { code }), + disable2FA: (password: string, code: string) => + req<{ status: string }>('POST', '/api/auth/2fa/disable', { password, code }), + regenerateRecoveryCodes: (password: string) => + req<{ recovery_codes: string[] }>('POST', '/api/auth/2fa/recovery-codes', { password }), + resetUser2FA: (userId: string) => + req<{ status: string }>('POST', `/api/users/${userId}/reset-2fa`), listRepos: () => req('GET', '/api/repos'), createRepo: (slug: string, name: string, codename: string, type: Repo['type']) => req('POST', '/api/repos', { slug, name, codename, type }), diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 8131afc..6dc0e11 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -30,17 +30,24 @@ export default function Login() { const [showPassword, setShowPassword] = useState(false) const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [step, setStep] = useState<'credentials' | '2fa'>('credentials') + const [preAuthToken, setPreAuthToken] = useState('') + const [twoFactorCode, setTwoFactorCode] = useState('') + const [useRecovery, setUseRecovery] = useState(false) const navigate = useNavigate() const location = useLocation() - const handleSubmit = async (e: React.FormEvent) => { + const handleCredentialsSubmit = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) setError('') try { - // The server sets an HttpOnly session cookie on successful login. - // No token is stored client-side. - await api.login(username, password) + const res = await api.login(username, password) + if (res.status === '2fa_required' && res.pre_auth_token) { + setPreAuthToken(res.pre_auth_token) + setStep('2fa') + return + } const from = (location.state as { from?: { pathname?: string } })?.from?.pathname || '/' navigate(from, { replace: true }) } catch { @@ -50,6 +57,21 @@ export default function Login() { } } + const handle2FASubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError('') + try { + await api.verify2FA(preAuthToken, twoFactorCode) + const from = (location.state as { from?: { pathname?: string } })?.from?.pathname || '/' + navigate(from, { replace: true }) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Invalid verification code') + } finally { + setLoading(false) + } + } + return
@@ -83,28 +105,82 @@ export default function Login() {
Secure administrator accessAPTIFY / AUTH
-
-
- Welcome back -

Sign in to Aptify

-

Enter your workspace credentials to continue.

-
- - {error &&
Sign-in failed{error}
} - - - - - - + {step === 'credentials' ? ( + <> +
+
+ Welcome back +

Sign in to Aptify

+

Enter your workspace credentials to continue.

+
+ + {error &&
Sign-in failed{error}
} + +
+ + + +
+ + ) : ( + <> +
+
+ Two-factor challenge +

Two-factor authentication

+

{useRecovery ? 'Enter an emergency single-use recovery code.' : 'Enter the 6-digit code from your authenticator app.'}

+
+ + {error &&
Verification failed{error}
} + +
+ + +
+ + +
+
+ + )}
Your credentials are sent securely to your Aptify server.
diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index f1b62ba..c529449 100644 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { Link, useOutletContext } from 'react-router-dom' -import { type CurrentUser, type Role } from '../api' +import { api, type CurrentUser, type Role } from '../api' import './Profile.css' type IconName = 'user' | 'shield' | 'check' | 'copy' | 'arrow' | 'key' | 'team' | 'audit' | 'repo' | 'spark' @@ -42,6 +42,18 @@ const roleContent: Record() const [copied, setCopied] = useState(false) + const [twoFactorEnabled, setTwoFactorEnabled] = useState(user?.two_factor_enabled ?? false) + + // 2FA Modals state + const [modalType, setModalType] = useState<'setup' | 'disable' | 'regen' | null>(null) + const [setupData, setSetupData] = useState<{ secret: string; otpauth_url: string; qr_code: string; recovery_codes: string[] } | null>(null) + const [verifyCode, setVerifyCode] = useState('') + const [passwordInput, setPasswordInput] = useState('') + const [disableCode, setDisableCode] = useState('') + const [newRecoveryCodes, setNewRecoveryCodes] = useState(null) + const [loading, setLoading] = useState(false) + const [modalError, setModalError] = useState('') + const [codesCopied, setCodesCopied] = useState(false) if (!user) return null @@ -54,6 +66,76 @@ export default function Profile() { window.setTimeout(() => setCopied(false), 1800) } + const handleStartSetup = async () => { + setLoading(true) + setModalError('') + try { + const data = await api.setup2FA() + setSetupData(data) + setVerifyCode('') + setModalType('setup') + } catch (err: unknown) { + alert(err instanceof Error ? err.message : 'Failed to start 2FA setup') + } finally { + setLoading(false) + } + } + + const handleConfirmEnable = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setModalError('') + try { + await api.enable2FA(verifyCode) + setTwoFactorEnabled(true) + user.two_factor_enabled = true + setModalType(null) + } catch (err: unknown) { + setModalError(err instanceof Error ? err.message : 'Invalid verification code') + } finally { + setLoading(false) + } + } + + const handleConfirmDisable = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setModalError('') + try { + await api.disable2FA(passwordInput, disableCode) + setTwoFactorEnabled(false) + user.two_factor_enabled = false + setModalType(null) + setPasswordInput('') + setDisableCode('') + } catch (err: unknown) { + setModalError(err instanceof Error ? err.message : 'Failed to disable 2FA') + } finally { + setLoading(false) + } + } + + const handleConfirmRegen = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setModalError('') + try { + const res = await api.regenerateRecoveryCodes(passwordInput) + setNewRecoveryCodes(res.recovery_codes) + setPasswordInput('') + } catch (err: unknown) { + setModalError(err instanceof Error ? err.message : 'Failed to regenerate codes') + } finally { + setLoading(false) + } + } + + const copyCodes = async (codes: string[]) => { + await navigator.clipboard.writeText(codes.join('\n')) + setCodesCopied(true) + window.setTimeout(() => setCodesCopied(false), 1800) + } + return (
@@ -79,10 +161,61 @@ export default function Profile() {
Username
{user.username}
Account ID
{user.user_id}
Account status
Active
+
Two-Factor Auth
{twoFactorEnabled ? Enabled : Disabled}
+ {/* Two-Factor Authentication Card */} +
+
+ + + +
+ Security +

Two-factor authentication (2FA)

+
+
+

+ Protect your Aptify account with an extra layer of security using a time-based one-time password (TOTP) from Google Authenticator, 1Password, Bitwarden, or Apple Passwords. +

+ +
+ {!twoFactorEnabled ? ( + + ) : ( + <> + + + + )} +
+
+
@@ -105,6 +238,172 @@ export default function Profile() {
+ + {/* 2FA Setup Modal */} + {modalType === 'setup' && setupData && ( +
setModalType(null)}> +
e.stopPropagation()} style={{ maxWidth: '540px' }}> +
+

Set up Two-Factor Authentication

+ +
+
+ {modalError &&

{modalError}

} +
+ 1. Scan this QR code +

+ Open your authenticator app (Google Authenticator, 1Password, Bitwarden, etc.) and scan this code: +

+
+ 2FA QR Code +
+

+ Or enter key manually: {setupData.secret} +

+
+ +
+
+ 2. Save emergency recovery codes + +
+

+ If you lose your authenticator device, each recovery code can be used once to sign in. +

+
+ {setupData.recovery_codes.map(c => {c})} +
+
+ +
+ + setVerifyCode(e.target.value)} + maxLength={8} + autoFocus + required + style={{ width: '100%', marginBottom: '16px' }} + /> +
+ + +
+
+
+
+
+ )} + + {/* 2FA Disable Modal */} + {modalType === 'disable' && ( +
setModalType(null)}> +
e.stopPropagation()} style={{ maxWidth: '440px' }}> +
+

Disable Two-Factor Authentication

+ +
+
+ {modalError &&

{modalError}

} +

+ Disabling 2FA will lower your account security. Please verify your password and 2FA code to confirm. +

+
+ + setPasswordInput(e.target.value)} + placeholder="Enter your current password" + required + style={{ width: '100%' }} + /> +
+
+ + setDisableCode(e.target.value)} + placeholder="123456 or recovery code" + required + style={{ width: '100%' }} + /> +
+
+ + +
+
+
+
+ )} + + {/* Regenerate Recovery Codes Modal */} + {modalType === 'regen' && ( +
setModalType(null)}> +
e.stopPropagation()} style={{ maxWidth: '480px' }}> +
+

{newRecoveryCodes ? 'Your new recovery codes' : 'Regenerate recovery codes'}

+ +
+
+ {modalError &&

{modalError}

} + {!newRecoveryCodes ? ( +
+

+ Generating new recovery codes will invalidate all existing codes. Please confirm your password to continue. +

+
+ + setPasswordInput(e.target.value)} + placeholder="Enter password" + required + style={{ width: '100%' }} + /> +
+
+ + +
+
+ ) : ( +
+

+ Save these 10 single-use codes now. Old recovery codes are now invalid. +

+
+ {newRecoveryCodes.map(c => {c})} +
+
+ + +
+
+ )} +
+
+
+ )}
) } diff --git a/web/src/pages/Users.tsx b/web/src/pages/Users.tsx index 7a8c365..13bf9d5 100644 --- a/web/src/pages/Users.tsx +++ b/web/src/pages/Users.tsx @@ -49,6 +49,15 @@ function AlertIcon() { ) } +function KeyIcon() { + return ( + + + + + ) +} + export default function Users() { const { user: current } = useOutletContext<{ user: CurrentUser | null }>() const [users, setUsers] = useState([]) @@ -127,6 +136,17 @@ export default function Users() { } } + const handleReset2FA = async (u: User) => { + if (!confirm(`Reset 2FA for "${u.username}"? They will be able to log in with their password alone.`)) return + setError('') + try { + await api.resetUser2FA(u.id) + setUsers(prev => prev.map(x => x.id === u.id ? { ...x, two_factor_enabled: false } : x)) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'failed to reset 2FA') + } + } + return (
@@ -206,6 +226,7 @@ export default function Users() { Username Role + 2FA Created Actions @@ -227,6 +248,15 @@ export default function Users() { )} + + {u.two_factor_enabled ? ( + + Active + + ) : ( + Off + )} + {formatDate(u.created_at)} {editing === u.id ? ( @@ -238,6 +268,11 @@ export default function Users() { ) : (
+ {u.two_factor_enabled && !isSelf && ( + + )} {!isSelf && }
)} @@ -245,7 +280,7 @@ export default function Users() { ) })} - {visibleUsers.length === 0 &&
No users foundTry a different name or role.
} + {visibleUsers.length === 0 &&
No users foundTry a different name or role.
}
From 366324a4565c15aaed8c98c08682e2f3042ec541 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Thu, 3 Sep 2026 15:52:00 +0500 Subject: [PATCH 52/71] feat: update 2FA recovery codes handling and improve modal UI --- internal/storage/db.go | 6 ------ web/src/pages/Profile.tsx | 27 ++++++++++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/internal/storage/db.go b/internal/storage/db.go index cac17e8..e353522 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -119,7 +119,6 @@ func (d *DB) migrate() error { password_hash VARCHAR(255) NOT NULL, totp_enabled TINYINT(1) NOT NULL DEFAULT 0, totp_secret VARCHAR(64) NOT NULL DEFAULT '', - totp_recovery_codes TEXT NOT NULL DEFAULT '[]', totp_recovery_codes TEXT, created_at DATETIME NOT NULL );` @@ -360,9 +359,6 @@ func (d *DB) ensureUser2FAColumns() error { return err } if !hasCodes { - colType := "TEXT NOT NULL DEFAULT '[]'" - if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN totp_recovery_codes ` + colType); err != nil { - return err if d.dbType == "mysql" { if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT`); err != nil { return err @@ -419,7 +415,6 @@ func (d *DB) columnExists(table, column string) (bool, error) { func (d *DB) GetUserByUsername(username string) (*User, error) { u := &User{} err := d.db.QueryRow( - `SELECT id, username, role, password_hash, totp_enabled, totp_secret, totp_recovery_codes, created_at FROM users WHERE username=?`, username, `SELECT id, username, role, password_hash, totp_enabled, COALESCE(totp_secret, ''), COALESCE(totp_recovery_codes, '[]'), created_at FROM users WHERE username=?`, username, ).Scan(&u.ID, &u.Username, &u.Role, &u.PasswordHash, &u.TOTPEnabled, &u.TOTPSecret, &u.TOTPRecoveryCodes, &u.CreatedAt) if err == sql.ErrNoRows { @@ -472,7 +467,6 @@ func (d *DB) ListUsers() ([]User, error) { func (d *DB) GetUserByID(id string) (*User, error) { u := &User{} err := d.db.QueryRow( - `SELECT id, username, role, password_hash, totp_enabled, totp_secret, totp_recovery_codes, created_at FROM users WHERE id=?`, id, `SELECT id, username, role, password_hash, totp_enabled, COALESCE(totp_secret, ''), COALESCE(totp_recovery_codes, '[]'), created_at FROM users WHERE id=?`, id, ).Scan(&u.ID, &u.Username, &u.Role, &u.PasswordHash, &u.TOTPEnabled, &u.TOTPSecret, &u.TOTPRecoveryCodes, &u.CreatedAt) if err == sql.ErrNoRows { diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index c529449..b18b7a6 100644 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -21,6 +21,15 @@ function Icon({ name, size = 16 }: { name: IconName; size?: number }) { return } +function XIcon() { + return ( + + + + + ) +} + const roleContent: Record = { admin: { name: 'Administrator', @@ -241,11 +250,11 @@ export default function Profile() { {/* 2FA Setup Modal */} {modalType === 'setup' && setupData && ( -
setModalType(null)}> -
e.stopPropagation()} style={{ maxWidth: '540px' }}> +
{ if (e.target === e.currentTarget) setModalType(null) }}> +

Set up Two-Factor Authentication

- +
{modalError &&

{modalError}

} @@ -305,11 +314,11 @@ export default function Profile() { {/* 2FA Disable Modal */} {modalType === 'disable' && ( -
setModalType(null)}> -
e.stopPropagation()} style={{ maxWidth: '440px' }}> +
{ if (e.target === e.currentTarget) setModalType(null) }}> +

Disable Two-Factor Authentication

- +
{modalError &&

{modalError}

} @@ -351,11 +360,11 @@ export default function Profile() { {/* Regenerate Recovery Codes Modal */} {modalType === 'regen' && ( -
setModalType(null)}> -
e.stopPropagation()} style={{ maxWidth: '480px' }}> +
{ if (e.target === e.currentTarget) setModalType(null) }}> +

{newRecoveryCodes ? 'Your new recovery codes' : 'Regenerate recovery codes'}

- +
{modalError &&

{modalError}

} From 475f38e1555e8597e35b623843afffe1bf29ea47 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Thu, 3 Sep 2026 17:06:39 +0500 Subject: [PATCH 53/71] feat: add initial HTML structure and styling for Aptify landing page --- demo9.html | 728 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 728 insertions(+) create mode 100644 demo9.html diff --git a/demo9.html b/demo9.html new file mode 100644 index 0000000..3da5d26 --- /dev/null +++ b/demo9.html @@ -0,0 +1,728 @@ + + + + + + Aptify + + + + + + + + + +
+
+
+ + +
aptify-cli v1.1.0
+
vault-agent 2.1.0-rc2
+
kernel-headers 6.10
+
openssl 3.3.1
+
node-runtime 22.6
+
postgresql-16
+ +
+ +
+ + +
+
Package Release Control
+

+ Your packages.
+ Signed. Indexed.
Served.
+

+

+ A self-hosted Debian & RPM registry engineered to move packages from your CI/CD pipeline to production servers in under a second — cryptographically sealed every step. +

+ +
+
+ 18ms + Index Rebuild +
+
+ 0+ + Packages Served +
+
+ ED25519 + Release Signing +
+
+ + +
+
+ nginx 1.27.0 · stable + aptify-cli 1.1.0 · amd64/arm64 + vault-mesh-agent 2.1.0-rc2 · testing + node-runtime 22.6.0 · lts + kernel-headers 6.10.4-1 · stable + postgresql 16.4 · stable + + nginx 1.27.0 · stable + aptify-cli 1.1.0 · amd64/arm64 + vault-mesh-agent 2.1.0-rc2 · testing + node-runtime 22.6.0 · lts + kernel-headers 6.10.4-1 · stable + postgresql 16.4 · stable +
+
+
+ +
+ DEB (APT) · RPM (YUM/DNF) · amd64 · arm64 · TOTP 2FA · Aptify v1.1.0 +
+
+ + +
+
+ +
+ +
+ +
+
+
+ STEP 01 / 02 +
+ + +
+

Sign in

+

Access your private package registry.

+ + +
+ +
+ +
+
+
+ +
+ +
+
+ + +
+ + +
+

Security Code

+

6-digit TOTP from your authenticator app.

+ +
+
+ +
+ +
+
+ +
+ + +
+ +
+ + + + + CSRF Isolated · HMAC-SHA256 Session Tokens +
+
+
+
+
+ + + + + From 2bd724d8a37c91244b38249c840cfe4abb3fe1a7 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Mon, 7 Sep 2026 11:49:35 +0500 Subject: [PATCH 54/71] feat: remove demo9.html file to clean up unused resources --- demo9.html | 728 ----------------------------------------------------- 1 file changed, 728 deletions(-) delete mode 100644 demo9.html diff --git a/demo9.html b/demo9.html deleted file mode 100644 index 3da5d26..0000000 --- a/demo9.html +++ /dev/null @@ -1,728 +0,0 @@ - - - - - - Aptify - - - - - - - - - -
-
-
- - -
aptify-cli v1.1.0
-
vault-agent 2.1.0-rc2
-
kernel-headers 6.10
-
openssl 3.3.1
-
node-runtime 22.6
-
postgresql-16
- -
- - - - - From dc4268a5927f3621b8cf1f4eb932e093909f60a9 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Mon, 7 Sep 2026 12:00:54 +0500 Subject: [PATCH 55/71] feat: update version to v1.1.1 across all relevant files --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- Dockerfile | 2 +- README.md | 2 +- aptify-stack.yml | 2 +- cmd/aptify-cli/main.go | 1 + cmd/aptify-cli/main_test.go | 2 +- cmd/server/main.go | 4 ++-- docker-compose.yml | 2 +- internal/api/security_test.go | 2 +- web/package-lock.json | 4 ++-- web/package.json | 2 +- 11 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ca027ac..fdb347b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -28,7 +28,7 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g. Ubuntu 22.04] - Deployment Method: [e.g. Docker, Binary] - Browser: [e.g. Chrome, Firefox] -- Version: [e.g. v1.1.0 or commit hash] +- Version: [e.g. v1.1.1 or commit hash] **Additional context** Add any other context about the problem here (e.g. logs from the docker container). diff --git a/Dockerfile b/Dockerfile index bf8c8f9..b6cbc6a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . COPY --from=frontend /app/internal/web/dist ./internal/web/dist -ARG VERSION=v1.1.0 +ARG VERSION=v1.1.1 RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify-cli ./cmd/aptify-cli diff --git a/README.md b/README.md index 3d1b95b..fc059e4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Aptify — Self-hosted APT, simplified -![Version](https://img.shields.io/badge/version-v1.1.0-blue.svg) +![Version](https://img.shields.io/badge/version-v1.1.1-blue.svg) Aptify is a self-hosted APT repository manager with a clean web UI. Host, sign, and serve your own `.deb` packages without any external infrastructure. diff --git a/aptify-stack.yml b/aptify-stack.yml index 37198da..48f2639 100644 --- a/aptify-stack.yml +++ b/aptify-stack.yml @@ -6,7 +6,7 @@ services: build: context: . args: - VERSION: ${VERSION:-v1.1.0} + VERSION: ${VERSION:-v1.1.1} volumes: - apt-data:/data environment: diff --git a/cmd/aptify-cli/main.go b/cmd/aptify-cli/main.go index 7583ae3..6f59119 100644 --- a/cmd/aptify-cli/main.go +++ b/cmd/aptify-cli/main.go @@ -19,6 +19,7 @@ import ( // version is overridden by release builds via -ldflags "-X main.version=vX.Y.Z". var version = "v1.1.0" +var version = "v1.1.1" // Config is stored at ~/.config/aptify/config.json. type Config struct { diff --git a/cmd/aptify-cli/main_test.go b/cmd/aptify-cli/main_test.go index 2a1f6ac..90b7256 100644 --- a/cmd/aptify-cli/main_test.go +++ b/cmd/aptify-cli/main_test.go @@ -134,7 +134,7 @@ func TestLoginCreatesOneReusableAPIKey(t *testing.T) { t.Fatal(err) } - handler := aptifyapi.New(db, nil, nil, nil, "integration_test_jwt_secret_32_chars", "v1.1.0", nil) + handler := aptifyapi.New(db, nil, nil, nil, "integration_test_jwt_secret_32_chars", "v1.1.1", nil) jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) diff --git a/cmd/server/main.go b/cmd/server/main.go index ba8ea86..d22e66c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,8 +23,8 @@ import ( ) // version is injected at build time via -ldflags "-X main.version=vX.Y.Z". -// It defaults to "v1.1.0" for local builds. -var version = "v1.1.0" +// It defaults to "v1.1.1" for local builds. +var version = "v1.1.1" // knownWeakPasswords is an explicit blocklist of credentials that must never // be accepted in production. Fail fast if an operator forgets to rotate them. diff --git a/docker-compose.yml b/docker-compose.yml index 7c3c9dc..d03d782 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . args: - VERSION: ${VERSION:-v1.1.0} + VERSION: ${VERSION:-v1.1.1} ports: - "8080:8080" volumes: diff --git a/internal/api/security_test.go b/internal/api/security_test.go index e36a66a..f9f8abf 100644 --- a/internal/api/security_test.go +++ b/internal/api/security_test.go @@ -38,7 +38,7 @@ func setupTestHandler(t *testing.T) *Handler { t.Fatalf("failed to create signer: %v", err) } - return New(db, nil, nil, signer, "super_secret_test_jwt_key", "v1.1.0", nil) + return New(db, nil, nil, signer, "super_secret_test_jwt_key", "v1.1.1", nil) } func TestSecurityHeaders(t *testing.T) { diff --git a/web/package-lock.json b/web/package-lock.json index ac7390c..fdbbe9b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "aptify-web", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aptify-web", - "version": "1.1.0", + "version": "1.1.1", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/web/package.json b/web/package.json index 01a8b0f..4d2811a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "aptify-web", "private": true, - "version": "1.1.0", + "version": "1.1.1", "type": "module", "scripts": { "dev": "vite", From 5dced89b9fe72d6865403d7b0082b9b8fcd0cb3b Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Mon, 7 Sep 2026 12:05:48 +0500 Subject: [PATCH 56/71] feat: update version to v1.1.1 in main.go --- cmd/aptify-cli/main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/aptify-cli/main.go b/cmd/aptify-cli/main.go index 6f59119..82011d8 100644 --- a/cmd/aptify-cli/main.go +++ b/cmd/aptify-cli/main.go @@ -18,7 +18,6 @@ import ( ) // version is overridden by release builds via -ldflags "-X main.version=vX.Y.Z". -var version = "v1.1.0" var version = "v1.1.1" // Config is stored at ~/.config/aptify/config.json. From 9fd6d0177e0a5f6fb465da115c7257d03531e3b1 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Tue, 15 Sep 2026 22:32:03 +0500 Subject: [PATCH 57/71] fix: add gosec annotations and comment out optional mysql service --- internal/api/router.go | 1 + internal/api/totp.go | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index c39154f..bb1f26f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -217,5 +217,6 @@ func (h *Handler) serveRepoFile(w http.ResponseWriter, r *http.Request) { return } + /* #nosec G703 */ http.ServeFile(w, r, cleanPath) } diff --git a/internal/api/totp.go b/internal/api/totp.go index 11e98af..1377a25 100644 --- a/internal/api/totp.go +++ b/internal/api/totp.go @@ -3,7 +3,7 @@ package api import ( "crypto/hmac" "crypto/rand" - "crypto/sha1" + "crypto/sha1" // #nosec G505 -- RFC 6238 specifies HMAC-SHA1 "crypto/subtle" "encoding/base32" "encoding/base64" @@ -35,7 +35,12 @@ func GenerateTOTP(secret string, t time.Time) (string, error) { return "", fmt.Errorf("invalid base32 secret: %w", err) } - counter := uint64(t.Unix() / 30) + unixTime := t.Unix() + if unixTime < 0 { + return "", fmt.Errorf("timestamp before unix epoch is not supported") + } + /* #nosec G115 */ + counter := uint64(unixTime / 30) buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, counter) From 0a4a6b5340c9e717523a0ce608959ae622f68787 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Tue, 15 Sep 2026 22:53:54 +0500 Subject: [PATCH 58/71] refactor: redesign package flow component and comment out optional database service in docker-compose --- docker-compose.yml | 34 ++++++++++---------- web/src/pages/Login.css | 71 ++++++++++++++++++++++++++++++++++------- web/src/pages/Login.tsx | 35 +++++++++++++++++--- 3 files changed, 107 insertions(+), 33 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d03d782..0b42fbc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,27 +26,27 @@ services: # To use MySQL, set DB_TYPE=mysql and provide DB_DSN via environment. # See .env.example for the DSN format. Do NOT put credentials here. - DB_TYPE=${DB_TYPE:-sqlite} - - DB_DSN=${DB_DSN:-} + # - DB_DSN=${DB_DSN:-} restart: unless-stopped - depends_on: - - db # Uncomment when using the MySQL service below + # depends_on: + # - db # Uncomment when using the MySQL service below # Uncomment the db service to use MySQL instead of the default SQLite. # Provide MYSQL_PASSWORD and MYSQL_ROOT_PASSWORD via a .env file or secrets manager. - db: - image: mysql:8 - environment: - - MYSQL_DATABASE=aptify - - MYSQL_USER=aptify - # REQUIRED: set in .env or via secrets manager — do not hardcode here. - - MYSQL_PASSWORD=${MYSQL_PASSWORD} - - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} - ports: - # Bind to loopback only — do not expose MySQL to the public network. - - "127.0.0.1:3306:3306" - volumes: - - mysql-data:/var/lib/mysql + # db: + # image: mysql:8 + # environment: + # - MYSQL_DATABASE=aptify + # - MYSQL_USER=aptify + # # REQUIRED: set in .env or via secrets manager — do not hardcode here. + # - MYSQL_PASSWORD=${MYSQL_PASSWORD} + # - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + # ports: + # # Bind to loopback only — do not expose MySQL to the public network. + # - "127.0.0.1:3306:3306" + # volumes: + # - mysql-data:/var/lib/mysql volumes: apt-data: - mysql-data: + # mysql-data: diff --git a/web/src/pages/Login.css b/web/src/pages/Login.css index 1b6d5d5..fb13cb5 100644 --- a/web/src/pages/Login.css +++ b/web/src/pages/Login.css @@ -19,17 +19,60 @@ .login-story-copy h1 em { color:transparent; background:linear-gradient(100deg,#5eead4,#2dd4bf 55%,#fbbf24); background-clip:text; -webkit-background-clip:text; font-style:normal; } .login-story-copy p { max-width:570px; margin-top:23px; color:#909baa; font-size:clamp(13px,1.15vw,16px); line-height:1.7; } -.package-flow { position:relative; display:grid; grid-template-columns:repeat(3,1fr); gap:clamp(12px,2vw,26px); max-width:720px; margin-bottom:38px; } -.flow-glow { position:absolute; inset:-45px 8% -35px; z-index:-1; background:radial-gradient(ellipse,rgba(45,212,191,.075),transparent 68%); } -.flow-track { position:absolute; top:21px; left:14%; right:14%; height:1px; overflow:visible; background:linear-gradient(90deg,rgba(45,212,191,.2),var(--accent),rgba(245,158,11,.35)); } -.flow-track::after { content:''; position:absolute; width:72px; height:1px; background:linear-gradient(90deg,transparent,#fff,transparent); animation:flow-pulse 3.2s ease-in-out infinite; } -.flow-track span { position:absolute; top:-2px; width:5px; height:5px; border-radius:50%; background:var(--accent-hover); box-shadow:0 0 9px var(--accent); }.flow-track span:nth-child(1){left:0}.flow-track span:nth-child(2){left:33%}.flow-track span:nth-child(3){left:66%}.flow-track span:nth-child(4){right:0;background:#fbbf24;box-shadow:0 0 9px rgba(245,158,11,.7)} -@keyframes flow-pulse { from{left:-60px} to{left:100%} } -.flow-step { position:relative; display:grid; grid-template-columns:43px 1fr; grid-template-rows:auto auto; align-items:center; column-gap:10px; min-width:0; } -.flow-step i { grid-row:1/-1; display:grid; place-items:center; width:43px; height:43px; color:var(--accent-hover); background:rgba(17,23,30,.96); border:1px solid rgba(45,212,191,.25); border-radius:12px; box-shadow:0 10px 25px rgba(0,0,0,.3),0 0 0 5px rgba(45,212,191,.025); font-style:normal; } -.flow-publish i { color:#fbbf24; border-color:rgba(245,158,11,.25); box-shadow:0 10px 25px rgba(0,0,0,.3),0 0 0 5px rgba(245,158,11,.025); } -.flow-step strong { align-self:end; overflow:hidden; color:var(--text); font-size:11px; text-overflow:ellipsis; } -.flow-step small { align-self:start; overflow:hidden; color:var(--muted); font-family:var(--mono); font-size:8px; text-overflow:ellipsis; white-space:nowrap; } +.package-flow { position:relative; display:flex; align-items:center; justify-content:space-between; max-width:680px; margin-bottom:38px; padding:6px 0; } +.flow-glow { position:absolute; inset:-30px 0; z-index:0; background:radial-gradient(ellipse 65% 50% at 50% 50%,rgba(45,212,191,.08),transparent 70%); pointer-events:none; } +.flow-step { position:relative; z-index:1; display:flex; align-items:center; gap:11px; flex-shrink:0; } +.flow-step i { display:grid; place-items:center; width:43px; height:43px; color:var(--accent-hover); background:rgba(17,23,30,.96); border:1px solid rgba(45,212,191,.25); border-radius:12px; box-shadow:0 10px 25px rgba(0,0,0,.3),0 0 0 1px rgba(255,255,255,.04); font-style:normal; transition:all .3s ease; } +.flow-publish i { color:#fbbf24; border-color:rgba(245,158,11,.25); } +.flow-step-text { display:flex; flex-direction:column; gap:2px; } +.flow-step strong { color:var(--text); font-size:11.5px; font-weight:750; letter-spacing:-.01em; } +.flow-step small { color:var(--muted); font-family:var(--mono); font-size:8.5px; white-space:nowrap; } + +.flow-source i { animation:icon-source-pulse 3.6s ease-in-out infinite; } +.flow-sign i { animation:icon-sign-pulse 3.6s ease-in-out infinite; } +.flow-publish i { animation:icon-publish-pulse 3.6s ease-in-out infinite; } + +@keyframes icon-source-pulse { + 0%,10% { border-color:rgba(94,234,212,.7); box-shadow:0 0 18px rgba(45,212,191,.35),0 10px 25px rgba(0,0,0,.3); } + 25%,100% { border-color:rgba(45,212,191,.25); box-shadow:0 10px 25px rgba(0,0,0,.3); } +} +@keyframes icon-sign-pulse { + 0%,28% { border-color:rgba(45,212,191,.25); box-shadow:0 10px 25px rgba(0,0,0,.3); transform:scale(1); } + 38%,48% { border-color:rgba(94,234,212,.85); box-shadow:0 0 22px rgba(45,212,191,.45),0 10px 25px rgba(0,0,0,.3); transform:scale(1.05); } + 58%,100% { border-color:rgba(45,212,191,.25); box-shadow:0 10px 25px rgba(0,0,0,.3); transform:scale(1); } +} +@keyframes icon-publish-pulse { + 0%,65% { border-color:rgba(245,158,11,.25); box-shadow:0 10px 25px rgba(0,0,0,.3); transform:scale(1); } + 75%,88% { border-color:rgba(251,191,36,.85); box-shadow:0 0 24px rgba(245,158,11,.45),0 10px 25px rgba(0,0,0,.3); transform:scale(1.05); } + 98%,100% { border-color:rgba(245,158,11,.25); box-shadow:0 10px 25px rgba(0,0,0,.3); transform:scale(1); } +} + +.flow-connector { position:relative; z-index:1; flex:1; min-width:24px; margin:0 12px; height:2px; display:flex; align-items:center; } +.connector-track { position:relative; width:100%; height:2px; border-radius:999px; background:rgba(48,55,68,.6); } +.connector-1 .connector-track { background:linear-gradient(90deg,rgba(45,212,191,.4),rgba(45,212,191,.15)); } +.connector-2 .connector-track { background:linear-gradient(90deg,rgba(45,212,191,.2),rgba(245,158,11,.35)); } + +.connector-track::before,.connector-track::after { content:''; position:absolute; top:-2px; width:6px; height:6px; border-radius:50%; background:#11171e; border:1px solid rgba(45,212,191,.45); } +.connector-track::before { left:-3px; } +.connector-track::after { right:-3px; } +.connector-2 .connector-track::after { border-color:rgba(245,158,11,.55); } + +.connector-beam { position:absolute; top:-1px; height:4px; width:30px; border-radius:999px; pointer-events:none; } +.connector-1 .connector-beam { background:linear-gradient(90deg,transparent,#5eead4 60%,#fff 90%,transparent); box-shadow:0 0 12px var(--accent-hover),0 0 4px #fff; animation:pulse-stream-1 3.6s cubic-bezier(0.4,0,0.2,1) infinite; } +.connector-2 .connector-beam { background:linear-gradient(90deg,transparent,#fbbf24 60%,#fff 90%,transparent); box-shadow:0 0 12px #fbbf24,0 0 4px #fff; animation:pulse-stream-2 3.6s cubic-bezier(0.4,0,0.2,1) infinite; } + +@keyframes pulse-stream-1 { + 0% { left:-30px; opacity:0; } + 5% { opacity:1; } + 32% { opacity:1; } + 37%,100% { left:calc(100% + 4px); opacity:0; } +} +@keyframes pulse-stream-2 { + 0%,38% { left:-30px; opacity:0; } + 43% { opacity:1; } + 70% { opacity:1; } + 75%,100% { left:calc(100% + 4px); opacity:0; } +} .story-proof { display:grid; grid-template-columns:repeat(3,1fr); gap:20px; padding-top:24px; border-top:1px solid rgba(48,55,68,.6); } .story-proof > div { display:flex; flex-direction:column; } .story-proof strong { color:var(--text-2); font-size:11px; }.story-proof span { color:var(--muted); font-size:9px; } @@ -64,7 +107,11 @@ .login-root { grid-template-columns:minmax(0,.9fr) minmax(420px,1.1fr); } .login-story { padding-inline:40px; } .login-story-copy h1 { font-size:48px; } - .package-flow { grid-template-columns:1fr; gap:11px; }.flow-track,.flow-glow { display:none; }.flow-step { grid-template-columns:36px 1fr; }.flow-step i { width:36px; height:36px; border-radius:9px; }.story-proof { grid-template-columns:1fr; gap:9px; }.story-proof > div { flex-direction:row; justify-content:space-between; } + .package-flow { flex-direction:column; align-items:stretch; gap:12px; } + .flow-connector,.flow-glow { display:none; } + .flow-step i { width:38px; height:38px; border-radius:10px; } + .story-proof { grid-template-columns:1fr; gap:9px; } + .story-proof > div { flex-direction:row; justify-content:space-between; } } @media (max-width:780px) { .login-root { display:block; min-height:100svh; } diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 6dc0e11..5ba88c6 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -89,10 +89,37 @@ export default function Login() { )} + {confirmDialog}
) } From 8d396e6235c3a606bd7237bff0eaf982c64ad487 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Fri, 2 Oct 2026 22:49:59 +0500 Subject: [PATCH 67/71] feat: add middleware to trust X-Forwarded-For/X-Real-IP headers from reverse proxy --- internal/api/router.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/api/router.go b/internal/api/router.go index 77ef951..ac46158 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -41,6 +41,10 @@ func New(db *storage.DB, fs *storage.FileStore, gen *index.Generator, signer *si func (h *Handler) Router(spa http.Handler) http.Handler { r := chi.NewRouter() + // Trust X-Forwarded-For/X-Real-IP from the reverse proxy (Pangolin/Traefik). + // The app port is only reachable via the proxy's docker network, so the + // headers can't be spoofed by outside clients. + r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(SecurityHeaders) From 9c2c619975fbd06abb13eae6e139f0ebb37194c8 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Fri, 2 Oct 2026 22:56:36 +0500 Subject: [PATCH 68/71] feat: update version to v2.1.1 and enhance password input fields with autocomplete attributes --- Dockerfile | 2 +- VERSION | 2 +- aptify-stack.yml | 2 +- cmd/aptify-cli/main.go | 2 +- cmd/server/main.go | 4 ++-- docker-compose.yml | 2 +- web/package.json | 2 +- web/src/pages/Profile.tsx | 2 ++ web/src/pages/Users.tsx | 6 +++--- 9 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index b6cbc6a..08a7654 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: build React frontend -FROM node:20-alpine AS frontend +FROM node:24-alpine AS frontend WORKDIR /app/web COPY web/package*.json ./ RUN npm ci diff --git a/VERSION b/VERSION index 56130fb..826e142 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.1.1 +v2.1.1 diff --git a/aptify-stack.yml b/aptify-stack.yml index 48f2639..635f152 100644 --- a/aptify-stack.yml +++ b/aptify-stack.yml @@ -6,7 +6,7 @@ services: build: context: . args: - VERSION: ${VERSION:-v1.1.1} + VERSION: ${VERSION:-v2.1.1} volumes: - apt-data:/data environment: diff --git a/cmd/aptify-cli/main.go b/cmd/aptify-cli/main.go index 82011d8..888a7c9 100644 --- a/cmd/aptify-cli/main.go +++ b/cmd/aptify-cli/main.go @@ -18,7 +18,7 @@ import ( ) // version is overridden by release builds via -ldflags "-X main.version=vX.Y.Z". -var version = "v1.1.1" +var version = "v2.1.1" // Config is stored at ~/.config/aptify/config.json. type Config struct { diff --git a/cmd/server/main.go b/cmd/server/main.go index d22e66c..806575b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,8 +23,8 @@ import ( ) // version is injected at build time via -ldflags "-X main.version=vX.Y.Z". -// It defaults to "v1.1.1" for local builds. -var version = "v1.1.1" +// It defaults to "v2.1.1" for local builds. +var version = "v2.1.1" // knownWeakPasswords is an explicit blocklist of credentials that must never // be accepted in production. Fail fast if an operator forgets to rotate them. diff --git a/docker-compose.yml b/docker-compose.yml index 0b42fbc..70b3cb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . args: - VERSION: ${VERSION:-v1.1.1} + VERSION: ${VERSION:-v2.1.1} ports: - "8080:8080" volumes: diff --git a/web/package.json b/web/package.json index 4d2811a..f09777d 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "aptify-web", "private": true, - "version": "1.1.1", + "version": "2.1.1", "type": "module", "scripts": { "dev": "vite", diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index 0bc8e79..0949c80 100644 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -373,6 +373,7 @@ export default function Profile() { setPasswordInput(e.target.value)} placeholder="Enter your current password" @@ -421,6 +422,7 @@ export default function Profile() { setPasswordInput(e.target.value)} placeholder="Enter password" diff --git a/web/src/pages/Users.tsx b/web/src/pages/Users.tsx index 3be374b..ea16f2b 100644 --- a/web/src/pages/Users.tsx +++ b/web/src/pages/Users.tsx @@ -190,7 +190,7 @@ export default function Users() {
- setNewUser(f => ({ ...f, password: e.target.value }))} minLength={8} required /> + setNewUser(f => ({ ...f, password: e.target.value }))} minLength={8} required />
@@ -218,7 +218,7 @@ export default function Users() {

Workspace users

{visibleUsers.length === users.length ? `${users.length} account${users.length === 1 ? '' : 's'}` : `${visibleUsers.length} of ${users.length} accounts`}

- +
@@ -263,7 +263,7 @@ export default function Users() { {editing === u.id ? (
- setEditPassword(e.target.value)} /> + setEditPassword(e.target.value)} />
From 8e30a9bc9f5fdc12c714c3cc700c8c8d8cd2a9dc Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Fri, 2 Oct 2026 22:58:27 +0500 Subject: [PATCH 69/71] feat: update version to v2.1.1 in various files and tests --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- Dockerfile | 2 +- README.md | 2 +- cmd/aptify-cli/main_test.go | 2 +- internal/api/security_test.go | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index fdb347b..a6dd5f9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -28,7 +28,7 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g. Ubuntu 22.04] - Deployment Method: [e.g. Docker, Binary] - Browser: [e.g. Chrome, Firefox] -- Version: [e.g. v1.1.1 or commit hash] +- Version: [e.g. v2.1.1 or commit hash] **Additional context** Add any other context about the problem here (e.g. logs from the docker container). diff --git a/Dockerfile b/Dockerfile index 08a7654..fecbc59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . COPY --from=frontend /app/internal/web/dist ./internal/web/dist -ARG VERSION=v1.1.1 +ARG VERSION=v2.1.1 RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /aptify-cli ./cmd/aptify-cli diff --git a/README.md b/README.md index fc059e4..e660661 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Aptify — Self-hosted APT, simplified -![Version](https://img.shields.io/badge/version-v1.1.1-blue.svg) +![Version](https://img.shields.io/badge/version-v2.1.1-blue.svg) Aptify is a self-hosted APT repository manager with a clean web UI. Host, sign, and serve your own `.deb` packages without any external infrastructure. diff --git a/cmd/aptify-cli/main_test.go b/cmd/aptify-cli/main_test.go index 90b7256..25a94cd 100644 --- a/cmd/aptify-cli/main_test.go +++ b/cmd/aptify-cli/main_test.go @@ -134,7 +134,7 @@ func TestLoginCreatesOneReusableAPIKey(t *testing.T) { t.Fatal(err) } - handler := aptifyapi.New(db, nil, nil, nil, "integration_test_jwt_secret_32_chars", "v1.1.1", nil) + handler := aptifyapi.New(db, nil, nil, nil, "integration_test_jwt_secret_32_chars", "v2.1.1", nil) jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) diff --git a/internal/api/security_test.go b/internal/api/security_test.go index f9f8abf..10dcd92 100644 --- a/internal/api/security_test.go +++ b/internal/api/security_test.go @@ -38,7 +38,7 @@ func setupTestHandler(t *testing.T) *Handler { t.Fatalf("failed to create signer: %v", err) } - return New(db, nil, nil, signer, "super_secret_test_jwt_key", "v1.1.1", nil) + return New(db, nil, nil, signer, "super_secret_test_jwt_key", "v2.1.1", nil) } func TestSecurityHeaders(t *testing.T) { From 08ae190152d41d2f14e8e8de1d67735a6817bd48 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Fri, 2 Oct 2026 23:02:51 +0500 Subject: [PATCH 70/71] feat: update version to v2.1.1 in package-lock.json --- web/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index fdbbe9b..efef35c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "aptify-web", - "version": "1.1.1", + "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aptify-web", - "version": "1.1.1", + "version": "2.1.1", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", From 206c34ed65a6607daa9bc371cd497413ad51de08 Mon Sep 17 00:00:00 2001 From: Wajahat Ali Date: Fri, 2 Oct 2026 23:10:18 +0500 Subject: [PATCH 71/71] chore: update dependencies to latest versions - downgraded @esbuild packages from 0.27.7 to 0.27.2 - updated @remix-run/router from 1.23.3 to 1.23.4 - updated baseline-browser-mapping from 2.10.35 to 2.11.27 - updated browserslist from 4.28.2 to 4.29.3 - updated caniuse-lite from 1.0.30001797 to 1.0.30001814 - updated electron-to-chromium from 1.5.370 to 1.5.444 - updated esbuild from 0.27.7 to 0.27.2 - updated nanoid from 3.3.12 to 3.3.19 - updated node-releases from 2.0.47 to 2.0.57 - updated postcss from 8.5.15 to 8.5.28 - updated react-router from 6.30.4 to 6.30.6 - updated react-router-dom from 6.30.4 to 6.30.6 - updated update-browserslist-db from 1.2.3 to 1.3.3 --- web/package-lock.json | 298 +++++++++++++++++++++--------------------- 1 file changed, 149 insertions(+), 149 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index efef35c..5013f25 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -303,9 +303,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "cpu": [ "ppc64" ], @@ -320,9 +320,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "cpu": [ "arm" ], @@ -337,9 +337,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "cpu": [ "arm64" ], @@ -354,9 +354,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "cpu": [ "x64" ], @@ -371,9 +371,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -388,9 +388,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "cpu": [ "x64" ], @@ -405,9 +405,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "cpu": [ "arm64" ], @@ -422,9 +422,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "cpu": [ "x64" ], @@ -439,9 +439,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "cpu": [ "arm" ], @@ -456,9 +456,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "cpu": [ "arm64" ], @@ -473,9 +473,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "cpu": [ "ia32" ], @@ -490,9 +490,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "cpu": [ "loong64" ], @@ -507,9 +507,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "cpu": [ "mips64el" ], @@ -524,9 +524,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "cpu": [ "ppc64" ], @@ -541,9 +541,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "cpu": [ "riscv64" ], @@ -558,9 +558,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "cpu": [ "s390x" ], @@ -575,9 +575,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "cpu": [ "x64" ], @@ -592,9 +592,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "cpu": [ "arm64" ], @@ -609,9 +609,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "cpu": [ "x64" ], @@ -626,9 +626,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "cpu": [ "arm64" ], @@ -643,9 +643,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", "cpu": [ "x64" ], @@ -660,9 +660,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "cpu": [ "arm64" ], @@ -677,9 +677,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "cpu": [ "x64" ], @@ -694,9 +694,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "cpu": [ "arm64" ], @@ -711,9 +711,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "cpu": [ "ia32" ], @@ -728,9 +728,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "cpu": [ "x64" ], @@ -795,9 +795,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", - "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -1301,9 +1301,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.35", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", - "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1314,9 +1314,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -1334,11 +1334,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -1348,9 +1348,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001797", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", - "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "dev": true, "funding": [ { @@ -1401,16 +1401,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.370", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.370.tgz", - "integrity": "sha512-D5tSHJReAb/Kf3Hu9F/GO4lJuSWzEWHwvQ/kKSUP7pimNgvxkSKj+gUQhHpKKACwrin7rS3byU7IxreF56rl5g==", + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", "dev": true, "license": "ISC" }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1421,32 +1421,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/escalade": { @@ -1564,9 +1564,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -1583,9 +1583,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, "license": "MIT", "engines": { @@ -1613,9 +1613,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -1633,7 +1633,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1677,12 +1677,12 @@ } }, "node_modules/react-router": { - "version": "6.30.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", - "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.3" + "@remix-run/router": "1.23.4" }, "engines": { "node": ">=14.0.0" @@ -1692,13 +1692,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", - "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.3", - "react-router": "6.30.4" + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" }, "engines": { "node": ">=14.0.0" @@ -1814,9 +1814,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ {