diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..d566284 --- /dev/null +++ b/.air.toml @@ -0,0 +1,15 @@ +root = "." +tmp_dir = "tmp" + +[build] + cmd = "go build -o ./tmp/main ./cmd/goappmon" + entrypoint = ["./tmp/main"] + include_ext = ["go", "html", "sql", "json"] + exclude_dir = ["tmp", "vendor", ".git"] + exclude_regex = ["_test\\.go"] + +[log] + time = true + +[misc] + clean_on_exit = true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..55fe353 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,67 @@ +name: Bug report +description: Report a reproducible problem or regression +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug. + Please fill in as much detail as possible so the issue can be reproduced quickly. + - type: textarea + id: summary + attributes: + label: Summary + description: Describe the problem in one or two sentences. + placeholder: What happened? + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: List the exact steps that trigger the issue. + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What actually happened? + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Share relevant runtime, OS, Go version, or deployment details. + placeholder: Go version, OS, package version, etc. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs or screenshots + description: Paste logs, stack traces, or screenshots if they help explain the problem. + render: shell + - type: checkboxes + id: confirmations + attributes: + label: Confirmations + options: + - label: I have searched existing issues and docs. + required: true + - label: I can reproduce this issue consistently. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..92dac9b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Project documentation + url: https://github.com/phyowaiyan-dev/goappmon/tree/develop/docs + about: Review the project docs before opening a new issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..83a4927 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +name: Feature request +description: Suggest an improvement or new capability +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Thanks for sharing an idea. + Please describe the user problem and the outcome you want, not just the implementation. + - type: textarea + id: problem + attributes: + label: Problem statement + description: What user problem should this feature solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the solution you would like to see. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Share any other options you considered. + - type: textarea + id: context + attributes: + label: Additional context + description: Add screenshots, references, or related issues. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..e975c6c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,22 @@ +name: Question +description: Ask for help or clarification about the project +title: "[Question]: " +labels: + - question +body: + - type: markdown + attributes: + value: | + Use this template for support questions, usage clarification, or project guidance. + - type: textarea + id: question + attributes: + label: Your question + description: Ask your question clearly and include context. + validations: + required: true + - type: textarea + id: context + attributes: + label: Context + description: Include code, config, or links that help explain the question. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e596a42 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + push: + branches: + - develop + pull_request: + workflow_dispatch: + +jobs: + fmt: + name: Go Format + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.1" + check-latest: true + + - name: Verify gofmt + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "The following files need gofmt:" + gofmt -l . + exit 1 + fi + + lint: + name: GolangCI-Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.1" + check-latest: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: v2.11.2 + args: --timeout=5m + + test: + name: Go Test + runs-on: ubuntu-latest + needs: + - fmt + - lint + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.1" + check-latest: true + cache: true + + - name: Download modules + run: go mod download + + - name: Run tests + run: go test ./... + + - name: Build + run: go build ./... diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..3a251fa --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,40 @@ +name: Docs + +on: + push: + paths: + - "README.md" + - "docs/**" + - ".github/workflows/docs.yml" + pull_request: + paths: + - "README.md" + - "docs/**" + - ".github/workflows/docs.yml" + workflow_dispatch: + +jobs: + validate-docs: + name: Validate docs structure + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check required docs files exist + run: | + test -f README.md + test -f docs/README.md + test -f docs/project-overview.md + test -f docs/tech-stack.md + test -f docs/architecture.md + test -f docs/development.md + test -f docs/testing.md + test -f docs/release.md + test -f docs/security.md + test -f docs/contributing.md + test -f docs/roadmap.md + + - name: Print docs tree + run: find docs -maxdepth 1 -type f | sort diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b8d6511 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,121 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.os }} / ${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + + strategy: + fail-fast: false + matrix: + include: + - os: linux + arch: amd64 + runner: ubuntu-latest + artifact_name: goappmon-linux-amd64 + archive_name: goappmon-linux-amd64.tar.gz + binary_name: goappmon + - os: linux + arch: arm64 + runner: ubuntu-latest + artifact_name: goappmon-linux-arm64 + archive_name: goappmon-linux-arm64.tar.gz + binary_name: goappmon + - os: darwin + arch: amd64 + runner: macos-latest + artifact_name: goappmon-darwin-amd64 + archive_name: goappmon-darwin-amd64.tar.gz + binary_name: goappmon + - os: darwin + arch: arm64 + runner: macos-latest + artifact_name: goappmon-darwin-arm64 + archive_name: goappmon-darwin-arm64.tar.gz + binary_name: goappmon + - os: windows + arch: amd64 + runner: windows-latest + artifact_name: goappmon-windows-amd64 + archive_name: goappmon-windows-amd64.zip + binary_name: goappmon.exe + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.1" + check-latest: true + cache: true + + - name: Download modules + run: go mod download + + - name: Build binary + shell: bash + run: | + set -euo pipefail + mkdir -p dist + GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "dist/${{ matrix.binary_name }}" ./cmd/goappmon + + - name: Package Linux and macOS release + if: matrix.os != 'windows' + shell: bash + run: | + set -euo pipefail + tar -czf "dist/${{ matrix.archive_name }}" -C dist "${{ matrix.binary_name }}" + + - name: Package Windows release + if: matrix.os == 'windows' + shell: pwsh + run: | + Compress-Archive -Path "dist/${{ matrix.binary_name }}" -DestinationPath "dist/${{ matrix.archive_name }}" + + - name: Upload release artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: dist/${{ matrix.archive_name }} + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish GitHub Release + runs-on: ubuntu-latest + needs: + - build + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Download all release artifacts + uses: actions/download-artifact@v4 + with: + path: release-assets + + - name: Collect release files + shell: bash + run: | + set -euo pipefail + mkdir -p dist + find release-assets -type f | while read -r file; do + cp "$file" dist/ + done + ls -lah dist + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3c3d95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +storage/*.sqlite +storage/*.sqlite-wal +storage/*.sqlite-shm +storage/*.sqlite-journal +storage/*.db +storage/*.db-wal +storage/*.db-shm +storage/*.db-journal +storage/*.key +storage/session.key +tmp/ +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3d74ac6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## Unreleased + +- Initial repository scaffolding +- Documentation foundation +- GitHub issue templates and starter workflows + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..1cd0125 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,32 @@ +# Code of Conduct + +## Our Pledge + +We are committed to making this project a respectful, welcoming, and productive space for everyone. + +## Expected Behavior + +Please: + +- be respectful in reviews, issues, and discussions +- focus on the code and the problem, not the person +- give feedback that is specific and constructive +- assume good intent unless there is clear evidence otherwise + +## Unacceptable Behavior + +The following are not acceptable: + +- harassment or discrimination +- insulting or demeaning language +- deliberate disruption of discussions or reviews +- sharing private information without permission + +## Enforcement + +Maintainers may remove content or restrict participation when behavior does not meet this standard. + +## Scope + +This code of conduct applies to all project spaces, including issues, pull requests, documentation, and related community channels. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2447385 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Contributing + +Thanks for your interest in `goappmon`. + +This project is intended to be maintained as a production-quality open-source repository. Contributions are welcome as long as they stay focused, testable, and aligned with the project documentation. + +## Before You Start + +Please read: + +- [Project overview](docs/project-overview.md) +- [Tech stack](docs/tech-stack.md) +- [Architecture](docs/architecture.md) +- [Development guide](docs/development.md) +- [Testing guide](docs/testing.md) +- [Security policy](SECURITY.md) + +## Good Contributions + +- bug fixes +- tests +- documentation improvements +- dependency or build fixes +- code cleanup that improves maintainability + +## Contribution Workflow + +1. Open an issue or check existing work first. +2. Keep the change narrow and easy to review. +3. Add or update tests when behavior changes. +4. Update docs when commands, configuration, or output changes. +5. Run formatting and verification locally. +6. Open a pull request with a clear description of the change. + +## Local Checks + +```bash +go fmt ./... +go test ./... +go build ./... +``` + +## Pull Request Expectations + +- explain what changed and why +- call out breaking changes clearly +- include screenshots, logs, or examples when relevant +- keep unrelated cleanup out of feature PRs unless necessary + +## Code Style + +- use standard Go formatting +- keep functions and packages small when possible +- prefer readability over cleverness +- avoid introducing dependencies unless they solve a real problem + +## Documentation Rule + +If the code changes behavior, the docs should change too. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f09176 --- /dev/null +++ b/README.md @@ -0,0 +1,283 @@ +# goappmon + +`goappmon` is a lightweight application control center for mobile and web applications. + +It ships as a single Go binary with SQLite storage, Gin HTTP handlers, bcrypt-backed admin auth, server-rendered HTML, and JSON APIs for app status, version policy, and feature flags. + +## Start Here + +- [Project overview](docs/project-overview.md) +- [Tech stack](docs/tech-stack.md) +- [Architecture](docs/architecture.md) +- [Development guide](docs/development.md) +- [Deployment guide](docs/deployment.md) +- [Testing guide](docs/testing.md) +- [Release guide](docs/release.md) +- [Security policy](docs/security.md) +- [Contributing guide](docs/contributing.md) +- [Roadmap](docs/roadmap.md) +- [Docs index](docs/README.md) +- [Contributor guide](CONTRIBUTING.md) +- [Code of conduct](CODE_OF_CONDUCT.md) +- [Security reporting](SECURITY.md) +- [Changelog](CHANGELOG.md) + +## Current Status + +- Module path: `github.com/phyowaiyan-dev/goappmon` +- Go version: `1.26.1` +- License: MIT +- Storage: `storage/goappmon.sqlite` +- Server-rendered admin UI: yes +- Public JSON APIs: yes +- Source code: implemented MVP + +## Repository Contents + +- `go.mod` - module definition +- `LICENSE` - MIT license +- `README.md` - landing page and docs entry point +- `docs/` - production-oriented project documentation +- `CONTRIBUTING.md` - contributor workflow and standards +- `CODE_OF_CONDUCT.md` - community behavior policy +- `SECURITY.md` - vulnerability reporting and security contact guidance +- `CHANGELOG.md` - release history placeholder +- `internal/` - private implementation notes and work items + +## Build + +```bash +go build ./... +``` + +## Run + +```bash +go run ./cmd/goappmon +``` + +## GitHub Releases + +Every tagged release triggers `.github/workflows/release.yml` and publishes prebuilt archives for: + +- Linux `amd64` +- Linux `arm64` +- macOS `amd64` +- macOS `arm64` +- Windows `amd64` + +The archives contain a single runnable binary for the target platform, so production servers do not need to build from source. + +## Ubuntu Deployment + +`goappmon` is designed to run as a single Go binary on Ubuntu Server behind either Apache2 or Nginx. + +### 1. Install prerequisites + +```bash +sudo apt update +sudo apt install -y git build-essential ca-certificates +``` + +If you are deploying from a GitHub Release, download the Linux binary archive from the Releases page instead of building from source. + +```bash +sudo mkdir -p /opt/goappmon +curl -L -o /tmp/goappmon-linux-amd64.tar.gz "https://github.com/phyowaiyan-dev/goappmon/releases/latest/download/goappmon-linux-amd64.tar.gz" +tar -xzf /tmp/goappmon-linux-amd64.tar.gz -C /tmp +sudo install -m 755 /tmp/goappmon /opt/goappmon/goappmon +``` + +### 2. Create runtime directories + +The app stores SQLite data and its session secret under `storage/`. + +```bash +sudo mkdir -p /opt/goappmon/storage +sudo chown -R www-data:www-data /opt/goappmon +``` + +### 3. Run the app + +The default address is `:18180`. + +```bash +GOAPPMON_ADDR=:18180 /opt/goappmon/goappmon +``` + +You can also override the defaults with environment variables: + +```bash +export GOAPPMON_ADDR=:18180 +export GOAPPMON_DB_PATH=/opt/goappmon/storage/goappmon.sqlite +export GOAPPMON_SESSION_KEY_PATH=/opt/goappmon/storage/session.key +export GOAPPMON_LOG_LEVEL=info +/opt/goappmon/goappmon +``` + +### 4. Point your domain to the server + +For your root domain and any subdomain, create `A` records that point to your Ubuntu server public IPv4 address. + +Example: + +- `example.com` -> `203.0.113.10` +- `www.example.com` -> `203.0.113.10` +- `admin.example.com` -> `203.0.113.10` + +If you use IPv6, add `AAAA` records too. + +Make sure your DNS provider has fully propagated before requesting SSL certificates. + +### 5. Open firewall ports + +Allow HTTP and HTTPS traffic to the server: + +```bash +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw allow OpenSSH +sudo ufw enable +``` + +### 6. Run as a systemd service + +Create `/etc/systemd/system/goappmon.service`: + +```ini +[Unit] +Description=GoAppMon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/goappmon +Environment=GOAPPMON_ADDR=127.0.0.1:18180 +Environment=GOAPPMON_DB_PATH=/opt/goappmon/storage/goappmon.sqlite +Environment=GOAPPMON_SESSION_KEY_PATH=/opt/goappmon/storage/session.key +Environment=GOAPPMON_LOG_LEVEL=info +ExecStart=/opt/goappmon/goappmon +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target +``` + +Then enable it: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now goappmon +sudo systemctl status goappmon +``` + +### 7. Reverse proxy with Nginx + +Install Nginx: + +```bash +sudo apt install -y nginx +``` + +Create `/etc/nginx/sites-available/goappmon`: + +```nginx +server { + listen 80; + server_name example.com www.example.com admin.example.com; + + location / { + proxy_pass http://127.0.0.1:18180; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Enable and reload: + +```bash +sudo ln -s /etc/nginx/sites-available/goappmon /etc/nginx/sites-enabled/goappmon +sudo nginx -t +sudo systemctl reload nginx +``` + +### 8. Reverse proxy with Apache2 + +Install Apache2 and enable required modules: + +```bash +sudo apt install -y apache2 +sudo a2enmod proxy proxy_http headers rewrite ssl +``` + +Create a virtual host, for example `/etc/apache2/sites-available/goappmon.conf`: + +```apache + + ServerName example.com + ServerAlias www.example.com admin.example.com + + ProxyPreserveHost On + ProxyPass / http://127.0.0.1:18180/ + ProxyPassReverse / http://127.0.0.1:18180/ + + RequestHeader set X-Forwarded-Proto "http" + +``` + +Enable and reload: + +```bash +sudo a2ensite goappmon +sudo apache2ctl configtest +sudo systemctl reload apache2 +``` + +### 9. Add SSL with Certbot + +For Nginx: + +```bash +sudo apt install -y certbot python3-certbot-nginx +sudo certbot --nginx -d example.com -d www.example.com -d admin.example.com +``` + +For Apache2: + +```bash +sudo apt install -y certbot python3-certbot-apache +sudo certbot --apache -d example.com -d www.example.com -d admin.example.com +``` + +Certbot will renew automatically on Ubuntu through systemd timers. You can verify with: + +```bash +sudo certbot renew --dry-run +``` + +### 10. Final checks + +After deployment, confirm: + +- `https://example.com` loads the app +- `https://example.com/admin/login` opens the login page +- `storage/goappmon.sqlite` exists and is writable by the service user +- the reverse proxy forwards requests to `127.0.0.1:18180` + +## Deployment Notes + +- Keep the binary and `storage/` directory together on the server. +- Do not expose the Go app directly to the internet when using Apache2 or Nginx. +- Use HTTPS for all production traffic. + +## License + +This project is licensed under the MIT License. See [LICENSE](LICENSE) for the full text. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ba3e281 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security Policy + +## Supported Versions + +This repository is currently in its foundation stage, so there are no stable supported runtime releases yet. + +As the project matures, supported versions should be listed here. + +## Reporting a Vulnerability + +If you find a security issue: + +1. Do not open a public issue. +2. Describe the problem clearly and include reproduction steps. +3. Share any relevant logs, impact details, and affected versions. + +If a private reporting channel is added later, document it here. + +## Security Expectations + +- never commit secrets +- avoid logging sensitive data +- keep dependencies minimal and reviewed +- validate all untrusted input +- use explicit timeouts and cancellation for networked code + +## Responsible Disclosure + +Security fixes should be coordinated carefully so users can update before details are published. + diff --git a/cmd/goappmon/main.go b/cmd/goappmon/main.go new file mode 100644 index 0000000..bc7095a --- /dev/null +++ b/cmd/goappmon/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/phyowaiyan-dev/goappmon/internal/app" + "github.com/phyowaiyan-dev/goappmon/internal/config" +) + +func main() { + cfg := config.Load() + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: cfg.LogLevel, + })) + slog.SetDefault(logger) + + application, err := app.New(cfg, logger) + if err != nil { + logger.Error("failed to initialize application", "error", err) + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := application.Run(ctx); err != nil { + logger.Error("application stopped with error", "error", err) + os.Exit(1) + } +} diff --git a/dev-notes.md b/dev-notes.md new file mode 100644 index 0000000..9284120 --- /dev/null +++ b/dev-notes.md @@ -0,0 +1,4 @@ +# GoAppMon + +## Run Air on development +- air -c .air.toml \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3c86042 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,23 @@ +# Documentation Index + +This folder contains the project documentation for GoAppMon. + +## Guides + +- [Project overview](project-overview.md) +- [Tech stack](tech-stack.md) +- [Architecture](architecture.md) +- [Development guide](development.md) +- [Deployment guide](deployment.md) +- [Testing guide](testing.md) +- [Release guide](release.md) +- [Roadmap](roadmap.md) + +## Community and Policy + +- [Contributing guide](contributing.md) +- [Security policy](security.md) +- [Code of conduct](../CODE_OF_CONDUCT.md) +- [Root contributing guide](../CONTRIBUTING.md) +- [Root security policy](../SECURITY.md) + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..276e87e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,49 @@ +# Architecture + +## Target Shape + +The eventual architecture should keep the project easy to maintain and suitable for production use. + +Likely package boundaries: + +- `cmd/` for executable entry points +- `internal/` for private implementation details +- `pkg/` for reusable public packages, if needed +- `examples/` for runnable usage samples +- `tests/` for cross-package or integration-level tests + +## Design Goals + +- separate public API from implementation details +- keep configuration explicit and testable +- isolate I/O, networking, and external integrations +- make failure modes visible and debuggable +- avoid overengineering early + +## Suggested Layers + +### Application Layer + +Owns startup, wiring, and process lifecycle. + +### Domain or Core Layer + +Contains monitoring logic, policy decisions, and business rules. + +### Infrastructure Layer + +Contains adapters for logging, metrics export, storage, HTTP, filesystem, or third-party services. + +## Observability of the Tooling + +The project itself should be observable: + +- logs should be structured and actionable +- failures should carry context +- metrics or counters should be added where useful +- errors should be wrapped with meaning + +## Architecture Rule + +Do not add a pattern, framework, or abstraction unless it helps with a real project need. + diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..a601072 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,6 @@ +# Contributing + +For the full contributor workflow, please read the root-level [CONTRIBUTING.md](../CONTRIBUTING.md). + +This docs page exists so the documentation structure remains complete and the docs validation workflow can verify it. + diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..d2f7ef5 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,201 @@ +# Deployment Guide + +## Overview + +`goappmon` runs as a single Go binary and stores its data in SQLite. +The recommended production setup on Ubuntu is: + +- Go binary on the server +- SQLite database and session key under `storage/` +- systemd to manage the process +- Apache2 or Nginx as a reverse proxy +- Certbot for SSL certificates + +## Default Runtime Paths + +- database: `storage/goappmon.sqlite` +- session key: `storage/session.key` +- default address: `:18180` + +## Prepare the Server + +Install packages: + +```bash +sudo apt update +sudo apt install -y git build-essential ca-certificates +``` + +Install Go `1.26.1` or newer, then clone and build: + +```bash +git clone https://github.com/phyowaiyan-dev/goappmon.git +cd goappmon +go build -o goappmon ./cmd/goappmon +``` + +If you prefer the production binary from GitHub Releases, download the Linux archive for your server architecture and install it into `/opt/goappmon/` instead of building from source. + +Create the storage directory: + +```bash +mkdir -p storage +chmod 755 storage +``` + +## DNS Setup + +Point your domain and subdomains to the Ubuntu server with `A` records. + +Example: + +- `example.com` -> server public IPv4 +- `www.example.com` -> server public IPv4 +- `admin.example.com` -> server public IPv4 + +If your server has IPv6, add `AAAA` records too. + +Wait for DNS propagation before issuing SSL certificates. + +## Run with systemd + +Create `/etc/systemd/system/goappmon.service`: + +```ini +[Unit] +Description=GoAppMon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/goappmon +Environment=GOAPPMON_ADDR=127.0.0.1:18180 +Environment=GOAPPMON_DB_PATH=/opt/goappmon/storage/goappmon.sqlite +Environment=GOAPPMON_SESSION_KEY_PATH=/opt/goappmon/storage/session.key +Environment=GOAPPMON_LOG_LEVEL=info +ExecStart=/opt/goappmon/goappmon +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target +``` + +Enable it: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now goappmon +sudo systemctl status goappmon +``` + +## Reverse Proxy with Nginx + +Install Nginx: + +```bash +sudo apt install -y nginx +``` + +Example site file: + +```nginx +server { + listen 80; + server_name example.com www.example.com admin.example.com; + + location / { + proxy_pass http://127.0.0.1:18180; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Enable and reload: + +```bash +sudo ln -s /etc/nginx/sites-available/goappmon /etc/nginx/sites-enabled/goappmon +sudo nginx -t +sudo systemctl reload nginx +``` + +## Reverse Proxy with Apache2 + +Install Apache2: + +```bash +sudo apt install -y apache2 +sudo a2enmod proxy proxy_http headers rewrite ssl +``` + +Example virtual host: + +```apache + + ServerName example.com + ServerAlias www.example.com admin.example.com + + ProxyPreserveHost On + ProxyPass / http://127.0.0.1:18180/ + ProxyPassReverse / http://127.0.0.1:18180/ + + RequestHeader set X-Forwarded-Proto "http" + +``` + +Enable and reload: + +```bash +sudo a2ensite goappmon +sudo apache2ctl configtest +sudo systemctl reload apache2 +``` + +## SSL with Certbot + +For Nginx: + +```bash +sudo apt install -y certbot python3-certbot-nginx +sudo certbot --nginx -d example.com -d www.example.com -d admin.example.com +``` + +For Apache2: + +```bash +sudo apt install -y certbot python3-certbot-apache +sudo certbot --apache -d example.com -d www.example.com -d admin.example.com +``` + +Test renewal: + +```bash +sudo certbot renew --dry-run +``` + +## Firewall + +Allow required ports: + +```bash +sudo ufw allow OpenSSH +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +``` + +## Post-Deployment Checklist + +- app starts without errors +- `storage/goappmon.sqlite` is created +- `storage/session.key` is created +- reverse proxy points to `127.0.0.1:18180` +- DNS `A` records resolve correctly +- HTTPS certificate is active diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..6b57199 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,38 @@ +# Development Guide + +## Local Setup + +1. Install Go `1.26.1` or newer. +2. Clone the repository. +3. Run `go mod tidy` after dependencies or source files are added. + +## Common Commands + +```bash +go fmt ./... +go test ./... +go build ./... +``` + +## Workflow + +1. Make the smallest useful change. +2. Add or update tests. +3. Keep docs in sync with behavior. +4. Verify formatting and tests locally. +5. Open a pull request with a clear description. + +## Code Expectations + +- prefer small, composable packages +- keep public APIs minimal and stable +- use descriptive names +- avoid hidden global state when possible +- handle errors explicitly + +## Branch Hygiene + +- use feature branches for all changes +- keep commits focused +- avoid mixing unrelated refactors into feature work + diff --git a/docs/project-overview.md b/docs/project-overview.md new file mode 100644 index 0000000..3c03627 --- /dev/null +++ b/docs/project-overview.md @@ -0,0 +1,50 @@ +# Project Overview + +## Purpose + +`goappmon` is a production-oriented Go project for application monitoring and observability. + +It is currently implemented as a lightweight application control center for mobile and web apps. + +The project can grow into one or more of the following shapes: + +- a reusable Go library +- a CLI for monitoring and diagnostics +- an agent or service for health and telemetry collection +- integrations for dashboards, alerting, or external observability platforms + +## Current State + +The repository currently contains the MVP application, docs, templates, and GitHub workflow scaffolding. + +Current facts: + +- module path: `github.com/phyowaiyan-dev/goappmon` +- Go version: `1.26.1` +- license: MIT +- database: SQLite at `storage/goappmon.sqlite` +- auth: bcrypt + secure cookie session +- web UI: html/template with Tailwind CDN +- public API: health, status, version, config, feature flags + +## Principles + +The project should be built with the following principles: + +- production safety first +- clear defaults and minimal surprise +- testability and maintainability +- explicit configuration +- good observability of the observability tooling itself +- OSS-friendly contribution and release flow + +## Non-Goals + +The MVP does not include: + +- Docker packaging +- Redis +- PostgreSQL +- .env-based configuration +- a JavaScript frontend framework + diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..e9acfb2 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,48 @@ +# Release Guide + +## Release Model + +The project should use predictable, documented releases. + +Recommended release inputs: + +- tagged versions +- changelog entries +- CI validation +- documented breaking changes + +## Release Checklist + +- version is tagged consistently +- tests pass +- README and docs reflect the shipped behavior +- security-sensitive changes are reviewed +- example commands still work + +## Versioning + +Use a clear versioning policy once the codebase stabilizes. + +Common options: + +- semantic versioning +- date-based releases +- internal pre-release tags for early development + +## Artifacts to Publish + +Depending on the future project shape, releases may include: + +- Go module versions +- CLI binaries for Linux, macOS, and Windows +- release notes + +For `goappmon`, the GitHub release workflow builds runnable archives for: + +- Linux `amd64` +- Linux `arm64` +- macOS `amd64` +- macOS `arm64` +- Windows `amd64` + +That means production operators can download a release asset and run the binary directly on the target server without rebuilding from source. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..0f151dd --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,35 @@ +# Roadmap + +## Phase 0: Scaffold + +- module setup +- license +- documentation foundation + +## Phase 1: Core Project Shape + +- define the primary package structure +- establish the public API or CLI entry point +- add baseline tests +- wire CI + +## Phase 2: Production Readiness + +- configuration handling +- logging strategy +- error handling conventions +- release process +- security documentation + +## Phase 3: OSS Maturity + +- contributing workflow +- issue and PR templates +- changelog practice +- examples and guides +- versioned releases + +## Roadmap Rule + +Do not promise features here until they are planned and actively being implemented. + diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..cc70736 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,6 @@ +# Security + +For the full vulnerability reporting policy, please read the root-level [SECURITY.md](../SECURITY.md). + +This docs page exists so the documentation structure remains complete and the docs validation workflow can verify it. + diff --git a/docs/tech-stack.md b/docs/tech-stack.md new file mode 100644 index 0000000..712a30c --- /dev/null +++ b/docs/tech-stack.md @@ -0,0 +1,52 @@ +# Tech Stack + +## Baseline + +The project is currently defined as a Go module. + +- language: Go +- minimum module declaration: `go 1.26.1` +- package distribution: Go module +- license: MIT + +## Recommended Production Stack + +These are the technologies and patterns the project can use as it grows. + +### Core Runtime + +- Go standard library for the base implementation +- context-aware code for cancellation and deadlines +- structured logging +- configuration via environment variables and config files + +### Quality and Reliability + +- unit tests with `go test` +- table-driven tests for behavior coverage +- linting and formatting in CI +- dependency review and vulnerability checks +- release tags and changelog entries + +### Documentation + +- Markdown in `docs/` +- example snippets in README and docs +- architecture notes for major design decisions + +### Delivery and Maintenance + +- GitHub for source control and issues +- GitHub Actions for CI +- semantic or tag-based release flow +- issue and pull request templates + +## Stack Decision Rule + +When adding new dependencies, prefer the smallest set that: + +- solves the problem clearly +- has active maintenance +- keeps the project easy to build and audit +- does not lock the project into unnecessary complexity + diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..b72e657 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,41 @@ +# Testing Guide + +## Testing Goals + +The project should be testable from the start. + +Testing should prove: + +- core behavior works as intended +- regressions are caught early +- error paths are handled +- public examples remain valid + +## Minimum Test Baseline + +- unit tests for core packages +- table-driven tests where they fit naturally +- integration tests for external dependencies +- smoke tests for executable entry points, once added + +## Commands + +```bash +go test ./... +``` + +## Testing Standards + +- prefer deterministic tests +- avoid real network calls unless explicitly testing integrations +- isolate time, randomness, and filesystem access where practical +- make failures easy to understand + +## Release Gate + +Do not ship a release until: + +- the full test suite passes +- formatting and linting pass +- documentation matches the current behavior + diff --git a/go.mod b/go.mod index 83f8275..2aa7d89 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,55 @@ module github.com/phyowaiyan-dev/goappmon go 1.26.1 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/shirou/gopsutil/v4 v4.26.5 + golang.org/x/crypto v0.53.0 + modernc.org/sqlite v1.53.0 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bfe548f --- /dev/null +++ b/go.sum @@ -0,0 +1,155 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +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/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/README.md b/internal/README.md new file mode 100644 index 0000000..30d3e0e --- /dev/null +++ b/internal/README.md @@ -0,0 +1,13 @@ +# Internal + +This directory is reserved for private implementation details that should not be treated as part of the public API surface. + +Use this folder for: + +- internal services and adapters +- private helpers +- implementation notes that guide the codebase +- task lists that are only relevant to maintainers + +Anything that should be consumed by other packages should live elsewhere, such as `pkg/` if the project later exposes reusable public packages. + diff --git a/internal/TODO.md b/internal/TODO.md new file mode 100644 index 0000000..8fe3ce9 --- /dev/null +++ b/internal/TODO.md @@ -0,0 +1,25 @@ +# Internal TODO + +This file tracks implementation work for the initial project build-out. + +## Priority 1 + +- define the first public package or CLI entry point +- decide the initial package structure under `internal/` and `pkg/` +- add baseline unit tests +- add a minimal example or smoke test + +## Priority 2 + +- introduce configuration handling +- add structured logging +- define error wrapping and output conventions +- document operational defaults + +## Priority 3 + +- add integrations or exporters +- add release automation +- expand test coverage +- document supported environments + diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..de8cecd --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,345 @@ +package app + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "html/template" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/phyowaiyan-dev/goappmon/internal/config" + "github.com/phyowaiyan-dev/goappmon/internal/database" + "github.com/phyowaiyan-dev/goappmon/internal/handlers" + "github.com/phyowaiyan-dev/goappmon/internal/middleware" + "github.com/phyowaiyan-dev/goappmon/internal/repositories" + "github.com/phyowaiyan-dev/goappmon/internal/services" + "github.com/phyowaiyan-dev/goappmon/web" +) + +type App struct { + cfg config.Config + logger *slog.Logger + startedAt time.Time + db *sql.DB + router *gin.Engine + server *http.Server + + renderer *templateRenderer + + setupService *services.SetupService + authService *services.AuthService + statusService *services.StatusService + adminService *services.AdminService + adminRepo *repositories.AdminRepository +} + +func New(cfg config.Config, logger *slog.Logger) (*App, error) { + if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o755); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(cfg.SessionKeyPath), 0o755); err != nil { + return nil, err + } + + db, err := database.Open(cfg.DatabasePath) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + if err := database.Migrate(ctx, db); err != nil { + _ = db.Close() + return nil, err + } + + sessionSecret, err := loadSessionSecret(cfg.SessionKeyPath) + if err != nil { + _ = db.Close() + return nil, err + } + + adminRepo := repositories.NewAdminRepository(db) + settingRepo := repositories.NewSettingRepository(db) + flagRepo := repositories.NewFeatureFlagRepository(db) + setupService := services.NewSetupService(db) + if err := setupService.EnsureDefaultSettings(ctx); err != nil { + _ = db.Close() + return nil, err + } + + authService := services.NewAuthService(adminRepo, sessionSecret, time.Duration(cfg.SessionDuration)*time.Second) + statusService := services.NewStatusService(settingRepo, flagRepo) + startedAt := time.Now().UTC() + adminService := services.NewAdminService(db, settingRepo, flagRepo, cfg.DatabasePath, startedAt) + renderer, err := newTemplateRenderer() + if err != nil { + _ = db.Close() + return nil, err + } + + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Recovery()) + router.Use(loggingMiddleware(logger)) + router.Use(middleware.SetupRedirect(setupService)) + + app := &App{ + cfg: cfg, + logger: logger, + startedAt: startedAt, + db: db, + router: router, + renderer: renderer, + setupService: setupService, + authService: authService, + statusService: statusService, + adminService: adminService, + adminRepo: adminRepo, + } + app.registerRoutes() + app.server = &http.Server{ + Addr: cfg.Address, + Handler: app.router, + ReadHeaderTimeout: 10 * time.Second, + } + return app, nil +} + +func (a *App) Run(ctx context.Context) error { + errCh := make(chan error, 1) + go func() { + a.logger.Info("starting server", "addr", a.cfg.Address) + errCh <- a.server.ListenAndServe() + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := a.server.Shutdown(shutdownCtx); err != nil { + return err + } + return a.db.Close() + case err := <-errCh: + if err == nil || errors.Is(err, http.ErrServerClosed) { + return a.db.Close() + } + _ = a.db.Close() + return err + } +} + +func (a *App) RenderPage(w http.ResponseWriter, page string, data any) error { + return a.renderer.RenderPage(w, page, data) +} + +func (a *App) registerRoutes() { + publicHandler := handlers.NewPublicHandler(a.statusService) + setupHandler := handlers.NewSetupHandler(a.renderer, a.setupService) + authHandler := handlers.NewAuthHandler(a.renderer, a.authService, a.cfg.CookieName, time.Duration(a.cfg.SessionDuration)*time.Second) + adminHandler := handlers.NewAdminHandler(a.renderer, a.adminService) + + a.router.GET("/", func(c *gin.Context) { + if cookie, err := c.Cookie(a.cfg.CookieName); err == nil && strings.TrimSpace(cookie) != "" { + c.Redirect(http.StatusFound, "/admin") + return + } + c.Redirect(http.StatusFound, "/admin/login") + }) + + a.router.GET("/health", publicHandler.Health) + a.router.GET("/api/status", publicHandler.Status) + a.router.GET("/api/version", publicHandler.Version) + a.router.GET("/api/config", publicHandler.Config) + a.router.GET("/api/feature-flags", publicHandler.FeatureFlags) + + a.router.GET("/setup", setupHandler.Page) + a.router.POST("/setup", setupHandler.Submit) + + a.router.GET("/admin/login", authHandler.LoginPage) + a.router.POST("/admin/login", authHandler.Login) + a.router.POST("/admin/logout", authHandler.Logout) + + adminGroup := a.router.Group("/admin") + adminGroup.Use(middleware.RequireAuth(a.authService, a.adminRepo, a.cfg.CookieName)) + adminGroup.GET("", adminHandler.Dashboard) + adminGroup.GET("/system-health", adminHandler.SystemHealthPanel) + adminGroup.GET("/postman-collection", adminHandler.DownloadPostmanCollection) + adminGroup.GET("/audit-logs", adminHandler.AuditLogsPage) + adminGroup.POST("/platforms", adminHandler.UpdatePlatforms) + adminGroup.POST("/settings/application", adminHandler.UpdateApplication) + adminGroup.POST("/settings/version", adminHandler.UpdateVersion) + adminGroup.POST("/version/:platform", adminHandler.PublishVersion) + adminGroup.POST("/version/:platform/delete", adminHandler.DeleteCurrentVersion) + adminGroup.POST("/settings/maintenance", adminHandler.UpdateMaintenance) + adminGroup.POST("/settings/banner", adminHandler.UpdateBanner) + adminGroup.POST("/feature-flags", adminHandler.CreateFlag) + adminGroup.POST("/feature-flags/:id", adminHandler.UpdateFlag) + adminGroup.POST("/feature-flags/:id/delete", adminHandler.DeleteFlag) +} + +func loadSessionSecret(path string) ([]byte, error) { + if data, err := os.ReadFile(path); err == nil { + decoded, decodeErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data))) + if decodeErr == nil && len(decoded) >= 32 { + return decoded, nil + } + } + + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, err + } + if err := os.WriteFile(path, []byte(base64.StdEncoding.EncodeToString(secret)), 0o600); err != nil { + return nil, err + } + return secret, nil +} + +func loggingMiddleware(logger *slog.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + logger.Info("request", + "method", c.Request.Method, + "path", c.Request.URL.Path, + "status", c.Writer.Status(), + "latency", time.Since(start).String(), + "ip", c.ClientIP(), + ) + } +} + +type templateRenderer struct { + baseTemplates *template.Template + fragmentTemplates *template.Template +} + +func newTemplateRenderer() (*templateRenderer, error) { + funcMap := templateFuncs() + baseTemplates, err := template.New("base").Funcs(funcMap).ParseFS(web.TemplatesFS, + "templates/layouts/*.html", + "templates/components/*.html", + ) + if err != nil { + return nil, err + } + fragmentTemplates, err := template.New("fragments").Funcs(funcMap).ParseFS(web.TemplatesFS, + "templates/components/*.html", + ) + if err != nil { + return nil, err + } + return &templateRenderer{ + baseTemplates: baseTemplates, + fragmentTemplates: fragmentTemplates, + }, nil +} + +func (r *templateRenderer) RenderPage(w http.ResponseWriter, page string, data any) error { + tpl, err := r.baseTemplates.Clone() + if err != nil { + return fmt.Errorf("clone base templates: %w", err) + } + if _, err := tpl.ParseFS(web.TemplatesFS, "templates/pages/"+page); err != nil { + return fmt.Errorf("parse template %s: %w", page, err) + } + if err := tpl.ExecuteTemplate(w, "base", data); err != nil { + return err + } + return nil +} + +func (r *templateRenderer) RenderFragment(w http.ResponseWriter, fragment string, data any) error { + tpl, err := r.fragmentTemplates.Clone() + if err != nil { + return fmt.Errorf("clone fragment templates: %w", err) + } + if err := tpl.ExecuteTemplate(w, fragment, data); err != nil { + return err + } + return nil +} + +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "humanTime": func(t time.Time) string { + if t.IsZero() { + return "-" + } + now := time.Now() + localTime := t.In(now.Location()) + now = now.In(now.Location()) + + if localTime.After(now) { + return localTime.Format("Jan 2, 2006 3:04 PM") + } + + diff := now.Sub(localTime) + switch { + case diff < time.Minute: + return "just now" + case diff < time.Hour: + minutes := int(diff.Minutes()) + if minutes == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", minutes) + case diff < 24*time.Hour && sameDay(now, localTime): + hours := int(diff.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + case diff < 48*time.Hour: + return "Yesterday at " + localTime.Format("3:04 PM") + case diff < 7*24*time.Hour: + return localTime.Format("Mon at 3:04 PM") + default: + return localTime.Format("Jan 2, 2006 3:04 PM") + } + }, + "appName": func() string { + return config.AppName + }, + "appVersion": func() string { + return config.AppVersion + }, + "yearNow": func() int { + return time.Now().Year() + }, + "boolLabel": func(v bool) string { + if v { + return "Enabled" + } + return "Disabled" + }, + "usageTone": func(v float64) string { + switch { + case v >= 85: + return "error" + case v >= 70: + return "warning" + default: + return "success" + } + }, + } +} + +func sameDay(a, b time.Time) bool { + ay, am, ad := a.Date() + by, bm, bd := b.Date() + return ay == by && am == bm && ad == bd +} diff --git a/internal/app/full_flow_test.go b/internal/app/full_flow_test.go new file mode 100644 index 0000000..dd716c1 --- /dev/null +++ b/internal/app/full_flow_test.go @@ -0,0 +1,223 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestFullHTTPFlow(t *testing.T) { + app := newTestApp(t) + + // Setup wizard page and validation. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/setup", nil) + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Create admin account") { + t.Fatalf("unexpected setup page response: %d", rec.Code) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/setup", strings.NewReader("admin_name=&admin_email=&password=&app_name=")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "All fields are required") { + t.Fatalf("expected setup validation error, got %d %q", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + form := url.Values{} + form.Set("admin_name", "Admin") + form.Set("admin_email", "admin@example.com") + form.Set("password", "secret123") + form.Set("app_name", "GoAppMon") + req = httptest.NewRequest(http.MethodPost, "/setup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/login" { + t.Fatalf("expected setup redirect, got %d %q", rec.Code, rec.Header().Get("Location")) + } + + // Login page and authentication. + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/admin/login", nil) + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Sign in") { + t.Fatalf("unexpected login page response: %d", rec.Code) + } + + rec = httptest.NewRecorder() + form = url.Values{} + form.Set("email", "admin@example.com") + form.Set("password", "wrong") + req = httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Invalid email or password") { + t.Fatalf("expected login error, got %d %q", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + form.Set("password", "secret123") + req = httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin" { + t.Fatalf("expected login redirect, got %d %q", rec.Code, rec.Header().Get("Location")) + } + + sessionCookie := rec.Result().Cookies()[0] + + // Authenticated dashboard. + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/admin", nil) + req.AddCookie(sessionCookie) + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Admin Dashboard") { + t.Fatalf("unexpected dashboard response: %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "See All logs") { + t.Fatalf("expected audit log link in dashboard, got %q", rec.Body.String()) + } + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/admin/system-health", nil) + req.AddCookie(sessionCookie) + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Server and runtime overview") { + t.Fatalf("unexpected system health fragment response: %d %q", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/admin/postman-collection", nil) + req.AddCookie(sessionCookie) + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected postman download response, got %d", rec.Code) + } + if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, "GoAppMon.postman_collection.json") { + t.Fatalf("expected postman attachment header, got %q", got) + } + if !strings.Contains(rec.Body.String(), "\"Health\"") || !strings.Contains(rec.Body.String(), "\"Admin Login\"") { + t.Fatalf("expected postman collection payload, got %q", rec.Body.String()) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/admin/audit-logs", nil) + req.AddCookie(sessionCookie) + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Audit Log") { + t.Fatalf("unexpected audit logs page response: %d %q", rec.Code, rec.Body.String()) + } + + // Login page should redirect when already authenticated. + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/admin/login", nil) + req.AddCookie(sessionCookie) + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin" { + t.Fatalf("expected login redirect for authenticated user, got %d %q", rec.Code, rec.Header().Get("Location")) + } + + // Admin updates and feature flag CRUD. + post := func(path string, values url.Values) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(sessionCookie) + app.router.ServeHTTP(rec, req) + return rec + } + + if rec := post("/admin/settings/application", url.Values{"app_name": {"GoAppMon Plus"}, "api_url": {"https://api.example.com"}}); rec.Code != http.StatusFound { + t.Fatalf("expected application update redirect, got %d", rec.Code) + } + if rec := post("/admin/settings/version", url.Values{ + "android_latest_version": {"2.0.0"}, + "android_min_version": {"1.5.0"}, + "android_force_update": {"true"}, + "ios_latest_version": {"2.1.0"}, + "ios_min_version": {"1.6.0"}, + "ios_force_update": {"false"}, + }); rec.Code != http.StatusFound { + t.Fatalf("expected version update redirect, got %d", rec.Code) + } + if rec := post("/admin/settings/maintenance", url.Values{"maintenance_mode": {"true"}, "maintenance_message": {"maintenance"}}); rec.Code != http.StatusFound { + t.Fatalf("expected maintenance redirect, got %d", rec.Code) + } + if rec := post("/admin/settings/banner", url.Values{"banner_enabled": {"true"}, "banner_message": {"banner"}}); rec.Code != http.StatusFound { + t.Fatalf("expected banner redirect, got %d", rec.Code) + } + if rec := post("/admin/feature-flags", url.Values{"key": {"chat"}, "enabled": {"true"}}); rec.Code != http.StatusFound { + t.Fatalf("expected create flag redirect, got %d", rec.Code) + } + if rec := post("/admin/feature-flags", url.Values{"key": {"payment"}, "enabled": {"false"}}); rec.Code != http.StatusFound { + t.Fatalf("expected create second flag redirect, got %d", rec.Code) + } + if rec := post("/admin/version/android", url.Values{ + "latest_version": {"3.0.0"}, + "minimum_version": {"2.5.0"}, + "force_update": {"false"}, + "release_notes": {"android v3"}, + }); rec.Code != http.StatusFound { + t.Fatalf("expected android publish redirect, got %d", rec.Code) + } + if rec := post("/admin/version/ios", url.Values{ + "latest_version": {"3.1.0"}, + "minimum_version": {"2.6.0"}, + "force_update": {"true"}, + "release_notes": {"ios v3"}, + }); rec.Code != http.StatusFound { + t.Fatalf("expected ios publish redirect, got %d", rec.Code) + } + if rec := post("/admin/version/android/delete", url.Values{}); rec.Code != http.StatusFound { + t.Fatalf("expected android delete redirect, got %d", rec.Code) + } + if rec := post("/admin/version/ios/delete", url.Values{}); rec.Code != http.StatusFound { + t.Fatalf("expected ios delete redirect, got %d", rec.Code) + } + + // Verify dashboard shows success notice and updated content. + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/admin?success=application_updated", nil) + req.AddCookie(sessionCookie) + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Application settings updated.") { + t.Fatalf("expected dashboard notice, got %d %q", rec.Code, rec.Body.String()) + } + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/admin?success=version_deleted", nil) + req.AddCookie(sessionCookie) + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Version release deleted.") { + t.Fatalf("expected version deleted notice, got %d %q", rec.Code, rec.Body.String()) + } + + // Feature flag update/delete. + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/admin/feature-flags/1", strings.NewReader(url.Values{"key": {"chat-v2"}, "enabled": {"false"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(sessionCookie) + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusFound { + t.Fatalf("expected update flag redirect, got %d", rec.Code) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/admin/feature-flags/1/delete", nil) + req.AddCookie(sessionCookie) + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusFound { + t.Fatalf("expected delete flag redirect, got %d", rec.Code) + } + + // Logout clears session. + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/admin/logout", nil) + req.AddCookie(sessionCookie) + app.router.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/login" { + t.Fatalf("expected logout redirect, got %d %q", rec.Code, rec.Header().Get("Location")) + } +} diff --git a/internal/app/public_api_test.go b/internal/app/public_api_test.go new file mode 100644 index 0000000..58bdcf1 --- /dev/null +++ b/internal/app/public_api_test.go @@ -0,0 +1,145 @@ +package app + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/phyowaiyan-dev/goappmon/internal/config" + "github.com/phyowaiyan-dev/goappmon/internal/services" +) + +func newTestApp(t *testing.T) *App { + t.Helper() + + dir := t.TempDir() + cfg := config.Config{ + Address: ":0", + DatabasePath: filepath.Join(dir, "goappmon.sqlite"), + SessionKeyPath: filepath.Join(dir, "session.key"), + LogLevel: slog.LevelError, + Environment: "test", + CookieName: "goappmon_session", + SessionDuration: 60 * 60, + } + logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})) + app, err := New(cfg, logger) + if err != nil { + t.Fatalf("new app: %v", err) + } + t.Cleanup(func() { + _ = app.db.Close() + }) + return app +} + +func TestPublicRoutesRedirectBeforeSetup(t *testing.T) { + app := newTestApp(t) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rr := httptest.NewRecorder() + app.router.ServeHTTP(rr, req) + + if rr.Code != http.StatusFound { + t.Fatalf("expected redirect, got %d", rr.Code) + } + if location := rr.Header().Get("Location"); location != "/setup" { + t.Fatalf("expected redirect to /setup, got %q", location) + } +} + +func TestPublicAPIsAfterSetup(t *testing.T) { + app := newTestApp(t) + ctx := context.Background() + + if err := app.setupService.CreateInitialSetup(ctx, "Admin", "admin@example.com", "secret123", "GoAppMon"); err != nil { + t.Fatalf("create setup: %v", err) + } + if err := app.adminService.UpdateApplication(ctx, services.ActionMeta{}, "GoAppMon Plus", "https://api.example.com"); err != nil { + t.Fatalf("update application: %v", err) + } + if err := app.adminService.PublishVersion(ctx, services.ActionMeta{}, "android", "2.0.0", "1.5.0", true, "android release"); err != nil { + t.Fatalf("publish android version: %v", err) + } + if err := app.adminService.PublishVersion(ctx, services.ActionMeta{}, "ios", "2.1.0", "1.6.0", false, "ios release"); err != nil { + t.Fatalf("publish ios version: %v", err) + } + if err := app.adminService.UpdateMaintenance(ctx, services.ActionMeta{}, true, "maintenance"); err != nil { + t.Fatalf("update maintenance: %v", err) + } + if err := app.adminService.UpdateBanner(ctx, services.ActionMeta{}, true, "banner"); err != nil { + t.Fatalf("update banner: %v", err) + } + if err := app.adminService.CreateFlag(ctx, services.ActionMeta{}, "chat", true); err != nil { + t.Fatalf("create flag: %v", err) + } + if err := app.adminService.CreateFlag(ctx, services.ActionMeta{}, "payment", false); err != nil { + t.Fatalf("create flag: %v", err) + } + + tests := []struct { + name string + path string + want map[string]any + }{ + {name: "health", path: "/health", want: map[string]any{"status": "ok"}}, + {name: "status", path: "/api/status", want: map[string]any{"maintenance_mode": true, "maintenance_message": "maintenance", "banner_enabled": true, "banner_message": "banner"}}, + {name: "version", path: "/api/version", want: map[string]any{"android": map[string]any{"latest_version": "2.0.0", "minimum_version": "1.5.0", "force_update": true}, "ios": map[string]any{"latest_version": "2.1.0", "minimum_version": "1.6.0", "force_update": false}}}, + {name: "config", path: "/api/config", want: map[string]any{"app_name": "GoAppMon Plus", "api_url": "https://api.example.com"}}, + {name: "flags", path: "/api/feature-flags", want: map[string]any{"chat": true, "payment": false}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rr := httptest.NewRecorder() + app.router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + var got map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + assertJSONSubset(t, got, tc.want) + }) + } +} + +func assertJSONSubset(t *testing.T, got, want map[string]any) { + t.Helper() + for key, wantValue := range want { + gotValue, ok := got[key] + if !ok { + t.Fatalf("missing key %q in %#v", key, got) + } + switch wantTyped := wantValue.(type) { + case map[string]any: + gotTyped, ok := gotValue.(map[string]any) + if !ok { + t.Fatalf("key %q expected object, got %#v", key, gotValue) + } + assertJSONSubset(t, gotTyped, wantTyped) + case bool: + gotBool, ok := gotValue.(bool) + if !ok || gotBool != wantTyped { + t.Fatalf("key %q expected %v, got %#v", key, wantTyped, gotValue) + } + case string: + gotString, ok := gotValue.(string) + if !ok || gotString != wantTyped { + t.Fatalf("key %q expected %q, got %#v", key, wantTyped, gotValue) + } + default: + t.Fatalf("unsupported type %T", wantValue) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c8936b2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,49 @@ +package config + +import ( + "log/slog" + "os" + "strings" +) + +type Config struct { + Address string + DatabasePath string + SessionKeyPath string + LogLevel slog.Level + Environment string + CookieName string + SessionDuration int64 +} + +func Load() Config { + return Config{ + Address: getEnv("GOAPPMON_ADDR", ":18180"), + DatabasePath: getEnv("GOAPPMON_DB_PATH", "storage/goappmon.sqlite"), + SessionKeyPath: getEnv("GOAPPMON_SESSION_KEY_PATH", "storage/session.key"), + LogLevel: parseLogLevel(getEnv("GOAPPMON_LOG_LEVEL", "info")), + Environment: getEnv("GOAPPMON_ENV", "development"), + CookieName: getEnv("GOAPPMON_COOKIE_NAME", "goappmon_session"), + SessionDuration: 60 * 60 * 24 * 7, + } +} + +func getEnv(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func parseLogLevel(value string) slog.Level { + switch strings.ToLower(strings.TrimSpace(value)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..ad20baf --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,49 @@ +package config + +import "testing" + +func TestLoadUsesEnvironmentOverrides(t *testing.T) { + t.Setenv("GOAPPMON_ADDR", "127.0.0.1:9090") + t.Setenv("GOAPPMON_DB_PATH", "/tmp/goappmon.db") + t.Setenv("GOAPPMON_SESSION_KEY_PATH", "/tmp/session.key") + t.Setenv("GOAPPMON_LOG_LEVEL", "warning") + t.Setenv("GOAPPMON_ENV", "production") + t.Setenv("GOAPPMON_COOKIE_NAME", "custom_session") + + cfg := Load() + if cfg.Address != "127.0.0.1:9090" { + t.Fatalf("unexpected address: %s", cfg.Address) + } + if cfg.DatabasePath != "/tmp/goappmon.db" || cfg.SessionKeyPath != "/tmp/session.key" { + t.Fatalf("unexpected paths: %+v", cfg) + } + if cfg.Environment != "production" || cfg.CookieName != "custom_session" { + t.Fatalf("unexpected config: %+v", cfg) + } + if cfg.LogLevel.String() != "WARN" { + t.Fatalf("unexpected log level: %s", cfg.LogLevel.String()) + } +} + +func TestLoadUsesDefaults(t *testing.T) { + t.Setenv("GOAPPMON_ADDR", "") + t.Setenv("GOAPPMON_DB_PATH", "") + t.Setenv("GOAPPMON_SESSION_KEY_PATH", "") + t.Setenv("GOAPPMON_LOG_LEVEL", "") + t.Setenv("GOAPPMON_ENV", "") + t.Setenv("GOAPPMON_COOKIE_NAME", "") + + cfg := Load() + if cfg.Address != ":18180" { + t.Fatalf("unexpected default address: %s", cfg.Address) + } + if cfg.DatabasePath != "storage/goappmon.sqlite" || cfg.SessionKeyPath != "storage/session.key" { + t.Fatalf("unexpected default paths: %+v", cfg) + } + if cfg.Environment != "development" || cfg.CookieName != "goappmon_session" { + t.Fatalf("unexpected defaults: %+v", cfg) + } + if cfg.LogLevel.String() != "INFO" { + t.Fatalf("unexpected default log level: %s", cfg.LogLevel.String()) + } +} diff --git a/internal/config/constants.go b/internal/config/constants.go new file mode 100644 index 0000000..d7f33dd --- /dev/null +++ b/internal/config/constants.go @@ -0,0 +1,6 @@ +package config + +const ( + AppName = "GoAppMon" + AppVersion = "v0.0.1beta" +) diff --git a/internal/database/migrations.go b/internal/database/migrations.go new file mode 100644 index 0000000..9b10808 --- /dev/null +++ b/internal/database/migrations.go @@ -0,0 +1,87 @@ +package database + +import ( + "context" + "database/sql" + "embed" + "fmt" + "strings" +) + +//go:embed migrations.sql +var migrationsFS embed.FS + +func Migrate(ctx context.Context, db *sql.DB) error { + content, err := migrationsFS.ReadFile("migrations.sql") + if err != nil { + return err + } + + for _, statement := range splitStatements(string(content)) { + if _, err := db.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("run migration: %w", err) + } + } + if err := ensureSettingsColumns(ctx, db); err != nil { + return err + } + return nil +} + +func ensureSettingsColumns(ctx context.Context, db *sql.DB) error { + columns, err := tableColumns(ctx, db, "settings") + if err != nil { + return err + } + required := map[string]string{ + "android_enabled": "INTEGER NOT NULL DEFAULT 1", + "ios_enabled": "INTEGER NOT NULL DEFAULT 1", + } + for name, ddl := range required { + if _, ok := columns[name]; ok { + continue + } + if _, err := db.ExecContext(ctx, fmt.Sprintf("ALTER TABLE settings ADD COLUMN %s %s", name, ddl)); err != nil { + return fmt.Errorf("add settings column %s: %w", name, err) + } + } + return nil +} + +func tableColumns(ctx context.Context, db *sql.DB, table string) (map[string]struct{}, error) { + rows, err := db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return nil, err + } + defer rows.Close() + + columns := make(map[string]struct{}) + for rows.Next() { + var cid int + var name, colType string + var notNull int + var defaultValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &pk); err != nil { + return nil, err + } + columns[name] = struct{}{} + } + if err := rows.Err(); err != nil { + return nil, err + } + return columns, nil +} + +func splitStatements(content string) []string { + parts := strings.Split(content, ";") + statements := make([]string, 0, len(parts)) + for _, part := range parts { + statement := strings.TrimSpace(part) + if statement == "" { + continue + } + statements = append(statements, statement) + } + return statements +} diff --git a/internal/database/migrations.sql b/internal/database/migrations.sql new file mode 100644 index 0000000..46cf36a --- /dev/null +++ b/internal/database/migrations.sql @@ -0,0 +1,74 @@ +CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + applied_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app_name TEXT NOT NULL, + android_enabled INTEGER NOT NULL DEFAULT 1, + android_latest_version TEXT NOT NULL DEFAULT '1.0.0', + android_min_version TEXT NOT NULL DEFAULT '1.0.0', + android_force_update INTEGER NOT NULL DEFAULT 0, + ios_enabled INTEGER NOT NULL DEFAULT 1, + ios_latest_version TEXT NOT NULL DEFAULT '1.0.0', + ios_min_version TEXT NOT NULL DEFAULT '1.0.0', + ios_force_update INTEGER NOT NULL DEFAULT 0, + maintenance_mode INTEGER NOT NULL DEFAULT 0, + maintenance_message TEXT NOT NULL DEFAULT '', + banner_enabled INTEGER NOT NULL DEFAULT 0, + banner_message TEXT NOT NULL DEFAULT '', + api_url TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS feature_flags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS version_releases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + latest_version TEXT NOT NULL, + minimum_version TEXT NOT NULL, + force_update INTEGER NOT NULL DEFAULT 0, + release_notes TEXT NOT NULL DEFAULT '', + created_by_admin_id INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS state_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + created_by_admin_id INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_admin_id INTEGER NOT NULL DEFAULT 0, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL DEFAULT '', + before_json TEXT NOT NULL DEFAULT '', + after_json TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +); diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go new file mode 100644 index 0000000..ea358a2 --- /dev/null +++ b/internal/database/sqlite.go @@ -0,0 +1,29 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +func Open(path string) (*sql.DB, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + + db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)", path)) + if err != nil { + return nil, err + } + + if err := db.PingContext(context.Background()); err != nil { + _ = db.Close() + return nil, err + } + + return db, nil +} diff --git a/internal/handlers/admin_handler.go b/internal/handlers/admin_handler.go new file mode 100644 index 0000000..193afe6 --- /dev/null +++ b/internal/handlers/admin_handler.go @@ -0,0 +1,406 @@ +package handlers + +import ( + "errors" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/phyowaiyan-dev/goappmon/internal/middleware" + "github.com/phyowaiyan-dev/goappmon/internal/services" + "github.com/phyowaiyan-dev/goappmon/web" +) + +type AdminHandler struct { + renderer Renderer + adminService *services.AdminService +} + +func NewAdminHandler(renderer Renderer, adminService *services.AdminService) *AdminHandler { + return &AdminHandler{renderer: renderer, adminService: adminService} +} + +func (h *AdminHandler) Dashboard(c *gin.Context) { + dashboard, err := h.adminService.Dashboard(c.Request.Context()) + if err != nil { + c.String(http.StatusInternalServerError, "failed to load dashboard") + return + } + admin, _ := middleware.CurrentAdmin(c) + _ = h.renderer.RenderPage(c.Writer, "dashboard.html", PageData{ + Title: "Admin Dashboard", + Authenticated: true, + CurrentAdmin: admin, + Settings: dashboard.Settings, + Flags: dashboard.Flags, + AndroidReleases: dashboard.AndroidReleases, + IOSReleases: dashboard.IOSReleases, + MaintenanceHistory: dashboard.MaintenanceHistory, + BannerHistory: dashboard.BannerHistory, + AuditLogs: dashboard.AuditLogs, + SystemHealth: dashboard.SystemHealth, + Notice: friendlyNotice(c.Query("success")), + Error: friendlyError(c.Query("error")), + }) +} + +func (h *AdminHandler) SystemHealthPanel(c *gin.Context) { + pageData := PageData{ + SystemHealth: h.adminService.SystemHealth(), + } + if isHXRequest(c) { + _ = h.renderer.RenderFragment(c.Writer, "system-health-panel", pageData) + return + } + _ = h.renderer.RenderFragment(c.Writer, "system-health-panel", pageData) +} + +func (h *AdminHandler) AuditLogsPage(c *gin.Context) { + params, err := parseAuditLogParams(c) + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + result, err := h.adminService.SearchAuditLogs(c.Request.Context(), params) + if err != nil { + c.String(http.StatusInternalServerError, "failed to load audit logs") + return + } + admin, _ := middleware.CurrentAdmin(c) + prevURL, nextURL := buildAuditLogPageURLs(params, result.Page, result.TotalPages) + pageData := AuditLogPageData{ + Title: "Audit Log", + Authenticated: true, + CurrentAdmin: admin, + Logs: result.Logs, + Query: params.Query, + Page: result.Page, + PageSize: result.PageSize, + Total: result.Total, + TotalPages: result.TotalPages, + HasPrev: result.Page > 1, + HasNext: result.Page < result.TotalPages, + PrevPage: result.Page - 1, + NextPage: result.Page + 1, + PrevURL: prevURL, + NextURL: nextURL, + Notice: friendlyNotice(c.Query("success")), + Error: friendlyError(c.Query("error")), + } + if isHXRequest(c) { + _ = h.renderer.RenderFragment(c.Writer, "audit-logs-table", pageData) + return + } + _ = h.renderer.RenderPage(c.Writer, "audit_logs.html", pageData) +} + +func (h *AdminHandler) DownloadPostmanCollection(c *gin.Context) { + data, err := web.PostmanFS.ReadFile("postman/GoAppMon.postman_collection.json") + if err != nil { + c.String(http.StatusInternalServerError, "failed to load postman collection") + return + } + c.Header("Content-Disposition", `attachment; filename="GoAppMon.postman_collection.json"`) + c.Data(http.StatusOK, "application/json; charset=utf-8", data) +} + +func (h *AdminHandler) UpdateApplication(c *gin.Context) { + appName := strings.TrimSpace(c.PostForm("app_name")) + apiURL := strings.TrimSpace(c.PostForm("api_url")) + if appName == "" { + c.Redirect(http.StatusFound, "/admin?error=app_name_required") + return + } + if err := h.adminService.UpdateApplication(c.Request.Context(), adminMeta(c), appName, apiURL); err != nil { + c.String(http.StatusInternalServerError, "failed to update application settings") + return + } + c.Redirect(http.StatusFound, "/admin?success=application_updated") +} + +func (h *AdminHandler) UpdatePlatforms(c *gin.Context) { + if err := h.adminService.UpdatePlatforms(c.Request.Context(), adminMeta(c), parseBoolForm(c.PostForm("android_enabled")), parseBoolForm(c.PostForm("ios_enabled"))); err != nil { + c.String(http.StatusInternalServerError, "failed to update platform settings") + return + } + c.Redirect(http.StatusFound, "/admin?success=platforms_updated") +} + +func (h *AdminHandler) UpdateVersion(c *gin.Context) { + androidLatest := strings.TrimSpace(c.PostForm("android_latest_version")) + androidMin := strings.TrimSpace(c.PostForm("android_min_version")) + androidForce := parseBoolForm(c.PostForm("android_force_update")) + androidNotes := strings.TrimSpace(c.PostForm("android_release_notes")) + + iosLatest := strings.TrimSpace(c.PostForm("ios_latest_version")) + iosMin := strings.TrimSpace(c.PostForm("ios_min_version")) + iosForce := parseBoolForm(c.PostForm("ios_force_update")) + iosNotes := strings.TrimSpace(c.PostForm("ios_release_notes")) + + if androidLatest != "" || androidMin != "" { + if err := h.adminService.PublishVersion(c.Request.Context(), adminMeta(c), "android", androidLatest, androidMin, androidForce, androidNotes); err != nil { + if code := versionErrorCode(err); code != "" { + c.Redirect(http.StatusFound, "/admin?error="+code) + return + } + c.String(http.StatusInternalServerError, "failed to update android version") + return + } + } + if iosLatest != "" || iosMin != "" { + if err := h.adminService.PublishVersion(c.Request.Context(), adminMeta(c), "ios", iosLatest, iosMin, iosForce, iosNotes); err != nil { + if code := versionErrorCode(err); code != "" { + c.Redirect(http.StatusFound, "/admin?error="+code) + return + } + c.String(http.StatusInternalServerError, "failed to update ios version") + return + } + } + c.Redirect(http.StatusFound, "/admin?success=version_updated") +} + +func (h *AdminHandler) PublishVersion(c *gin.Context) { + platform := strings.ToLower(strings.TrimSpace(c.Param("platform"))) + latest := strings.TrimSpace(c.PostForm("latest_version")) + minimum := strings.TrimSpace(c.PostForm("minimum_version")) + force := parseBoolForm(c.PostForm("force_update")) + notes := strings.TrimSpace(c.PostForm("release_notes")) + if platform != "android" && platform != "ios" { + c.String(http.StatusBadRequest, "invalid platform") + return + } + if latest == "" || minimum == "" { + c.Redirect(http.StatusFound, "/admin?error=version_required") + return + } + if err := h.adminService.PublishVersion(c.Request.Context(), adminMeta(c), platform, latest, minimum, force, notes); err != nil { + if errors.Is(err, services.ErrPlatformDisabled) { + c.String(http.StatusBadRequest, "platform is disabled") + return + } + if code := versionErrorCode(err); code != "" { + c.Redirect(http.StatusFound, "/admin?error="+code) + return + } + c.String(http.StatusInternalServerError, "failed to publish version") + return + } + c.Redirect(http.StatusFound, "/admin?success=version_published") +} + +func (h *AdminHandler) DeleteCurrentVersion(c *gin.Context) { + platform := strings.ToLower(strings.TrimSpace(c.Param("platform"))) + if platform != "android" && platform != "ios" { + c.String(http.StatusBadRequest, "invalid platform") + return + } + if err := h.adminService.DeleteCurrentVersion(c.Request.Context(), adminMeta(c), platform); err != nil { + if code := versionErrorCode(err); code != "" { + c.Redirect(http.StatusFound, "/admin?error="+code) + return + } + c.String(http.StatusInternalServerError, "failed to delete version") + return + } + c.Redirect(http.StatusFound, "/admin?success=version_deleted") +} + +func (h *AdminHandler) UpdateMaintenance(c *gin.Context) { + enabled := parseBoolForm(c.PostForm("maintenance_mode")) + message := strings.TrimSpace(c.PostForm("maintenance_message")) + if err := h.adminService.UpdateMaintenance(c.Request.Context(), adminMeta(c), enabled, message); err != nil { + c.String(http.StatusInternalServerError, "failed to update maintenance") + return + } + c.Redirect(http.StatusFound, "/admin?success=maintenance_updated") +} + +func (h *AdminHandler) UpdateBanner(c *gin.Context) { + enabled := parseBoolForm(c.PostForm("banner_enabled")) + message := strings.TrimSpace(c.PostForm("banner_message")) + if err := h.adminService.UpdateBanner(c.Request.Context(), adminMeta(c), enabled, message); err != nil { + c.String(http.StatusInternalServerError, "failed to update banner") + return + } + c.Redirect(http.StatusFound, "/admin?success=banner_updated") +} + +func (h *AdminHandler) CreateFlag(c *gin.Context) { + key := strings.TrimSpace(c.PostForm("key")) + if key == "" { + c.Redirect(http.StatusFound, "/admin?error=flag_key_required") + return + } + if err := h.adminService.CreateFlag(c.Request.Context(), adminMeta(c), key, false); err != nil { + c.String(http.StatusInternalServerError, "failed to create feature flag") + return + } + c.Redirect(http.StatusFound, "/admin?success=flag_created") +} + +func (h *AdminHandler) UpdateFlag(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "invalid flag id") + return + } + key := strings.TrimSpace(c.PostForm("key")) + enabled := parseBoolForm(c.PostForm("enabled")) + if key == "" { + c.Redirect(http.StatusFound, "/admin?error=flag_key_required") + return + } + if err := h.adminService.UpdateFlag(c.Request.Context(), adminMeta(c), id, key, enabled); err != nil { + c.String(http.StatusInternalServerError, "failed to update feature flag") + return + } + c.Redirect(http.StatusFound, "/admin?success=flag_updated") +} + +func (h *AdminHandler) DeleteFlag(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "invalid flag id") + return + } + if err := h.adminService.DeleteFlag(c.Request.Context(), adminMeta(c), id); err != nil { + c.String(http.StatusInternalServerError, "failed to delete feature flag") + return + } + c.Redirect(http.StatusFound, "/admin?success=flag_deleted") +} + +func adminMeta(c *gin.Context) services.ActionMeta { + admin, _ := middleware.CurrentAdmin(c) + meta := services.ActionMeta{ + IP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + } + if admin != nil { + meta.ActorID = admin.ID + } + return meta +} + +func parseBoolForm(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "on", "yes", "enabled": + return true + default: + return false + } +} + +func friendlyNotice(code string) string { + switch code { + case "application_updated": + return "Application settings updated." + case "platforms_updated": + return "Platform settings updated." + case "version_updated": + return "Version history saved." + case "version_published": + return "Version release created." + case "version_deleted": + return "Version release deleted." + case "maintenance_updated": + return "Maintenance history saved." + case "banner_updated": + return "Banner history saved." + case "flag_created": + return "Feature flag created." + case "flag_updated": + return "Feature flag updated." + case "flag_deleted": + return "Feature flag deleted." + default: + return "" + } +} + +func friendlyError(code string) string { + switch code { + case "app_name_required": + return "App name is required." + case "flag_key_required": + return "Feature flag key is required." + case "version_required": + return "Version details are required." + case "version_format_invalid": + return "Version must follow major.minor.patch format like 1.0.1." + case "version_not_increasing": + return "Latest version must be greater than the current version." + case "minimum_version_invalid": + return "Minimum version must be less than or equal to latest version." + case "version_delete_last": + return "At least one version must remain." + default: + return "" + } +} + +func versionErrorCode(err error) string { + switch { + case errors.Is(err, services.ErrInvalidVersionFormat): + return "version_format_invalid" + case errors.Is(err, services.ErrLatestVersionNotGreater): + return "version_not_increasing" + case errors.Is(err, services.ErrMinimumVersionGreaterThanLatest): + return "minimum_version_invalid" + case errors.Is(err, services.ErrCannotDeleteLastVersion): + return "version_delete_last" + default: + return "" + } +} + +func parseAuditLogParams(c *gin.Context) (services.AuditLogSearchParams, error) { + params := services.AuditLogSearchParams{ + Query: strings.TrimSpace(c.Query("q")), + PageSize: 20, + } + if page, err := strconv.Atoi(strings.TrimSpace(c.Query("page"))); err == nil && page > 0 { + params.Page = page + } else { + params.Page = 1 + } + if pageSize, err := strconv.Atoi(strings.TrimSpace(c.Query("page_size"))); err == nil && pageSize > 0 && pageSize <= 100 { + params.PageSize = pageSize + } + return params, nil +} + +func buildAuditLogPageURLs(params services.AuditLogSearchParams, page, totalPages int) (string, string) { + base := "/admin/audit-logs" + build := func(targetPage int) string { + values := url.Values{} + if params.Query != "" { + values.Set("q", params.Query) + } + if params.PageSize > 0 { + values.Set("page_size", strconv.Itoa(params.PageSize)) + } + values.Set("page", strconv.Itoa(targetPage)) + encoded := values.Encode() + if encoded == "" { + return base + } + return base + "?" + encoded + } + + var prevURL, nextURL string + if page > 1 { + prevURL = build(page - 1) + } + if page < totalPages { + nextURL = build(page + 1) + } + return prevURL, nextURL +} + +func isHXRequest(c *gin.Context) bool { + return strings.EqualFold(strings.TrimSpace(c.GetHeader("HX-Request")), "true") +} diff --git a/internal/handlers/auth_handler.go b/internal/handlers/auth_handler.go new file mode 100644 index 0000000..9ba6c0a --- /dev/null +++ b/internal/handlers/auth_handler.go @@ -0,0 +1,102 @@ +package handlers + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/phyowaiyan-dev/goappmon/internal/services" + "github.com/phyowaiyan-dev/goappmon/internal/utils" +) + +type AuthHandler struct { + renderer Renderer + authService *services.AuthService + cookieName string + sessionTTL time.Duration +} + +func NewAuthHandler(renderer Renderer, authService *services.AuthService, cookieName string, sessionTTL time.Duration) *AuthHandler { + return &AuthHandler{ + renderer: renderer, + authService: authService, + cookieName: cookieName, + sessionTTL: sessionTTL, + } +} + +func (h *AuthHandler) LoginPage(c *gin.Context) { + if h.isAuthenticated(c) { + c.Redirect(http.StatusFound, "/admin") + return + } + _ = h.renderer.RenderPage(c.Writer, "login.html", PageData{Title: "Admin Login", Authenticated: false}) +} + +func (h *AuthHandler) Login(c *gin.Context) { + if err := c.Request.ParseForm(); err != nil { + _ = h.renderer.RenderPage(c.Writer, "login.html", PageData{Title: "Admin Login", Error: "Invalid login form", Authenticated: false}) + return + } + + email := strings.TrimSpace(c.PostForm("email")) + password := c.PostForm("password") + if err := utils.ValidateEmail(email); err != nil { + _ = h.renderer.RenderPage(c.Writer, "login.html", PageData{Title: "Admin Login", Error: err.Error(), LoginEmail: email, Authenticated: false}) + return + } + admin, err := h.authService.Authenticate(c.Request.Context(), email, password) + if err != nil { + _ = h.renderer.RenderPage(c.Writer, "login.html", PageData{Title: "Admin Login", Error: "Invalid email or password", LoginEmail: email, Authenticated: false}) + return + } + + token, err := h.authService.SignSession(admin.ID) + if err != nil { + _ = h.renderer.RenderPage(c.Writer, "login.html", PageData{Title: "Admin Login", Error: "Failed to create session", Authenticated: false}) + return + } + + h.setSessionCookie(c, token) + c.Redirect(http.StatusFound, "/admin") +} + +func (h *AuthHandler) Logout(c *gin.Context) { + h.clearSessionCookie(c) + c.Redirect(http.StatusFound, "/admin/login") +} + +func (h *AuthHandler) isAuthenticated(c *gin.Context) bool { + cookie, err := c.Cookie(h.cookieName) + if err != nil || strings.TrimSpace(cookie) == "" { + return false + } + _, err = h.authService.VerifySession(cookie) + return err == nil +} + +func (h *AuthHandler) setSessionCookie(c *gin.Context, token string) { + secure := c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") + http.SetCookie(c.Writer, &http.Cookie{ + Name: h.cookieName, + Value: token, + Path: "/", + MaxAge: int(h.sessionTTL.Seconds()), + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func (h *AuthHandler) clearSessionCookie(c *gin.Context) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: h.cookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: false, + SameSite: http.SameSiteLaxMode, + }) +} diff --git a/internal/handlers/public_handler.go b/internal/handlers/public_handler.go new file mode 100644 index 0000000..756f970 --- /dev/null +++ b/internal/handlers/public_handler.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/phyowaiyan-dev/goappmon/internal/services" + "github.com/phyowaiyan-dev/goappmon/internal/utils" +) + +type PublicHandler struct { + statusService *services.StatusService +} + +func NewPublicHandler(statusService *services.StatusService) *PublicHandler { + return &PublicHandler{statusService: statusService} +} + +func (h *PublicHandler) Health(c *gin.Context) { + utils.JSON(c, http.StatusOK, gin.H{"status": "ok"}) +} + +func (h *PublicHandler) Status(c *gin.Context) { + status, err := h.statusService.PublicStatus(c.Request.Context()) + if err != nil { + utils.JSONError(c, http.StatusInternalServerError, "failed to load status") + return + } + utils.JSON(c, http.StatusOK, status) +} + +func (h *PublicHandler) Version(c *gin.Context) { + version, err := h.statusService.PublicVersion(c.Request.Context()) + if err != nil { + utils.JSONError(c, http.StatusInternalServerError, "failed to load version") + return + } + utils.JSON(c, http.StatusOK, version) +} + +func (h *PublicHandler) Config(c *gin.Context) { + cfg, err := h.statusService.PublicConfig(c.Request.Context()) + if err != nil { + utils.JSONError(c, http.StatusInternalServerError, "failed to load config") + return + } + utils.JSON(c, http.StatusOK, cfg) +} + +func (h *PublicHandler) FeatureFlags(c *gin.Context) { + flags, err := h.statusService.PublicFeatureFlags(c.Request.Context()) + if err != nil { + utils.JSONError(c, http.StatusInternalServerError, "failed to load feature flags") + return + } + utils.JSON(c, http.StatusOK, flags) +} diff --git a/internal/handlers/render.go b/internal/handlers/render.go new file mode 100644 index 0000000..c9f4ea4 --- /dev/null +++ b/internal/handlers/render.go @@ -0,0 +1,59 @@ +package handlers + +import ( + "net/http" + + "github.com/phyowaiyan-dev/goappmon/internal/models" +) + +type Renderer interface { + RenderPage(w http.ResponseWriter, page string, data any) error + RenderFragment(w http.ResponseWriter, fragment string, data any) error +} + +type PageData struct { + Title string + Error string + Notice string + Authenticated bool + CurrentAdmin *models.Admin + Settings *models.Setting + Flags []models.FeatureFlag + AndroidReleases []models.VersionRelease + IOSReleases []models.VersionRelease + MaintenanceHistory []models.StateChange + BannerHistory []models.StateChange + AuditLogs []models.AuditLog + SystemHealth any + AdminName string + AdminEmail string + AppName string + LoginEmail string + SetupComplete bool +} + +type AuditLogPageData struct { + Title string + Error string + Notice string + Authenticated bool + CurrentAdmin *models.Admin + Logs []models.AuditLog + Query string + Action string + EntityType string + EntityID string + ActorID string + From string + To string + Page int + PageSize int + Total int64 + TotalPages int + HasPrev bool + HasNext bool + PrevPage int + NextPage int + PrevURL string + NextURL string +} diff --git a/internal/handlers/setup_handler.go b/internal/handlers/setup_handler.go new file mode 100644 index 0000000..1b46c94 --- /dev/null +++ b/internal/handlers/setup_handler.go @@ -0,0 +1,89 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/phyowaiyan-dev/goappmon/internal/services" + "github.com/phyowaiyan-dev/goappmon/internal/utils" +) + +type SetupHandler struct { + renderer Renderer + setupService *services.SetupService +} + +func NewSetupHandler(renderer Renderer, setupService *services.SetupService) *SetupHandler { + return &SetupHandler{renderer: renderer, setupService: setupService} +} + +func (h *SetupHandler) Page(c *gin.Context) { + complete, err := h.setupService.IsSetupComplete(c.Request.Context()) + if err == nil && complete { + c.Redirect(http.StatusFound, "/admin/login") + return + } + _ = h.renderer.RenderPage(c.Writer, "setup.html", PageData{Title: "Initial Setup", Authenticated: false}) +} + +func (h *SetupHandler) Submit(c *gin.Context) { + if err := c.Request.ParseForm(); err != nil { + _ = h.renderer.RenderPage(c.Writer, "setup.html", PageData{Title: "Initial Setup", Error: "Invalid setup form", Authenticated: false}) + return + } + + adminName := strings.TrimSpace(c.PostForm("admin_name")) + adminEmail := strings.TrimSpace(c.PostForm("admin_email")) + password := c.PostForm("password") + appName := strings.TrimSpace(c.PostForm("app_name")) + + if adminName == "" || adminEmail == "" || password == "" || appName == "" { + _ = h.renderer.RenderPage(c.Writer, "setup.html", PageData{ + Title: "Initial Setup", + Error: "All fields are required", + AdminName: adminName, + AdminEmail: adminEmail, + AppName: appName, + Authenticated: false, + }) + return + } + + if err := utils.ValidateAdminName(adminName); err != nil { + _ = h.renderer.RenderPage(c.Writer, "setup.html", PageData{ + Title: "Initial Setup", + Error: err.Error(), + AdminName: adminName, + AdminEmail: adminEmail, + AppName: appName, + Authenticated: false, + }) + return + } + if err := utils.ValidateEmail(adminEmail); err != nil { + _ = h.renderer.RenderPage(c.Writer, "setup.html", PageData{ + Title: "Initial Setup", + Error: err.Error(), + AdminName: adminName, + AdminEmail: adminEmail, + AppName: appName, + Authenticated: false, + }) + return + } + + if err := h.setupService.CreateInitialSetup(c.Request.Context(), adminName, adminEmail, password, appName); err != nil { + _ = h.renderer.RenderPage(c.Writer, "setup.html", PageData{ + Title: "Initial Setup", + Error: "Setup could not be completed", + AdminName: adminName, + AdminEmail: adminEmail, + AppName: appName, + Authenticated: false, + }) + return + } + + c.Redirect(http.StatusFound, "/admin/login") +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..9e9bd01 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,84 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/phyowaiyan-dev/goappmon/internal/models" + "github.com/phyowaiyan-dev/goappmon/internal/repositories" + "github.com/phyowaiyan-dev/goappmon/internal/services" +) + +const CurrentAdminKey = "current_admin" + +func SetupRedirect(setupService *services.SetupService) gin.HandlerFunc { + return func(c *gin.Context) { + if bypassSetupRedirect(c.Request.URL.Path) { + c.Next() + return + } + + complete, err := setupService.IsSetupComplete(c.Request.Context()) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check setup status"}) + return + } + if !complete { + c.Redirect(http.StatusFound, "/setup") + c.Abort() + return + } + c.Next() + } +} + +func RequireAuth(authService *services.AuthService, adminRepo *repositories.AdminRepository, cookieName string) gin.HandlerFunc { + return func(c *gin.Context) { + cookie, err := c.Cookie(cookieName) + if err != nil || strings.TrimSpace(cookie) == "" { + c.Redirect(http.StatusFound, "/admin/login") + c.Abort() + return + } + + adminID, err := authService.VerifySession(cookie) + if err != nil { + c.Redirect(http.StatusFound, "/admin/login") + c.Abort() + return + } + + admin, err := adminRepo.GetByID(c.Request.Context(), adminID) + if err != nil { + c.Redirect(http.StatusFound, "/admin/login") + c.Abort() + return + } + + c.Set(CurrentAdminKey, admin) + c.Next() + } +} + +func CurrentAdmin(c *gin.Context) (*models.Admin, bool) { + value, ok := c.Get(CurrentAdminKey) + if !ok { + return nil, false + } + admin, ok := value.(*models.Admin) + return admin, ok +} + +func bypassSetupRedirect(path string) bool { + switch { + case path == "/setup": + return true + case path == "/favicon.ico": + return true + case strings.HasPrefix(path, "/assets/"): + return true + default: + return false + } +} diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go new file mode 100644 index 0000000..0cc6819 --- /dev/null +++ b/internal/middleware/auth_test.go @@ -0,0 +1,123 @@ +package middleware + +import ( + "context" + "database/sql" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/phyowaiyan-dev/goappmon/internal/database" + "github.com/phyowaiyan-dev/goappmon/internal/models" + "github.com/phyowaiyan-dev/goappmon/internal/repositories" + "github.com/phyowaiyan-dev/goappmon/internal/services" + "github.com/phyowaiyan-dev/goappmon/internal/utils" +) + +func TestSetupRedirect(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newMiddlewareDB(t) + setupService := services.NewSetupService(db) + + r := gin.New() + r.Use(SetupRedirect(setupService)) + r.GET("/health", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/setup" { + t.Fatalf("expected redirect to /setup, got %d %q", rec.Code, rec.Header().Get("Location")) + } + + if err := setupService.CreateInitialSetup(context.Background(), "Admin", "admin@example.com", "secret123", "GoAppMon"); err != nil { + t.Fatalf("create setup: %v", err) + } + + req = httptest.NewRequest(http.MethodGet, "/health", nil) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || rec.Body.String() != "ok" { + t.Fatalf("expected ok response, got %d %q", rec.Code, rec.Body.String()) + } +} + +func TestRequireAuthAndCurrentAdmin(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newMiddlewareDB(t) + adminRepo := repositories.NewAdminRepository(db) + hashed, err := utils.HashPassword("secret123") + if err != nil { + t.Fatalf("hash password: %v", err) + } + adminID, err := adminRepo.Create(context.Background(), models.Admin{ + Name: "Admin", + Email: "admin@example.com", + PasswordHash: hashed, + CreatedAt: time.Now().UTC(), + }) + if err != nil { + t.Fatalf("create admin: %v", err) + } + + authService := services.NewAuthService(adminRepo, []byte("01234567890123456789012345678901"), time.Hour) + token, err := authService.SignSession(adminID) + if err != nil { + t.Fatalf("sign session: %v", err) + } + + r := gin.New() + r.Use(RequireAuth(authService, adminRepo, "goappmon_session")) + r.GET("/admin", func(c *gin.Context) { + admin, ok := CurrentAdmin(c) + if !ok { + c.String(http.StatusInternalServerError, "missing admin") + return + } + c.String(http.StatusOK, admin.Email) + }) + + req := httptest.NewRequest(http.MethodGet, "/admin", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/login" { + t.Fatalf("expected redirect for missing cookie, got %d %q", rec.Code, rec.Header().Get("Location")) + } + + req = httptest.NewRequest(http.MethodGet, "/admin", nil) + req.AddCookie(&http.Cookie{Name: "goappmon_session", Value: token + "tamper"}) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/login" { + t.Fatalf("expected redirect for bad cookie, got %d %q", rec.Code, rec.Header().Get("Location")) + } + + req = httptest.NewRequest(http.MethodGet, "/admin", nil) + req.AddCookie(&http.Cookie{Name: "goappmon_session", Value: token}) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || rec.Body.String() != "admin@example.com" { + t.Fatalf("expected authenticated response, got %d %q", rec.Code, rec.Body.String()) + } +} + +func newMiddlewareDB(t *testing.T) *sql.DB { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.sqlite") + db, err := database.Open(dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := database.Migrate(context.Background(), db); err != nil { + t.Fatalf("migrate db: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + return db +} diff --git a/internal/models/admin.go b/internal/models/admin.go new file mode 100644 index 0000000..7c2a8f7 --- /dev/null +++ b/internal/models/admin.go @@ -0,0 +1,11 @@ +package models + +import "time" + +type Admin struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/models/audit_log.go b/internal/models/audit_log.go new file mode 100644 index 0000000..5f9b454 --- /dev/null +++ b/internal/models/audit_log.go @@ -0,0 +1,17 @@ +package models + +import "time" + +type AuditLog struct { + ID int64 `json:"id"` + ActorAdminID int64 `json:"actor_admin_id"` + ActorName string `json:"actor_name"` + Action string `json:"action"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + BeforeJSON string `json:"before_json"` + AfterJSON string `json:"after_json"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/models/feature_flag.go b/internal/models/feature_flag.go new file mode 100644 index 0000000..3a09615 --- /dev/null +++ b/internal/models/feature_flag.go @@ -0,0 +1,7 @@ +package models + +type FeatureFlag struct { + ID int64 `json:"id"` + Key string `json:"key"` + Enabled bool `json:"enabled"` +} diff --git a/internal/models/setting.go b/internal/models/setting.go new file mode 100644 index 0000000..fafb05e --- /dev/null +++ b/internal/models/setting.go @@ -0,0 +1,23 @@ +package models + +import "time" + +type Setting struct { + ID int64 `json:"id"` + AppName string `json:"app_name"` + AndroidEnabled bool `json:"android_enabled"` + AndroidLatestVersion string `json:"android_latest_version"` + AndroidMinVersion string `json:"android_min_version"` + AndroidForceUpdate bool `json:"android_force_update"` + IOSEnabled bool `json:"ios_enabled"` + IOSLatestVersion string `json:"ios_latest_version"` + IOSMinVersion string `json:"ios_min_version"` + IOSForceUpdate bool `json:"ios_force_update"` + MaintenanceMode bool `json:"maintenance_mode"` + MaintenanceMessage string `json:"maintenance_message"` + BannerEnabled bool `json:"banner_enabled"` + BannerMessage string `json:"banner_message"` + APIURL string `json:"api_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/models/state_change.go b/internal/models/state_change.go new file mode 100644 index 0000000..f93a829 --- /dev/null +++ b/internal/models/state_change.go @@ -0,0 +1,13 @@ +package models + +import "time" + +type StateChange struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + Enabled bool `json:"enabled"` + Message string `json:"message"` + CreatedByAdminID int64 `json:"created_by_admin_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/models/version_release.go b/internal/models/version_release.go new file mode 100644 index 0000000..9567a0c --- /dev/null +++ b/internal/models/version_release.go @@ -0,0 +1,15 @@ +package models + +import "time" + +type VersionRelease struct { + ID int64 `json:"id"` + Platform string `json:"platform"` + LatestVersion string `json:"latest_version"` + MinimumVersion string `json:"minimum_version"` + ForceUpdate bool `json:"force_update"` + ReleaseNotes string `json:"release_notes"` + CreatedByAdminID int64 `json:"created_by_admin_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/repositories/admin_repository.go b/internal/repositories/admin_repository.go new file mode 100644 index 0000000..7f0538e --- /dev/null +++ b/internal/repositories/admin_repository.go @@ -0,0 +1,70 @@ +package repositories + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/models" +) + +var ErrAdminNotFound = errors.New("admin not found") + +type AdminRepository struct { + db DBTX +} + +func NewAdminRepository(db DBTX) *AdminRepository { + return &AdminRepository{db: db} +} + +func (r *AdminRepository) Count(ctx context.Context) (int64, error) { + var count int64 + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM admins`).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (r *AdminRepository) Create(ctx context.Context, admin models.Admin) (int64, error) { + result, err := r.db.ExecContext(ctx, ` + INSERT INTO admins (name, email, password_hash, created_at) + VALUES (?, ?, ?, ?) + `, admin.Name, admin.Email, admin.PasswordHash, admin.CreatedAt.Unix()) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +func (r *AdminRepository) GetByEmail(ctx context.Context, email string) (*models.Admin, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, name, email, password_hash, created_at + FROM admins + WHERE email = ? + `, email) + return scanAdmin(row) +} + +func (r *AdminRepository) GetByID(ctx context.Context, id int64) (*models.Admin, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, name, email, password_hash, created_at + FROM admins + WHERE id = ? + `, id) + return scanAdmin(row) +} + +func scanAdmin(row *sql.Row) (*models.Admin, error) { + var admin models.Admin + var createdAt int64 + if err := row.Scan(&admin.ID, &admin.Name, &admin.Email, &admin.PasswordHash, &createdAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAdminNotFound + } + return nil, err + } + admin.CreatedAt = time.Unix(createdAt, 0).UTC() + return &admin, nil +} diff --git a/internal/repositories/admin_repository_test.go b/internal/repositories/admin_repository_test.go new file mode 100644 index 0000000..145eed9 --- /dev/null +++ b/internal/repositories/admin_repository_test.go @@ -0,0 +1,88 @@ +package repositories + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/database" + "github.com/phyowaiyan-dev/goappmon/internal/models" +) + +func newTestDB(t *testing.T) *sql.DB { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "test.sqlite") + db, err := database.Open(dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := database.Migrate(context.Background(), db); err != nil { + t.Fatalf("migrate db: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + return db +} + +func TestAdminRepositoryCRUD(t *testing.T) { + db := newTestDB(t) + repo := NewAdminRepository(db) + ctx := context.Background() + + count, err := repo.Count(ctx) + if err != nil { + t.Fatalf("count empty: %v", err) + } + if count != 0 { + t.Fatalf("expected empty count, got %d", count) + } + + createdAt := time.Unix(1_700_000_000, 0).UTC() + id, err := repo.Create(ctx, models.Admin{ + Name: "Admin", + Email: "admin@example.com", + PasswordHash: "hash", + CreatedAt: createdAt, + }) + if err != nil { + t.Fatalf("create admin: %v", err) + } + if id == 0 { + t.Fatal("expected non-zero id") + } + + count, err = repo.Count(ctx) + if err != nil { + t.Fatalf("count after create: %v", err) + } + if count != 1 { + t.Fatalf("expected count 1, got %d", count) + } + + admin, err := repo.GetByEmail(ctx, "admin@example.com") + if err != nil { + t.Fatalf("get by email: %v", err) + } + if admin.Name != "Admin" || admin.Email != "admin@example.com" || admin.PasswordHash != "hash" { + t.Fatalf("unexpected admin: %+v", admin) + } + if !admin.CreatedAt.Equal(createdAt) { + t.Fatalf("unexpected created at: %v", admin.CreatedAt) + } + + byID, err := repo.GetByID(ctx, id) + if err != nil { + t.Fatalf("get by id: %v", err) + } + if byID.ID != id { + t.Fatalf("unexpected id: %d", byID.ID) + } + + if _, err := repo.GetByEmail(ctx, "missing@example.com"); err != ErrAdminNotFound { + t.Fatalf("expected ErrAdminNotFound, got %v", err) + } +} diff --git a/internal/repositories/audit_log_repository.go b/internal/repositories/audit_log_repository.go new file mode 100644 index 0000000..361f9b0 --- /dev/null +++ b/internal/repositories/audit_log_repository.go @@ -0,0 +1,148 @@ +package repositories + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/models" +) + +type AuditLogRepository struct { + db DBTX +} + +type AuditLogFilter struct { + Query string + Limit int + Offset int +} + +type AuditLogPage struct { + Logs []models.AuditLog + Total int64 +} + +func NewAuditLogRepository(db DBTX) *AuditLogRepository { + return &AuditLogRepository{db: db} +} + +func (r *AuditLogRepository) Create(ctx context.Context, log models.AuditLog) (int64, error) { + result, err := r.db.ExecContext(ctx, ` + INSERT INTO audit_logs ( + actor_admin_id, action, entity_type, entity_id, before_json, after_json, ip, user_agent, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + log.ActorAdminID, + log.Action, + log.EntityType, + log.EntityID, + log.BeforeJSON, + log.AfterJSON, + log.IP, + log.UserAgent, + log.CreatedAt.Unix(), + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +func (r *AuditLogRepository) ListRecent(ctx context.Context, limit int) ([]models.AuditLog, error) { + page, err := r.Search(ctx, AuditLogFilter{Limit: limit}) + if err != nil { + return nil, err + } + return page.Logs, nil +} + +func (r *AuditLogRepository) Search(ctx context.Context, filter AuditLogFilter) (AuditLogPage, error) { + if filter.Limit <= 0 { + filter.Limit = 10 + } + if filter.Offset < 0 { + filter.Offset = 0 + } + + where, args := buildAuditWhere(filter) + query := fmt.Sprintf(` + SELECT al.id, al.actor_admin_id, COALESCE(a.name, ''), al.action, al.entity_type, al.entity_id, + al.before_json, al.after_json, al.ip, al.user_agent, al.created_at + FROM audit_logs al + LEFT JOIN admins a ON a.id = al.actor_admin_id + %s + ORDER BY al.created_at DESC, al.id DESC + LIMIT ? OFFSET ? + `, where) + argsWithLimit := append(args, filter.Limit, filter.Offset) + rows, err := r.db.QueryContext(ctx, query, argsWithLimit...) + if err != nil { + return AuditLogPage{}, err + } + defer rows.Close() + + logs := make([]models.AuditLog, 0) + for rows.Next() { + entry, err := scanAuditLog(rows) + if err != nil { + return AuditLogPage{}, err + } + logs = append(logs, *entry) + } + if err := rows.Err(); err != nil { + return AuditLogPage{}, err + } + + countQuery := fmt.Sprintf(` + SELECT COUNT(*) + FROM audit_logs al + LEFT JOIN admins a ON a.id = al.actor_admin_id + %s + `, where) + var total int64 + if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { + return AuditLogPage{}, err + } + + return AuditLogPage{Logs: logs, Total: total}, nil +} + +func buildAuditWhere(filter AuditLogFilter) (string, []any) { + clauses := make([]string, 0, 2) + args := make([]any, 0, 2) + clauses = append(clauses, "1=1") + + if q := strings.TrimSpace(filter.Query); q != "" { + like := "%" + strings.ToLower(q) + "%" + clauses = append(clauses, "(LOWER(al.action) LIKE ? OR LOWER(al.entity_type) LIKE ? OR LOWER(al.entity_id) LIKE ? OR LOWER(al.ip) LIKE ? OR LOWER(al.user_agent) LIKE ? OR LOWER(COALESCE(a.name, '')) LIKE ?)") + for i := 0; i < 6; i++ { + args = append(args, like) + } + } + return "WHERE " + strings.Join(clauses, " AND "), args +} + +func scanAuditLog(rows *sql.Rows) (*models.AuditLog, error) { + var entry models.AuditLog + var createdAt int64 + if err := rows.Scan( + &entry.ID, + &entry.ActorAdminID, + &entry.ActorName, + &entry.Action, + &entry.EntityType, + &entry.EntityID, + &entry.BeforeJSON, + &entry.AfterJSON, + &entry.IP, + &entry.UserAgent, + &createdAt, + ); err != nil { + return nil, err + } + entry.CreatedAt = time.Unix(createdAt, 0).UTC() + return &entry, nil +} diff --git a/internal/repositories/audit_log_repository_test.go b/internal/repositories/audit_log_repository_test.go new file mode 100644 index 0000000..817b1f8 --- /dev/null +++ b/internal/repositories/audit_log_repository_test.go @@ -0,0 +1,61 @@ +package repositories + +import ( + "context" + "testing" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/models" +) + +func TestAuditLogRepositorySearch(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + adminRepo := NewAdminRepository(db) + auditRepo := NewAuditLogRepository(db) + + adminID, err := adminRepo.Create(ctx, models.Admin{ + Name: "Admin", + Email: "admin@example.com", + PasswordHash: "hash", + CreatedAt: time.Unix(1_700_000_000, 0).UTC(), + }) + if err != nil { + t.Fatalf("create admin: %v", err) + } + + now := time.Unix(1_700_000_100, 0).UTC() + if _, err := auditRepo.Create(ctx, models.AuditLog{ + ActorAdminID: adminID, + Action: "version.created", + EntityType: "version_release", + EntityID: "android", + IP: "127.0.0.1", + UserAgent: "test", + CreatedAt: now, + }); err != nil { + t.Fatalf("create audit: %v", err) + } + if _, err := auditRepo.Create(ctx, models.AuditLog{ + ActorAdminID: adminID, + Action: "flag.deleted", + EntityType: "feature_flag", + EntityID: "1", + IP: "127.0.0.1", + UserAgent: "test", + CreatedAt: now.Add(time.Minute), + }); err != nil { + t.Fatalf("create audit: %v", err) + } + + page, err := auditRepo.Search(ctx, AuditLogFilter{Query: "deleted", Limit: 10}) + if err != nil { + t.Fatalf("search audit: %v", err) + } + if page.Total != 1 || len(page.Logs) != 1 { + t.Fatalf("unexpected page result: %+v", page) + } + if page.Logs[0].ActorName != "Admin" { + t.Fatalf("expected actor name, got %+v", page.Logs[0]) + } +} diff --git a/internal/repositories/dbtx.go b/internal/repositories/dbtx.go new file mode 100644 index 0000000..3910d34 --- /dev/null +++ b/internal/repositories/dbtx.go @@ -0,0 +1,12 @@ +package repositories + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} diff --git a/internal/repositories/feature_flag_repository.go b/internal/repositories/feature_flag_repository.go new file mode 100644 index 0000000..9bd0895 --- /dev/null +++ b/internal/repositories/feature_flag_repository.go @@ -0,0 +1,123 @@ +package repositories + +import ( + "context" + "database/sql" + "errors" + + "github.com/phyowaiyan-dev/goappmon/internal/models" +) + +var ErrFeatureFlagNotFound = errors.New("feature flag not found") + +type FeatureFlagRepository struct { + db DBTX +} + +func NewFeatureFlagRepository(db DBTX) *FeatureFlagRepository { + return &FeatureFlagRepository{db: db} +} + +func (r *FeatureFlagRepository) List(ctx context.Context) ([]models.FeatureFlag, error) { + rows, err := r.db.QueryContext(ctx, `SELECT id, key, enabled FROM feature_flags ORDER BY key ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + + flags := make([]models.FeatureFlag, 0) + for rows.Next() { + var flag models.FeatureFlag + var enabled int + if err := rows.Scan(&flag.ID, &flag.Key, &enabled); err != nil { + return nil, err + } + flag.Enabled = enabled != 0 + flags = append(flags, flag) + } + if err := rows.Err(); err != nil { + return nil, err + } + return flags, nil +} + +func (r *FeatureFlagRepository) GetByID(ctx context.Context, id int64) (*models.FeatureFlag, error) { + row := r.db.QueryRowContext(ctx, `SELECT id, key, enabled FROM feature_flags WHERE id = ?`, id) + var flag models.FeatureFlag + var enabled int + if err := row.Scan(&flag.ID, &flag.Key, &enabled); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrFeatureFlagNotFound + } + return nil, err + } + flag.Enabled = enabled != 0 + return &flag, nil +} + +func (r *FeatureFlagRepository) Create(ctx context.Context, key string, enabled bool) (int64, error) { + result, err := r.db.ExecContext(ctx, ` + INSERT INTO feature_flags (key, enabled) + VALUES (?, ?) + `, key, boolToInt(enabled)) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +func (r *FeatureFlagRepository) Update(ctx context.Context, id int64, key string, enabled bool) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE feature_flags + SET key = ?, enabled = ? + WHERE id = ? + `, key, boolToInt(enabled), id) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrFeatureFlagNotFound + } + return nil +} + +func (r *FeatureFlagRepository) Delete(ctx context.Context, id int64) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM feature_flags WHERE id = ?`, id) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrFeatureFlagNotFound + } + return nil +} + +func (r *FeatureFlagRepository) AsMap(ctx context.Context) (map[string]bool, error) { + rows, err := r.db.QueryContext(ctx, `SELECT key, enabled FROM feature_flags ORDER BY key ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + + flags := make(map[string]bool) + for rows.Next() { + var key string + var enabled int + if err := rows.Scan(&key, &enabled); err != nil { + return nil, err + } + flags[key] = enabled != 0 + } + if err := rows.Err(); err != nil { + return nil, err + } + return flags, nil +} diff --git a/internal/repositories/feature_flag_repository_test.go b/internal/repositories/feature_flag_repository_test.go new file mode 100644 index 0000000..690908a --- /dev/null +++ b/internal/repositories/feature_flag_repository_test.go @@ -0,0 +1,77 @@ +package repositories + +import ( + "context" + "testing" + + "github.com/phyowaiyan-dev/goappmon/internal/models" +) + +func TestFeatureFlagRepositoryLifecycle(t *testing.T) { + db := newTestDB(t) + repo := NewFeatureFlagRepository(db) + ctx := context.Background() + + id, err := repo.Create(ctx, "chat", true) + if err != nil { + t.Fatalf("create flag: %v", err) + } + if id == 0 { + t.Fatal("expected non-zero id") + } + if _, err := repo.Create(ctx, "payment", false); err != nil { + t.Fatalf("create second flag: %v", err) + } + + flags, err := repo.List(ctx) + if err != nil { + t.Fatalf("list flags: %v", err) + } + if len(flags) != 2 { + t.Fatalf("expected 2 flags, got %d", len(flags)) + } + + flagMap, err := repo.AsMap(ctx) + if err != nil { + t.Fatalf("flags as map: %v", err) + } + if !flagMap["chat"] || flagMap["payment"] { + t.Fatalf("unexpected flag map: %#v", flagMap) + } + + if err := repo.Update(ctx, id, "chat-v2", false); err != nil { + t.Fatalf("update flag: %v", err) + } + updated, err := repo.List(ctx) + if err != nil { + t.Fatalf("list after update: %v", err) + } + if updated[0].Key != "chat-v2" || updated[0].Enabled { + t.Fatalf("unexpected updated flag: %+v", updated[0]) + } + + if err := repo.Delete(ctx, id); err != nil { + t.Fatalf("delete flag: %v", err) + } + remaining, err := repo.List(ctx) + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if len(remaining) != 1 { + t.Fatalf("expected 1 flag, got %d", len(remaining)) + } +} + +func TestFeatureFlagRepositoryMissing(t *testing.T) { + db := newTestDB(t) + repo := NewFeatureFlagRepository(db) + ctx := context.Background() + + if err := repo.Update(ctx, 999, "missing", true); err != ErrFeatureFlagNotFound { + t.Fatalf("expected ErrFeatureFlagNotFound on update, got %v", err) + } + if err := repo.Delete(ctx, 999); err != ErrFeatureFlagNotFound { + t.Fatalf("expected ErrFeatureFlagNotFound on delete, got %v", err) + } + _ = models.FeatureFlag{} +} diff --git a/internal/repositories/setting_repository.go b/internal/repositories/setting_repository.go new file mode 100644 index 0000000..c444892 --- /dev/null +++ b/internal/repositories/setting_repository.go @@ -0,0 +1,199 @@ +package repositories + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/models" +) + +var ErrSettingsNotFound = errors.New("settings not found") + +type SettingRepository struct { + db DBTX +} + +func NewSettingRepository(db DBTX) *SettingRepository { + return &SettingRepository{db: db} +} + +func (r *SettingRepository) Count(ctx context.Context) (int64, error) { + var count int64 + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM settings`).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (r *SettingRepository) Create(ctx context.Context, setting models.Setting) (int64, error) { + result, err := r.db.ExecContext(ctx, ` + INSERT INTO settings ( + app_name, android_enabled, android_latest_version, android_min_version, android_force_update, + ios_enabled, ios_latest_version, ios_min_version, ios_force_update, + maintenance_mode, maintenance_message, banner_enabled, banner_message, api_url, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + setting.AppName, + boolToInt(setting.AndroidEnabled), + setting.AndroidLatestVersion, + setting.AndroidMinVersion, + boolToInt(setting.AndroidForceUpdate), + boolToInt(setting.IOSEnabled), + setting.IOSLatestVersion, + setting.IOSMinVersion, + boolToInt(setting.IOSForceUpdate), + boolToInt(setting.MaintenanceMode), + setting.MaintenanceMessage, + boolToInt(setting.BannerEnabled), + setting.BannerMessage, + setting.APIURL, + setting.CreatedAt.Unix(), + setting.UpdatedAt.Unix(), + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +func (r *SettingRepository) GetCurrent(ctx context.Context) (*models.Setting, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, app_name, android_enabled, android_latest_version, android_min_version, android_force_update, + ios_enabled, ios_latest_version, ios_min_version, ios_force_update, maintenance_mode, + maintenance_message, banner_enabled, banner_message, api_url, created_at, updated_at + FROM settings + ORDER BY id ASC + LIMIT 1 + `) + return scanSetting(row) +} + +func (r *SettingRepository) UpdateApplication(ctx context.Context, appName, apiURL string) error { + return r.update(ctx, ` + UPDATE settings + SET app_name = ?, api_url = ?, updated_at = ? + WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1) + `, appName, apiURL) +} + +func (r *SettingRepository) UpdateVersion(ctx context.Context, androidLatest, androidMin string, androidForce bool, iosLatest, iosMin string, iosForce bool) error { + return r.update(ctx, ` + UPDATE settings + SET android_latest_version = ?, + android_min_version = ?, + android_force_update = ?, + ios_latest_version = ?, + ios_min_version = ?, + ios_force_update = ?, + updated_at = ? + WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1) + `, androidLatest, androidMin, boolToInt(androidForce), iosLatest, iosMin, boolToInt(iosForce)) +} + +func (r *SettingRepository) UpdatePlatformVersion(ctx context.Context, platform, latest, minimum string, force bool) error { + switch platform { + case "android": + return r.update(ctx, ` + UPDATE settings + SET android_latest_version = ?, android_min_version = ?, android_force_update = ?, updated_at = ? + WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1) + `, latest, minimum, boolToInt(force)) + case "ios": + return r.update(ctx, ` + UPDATE settings + SET ios_latest_version = ?, ios_min_version = ?, ios_force_update = ?, updated_at = ? + WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1) + `, latest, minimum, boolToInt(force)) + default: + return ErrSettingsNotFound + } +} + +func (r *SettingRepository) UpdatePlatforms(ctx context.Context, androidEnabled, iosEnabled bool) error { + return r.update(ctx, ` + UPDATE settings + SET android_enabled = ?, ios_enabled = ?, updated_at = ? + WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1) + `, boolToInt(androidEnabled), boolToInt(iosEnabled)) +} + +func (r *SettingRepository) UpdateMaintenance(ctx context.Context, enabled bool, message string) error { + return r.update(ctx, ` + UPDATE settings + SET maintenance_mode = ?, maintenance_message = ?, updated_at = ? + WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1) + `, boolToInt(enabled), message) +} + +func (r *SettingRepository) UpdateBanner(ctx context.Context, enabled bool, message string) error { + return r.update(ctx, ` + UPDATE settings + SET banner_enabled = ?, banner_message = ?, updated_at = ? + WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1) + `, boolToInt(enabled), message) +} + +func (r *SettingRepository) update(ctx context.Context, query string, args ...any) error { + args = append(args, time.Now().UTC().Unix()) + result, err := r.db.ExecContext(ctx, query, args...) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrSettingsNotFound + } + return nil +} + +func scanSetting(row *sql.Row) (*models.Setting, error) { + var setting models.Setting + var androidEnabled, androidForce, iosEnabled, iosForce, maintenanceMode, bannerEnabled int + var createdAt, updatedAt int64 + if err := row.Scan( + &setting.ID, + &setting.AppName, + &androidEnabled, + &setting.AndroidLatestVersion, + &setting.AndroidMinVersion, + &androidForce, + &iosEnabled, + &setting.IOSLatestVersion, + &setting.IOSMinVersion, + &iosForce, + &maintenanceMode, + &setting.MaintenanceMessage, + &bannerEnabled, + &setting.BannerMessage, + &setting.APIURL, + &createdAt, + &updatedAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrSettingsNotFound + } + return nil, err + } + setting.AndroidEnabled = androidEnabled != 0 + setting.AndroidForceUpdate = androidForce != 0 + setting.IOSEnabled = iosEnabled != 0 + setting.IOSForceUpdate = iosForce != 0 + setting.MaintenanceMode = maintenanceMode != 0 + setting.BannerEnabled = bannerEnabled != 0 + setting.CreatedAt = time.Unix(createdAt, 0).UTC() + setting.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return &setting, nil +} + +func boolToInt(v bool) int { + if v { + return 1 + } + return 0 +} diff --git a/internal/repositories/setting_repository_test.go b/internal/repositories/setting_repository_test.go new file mode 100644 index 0000000..96d1b01 --- /dev/null +++ b/internal/repositories/setting_repository_test.go @@ -0,0 +1,95 @@ +package repositories + +import ( + "context" + "testing" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/models" +) + +func TestSettingRepositoryLifecycle(t *testing.T) { + db := newTestDB(t) + repo := NewSettingRepository(db) + ctx := context.Background() + + if count, err := repo.Count(ctx); err != nil || count != 0 { + t.Fatalf("expected empty settings count, got %d, %v", count, err) + } + + now := time.Unix(1_700_000_100, 0).UTC() + id, err := repo.Create(ctx, models.Setting{ + AndroidEnabled: true, + AppName: "GoAppMon", + AndroidLatestVersion: "1.0.0", + AndroidMinVersion: "1.0.0", + IOSEnabled: true, + IOSLatestVersion: "1.0.0", + IOSMinVersion: "1.0.0", + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatalf("create settings: %v", err) + } + if id == 0 { + t.Fatal("expected non-zero id") + } + + current, err := repo.GetCurrent(ctx) + if err != nil { + t.Fatalf("get current: %v", err) + } + if current.AppName != "GoAppMon" || !current.CreatedAt.Equal(now) || !current.UpdatedAt.Equal(now) { + t.Fatalf("unexpected settings: %+v", current) + } + if !current.AndroidEnabled || !current.IOSEnabled { + t.Fatalf("expected platforms enabled by default in test fixture: %+v", current) + } + + if err := repo.UpdateApplication(ctx, "App Two", "https://api.example.com"); err != nil { + t.Fatalf("update application: %v", err) + } + if err := repo.UpdateVersion(ctx, "2.0.0", "1.5.0", true, "2.1.0", "1.5.1", false); err != nil { + t.Fatalf("update version: %v", err) + } + if err := repo.UpdateMaintenance(ctx, true, "maintenance"); err != nil { + t.Fatalf("update maintenance: %v", err) + } + if err := repo.UpdateBanner(ctx, true, "banner"); err != nil { + t.Fatalf("update banner: %v", err) + } + + updated, err := repo.GetCurrent(ctx) + if err != nil { + t.Fatalf("get updated current: %v", err) + } + if updated.AppName != "App Two" || updated.APIURL != "https://api.example.com" { + t.Fatalf("unexpected updated app settings: %+v", updated) + } + if updated.AndroidLatestVersion != "2.0.0" || updated.AndroidMinVersion != "1.5.0" || !updated.AndroidForceUpdate { + t.Fatalf("unexpected android settings: %+v", updated) + } + if updated.IOSLatestVersion != "2.1.0" || updated.IOSMinVersion != "1.5.1" || updated.IOSForceUpdate { + t.Fatalf("unexpected ios settings: %+v", updated) + } + if !updated.MaintenanceMode || updated.MaintenanceMessage != "maintenance" { + t.Fatalf("unexpected maintenance settings: %+v", updated) + } + if !updated.BannerEnabled || updated.BannerMessage != "banner" { + t.Fatalf("unexpected banner settings: %+v", updated) + } + if updated.UpdatedAt.Equal(now) { + t.Fatalf("expected updated_at to change") + } +} + +func TestSettingRepositoryUpdateMissing(t *testing.T) { + db := newTestDB(t) + repo := NewSettingRepository(db) + ctx := context.Background() + + if err := repo.UpdateApplication(ctx, "App", ""); err != ErrSettingsNotFound { + t.Fatalf("expected ErrSettingsNotFound, got %v", err) + } +} diff --git a/internal/repositories/state_history_repository.go b/internal/repositories/state_history_repository.go new file mode 100644 index 0000000..4e69687 --- /dev/null +++ b/internal/repositories/state_history_repository.go @@ -0,0 +1,123 @@ +package repositories + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/models" +) + +type StateHistoryRepository struct { + db DBTX +} + +func NewStateHistoryRepository(db DBTX) *StateHistoryRepository { + return &StateHistoryRepository{db: db} +} + +func (r *StateHistoryRepository) Create(ctx context.Context, change models.StateChange) (int64, error) { + result, err := r.db.ExecContext(ctx, ` + INSERT INTO state_changes ( + kind, enabled, message, created_by_admin_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + `, + change.Kind, + boolToInt(change.Enabled), + change.Message, + change.CreatedByAdminID, + change.CreatedAt.Unix(), + change.UpdatedAt.Unix(), + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +func (r *StateHistoryRepository) ListByKind(ctx context.Context, kind string, limit int) ([]models.StateChange, error) { + if limit <= 0 { + limit = 10 + } + rows, err := r.db.QueryContext(ctx, ` + SELECT id, kind, enabled, message, created_by_admin_id, created_at, updated_at + FROM state_changes + WHERE kind = ? + ORDER BY created_at DESC, id DESC + LIMIT ? + `, kind, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + changes := make([]models.StateChange, 0) + for rows.Next() { + change, err := scanStateChange(rows) + if err != nil { + return nil, err + } + changes = append(changes, *change) + } + if err := rows.Err(); err != nil { + return nil, err + } + return changes, nil +} + +func (r *StateHistoryRepository) LatestByKind(ctx context.Context, kind string) (*models.StateChange, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, kind, enabled, message, created_by_admin_id, created_at, updated_at + FROM state_changes + WHERE kind = ? + ORDER BY created_at DESC, id DESC + LIMIT 1 + `, kind) + return scanStateChangeRow(row) +} + +func scanStateChange(rows *sql.Rows) (*models.StateChange, error) { + var change models.StateChange + var enabled int + var createdAt, updatedAt int64 + if err := rows.Scan( + &change.ID, + &change.Kind, + &enabled, + &change.Message, + &change.CreatedByAdminID, + &createdAt, + &updatedAt, + ); err != nil { + return nil, err + } + change.Enabled = enabled != 0 + change.CreatedAt = time.Unix(createdAt, 0).UTC() + change.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return &change, nil +} + +func scanStateChangeRow(row *sql.Row) (*models.StateChange, error) { + var change models.StateChange + var enabled int + var createdAt, updatedAt int64 + if err := row.Scan( + &change.ID, + &change.Kind, + &enabled, + &change.Message, + &change.CreatedByAdminID, + &createdAt, + &updatedAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, err + } + change.Enabled = enabled != 0 + change.CreatedAt = time.Unix(createdAt, 0).UTC() + change.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return &change, nil +} diff --git a/internal/repositories/version_release_repository.go b/internal/repositories/version_release_repository.go new file mode 100644 index 0000000..e0b26d8 --- /dev/null +++ b/internal/repositories/version_release_repository.go @@ -0,0 +1,144 @@ +package repositories + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/models" +) + +type VersionReleaseRepository struct { + db DBTX +} + +func NewVersionReleaseRepository(db DBTX) *VersionReleaseRepository { + return &VersionReleaseRepository{db: db} +} + +func (r *VersionReleaseRepository) Create(ctx context.Context, release models.VersionRelease) (int64, error) { + result, err := r.db.ExecContext(ctx, ` + INSERT INTO version_releases ( + platform, latest_version, minimum_version, force_update, release_notes, created_by_admin_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + release.Platform, + release.LatestVersion, + release.MinimumVersion, + boolToInt(release.ForceUpdate), + release.ReleaseNotes, + release.CreatedByAdminID, + release.CreatedAt.Unix(), + release.UpdatedAt.Unix(), + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +func (r *VersionReleaseRepository) DeleteByID(ctx context.Context, id int64) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM version_releases WHERE id = ?`, id) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +func (r *VersionReleaseRepository) ListByPlatform(ctx context.Context, platform string, limit int) ([]models.VersionRelease, error) { + if limit <= 0 { + limit = 10 + } + rows, err := r.db.QueryContext(ctx, ` + SELECT id, platform, latest_version, minimum_version, force_update, release_notes, created_by_admin_id, created_at, updated_at + FROM version_releases + WHERE platform = ? + ORDER BY created_at DESC, id DESC + LIMIT ? + `, platform, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + releases := make([]models.VersionRelease, 0) + for rows.Next() { + entry, err := scanVersionRelease(rows) + if err != nil { + return nil, err + } + releases = append(releases, *entry) + } + if err := rows.Err(); err != nil { + return nil, err + } + return releases, nil +} + +func (r *VersionReleaseRepository) LatestByPlatform(ctx context.Context, platform string) (*models.VersionRelease, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, platform, latest_version, minimum_version, force_update, release_notes, created_by_admin_id, created_at, updated_at + FROM version_releases + WHERE platform = ? + ORDER BY created_at DESC, id DESC + LIMIT 1 + `, platform) + return scanVersionReleaseRow(row) +} + +func scanVersionRelease(rows *sql.Rows) (*models.VersionRelease, error) { + var entry models.VersionRelease + var force int + var createdAt, updatedAt int64 + if err := rows.Scan( + &entry.ID, + &entry.Platform, + &entry.LatestVersion, + &entry.MinimumVersion, + &force, + &entry.ReleaseNotes, + &entry.CreatedByAdminID, + &createdAt, + &updatedAt, + ); err != nil { + return nil, err + } + entry.ForceUpdate = force != 0 + entry.CreatedAt = time.Unix(createdAt, 0).UTC() + entry.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return &entry, nil +} + +func scanVersionReleaseRow(row *sql.Row) (*models.VersionRelease, error) { + var entry models.VersionRelease + var force int + var createdAt, updatedAt int64 + if err := row.Scan( + &entry.ID, + &entry.Platform, + &entry.LatestVersion, + &entry.MinimumVersion, + &force, + &entry.ReleaseNotes, + &entry.CreatedByAdminID, + &createdAt, + &updatedAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, err + } + entry.ForceUpdate = force != 0 + entry.CreatedAt = time.Unix(createdAt, 0).UTC() + entry.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return &entry, nil +} diff --git a/internal/services/admin_service.go b/internal/services/admin_service.go new file mode 100644 index 0000000..d402824 --- /dev/null +++ b/internal/services/admin_service.go @@ -0,0 +1,747 @@ +package services + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/models" + "github.com/phyowaiyan-dev/goappmon/internal/repositories" + "github.com/phyowaiyan-dev/goappmon/internal/utils" + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/host" + "github.com/shirou/gopsutil/v4/mem" +) + +var ErrPlatformDisabled = errors.New("platform disabled") +var ErrInvalidVersionFormat = errors.New("invalid version format") +var ErrLatestVersionNotGreater = errors.New("latest version must be greater than current version") +var ErrMinimumVersionGreaterThanLatest = errors.New("minimum version must be less than or equal to latest version") +var ErrCannotDeleteLastVersion = errors.New("cannot delete last version") + +type ActionMeta struct { + ActorID int64 + IP string + UserAgent string +} + +type AdminDashboard struct { + Settings *models.Setting + Flags []models.FeatureFlag + AndroidReleases []models.VersionRelease + IOSReleases []models.VersionRelease + MaintenanceHistory []models.StateChange + BannerHistory []models.StateChange + AuditLogs []models.AuditLog + SystemHealth SystemHealth +} + +type AuditLogSearchParams struct { + Query string + Page int + PageSize int +} + +type AuditLogSearchResult struct { + Logs []models.AuditLog + Total int64 + Page int + PageSize int + TotalPages int +} + +type SystemHealth struct { + Score int + Status string + ScoreTone string + StatusTone string + Uptime string + SystemUptime string + GoVersion string + NumCPU int + CPUUsagePercent float64 + NumGoroutine int + AllocMB float64 + TotalAllocMB float64 + SysMB float64 + MemoryTotalMB float64 + MemoryUsedMB float64 + MemoryFreeMB float64 + MemoryUsedPercent float64 + DiskTotalGB float64 + DiskUsedGB float64 + DiskFreeGB float64 + DiskUsedPercent float64 + SQLiteFileSize string + SQLiteFilePath string + HealthNotes []string + UpdatedAt time.Time +} + +type AdminService struct { + db *sql.DB + settings *repositories.SettingRepository + flags *repositories.FeatureFlagRepository + versions *repositories.VersionReleaseRepository + states *repositories.StateHistoryRepository + audits *repositories.AuditLogRepository + dbPath string + startedAt time.Time +} + +func NewAdminService(db *sql.DB, settings *repositories.SettingRepository, flags *repositories.FeatureFlagRepository, dbPath string, startedAt time.Time) *AdminService { + return &AdminService{ + db: db, + settings: settings, + flags: flags, + versions: repositories.NewVersionReleaseRepository(db), + states: repositories.NewStateHistoryRepository(db), + audits: repositories.NewAuditLogRepository(db), + dbPath: dbPath, + startedAt: startedAt, + } +} + +func (s *AdminService) Dashboard(ctx context.Context) (*AdminDashboard, error) { + settings, err := s.settings.GetCurrent(ctx) + if err != nil { + return nil, err + } + flags, err := s.flags.List(ctx) + if err != nil { + return nil, err + } + androidReleases, err := s.versions.ListByPlatform(ctx, "android", 10) + if err != nil { + return nil, err + } + iosReleases, err := s.versions.ListByPlatform(ctx, "ios", 10) + if err != nil { + return nil, err + } + maintenanceHistory, err := s.states.ListByKind(ctx, "maintenance", 5) + if err != nil { + return nil, err + } + bannerHistory, err := s.states.ListByKind(ctx, "banner", 5) + if err != nil { + return nil, err + } + auditLogs, err := s.audits.ListRecent(ctx, 10) + if err != nil { + return nil, err + } + return &AdminDashboard{ + Settings: settings, + Flags: flags, + AndroidReleases: androidReleases, + IOSReleases: iosReleases, + MaintenanceHistory: maintenanceHistory, + BannerHistory: bannerHistory, + AuditLogs: auditLogs, + SystemHealth: s.SystemHealth(), + }, nil +} + +func (s *AdminService) SearchAuditLogs(ctx context.Context, params AuditLogSearchParams) (AuditLogSearchResult, error) { + if params.Page <= 0 { + params.Page = 1 + } + if params.PageSize <= 0 { + params.PageSize = 20 + } + filter := repositories.AuditLogFilter{ + Query: params.Query, + Limit: params.PageSize, + Offset: (params.Page - 1) * params.PageSize, + } + page, err := s.audits.Search(ctx, filter) + if err != nil { + return AuditLogSearchResult{}, err + } + totalPages := 0 + if page.Total > 0 { + totalPages = int((page.Total + int64(params.PageSize) - 1) / int64(params.PageSize)) + } + if totalPages == 0 { + totalPages = 1 + } + return AuditLogSearchResult{ + Logs: page.Logs, + Total: page.Total, + Page: params.Page, + PageSize: params.PageSize, + TotalPages: totalPages, + }, nil +} + +func (s *AdminService) UpdateApplication(ctx context.Context, meta ActionMeta, appName, apiURL string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + settings := repositories.NewSettingRepository(tx) + audits := repositories.NewAuditLogRepository(tx) + before, err := settings.GetCurrent(ctx) + if err != nil { + _ = tx.Rollback() + return err + } + if err := settings.UpdateApplication(ctx, appName, apiURL); err != nil { + _ = tx.Rollback() + return err + } + if err := createAudit(ctx, audits, meta, "settings.updated", "settings", "application", before, map[string]string{"app_name": appName, "api_url": apiURL}); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (s *AdminService) UpdatePlatforms(ctx context.Context, meta ActionMeta, androidEnabled, iosEnabled bool) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + settings := repositories.NewSettingRepository(tx) + audits := repositories.NewAuditLogRepository(tx) + before, err := settings.GetCurrent(ctx) + if err != nil { + _ = tx.Rollback() + return err + } + if err := settings.UpdatePlatforms(ctx, androidEnabled, iosEnabled); err != nil { + _ = tx.Rollback() + return err + } + if err := createAudit(ctx, audits, meta, "platforms.updated", "settings", "platforms", before, map[string]bool{"android_enabled": androidEnabled, "ios_enabled": iosEnabled}); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (s *AdminService) PublishVersion(ctx context.Context, meta ActionMeta, platform, latestVersion, minimumVersion string, forceUpdate bool, releaseNotes string) error { + if _, err := utils.ParseSemanticVersion(latestVersion); err != nil { + return ErrInvalidVersionFormat + } + if _, err := utils.ParseSemanticVersion(minimumVersion); err != nil { + return ErrInvalidVersionFormat + } + if cmp, err := utils.CompareSemanticVersion(minimumVersion, latestVersion); err != nil { + return ErrInvalidVersionFormat + } else if cmp > 0 { + return ErrMinimumVersionGreaterThanLatest + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + settings := repositories.NewSettingRepository(tx) + versions := repositories.NewVersionReleaseRepository(tx) + audits := repositories.NewAuditLogRepository(tx) + before, err := settings.GetCurrent(ctx) + if err != nil { + _ = tx.Rollback() + return err + } + switch platform { + case "android": + if !before.AndroidEnabled { + _ = tx.Rollback() + return ErrPlatformDisabled + } + case "ios": + if !before.IOSEnabled { + _ = tx.Rollback() + return ErrPlatformDisabled + } + } + if latest, err := versions.LatestByPlatform(ctx, platform); err == nil && latest != nil { + if cmp, err := utils.CompareSemanticVersion(latestVersion, latest.LatestVersion); err != nil { + _ = tx.Rollback() + return ErrInvalidVersionFormat + } else if cmp <= 0 { + _ = tx.Rollback() + return ErrLatestVersionNotGreater + } + } + now := time.Now().UTC() + release := models.VersionRelease{ + Platform: platform, + LatestVersion: latestVersion, + MinimumVersion: minimumVersion, + ForceUpdate: forceUpdate, + ReleaseNotes: releaseNotes, + CreatedByAdminID: meta.ActorID, + CreatedAt: now, + UpdatedAt: now, + } + if _, err := versions.Create(ctx, release); err != nil { + _ = tx.Rollback() + return err + } + if err := settings.UpdatePlatformVersion(ctx, platform, latestVersion, minimumVersion, forceUpdate); err != nil { + _ = tx.Rollback() + return err + } + payload := map[string]any{ + "platform": platform, + "latest_version": latestVersion, + "minimum_version": minimumVersion, + "force_update": forceUpdate, + "release_notes": releaseNotes, + } + if err := createAudit(ctx, audits, meta, "version.created", "version_release", platform, before, payload); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (s *AdminService) DeleteCurrentVersion(ctx context.Context, meta ActionMeta, platform string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + settings := repositories.NewSettingRepository(tx) + versions := repositories.NewVersionReleaseRepository(tx) + audits := repositories.NewAuditLogRepository(tx) + before, err := settings.GetCurrent(ctx) + if err != nil { + _ = tx.Rollback() + return err + } + if platform != "android" && platform != "ios" { + _ = tx.Rollback() + return fmt.Errorf("invalid platform") + } + + releases, err := versions.ListByPlatform(ctx, platform, 2) + if err != nil { + _ = tx.Rollback() + return err + } + if len(releases) <= 1 { + _ = tx.Rollback() + return ErrCannotDeleteLastVersion + } + deleted := releases[0] + promoted := releases[1] + + if err := versions.DeleteByID(ctx, deleted.ID); err != nil { + _ = tx.Rollback() + return err + } + if err := settings.UpdatePlatformVersion(ctx, platform, promoted.LatestVersion, promoted.MinimumVersion, promoted.ForceUpdate); err != nil { + _ = tx.Rollback() + return err + } + payload := map[string]any{ + "platform": platform, + "deleted_version": deleted.LatestVersion, + "promoted_version": promoted.LatestVersion, + } + if err := createAudit(ctx, audits, meta, "version.deleted", "version_release", platform, before, payload); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (s *AdminService) UpdateMaintenance(ctx context.Context, meta ActionMeta, enabled bool, message string) error { + return s.updateState(ctx, meta, "maintenance", "maintenance.updated", enabled, message) +} + +func (s *AdminService) UpdateBanner(ctx context.Context, meta ActionMeta, enabled bool, message string) error { + return s.updateState(ctx, meta, "banner", "banner.updated", enabled, message) +} + +func (s *AdminService) updateState(ctx context.Context, meta ActionMeta, kind, action string, enabled bool, message string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + settings := repositories.NewSettingRepository(tx) + states := repositories.NewStateHistoryRepository(tx) + audits := repositories.NewAuditLogRepository(tx) + before, err := settings.GetCurrent(ctx) + if err != nil { + _ = tx.Rollback() + return err + } + if !enabled { + message = "" + } + now := time.Now().UTC() + change := models.StateChange{ + Kind: kind, + Enabled: enabled, + Message: message, + CreatedByAdminID: meta.ActorID, + CreatedAt: now, + UpdatedAt: now, + } + if _, err := states.Create(ctx, change); err != nil { + _ = tx.Rollback() + return err + } + switch kind { + case "maintenance": + if err := settings.UpdateMaintenance(ctx, enabled, message); err != nil { + _ = tx.Rollback() + return err + } + case "banner": + if err := settings.UpdateBanner(ctx, enabled, message); err != nil { + _ = tx.Rollback() + return err + } + } + payload := map[string]any{ + "enabled": enabled, + "message": message, + "kind": kind, + } + if err := createAudit(ctx, audits, meta, action, "state_change", kind, before, payload); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (s *AdminService) CreateFlag(ctx context.Context, meta ActionMeta, key string, enabled bool) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + flags := repositories.NewFeatureFlagRepository(tx) + audits := repositories.NewAuditLogRepository(tx) + id, err := flags.Create(ctx, key, enabled) + if err != nil { + _ = tx.Rollback() + return err + } + flag, err := flags.GetByID(ctx, id) + if err != nil { + _ = tx.Rollback() + return err + } + if err := createAudit(ctx, audits, meta, "flag.created", "feature_flag", fmt.Sprintf("%d", id), nil, flag); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (s *AdminService) UpdateFlag(ctx context.Context, meta ActionMeta, id int64, key string, enabled bool) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + flags := repositories.NewFeatureFlagRepository(tx) + audits := repositories.NewAuditLogRepository(tx) + before, err := flags.GetByID(ctx, id) + if err != nil { + _ = tx.Rollback() + return err + } + if err := flags.Update(ctx, id, key, enabled); err != nil { + _ = tx.Rollback() + return err + } + if err := createAudit(ctx, audits, meta, "flag.updated", "feature_flag", fmt.Sprintf("%d", id), before, map[string]any{"key": key, "enabled": enabled}); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (s *AdminService) DeleteFlag(ctx context.Context, meta ActionMeta, id int64) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + flags := repositories.NewFeatureFlagRepository(tx) + audits := repositories.NewAuditLogRepository(tx) + before, err := flags.GetByID(ctx, id) + if err != nil { + _ = tx.Rollback() + return err + } + if err := flags.Delete(ctx, id); err != nil { + _ = tx.Rollback() + return err + } + if err := createAudit(ctx, audits, meta, "flag.deleted", "feature_flag", fmt.Sprintf("%d", id), before, nil); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (s *AdminService) SystemHealth() SystemHealth { + var runtimeMem runtime.MemStats + runtime.ReadMemStats(&runtimeMem) + + ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond) + defer cancel() + + dbSize := s.sqliteFileSize() + notes := make([]string, 0, 6) + + memInfo, memErr := mem.VirtualMemoryWithContext(ctx) + diskInfo, diskErr := disk.UsageWithContext(ctx, s.diskUsagePath()) + cpuUsage := 0.0 + if cpuPercents, err := cpu.PercentWithContext(ctx, 200*time.Millisecond, false); err == nil && len(cpuPercents) > 0 { + cpuUsage = cpuPercents[0] + } + systemUptime := "" + if uptimeSeconds, err := host.UptimeWithContext(ctx); err == nil && uptimeSeconds > 0 { + systemUptime = (time.Duration(uptimeSeconds) * time.Second).Truncate(time.Second).String() + } + if memErr != nil { + notes = append(notes, "System memory snapshot is unavailable") + } + if diskErr != nil { + notes = append(notes, "Disk usage snapshot is unavailable") + } + + score, status, scoreNotes := calculateHealthScore(cpuUsage, memInfo, memErr, diskInfo, diskErr, runtime.NumGoroutine(), dbSize, runtimeMem.Alloc) + notes = append(notes, scoreNotes...) + + return SystemHealth{ + Score: score, + Status: status, + ScoreTone: healthTone(status), + StatusTone: healthTone(status), + Uptime: time.Since(s.startedAt).Truncate(time.Second).String(), + SystemUptime: systemUptime, + GoVersion: runtime.Version(), + NumCPU: runtime.NumCPU(), + CPUUsagePercent: cpuUsage, + NumGoroutine: runtime.NumGoroutine(), + AllocMB: bytesToMB(runtimeMem.Alloc), + TotalAllocMB: bytesToMB(runtimeMem.TotalAlloc), + SysMB: bytesToMB(runtimeMem.Sys), + MemoryTotalMB: func() float64 { + if memErr != nil { + return 0 + } + return bytesToMB(memInfo.Total) + }(), + MemoryUsedMB: func() float64 { + if memErr != nil { + return 0 + } + return bytesToMB(memInfo.Used) + }(), + MemoryFreeMB: func() float64 { + if memErr != nil { + return 0 + } + return bytesToMB(memInfo.Available) + }(), + MemoryUsedPercent: func() float64 { + if memErr != nil { + return 0 + } + return memInfo.UsedPercent + }(), + DiskTotalGB: func() float64 { + if diskErr != nil { + return 0 + } + return bytesToGB(diskInfo.Total) + }(), + DiskUsedGB: func() float64 { + if diskErr != nil { + return 0 + } + return bytesToGB(diskInfo.Used) + }(), + DiskFreeGB: func() float64 { + if diskErr != nil { + return 0 + } + return bytesToGB(diskInfo.Free) + }(), + DiskUsedPercent: func() float64 { + if diskErr != nil { + return 0 + } + return diskInfo.UsedPercent + }(), + SQLiteFileSize: humanFileSize(dbSize), + SQLiteFilePath: s.dbPath, + HealthNotes: notes, + UpdatedAt: time.Now().UTC(), + } +} + +func calculateHealthScore(cpuUsage float64, memInfo *mem.VirtualMemoryStat, memErr error, diskInfo *disk.UsageStat, diskErr error, goroutines int, dbSize int64, appAllocBytes uint64) (int, string, []string) { + score := 96.0 + notes := make([]string, 0, 6) + + if memErr == nil && memInfo != nil { + switch { + case memInfo.UsedPercent > 90: + score -= (memInfo.UsedPercent - 70) * 1.1 + notes = append(notes, "Memory is in a critical zone") + case memInfo.UsedPercent > 70: + score -= (memInfo.UsedPercent - 70) * 0.9 + } + if memInfo.Available < 1024*1024*1024 { + score -= 6 + notes = append(notes, "Available memory is below 1 GB") + } + } + + if diskErr == nil && diskInfo != nil { + switch { + case diskInfo.UsedPercent > 95: + score -= (diskInfo.UsedPercent - 75) * 1.25 + notes = append(notes, "Disk usage is critically high") + case diskInfo.UsedPercent > 75: + score -= (diskInfo.UsedPercent - 75) * 1.25 + } + if diskInfo.Free < 10*1024*1024*1024 { + score -= 6 + notes = append(notes, "Disk free space is below 10 GB") + } + } + + if cpuUsage > 80 { + score -= (cpuUsage - 80) * 0.35 + notes = append(notes, "CPU usage is elevated") + } else if cpuUsage > 55 { + score -= (cpuUsage - 55) * 0.22 + } + + if goroutines > 75 { + score -= 2 + } + if dbSize > 100*1024*1024 { + score -= 4 + } + if appAllocBytes > 256*1024*1024 { + score -= 4 + } + + if score > 96 { + score = 96 + } + if score < 36 { + score = 36 + } + + status := "Healthy" + switch { + case score >= 90: + status = "Excellent" + case score >= 75: + status = "Good" + case score >= 55: + status = "Fair" + default: + status = "Needs attention" + } + + return int(score + 0.5), status, notes +} + +func (s *AdminService) sqliteFileSize() int64 { + if s.dbPath == "" { + return 0 + } + info, err := os.Stat(s.dbPath) + if err != nil { + return 0 + } + return info.Size() +} + +func (s *AdminService) diskUsagePath() string { + if s.dbPath == "" { + return "." + } + dir := filepath.Dir(s.dbPath) + if dir == "" || dir == "." { + return s.dbPath + } + return dir +} + +func createAudit(ctx context.Context, repo *repositories.AuditLogRepository, meta ActionMeta, action, entityType, entityID string, before, after any) error { + entry := models.AuditLog{ + ActorAdminID: meta.ActorID, + Action: action, + EntityType: entityType, + EntityID: entityID, + IP: meta.IP, + UserAgent: meta.UserAgent, + CreatedAt: time.Now().UTC(), + } + if before != nil { + data, err := json.Marshal(before) + if err != nil { + return err + } + entry.BeforeJSON = string(data) + } + if after != nil { + data, err := json.Marshal(after) + if err != nil { + return err + } + entry.AfterJSON = string(data) + } + _, err := repo.Create(ctx, entry) + return err +} + +func bytesToMB(v uint64) float64 { + return float64(v) / (1024 * 1024) +} + +func bytesToGB(v uint64) float64 { + return float64(v) / (1024 * 1024 * 1024) +} + +func humanFileSize(bytes int64) string { + if bytes < 1024 { + return fmt.Sprintf("%d B", bytes) + } + unit := []string{"KB", "MB", "GB", "TB"} + size := float64(bytes) + i := 0 + for size >= 1024 && i < len(unit)-1 { + size /= 1024 + i++ + } + return fmt.Sprintf("%.1f %s", size, unit[i]) +} + +func healthTone(status string) string { + switch strings.ToLower(status) { + case "healthy": + return "success" + case "excellent": + return "success" + case "good": + return "info" + case "fair": + return "warning" + default: + return "error" + } +} diff --git a/internal/services/admin_service_test.go b/internal/services/admin_service_test.go new file mode 100644 index 0000000..6f70de0 --- /dev/null +++ b/internal/services/admin_service_test.go @@ -0,0 +1,194 @@ +package services + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/database" + "github.com/phyowaiyan-dev/goappmon/internal/models" + "github.com/phyowaiyan-dev/goappmon/internal/repositories" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/mem" +) + +func TestAdminServiceHistoryAndAuditTrail(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "goappmon.sqlite") + db, err := database.Open(dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := database.Migrate(context.Background(), db); err != nil { + t.Fatalf("migrate db: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + + ctx := context.Background() + settingsRepo := repositories.NewSettingRepository(db) + flagsRepo := repositories.NewFeatureFlagRepository(db) + now := time.Unix(1_700_000_500, 0).UTC() + if _, err := settingsRepo.Create(ctx, models.Setting{ + AppName: "GoAppMon", + AndroidEnabled: true, + AndroidLatestVersion: "1.0.0", + AndroidMinVersion: "1.0.0", + IOSEnabled: true, + IOSLatestVersion: "1.0.0", + IOSMinVersion: "1.0.0", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("create settings: %v", err) + } + + service := NewAdminService(db, settingsRepo, flagsRepo, dbPath, now) + meta := ActionMeta{ActorID: 42, IP: "127.0.0.1", UserAgent: "codex-test"} + + if err := service.PublishVersion(ctx, meta, "android", "2.0.0", "1.5.0", true, "android release"); err != nil { + t.Fatalf("publish android version: %v", err) + } + if err := service.UpdateMaintenance(ctx, meta, true, "maintenance"); err != nil { + t.Fatalf("update maintenance: %v", err) + } + if err := service.UpdateBanner(ctx, meta, true, "banner"); err != nil { + t.Fatalf("update banner: %v", err) + } + if err := service.CreateFlag(ctx, meta, "chat", true); err != nil { + t.Fatalf("create flag: %v", err) + } + + dashboard, err := service.Dashboard(ctx) + if err != nil { + t.Fatalf("dashboard: %v", err) + } + if len(dashboard.AndroidReleases) != 1 { + t.Fatalf("expected 1 android release, got %d", len(dashboard.AndroidReleases)) + } + if len(dashboard.MaintenanceHistory) != 1 || len(dashboard.BannerHistory) != 1 { + t.Fatalf("expected history entries, got maintenance=%d banner=%d", len(dashboard.MaintenanceHistory), len(dashboard.BannerHistory)) + } + if len(dashboard.AuditLogs) == 0 { + t.Fatal("expected audit log entries") + } + if dashboard.SystemHealth.Score < 36 || dashboard.SystemHealth.Score > 96 { + t.Fatalf("expected bounded health score, got %d", dashboard.SystemHealth.Score) + } + if dashboard.SystemHealth.MemoryTotalMB < 0 || dashboard.SystemHealth.DiskTotalGB < 0 { + t.Fatalf("expected non-negative machine metrics, got %+v", dashboard.SystemHealth) + } +} + +func TestAdminServiceVersionValidationAndDeleteLatest(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "goappmon.sqlite") + db, err := database.Open(dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := database.Migrate(context.Background(), db); err != nil { + t.Fatalf("migrate db: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + + ctx := context.Background() + settingsRepo := repositories.NewSettingRepository(db) + flagsRepo := repositories.NewFeatureFlagRepository(db) + now := time.Unix(1_700_000_500, 0).UTC() + if _, err := settingsRepo.Create(ctx, models.Setting{ + AppName: "GoAppMon", + AndroidEnabled: true, + AndroidLatestVersion: "1.0.0", + AndroidMinVersion: "1.0.0", + IOSEnabled: true, + IOSLatestVersion: "1.0.0", + IOSMinVersion: "1.0.0", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("create settings: %v", err) + } + + service := NewAdminService(db, settingsRepo, flagsRepo, dbPath, now) + meta := ActionMeta{ActorID: 42, IP: "127.0.0.1", UserAgent: "codex-test"} + + if err := service.PublishVersion(ctx, meta, "android", "1.0.0", "1.0.0", false, "seed"); err != nil { + t.Fatalf("publish android seed version: %v", err) + } + if err := service.PublishVersion(ctx, meta, "android", "2.0.0", "1.5.0", true, "android release"); err != nil { + t.Fatalf("publish android latest version: %v", err) + } + if err := service.PublishVersion(ctx, meta, "ios", "1.0.0", "1.0.0", false, "seed"); err != nil { + t.Fatalf("publish ios seed version: %v", err) + } + if err := service.PublishVersion(ctx, meta, "ios", "2.0.0", "1.5.0", true, "ios release"); err != nil { + t.Fatalf("publish ios latest version: %v", err) + } + + if err := service.PublishVersion(ctx, meta, "android", "2.0", "1.5.0", false, "bad"); !errors.Is(err, ErrInvalidVersionFormat) { + t.Fatalf("expected invalid version format, got %v", err) + } + if err := service.PublishVersion(ctx, meta, "android", "2.0.0", "2.1.0", false, "bad"); !errors.Is(err, ErrMinimumVersionGreaterThanLatest) { + t.Fatalf("expected minimum greater than latest, got %v", err) + } + if err := service.PublishVersion(ctx, meta, "android", "2.0.0", "1.5.0", false, "bad"); !errors.Is(err, ErrLatestVersionNotGreater) { + t.Fatalf("expected latest not increasing, got %v", err) + } + + if err := service.DeleteCurrentVersion(ctx, meta, "android"); err != nil { + t.Fatalf("delete android latest version: %v", err) + } + if err := service.DeleteCurrentVersion(ctx, meta, "ios"); err != nil { + t.Fatalf("delete ios latest version: %v", err) + } + + dashboard, err := service.Dashboard(ctx) + if err != nil { + t.Fatalf("dashboard: %v", err) + } + if len(dashboard.AndroidReleases) != 1 { + t.Fatalf("expected 1 android release after delete, got %d", len(dashboard.AndroidReleases)) + } + if dashboard.Settings.AndroidLatestVersion != "1.0.0" { + t.Fatalf("expected android latest version to roll back, got %s", dashboard.Settings.AndroidLatestVersion) + } + if len(dashboard.IOSReleases) != 1 { + t.Fatalf("expected 1 ios release after delete, got %d", len(dashboard.IOSReleases)) + } + if dashboard.Settings.IOSLatestVersion != "1.0.0" { + t.Fatalf("expected ios latest version to roll back, got %s", dashboard.Settings.IOSLatestVersion) + } + + if err := service.DeleteCurrentVersion(ctx, meta, "android"); !errors.Is(err, ErrCannotDeleteLastVersion) { + t.Fatalf("expected cannot delete last version, got %v", err) + } +} + +func TestCalculateHealthScorePenaltyProfile(t *testing.T) { + score, status, notes := calculateHealthScore( + 73.1, + &mem.VirtualMemoryStat{UsedPercent: 85.1, Available: 1221 * 1024 * 1024}, + nil, + &disk.UsageStat{UsedPercent: 97.3, Free: 6 * 1024 * 1024 * 1024}, + nil, + 7, + 56*1024*1024, + 4*1024*1024, + ) + + if score >= 70 { + t.Fatalf("expected heavy pressure score to be low, got %d", score) + } + if status != "Needs attention" { + t.Fatalf("expected needs attention status, got %q", status) + } + if len(notes) == 0 { + t.Fatal("expected warning notes for heavy pressure") + } +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go new file mode 100644 index 0000000..5bd9a82 --- /dev/null +++ b/internal/services/auth_service.go @@ -0,0 +1,49 @@ +package services + +import ( + "context" + "errors" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/models" + "github.com/phyowaiyan-dev/goappmon/internal/repositories" + "github.com/phyowaiyan-dev/goappmon/internal/utils" +) + +var ErrInvalidCredentials = errors.New("invalid credentials") + +type AuthService struct { + admins *repositories.AdminRepository + sessionSecret []byte + sessionTTL time.Duration +} + +func NewAuthService(admins *repositories.AdminRepository, sessionSecret []byte, ttl time.Duration) *AuthService { + return &AuthService{admins: admins, sessionSecret: sessionSecret, sessionTTL: ttl} +} + +func (s *AuthService) Authenticate(ctx context.Context, email, password string) (*models.Admin, error) { + admin, err := s.admins.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, repositories.ErrAdminNotFound) { + return nil, ErrInvalidCredentials + } + return nil, err + } + if err := utils.CheckPassword(admin.PasswordHash, password); err != nil { + return nil, ErrInvalidCredentials + } + return admin, nil +} + +func (s *AuthService) SignSession(adminID int64) (string, error) { + return utils.SignSession(s.sessionSecret, adminID, s.sessionTTL) +} + +func (s *AuthService) VerifySession(token string) (int64, error) { + claims, err := utils.VerifySession(s.sessionSecret, token) + if err != nil { + return 0, err + } + return claims.AdminID, nil +} diff --git a/internal/services/auth_service_test.go b/internal/services/auth_service_test.go new file mode 100644 index 0000000..5cf6786 --- /dev/null +++ b/internal/services/auth_service_test.go @@ -0,0 +1,62 @@ +package services + +import ( + "context" + "testing" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/models" + "github.com/phyowaiyan-dev/goappmon/internal/repositories" + "github.com/phyowaiyan-dev/goappmon/internal/utils" +) + +func TestAuthServiceAuthenticateAndSession(t *testing.T) { + db := repositoriesTestDB(t) + adminRepo := repositories.NewAdminRepository(db) + + hash, err := utils.HashPassword("secret123") + if err != nil { + t.Fatalf("hash password: %v", err) + } + + _, err = adminRepo.Create(context.Background(), models.Admin{ + Name: "Admin", + Email: "admin@example.com", + PasswordHash: hash, + }) + if err != nil { + t.Fatalf("create admin: %v", err) + } + + service := NewAuthService(adminRepo, []byte("01234567890123456789012345678901"), time.Hour) + admin, err := service.Authenticate(context.Background(), "admin@example.com", "secret123") + if err != nil { + t.Fatalf("authenticate: %v", err) + } + if admin.Email != "admin@example.com" { + t.Fatalf("unexpected admin: %+v", admin) + } + + if _, err := service.Authenticate(context.Background(), "admin@example.com", "bad"); err != ErrInvalidCredentials { + t.Fatalf("expected invalid credentials, got %v", err) + } + if _, err := service.Authenticate(context.Background(), "missing@example.com", "secret123"); err != ErrInvalidCredentials { + t.Fatalf("expected invalid credentials for missing admin, got %v", err) + } + + token, err := service.SignSession(admin.ID) + if err != nil { + t.Fatalf("sign session: %v", err) + } + adminID, err := service.VerifySession(token) + if err != nil { + t.Fatalf("verify session: %v", err) + } + if adminID != admin.ID { + t.Fatalf("unexpected admin id: %d", adminID) + } + + if _, err := service.VerifySession(token + "tamper"); err == nil { + t.Fatal("expected tampered session to fail") + } +} diff --git a/internal/services/setup_service.go b/internal/services/setup_service.go new file mode 100644 index 0000000..b05c676 --- /dev/null +++ b/internal/services/setup_service.go @@ -0,0 +1,116 @@ +package services + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/models" + "github.com/phyowaiyan-dev/goappmon/internal/repositories" + "github.com/phyowaiyan-dev/goappmon/internal/utils" +) + +var ErrSetupAlreadyComplete = errors.New("setup already complete") + +type SetupService struct { + db *sql.DB +} + +func NewSetupService(db *sql.DB) *SetupService { + return &SetupService{db: db} +} + +func (s *SetupService) IsSetupComplete(ctx context.Context) (bool, error) { + count, err := repositories.NewAdminRepository(s.db).Count(ctx) + if err != nil { + return false, err + } + return count > 0, nil +} + +func (s *SetupService) EnsureDefaultSettings(ctx context.Context) error { + adminCount, err := repositories.NewAdminRepository(s.db).Count(ctx) + if err != nil { + return err + } + if adminCount == 0 { + return nil + } + + repo := repositories.NewSettingRepository(s.db) + count, err := repo.Count(ctx) + if err != nil { + return err + } + if count > 0 { + return nil + } + + now := time.Now().UTC() + _, err = repo.Create(ctx, models.Setting{ + AppName: "GoAppMon", + AndroidEnabled: true, + AndroidLatestVersion: "1.0.0", + AndroidMinVersion: "1.0.0", + IOSEnabled: true, + IOSLatestVersion: "1.0.0", + IOSMinVersion: "1.0.0", + CreatedAt: now, + UpdatedAt: now, + }) + return err +} + +func (s *SetupService) CreateInitialSetup(ctx context.Context, adminName, adminEmail, password, appName string) (err error) { + adminRepo := repositories.NewAdminRepository(s.db) + count, err := adminRepo.Count(ctx) + if err != nil { + return err + } + if count > 0 { + return ErrSetupAlreadyComplete + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + txAdmins := repositories.NewAdminRepository(tx) + txSettings := repositories.NewSettingRepository(tx) + hash, err := utils.HashPassword(password) + if err != nil { + return err + } + + now := time.Now().UTC() + if _, err = txAdmins.Create(ctx, models.Admin{ + Name: adminName, + Email: adminEmail, + PasswordHash: hash, + CreatedAt: now, + }); err != nil { + return err + } + if _, err = txSettings.Create(ctx, models.Setting{ + AppName: appName, + AndroidEnabled: true, + AndroidLatestVersion: "1.0.0", + AndroidMinVersion: "1.0.0", + IOSEnabled: true, + IOSLatestVersion: "1.0.0", + IOSMinVersion: "1.0.0", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + return err + } + + return tx.Commit() +} diff --git a/internal/services/setup_service_test.go b/internal/services/setup_service_test.go new file mode 100644 index 0000000..2daf01e --- /dev/null +++ b/internal/services/setup_service_test.go @@ -0,0 +1,89 @@ +package services + +import ( + "context" + "testing" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/models" + "github.com/phyowaiyan-dev/goappmon/internal/repositories" +) + +func TestSetupServiceWorkflow(t *testing.T) { + db := repositoriesTestDB(t) + service := NewSetupService(db) + ctx := context.Background() + + complete, err := service.IsSetupComplete(ctx) + if err != nil { + t.Fatalf("is setup complete: %v", err) + } + if complete { + t.Fatal("expected setup to be incomplete") + } + + if err := service.EnsureDefaultSettings(ctx); err != nil { + t.Fatalf("ensure default settings with no admin: %v", err) + } + + adminRepo := repositories.NewAdminRepository(db) + if _, err := adminRepo.Create(ctx, models.Admin{ + Name: "Existing Admin", + Email: "existing@example.com", + PasswordHash: "hash", + CreatedAt: time.Unix(1_700_000_200, 0).UTC(), + }); err != nil { + t.Fatalf("create admin: %v", err) + } + + if err := service.EnsureDefaultSettings(ctx); err != nil { + t.Fatalf("ensure default settings with admin: %v", err) + } + + settingsRepo := repositories.NewSettingRepository(db) + current, err := settingsRepo.GetCurrent(ctx) + if err != nil { + t.Fatalf("get current settings: %v", err) + } + if current.AppName != "GoAppMon" { + t.Fatalf("unexpected default app name: %s", current.AppName) + } + + // A separate database validates the full initial setup flow without + // interference from the direct admin insert above. + db2 := repositoriesTestDB(t) + service2 := NewSetupService(db2) + if err := service2.CreateInitialSetup(ctx, "First Admin", "first@example.com", "secret123", "Control Center"); err != nil { + t.Fatalf("create initial setup: %v", err) + } + + complete, err = service2.IsSetupComplete(ctx) + if err != nil { + t.Fatalf("is setup complete after setup: %v", err) + } + if !complete { + t.Fatal("expected setup to be complete") + } + + adminRepo2 := repositories.NewAdminRepository(db2) + admin, err := adminRepo2.GetByEmail(ctx, "first@example.com") + if err != nil { + t.Fatalf("get first admin: %v", err) + } + if admin.Name != "First Admin" { + t.Fatalf("unexpected first admin: %+v", admin) + } + + settingsRepo2 := repositories.NewSettingRepository(db2) + current2, err := settingsRepo2.GetCurrent(ctx) + if err != nil { + t.Fatalf("get setup settings: %v", err) + } + if current2.AppName != "Control Center" { + t.Fatalf("unexpected setup app name: %s", current2.AppName) + } + + if err := service2.CreateInitialSetup(ctx, "Second", "second@example.com", "secret123", "Other"); err != ErrSetupAlreadyComplete { + t.Fatalf("expected ErrSetupAlreadyComplete, got %v", err) + } +} diff --git a/internal/services/status_service.go b/internal/services/status_service.go new file mode 100644 index 0000000..4f20fe8 --- /dev/null +++ b/internal/services/status_service.go @@ -0,0 +1,89 @@ +package services + +import ( + "context" + + "github.com/phyowaiyan-dev/goappmon/internal/models" + "github.com/phyowaiyan-dev/goappmon/internal/repositories" +) + +type StatusService struct { + settings *repositories.SettingRepository + flags *repositories.FeatureFlagRepository +} + +type PublicStatus struct { + MaintenanceMode bool `json:"maintenance_mode"` + MaintenanceMessage string `json:"maintenance_message"` + BannerEnabled bool `json:"banner_enabled"` + BannerMessage string `json:"banner_message"` +} + +type PublicVersion struct { + Android struct { + LatestVersion string `json:"latest_version"` + MinimumVersion string `json:"minimum_version"` + ForceUpdate bool `json:"force_update"` + } `json:"android"` + IOS struct { + LatestVersion string `json:"latest_version"` + MinimumVersion string `json:"minimum_version"` + ForceUpdate bool `json:"force_update"` + } `json:"ios"` +} + +type PublicConfig struct { + AppName string `json:"app_name"` + APIURL string `json:"api_url"` +} + +func NewStatusService(settings *repositories.SettingRepository, flags *repositories.FeatureFlagRepository) *StatusService { + return &StatusService{settings: settings, flags: flags} +} + +func (s *StatusService) CurrentSettings(ctx context.Context) (*models.Setting, error) { + return s.settings.GetCurrent(ctx) +} + +func (s *StatusService) PublicStatus(ctx context.Context) (PublicStatus, error) { + setting, err := s.settings.GetCurrent(ctx) + if err != nil { + return PublicStatus{}, err + } + return PublicStatus{ + MaintenanceMode: setting.MaintenanceMode, + MaintenanceMessage: setting.MaintenanceMessage, + BannerEnabled: setting.BannerEnabled, + BannerMessage: setting.BannerMessage, + }, nil +} + +func (s *StatusService) PublicVersion(ctx context.Context) (PublicVersion, error) { + setting, err := s.settings.GetCurrent(ctx) + if err != nil { + return PublicVersion{}, err + } + var version PublicVersion + version.Android.LatestVersion = setting.AndroidLatestVersion + version.Android.MinimumVersion = setting.AndroidMinVersion + version.Android.ForceUpdate = setting.AndroidForceUpdate + version.IOS.LatestVersion = setting.IOSLatestVersion + version.IOS.MinimumVersion = setting.IOSMinVersion + version.IOS.ForceUpdate = setting.IOSForceUpdate + return version, nil +} + +func (s *StatusService) PublicConfig(ctx context.Context) (PublicConfig, error) { + setting, err := s.settings.GetCurrent(ctx) + if err != nil { + return PublicConfig{}, err + } + return PublicConfig{ + AppName: setting.AppName, + APIURL: setting.APIURL, + }, nil +} + +func (s *StatusService) PublicFeatureFlags(ctx context.Context) (map[string]bool, error) { + return s.flags.AsMap(ctx) +} diff --git a/internal/services/status_service_test.go b/internal/services/status_service_test.go new file mode 100644 index 0000000..0e3e0f4 --- /dev/null +++ b/internal/services/status_service_test.go @@ -0,0 +1,78 @@ +package services + +import ( + "context" + "testing" + "time" + + "github.com/phyowaiyan-dev/goappmon/internal/models" + "github.com/phyowaiyan-dev/goappmon/internal/repositories" +) + +func TestStatusServicePublicViews(t *testing.T) { + db := repositoriesTestDB(t) + ctx := context.Background() + settingsRepo := repositories.NewSettingRepository(db) + flagsRepo := repositories.NewFeatureFlagRepository(db) + + now := time.Unix(1_700_000_300, 0).UTC() + if _, err := settingsRepo.Create(ctx, models.Setting{ + AndroidEnabled: true, + AppName: "GoAppMon", + AndroidLatestVersion: "1.2.0", + AndroidMinVersion: "1.0.0", + AndroidForceUpdate: true, + IOSEnabled: true, + IOSLatestVersion: "1.1.0", + IOSMinVersion: "1.0.0", + IOSForceUpdate: false, + MaintenanceMode: true, + MaintenanceMessage: "maintenance", + BannerEnabled: true, + BannerMessage: "banner", + APIURL: "https://api.example.com", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("create settings: %v", err) + } + if _, err := flagsRepo.Create(ctx, "chat", true); err != nil { + t.Fatalf("create feature flag: %v", err) + } + if _, err := flagsRepo.Create(ctx, "payment", false); err != nil { + t.Fatalf("create feature flag: %v", err) + } + + service := NewStatusService(settingsRepo, flagsRepo) + status, err := service.PublicStatus(ctx) + if err != nil { + t.Fatalf("public status: %v", err) + } + if !status.MaintenanceMode || !status.BannerEnabled { + t.Fatalf("unexpected status: %+v", status) + } + + version, err := service.PublicVersion(ctx) + if err != nil { + t.Fatalf("public version: %v", err) + } + if !version.Android.ForceUpdate || version.Android.LatestVersion != "1.2.0" { + t.Fatalf("unexpected version: %+v", version) + } + + cfg, err := service.PublicConfig(ctx) + if err != nil { + t.Fatalf("public config: %v", err) + } + if cfg.AppName != "GoAppMon" || cfg.APIURL != "https://api.example.com" { + t.Fatalf("unexpected config: %+v", cfg) + } + + flagMap, err := service.PublicFeatureFlags(ctx) + if err != nil { + t.Fatalf("public feature flags: %v", err) + } + if !flagMap["chat"] || flagMap["payment"] { + t.Fatalf("unexpected feature map: %#v", flagMap) + } +} diff --git a/internal/services/test_helpers_test.go b/internal/services/test_helpers_test.go new file mode 100644 index 0000000..f9fc8c4 --- /dev/null +++ b/internal/services/test_helpers_test.go @@ -0,0 +1,36 @@ +package services + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + + "github.com/phyowaiyan-dev/goappmon/internal/database" + "github.com/phyowaiyan-dev/goappmon/internal/models" +) + +func repositoriesTestDB(t *testing.T) *sql.DB { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "test.sqlite") + db, err := database.Open(dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := database.Migrate(context.Background(), db); err != nil { + t.Fatalf("migrate db: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + return db +} + +func adminRow(name, email, hash string) models.Admin { + return models.Admin{ + Name: name, + Email: email, + PasswordHash: hash, + } +} diff --git a/internal/utils/password.go b/internal/utils/password.go new file mode 100644 index 0000000..9b15df6 --- /dev/null +++ b/internal/utils/password.go @@ -0,0 +1,15 @@ +package utils + +import "golang.org/x/crypto/bcrypt" + +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +func CheckPassword(hash, password string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} diff --git a/internal/utils/password_test.go b/internal/utils/password_test.go new file mode 100644 index 0000000..06df87d --- /dev/null +++ b/internal/utils/password_test.go @@ -0,0 +1,16 @@ +package utils + +import "testing" + +func TestHashPasswordAndCheckPassword(t *testing.T) { + hash, err := HashPassword("secret123") + if err != nil { + t.Fatalf("hash password: %v", err) + } + if err := CheckPassword(hash, "secret123"); err != nil { + t.Fatalf("check password: %v", err) + } + if err := CheckPassword(hash, "wrong"); err == nil { + t.Fatal("expected wrong password to fail") + } +} diff --git a/internal/utils/response.go b/internal/utils/response.go new file mode 100644 index 0000000..1f77428 --- /dev/null +++ b/internal/utils/response.go @@ -0,0 +1,15 @@ +package utils + +import "github.com/gin-gonic/gin" + +type ErrorResponse struct { + Error string `json:"error"` +} + +func JSON(c *gin.Context, status int, payload any) { + c.JSON(status, payload) +} + +func JSONError(c *gin.Context, status int, message string) { + c.AbortWithStatusJSON(status, ErrorResponse{Error: message}) +} diff --git a/internal/utils/response_test.go b/internal/utils/response_test.go new file mode 100644 index 0000000..626c065 --- /dev/null +++ b/internal/utils/response_test.go @@ -0,0 +1,34 @@ +package utils + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestJSONHelpers(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("json", func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + JSON(c, http.StatusCreated, gin.H{"status": "ok"}) + if rec.Code != http.StatusCreated { + t.Fatalf("unexpected code: %d", rec.Code) + } + if rec.Body.String() == "" { + t.Fatal("expected body") + } + }) + + t.Run("json error", func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + JSONError(c, http.StatusBadRequest, "bad request") + if rec.Code != http.StatusBadRequest { + t.Fatalf("unexpected code: %d", rec.Code) + } + }) +} diff --git a/internal/utils/session.go b/internal/utils/session.go new file mode 100644 index 0000000..c5ba53a --- /dev/null +++ b/internal/utils/session.go @@ -0,0 +1,94 @@ +package utils + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "time" +) + +type SessionClaims struct { + AdminID int64 `json:"admin_id"` + ExpiresAt int64 `json:"expires_at"` +} + +func SignSession(secret []byte, adminID int64, ttl time.Duration) (string, error) { + claims := SessionClaims{ + AdminID: adminID, + ExpiresAt: time.Now().Add(ttl).UTC().Unix(), + } + payload, err := json.Marshal(claims) + if err != nil { + return "", err + } + encodedPayload := base64.RawURLEncoding.EncodeToString(payload) + mac := hmac.New(sha256.New, secret) + _, _ = mac.Write([]byte(encodedPayload)) + signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return encodedPayload + "." + signature, nil +} + +func VerifySession(secret []byte, token string) (SessionClaims, error) { + parts := splitToken(token) + if len(parts) != 2 { + return SessionClaims{}, errors.New("invalid session token") + } + + expectedMAC := hmac.New(sha256.New, secret) + _, _ = expectedMAC.Write([]byte(parts[0])) + expectedSignature := expectedMAC.Sum(nil) + + signature, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return SessionClaims{}, errors.New("invalid session signature") + } + if !hmac.Equal(signature, expectedSignature) { + return SessionClaims{}, errors.New("invalid session signature") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return SessionClaims{}, errors.New("invalid session payload") + } + + var claims SessionClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return SessionClaims{}, err + } + if time.Now().UTC().Unix() > claims.ExpiresAt { + return SessionClaims{}, errors.New("session expired") + } + return claims, nil +} + +func GenerateSecretKey(size int) ([]byte, error) { + if size < 32 { + size = 32 + } + key := make([]byte, size) + if _, err := rand.Read(key); err != nil { + return nil, err + } + return key, nil +} + +func splitToken(token string) []string { + var parts []string + start := 0 + for i := 0; i < len(token); i++ { + if token[i] == '.' { + parts = append(parts, token[start:i]) + start = i + 1 + } + } + parts = append(parts, token[start:]) + return parts +} + +func FormatSessionKey(secret []byte) string { + return fmt.Sprintf("%x", secret) +} diff --git a/internal/utils/session_test.go b/internal/utils/session_test.go new file mode 100644 index 0000000..f900395 --- /dev/null +++ b/internal/utils/session_test.go @@ -0,0 +1,46 @@ +package utils + +import ( + "testing" + "time" +) + +func TestSessionHelpers(t *testing.T) { + key, err := GenerateSecretKey(16) + if err != nil { + t.Fatalf("generate secret key: %v", err) + } + if len(key) != 32 { + t.Fatalf("expected minimum 32-byte key, got %d", len(key)) + } + + if got := FormatSessionKey([]byte{0x01, 0x02, 0x0a}); got != "01020a" { + t.Fatalf("unexpected formatted key: %s", got) + } + + secret := []byte("01234567890123456789012345678901") + token, err := SignSession(secret, 42, time.Minute) + if err != nil { + t.Fatalf("sign session: %v", err) + } + + claims, err := VerifySession(secret, token) + if err != nil { + t.Fatalf("verify session: %v", err) + } + if claims.AdminID != 42 { + t.Fatalf("unexpected admin id: %d", claims.AdminID) + } + + if _, err := VerifySession(secret, token+"tamper"); err == nil { + t.Fatal("expected tampered token to fail") + } + + expired, err := SignSession(secret, 7, -time.Minute) + if err != nil { + t.Fatalf("sign expired session: %v", err) + } + if _, err := VerifySession(secret, expired); err == nil { + t.Fatal("expected expired session to fail") + } +} diff --git a/internal/utils/validation.go b/internal/utils/validation.go new file mode 100644 index 0000000..e35ada9 --- /dev/null +++ b/internal/utils/validation.go @@ -0,0 +1,35 @@ +package utils + +import ( + "errors" + "fmt" + "net/mail" + "strings" +) + +const ( + AdminNameMinLength = 3 + AdminNameMaxLength = 50 +) + +func ValidateAdminName(name string) error { + trimmed := strings.TrimSpace(name) + if len(trimmed) < AdminNameMinLength { + return fmt.Errorf("admin name must be at least %d characters", AdminNameMinLength) + } + if len(trimmed) > AdminNameMaxLength { + return fmt.Errorf("admin name must be at most %d characters", AdminNameMaxLength) + } + return nil +} + +func ValidateEmail(email string) error { + trimmed := strings.TrimSpace(email) + if trimmed == "" { + return errors.New("email is required") + } + if _, err := mail.ParseAddress(trimmed); err != nil { + return errors.New("email format is invalid") + } + return nil +} diff --git a/internal/utils/validation_test.go b/internal/utils/validation_test.go new file mode 100644 index 0000000..0dc7f26 --- /dev/null +++ b/internal/utils/validation_test.go @@ -0,0 +1,27 @@ +package utils + +import "testing" + +func TestValidateAdminName(t *testing.T) { + if err := ValidateAdminName("ab"); err == nil { + t.Fatal("expected short name to fail") + } + if err := ValidateAdminName("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz"); err == nil { + t.Fatal("expected long name to fail") + } + if err := ValidateAdminName("Admin User"); err != nil { + t.Fatalf("expected valid name, got %v", err) + } +} + +func TestValidateEmail(t *testing.T) { + if err := ValidateEmail(""); err == nil { + t.Fatal("expected empty email to fail") + } + if err := ValidateEmail("not-an-email"); err == nil { + t.Fatal("expected invalid email to fail") + } + if err := ValidateEmail("admin@example.com"); err != nil { + t.Fatalf("expected valid email, got %v", err) + } +} diff --git a/internal/utils/version.go b/internal/utils/version.go new file mode 100644 index 0000000..8b27b63 --- /dev/null +++ b/internal/utils/version.go @@ -0,0 +1,72 @@ +package utils + +import ( + "errors" + "fmt" + "regexp" + "strconv" +) + +var versionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`) + +type SemanticVersion struct { + Major int + Minor int + Patch int +} + +func ParseSemanticVersion(value string) (SemanticVersion, error) { + if !versionPattern.MatchString(value) { + return SemanticVersion{}, errors.New("version must use format major.minor.patch") + } + + parts := make([]int, 0, 3) + start := 0 + for i := 0; i <= len(value); i++ { + if i == len(value) || value[i] == '.' { + part, err := strconv.Atoi(value[start:i]) + if err != nil { + return SemanticVersion{}, fmt.Errorf("invalid version number: %w", err) + } + parts = append(parts, part) + start = i + 1 + } + } + + return SemanticVersion{ + Major: parts[0], + Minor: parts[1], + Patch: parts[2], + }, nil +} + +func CompareSemanticVersion(a, b string) (int, error) { + left, err := ParseSemanticVersion(a) + if err != nil { + return 0, err + } + right, err := ParseSemanticVersion(b) + if err != nil { + return 0, err + } + + switch { + case left.Major != right.Major: + if left.Major < right.Major { + return -1, nil + } + return 1, nil + case left.Minor != right.Minor: + if left.Minor < right.Minor { + return -1, nil + } + return 1, nil + case left.Patch != right.Patch: + if left.Patch < right.Patch { + return -1, nil + } + return 1, nil + default: + return 0, nil + } +} diff --git a/internal/utils/version_test.go b/internal/utils/version_test.go new file mode 100644 index 0000000..9627d4d --- /dev/null +++ b/internal/utils/version_test.go @@ -0,0 +1,39 @@ +package utils + +import "testing" + +func TestCompareSemanticVersion(t *testing.T) { + tests := []struct { + name string + a string + b string + want int + }{ + {name: "equal", a: "1.0.0", b: "1.0.0", want: 0}, + {name: "greater major", a: "2.0.0", b: "1.9.9", want: 1}, + {name: "greater minor", a: "1.2.0", b: "1.1.9", want: 1}, + {name: "greater patch", a: "1.2.3", b: "1.2.2", want: 1}, + {name: "less", a: "1.2.2", b: "1.2.3", want: -1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := CompareSemanticVersion(tt.a, tt.b) + if err != nil { + t.Fatalf("compare version: %v", err) + } + if got != tt.want { + t.Fatalf("compare version = %d, want %d", got, tt.want) + } + }) + } +} + +func TestParseSemanticVersion(t *testing.T) { + if _, err := ParseSemanticVersion("1.2"); err == nil { + t.Fatal("expected invalid version format") + } + if _, err := ParseSemanticVersion("1.2.3"); err != nil { + t.Fatalf("parse semantic version: %v", err) + } +} diff --git a/storage/.gitkeep b/storage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/build-errors.log b/tmp/build-errors.log new file mode 100644 index 0000000..c38c619 --- /dev/null +++ b/tmp/build-errors.log @@ -0,0 +1 @@ +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/tmp/main b/tmp/main new file mode 100755 index 0000000..10c6ede Binary files /dev/null and b/tmp/main differ diff --git a/web/postman.go b/web/postman.go new file mode 100644 index 0000000..6e24ee2 --- /dev/null +++ b/web/postman.go @@ -0,0 +1,8 @@ +package web + +import "embed" + +// PostmanFS embeds sample API collections into the binary. +// +//go:embed postman/*.json +var PostmanFS embed.FS diff --git a/web/postman/GoAppMon.postman_collection.json b/web/postman/GoAppMon.postman_collection.json new file mode 100644 index 0000000..8c2c807 --- /dev/null +++ b/web/postman/GoAppMon.postman_collection.json @@ -0,0 +1,145 @@ +{ + "info": { + "name": "GoAppMon API", + "description": "Sample Postman collection for GoAppMon public and admin API workflows.", + "_postman_id": "c4c1f6f0-8a68-4d8f-a5f6-6d4e9f1c9a01", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "base_url", + "value": "http://localhost:18180" + }, + { + "key": "admin_email", + "value": "admin@example.com" + }, + { + "key": "admin_password", + "value": "secret123" + } + ], + "item": [ + { + "name": "Health", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/health", + "host": [ + "{{base_url}}" + ], + "path": [ + "health" + ] + } + } + }, + { + "name": "Status", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/status", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "status" + ] + } + } + }, + { + "name": "Version", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/version", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "version" + ] + } + } + }, + { + "name": "Config", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/config", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "config" + ] + } + } + }, + { + "name": "Feature Flags", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/feature-flags", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "feature-flags" + ] + } + } + }, + { + "name": "Admin Login", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "email", + "value": "{{admin_email}}", + "type": "text" + }, + { + "key": "password", + "value": "{{admin_password}}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{base_url}}/admin/login", + "host": [ + "{{base_url}}" + ], + "path": [ + "admin", + "login" + ] + } + } + } + ] +} diff --git a/web/templates.go b/web/templates.go new file mode 100644 index 0000000..794a4d7 --- /dev/null +++ b/web/templates.go @@ -0,0 +1,8 @@ +package web + +import "embed" + +// TemplatesFS embeds the admin UI templates into the binary. +// +//go:embed templates/**/*.html +var TemplatesFS embed.FS diff --git a/web/templates/components/audit_logs_table.html b/web/templates/components/audit_logs_table.html new file mode 100644 index 0000000..7809de9 --- /dev/null +++ b/web/templates/components/audit_logs_table.html @@ -0,0 +1,66 @@ +{{ define "audit-logs-table" }} +
+
+
+ + + + + + + + + + + + {{ range .Logs }} + + + + + + + + {{ else }} + + + + {{ end }} + +
ActionEntityActorTimeDetails
{{ .Action }}{{ .EntityType }} {{ if .EntityID }}#{{ .EntityID }}{{ end }} +
{{ if .ActorName }}{{ .ActorName }}{{ else if .ActorAdminID }}Admin #{{ .ActorAdminID }}{{ else }}System{{ end }}
+
{{ .IP }}
+
{{ humanTime .CreatedAt }} +
+ View payload +
+
+

