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 8af7787..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 +# 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/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..a6dd5f9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +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. 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/.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/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 new file mode 100644 index 0000000..9d4cb4e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: [ "main", "develop" ] + pull_request: + branches: [ "main", "develop" ] + +# Minimal token permissions — principle of least privilege. +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: ubuntu-latest + 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 + with: + go-version: '1.25' + cache: true + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '20' + 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: | + 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 + + # ── 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@44c470ffc35caa8b1eb3e8012ca53c2f9bea4eb5 # v2.3.6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..37a4ec2 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,104 @@ +name: Docker Image CI/CD + +on: + push: + branches: [ "main", "develop" ] + tags: [ "v*" ] + pull_request: + branches: [ "main", "develop" ] + +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 + permissions: + contents: read + 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Verify version metadata + run: bash scripts/check-version.sh + + - 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 + + # Login against a Docker registry except on PR + - name: Log into Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + 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@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + with: + # 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=${{ 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 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5.4.0 + 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: | + 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 new file mode 100644 index 0000000..f543ce3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,115 @@ +name: Release CLI + +on: + push: + branches: + - main + paths: + - VERSION + tags: + - 'v*' + workflow_dispatch: + +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false + +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@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: Verify version metadata + run: bash scripts/check-version.sh + + - 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 + with: + go-version-file: 'go.mod' + cache: true + + - name: Build CLI binaries + env: + VERSION: ${{ steps.version.outputs.value }} + run: | + mkdir -p dist + + build_and_archive() { + local os=$1 + local arch=$2 + local ext="" + if [ "$os" = "windows" ]; then + ext=".exe" + fi + + echo "Building for $os/$arch..." + GOOS=$os GOARCH=$arch go build -ldflags="-s -w -X main.version=$VERSION" -o "dist/aptify-cli-${os}-${arch}${ext}" ./cmd/aptify-cli + } + + build_and_archive linux amd64 + build_and_archive linux arm64 + build_and_archive darwin amd64 + 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@c062e08bd532815e2082a85e87e3ef29c3e6d191 # v2.0.8 + 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/.gitignore b/.gitignore index 8658554..b1708e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,13 @@ # Dependencies web/node_modules/ -server +/server # Build outputs internal/web/dist/ web/dist/ -aptify +aptify +/bin/* # Data volumes data/ apt-data/ @@ -17,4 +18,6 @@ mysql-data/ apt-repository # Environment -.env \ No newline at end of file +.env +dist +testdata \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index acb9c84..fecbc59 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 @@ -8,21 +8,44 @@ COPY web/ ./ COPY internal/web/ ../internal/web/ RUN npm run build -# Stage 2: build Go binary +# Stage 2: build Go binaries FROM golang:1.25-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . COPY --from=frontend /app/internal/web/dist ./internal/web/dist -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /aptify ./cmd/server +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 -# Stage 3: minimal runtime image +# Stage 3: minimal runtime image (server only) FROM alpine:3.20 -RUN apk --no-cache add ca-certificates tzdata + +# 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 -ENTRYPOINT ["/app/aptify"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:8080/health || exit 1 + +# 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/Makefile b/Makefile index 3797a3d..d6534a4 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,19 @@ -.PHONY: build ui dev clean +VERSION := $(shell tr -d '[:space:]' < VERSION) +VERSION_LDFLAGS := -s -w -X main.version=$(VERSION) + +.PHONY: build build-cli check-version ui dev clean + +check-version: + bash scripts/check-version.sh ui: cd web && npm run build build: ui - go build -o aptify ./cmd/server + go build -ldflags="$(VERSION_LDFLAGS)" -o bin/aptify ./cmd/server + +build-cli: + go build -ldflags="$(VERSION_LDFLAGS)" -o bin/aptify-cli ./cmd/aptify-cli dev-backend: go run ./cmd/server @@ -13,4 +22,4 @@ dev-ui: cd web && npm run dev clean: - rm -rf aptify data/ internal/web/dist/ web/dist/ + rm -rf bin/ data/ internal/web/dist/ web/dist/ diff --git a/README.md b/README.md index 9d416de..e660661 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Aptify — Self-hosted APT, simplified +![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. ## Features @@ -29,6 +31,29 @@ Aptify is a self-hosted APT repository manager with a clean web UI. Host, sign, ``` 4. Open `http://localhost:8080` in your browser. +## CLI Installation & Usage + +Aptify provides a robust command-line client (`aptify-cli`) for easily pushing packages from your local machine or automating uploads in your CI/CD pipelines. + +Install it in one line (macOS, Linux, and Windows via Git Bash): + +```bash +curl -fsSL https://raw.githubusercontent.com/kernelcode0/aptify/main/install.sh | bash +``` + +**Usage Examples:** + +```bash +# 1. Login locally (creates and saves an API key to ~/.config/aptify/config.json) +aptify-cli login http://localhost:8080 + +# 2. Push a package to your repository +aptify-cli push my-repo package_1.0.0_amd64.deb + +# 3. Use it in a CI/CD script without logging in +aptify-cli push --server http://localhost:8080 --token YOUR_API_KEY my-repo *.deb +``` + ## Database Aptify defaults to SQLite — no configuration needed. For larger deployments, switch to MySQL: @@ -66,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! @@ -74,3 +101,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) diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..826e142 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +v2.1.1 diff --git a/aptify-stack.yml b/aptify-stack.yml new file mode 100644 index 0000000..635f152 --- /dev/null +++ b/aptify-stack.yml @@ -0,0 +1,53 @@ +version: "3.8" + +services: + aptify: + image: ${IMAGE:-aptify:latest} + build: + context: . + args: + VERSION: ${VERSION:-v2.1.1} + 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/cmd/aptify-cli/main.go b/cmd/aptify-cli/main.go new file mode 100644 index 0000000..888a7c9 --- /dev/null +++ b/cmd/aptify-cli/main.go @@ -0,0 +1,554 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/cookiejar" + "os" + "path/filepath" + "strings" + "syscall" + + "golang.org/x/term" +) + +// version is overridden by release builds via -ldflags "-X main.version=vX.Y.Z". +var version = "v2.1.1" + +// Config is stored at ~/.config/aptify/config.json. +type Config struct { + Server string `json:"server"` + Token string `json:"token"` +} + +func defaultConfigPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "aptify", "config.json") +} + +func loadConfig(path string) (*Config, error) { + /* #nosec G304 */ + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var c Config + return &c, json.Unmarshal(data, &c) +} + +func saveConfig(path string, c *Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} + +// commonFlags registers --server, --token, --config on a FlagSet. +type commonOpts struct { + server string + token string + configPath string +} + +func registerCommon(fs *flag.FlagSet) *commonOpts { + o := &commonOpts{} + fs.StringVar(&o.server, "server", "", "Override server URL") + fs.StringVar(&o.token, "token", "", "Override API token") + fs.StringVar(&o.configPath, "config", defaultConfigPath(), "Config file path") + return o +} + +// resolve loads the config and merges flag overrides, then validates. +func (o *commonOpts) resolve() (*Config, error) { + cfg, err := loadConfig(o.configPath) + if err != nil { + if os.IsNotExist(err) { + cfg = &Config{} + } else { + return nil, fmt.Errorf("read config: %w", err) + } + } + 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 + } + return cfg, nil +} + +func (o *commonOpts) requireConfig() (*Config, error) { + cfg, err := o.resolve() + if err != nil { + return nil, err + } + if cfg.Server == "" || cfg.Token == "" { + return nil, fmt.Errorf("no config found. Run: aptify-cli login ") + } + return cfg, nil +} + +// 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 + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if origin != "" { + req.Header.Set("Origin", origin) + } + return client.Do(req) +} + +func jsonBody(v any) (io.Reader, string, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, "", err + } + return bytes.NewReader(data), "application/json", nil +} + +func errBody(resp *http.Response) string { + b, _ := io.ReadAll(resp.Body) + var e struct { + Error string `json:"error"` + } + if json.Unmarshal(b, &e) == nil && e.Error != "" { + return e.Error + } + 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) { + 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 [--code ]") + os.Exit(1) + } + 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: ") + var username string + _, _ = fmt.Scanln(&username) + + fmt.Print("Password: ") + pwBytes, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + fmt.Fprintln(os.Stderr, "error reading password:", err) + os.Exit(1) + } + password := string(pwBytes) + + // 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} + 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) + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "login failed: %s\n", errBody(resp)) + os.Exit(1) + } + + 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() + keyName := "cli-" + hostname + body2, ct2, _ := jsonBody(map[string]string{"name": keyName}) + 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) + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusCreated { + fmt.Fprintf(os.Stderr, "create api key failed: %s\n", errBody(resp2)) + os.Exit(1) + } + var keyResp struct { + Key string `json:"key"` + } + if err := json.NewDecoder(resp2.Body).Decode(&keyResp); err != nil { + fmt.Fprintln(os.Stderr, "decode key response:", err) + os.Exit(1) + } + + // 3. Save API key (not the JWT) to config. + cfg := &Config{Server: serverURL, Token: keyResp.Key} + configPath := opts.configPath + if err := saveConfig(configPath, cfg); err != nil { + fmt.Fprintln(os.Stderr, "save config:", err) + os.Exit(1) + } + fmt.Printf("Logged in to %s. API key saved.\n", serverURL) +} + +func runPush(args []string) { + fs := flag.NewFlagSet("push", flag.ExitOnError) + opts := registerCommon(fs) + _ = fs.Parse(args) + + if fs.NArg() < 2 { + fmt.Fprintln(os.Stderr, "usage: aptify-cli push [ ...]") + os.Exit(1) + } + slug := fs.Arg(0) + patterns := fs.Args()[1:] + + cfg, err := opts.requireConfig() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + // Resolve repo slug → ID. + resp, err := apiDo("GET", cfg.Server+"/api/repos", cfg.Token, nil, "") + if err != nil { + fmt.Fprintln(os.Stderr, "list repos:", err) + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "list repos failed: %s\n", errBody(resp)) + os.Exit(1) + } + var repos []struct { + ID string `json:"id"` + Slug string `json:"slug"` + } + if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { + fmt.Fprintln(os.Stderr, "decode repos:", err) + os.Exit(1) + } + var repoID string + for _, r := range repos { + if r.Slug == slug { + repoID = r.ID + break + } + } + if repoID == "" { + fmt.Fprintf(os.Stderr, "repo %q not found\n", slug) + os.Exit(1) + } + + // Expand glob patterns. + var files []string + for _, p := range patterns { + if strings.ContainsAny(p, "*?[") { + matches, err := filepath.Glob(p) + if err != nil { + fmt.Fprintln(os.Stderr, "glob error:", err) + os.Exit(1) + } + files = append(files, matches...) + } else { + files = append(files, p) + } + } + if len(files) == 0 { + fmt.Fprintln(os.Stderr, "no files matched") + os.Exit(1) + } + + exitCode := 0 + for _, filePath := range files { + name := filepath.Base(filePath) + fmt.Printf("Pushing %s ... ", name) + + status, msg, err := uploadDeb(cfg.Server, repoID, cfg.Token, filePath) + if err != nil { + fmt.Printf("error: %v\n", err) + exitCode = 1 + continue + } + switch status { + case http.StatusCreated: + fmt.Println("done (201)") + case http.StatusConflict: + fmt.Println("already exists (409)") + default: + fmt.Printf("error %d: %s\n", status, msg) + exitCode = 1 + } + } + os.Exit(exitCode) +} + +func uploadDeb(serverURL, repoID, token, filePath string) (int, string, error) { + /* #nosec G304 */ + f, err := os.Open(filePath) + if err != nil { + return 0, "", err + } + defer f.Close() + + pr, pw := io.Pipe() + mw := multipart.NewWriter(pw) + go func() { + part, err := mw.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + pw.CloseWithError(err) + return + } + if _, err := io.Copy(part, f); err != nil { + pw.CloseWithError(err) + return + } + pw.CloseWithError(mw.Close()) + }() + + url := serverURL + "/api/repos/" + repoID + "/packages" + req, err := http.NewRequest("POST", url, pr) + if err != nil { + return 0, "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", mw.FormDataContentType()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, "", err + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b), nil +} + +func runRepos(args []string) { + fs := flag.NewFlagSet("repos", flag.ExitOnError) + opts := registerCommon(fs) + _ = fs.Parse(args) + + cfg, err := opts.requireConfig() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + resp, err := apiDo("GET", cfg.Server+"/api/repos", cfg.Token, nil, "") + if err != nil { + fmt.Fprintln(os.Stderr, "list repos:", err) + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "list repos failed: %s\n", errBody(resp)) + os.Exit(1) + } + var repos []struct { + Slug string `json:"slug"` + Name string `json:"name"` + Codename string `json:"codename"` + } + if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { + fmt.Fprintln(os.Stderr, "decode repos:", err) + os.Exit(1) + } + + fmt.Printf("%-20s %-30s %s\n", "SLUG", "NAME", "CODENAME") + for _, r := range repos { + fmt.Printf("%-20s %-30s %s\n", r.Slug, r.Name, r.Codename) + } +} + +func runWhoami(args []string) { + fs := flag.NewFlagSet("whoami", flag.ExitOnError) + opts := registerCommon(fs) + _ = fs.Parse(args) + + cfg, err := opts.requireConfig() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + resp, err := apiDo("GET", cfg.Server+"/api/auth/check", cfg.Token, nil, "") + if err != nil || resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "Not logged in or token expired. Run: aptify-cli login \n") + os.Exit(1) + } + _ = resp.Body.Close() + fmt.Printf("Server: %s\nToken: valid\n", cfg.Server) +} + +func runLogout(args []string) { + fs := flag.NewFlagSet("logout", flag.ExitOnError) + opts := registerCommon(fs) + _ = fs.Parse(args) + + if err := os.Remove(opts.configPath); err != nil && !os.IsNotExist(err) { + fmt.Fprintln(os.Stderr, "logout:", err) + os.Exit(1) + } + fmt.Println("Logged out.") +} + +// ---- main ------------------------------------------------------------------- + +func usage() { + fmt.Fprintf(os.Stderr, `aptify-cli %s — Aptify command-line client + +Commands: + login Authenticate and save an API key + push [...] Upload packages to a repository + repos List repositories + whoami Check authentication status + logout Remove saved credentials + +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() { + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + cmd := os.Args[1] + rest := os.Args[2:] + + switch cmd { + case "login": + runLogin(rest) + case "push": + runPush(rest) + case "repos": + runRepos(rest) + case "whoami": + runWhoami(rest) + case "logout": + runLogout(rest) + case "--help", "-h", "help": + usage() + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", cmd) + usage() + os.Exit(1) + } +} diff --git a/cmd/aptify-cli/main_test.go b/cmd/aptify-cli/main_test.go new file mode 100644 index 0000000..25a94cd --- /dev/null +++ b/cmd/aptify-cli/main_test.go @@ -0,0 +1,196 @@ +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 + 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") + } +} + +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") + } +} + +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", "v2.1.1", 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 new file mode 100644 index 0000000..806575b --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,234 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/kernelcode0/aptify/internal/api" + "github.com/kernelcode0/aptify/internal/index" + "github.com/kernelcode0/aptify/internal/indexqueue" + "github.com/kernelcode0/aptify/internal/signing" + "github.com/kernelcode0/aptify/internal/storage" + "github.com/kernelcode0/aptify/internal/web" + "golang.org/x/crypto/bcrypt" +) + +// version is injected at build time via -ldflags "-X main.version=vX.Y.Z". +// 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. +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 is a known insecure default value. " + + "Choose a strong, unique password.") + } +} + +func main() { + dataDir := os.Getenv("DATA_DIR") + if dataDir == "" { + dataDir = "./data" + } + + /* #nosec G703 */ + if err := os.MkdirAll(dataDir, 0750); 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() + + // --- 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", "") + + 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), "admin"); err != nil { + log.Fatalf("failed to create admin user: %v", err) + } + log.Printf("Created default admin user: %s", adminUser) // #nosec G706 + } + + 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) + + // Context cancelled on SIGINT/SIGTERM so the queue worker shuts down cleanly. + ctx, cancel := context.WithCancel(context.Background()) + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + cancel() + }() + + queue := indexqueue.New(gen, db) + queue.Start(ctx) + + handler := api.New(db, fs, gen, signer, jwtSecret, version, queue) + + spa := web.Handler() + r := handler.Router(spa) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + port = strings.ReplaceAll(strings.ReplaceAll(port, "\n", ""), "\r", "") + + srv := &http.Server{ + 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() { + log.Printf("Starting Aptify server on :%s", port) // #nosec G706 + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server error: %v", err) + } + }() + + <-ctx.Done() + log.Println("Shutting down...") + if err := srv.Close(); err != nil { + log.Printf("server close error: %v", err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index ef24cbd..70b3cb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ services: aptify: - build: . + build: + context: . + args: + VERSION: ${VERSION:-v2.1.1} ports: - "8080:8080" volumes: @@ -9,28 +12,38 @@ 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=${JWT_SECRET:-YOUR_GENERATED_STRING_HERE} - - 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 + # - db # Uncomment when using the MySQL service below - # Uncomment to use MySQL instead of the default SQLite + # 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 - # - MYSQL_PASSWORD=aptifypass - # - MYSQL_ROOT_PASSWORD=rootpass + # # REQUIRED: set in .env or via secrets manager — do not hardcode here. + # - MYSQL_PASSWORD=${MYSQL_PASSWORD} + # - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} # ports: - # - "3306:3306" + # # Bind to loopback only — do not expose MySQL to the public network. + # - "127.0.0.1:3306:3306" # volumes: # - mysql-data:/var/lib/mysql 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/go.mod b/go.mod index a40b2e9..4264967 100644 --- a/go.mod +++ b/go.mod @@ -11,16 +11,18 @@ require ( github.com/klauspost/compress v1.18.6 github.com/ulikunitz/xz v0.5.15 golang.org/x/crypto v0.53.0 + golang.org/x/term v0.44.0 modernc.org/sqlite v1.52.0 ) 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 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 f1c9ab4..e37ee0f 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= @@ -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= @@ -37,6 +39,8 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a147323 --- /dev/null +++ b/install.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -e + +REPO="kernelcode0/aptify" + +echo "Installing aptify-cli..." + +# Detect OS +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +case "$OS" in + linux*) os="linux" ;; + darwin*) os="darwin" ;; + msys*|cygwin*|mingw*) os="windows" ;; + *) echo "Unsupported OS: $OS"; exit 1 ;; +esac + +# Detect Architecture +ARCH="$(uname -m)" +case "$ARCH" in + x86_64|amd64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +EXT="" +if [ "$os" = "windows" ]; then + EXT=".exe" +fi + +BINARY_NAME="aptify-cli-${os}-${arch}${EXT}" + +# Fetch latest release URL +API_URL="https://api.github.com/repos/$REPO/releases/latest" +echo "Fetching latest release from $API_URL..." +DOWNLOAD_URL=$(curl -s "$API_URL" | grep -o "https://github.com/[^\"]*$BINARY_NAME") + +if [ -z "$DOWNLOAD_URL" ]; then + echo "Error: Could not find release asset $BINARY_NAME for your platform." + echo "You may need to compile from source or check the Releases page manually." + exit 1 +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" + +if [ "$os" = "windows" ]; then + # For windows via Git Bash/MSYS + DEST="$HOME/bin/aptify-cli.exe" + mkdir -p "$HOME/bin" + mv "$TMP_BIN" "$DEST" + echo "Installed to $DEST. Make sure $HOME/bin is in your PATH." +else + echo "Moving to $DEST (you may be prompted for your password)..." + sudo mv "$TMP_BIN" "$DEST" + echo "Installed successfully! Run 'aptify-cli --help' to get started." +fi diff --git a/internal/api/2fa_test.go b/internal/api/2fa_test.go new file mode 100644 index 0000000..8adb7a4 --- /dev/null +++ b/internal/api/2fa_test.go @@ -0,0 +1,479 @@ +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) + } +} + +func Test2FATrustDeviceFlow(t *testing.T) { + t.Setenv("LOGIN_RATE_LIMIT_ATTEMPTS", "50") + h := setupTestHandler(t) + router := h.Router(nil) + + // 1. 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)) + + // 2. Login requires 2FA initially + 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 req2FAResp struct { + Status string `json:"status"` + PreAuthToken string `json:"pre_auth_token"` + } + _ = json.NewDecoder(w.Body).Decode(&req2FAResp) + if req2FAResp.Status != "2fa_required" { + t.Fatalf("expected 2fa_required, got %s", req2FAResp.Status) + } + + // 3. Verify with trust_device: true + code, _ := GenerateTOTP(secret, time.Now()) + verifyReq := newPostRequest("/api/auth/2fa/verify", map[string]any{ + "pre_auth_token": req2FAResp.PreAuthToken, + "code": code, + "trust_device": true, + }) + w = httptest.NewRecorder() + router.ServeHTTP(w, verifyReq) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 from verify, got %d", w.Code) + } + + var trustCookie *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == "apt_2fa_trust" { + trustCookie = c + break + } + } + if trustCookie == nil || trustCookie.Value == "" { + t.Fatalf("expected apt_2fa_trust cookie to be issued") + } + + // 4. Subsequent login WITH trusted device cookie should skip 2FA! + reqWithTrust := newPostRequest("/api/auth/login", loginData) + reqWithTrust.AddCookie(trustCookie) + w = httptest.NewRecorder() + router.ServeHTTP(w, reqWithTrust) + 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 on trusted device, got %+v", loginResp) + } + + // 5. Subsequent login WITHOUT trusted device cookie still requires 2FA + reqUntrusted := newPostRequest("/api/auth/login", loginData) + w = httptest.NewRecorder() + router.ServeHTTP(w, reqUntrusted) + var untrustedResp struct { + Status string `json:"status"` + } + _ = json.NewDecoder(w.Body).Decode(&untrustedResp) + if untrustedResp.Status != "2fa_required" { + t.Fatalf("expected 2fa_required on untrusted device, got %s", untrustedResp.Status) + } + + // 6. Test logout endpoint + logoutReq := newPostRequest("/api/auth/logout", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, logoutReq) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 from logout, got %d", w.Code) + } + var sessionCleared bool + for _, c := range w.Result().Cookies() { + if c.Name == "apt_session" && c.MaxAge == -1 { + sessionCleared = true + break + } + } + if !sessionCleared { + t.Fatalf("expected apt_session cookie to be cleared on logout") + } + + // 7. Test forget-device endpoint clears apt_2fa_trust cookie + forgetReq := newPostRequest("/api/auth/2fa/forget-device", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, forgetReq) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 from forget-device, got %d", w.Code) + } + var trustCleared bool + for _, c := range w.Result().Cookies() { + if c.Name == "apt_2fa_trust" && c.MaxAge == -1 { + trustCleared = true + break + } + } + if !trustCleared { + t.Fatalf("expected apt_2fa_trust cookie to be cleared on forget-device") + } +} diff --git a/internal/api/apikeys.go b/internal/api/apikeys.go new file mode 100644 index 0000000..dca3fd0 --- /dev/null +++ b/internal/api/apikeys.go @@ -0,0 +1,102 @@ +package api + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/kernelcode0/aptify/internal/storage" + "golang.org/x/crypto/bcrypt" +) + +// generateAPIKey creates a new raw key, its 8-char prefix, and a bcrypt hash. +// Raw format: aptify_<32 base64url chars> +func generateAPIKey() (raw, prefix, hash string, err error) { + b := make([]byte, 24) + if _, err = rand.Read(b); err != nil { + return + } + raw = "aptify_" + base64.RawURLEncoding.EncodeToString(b) + prefix = raw[7:15] // first 8 chars after "aptify_" + hashBytes, err := bcrypt.GenerateFromPassword([]byte(raw), bcrypt.DefaultCost) + if err != nil { + return + } + hash = string(hashBytes) + return +} + +func (h *Handler) createAPIKey(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { + jsonError(w, "name is required", http.StatusBadRequest) + return + } + + userID, _ := r.Context().Value(ctxUserID).(string) + if userID == "" { + jsonError(w, "unauthorized", http.StatusUnauthorized) + return + } + + raw, prefix, hash, err := generateAPIKey() + if err != nil { + jsonError(w, "failed to generate key", http.StatusInternalServerError) + return + } + + key, err := h.db.CreateAPIKey(userID, req.Name, hash, prefix) + if err != nil { + jsonError(w, "db error: "+err.Error(), http.StatusInternalServerError) + return + } + + detail, _ := json.Marshal(map[string]string{"prefix": key.Prefix}) + h.audit(currentUser(r), "create_api_key", "apikey:"+key.Name, string(detail)) + // Return the raw key exactly once — it is never stored or returned again. + jsonOK(w, map[string]any{ + "id": key.ID, + "name": key.Name, + "prefix": key.Prefix, + "key": raw, + "created_at": key.CreatedAt, + }, http.StatusCreated) +} + +func (h *Handler) listAPIKeys(w http.ResponseWriter, r *http.Request) { + userID, _ := r.Context().Value(ctxUserID).(string) + if userID == "" { + jsonError(w, "unauthorized", http.StatusUnauthorized) + return + } + + keys, err := h.db.ListAPIKeys(userID) + if err != nil { + jsonError(w, "db error: "+err.Error(), http.StatusInternalServerError) + return + } + if keys == nil { + keys = []storage.APIKey{} + } + jsonOK(w, keys, http.StatusOK) +} + +func (h *Handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) { + keyID := chi.URLParam(r, "keyID") + userID, _ := r.Context().Value(ctxUserID).(string) + if userID == "" { + jsonError(w, "unauthorized", http.StatusUnauthorized) + return + } + + if err := h.db.DeleteAPIKey(keyID, userID); err != nil { + jsonError(w, "db error: "+err.Error(), http.StatusInternalServerError) + return + } + h.audit(currentUser(r), "delete_api_key", "apikey:"+keyID, "") + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/audit.go b/internal/api/audit.go new file mode 100644 index 0000000..827bab6 --- /dev/null +++ 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", "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 + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/auth.go b/internal/api/auth.go index 558b175..b393e91 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -2,56 +2,497 @@ 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"` + TrustDevice bool `json:"trust_device,omitempty"` +} + +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) { var req loginReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request", http.StatusBadRequest) + jsonError(w, "invalid request", http.StatusBadRequest) return } user, err := h.db.GetUserByUsername(req.Username) if err != nil { - http.Error(w, "server error", http.StatusInternalServerError) + jsonError(w, "server error", http.StatusInternalServerError) return } if user == nil { - http.Error(w, "invalid credentials", http.StatusUnauthorized) + jsonError(w, "invalid credentials", http.StatusUnauthorized) return } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { - http.Error(w, "invalid credentials", http.StatusUnauthorized) + jsonError(w, "invalid credentials", http.StatusUnauthorized) + 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 + } + + // Check if this browser/device is trusted for 30 days + if h.isDeviceTrusted(r, user.ID) { + h.issueSessionCookie(w, user) + h.audit(user, "login_trusted_device", "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 req.TrustDevice { + h.issue2FATrustCookie(w, user.ID) + } + 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.clear2FATrustCookie(w) + + 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, - "exp": time.Now().Add(24 * 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 { - http.Error(w, "failed to sign token", http.StatusInternalServerError) return } - jsonOK(w, map[string]string{"token": tokenString}, http.StatusOK) + http.SetCookie(w, &http.Cookie{ + Name: "apt_session", + Value: tokenString, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: 7200, // 2 hours + }) } func (h *Handler) authCheck(w http.ResponseWriter, r *http.Request) { - // If the request made it here, it passed the authMiddleware - jsonOK(w, map[string]bool{"authenticated": true}, http.StatusOK) + 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, + "two_factor_enabled": user.TOTPEnabled, + "device_trusted": h.isDeviceTrusted(r, user.ID), + }, http.StatusOK) +} + +func (h *Handler) logout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "apt_session", + Value: "", + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + }) + jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) +} + +func (h *Handler) forgetDevice(w http.ResponseWriter, r *http.Request) { + h.clear2FATrustCookie(w) + jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) +} + +func (h *Handler) issue2FATrustCookie(w http.ResponseWriter, userID string) { + claims := jwt.MapClaims{ + "sub": userID, + "purpose": "2fa_trust", + "exp": time.Now().Add(30 * 24 * time.Hour).Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(h.jwtSecret)) + if err != nil { + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "apt_2fa_trust", + Value: tokenString, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: 30 * 24 * 3600, // 30 days + }) +} + +func (h *Handler) clear2FATrustCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: "apt_2fa_trust", + Value: "", + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + }) +} + +func (h *Handler) isDeviceTrusted(r *http.Request, userID string) bool { + cookie, err := r.Cookie("apt_2fa_trust") + if err != nil || cookie.Value == "" { + return false + } + + token, err := jwt.Parse(cookie.Value, 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 { + return false + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || claims["purpose"] != "2fa_trust" { + return false + } + + sub, _ := claims["sub"].(string) + return sub == userID } diff --git a/internal/api/csrf.go b/internal/api/csrf.go new file mode 100644 index 0000000..bf34cf3 --- /dev/null +++ b/internal/api/csrf.go @@ -0,0 +1,25 @@ +package api + +import ( + "net/http" + "strings" +) + +func CSRFProtection(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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) + }) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..49f71c0 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,49 @@ +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 + } + 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/packages.go b/internal/api/packages.go index 15c65ff..ae6c95b 100644 --- a/internal/api/packages.go +++ b/internal/api/packages.go @@ -2,6 +2,11 @@ package api import ( "bytes" + "crypto/md5" // #nosec G501 + "crypto/sha1" // #nosec G505 + "crypto/sha256" + "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -17,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 @@ -41,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 } @@ -66,6 +77,17 @@ func (h *Handler) uploadPackage(w http.ResponseWriter, r *http.Request) { return } + // Reject duplicate file overwrites in this repo. + if existingID, exists, err := h.db.PackageFilenameExists(repo.ID, filename); 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 + } + // Save to pool. savedPath, err := h.fs.SavePackage(repo.Slug, info.Package, filename, bytes.NewReader(data)) if err != nil { @@ -74,6 +96,7 @@ func (h *Handler) uploadPackage(w http.ResponseWriter, r *http.Request) { } cleanupSavedFile := func() { if savedPath != "" { + /* #nosec G703 */ _ = os.Remove(savedPath) } } @@ -96,15 +119,122 @@ func (h *Handler) uploadPackage(w http.ResponseWriter, r *http.Request) { return } - if err := h.regenerateRepoIndex(repo); err != nil { - _ = h.db.DeletePackage(pkg.ID) + // Enqueue index regeneration asynchronously; return 201 immediately. + h.queue.Enqueue(repo.ID) + detail, _ := json.Marshal(map[string]string{ + "repo": repo.Slug, + "version": info.Version, + "arch": info.Architecture, + }) + h.audit(currentUser(r), "upload", "package:"+filename, string(detail)) + 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 + } + // Reject duplicate file overwrites in this repo. + if existingID, exists, err := h.db.PackageFilenameExists(repo.ID, filename); 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 != "" { + /* #nosec G703 */ + _ = 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() - _ = h.regenerateRepoIndex(repo) - jsonError(w, "index regenerate error: "+err.Error(), http.StatusInternalServerError) + 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) +} - jsonOK(w, pkg, 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) { @@ -175,14 +305,37 @@ 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 } + detail, _ := json.Marshal(map[string]string{"repo": repo.Slug}) + h.audit(currentUser(r), "delete_package", "package:"+pkg.Filename, string(detail)) w.WriteHeader(http.StatusNoContent) } +func (h *Handler) getRepoStatus(w http.ResponseWriter, r *http.Request) { + repoID := chi.URLParam(r, "id") + repo, err := h.db.GetRepo(repoID) + if err != nil || repo == nil { + jsonError(w, "repo not found", http.StatusNotFound) + return + } + s := h.queue.RepoStatus(repoID) + jsonOK(w, map[string]any{ + "id": repoID, + "indexing": s.Indexing, + "last_indexed": s.LastIndexed, + }, http.StatusOK) +} + // sanitizeFilename strips path components and normalises the filename. func sanitizeFilename(name string) string { return filepath.Base(filepath.Clean(name)) @@ -197,6 +350,9 @@ func cleanControl(block string) string { "sha1": true, "md5sum": true, "md5": true, } for _, line := range strings.Split(block, "\n") { + if strings.TrimSpace(line) == "" { + continue + } if idx := strings.IndexByte(line, ':'); idx > 0 { key := strings.ToLower(strings.TrimSpace(line[:idx])) if skip[key] { diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go new file mode 100644 index 0000000..acb0a67 --- /dev/null +++ b/internal/api/ratelimit.go @@ -0,0 +1,83 @@ +package api + +import ( + "net" + "net/http" + "os" + "strconv" + "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 + } + + 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: maxAttempts, last: time.Now()} + rl.clients[ip] = client + } + + if time.Since(client.last) > time.Duration(windowSecs)*time.Second { + client.tokens = maxAttempts + } + 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 5dfe7b3..d371ec9 100644 --- a/internal/api/repos.go +++ b/internal/api/repos.go @@ -2,7 +2,9 @@ package api import ( "encoding/json" + "fmt" "net/http" + "os" "regexp" "strings" @@ -16,6 +18,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 +27,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 +45,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 } @@ -46,16 +56,23 @@ 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 } - 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 @@ -67,6 +84,7 @@ func (h *Handler) createRepo(w http.ResponseWriter, r *http.Request) { return } + h.audit(currentUser(r), "create_repo", "repo:"+repo.Slug, "") jsonOK(w, repo, http.StatusCreated) } @@ -97,6 +115,7 @@ func (h *Handler) deleteRepo(w http.ResponseWriter, r *http.Request) { jsonError(w, "repo deleted but file cleanup failed", http.StatusInternalServerError) return } + h.audit(currentUser(r), "delete_repo", "repo:"+repo.Slug, "") w.WriteHeader(http.StatusNoContent) } @@ -124,12 +143,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) @@ -156,6 +175,17 @@ func (h *Handler) updateRepo(w http.ResponseWriter, r *http.Request) { 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) @@ -164,13 +194,7 @@ func (h *Handler) getSetup(w http.ResponseWriter, r *http.Request) { 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 + baseURL := baseURLFromRequest(r) arches, _ := h.db.GetRepoArchitectures(repo.ID) archStr := "amd64" @@ -178,7 +202,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 4acd8f7..ac46158 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1,6 +1,7 @@ package api import ( + "context" "fmt" "net/http" "path/filepath" @@ -11,10 +12,17 @@ import ( "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 @@ -22,38 +30,69 @@ type Handler struct { 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 string) *Handler { - return &Handler{db: db, fs: fs, gen: gen, signer: signer, jwtSecret: jwtSecret} +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() + // 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) + r.Use(CSRFProtection) + + rl := newRateLimiter() - // Public APT endpoints — no auth. + // 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) + // Auth endpoints + r.With(rl.RateLimit).Post("/api/auth/login", h.login) + r.With(rl.RateLimit).Post("/api/auth/2fa/verify", h.verify2FA) + r.Post("/api/auth/logout", h.logout) + r.Post("/api/auth/2fa/forget-device", h.forgetDevice) - // Admin API — protected. + // 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/repos", h.createRepo) + 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) + r.With(RequireRole("admin")).Post("/api/repos", h.createRepo) r.Get("/api/repos", h.listRepos) - r.Put("/api/repos/{id}", h.updateRepo) - r.Delete("/api/repos/{id}", h.deleteRepo) - r.Post("/api/repos/{id}/packages", h.uploadPackage) + 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.Delete("/api/repos/{id}/packages/{pkgID}", h.deletePackage) + 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")).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) }) @@ -64,22 +103,75 @@ func (h *Handler) Router(spa http.Handler) http.Handler { 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 ") + 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 { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + if err == nil && token.Valid { + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + 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 { + 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 } - next.ServeHTTP(w, r) + // 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) }) } @@ -107,14 +199,22 @@ func (h *Handler) servePublicKey(w http.ResponseWriter, r *http.Request) { // 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 { + } 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) @@ -123,5 +223,6 @@ func (h *Handler) serveRepoFile(w http.ResponseWriter, r *http.Request) { return } + /* #nosec G703 */ 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..1a256a1 --- /dev/null +++ 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/security_test.go b/internal/api/security_test.go new file mode 100644 index 0000000..10dcd92 --- /dev/null +++ b/internal/api/security_test.go @@ -0,0 +1,287 @@ +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", "v2.1.1", 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.LogAuditWithID("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 1dab5ed..68c2e6d 100644 --- a/internal/api/system.go +++ b/internal/api/system.go @@ -2,9 +2,26 @@ package api import ( "net/http" + "os" ) +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, + }, 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/api/totp.go b/internal/api/totp.go new file mode 100644 index 0000000..1377a25 --- /dev/null +++ b/internal/api/totp.go @@ -0,0 +1,117 @@ +package api + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" // #nosec G505 -- RFC 6238 specifies HMAC-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) + } + + 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) + + 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 new file mode 100644 index 0000000..bdadf87 --- /dev/null +++ b/internal/api/users.go @@ -0,0 +1,177 @@ +package api + +import ( + "encoding/json" + "net/http" + "regexp" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/kernelcode0/aptify/internal/storage" + "golang.org/x/crypto/bcrypt" +) + +var usernameRe = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,32}$`) + +type createUserRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` +} + +type updateUserRequest struct { + Role *string `json:"role"` + Password *string `json:"password"` +} + +func validRole(role string) bool { + return role == "admin" || role == "member" || role == "viewer" +} + +func (h *Handler) listUsers(w http.ResponseWriter, r *http.Request) { + users, err := h.db.ListUsers() + if err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + if users == nil { + users = []storage.User{} + } + jsonOK(w, users, http.StatusOK) +} + +func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) { + var req createUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid JSON", http.StatusBadRequest) + return + } + req.Username = strings.TrimSpace(req.Username) + if !usernameRe.MatchString(req.Username) { + 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 { + jsonError(w, "password must be at least 8 characters", http.StatusBadRequest) + return + } + if !validRole(req.Role) { + jsonError(w, "invalid role", http.StatusBadRequest) + return + } + + existing, err := h.db.GetUserByUsername(req.Username) + if err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + if existing != nil { + jsonError(w, "username already exists", http.StatusConflict) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + jsonError(w, "password error", http.StatusInternalServerError) + return + } + user, err := h.db.CreateUser(req.Username, string(hash), req.Role) + if err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + + detail, _ := json.Marshal(map[string]string{"role": req.Role}) + h.audit(currentUser(r), "create_user", "user:"+user.Username, string(detail)) + jsonOK(w, user, http.StatusCreated) +} + +func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + user, err := h.db.GetUserByID(id) + if err != nil || user == nil { + jsonError(w, "user not found", http.StatusNotFound) + return + } + + var req updateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "invalid JSON", http.StatusBadRequest) + return + } + + current := currentUser(r) + if req.Role != nil { + role := strings.TrimSpace(*req.Role) + if !validRole(role) { + jsonError(w, "invalid role", http.StatusBadRequest) + return + } + if current != nil && current.ID == id { + jsonError(w, "cannot change your own role", http.StatusBadRequest) + return + } + if err := h.db.UpdateUserRole(id, role); err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + user.Role = role + } + + if req.Password != nil { + if len(*req.Password) < 8 { + jsonError(w, "password must be at least 8 characters", http.StatusBadRequest) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + jsonError(w, "password error", http.StatusInternalServerError) + return + } + if err := h.db.UpdateUserPassword(id, string(hash)); err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + } + + jsonOK(w, user, http.StatusOK) +} + +func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + current := currentUser(r) + if current != nil && current.ID == id { + jsonError(w, "cannot delete yourself", http.StatusBadRequest) + return + } + + user, err := h.db.GetUserByID(id) + if err != nil || user == nil { + jsonError(w, "user not found", http.StatusNotFound) + return + } + if err := h.db.DeleteUser(id); err != nil { + jsonError(w, "db error", http.StatusInternalServerError) + return + } + + 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/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/internal/deb/parse.go b/internal/deb/parse.go index 87ae1c2..25b26c6 100644 --- a/internal/deb/parse.go +++ b/internal/deb/parse.go @@ -142,7 +142,11 @@ func extractControlFromTar(name string, r io.Reader) (string, error) { } base := strings.TrimPrefix(hdr.Name, "./") if base == "control" { - data, err := io.ReadAll(tr) + 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 } 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/index/generate_test.go b/internal/index/generate_test.go deleted file mode 100644 index 18579fa..0000000 --- a/internal/index/generate_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package index - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/kernelcode0/aptify/internal/storage" -) - -func TestRegenerateWritesArchitectureSpecificPackages(t *testing.T) { - t.Parallel() - - fs, err := storage.NewFileStore(t.TempDir()) - if err != nil { - t.Fatalf("NewFileStore: %v", err) - } - repo := &storage.Repo{Slug: "tools", Name: "Tools", Codename: "stable"} - packages := []storage.Package{ - { - Package: "amd-tool", - Version: "1.0", - Arch: "amd64", - Filename: "amd-tool_1.0_amd64.deb", - Size: 10, - SHA256: strings.Repeat("a", 64), - SHA1: strings.Repeat("b", 40), - MD5: strings.Repeat("c", 32), - ControlJSON: "Package: amd-tool\nVersion: 1.0\nArchitecture: amd64\n", - }, - { - Package: "arm-tool", - Version: "1.0", - Arch: "arm64", - Filename: "arm-tool_1.0_arm64.deb", - Size: 10, - SHA256: strings.Repeat("d", 64), - SHA1: strings.Repeat("e", 40), - MD5: strings.Repeat("f", 32), - ControlJSON: "Package: arm-tool\nVersion: 1.0\nArchitecture: arm64\n", - }, - { - Package: "shared-tool", - Version: "1.0", - Arch: "all", - Filename: "shared-tool_1.0_all.deb", - Size: 10, - SHA256: strings.Repeat("1", 64), - SHA1: strings.Repeat("2", 40), - MD5: strings.Repeat("3", 32), - ControlJSON: "Package: shared-tool\nVersion: 1.0\nArchitecture: all\n", - }, - } - - if err := New(fs, nil).Regenerate(repo, packages); err != nil { - t.Fatalf("Regenerate: %v", err) - } - - amd64Packages := readIndex(t, fs, repo, "amd64") - assertContains(t, amd64Packages, "Package: amd-tool") - assertContains(t, amd64Packages, "Package: shared-tool") - assertNotContains(t, amd64Packages, "Package: arm-tool") - - arm64Packages := readIndex(t, fs, repo, "arm64") - assertContains(t, arm64Packages, "Package: arm-tool") - assertContains(t, arm64Packages, "Package: shared-tool") - assertNotContains(t, arm64Packages, "Package: amd-tool") - - allPackages := readIndex(t, fs, repo, "all") - assertContains(t, allPackages, "Package: shared-tool") - assertNotContains(t, allPackages, "Package: amd-tool") - assertNotContains(t, allPackages, "Package: arm-tool") - - release, err := os.ReadFile(filepath.Join(fs.DistsDir(repo.Slug, repo.Codename), "Release")) - if err != nil { - t.Fatalf("read Release: %v", err) - } - assertContains(t, string(release), "main/binary-amd64/Packages") - assertContains(t, string(release), "main/binary-arm64/Packages") - assertContains(t, string(release), "main/binary-all/Packages") -} - -func readIndex(t *testing.T, fs *storage.FileStore, repo *storage.Repo, arch string) string { - t.Helper() - - data, err := os.ReadFile(filepath.Join(fs.DistsDir(repo.Slug, repo.Codename), "main", "binary-"+arch, "Packages")) - if err != nil { - t.Fatalf("read Packages for %s: %v", arch, err) - } - return string(data) -} - -func assertContains(t *testing.T, got, want string) { - t.Helper() - if !strings.Contains(got, want) { - t.Fatalf("expected %q to contain %q", got, want) - } -} - -func assertNotContains(t *testing.T, got, want string) { - t.Helper() - if strings.Contains(got, want) { - t.Fatalf("expected %q not to contain %q", got, want) - } -} diff --git a/internal/indexqueue/queue.go b/internal/indexqueue/queue.go new file mode 100644 index 0000000..f41156f --- /dev/null +++ b/internal/indexqueue/queue.go @@ -0,0 +1,138 @@ +package indexqueue + +import ( + "context" + "log" + "sync" + "time" + + "github.com/kernelcode0/aptify/internal/index" + "github.com/kernelcode0/aptify/internal/storage" +) + +type repoState struct { + indexing bool + pending bool + lastIndexed *time.Time +} + +// Queue is an in-process async worker for regenerating APT indexes. +// A single goroutine processes jobs; duplicate enqueues for the same repo +// while a job is running collapse into one follow-up run. +type Queue struct { + gen *index.Generator + db *storage.DB + mu sync.Mutex + states map[string]*repoState + jobs chan string // buffered channel of repo IDs +} + +// Status reports the current indexing state for a repo. +type Status struct { + Indexing bool `json:"indexing"` + LastIndexed *time.Time `json:"last_indexed"` +} + +func New(gen *index.Generator, db *storage.DB) *Queue { + return &Queue{ + gen: gen, + db: db, + states: make(map[string]*repoState), + jobs: make(chan string, 256), + } +} + +// Start launches the background worker. ctx cancellation stops the worker. +func (q *Queue) Start(ctx context.Context) { + go q.worker(ctx) +} + +// Enqueue schedules an index regeneration for repoID. If a job is already +// running for that repo, a follow-up run is scheduled instead (deduplicated). +func (q *Queue) Enqueue(repoID string) { + q.mu.Lock() + state, ok := q.states[repoID] + if !ok { + state = &repoState{} + q.states[repoID] = state + } + if state.indexing { + state.pending = true + q.mu.Unlock() + return + } + state.indexing = true + q.mu.Unlock() + + q.jobs <- repoID +} + +// RepoStatus returns the current indexing state for repoID. +func (q *Queue) RepoStatus(repoID string) Status { + q.mu.Lock() + defer q.mu.Unlock() + state, ok := q.states[repoID] + if !ok { + return Status{} + } + return Status{ + Indexing: state.indexing, + LastIndexed: state.lastIndexed, + } +} + +func (q *Queue) worker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case repoID := <-q.jobs: + q.run(repoID) + } + } +} + +func (q *Queue) run(repoID string) { + repo, err := q.db.GetRepo(repoID) + if err != nil || repo == nil { + log.Printf("indexqueue: repo %s not found: %v", repoID, err) + q.finish(repoID, false) + return + } + packages, err := q.db.ListPackages(repoID, 0, 0) + if err != nil { + log.Printf("indexqueue: list packages for repo %s: %v", repoID, err) + q.finish(repoID, false) + return + } + if err := q.gen.Regenerate(repo, packages); err != nil { + log.Printf("indexqueue: regenerate index for repo %s: %v", repoID, err) + q.finish(repoID, false) + return + } + q.finish(repoID, true) +} + +func (q *Queue) finish(repoID string, success bool) { + q.mu.Lock() + state, ok := q.states[repoID] + if !ok { + q.mu.Unlock() + return + } + if success { + now := time.Now().UTC() + state.lastIndexed = &now + } + requeue := state.pending + state.pending = false + if !requeue { + state.indexing = false + } + q.mu.Unlock() + + if requeue { + // Worker is idle now; send next job (no mutex held, no deadlock risk). + q.jobs <- repoID + } +} diff --git a/internal/storage/db.go b/internal/storage/db.go index ce43467..e353522 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -1,6 +1,7 @@ package storage import ( + "context" "database/sql" "fmt" "time" @@ -16,12 +17,38 @@ type DB struct { 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"` - 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. +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. @@ -30,6 +57,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"` } @@ -40,6 +68,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"` @@ -80,14 +109,18 @@ func Open(dbType, dsn string) (*DB, error) { } func (d *DB) migrate() error { - var repoTable, pkgTable, userTable string + 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, - 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, + created_at DATETIME NOT NULL );` repoTable = ` CREATE TABLE IF NOT EXISTS repos ( @@ -95,6 +128,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 = ` @@ -104,7 +138,8 @@ func (d *DB) migrate() error { filename VARCHAR(255) NOT NULL, package VARCHAR(255) NOT NULL, version VARCHAR(255) NOT NULL, - arch 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, @@ -114,13 +149,41 @@ 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, + 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, - 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 ( @@ -128,6 +191,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 = ` @@ -137,6 +201,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, @@ -145,6 +210,27 @@ 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, + 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 { @@ -156,20 +242,181 @@ func (d *DB) migrate() error { 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.ensureUser2FAColumns(); 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) 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 { + 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} + 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, password_hash, created_at FROM users WHERE username=?`, username, - ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt) + `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 } @@ -177,16 +424,20 @@ func (d *DB) GetUserByUsername(username string) (*User, error) { } // CreateUser inserts a new user. -func (d *DB) CreateUser(username, passwordHash string) (*User, error) { +func (d *DB) CreateUser(username, passwordHash, role string) (*User, error) { u := &User{ - ID: uuid.NewString(), - Username: username, - 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, password_hash, created_at) VALUES (?,?,?,?)`, - u.ID, u.Username, u.PasswordHash, u.CreatedAt, + `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 { return nil, fmt.Errorf("create user: %w", err) @@ -194,18 +445,88 @@ func (d *DB) CreateUser(username, passwordHash string) (*User, error) { 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, totp_enabled, 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.TOTPEnabled, &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, 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 + } + 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 +} + +// 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) + return err +} + // 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) @@ -217,8 +538,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 } @@ -229,8 +550,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 } @@ -239,7 +560,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 } @@ -247,7 +568,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) @@ -290,9 +611,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 @@ -300,7 +621,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 @@ -319,7 +640,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 } @@ -339,9 +660,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 @@ -355,4 +676,182 @@ func (d *DB) DeletePackage(id string) error { 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, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, id, userID, username, action, resource, detail, time.Now().UTC()) + 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/internal/storage/db_test.go b/internal/storage/db_test.go deleted file mode 100644 index e34846b..0000000 --- a/internal/storage/db_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package storage - -import ( - "strings" - "testing" -) - -func TestSQLiteRepoDeleteCascadesPackages(t *testing.T) { - t.Parallel() - - db, err := Open("sqlite", t.TempDir()+"/aptify.sqlite") - if err != nil { - t.Fatalf("Open: %v", err) - } - defer db.Close() - - repo, err := db.CreateRepo("tools", "Tools", "stable") - if err != nil { - t.Fatalf("CreateRepo: %v", err) - } - - if err := db.AddPackage(&Package{ - RepoID: repo.ID, - Filename: "tool_1.0_amd64.deb", - Package: "tool", - Version: "1.0", - Arch: "amd64", - Size: 1, - SHA256: strings.Repeat("a", 64), - SHA1: strings.Repeat("b", 40), - MD5: strings.Repeat("c", 32), - ControlJSON: "Package: tool\n", - }); err != nil { - t.Fatalf("AddPackage: %v", err) - } - - if err := db.DeleteRepo(repo.ID); err != nil { - t.Fatalf("DeleteRepo: %v", err) - } - - count, err := db.CountPackages(repo.ID) - if err != nil { - t.Fatalf("CountPackages: %v", err) - } - if count != 0 { - t.Fatalf("expected packages to cascade-delete, got %d", count) - } -} diff --git a/internal/storage/files.go b/internal/storage/files.go index b47f25c..106f291 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,26 @@ 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) + /* #nosec G304 */ + 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 +141,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/internal/storage/files_test.go b/internal/storage/files_test.go deleted file mode 100644 index d9477ee..0000000 --- a/internal/storage/files_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package storage - -import ( - "os" - "testing" -) - -func TestDeleteRepoDirRemovesRepositoryFiles(t *testing.T) { - t.Parallel() - - fs, err := NewFileStore(t.TempDir()) - if err != nil { - t.Fatalf("NewFileStore: %v", err) - } - if err := fs.InitRepo("tools", "stable"); err != nil { - t.Fatalf("InitRepo: %v", err) - } - if _, err := os.Stat(fs.RepoDir("tools")); err != nil { - t.Fatalf("repo dir should exist before delete: %v", err) - } - - if err := fs.DeleteRepoDir("tools"); err != nil { - t.Fatalf("DeleteRepoDir: %v", err) - } - if _, err := os.Stat(fs.RepoDir("tools")); !os.IsNotExist(err) { - t.Fatalf("expected repo dir to be gone, got err=%v", err) - } -} 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": {} +} 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." 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/package-lock.json b/web/package-lock.json index 4b3b495..5013f25 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { - "name": "Aptify — Self-hosted APT, simplified-ui", - "version": "0.1.0", + "name": "aptify-web", + "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "Aptify — Self-hosted APT, simplified-ui", - "version": "0.1.0", + "name": "aptify-web", + "version": "2.1.1", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", @@ -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": [ { diff --git a/web/package.json b/web/package.json index 63a4dfa..f09777d 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { - "name": "aptify-ui", + "name": "aptify-web", "private": true, - "version": "0.1.0", + "version": "2.1.1", "type": "module", "scripts": { "dev": "vite", diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..2181535 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx index ebb0d8f..47de712 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,10 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import Layout from './components/Layout' import Dashboard from './pages/Dashboard' import RepoDetail from './pages/RepoDetail' +import ApiKeys from './pages/ApiKeys' +import Profile from './pages/Profile' +import Users from './pages/Users' +import AuditLog from './pages/AuditLog' import Login from './pages/Login' export default function App() { @@ -12,6 +16,10 @@ export default function App() { }> } /> } /> + } /> + } /> + } /> + } /> } /> diff --git a/web/src/api.ts b/web/src/api.ts index 40ff44d..de4152f 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,11 +1,11 @@ -const TOKEN_KEY = 'apt_admin_token' - +// 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 localStorage.getItem(TOKEN_KEY) ?? '' + return '' } -export function setToken(t: string) { - localStorage.setItem(TOKEN_KEY, t) +export function setToken(_t: string) { + // no-op } async function req(method: string, path: string, body?: unknown): Promise { @@ -17,14 +17,17 @@ async function req(method: string, path: string, body?: unknown): Promise 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') { - 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 @@ -36,6 +39,7 @@ export interface Repo { slug: string name: string codename: string + type: 'deb' | 'rpm' created_at: string } @@ -45,6 +49,7 @@ export interface Package { filename: string package: string version: string + release?: string arch: string size: number sha256: string @@ -52,12 +57,15 @@ export interface Package { } export interface SetupInfo { + type: 'deb' | 'rpm' keyURL: string repoURL: string - codename: string - component: string - addKey: string - addSource: string + codename?: string + component?: string + addKey?: string + addSource?: string + install?: string + repoFile?: string update: string } @@ -66,20 +74,98 @@ export interface PackageList { 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 + two_factor_enabled?: boolean + device_trusted?: 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 + username: string + action: string + resource: string + detail: string + created_at: string +} + +export interface AuditList { + total: number + entries: AuditEntry[] +} + export const api = { - checkAuth: () => req<{ authenticated: boolean }>('GET', '/api/auth/check'), - login: (username: string, password: string) => req<{ token: string }>('POST', '/api/auth/login', { username, password }), + checkAuth: () => req('GET', '/api/auth/check'), + login: (username: string, password: string, code?: string) => + req('POST', '/api/auth/login', { username, password, code }), + verify2FA: (preAuthToken: string, code: string, trustDevice: boolean = false) => + req<{ status: string }>('POST', '/api/auth/2fa/verify', { pre_auth_token: preAuthToken, code, trust_device: trustDevice }), + logout: () => req<{ status: string }>('POST', '/api/auth/logout'), + forgetDevice: () => req<{ status: string }>('POST', '/api/auth/2fa/forget-device'), + 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) => - 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}`), - listPackages: (repoId: string, page: number = 1, limit: number = 50) => + 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() @@ -87,16 +173,31 @@ export const api = { 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') { - 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/components/ConfirmDialog.tsx b/web/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..8b6b2d8 --- /dev/null +++ b/web/src/components/ConfirmDialog.tsx @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from 'react' + +export interface ConfirmOptions { + title: string + message: string + confirmLabel?: string + danger?: boolean +} + +export function useConfirm() { + const [opts, setOpts] = useState(null) + const [resolve, setResolve] = useState<(v: boolean) => void>(() => () => {}) + + const confirm = useCallback( + (o: ConfirmOptions) => new Promise(res => { setResolve(() => res); setOpts(o) }), + [] + ) + + const done = (v: boolean) => { + setOpts(null) + resolve(v) + } + + useEffect(() => { + if (!opts) return + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') done(false) } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [opts]) + + const dialog = opts ? ( +
{ if (e.target === e.currentTarget) done(false) }}> +
+