Before

+
{{ if .BeforeJSON }}{{ .BeforeJSON }}{{ else }}-{{ end }}
+
+
+

After

+
{{ if .AfterJSON }}{{ .AfterJSON }}{{ else }}-{{ end }}
+
+

User agent: {{ .UserAgent }}

+
+
+
No audit logs match the current filters.
+
+
+ +
+

+ Page {{ .Page }} of {{ .TotalPages }}, showing {{ .PageSize }} per page. +

+
+ {{ if .HasPrev }} + Previous + {{ end }} + {{ if .HasNext }} + Next + {{ end }} +
+
+
+{{ end }} diff --git a/web/templates/components/flash.html b/web/templates/components/flash.html new file mode 100644 index 0000000..9216810 --- /dev/null +++ b/web/templates/components/flash.html @@ -0,0 +1,14 @@ +{{ define "flash" }} +
+ {{ if .Notice }} +
+ {{ .Notice }} +
+ {{ end }} + {{ if .Error }} +
+ {{ .Error }} +
+ {{ end }} +
+{{ end }} diff --git a/web/templates/components/system_health_panel.html b/web/templates/components/system_health_panel.html new file mode 100644 index 0000000..9a0bbcc --- /dev/null +++ b/web/templates/components/system_health_panel.html @@ -0,0 +1,174 @@ +{{ define "system-health-panel" }} +
+
+
+
+
+ System health + Last refreshed {{ humanTime .SystemHealth.UpdatedAt }} +
+

Server and runtime overview

+

Track host CPU, memory, disk pressure, app runtime stats, and SQLite storage in one dashboard.

+
+
+
+
+ + Health score +
+ +
+
{{ .SystemHealth.Score }}
+
{{ .SystemHealth.Status }}
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + CPU +
+
{{ printf "%.1f" .SystemHealth.CPUUsagePercent }}%
+
{{ .SystemHealth.NumCPU }} cores · system-wide load
+
+
+
+
+
+
+ +
+
+
+
+ + + + + Memory +
+
{{ printf "%.1f" .SystemHealth.MemoryUsedPercent }}%
+
{{ printf "%.1f" .SystemHealth.MemoryUsedMB }} MB used of {{ printf "%.1f" .SystemHealth.MemoryTotalMB }} MB
+
+ Host +
+
+
+
+
Free: {{ printf "%.1f" .SystemHealth.MemoryFreeMB }} MB
+
+ +
+
+
+
+ + + + + Disk +
+
{{ printf "%.1f" .SystemHealth.DiskUsedPercent }}%
+
{{ printf "%.1f" .SystemHealth.DiskUsedGB }} GB used of {{ printf "%.1f" .SystemHealth.DiskTotalGB }} GB
+
+ Storage +
+
+
+
+
{{ .SystemHealth.SQLiteFilePath }}
+
+
+ +
+
+
+ + + +
+
Process uptime
+
{{ .SystemHealth.Uptime }}
+
How long GoAppMon has been running
+
+
+
+ + + +
+
System uptime
+
{{ if .SystemHealth.SystemUptime }}{{ .SystemHealth.SystemUptime }}{{ else }}unavailable{{ end }}
+
How long the host machine has been up
+
+
+
+ + + +
+
App memory
+
{{ printf "%.1f" .SystemHealth.AllocMB }} MB
+
Runtime sys: {{ printf "%.1f" .SystemHealth.SysMB }} MB
+
+
+
+ + + +
+
Goroutines
+
{{ .SystemHealth.NumGoroutine }}
+
Go {{ .SystemHealth.GoVersion }}
+
+
+ +
+
+
SQLite storage
+
{{ .SystemHealth.SQLiteFileSize }}
+
{{ .SystemHealth.SQLiteFilePath }}
+
+
+
Health style
+
+ {{ .SystemHealth.Status }} +
+
Score reflects host pressure across CPU, memory, disk, and runtime signals.
+
+
+
+
Last refresh
+ Manual +
+
{{ humanTime .SystemHealth.UpdatedAt }}
+
Click refresh to fetch the latest machine snapshot.
+
+
+ + {{ if .SystemHealth.HealthNotes }} +
+
Signals
+
+ {{ range .SystemHealth.HealthNotes }} + {{ . }} + {{ end }} +
+
+ {{ end }} +
+
+{{ end }} diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html new file mode 100644 index 0000000..053250b --- /dev/null +++ b/web/templates/layouts/base.html @@ -0,0 +1,131 @@ +{{ define "base" }} + + + + + + {{ if .Title }}{{ .Title }} · {{ appName }}{{ else }}{{ appName }}{{ end }} + + + + + + +
+ + + {{ template "flash" . }} + +
+ {{ template "content" . }} +
+
+ + + + + + + + +{{ end }} diff --git a/web/templates/pages/audit_logs.html b/web/templates/pages/audit_logs.html new file mode 100644 index 0000000..f426800 --- /dev/null +++ b/web/templates/pages/audit_logs.html @@ -0,0 +1,45 @@ +{{ define "content" }} +
+
+
+ Back +
+