{opts.title}

+

{opts.message}

+
+ + +
+
+
+ ) : null + + return [dialog, confirm] as const +} diff --git a/web/src/components/Layout.css b/web/src/components/Layout.css index 0144ab3..2e2019f 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; @@ -72,6 +74,9 @@ gap: 8px; } +.nav-toggle { display:none; width:38px; height:38px; min-height:38px; padding:9px; flex-direction:column; gap:4px; background:rgba(255,255,255,.025); border-color:var(--border); } +.nav-toggle span { display:block; width:17px; height:1.5px; border-radius:2px; background:var(--text-2); } + .nav-links { display: flex; gap: 4px; @@ -89,6 +94,25 @@ color: var(--accent-hover); } +.nav-link { + padding: 6px 12px; + border-radius: 999px; + color: var(--text-2); + font-size: 13px; + font-weight: 650; + text-decoration: none; + transition: background 0.16s, color 0.16s; + cursor: pointer; + border: none; + background: none; +} +.nav-link:hover, .nav-link.active { + background: rgba(45, 212, 191, 0.1); + color: var(--accent-hover); +} +.nav-profile { display:flex; align-items:center; gap:7px; } +.nav-avatar { display:grid; place-items:center; width:22px; height:22px; border-radius:50%; color:var(--accent-hover); background:var(--accent-subtle); font-size:10px; font-weight:800; } + .nav-divider { width: 1px; height: 22px; @@ -119,26 +143,65 @@ 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; } + .nav-right { gap:5px; } + .nav-profile { padding-inline:8px; } +} + @media (max-width: 720px) { .nav { - height: auto; + height: 64px; min-height: 64px; - padding: 12px 16px; - align-items: flex-start; - gap: 14px; - flex-direction: column; + padding: 0 16px; + align-items: center; + gap: 12px; + flex-direction: row; + flex-wrap: wrap; } + .nav-toggle { display:flex; margin-left:auto; } .nav-right { + display:none; + position:absolute; + top:63px; + left:0; + right:0; width: 100%; - justify-content: space-between; + padding:12px 16px 16px; + flex-direction:column; + align-items:stretch; + gap:6px; + background:rgba(15,17,21,.98); + border-bottom:1px solid var(--border); + box-shadow:0 18px 35px rgba(0,0,0,.28); + backdrop-filter:blur(14px); } + .nav-right.open { display:flex; } + .nav-links { flex-direction:column; gap:3px; } + .nav-links a,.nav-link { width:100%; padding:10px 12px; border-radius:7px; } + .nav-profile { justify-content:flex-start; } .nav-divider { display: none; } - .nav-logout { width: auto; } + .nav-logout { width: 100%; justify-content:flex-start; min-height:38px; border-radius:7px; margin-top:3px; } .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 698b904..cf1860e 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,14 +1,17 @@ import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom' import { useEffect, useState } from 'react' -import { api, setToken } from '../api' +import { api, setToken, type CurrentUser } from '../api' import './Layout.css' -function PackageIcon({ size = 18 }: { size?: number }) { +function AptifyMark() { return ( - - - - + + + + + + + ) } @@ -23,22 +26,58 @@ 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() + .then(setUser) .catch(() => {}) .finally(() => setChecking(false)) }, []) - const handleLogout = () => { + useEffect(() => setMenuOpen(false), [loc.pathname]) + + const handleLogout = async () => { + try { + await api.logout() + } catch { + // ignore + } setToken('') 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 (
@@ -51,18 +90,29 @@ export default function Layout() {
- + {showStarPrompt && ( + + )} +
) diff --git a/web/src/index.css b/web/src/index.css index 6fd3127..0eba985 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); } @@ -84,6 +126,11 @@ button.ghost { } button.ghost:hover { color: var(--text); border-color: var(--border-hover); background: var(--surface2); } +button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + button.danger { background: transparent; color: var(--danger); @@ -91,20 +138,28 @@ 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; + background-color: #11151d; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); font-family: inherit; font-size: 14px; padding: 10px 12px; - transition: border-color 0.16s, box-shadow 0.16s, background 0.16s; + transition: border-color 0.16s, box-shadow 0.16s, background-color 0.16s; } -input:focus, textarea:focus { +select { + appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%237d8897' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 14px; + padding-right: 36px; +} +input:focus, textarea:focus, select:focus { outline: none; - background: #121922; + background-color: #121922; border-color: var(--accent-strong); box-shadow: 0 0 0 3px rgba(45, 212, 191, 0.12); } @@ -179,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; @@ -267,9 +332,25 @@ tbody tr:hover td { background: rgba(45, 212, 191, 0.045); } } .modal-close:hover { color: var(--text); background: var(--surface2); } +.confirm-modal { max-width: 380px; padding: 24px; } +.confirm-modal h2 { font-size: 16px; font-weight: 750; margin-bottom: 10px; } +.confirm-modal p { color: var(--muted); font-size: 13px; line-height: 1.55; margin-bottom: 20px; } +.confirm-modal .form-actions { margin-top: 0; justify-content: flex-end; } +.confirm-danger h2 { color: #fda4af; } + @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) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } } diff --git a/web/src/pages/ApiKeys.css b/web/src/pages/ApiKeys.css new file mode 100644 index 0000000..eeac006 --- /dev/null +++ b/web/src/pages/ApiKeys.css @@ -0,0 +1,53 @@ +.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); } + +.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; } + +.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; } + +.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); } + +@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 new file mode 100644 index 0000000..a3a2ae8 --- /dev/null +++ b/web/src/pages/ApiKeys.tsx @@ -0,0 +1,289 @@ +import { useEffect, useMemo, useState } from 'react' +import { useOutletContext } from 'react-router-dom' +import { api, type APIKey, type APIKeyCreated, type CurrentUser } from '../api' +import { useConfirm } from '../components/ConfirmDialog' +import './ApiKeys.css' + +function PlusIcon() { + return ( + + + + ) +} + +function TrashIcon() { + return ( + + + + + + + ) +} + +function XIcon() { + return ( + + + + ) +} + +function AlertIcon() { + return ( + + + + + + ) +} + +function KeyIcon() { + return ( + + + + ) +} + +function ShieldIcon() { + return +} + +export default function ApiKeys() { + const { user } = useOutletContext<{ user: CurrentUser | null }>() + const [keys, setKeys] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [showNew, setShowNew] = useState(false) + const [newName, setNewName] = useState('') + const [creating, setCreating] = useState(false) + const [newKey, setNewKey] = useState(null) + const [copied, setCopied] = useState(false) + const [query, setQuery] = useState('') + const [confirmDialog, confirm] = useConfirm() + + if (user?.role === 'viewer') { + return ( +
+
+
+
Authentication
+

API Keys

+
+
+
+

Access Denied

+

You don't have permission to manage API keys. Contact an administrator if you need access.

+
+
+ ) + } + + const load = () => { + api.listAPIKeys() + .then(k => setKeys(k ?? [])) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)) + } + + 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 + setCreating(true) + setError('') + try { + const created = await api.createAPIKey(newName.trim()) + setNewKey(created) + setShowNew(false) + setNewName('') + setKeys(prev => [...prev, created]) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'failed to create key') + } finally { + setCreating(false) + } + } + + const handleDelete = async (key: APIKey) => { + if (!(await confirm({ title: 'Delete API key', message: `Delete API key "${key.name}"? Anything using it will stop authenticating. This cannot be undone.`, confirmLabel: 'Delete', danger: true }))) return + try { + await api.deleteAPIKey(key.id) + setKeys(prev => prev.filter(k => k.id !== key.id)) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'failed to delete key') + } + } + + const copyKey = () => { + if (!newKey) return + const handleSuccess = () => { setCopied(true); setTimeout(() => setCopied(false), 2000) } + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(newKey.key).then(handleSuccess) + } else { + const ta = document.createElement('textarea') + ta.value = newKey.key + ta.style.position = 'fixed'; ta.style.left = '-9999px' + document.body.appendChild(ta); ta.select() + document.execCommand('copy') + ta.remove() + handleSuccess() + } + } + + return ( +
+
+
+
Authentication
+

API Keys

+

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

+
+
+ +
+
+ + {error && ( +
+ + {error} +
+ )} + + {!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) } }}> +
+
+
Credential ready