Logs

+

Audit log

+

+ Search recent admin actions. Keep it simple and quick. +

+
+
+
+
Total logs
+
{{ .Total }}
+
+
+ +
+
+ + +
+ +
+ + Reset +
+
+ + {{ template "audit-logs-table" . }} +
+{{ end }} diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html new file mode 100644 index 0000000..e5cd983 --- /dev/null +++ b/web/templates/pages/dashboard.html @@ -0,0 +1,522 @@ +{{ define "content" }} +
+
+
+
+

Application

+ {{ if .CurrentAdmin }} +
Signed in as {{ .CurrentAdmin.Name }}
+ {{ end }} +
+
+ + +
+ +
+
+
+
+ +
+
+
+
+

Releases

+

Android and iOS versions

+

Keep version history and platform toggles in one place.

+
+
+
Platform toggles
+ + +
+ +
+
+
+ +
+
+
+
+

Android

+

Current version

+
+
+ {{ if .Settings.AndroidEnabled }} + Enabled + {{ else }} + Disabled + {{ end }} + {{ if gt (len .AndroidReleases) 1 }} +
+ +
+ {{ end }} +
+
+
+
+
Latest
+
{{ .Settings.AndroidLatestVersion }}
+
Most recent Android release
+
+
+
Minimum
+
{{ .Settings.AndroidMinVersion }}
+
Lowest supported Android release
+
+
+
Force update
+ {{ if .Settings.AndroidForceUpdate }} +
Enabled
+ {{ else }} +
Disabled
+ {{ end }} +
Users must update before continuing
+
+
+ + {{ if .Settings.AndroidEnabled }} +
+
+ + + +
+ +
+
+ {{ else }} +
+ Android is disabled. Turn it on above to add releases. +
+ {{ end }} + +
+ + + + + + + + + + + {{ range .AndroidReleases }} + + + + + + + {{ else }} + + {{ end }} + +
VersionMinimumStatusAdded
{{ .LatestVersion }}{{ .MinimumVersion }} + {{ if .ForceUpdate }} + Force update + {{ else }} + Optional + {{ end }} + {{ humanTime .CreatedAt }}
No Android release history yet.
+
+
+ +
+
+
+

iOS

+

Current version

+
+
+ {{ if .Settings.IOSEnabled }} + Enabled + {{ else }} + Disabled + {{ end }} + {{ if gt (len .IOSReleases) 1 }} +
+ +
+ {{ end }} +
+
+
+
+
Latest
+
{{ .Settings.IOSLatestVersion }}
+
Most recent iOS release
+
+
+
Minimum
+
{{ .Settings.IOSMinVersion }}
+
Lowest supported iOS release
+
+
+
Force update
+ {{ if .Settings.IOSForceUpdate }} +
Enabled
+ {{ else }} +
Disabled
+ {{ end }} +
Users must update before continuing
+
+
+ + {{ if .Settings.IOSEnabled }} +
+
+ + + +
+ +
+
+ {{ else }} +
+ iOS is disabled. Turn it on above to add releases. +
+ {{ end }} + +
+ + + + + + + + + + + {{ range .IOSReleases }} + + + + + + + {{ else }} + + {{ end }} + +
VersionMinimumStatusAdded
{{ .LatestVersion }}{{ .MinimumVersion }} + {{ if .ForceUpdate }} + Force update + {{ else }} + Optional + {{ end }} + {{ humanTime .CreatedAt }}
No iOS release history yet.
+
+
+
+
+
+ +
+
+

Maintenance

+
+ + +
+ +
+
+
+ +
+ + + + + + + + + + {{ range .MaintenanceHistory }} + + + + + + {{ else }} + + {{ end }} + +
StateMessageAdded
+ {{ if .Enabled }} + Enabled + {{ else }} + Disabled + {{ end }} + {{ if .Message }}{{ .Message }}{{ else }}{{ end }}{{ humanTime .CreatedAt }}
No maintenance history yet.
+
+
+
+ +
+
+

Banner

+
+ + +
+ +
+
+
+ +
+ + + + + + + + + + {{ range .BannerHistory }} + + + + + + {{ else }} + + {{ end }} + +
StateMessageAdded
+ {{ if .Enabled }} + Enabled + {{ else }} + Disabled + {{ end }} + {{ if .Message }}{{ .Message }}{{ else }}{{ end }}{{ humanTime .CreatedAt }}
No banner history yet.
+
+
+
+ +
+
+
+
+

Feature flags

+

Create and manage feature switches for clients.

+
+
+ +
+ +
+ +
+
+ +
+ + + + + + + + + + {{ range .Flags }} + + + + + + {{ else }} + + {{ end }} + +
KeyStatusActions
+
+ +
+ + +
+ + +
+ +
+
+
No feature flags yet.
+
+
+
+ + {{ template "system-health-panel" . }} + +
+
+
+
+

Audit log

+

Recent system changes with actor, entity, and request data.

+
+ See All logs +
+ +
+ + + + + + + + + + + {{ range .AuditLogs }} + + + + + + + {{ else }} + + {{ end }} + +
ActionEntityActorTime
{{ .Action }}{{ .EntityType }} {{ if .EntityID }}#{{ .EntityID }}{{ end }} +
{{ if .ActorAdminID }}Admin #{{ .ActorAdminID }}{{ else }}System{{ end }}
+
{{ .IP }}
+
{{ humanTime .CreatedAt }}
No audit log entries yet.
+
+
+
+
+ +{{ end }} diff --git a/web/templates/pages/login.html b/web/templates/pages/login.html new file mode 100644 index 0000000..138964a --- /dev/null +++ b/web/templates/pages/login.html @@ -0,0 +1,53 @@ +{{ define "content" }} +
+
+
+
+

Admin login

+

Sign in

+

Use your admin email and password.

+
+ +
+ + + +
+
+
+
+ +{{ end }} diff --git a/web/templates/pages/setup.html b/web/templates/pages/setup.html new file mode 100644 index 0000000..debfc42 --- /dev/null +++ b/web/templates/pages/setup.html @@ -0,0 +1,67 @@ +{{ define "content" }} +
+
+
+
+

First-time setup

+

Create admin account

+

This creates the first admin user and the initial app settings.

+
+ +
+
+ + +
+ +
+ + +
+ + +
+
+
+
+ +{{ end }}