API key created

+ +
+
+
+
Copy this key nowFor your security, the secret will not be shown again.
+
+
+ {newKey.key} + +
+

+ Name: {newKey.name}  ·  Prefix: {newKey.prefix} +

+
+
+ +
+
+
+ )} + + {/* Create key modal */} + {showNew && ( +
{ if (e.target === e.currentTarget) setShowNew(false) }}> +
+
+
New credential

Create API key

+ +
+
+
+ + setNewName(e.target.value)} + placeholder="my-ci-pipeline" + required + autoFocus + /> + Use a descriptive label for the service or machine using this key. +
+
Store the generated secret in a secure credential manager.
+
+ + +
+
+
+
+ )} + + {loading ? ( +
+ ) : keys.length === 0 ? ( +
+
+
Secure automation starts here

No API keys yet

+

Create a key to let CI pipelines and scripts authenticate without sharing your password.

+ +
+ ) : ( +
+

Issued credentials

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

+ +
+
+ + + + + + + + + + + + {visibleKeys.map(k => ( + + + + + + + + ))} + {visibleKeys.length === 0 && } + +
NamePrefixCreatedLast used
{k.name}{k.last_used ? 'Active credential' : 'Not used yet'}
{k.prefix}…{formatDate(k.created_at)}{k.last_used ? {formatDate(k.last_used)} : Never} + +
No keys foundTry a different name or prefix.
+
+
+ )} + {confirmDialog} +
+ ) +} + +function formatDate(s: string) { + return new Date(s).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +} diff --git a/web/src/pages/AuditLog.css b/web/src/pages/AuditLog.css new file mode 100644 index 0000000..efc948f --- /dev/null +++ b/web/src/pages/AuditLog.css @@ -0,0 +1,169 @@ +.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; + 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; +} + +.audit-detail-kv { + display: inline-flex; + flex-direction: column; + gap: 4px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border); + padding: 6px 10px; + border-radius: var(--radius-sm); + min-width: 140px; +} + +.kv-row { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 11px; +} + +.kv-key { + color: var(--text-2); + font-weight: 600; +} + +.kv-val { + color: var(--accent-hover); + 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 new file mode 100644 index 0000000..0f9885b --- /dev/null +++ b/web/src/pages/AuditLog.tsx @@ -0,0 +1,259 @@ +import { useEffect, useState } from 'react' +import { useOutletContext } from 'react-router-dom' +import { api, type AuditEntry, type CurrentUser } from '../api' +import './AuditLog.css' + +function AlertIcon() { + return ( + + + + + + ) +} + +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('') + + useEffect(() => { + setLoading(true) + api.listAudit(offset, limit) + .then(data => { + setEntries(data.entries ?? []) + setTotal(data.total) + }) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)) + }, [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

+

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
+
+ + + + + + + + + + + + {entries.map(e => ( + + + + + + + + ))} + {entries.length === 0 && ( + + + + )} + +
UserActionResourceDetailTime
{e.username.slice(0, 2).toUpperCase()}{e.username}
{formatAction(e.action)}{e.resource}{formatDateTime(e.created_at)}
No audit entries yet.
+
+ +
+ {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)}`} +
+
+ + +
+
+
+
+ )} +
+ ) +} + +function Detail({ value }: { value: string }) { + if (!value || value === '{}') return - + try { + const parsed = JSON.parse(value) + if (typeof parsed === 'object' && parsed !== null && Object.keys(parsed).length > 0) { + return ( +
+ {Object.entries(parsed).map(([k, v]) => ( +
+ {k}: + {typeof v === 'object' ? JSON.stringify(v) : String(v)} +
+ ))} +
+ ) + } + return
{JSON.stringify(parsed, null, 2)}
+ } catch { + return {value} + } +} + +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', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + 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 906b1f0..d8e54dd 100644 --- a/web/src/pages/Dashboard.css +++ b/web/src/pages/Dashboard.css @@ -1,182 +1,115 @@ -.page-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - margin-bottom: 24px; - gap: 18px; -} - -.page-kicker { - color: var(--accent-hover); - font-size: 11px; - font-weight: 800; - text-transform: uppercase; - margin-bottom: 7px; -} - -.page-title { - font-size: 28px; - font-weight: 820; - color: var(--text); - line-height: 1.12; -} - -.page-sub { - color: var(--muted); - font-size: 13px; - margin-top: 7px; -} - -.page-actions { - display: flex; - gap: 9px; - flex-shrink: 0; -} - -.loading-center { - display: flex; - justify-content: center; - padding: 72px 0; -} - -.dashboard-strip { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1px; - overflow: hidden; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--border); - margin-bottom: 18px; -} - -.dashboard-strip > div { - background: rgba(25, 29, 37, 0.88); - padding: 15px 16px; - min-width: 0; -} - -.strip-label { - display: block; - color: var(--muted); - font-size: 11px; - font-weight: 750; - text-transform: uppercase; - margin-bottom: 5px; -} - -.dashboard-strip strong { - display: block; - color: var(--text); - font-size: 18px; - line-height: 1.2; - overflow: hidden; - text-overflow: ellipsis; -} - -.repo-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 14px; -} - -.repo-card { - display: flex; - flex-direction: column; - min-height: 178px; - padding: 18px; - transition: border-color 0.16s, box-shadow 0.16s, transform 0.16s, background 0.16s; - color: var(--text) !important; - text-decoration: none; - position: relative; - overflow: hidden; -} - -.repo-card::before { - content: ''; - position: absolute; - inset: 0 0 auto; - height: 2px; - background: var(--accent-strong); - opacity: 0; - transition: opacity 0.16s; -} - -.repo-card:hover { - border-color: rgba(45, 212, 191, 0.34); - background: rgba(32, 38, 49, 0.92); - box-shadow: var(--shadow); - transform: translateY(-2px); -} -.repo-card:hover::before { opacity: 1; } - -.repo-card-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 18px; - gap: 12px; -} - -.repo-card-icon { - display: flex; - align-items: center; - justify-content: center; - width: 38px; - height: 38px; - background: rgba(45, 212, 191, 0.1); - border: 1px solid rgba(45, 212, 191, 0.18); - border-radius: 8px; - color: var(--accent-hover); - flex-shrink: 0; -} - -.repo-card-body { - flex: 1; - margin-bottom: 16px; - min-width: 0; -} - -.repo-name { - font-weight: 760; - font-size: 15px; - color: var(--text); - margin-bottom: 5px; - overflow-wrap: anywhere; -} - -.repo-slug { - font-size: 12px; - color: var(--muted); - font-family: var(--mono); - overflow-wrap: anywhere; -} - -.repo-card-footer { - border-top: 1px solid rgba(48, 55, 68, 0.7); - padding-top: 12px; -} - -.repo-date { - font-size: 11px; - color: var(--muted); -} - -@media (max-width: 720px) { - .page-header { - flex-direction: column; - } - - .page-actions { - width: 100%; - flex-direction: column; - } - - .dashboard-strip { - grid-template-columns: 1fr; - } - - .repo-grid { - grid-template-columns: 1fr; - } -} +.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; } +.hero-primary { min-height:40px; padding-inline:16px; } + +.metrics { display:grid; grid-template-columns:1.35fr 1fr 1fr; gap:12px; margin-bottom:30px; } +.metric { min-height:112px; display:flex; align-items:center; gap:14px; padding:20px; border:1px solid var(--border); border-radius:var(--radius-lg); background:linear-gradient(145deg,rgba(30,36,47,.92),rgba(22,26,34,.85)); box-shadow:inset 0 1px 0 rgba(255,255,255,.03); } +.metric-featured { display:block; position:relative; overflow:hidden; background:linear-gradient(135deg,rgba(20,184,166,.16),rgba(25,29,37,.92) 62%); border-color:rgba(45,212,191,.23); } +.metric-featured::after { content:''; position:absolute; width:130px; height:130px; right:-45px; top:-60px; border:1px solid rgba(94,234,212,.15); border-radius:50%; box-shadow:0 0 0 22px rgba(94,234,212,.035),0 0 0 44px rgba(94,234,212,.02); } +.metric-label { display:block; color:var(--text-2); font-size:12px; font-weight:650; } +.metric strong { display:block; font-size:28px; line-height:1.1; letter-spacing:-.03em; margin-top:5px; color:var(--text); } +.metric-note { display:block; color:var(--muted); font-size:11px; margin-top:6px; } +.metric-dot { width:10px; height:42px; border-radius:99px; background:var(--deb); box-shadow:0 0 24px rgba(45,212,191,.22); } +.metric-dot-rpm { background:var(--rpm); box-shadow:0 0 24px rgba(245,158,11,.18); } + +.collection { border-top:1px solid rgba(48,55,68,.7); padding-top:25px; } +.collection-heading { display:flex; align-items:center; justify-content:space-between; gap:20px; margin-bottom:16px; } +.collection-heading h2 { font-size:17px; letter-spacing:-.015em; } +.collection-heading p { color:var(--muted); font-size:12px; margin-top:2px; } +.key-action { min-height:32px; font-size:12px; } +.repo-toolbar { display:grid; grid-template-columns:minmax(240px,1fr) auto 150px; align-items:center; gap:10px; margin-bottom:16px; } +.search-box { position:relative; display:flex; align-items:center; color:var(--muted); } +.search-box > svg { position:absolute; left:12px; pointer-events:none; } +.search-box input { height:38px; padding-left:38px; padding-right:38px; background:rgba(17,21,29,.72); } +.search-box button { position:absolute; right:5px; min-height:28px; width:28px; padding:0; background:transparent; color:var(--muted); } +.search-box button:hover { color:var(--text); } +.filter-group { display:flex; align-items:center; gap:3px; padding:3px; border:1px solid var(--border); background:rgba(17,21,29,.7); border-radius:8px; } +.filter-group button { min-height:30px; padding:4px 10px; border:0; background:transparent; color:var(--muted); font-size:11px; box-shadow:none; } +.filter-group button:hover { color:var(--text-2); } +.filter-group button.active { color:var(--text); background:var(--surface2); box-shadow:0 1px 4px rgba(0,0,0,.25); } +.sort-control select { height:38px; padding-block:7px; font-size:12px; background-color:rgba(17,21,29,.72); } + +.repo-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(270px,1fr)); gap:14px; } +.repo-card { display:flex; flex-direction:column; min-height:245px; padding:18px; color:var(--text); border:1px solid var(--border); border-radius:var(--radius-lg); background:linear-gradient(155deg,rgba(29,34,44,.94),rgba(21,25,33,.9)); box-shadow:inset 0 1px 0 rgba(255,255,255,.025); transition:border-color .18s,transform .18s,box-shadow .18s,background .18s; } +.repo-card:hover { color:var(--text); transform:translateY(-3px); border-color:rgba(94,234,212,.34); background:linear-gradient(155deg,rgba(33,40,51,.98),rgba(23,28,36,.94)); box-shadow:0 16px 38px rgba(0,0,0,.23); } +.repo-card:focus-visible { outline:2px solid var(--accent); outline-offset:3px; } +.repo-card-top { display:flex; align-items:center; justify-content:space-between; margin-bottom:22px; } +.format-mark { display:grid; place-items:center; width:40px; height:40px; border-radius:10px; color:var(--deb); background:rgba(45,212,191,.09); border:1px solid rgba(45,212,191,.18); } +.format-rpm { color:var(--rpm); background:rgba(245,158,11,.08); border-color:rgba(245,158,11,.18); } +.format-badge { color:var(--deb); border:1px solid rgba(45,212,191,.2); background:rgba(45,212,191,.07); padding:3px 8px; border-radius:99px; font-size:10px; font-weight:800; letter-spacing:.08em; } +.format-badge-rpm { color:#fbbf24; border-color:rgba(245,158,11,.2); background:rgba(245,158,11,.07); } +.repo-card-body { flex:1; min-width:0; } +.repo-card-body h3 { font-size:16px; line-height:1.3; letter-spacing:-.01em; overflow-wrap:anywhere; } +.repo-path { display:block; color:var(--muted); font-family:var(--mono); font-size:11px; margin-top:5px; overflow-wrap:anywhere; } +.repo-meta { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:18px 0; padding:14px 0; border-top:1px solid rgba(48,55,68,.65); border-bottom:1px solid rgba(48,55,68,.65); } +.repo-meta dt { color:var(--muted); font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; } +.repo-meta dd { color:var(--text-2); font-size:12px; font-weight:650; margin-top:3px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.repo-card-footer { display:flex; align-items:center; justify-content:space-between; color:var(--text-2); font-size:12px; font-weight:650; } +.card-arrow { display:grid; place-items:center; width:28px; height:28px; border-radius:50%; color:var(--muted); background:rgba(255,255,255,.035); transition:transform .18s,background .18s,color .18s; } +.repo-card:hover .card-arrow { transform:translateX(2px); background:var(--accent-subtle); color:var(--accent-hover); } + +.dashboard-empty { padding:84px 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%); } +.empty-visual { position:relative; width:96px; height:96px; display:grid; place-items:center; margin:0 auto 20px; } +.empty-orbit { position:absolute; inset:5px; border:1px solid rgba(94,234,212,.18); border-radius:50%; box-shadow:0 0 0 12px rgba(45,212,191,.025); } +.empty-repo { position:relative; display:grid; place-items:center; width:60px; height:60px; border-radius:16px; color:var(--accent-hover); background:var(--surface); border:1px solid rgba(94,234,212,.25); box-shadow:0 14px 35px rgba(0,0,0,.25); } +.empty-eyebrow { color:var(--accent-hover); font-size:10px; font-weight:800; text-transform:uppercase; letter-spacing:.12em; margin-bottom:8px; } +.dashboard-empty h2 { font-size:22px; letter-spacing:-.02em; } +.dashboard-empty p { max-width:430px; margin:8px auto 20px; font-size:13px; } +.dashboard-empty button { width:auto; } +.no-results { text-align:center; padding:65px 20px; color:var(--muted); border:1px dashed var(--border); border-radius:var(--radius-lg); } +.no-results h3 { color:var(--text); font-size:15px; margin-top:12px; } +.no-results p { font-size:12px; margin:4px 0 15px; } +.no-results button { width:auto; } + +.modal-kicker { color:var(--accent-hover); font-size:10px; font-weight:800; letter-spacing:.1em; text-transform:uppercase; margin-bottom:3px; } +.type-picker { display:grid; grid-template-columns:1fr 1fr; gap:8px; } +.type-picker button { display:flex; align-items:flex-start; flex-direction:column; gap:1px; min-height:58px; padding:10px 12px; color:var(--muted); background:#11151d; border-color:var(--border); text-align:left; } +.type-picker button strong { color:var(--text-2); font-size:12px; } +.type-picker button span { font-size:10px; font-weight:500; } +.type-picker button.selected { color:var(--accent-hover); background:var(--accent-subtle); border-color:rgba(45,212,191,.45); box-shadow:0 0 0 2px rgba(45,212,191,.06); } +.type-picker button.selected strong { color:var(--accent-hover); } +.input-prefix { display:flex; align-items:center; border:1px solid var(--border); border-radius:var(--radius-sm); background:#11151d; transition:border-color .16s,box-shadow .16s; } +.input-prefix:focus-within { border-color:var(--accent-strong); box-shadow:0 0 0 3px rgba(45,212,191,.12); } +.input-prefix > span { color:var(--muted); font-family:var(--mono); padding-left:12px; } +.input-prefix input { border:0; box-shadow:none!important; padding-left:3px; background:transparent!important; } +.form-actions { justify-content:flex-end; } +.spinner-small { width:14px; height:14px; border-width:2px; } +.alert-error button { margin-left:auto; min-height:26px; width:26px; padding:0; color:inherit; border:0; background:transparent; } + +.skeleton { position:relative; overflow:hidden; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); } +.skeleton::after { content:''; position:absolute; inset:0; transform:translateX(-100%); background:linear-gradient(90deg,transparent,rgba(255,255,255,.045),transparent); animation:shimmer 1.3s infinite; } +.metrics-skeleton { height:112px; margin-bottom:30px; } +.toolbar-skeleton { height:72px; margin-bottom:16px; } +.skeleton-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; } +.card-skeleton { height:245px; } +@keyframes shimmer { to { transform:translateX(100%); } } +.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } + +@media (max-width:820px) { + .metrics { grid-template-columns:1fr 1fr; } + .metric-featured { grid-column:1/-1; } + .repo-toolbar { grid-template-columns:1fr auto; } + .search-box { grid-column:1/-1; } + .skeleton-grid { grid-template-columns:1fr 1fr; } +} +@media (max-width:600px) { + .dashboard-hero { align-items:flex-start; flex-direction:column; margin-bottom:24px; } + .page-title { font-size:30px; } + .page-sub { font-size:13px; } + .hero-primary { width:100%; } + .metrics { gap:8px; margin-bottom:24px; } + .metric { min-height:94px; padding:16px; } + .metric strong { font-size:24px; } + .collection-heading { align-items:flex-start; } + .key-action { width:auto; padding-inline:9px; } + .key-action svg { margin:0; } + .repo-toolbar { display:flex; flex-wrap:wrap; } + .search-box { width:100%; } + .filter-group { flex:1; } + .filter-group button { width:auto; flex:1; } + .sort-control { width:132px; } + .repo-grid,.skeleton-grid { grid-template-columns:1fr; } + .type-picker { grid-template-columns:1fr; } +} +@media (prefers-reduced-motion:reduce) { .repo-card,.card-arrow,.skeleton::after { transition:none; animation:none; } } diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 01c1a50..cc0956f 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,91 +1,71 @@ -import { useEffect, useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' -import { api, getToken, type Repo } from '../api' +import { useEffect, useMemo, useState } from 'react' +import { Link, useNavigate, useOutletContext } from 'react-router-dom' +import { api, getToken, type CurrentUser, type Repo } from '../api' import './Dashboard.css' -function PlusIcon() { - return ( - - - - ) -} - -function KeyIcon() { - return ( - - - - ) -} - -function XIcon() { - return ( - - - - ) -} - -function RepoIcon() { - return ( - - - - - - ) -} - -function EmptyBoxIcon() { - return ( - - - - - - ) -} - -function AlertIcon() { - return ( - - - - - - ) +type RepoFilter = 'all' | Repo['type'] +type RepoSort = 'newest' | 'oldest' | 'name' + +function Icon({ name, size = 16 }: { name: 'plus' | 'key' | 'x' | 'repo' | 'search' | 'arrow' | 'alert' | 'spark'; size?: number }) { + const paths = { + plus: <>, + key: <>, + x: <>, + repo: <>, + search: <>, + arrow: <>, + alert: <>, + spark: <>, + } + return } export default function Dashboard() { + const { user } = useOutletContext<{ user: CurrentUser | null }>() 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 [createError, setCreateError] = useState('') + const [query, setQuery] = useState('') + const [filter, setFilter] = useState('all') + const [sort, setSort] = useState('newest') const navigate = useNavigate() const load = () => { - api.listRepos() - .then(setRepos) - .catch(e => setError(e.message)) - .finally(() => setLoading(false)) + setLoading(true) + api.listRepos().then(setRepos).catch(e => setError(e.message)).finally(() => setLoading(false)) } - useEffect(load, []) + const visibleRepos = useMemo(() => { + const term = query.trim().toLowerCase() + return repos + .filter(repo => filter === 'all' || repo.type === filter) + .filter(repo => !term || [repo.name, repo.slug, repo.codename, repo.type].some(value => value.toLowerCase().includes(term))) + .sort((a, b) => sort === 'name' + ? a.name.localeCompare(b.name) + : sort === 'oldest' + ? +new Date(a.created_at) - +new Date(b.created_at) + : +new Date(b.created_at) - +new Date(a.created_at)) + }, [repos, query, filter, sort]) + + const debCount = repos.filter(repo => repo.type === 'deb').length + const rpmCount = repos.length - debCount + const handleCreate = async (e: React.FormEvent) => { e.preventDefault() setCreating(true) - setError('') + setCreateError('') 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') + setCreateError(e instanceof Error ? e.message : 'Unknown error') } finally { setCreating(false) } @@ -93,154 +73,90 @@ export default function Dashboard() { const handleExportKey = async () => { try { - const res = await fetch('/api/system/gpg-key', { - headers: { Authorization: `Bearer ${getToken() || ''}` }, - }) + const res = await fetch('/api/system/gpg-key', { headers: { Authorization: `Bearer ${getToken() || ''}` } }) if (!res.ok) throw new Error('Failed to export GPG key') - const blob = await res.blob() - const url = window.URL.createObjectURL(blob) + const url = window.URL.createObjectURL(await res.blob()) const a = document.createElement('a') a.href = url a.download = 'signing-key.asc' document.body.appendChild(a) a.click() window.URL.revokeObjectURL(url) - document.body.removeChild(a) + a.remove() } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Export failed') } } return ( -
-
-
-
Control plane
+
+
+
+
Control plane

Repositories

-

{repos.length} active repositor{repos.length === 1 ? 'y' : 'ies'}

-
-
- - +

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="Ubuntu Developer Toolkit" - required - autoFocus - /> -
-
- - setForm(f => ({ ...f, slug: e.target.value.toLowerCase() }))} - placeholder="ubuntu-toolkit" - pattern="[a-z0-9][a-z0-9\-]{0,62}" - required - /> - Lowercase letters, numbers, and hyphens only -
-
- - setForm(f => ({ ...f, codename: e.target.value }))} - placeholder="stable" - required - /> -
-
- - -
+ {createError &&
{createError}
} +
+ {(['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.codename} -
-
-
{r.name}
-
/{r.slug}
-
-
- {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' }) } diff --git a/web/src/pages/Login.css b/web/src/pages/Login.css index 394f3e1..4925622 100644 --- a/web/src/pages/Login.css +++ b/web/src/pages/Login.css @@ -1,85 +1,467 @@ -.login-root { - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - background: - linear-gradient(180deg, rgba(45, 212, 191, 0.08), transparent 260px), - var(--bg); - padding: 24px; +.login-root { position:relative; display:grid; grid-template-columns:minmax(0,1.15fr) minmax(430px,.85fr); min-height:100vh; overflow:hidden; background:#0b0e12; isolation:isolate; } +.login-root::before { content:''; position:absolute; inset:0; z-index:-2; opacity:.19; background-image:linear-gradient(rgba(125,136,151,.13) 1px,transparent 1px),linear-gradient(90deg,rgba(125,136,151,.13) 1px,transparent 1px); background-size:52px 52px; mask-image:linear-gradient(90deg,#000,transparent 76%); } +.login-atmosphere { position:absolute; inset:0; z-index:-1; pointer-events:none; } +.login-atmosphere span { position:absolute; border-radius:50%; filter:blur(2px); } +.login-atmosphere span:nth-child(1) { width:560px; height:560px; left:-240px; top:-260px; background:radial-gradient(circle,rgba(20,184,166,.16),transparent 68%); } +.login-atmosphere span:nth-child(2) { width:500px; height:500px; left:38%; bottom:-350px; background:radial-gradient(circle,rgba(45,212,191,.1),transparent 66%); } +.login-atmosphere span:nth-child(3) { width:260px; height:260px; left:53%; top:11%; background:radial-gradient(circle,rgba(245,158,11,.045),transparent 66%); } + +.login-story { display:flex; min-width:0; padding:46px clamp(44px,6vw,96px); flex-direction:column; border-right:1px solid rgba(48,55,68,.55); } +.login-brand { display:flex; align-items:center; gap:11px; } +.login-logo-mark { width:39px; height:39px; filter:drop-shadow(0 8px 18px rgba(0,0,0,.32)); } +.login-brand > div { display:flex; flex-direction:column; } +.login-brand-name { display:flex; align-items:center; gap:8px; } +.login-brand strong { color:var(--text); font-size:16px; font-weight:850; letter-spacing:-.015em; } +.brand-status-badge { display:inline-flex; align-items:center; gap:5px; padding:2px 7px; border-radius:999px; font-size:9px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; line-height:1; } +.brand-status-badge i { width:5px; height:5px; border-radius:50%; } +.brand-status-badge.status-online { color:#34d399; background:rgba(16,185,129,.12); border:1px solid rgba(16,185,129,.3); } +.brand-status-badge.status-online i { background:#10b981; box-shadow:0 0 6px #10b981; } +.brand-status-badge.status-offline { color:#f87171; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.3); } +.brand-status-badge.status-offline i { background:#ef4444; box-shadow:0 0 6px #ef4444; } +.login-brand span { color:var(--muted); font-size:9px; font-weight:650; letter-spacing:.06em; text-transform:uppercase; } +.login-story-copy { max-width:680px; margin:auto 0 38px; } +.story-kicker { display:flex; align-items:center; gap:8px; margin-bottom:17px; color:var(--accent-hover); font-size:10px; font-weight:800; letter-spacing:.15em; text-transform:uppercase; } +.story-kicker span { width:25px; height:1px; background:var(--accent); box-shadow:0 0 12px var(--accent); } +.login-story-copy h1 { color:#f3f6f9; font-size:clamp(43px,5.1vw,76px); font-weight:850; letter-spacing:-.055em; line-height:.99; } +.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: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; cursor:pointer; outline:none; } +.flow-step:hover i, .flow-step.is-hovered i { transform:translateY(-2px) scale(1.06); } +.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-tooltip { + position:absolute; + bottom:calc(100% + 14px); + left:50%; + transform:translateX(-50%); + padding:7px 11px; + background:rgba(15,23,42,.96); + border:1px solid rgba(45,212,191,.4); + border-radius:8px; + box-shadow:0 12px 28px rgba(0,0,0,.65),0 0 16px rgba(45,212,191,.15); + backdrop-filter:blur(12px); + white-space:nowrap; + z-index:20; + display:flex; + flex-direction:column; + gap:2px; + pointer-events:none; + animation:tooltipFadeIn .2s cubic-bezier(.16,1,.3,1) both; +} +.flow-tooltip::after { + content:''; + position:absolute; + top:100%; + left:50%; + transform:translateX(-50%); + border-width:5px; + border-style:solid; + border-color:rgba(45,212,191,.4) transparent transparent transparent; +} +.tooltip-title { + font-size:9.5px; + font-weight:800; + color:#5eead4; + letter-spacing:.06em; + text-transform:uppercase; +} +.tooltip-desc { + font-size:11px; + color:#cbd5e1; + font-weight:550; +} +@keyframes tooltipFadeIn { + from { opacity:0; transform:translate(-50%,5px); } + to { opacity:1; transform:translate(-50%,0); } } -.login-card { - background: rgba(25, 29, 37, 0.92); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 34px; - width: 100%; - max-width: 420px; - box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.04); +.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); } } -.login-brand { - display: flex; - align-items: center; - gap: 11px; - margin-bottom: 34px; +.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; } + +.login-access { position:relative; display:flex; align-items:center; justify-content:center; flex-direction:column; padding:46px clamp(30px,5vw,76px); background:linear-gradient(145deg,rgba(18,22,28,.96),rgba(10,13,17,.98)); } +.login-access::before { content:''; position:absolute; width:520px; height:520px; background:radial-gradient(circle,rgba(20,184,166,.075) 0%,rgba(45,212,191,.02) 40%,transparent 70%); filter:blur(40px); pointer-events:none; z-index:0; } + +.access-topline { position:absolute; top:36px; left:clamp(30px,5vw,76px); right:clamp(30px,5vw,76px); z-index:2; display:flex; align-items:center; justify-content:space-between; color:var(--muted); font-size:9px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; } +.access-topline > span { display:flex; align-items:center; gap:7px; } +.access-topline i { width:6px; height:6px; border-radius:50%; background:var(--success); box-shadow:0 0 0 4px rgba(34,197,94,.08); } + +.author-pill { display:inline-flex; align-items:center; gap:6px; padding:5px 11px; color:#c9d1d9; background:rgba(22,27,34,.7); border:1px solid rgba(56,68,86,.7); border-radius:999px; font-size:10px; font-weight:600; letter-spacing:.02em; text-decoration:none; text-transform:none; backdrop-filter:blur(8px); transition:all .2s ease; } +.author-pill:hover { color:#fff; background:rgba(30,38,49,.95); border-color:rgba(45,212,191,.4); box-shadow:0 0 14px rgba(45,212,191,.18); transform:translateY(-1px); } +.author-pill svg { color:var(--accent-hover); transition:transform .2s ease; } +.author-pill:hover svg { transform:scale(1.1); } + +.login-card { position:relative; z-index:1; width:100%; max-width:440px; padding:clamp(30px,3.6vw,44px) clamp(26px,3.2vw,36px); background:linear-gradient(160deg,rgba(24,30,42,.9),rgba(13,17,24,.96)); border:1px solid rgba(56,68,86,.85); border-radius:20px; box-shadow:0 35px 90px rgba(0,0,0,.6),inset 0 1px 0 rgba(255,255,255,.08); backdrop-filter:blur(18px); overflow:hidden; } +.login-card::before { content:''; position:absolute; top:-80px; left:50%; transform:translateX(-50%); width:220px; height:120px; background:radial-gradient(circle,rgba(45,212,191,.16),transparent 70%); pointer-events:none; } + +.login-heading { margin-bottom:25px; } +.login-heading-icon { display:grid; place-items:center; width:48px; height:48px; margin-bottom:20px; color:var(--accent-hover); background:linear-gradient(135deg,rgba(20,184,166,.18),rgba(45,212,191,.05)); border:1px solid rgba(45,212,191,.35); border-radius:14px; box-shadow:0 8px 24px rgba(0,0,0,.3),0 0 20px rgba(45,212,191,.14); } +.login-kicker { display:inline-flex; align-items:center; gap:6px; font-size:10.5px; font-weight:750; letter-spacing:.12em; text-transform:uppercase; color:var(--accent-hover); margin-bottom:6px; } +.login-kicker-dot { width:5px; height:5px; border-radius:50%; background:var(--accent); box-shadow:0 0 8px var(--accent); } +.login-heading h2 { margin-top:2px; color:#fff; font-size:26px; font-weight:850; letter-spacing:-.035em; } +.login-heading p { margin-top:6px; color:var(--text-muted); font-size:13px; line-height:1.5; } + +.login-error { display:flex; align-items:flex-start; gap:9px; margin:-5px 0 19px; padding:11px 12px; color:#fda4af; background:rgba(251,113,133,.08); border:1px solid rgba(251,113,133,.25); border-radius:8px; animation:error-shake .35s cubic-bezier(.36,.07,.19,.97) both; } +@keyframes error-shake { 10%,90%{transform:translate3d(-1px,0,0)} 20%,80%{transform:translate3d(2px,0,0)} 30%,50%,70%{transform:translate3d(-3px,0,0)} 40%,60%{transform:translate3d(3px,0,0)} } +.login-error > svg { flex-shrink:0; margin-top:2px; }.login-error > span { display:flex; flex-direction:column; color:#d88a96; font-size:10px; }.login-error strong { color:#fda4af; font-size:11px; } -.login-brand-icon { - display: flex; - align-items: center; - justify-content: center; - width: 42px; - height: 42px; - background: rgba(45, 212, 191, 0.1); - border-radius: 8px; - color: var(--accent-hover); - border: 1px solid rgba(45, 212, 191, 0.2); +.login-form { display:flex; flex-direction:column; gap:17px; } +.login-field { display:flex; flex-direction:column; gap:7px; } +.login-field > span { color:#e2e8f0; font-size:11.5px; font-weight:650; letter-spacing:.02em; } +.login-input { position:relative; display:flex; align-items:center; color:var(--muted); } +.login-input > svg { position:absolute; left:14px; pointer-events:none; transition:color .2s ease; } +.login-input:focus-within > svg { color:var(--accent-hover); } +.login-input input { height:46px; padding:0 40px 0 42px; background:#0b0f15; border:1px solid #283342; border-radius:10px; font-size:13px; color:#fff; transition:all .2s ease; } +.login-input input:focus { background:#0e141c; border-color:var(--accent); box-shadow:0 0 0 3px rgba(45,212,191,.16),0 0 16px rgba(45,212,191,.08); } +.login-input button { position:absolute; right:6px; width:32px; height:32px; padding:0; display:grid; place-items:center; color:var(--text-dim); background:transparent; border:0; border-radius:6px; box-shadow:none; transition:all .15s ease; } +.login-input button:hover { color:#fff; background:rgba(255,255,255,.06); } + +.caps-lock-badge { + display:inline-flex; + align-items:center; + gap:7px; + align-self:flex-start; + margin-top:7px; + padding:4px 10px; + border-radius:6px; + background:linear-gradient(135deg,rgba(245,158,11,.14),rgba(180,83,9,.08)); + border:1px solid rgba(245,158,11,.38); + box-shadow:0 4px 14px rgba(245,158,11,.12),inset 0 1px 0 rgba(255,255,255,.08); + color:#fde68a; + font-size:11px; + font-weight:600; + letter-spacing:.015em; + animation:capsLockPop .22s cubic-bezier(.16,1,.3,1) both; +} +.caps-lock-icon-wrap { + display:grid; + place-items:center; + width:17px; + height:17px; + border-radius:4px; + background:rgba(245,158,11,.22); + border:1px solid rgba(245,158,11,.45); + color:#fbbf24; +} +@keyframes capsLockPop { + 0% { opacity:0; transform:translateY(-4px) scale(.97); } + 100% { opacity:1; transform:translateY(0) scale(1); } } -.login-brand-text { - display: flex; - flex-direction: column; - gap: 1px; +.login-input-2fa { + position:relative; + display:flex; +} +.login-input-2fa input.input-totp { + width:100%; + height:56px; + padding:2px 16px 12px; + text-align:center; + font-family:var(--mono); + font-size:24px; + font-weight:750; + letter-spacing:.32em; + text-indent:.32em; + color:#5eead4; + background:#080c11; + border:1.5px solid rgba(45,212,191,.35); + border-radius:12px; + box-shadow:0 6px 20px rgba(0,0,0,.45),inset 0 2px 4px rgba(0,0,0,.6); + transition:all .2s ease; +} +.login-input-2fa input.input-totp:focus { + background:#0c1219; + border-color:#5eead4; + box-shadow:0 0 0 3px rgba(45,212,191,.2),0 0 24px rgba(45,212,191,.25); +} +.login-input-2fa input.input-totp::placeholder { + color:rgba(94,234,212,.22); + letter-spacing:.32em; +} +.login-input-2fa input.input-recovery { + width:100%; + height:48px; + padding:0 16px; + text-align:center; + font-family:var(--mono); + font-size:14px; + font-weight:650; + letter-spacing:.12em; + color:#fbbf24; + background:#080c11; + border:1.5px solid rgba(245,158,11,.35); + border-radius:10px; +} +.login-input-2fa input.input-recovery:focus { + border-color:#fbbf24; + box-shadow:0 0 0 3px rgba(245,158,11,.2),0 0 20px rgba(245,158,11,.25); } -.login-brand-name { - font-size: 21px; - font-weight: 820; - color: var(--text); - line-height: 1.2; +.btn-clear-totp { + position:absolute; + right:12px; + top:50%; + transform:translateY(-50%); + display:grid; + place-items:center; + width:22px; + height:22px; + border-radius:50%; + background:rgba(255,255,255,.08); + color:#94a3b8; + border:0; + padding:0; + cursor:pointer; + transition:all .15s ease; +} +.btn-clear-totp:hover { + background:rgba(255,255,255,.18); + color:#fff; } -.login-brand-tagline { - font-size: 11px; - color: var(--muted); +.totp-indicator-row { + display:flex; + align-items:center; + justify-content:center; + margin-top:8px; + padding:0 4px; +} +.totp-slots { + position:absolute; + bottom:9px; + left:50%; + transform:translateX(-50%); + display:flex; + align-items:center; + gap:10px; + pointer-events:none; + z-index:2; +} +.totp-slot { + width:12px; + height:3px; + border-radius:999px; + background:rgba(56,68,86,.65); + transition:all .2s cubic-bezier(.16,1,.3,1); +} +.totp-slot.active { + background:rgba(94,234,212,.5); + box-shadow:0 0 6px rgba(94,234,212,.4); +} +.totp-slot.filled { + background:#2dd4bf; + box-shadow:0 0 8px rgba(45,212,191,.65); +} +.totp-ready-hint { + font-size:10.5px; + font-weight:700; + color:#5eead4; + letter-spacing:.02em; + animation:readyPulse 1.5s ease-in-out infinite alternate; +} +@keyframes readyPulse { + from { opacity:.8; } + to { opacity:1; text-shadow:0 0 8px rgba(94,234,212,.6); } } -.login-heading { - margin-bottom: 24px; +.login-checkbox-field { + display:flex; + align-items:center; + gap:9px; + margin-top:-3px; + cursor:pointer; + user-select:none; + font-size:11.5px; + color:#94a3b8; + transition:color .15s ease; +} +.login-checkbox-field:hover { + color:#e2e8f0; } -.login-heading h1 { - font-size: 24px; - font-weight: 820; - margin-bottom: 6px; - color: var(--text); +.login-checkbox-field input[type="checkbox"] { + position:absolute; + opacity:0; + width:0; + height:0; + pointer-events:none; } -.login-heading p { - color: var(--muted); - font-size: 13px; +.custom-checkbox { + display:grid; + place-items:center; + width:16px; + height:16px; + border-radius:4px; + border:1px solid rgba(56,68,86,.9); + background:#0b0f15; + color:#041411; + transition:all .15s ease; + flex-shrink:0; +} +.login-checkbox-field input:checked + .custom-checkbox { + background:#2dd4bf; + border-color:#5eead4; + box-shadow:0 0 10px rgba(45,212,191,.4); } -.login-submit { - width: 100%; - justify-content: center; - padding: 10px 16px; - font-size: 14px; - margin-top: 4px; +.login-submit.btn-ready { + background:linear-gradient(135deg,#5eead4,#14b8a6); + border-color:#5eead4; + box-shadow:0 14px 34px rgba(20,184,166,.4),inset 0 1px 0 rgba(255,255,255,.5); +} +.key-return-hint { + display:grid; + place-items:center; + width:24px; + height:24px; + border-radius:5px; + background:rgba(4,20,17,.25); + color:#041411; + font-family:var(--mono); + font-size:13px; + font-weight:800; +} +.btn-kbd-hint { + display:inline-block; + padding:1px 5px; + margin-left:5px; + font-family:var(--mono); + font-size:9.5px; + font-weight:700; + color:#94a3b8; + background:rgba(255,255,255,.08); + border:1px solid rgba(255,255,255,.12); + border-radius:4px; + line-height:1.2; } -@media (max-width: 480px) { - .login-card { - padding: 26px; - } +.twofactor-links { + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + margin-top:14px; + padding-top:14px; + border-top:1px solid rgba(48,55,68,.45); +} +.btn-link-accent,.btn-link-muted { + background:none; + border:none; + padding:4px 6px; + border-radius:6px; + cursor:pointer; + font-size:11.5px; + font-weight:600; + transition:all .15s ease; +} +.btn-link-accent { + color:#5eead4; +} +.btn-link-accent:hover { + color:#99f6e4; + background:rgba(45,212,191,.1); + text-decoration:underline; + text-underline-offset:3px; +} +.btn-link-muted { + color:#94a3b8; +} +.btn-link-muted:hover { + color:#f1f5f9; + background:rgba(255,255,255,.06); +} + +.login-submit { display:flex; align-items:center; justify-content:space-between; width:100%; height:48px; margin-top:6px; padding:0 16px; color:#041411; background:linear-gradient(135deg,#2dd4bf,#14b8a6); border:1px solid rgba(94,234,212,.35); border-radius:10px; box-shadow:0 12px 28px rgba(20,184,166,.22),inset 0 1px 0 rgba(255,255,255,.35); font-size:13px; font-weight:800; transition:all .2s ease; } +.login-submit:hover { background:linear-gradient(135deg,#5eead4,#2dd4bf); transform:translateY(-1px); box-shadow:0 16px 36px rgba(20,184,166,.32); } +.login-submit i { display:grid; place-items:center; width:28px; height:28px; color:#06201b; background:rgba(255,255,255,.25); border-radius:6px; font-style:normal; transition:transform .2s ease; } +.login-submit:hover i { transform:translateX(3px); } +.login-submit:disabled { justify-content:center; opacity:.65; cursor:not-allowed; transform:none; } +.login-spinner { width:14px; height:14px; border-width:2px; border-color:rgba(4,20,17,.3); border-top-color:#041411; } + +.login-security { display:flex; align-items:center; justify-content:center; gap:7px; margin-top:22px; color:var(--text-dim); font-size:11px; } +.login-security svg { color:#6ee7b7; } + +.login-foot { position:absolute; bottom:34px; z-index:2; display:flex; align-items:center; gap:8px; color:#6e7681; font-size:9px; font-weight:600; letter-spacing:.04em; } +.login-foot i { width:2px; height:2px; background:#484f58; border-radius:50%; } +.foot-author-link { display:inline-flex; align-items:center; gap:5px; color:#8b949e; text-decoration:none; transition:all .15s ease; } +.foot-author-link:hover { color:#f0f6fc; } +.foot-author-link strong { color:var(--accent-hover); font-weight:700; } +.foot-author-link svg { color:#6e7681; transition:color .15s ease; } +.foot-author-link:hover svg { color:var(--accent-hover); } + +@media (max-width:1000px) { + .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 { 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; } + .login-story { display:none; } + .login-access { min-height:100svh; padding:90px 20px 75px; } + .access-topline { top:25px; left:20px; right:20px; } + .access-topline::before { content:''; width:29px; height:29px; margin-right:8px; background:url('/favicon.svg') center/contain no-repeat; } + .access-topline > span { margin-right:auto; } + .login-foot { bottom:24px; } +} +@media (max-width:480px) { + .login-access { padding-inline:14px; } + .login-card { padding:27px 21px; border-radius:14px; } + .login-heading h2 { font-size:24px; } } +@media (prefers-reduced-motion:reduce) { .flow-track::after { animation:none; } } diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 0371fe9..c3bcefd 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -1,24 +1,35 @@ -import { useState } from 'react' -import { useNavigate, useLocation } from 'react-router-dom' -import { setToken, api } from '../api' +import { useState, useEffect } from 'react' +import { useNavigate, useLocation, useSearchParams } from 'react-router-dom' +import { api } from '../api' import './Login.css' -function PackageIcon() { - return ( - - - - - - ) +type IconName = 'user' | 'lock' | 'eye' | 'eyeOff' | 'arrow' | 'alert' | 'shield' | 'package' | 'check' | 'capsLock' | 'x' + +function Icon({ name, size = 16 }: { name: IconName; size?: number }) { + const paths: Record = { + user: <>, + lock: <>, + eye: <>, + eyeOff: <>, + arrow: <>, + alert: <>, + shield: <>, + package: <>, + check: , + capsLock: <>, + x: <>, + } + return +} + +function AptifyMark() { + return } -function AlertIcon() { +function GitHubIcon({ size = 13 }: { size?: number }) { return ( - - - - + ) } @@ -26,81 +37,385 @@ function AlertIcon() { export default function Login() { const [username, setUsername] = useState('') const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [capsLock, setCapsLock] = useState(false) + const [serverHealthy, setServerHealthy] = useState(null) const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [searchParams] = useSearchParams() + const [step, setStep] = useState<'credentials' | '2fa'>( + searchParams.get('step') === '2fa' ? '2fa' : 'credentials' + ) + const [preAuthToken, setPreAuthToken] = useState('') + const [twoFactorCode, setTwoFactorCode] = useState('') + const [useRecovery, setUseRecovery] = useState(false) + const [rememberDevice, setRememberDevice] = useState(true) + const [activeStepTooltip, setActiveStepTooltip] = useState<'source' | 'sign' | 'publish' | null>(null) const navigate = useNavigate() const location = useLocation() - const handleSubmit = async (e: React.FormEvent) => { + useEffect(() => { + fetch('/health') + .then(res => setServerHealthy(res.ok)) + .catch(() => setServerHealthy(false)) + }, []) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && step === '2fa') { + setStep('credentials') + setError('') + setTwoFactorCode('') + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [step]) + + const checkCapsLock = (e: React.KeyboardEvent) => { + setCapsLock(e.getModifierState('CapsLock')) + } + + const handleCredentialsSubmit = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) setError('') try { const res = await api.login(username, password) - setToken(res.token) + 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 { - setToken('') - setError('Invalid username or password.') + setError('The username or password you entered is incorrect.') } finally { setLoading(false) } } - return ( -
-
-
- -
- Aptify - Self-hosted APT, simplified + const verify2FACode = async (codeToVerify: string) => { + setLoading(true) + setError('') + try { + await api.verify2FA(preAuthToken, codeToVerify, rememberDevice) + 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) + } + } + + const handle2FASubmit = (e: React.FormEvent) => { + e.preventDefault() + verify2FACode(twoFactorCode) + } + + const handle2FACodeChange = (val: string) => { + if (useRecovery) { + setTwoFactorCode(val) + return + } + const cleaned = val.replace(/[^0-9]/g, '').slice(0, 6) + setTwoFactorCode(cleaned) + } + + return
+ + +
+
+ +
+
+ Aptify + {serverHealthy !== null && ( + + + {serverHealthy ? 'Online' : 'Offline'} + + )}
+ Package control plane
+
-
-

Welcome back

-

Sign in to manage your APT repositories

-
+
+
Self-hosted by design
+

Private packages.
Beautifully controlled.

+

One secure home for publishing, signing, and distributing the software your teams depend on.

+
- {error && ( -
- - {error} +
+
+
setActiveStepTooltip('source')} + onMouseLeave={() => setActiveStepTooltip(null)} + tabIndex={0} + role="button" + aria-label="Package source guarantee" + > + +
+ Package + nano_9.0.deb
- )} - -
-
- - setUsername(e.target.value)} - placeholder="admin" - autoFocus - required - /> + {activeStepTooltip === 'source' && ( +
+ Ingestion Guarantee + Format validation & SHA256 checksumming +
+ )} +
+
+
+
-
- - setPassword(e.target.value)} - placeholder="••••••••" - required - /> +
+
setActiveStepTooltip('sign')} + onMouseLeave={() => setActiveStepTooltip(null)} + tabIndex={0} + role="button" + aria-label="Cryptographic signature guarantee" + > + +
+ Sign + GPG verified +
+ {activeStepTooltip === 'sign' && ( +
+ Chain of Trust + InRelease & Release.gpg signing +
+ )} +
+
+
+
- - +
+
setActiveStepTooltip('publish')} + onMouseLeave={() => setActiveStepTooltip(null)} + tabIndex={0} + role="button" + aria-label="Distribution guarantee" + > + +
+ Publish + Ready to install +
+ {activeStepTooltip === 'publish' && ( +
+ Atomic Distribution + Zero-downtime pool index updates +
+ )} +
-
- ) + +
+
DEB + RPMOne control plane
+
GPG signedTrusted releases
+
Self-hostedYour infrastructure
+
+
+ +
+
+ Secure administrator access + + + @kernelcode0 + +
+
+ {step === 'credentials' ? ( + <> +
+
+
Secure Workspace
+

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.
+
+
+ Aptify control plane + + + + Engineered by @kernelcode0 + + + Private by default +
+
+
} diff --git a/web/src/pages/Profile.css b/web/src/pages/Profile.css new file mode 100644 index 0000000..43e3bd0 --- /dev/null +++ b/web/src/pages/Profile.css @@ -0,0 +1,75 @@ +.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); } + +.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); } + +.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%; } + +.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); } + +@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: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 new file mode 100644 index 0000000..0949c80 --- /dev/null +++ b/web/src/pages/Profile.tsx @@ -0,0 +1,468 @@ +import { useState } from 'react' +import { Link, useOutletContext } from 'react-router-dom' +import { api, type CurrentUser, type Role } from '../api' +import './Profile.css' + +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 +} + +function XIcon() { + 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 [twoFactorEnabled, setTwoFactorEnabled] = useState(user?.two_factor_enabled ?? false) + const [deviceTrusted, setDeviceTrusted] = useState(user?.device_trusted ?? false) + const [forgetLoading, setForgetLoading] = useState(false) + const [deviceMsg, setDeviceMsg] = useState('') + + // 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) + + const handleForgetDevice = async () => { + setForgetLoading(true) + setDeviceMsg('') + try { + await api.forgetDevice() + setDeviceTrusted(false) + if (user) user.device_trusted = false + setDeviceMsg('This browser has been forgotten. Two-factor authentication will be required next time you log in.') + } catch (err: unknown) { + setDeviceMsg(err instanceof Error ? err.message : 'Failed to forget browser') + } finally { + setForgetLoading(false) + } + } + + if (!user) return null + + 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) + } + + 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 ( +
+
+
+
Account
+

Your profile

+

Your identity and access across this Aptify workspace.

+
+
Authenticated session
+
+ +
+
+
+ +
+ Signed in as +

{user.username}

+ {role.name} +
+ +
+
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 ? ( + + ) : ( + <> + + + + + )} +
+ + {twoFactorEnabled && ( +
+ + + {deviceTrusted ? 'This browser is remembered (2FA code skipped for 30 days).' : 'This browser is not remembered (2FA code required every login).'} + +
+ )} + + {deviceMsg && ( +
+ + {deviceMsg} +
+ )} +
+ +
+
+ +
Role & access

{role.name} permissions

+
+

{role.summary}

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

Quick access

Based on your role
+
+ + {user.role !== 'viewer' && } + {user.role === 'admin' && } + {user.role === 'admin' && } +
+
+
+
+ + {/* 2FA Setup Modal */} + {modalType === 'setup' && setupData && ( +
{ if (e.target === e.currentTarget) setModalType(null) }}> +
+
+

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' && ( +
{ if (e.target === e.currentTarget) setModalType(null) }}> +
+
+

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' && ( +
{ if (e.target === e.currentTarget) setModalType(null) }}> +
+
+

{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})} +
+
+ + +
+
+ )} +
+
+
+ )} +
+ ) +} + +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 52f9844..ca48e8f 100644 --- a/web/src/pages/RepoDetail.css +++ b/web/src/pages/RepoDetail.css @@ -1,3 +1,12 @@ +.index-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: var(--muted); + margin-bottom: 12px; +} + .detail-header { display: flex; justify-content: space-between; @@ -139,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/RepoDetail.tsx b/web/src/pages/RepoDetail.tsx index b9e09d4..1977286 100644 --- a/web/src/pages/RepoDetail.tsx +++ b/web/src/pages/RepoDetail.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' -import { useParams, useNavigate, Link } from 'react-router-dom' -import { api, type Package, type Repo, 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 { useConfirm } from '../components/ConfirmDialog' import './RepoDetail.css' type Tab = 'packages' | 'setup' @@ -73,6 +74,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) @@ -88,7 +90,12 @@ export default function RepoDetail() { const [error, setError] = useState('') const [uploading, setUploading] = useState(false) const [dragOver, setDragOver] = useState(false) + const [indexStatus, setIndexStatus] = useState(null) + const [confirmDialog, confirm] = useConfirm() 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 @@ -123,9 +130,21 @@ export default function RepoDetail() { if (tab === 'setup') loadSetup() }, [tab]) + // Poll index status every 3 s while this page is open. + useEffect(() => { + if (!id) return + let cancelled = false + const poll = () => { + api.getRepoStatus(id).then(s => { if (!cancelled) setIndexStatus(s) }).catch(() => {}) + } + poll() + const timer = setInterval(poll, 3000) + return () => { cancelled = true; clearInterval(timer) } + }, [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) @@ -151,7 +170,7 @@ export default function RepoDetail() { const handleDelete = async (pkg: Package) => { if (!id) return - if (!confirm(`Remove ${pkg.package} ${pkg.version}?`)) return + if (!(await confirm({ title: 'Remove package', message: `Remove ${pkg.package} ${pkg.version}?`, confirmLabel: 'Remove', danger: true }))) return try { await api.deletePackage(id, pkg.id) setPackages(p => p.filter(x => x.id !== pkg.id)) @@ -164,7 +183,7 @@ export default function RepoDetail() { const handleDeleteRepo = async () => { if (!id || !repo) return - if (!confirm(`Permanently delete repository "${repo.name}"? This cannot be undone.`)) return + if (!(await confirm({ title: 'Delete repository', message: `Permanently delete repository "${repo.name}"? This cannot be undone.`, confirmLabel: 'Delete', danger: true }))) return try { await api.deleteRepo(id) navigate('/') @@ -215,22 +234,25 @@ export default function RepoDetail() {

{repo?.name}

- {repo?.codename} + {repo?.type.toUpperCase()} + {repo?.type === 'deb' && {repo.codename}} /{repo?.slug} {totalPkgs} package{totalPkgs !== 1 ? 's' : ''}
-
- - -
+ {canManageRepo && ( +
+ + +
+ )}
{editing && ( @@ -247,10 +269,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 /> +
+ )}
+ {canManagePackages && ( + + )} ))} @@ -369,22 +407,63 @@ export default function RepoDetail() {
)} + {confirmDialog}
) } function SetupInstructions({ setup, slug, packages }: { setup: SetupInfo; slug: string; packages: Package[] }) { const examplePkg = packages.length > 0 ? packages[0].package : '' + if (setup.type === 'rpm') { + return ( +
+

Add this repository to your system and install packages.

+ + + + + + + + + + + + + + + + + +
+
Repository details
+ + + + + + +
Repository URL{setup.repoURL}
TypeRPM
Signing key{setup.keyURL}
+
+ +
+
/etc/yum.repos.d/{slug}.repo
+
{setup.repoFile}
+
+
+ ) + } + return (

Add this repository to your system and install packages.

- + - + @@ -409,7 +488,7 @@ function SetupInstructions({ setup, slug, packages }: { setup: SetupInfo; slug:
/etc/apt/sources.list.d/{slug}.list
-
{setup.addSource.split('"')[1]}
+
{setup.addSource?.split('"')[1]}
) diff --git a/web/src/pages/Users.css b/web/src/pages/Users.css new file mode 100644 index 0000000..a208837 --- /dev/null +++ b/web/src/pages/Users.css @@ -0,0 +1,56 @@ +.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 new file mode 100644 index 0000000..ea16f2b --- /dev/null +++ b/web/src/pages/Users.tsx @@ -0,0 +1,302 @@ +import { useEffect, useMemo, useState } from 'react' +import { useOutletContext } from 'react-router-dom' +import { api, type CurrentUser, type Role, type User } from '../api' +import { useConfirm } from '../components/ConfirmDialog' +import './Users.css' + +function PlusIcon() { + return ( + + + + ) +} + +function EditIcon() { + return ( + + + + + ) +} + +function TrashIcon() { + return ( + + + + + + + ) +} + +function XIcon() { + return ( + + + + ) +} + +function AlertIcon() { + return ( + + + + + + ) +} + +function KeyIcon() { + 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 [createError, setCreateError] = useState('') + 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 [query, setQuery] = useState('') + const [roleFilter, setRoleFilter] = useState<'all' | Role>('all') + const [confirmDialog, confirm] = useConfirm() + + const load = () => { + api.listUsers() + .then(u => setUsers(u ?? [])) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)) + } + + 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) + 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) { + setCreateError(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 (!(await confirm({ title: 'Delete user', message: `Delete user "${u.username}"? This cannot be undone.`, confirmLabel: 'Delete', danger: true }))) 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') + } + } + + const handleReset2FA = async (u: User) => { + if (!(await confirm({ title: 'Reset 2FA', message: `Reset 2FA for "${u.username}"? They will be able to log in with their password alone.`, confirmLabel: 'Reset 2FA' }))) 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 ( +
+
+
+
Access control
+

Users

+

Manage who can access Aptify and what they can do.

+
+
+ +
+
+ + {error &&
{error}
} + + {!loading &&
+
Workspace accounts{users.length}{users.length === 1 ? 'Active identity' : 'Active identities'}
+
Administrators{roleCount('admin')}
+
Members{roleCount('member')}
+
Viewers{roleCount('viewer')}
+
} + + {showNew && ( +
{ if (e.target === e.currentTarget) setShowNew(false) }}> +
+
+

Add User

+ +
+
+ {createError &&
{createError}
} +
+ + { 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. +
+
+ + setNewUser(f => ({ ...f, password: e.target.value }))} minLength={8} required /> +
+
+ + +
+
+ + +
+
+
+
+ )} + + {loading ? ( +
+ ) : ( +
+
+

Workspace users

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

+
+ + +
+
+
+ + + + + + + + + + + + {visibleUsers.map(u => { + const isSelf = current?.user_id === u.id + return ( + + + + + + + + ) + })} + {visibleUsers.length === 0 && } + +
UsernameRole2FACreatedActions
{u.username.slice(0, 2).toUpperCase()}{u.username}{isSelf ? 'Your account' : 'Workspace user'}{isSelf && You}
+ {editing === u.id ? ( + + ) : ( + + )} + + {u.two_factor_enabled ? ( + + Active + + ) : ( + Off + )} + {formatDate(u.created_at)} + {editing === u.id ? ( +
+ setEditPassword(e.target.value)} /> + + +
+ ) : ( +
+ + {u.two_factor_enabled && !isSelf && ( + + )} + {!isSelf && } +
+ )} +
No users foundTry a different name or role.
+
+
+ )} + {confirmDialog} +
+ ) +} + +function RoleBadge({ role }: { role: Role }) { + return {role} +} + +function formatDate(s: string) { + return new Date(s).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +}