Compare commits
22 Commits
v1.1.0
...
feature/13
| Author | SHA1 | Date | |
|---|---|---|---|
|
0442e4f699
|
|||
|
96f1a40266
|
|||
|
959ba7d2d1
|
|||
|
c579fc95be
|
|||
|
d70aa85f99
|
|||
|
e5276053f2
|
|||
|
9f9386eba8
|
|||
|
1ff7d6b776
|
|||
|
9fd789bb6a
|
|||
|
043d4c0d5e
|
|||
|
59ba5a00e1
|
|||
|
50da145feb
|
|||
|
0b5943f792
|
|||
|
ba31c4f582
|
|||
|
370b875df1
|
|||
|
9393004434
|
|||
|
aec68c3ea5
|
|||
|
baad115f01
|
|||
|
09e1b2bcdc
|
|||
|
2ffe17ca60
|
|||
|
3596998f28
|
|||
|
2dc854c65a
|
@@ -6,31 +6,38 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: docker-builder
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Enable QEMU for multi-arch builds
|
||||||
uses: actions/setup-qemu-action@v3
|
run: |
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt --install all
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Create and use Docker Buildx builder
|
||||||
uses: actions/setup-buildx-action@v3
|
run: |
|
||||||
|
docker buildx create --name miauinv-builder --use || docker buildx use miauinv-builder
|
||||||
|
docker buildx inspect --bootstrap
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
run: |
|
||||||
with:
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.miaurizius.de \
|
||||||
registry: git.miaurizius.de
|
--username "${{ secrets.REGISTRY_USER }}" \
|
||||||
username: ${{ gitea.actor }}
|
--password-stdin
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Prepare Docker image tags
|
||||||
uses: docker/build-push-action@v6
|
run: |
|
||||||
with:
|
IMAGE_REPO="$(echo "git.miaurizius.de/${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
|
||||||
context: .
|
echo "IMAGE_REPO=$IMAGE_REPO" >> "$GITHUB_ENV"
|
||||||
file: ./Dockerfile
|
|
||||||
push: true
|
- name: Build and push multi-arch Docker image
|
||||||
platforms: linux/amd64,linux/arm64
|
run: |
|
||||||
tags: |
|
docker buildx build \
|
||||||
git.miaurizius.de/${{ gitea.repository }}:latest
|
--platform linux/amd64,linux/arm64 \
|
||||||
git.miaurizius.de/${{ gitea.repository }}:${{ gitea.event.release.tag_name }}
|
--file ./Dockerfile \
|
||||||
|
--tag "$IMAGE_REPO:latest" \
|
||||||
|
--tag "$IMAGE_REPO:${{ gitea.event.release.tag_name }}" \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
248
README.md
248
README.md
@@ -1,81 +1,25 @@
|
|||||||
# MiauInv
|
# MiauInv
|
||||||
|
|
||||||
MiauInv is a lightweight inventory, stock, and project allocation management system written in Go. It provides a server-rendered dashboard with HTMX-style page composition, vanilla JavaScript for API interactions, SQLite for persistence, JWT-based sessions, refresh-token rotation, account settings, and optional TOTP-based two-factor authentication and WebAuthn passkey authentication.
|
MiauInv is a secure, lightweight inventory, stock, and project allocation tracking system written in Go. It uses server-rendered HTML blocks with a small HTMX-style frontend layer for a dynamic single-page feel, backed by signed JWT access tokens, database-backed refresh-token rotation, optional TOTP 2FA, optional WebAuthn passkeys, an authenticated activity log, and embedded SQLite storage.
|
||||||
|
|
||||||
The project is designed for self-hosted/private deployments. It is not a full enterprise asset-management platform, but it already covers the main workflows needed for tracking items, locations, stock distribution, and project allocations.
|
It is built for self-hosted/private deployments where you want a small, understandable asset and stock tracker without running a large enterprise inventory suite.
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
- [Features](#features)
|
|
||||||
- [Current Status](#current-status)
|
|
||||||
- [Technical Stack](#technical-stack)
|
|
||||||
- [Architecture](#architecture)
|
|
||||||
- [Documentation](#documentation)
|
|
||||||
- [Configuration](#configuration)
|
|
||||||
- [Routes and API Endpoints](#routes-and-api-endpoints)
|
|
||||||
- [Setup](#setup)
|
|
||||||
- [Docker Deployment](#docker-deployment)
|
|
||||||
- [Reverse Proxy Deployment](#reverse-proxy-deployment)
|
|
||||||
- [Security Notes](#security-notes)
|
|
||||||
- [Screenshots](#screenshots)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Inventory and allocation
|
- Inventory item tracking with categories, descriptions, and quantities.
|
||||||
|
|
||||||
- Item management with name, category, description, and total quantity.
|
|
||||||
- Location management for physical or logical storage places.
|
- Location management for physical or logical storage places.
|
||||||
- Stock mapping between items and locations.
|
- Stock distribution between items and locations.
|
||||||
- Project management for allocating items to projects.
|
- Project allocation tracking for reserving item quantities for projects.
|
||||||
- Association tracking between projects and item quantities.
|
- Dashboard overview for items, locations, and projects.
|
||||||
- Dashboard statistics for items, locations, and projects.
|
- Account settings for username, password, 2FA, and passkey management.
|
||||||
|
- Optional TOTP two-factor authentication with QR setup and recovery codes.
|
||||||
### Account and authentication
|
- Optional discoverable WebAuthn passkey login.
|
||||||
|
- Activity log for account, authentication, security, and inventory changes.
|
||||||
- User registration, if enabled in the configuration.
|
- Docker-ready deployment with SQLite persistence.
|
||||||
- Password hashing with bcrypt.
|
|
||||||
- Signed JWT access tokens.
|
|
||||||
- Database-backed refresh tokens with rotation.
|
|
||||||
- HTTP-only secure cookies for access and refresh tokens.
|
|
||||||
- Account settings page at `/profile/settings`.
|
|
||||||
- Passkey registration, login, removal, and full passkey disable from account settings.
|
|
||||||
- Username change with password confirmation.
|
|
||||||
- Password change with old-password verification and session refresh.
|
|
||||||
|
|
||||||
### Two-factor authentication
|
|
||||||
|
|
||||||
- Optional TOTP 2FA using authenticator apps.
|
|
||||||
- QR-code based setup from the account settings page.
|
|
||||||
- Manual setup key fallback if QR scanning is not available.
|
|
||||||
- Two-step login flow for accounts with 2FA enabled.
|
|
||||||
- Recovery codes generated when 2FA is enabled.
|
|
||||||
- Recovery codes are stored only as hashes.
|
|
||||||
- Recovery codes can be downloaded as a text file after generation.
|
|
||||||
- Recovery codes can be regenerated from account settings.
|
|
||||||
- Recovery-code count warnings in account settings.
|
|
||||||
- Recovery codes are one-time use.
|
|
||||||
- The setup secret is only stored after the first valid authenticator code.
|
|
||||||
- Existing refresh sessions are revoked when 2FA is enabled or disabled.
|
|
||||||
|
|
||||||
### Passkeys
|
|
||||||
|
|
||||||
- WebAuthn-based passkey registration from account settings.
|
|
||||||
- Passkey login from the normal sign-in page.
|
|
||||||
- Discoverable passkey login without entering a username first.
|
|
||||||
- User-verification required for registration and login.
|
|
||||||
- Server-side challenge storage using opaque one-time challenge tokens.
|
|
||||||
- Passkey removal with current-password confirmation.
|
|
||||||
- Full passkey disable with current-password confirmation.
|
|
||||||
- Existing refresh sessions are revoked when passkeys are added, removed, or disabled.
|
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
MiauInv is an active private project. The current version supports core inventory workflows and account-level security settings. Some areas are intentionally still basic:
|
MiauInv is an active private project. Core inventory workflows, authentication, account security settings, passkeys, and activity logging are implemented. Automated testing is currently limited and should be expanded before treating the project as high-assurance software.
|
||||||
|
|
||||||
- There is no dedicated admin panel yet.
|
|
||||||
- Basic in-memory rate limiting protects login, 2FA, refresh, registration, and sensitive account endpoints.
|
|
||||||
- Automated testing is currently limited and will be expanded in future releases.
|
|
||||||
- The application currently uses native TLS. If deployed behind a reverse proxy, the proxy must connect to the backend over HTTPS or the backend TLS behavior must be adjusted intentionally.
|
|
||||||
|
|
||||||
## Technical Stack
|
## Technical Stack
|
||||||
|
|
||||||
@@ -89,149 +33,39 @@ MiauInv is an active private project. The current version supports core inventor
|
|||||||
| 2FA | TOTP via `github.com/pquerna/otp/totp` |
|
| 2FA | TOTP via `github.com/pquerna/otp/totp` |
|
||||||
| Passkeys | WebAuthn via `github.com/go-webauthn/webauthn` |
|
| Passkeys | WebAuthn via `github.com/go-webauthn/webauthn` |
|
||||||
| Frontend | Server-rendered HTML, HTMX-style structure, vanilla JavaScript |
|
| Frontend | Server-rendered HTML, HTMX-style structure, vanilla JavaScript |
|
||||||
| Styling | Custom CSS with dark theme variables |
|
|
||||||
| Deployment | Docker / Docker Compose |
|
| Deployment | Docker / Docker Compose |
|
||||||
|
|
||||||
## Architecture
|
## Project Structure
|
||||||
|
|
||||||
The codebase is split into small packages with mostly direct responsibilities:
|
| Path | Purpose |
|
||||||
|
|
||||||
| Path | Responsibility |
|
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `main.go` | Application entrypoint. Initializes configuration, database, and server startup. |
|
| `main.go` | Application entrypoint. |
|
||||||
| `server/` | HTTP route registration, TLS listener, and server-level configuration. |
|
| `server/` | Route registration, TLS listener, and rate limiting. |
|
||||||
| `config/` | Runtime configuration file creation and loading. |
|
| `config/` | Runtime configuration creation and loading. |
|
||||||
| `auth/` | JWT generation, JWT validation, middleware, role middleware, and password helpers. |
|
| `auth/` | JWT, middleware, roles, and password helpers. |
|
||||||
| `handlers/` | JSON API handlers for authentication, account settings, inventory, locations, projects, stock, and associations. |
|
| `handlers/` | JSON API handlers for auth, activity, account security, and inventory. |
|
||||||
| `storage/` | SQLite schema setup, migrations, and database access helpers. |
|
| `storage/` | SQLite schema, migrations, and database access helpers. |
|
||||||
| `models/` | Shared data structures and constants. |
|
| `models/` | Shared data structures and constants. |
|
||||||
| `frontend/` | HTML template rendering and static asset serving. |
|
| `frontend/` | HTML templates, CSS, and JavaScript. |
|
||||||
| `frontend/assets/js/` | Frontend API client, login flow, token refresh logic, account settings logic, and dashboard actions. |
|
| `docs/` | Technical documentation. |
|
||||||
| `frontend/htmx/` | HTML views and dashboard content templates. |
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
More detailed documentation is available in:
|
|
||||||
|
|
||||||
- [Authentication](docs/AUTHENTICATION.md)
|
- [Authentication](docs/AUTHENTICATION.md)
|
||||||
- [Database](docs/DATABASE.md)
|
- [Database](docs/DATABASE.md)
|
||||||
|
- [API Endpoints](docs/ENDPOINTS.md)
|
||||||
- [Security](docs/SECURITY.md)
|
- [Security](docs/SECURITY.md)
|
||||||
- [Contributing](CONTRIBUTING.md)
|
- [Contributing](CONTRIBUTING.md)
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
MiauInv reads `./appdata/config.yaml`. If the file does not exist, the application creates a default configuration on startup.
|
On first startup, MiauInv creates a config file if none exists. The server also requires a `JWT_SECRET` environment variable with at least 32 characters.
|
||||||
|
|
||||||
```yaml
|
|
||||||
port: "8080"
|
|
||||||
database_path: ./appdata/database.db
|
|
||||||
certificate_path: ./appdata/cert.pem
|
|
||||||
private_key_path: ./appdata/key.pem
|
|
||||||
allow_registration: true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration fields
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
| --- | --- |
|
|
||||||
| `port` | HTTPS listen port. |
|
|
||||||
| `database_path` | SQLite database path. |
|
|
||||||
| `certificate_path` | TLS certificate path. |
|
|
||||||
| `private_key_path` | TLS private key path. |
|
|
||||||
| `allow_registration` | Enables or disables public registration. If false, `/register` renders a blocked-registration page and `/api/register` is not registered. |
|
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
| Variable | Required | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `JWT_SECRET` | Yes | Symmetric signing secret for JWTs. Must be at least 32 characters. |
|
|
||||||
|
|
||||||
Generate a local development secret with:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openssl rand -base64 48
|
export JWT_SECRET="replace-this-with-a-random-secret-of-at-least-32-chars"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Routes and API Endpoints
|
For local development, generate TLS files first:
|
||||||
|
|
||||||
### Frontend routes
|
|
||||||
|
|
||||||
| Route | Method | Auth required | Description |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `/` | `GET` | No | Landing page. |
|
|
||||||
| `/login` | `GET` | No | Login page. Supports password login and the second 2FA step. |
|
|
||||||
| `/register` | `GET` | No | Registration page or blocked-registration page, depending on configuration. |
|
|
||||||
| `/dashboard` | `GET` | Yes | Dashboard overview. |
|
|
||||||
| `/inventory` | `GET` | Yes | Stock and inventory overview. |
|
|
||||||
| `/items` | `GET` | Yes | Item management view. |
|
|
||||||
| `/locations` | `GET` | Yes | Location management view. |
|
|
||||||
| `/projects` | `GET` | Yes | Project management view. |
|
|
||||||
| `/profile/settings` | `GET` | Yes | Account settings, password changes, passkey management, 2FA setup, 2FA disable, and recovery-code management. |
|
|
||||||
| `/profile/` | `GET` | No | Placeholder page for unfinished profile subpages. |
|
|
||||||
| `/assets/*` | `GET` | No | Static CSS/JS assets. Minified CSS/JS variants are generated on request. |
|
|
||||||
|
|
||||||
### Authentication and account API
|
|
||||||
|
|
||||||
| Endpoint | Method | Auth required | Description |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `/api/register` | `POST` | No | Creates a user if registration is enabled. |
|
|
||||||
| `/api/login` | `POST` | No | Validates username/password. Returns a full session if 2FA is disabled; otherwise returns a short-lived 2FA challenge token. |
|
|
||||||
| `/api/login/2fa` | `POST` | No | Completes login using the 2FA challenge token plus either a TOTP code or a recovery code. |
|
|
||||||
| `/api/refresh` | `POST` | No | Rotates a refresh token. Accepts the token from JSON or the `refresh_token` cookie. |
|
|
||||||
| `/api/logout` | `POST` | Yes | Revokes refresh tokens for the current user and clears auth cookies. |
|
|
||||||
| `/api/profile` | `GET` | Yes | Returns current user metadata, 2FA state, and unused recovery-code count. |
|
|
||||||
| `/api/userinfo` | `GET` | Yes | Same user information handler as `/api/profile`. |
|
|
||||||
| `/api/account/username` | `POST` | Yes | Changes the current username after password confirmation. |
|
|
||||||
| `/api/account/password` | `POST` | Yes | Changes the current password, revokes old refresh tokens, and issues a new session. |
|
|
||||||
| `/api/2fa/setup` | `POST` | Yes | Creates a pending TOTP secret and returns `secret`, `setup_token`, `otpauth_url`, and a base64 PNG QR code. |
|
|
||||||
| `/api/2fa/enable` | `POST` | Yes | Enables 2FA after validating the temporary setup token and a TOTP code. Replaces recovery codes and revokes old sessions. |
|
|
||||||
| `/api/2fa/disable` | `POST` | Yes | Disables 2FA after password and TOTP confirmation. Revokes sessions and clears auth cookies. |
|
|
||||||
| `/api/2fa/recovery-codes/regenerate` | `POST` | Yes | Invalidates existing recovery codes and returns a new set after password and TOTP confirmation. |
|
|
||||||
| `/api/passkeys` | `GET` | Yes | Lists passkeys registered for the current user. |
|
|
||||||
| `/api/passkeys` | `DELETE` | Yes | Removes one passkey after current-password confirmation. |
|
|
||||||
| `/api/passkeys/register/options` | `POST` | Yes | Creates a server-side passkey registration challenge after current-password confirmation. |
|
|
||||||
| `/api/passkeys/register/finish` | `POST` | Yes | Verifies and stores a new passkey credential. Revokes old sessions and issues a new current session. |
|
|
||||||
| `/api/passkeys/login/options` | `POST` | No | Creates a discoverable passkey login challenge. |
|
|
||||||
| `/api/passkeys/login/finish` | `POST` | No | Verifies passkey login and issues a full session. |
|
|
||||||
| `/api/passkeys/disable` | `POST` | Yes | Deletes all passkeys after current-password confirmation. Revokes old sessions and issues a new current session. |
|
|
||||||
|
|
||||||
### Inventory API
|
|
||||||
|
|
||||||
| Endpoint | Method | Auth required | Query parameters | Description |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `/api/item` | `GET` | Yes | `id` optional | Returns all items with aggregate quantities, or one item by ID. |
|
|
||||||
| `/api/item` | `POST` | Yes | None | Creates an item. |
|
|
||||||
| `/api/item` | `PUT` | Yes | `id` required | Updates item fields. |
|
|
||||||
| `/api/item` | `DELETE` | Yes | `id` required | Deletes an item if no dependent rows block it. |
|
|
||||||
| `/api/location` | `GET` | Yes | `id`, `content` optional | Returns all locations, one location, or location contents with `content=true`. |
|
|
||||||
| `/api/location` | `POST` | Yes | None | Creates a location. |
|
|
||||||
| `/api/location` | `PUT` | Yes | `id` required | Renames a location. |
|
|
||||||
| `/api/location` | `DELETE` | Yes | `id` required | Deletes a location if no dependent rows block it. |
|
|
||||||
| `/api/project` | `GET` | Yes | `id`, `details` optional | Returns all projects, one project, or project allocations with `details=true`. |
|
|
||||||
| `/api/project` | `POST` | Yes | None | Creates a project. |
|
|
||||||
| `/api/project` | `PUT` | Yes | `id` required | Updates a project. |
|
|
||||||
| `/api/project` | `DELETE` | Yes | `id` required | Deletes a project if no dependent rows block it. |
|
|
||||||
| `/api/stock` | `GET` | Yes | `id` optional | Returns stock entries. |
|
|
||||||
| `/api/stock` | `POST` | Yes | None | Creates a stock entry. |
|
|
||||||
| `/api/stock` | `PUT` | Yes | `id` required | Updates a stock entry. |
|
|
||||||
| `/api/stock` | `DELETE` | Yes | `id` required | Deletes a stock entry. |
|
|
||||||
| `/api/association` | `GET` | Yes | `id`, `project_id` optional | Returns project-item associations. |
|
|
||||||
| `/api/association` | `POST` | Yes | None | Allocates item quantity to a project. |
|
|
||||||
| `/api/association` | `PUT` | Yes | `id` required | Updates a project allocation. |
|
|
||||||
| `/api/association` | `DELETE` | Yes | `id` required | Removes a project allocation. |
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Go 1.26.
|
|
||||||
- SQLite-compatible environment.
|
|
||||||
- OpenSSL or another way to generate TLS certificates for local development.
|
|
||||||
- A `JWT_SECRET` with at least 32 characters.
|
|
||||||
|
|
||||||
### Generate development TLS files
|
|
||||||
|
|
||||||
The server uses `http.ListenAndServeTLS`, so TLS files must exist before startup.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p appdata
|
mkdir -p appdata
|
||||||
@@ -243,10 +77,9 @@ openssl req -x509 -newkey rsa:4096 \
|
|||||||
-subj "/CN=localhost"
|
-subj "/CN=localhost"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Native local run
|
Run locally:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export JWT_SECRET="replace-this-with-a-random-secret-of-at-least-32-chars"
|
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go build -o miauinv .
|
go build -o miauinv .
|
||||||
./miauinv
|
./miauinv
|
||||||
@@ -260,8 +93,6 @@ https://localhost:8080
|
|||||||
|
|
||||||
## Docker Deployment
|
## Docker Deployment
|
||||||
|
|
||||||
The repository contains a multi-stage Dockerfile. The final image is based on `scratch` and contains the compiled binary plus frontend assets.
|
|
||||||
|
|
||||||
Example `docker-compose.yaml`:
|
Example `docker-compose.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -278,9 +109,10 @@ services:
|
|||||||
- ./appdata:/appdata
|
- ./appdata:/appdata
|
||||||
```
|
```
|
||||||
|
|
||||||
Start the container:
|
Start or update the container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker compose pull
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -290,9 +122,15 @@ View logs:
|
|||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For production updates, pin a versioned image tag and back up `appdata` before upgrading:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
image: git.miaurizius.de/miaurizius/miauinv:v1.1.1
|
||||||
|
```
|
||||||
|
|
||||||
## Reverse Proxy Deployment
|
## Reverse Proxy Deployment
|
||||||
|
|
||||||
MiauInv currently listens with native TLS. If it is deployed behind Caddy or another reverse proxy, the proxy must connect to the backend using HTTPS unless the server code is changed to listen without TLS.
|
MiauInv currently listens with native TLS. When using Caddy or another reverse proxy, either proxy to the backend with HTTPS or intentionally adjust the server to listen without TLS.
|
||||||
|
|
||||||
Example Caddy configuration with a self-signed backend certificate:
|
Example Caddy configuration with a self-signed backend certificate:
|
||||||
|
|
||||||
@@ -315,20 +153,6 @@ inv.example.com {
|
|||||||
|
|
||||||
For Docker deployments, place Caddy and MiauInv on the same Docker network and reverse proxy to the service name.
|
For Docker deployments, place Caddy and MiauInv on the same Docker network and reverse proxy to the service name.
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
|
|
||||||
- JWTs are signed, not encrypted. Normal access and purpose tokens must not contain secrets. The temporary 2FA setup token is a narrow exception because it carries the not-yet-enabled TOTP secret back to the authenticated browser until confirmation.
|
|
||||||
- `JWT_SECRET` must be random and private.
|
|
||||||
- Access tokens expire after 15 minutes.
|
|
||||||
- Refresh tokens expire after 7 days and are rotated on refresh.
|
|
||||||
- Refresh tokens and recovery codes are stored in the database as hashes.
|
|
||||||
- Passkey credentials store public-key credential data, not private keys. Private keys remain in the authenticator or platform passkey provider.
|
|
||||||
- TOTP secrets are currently stored in the database because the server must validate codes. Protect the database file accordingly.
|
|
||||||
- Recovery codes are only shown when generated. Users should download or copy them immediately. The UI warns when few unused codes remain.
|
|
||||||
- 2FA disable and recovery-code regeneration require both the current password and a valid TOTP code.
|
|
||||||
- Basic in-memory rate limiting is enabled for login, passkey ceremonies, 2FA, refresh, registration, and sensitive account endpoints. Use persistent or distributed rate limiting for multi-instance deployments.
|
|
||||||
- Automated testing is currently limited. Authentication, 2FA, recovery codes, rate limiting, account settings, and inventory handlers should be covered before production use.
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
#### Dashboard
|
#### Dashboard
|
||||||
|
|||||||
475
ad.html
475
ad.html
@@ -1,358 +1,172 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MiauInv - Modern Inventory & Project Management</title>
|
<title>MiauInv - Lightweight Inventory Tracking</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b0f19;
|
--bg: #0b0f19;
|
||||||
--card: #111827;
|
--card: #111827;
|
||||||
--border: #1f2937;
|
--card-soft: #151f31;
|
||||||
|
--border: #233047;
|
||||||
--text: #f9fafb;
|
--text: #f9fafb;
|
||||||
--text-muted: #9ca3af;
|
--muted: #9ca3af;
|
||||||
--accent: #3b82f6;
|
--accent: #3b82f6;
|
||||||
--accent-hover: #2563eb;
|
|
||||||
--success: #10b981;
|
--success: #10b981;
|
||||||
--code-bg: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg);
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(59, 130, 246, 0.18), transparent 34rem),
|
||||||
|
radial-gradient(circle at bottom right, rgba(16, 185, 129, 0.10), transparent 30rem),
|
||||||
|
var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container { max-width: 1120px; margin: 0 auto; padding: 1.25rem 1.5rem 4rem; }
|
||||||
|
.topbar { display: flex; justify-content: flex-end; margin-bottom: 3rem; }
|
||||||
.lang-switch {
|
.lang-switch {
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(17, 24, 39, 0.8);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-switch:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 1.5rem 4rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 0 3rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
font-size: 3.5rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand span {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagline {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto 2.5rem auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
background: rgba(59, 130, 246, 0.1);
|
|
||||||
color: var(--accent);
|
|
||||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.85rem;
|
padding: 0.45rem 0.9rem;
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.green {
|
|
||||||
background: rgba(16, 185, 129, 0.1);
|
|
||||||
color: var(--success);
|
|
||||||
border-color: rgba(16, 185, 129, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 2rem;
|
|
||||||
margin-bottom: 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.grid {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2.5rem;
|
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 1.25rem;
|
cursor: pointer;
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
}
|
||||||
|
.hero { text-align: center; padding: 2.5rem 0 3.5rem; }
|
||||||
.features-list {
|
.brand { font-size: clamp(3rem, 7vw, 5.5rem); font-weight: 900; letter-spacing: -0.07em; line-height: 0.95; }
|
||||||
list-style: none;
|
.brand span { color: var(--accent); }
|
||||||
text-align: left;
|
.tagline { max-width: 820px; margin: 1.25rem auto 0; color: var(--muted); font-size: clamp(1.05rem, 2vw, 1.35rem); }
|
||||||
}
|
.hero-actions { display: flex; justify-content: center; flex-wrap: wrap; gap: 0.8rem; margin-top: 1.75rem; }
|
||||||
|
.hero-link {
|
||||||
.features-list li {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.features-list li strong {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.features-list svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.install-section {
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 3rem 2rem;
|
|
||||||
text-align: left;
|
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
|
||||||
margin-bottom: 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.install-section h2 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.install-section p {
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step h3 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
color: var(--text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-num {
|
|
||||||
background: var(--border);
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 0.5rem;
|
||||||
font-size: 0.8rem;
|
border-radius: 999px;
|
||||||
font-weight: bold;
|
padding: 0.75rem 1.1rem;
|
||||||
}
|
text-decoration: none;
|
||||||
|
font-weight: 800;
|
||||||
pre {
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 1rem 1.25rem;
|
color: var(--text);
|
||||||
border-radius: 10px;
|
background: rgba(17, 24, 39, 0.78);
|
||||||
font-family: monospace;
|
transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease;
|
||||||
font-size: 0.9rem;
|
}
|
||||||
color: #e5e7eb;
|
.hero-link:hover { transform: translateY(-1px); border-color: rgba(59, 130, 246, 0.7); background: rgba(59, 130, 246, 0.12); }
|
||||||
|
.hero-link.primary { border-color: rgba(59, 130, 246, 0.48); background: rgba(59, 130, 246, 0.18); color: #dbeafe; }
|
||||||
|
.hero-link svg { width: 1rem; height: 1rem; }
|
||||||
|
.badges { display: flex; justify-content: center; flex-wrap: wrap; gap: 0.75rem; margin-top: 2rem; }
|
||||||
|
.badge {
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.28);
|
||||||
|
background: rgba(59, 130, 246, 0.10);
|
||||||
|
color: #bfdbfe;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.badge.green { border-color: rgba(16, 185, 129, 0.28); background: rgba(16, 185, 129, 0.10); color: #a7f3d0; }
|
||||||
|
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin: 1rem 0 3rem; }
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(180deg, rgba(21, 31, 49, 0.92), rgba(17, 24, 39, 0.92));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
.card h2 { font-size: 1.1rem; margin-bottom: 0.65rem; }
|
||||||
|
.card p { color: var(--muted); }
|
||||||
|
.check { color: var(--success); font-weight: 900; margin-right: 0.4rem; }
|
||||||
|
|
||||||
|
.install {
|
||||||
|
background: rgba(17, 24, 39, 0.82);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.install h2 { font-size: 1.7rem; margin-bottom: 0.5rem; }
|
||||||
|
.install > p { color: var(--muted); margin-bottom: 1.25rem; }
|
||||||
|
pre {
|
||||||
|
background: #050816;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
white-space: pre;
|
color: #e5e7eb;
|
||||||
margin-bottom: 1rem;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
footer { text-align: center; color: var(--muted); padding-top: 3rem; }
|
||||||
|
|
||||||
footer {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 0;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sprach-Sichtbarkeitsregeln */
|
|
||||||
[lang="de"] { display: none; }
|
[lang="de"] { display: none; }
|
||||||
html[data-lang="de"] [lang="de"] { display: block; }
|
html[data-lang="de"] [lang="de"] { display: initial; }
|
||||||
html[data-lang="de"] [lang="en"] { display: none; }
|
html[data-lang="de"] [lang="en"] { display: none; }
|
||||||
|
html[data-lang="de"] p[lang="de"], html[data-lang="de"] div[lang="de"] { display: block; }
|
||||||
|
html[data-lang="de"] span[lang="de"], html[data-lang="de"] strong[lang="de"] { display: inline; }
|
||||||
|
|
||||||
html[data-lang="de"] span[lang="de"],
|
@media (max-width: 860px) { .grid { grid-template-columns: 1fr; } }
|
||||||
html[data-lang="de"] strong[lang="de"] {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
html[data-lang="en"] span[lang="en"],
|
|
||||||
html[data-lang="en"] strong[lang="en"] {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="lang-en">
|
<body>
|
||||||
|
|
||||||
<div class="navbar">
|
|
||||||
<button class="lang-switch" id="langBtn" onclick="toggleLanguage()">DE</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<div class="topbar">
|
||||||
|
<button class="lang-switch" id="langBtn" onclick="toggleLanguage()">DE</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
<div class="brand">Miau<span>Inv</span></div>
|
<div class="brand">Miau<span>Inv</span></div>
|
||||||
<p class="tagline" lang="en">A secure, light-weight, and minimalistic management system for tracking your inventory stock and project allocations.</p>
|
<p class="tagline" lang="en">Secure, lightweight inventory, stock, and project allocation tracking for self-hosted environments.</p>
|
||||||
<p class="tagline" lang="de">Ein sicheres, pfeilschnelles und minimalistisches System für deine Lagerbestände und Projekt-Zuweisungen.</p>
|
<p class="tagline" lang="de">Sicheres, leichtgewichtiges Inventory-, Lager- und Projektzuweisungs-Tracking für Self-Hosting.</p>
|
||||||
|
<div class="hero-actions">
|
||||||
<div class="badge-container">
|
<a class="hero-link primary" href="https://git.miaurizius.de/MiauRizius/MiauInv" target="_blank" rel="noopener noreferrer">
|
||||||
<span class="badge">Go Backend</span>
|
<span lang="en">View repository</span>
|
||||||
<span class="badge">HTMX Frontend</span>
|
<span lang="de">Repository ansehen</span>
|
||||||
<span class="badge">SQLite Inside</span>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17L17 7"></path><path d="M7 7h10v10"></path></svg>
|
||||||
<span class="badge green">Docker Ready</span>
|
</a>
|
||||||
|
<a class="hero-link" href="https://git.miaurizius.de/MiauRizius/MiauInv/packages" target="_blank" rel="noopener noreferrer">
|
||||||
|
<span lang="en">Container image</span>
|
||||||
|
<span lang="de">Container-Image</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<div class="badges">
|
||||||
|
<span class="badge">Go</span>
|
||||||
<div class="grid">
|
<span class="badge">SQLite</span>
|
||||||
<div class="card">
|
<span class="badge">HTMX-style UI</span>
|
||||||
<h2 lang="en">Why MiauInv?</h2>
|
<span class="badge green">Docker ready</span>
|
||||||
<h2 lang="de">Warum MiauInv?</h2>
|
|
||||||
<ul class="features-list">
|
|
||||||
<li>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
||||||
<span>
|
|
||||||
<strong lang="en">Zero JS-Overhead:</strong><strong lang="de">Kein JS-Overhead:</strong>
|
|
||||||
<span lang="en"> Powered by HTMX for an ultra-reactive, dynamic SPA experience without heavy client-side bundles.</span>
|
|
||||||
<span lang="de"> Dank HTMX extrem reaktiv und dynamisch wie eine SPA, aber ohne tonnenweise schweres JavaScript.</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
||||||
<span>
|
|
||||||
<strong lang="en">Live Quantities:</strong><strong lang="de">Live-Verfügbarkeit:</strong>
|
|
||||||
<span lang="en"> See overall totals, allocated project metrics, and remaining available counts instantly.</span>
|
|
||||||
<span lang="de"> Siehst sofort den Gesamtbestand, was in Projekten verplant ist und was noch frei zur Verfügung steht.</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
||||||
<span>
|
|
||||||
<strong lang="en">Unified Dashboards:</strong><strong lang="de">Orte & Projekte im Blick:</strong>
|
|
||||||
<span lang="en"> Drill straight down into site boundaries or project lists to inspect stock components with one click.</span>
|
|
||||||
<span lang="de"> Klicke auf Lagerorte oder aktive Projekte im Dashboard, um direkt den aktuellen Inhalt einzusehen.</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="card">
|
<section class="grid">
|
||||||
<h2 lang="en">Security & Tech Stack</h2>
|
<article class="card">
|
||||||
<h2 lang="de">Sicherheit & Tech</h2>
|
<h2 lang="en"><span class="check">✓</span>Track stock clearly</h2>
|
||||||
<ul class="features-list">
|
<h2 lang="de"><span class="check">✓</span>Bestände klar verfolgen</h2>
|
||||||
<li>
|
<p lang="en">Manage items, locations, quantities, and project allocations from one small dashboard.</p>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
<p lang="de">Verwalte Items, Lagerorte, Mengen und Projektzuweisungen in einem kleinen Dashboard.</p>
|
||||||
<span>
|
</article>
|
||||||
<strong lang="en">Dual-Token Security:</strong><strong lang="de">Dual-Token Security:</strong>
|
<article class="card">
|
||||||
<span lang="en"> Uses robust cryptographically signed JWT cookies combined with seamless backend refresh token rotations.</span>
|
<h2 lang="en"><span class="check">✓</span>Built-in account security</h2>
|
||||||
<span lang="de"> Zugriffsschutz über sichere, verschlüsselte JWT-Cookies inkl. Refresh-Token-Rotation.</span>
|
<h2 lang="de"><span class="check">✓</span>Account-Sicherheit inklusive</h2>
|
||||||
</span>
|
<p lang="en">Signed JWT sessions, refresh-token rotation, TOTP 2FA, recovery codes, passkeys, and an activity log.</p>
|
||||||
</li>
|
<p lang="de">Signierte JWT-Sessions, Refresh-Token-Rotation, TOTP-2FA, Recovery-Codes, Passkeys und Activity Log.</p>
|
||||||
<li>
|
</article>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
<article class="card">
|
||||||
<span>
|
<h2 lang="en"><span class="check">✓</span>Small deployment footprint</h2>
|
||||||
<strong lang="en">Native TLS Listener:</strong><strong lang="de">Native TLS Listener:</strong>
|
<h2 lang="de"><span class="check">✓</span>Kleiner Deployment-Footprint</h2>
|
||||||
<span lang="en"> Out-of-the-box enforced HTTPS transport layer security powered directly by Go's core networking.</span>
|
<p lang="en">A compiled Go binary with embedded SQLite persistence and a minimal Docker image.</p>
|
||||||
<span lang="de"> Enforcierte HTTPS-Verschlüsselung direkt aus dem Go-Core heraus.</span>
|
<p lang="de">Kompilierte Go-Binary mit eingebetteter SQLite-Persistenz und minimalem Docker-Image.</p>
|
||||||
</span>
|
</article>
|
||||||
</li>
|
</section>
|
||||||
<li>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
||||||
<span>
|
|
||||||
<strong lang="en">Ultra Lightweight:</strong><strong lang="de">Leichtgewicht:</strong>
|
|
||||||
<span lang="en"> Runs on absolute minimal system footprint resource margins using an embedded optimized SQLite model.</span>
|
|
||||||
<span lang="de"> Minimaler Ressourcenverbrauch dank kompilierter Go-Binary und eingebetteter SQLite-Datenbank.</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="install-section">
|
<section class="install">
|
||||||
<h2 lang="en">Install Your Server</h2>
|
<h2 lang="en">Run with Docker Compose</h2>
|
||||||
<h2 lang="de">Server installieren</h2>
|
<h2 lang="de">Mit Docker Compose starten</h2>
|
||||||
|
<p lang="en">Create an appdata directory, provide TLS files, set a strong JWT secret, and start the container.</p>
|
||||||
<p lang="en">Deploy MiauInv to your server infrastructure or local machine in less than a minute utilizing our streamlined container configurations.</p>
|
<p lang="de">Appdata-Ordner erstellen, TLS-Dateien bereitstellen, starkes JWT-Secret setzen und Container starten.</p>
|
||||||
<p lang="de">MiauInv lässt sich dank vorkonfiguriertem Docker-Setup innerhalb weniger Sekunden auf deinem Server oder lokalen System starten.</p>
|
|
||||||
|
|
||||||
<div class="step">
|
|
||||||
<h3>
|
|
||||||
<span class="step-num">1</span>
|
|
||||||
<span lang="en">Setup Asset Directories and TLS</span>
|
|
||||||
<span lang="de">Ordner und Zertifikate vorbereiten</span>
|
|
||||||
</h3>
|
|
||||||
<p lang="en">Since MiauInv natively enforces encrypted communication channels, initialize your tracking asset directory and place your TLS files there (or generate a self-signed keypair for testing targets):</p>
|
|
||||||
<p lang="de">Da MiauInv standardmäßig verschlüsseltes HTTPS erzwingt, erstelle den Appdata-Ordner und lege deine SSL-Zertifikate ab (oder generiere ein Self-Signed Cert für Tests):</p>
|
|
||||||
<pre>mkdir -p appdata
|
|
||||||
openssl req -x509 -newkey rsa:4096 -keyout appdata/key.pem -out appdata/cert.pem -sha256 -days 365 -nodes -subj "/CN=localhost"</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step">
|
|
||||||
<h3>
|
|
||||||
<span class="step-num">2</span>
|
|
||||||
<span lang="en">Configure Docker Compose</span>
|
|
||||||
<span lang="de">Docker Compose Datei anlegen</span>
|
|
||||||
</h3>
|
|
||||||
<p lang="en">Create a <code>docker-compose.yaml</code> layout file in the parent folder, and inject the orchestration service blocks. Ensure your <code>JWT_SECRET</code> environment variable meets the minimum security threshold length requirements:</p>
|
|
||||||
<p lang="de">Erstelle eine <code>docker-compose.yaml</code> im selben Verzeichnis und füge folgenden Inhalt ein. Passe den <code>JWT_SECRET</code>-String an:</p>
|
|
||||||
<pre>services:
|
<pre>services:
|
||||||
miauinv:
|
miauinv:
|
||||||
image: git.miaurizius.de/miaurizius/miauinv:latest
|
image: git.miaurizius.de/miaurizius/miauinv:latest
|
||||||
@@ -361,47 +175,26 @@ openssl req -x509 -newkey rsa:4096 -keyout appdata/key.pem -out appdata/cert.pem
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
environment:
|
environment:
|
||||||
- JWT_SECRET=SECURE_RANDOM_STRING_HERE_MIN_32_CHARS
|
- JWT_SECRET=replace-this-with-a-random-secret-of-at-least-32-chars
|
||||||
volumes:
|
volumes:
|
||||||
- ./appdata:/appdata</pre>
|
- ./appdata:/appdata</pre>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="step">
|
|
||||||
<h3>
|
|
||||||
<span class="step-num">3</span>
|
|
||||||
<span lang="en">Boot the Environment</span>
|
|
||||||
<span lang="de">Container starten</span>
|
|
||||||
</h3>
|
|
||||||
<p lang="en">Fire up the production background workers by executing your infrastructure controls daemon tasks:</p>
|
|
||||||
<p lang="de">Führe nun einfach den Start-Befehl aus, um das Image zu ziehen und im Hintergrund zu starten:</p>
|
|
||||||
<pre>docker-compose up -d</pre>
|
|
||||||
|
|
||||||
<p style="margin-top: 0.5rem;" lang="en">Everything is set up! Your instance is running and fully accessible at <strong>https://localhost:8080</strong>.</p>
|
|
||||||
<p style="margin-top: 0.5rem;" lang="de">Fertig! Dein Server läuft und ist sofort unter <strong>https://localhost:8080</strong> erreichbar.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p lang="en">© 2026 Maurice Larivière.</p>
|
<p>© 2026 Maurice Larivière.</p>
|
||||||
<p lang="de">© 2026 Maurice Larivière.</p>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function setLanguage(lang) {
|
function setLanguage(lang) {
|
||||||
document.documentElement.setAttribute('data-lang', lang);
|
document.documentElement.setAttribute('data-lang', lang);
|
||||||
document.getElementById('langBtn').innerText = lang === 'en' ? 'DE' : 'EN';
|
document.getElementById('langBtn').innerText = lang === 'en' ? 'DE' : 'EN';
|
||||||
localStorage.setItem('miauinv-lang', lang);
|
localStorage.setItem('miauinv-lang', lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLanguage() {
|
function toggleLanguage() {
|
||||||
const currentLang = document.documentElement.getAttribute('data-lang') || 'en';
|
const current = document.documentElement.getAttribute('data-lang') || 'en';
|
||||||
const nextLang = currentLang === 'en' ? 'de' : 'en';
|
setLanguage(current === 'en' ? 'de' : 'en');
|
||||||
setLanguage(nextLang);
|
|
||||||
}
|
}
|
||||||
|
setLanguage(localStorage.getItem('miauinv-lang') || 'en');
|
||||||
const savedLang = localStorage.getItem('miauinv-lang') || 'en';
|
|
||||||
setLanguage(savedLang);
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -12,6 +12,7 @@ JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variab
|
|||||||
| Middleware | `auth/middleware.go` | Extracts access tokens from bearer headers or cookies and injects claims into the request context. |
|
| Middleware | `auth/middleware.go` | Extracts access tokens from bearer headers or cookies and injects claims into the request context. |
|
||||||
| Password helpers | `auth/password.go` | bcrypt hashing and verification. |
|
| Password helpers | `auth/password.go` | bcrypt hashing and verification. |
|
||||||
| Login/account handlers | `handlers/account.go` | Register, login, 2FA, refresh, logout, account settings, and user metadata. |
|
| Login/account handlers | `handlers/account.go` | Register, login, 2FA, refresh, logout, account settings, and user metadata. |
|
||||||
|
| Activity handlers | `handlers/activity.go` | Activity log endpoint, activity metadata recording, and audit middleware. |
|
||||||
| Passkey handlers | `handlers/passkeys.go` | WebAuthn/passkey registration, login, removal, and disable flows. |
|
| Passkey handlers | `handlers/passkeys.go` | WebAuthn/passkey registration, login, removal, and disable flows. |
|
||||||
| Persistent session storage | `storage/storage.go`, `storage/passkeys.go` | Refresh tokens, 2FA state, TOTP secret, recovery-code hashes, passkey credentials, and WebAuthn challenge state. |
|
| Persistent session storage | `storage/storage.go`, `storage/passkeys.go` | Refresh tokens, 2FA state, TOTP secret, recovery-code hashes, passkey credentials, and WebAuthn challenge state. |
|
||||||
| Frontend auth logic | `frontend/assets/js/auth.js`, `frontend/assets/js/login.js`, `frontend/assets/js/api.js` | Login UI, passkey login, token refresh, account settings, 2FA UI interactions, and passkey UI interactions. |
|
| Frontend auth logic | `frontend/assets/js/auth.js`, `frontend/assets/js/login.js`, `frontend/assets/js/api.js` | Login UI, passkey login, token refresh, account settings, 2FA UI interactions, and passkey UI interactions. |
|
||||||
@@ -263,3 +264,22 @@ The current implementation is usable for private/self-hosted deployments, but th
|
|||||||
- Add optional session/device management UI.
|
- Add optional session/device management UI.
|
||||||
- Consider encrypting TOTP secrets and passkey credential data at rest if the deployment threat model includes database disclosure.
|
- Consider encrypting TOTP secrets and passkey credential data at rest if the deployment threat model includes database disclosure.
|
||||||
- Expand tests for all authentication and account settings handlers.
|
- Expand tests for all authentication and account settings handlers.
|
||||||
|
|
||||||
|
## Activity Log
|
||||||
|
|
||||||
|
MiauInv records account, authentication, security, and inventory activity in the `activity_logs` table. The log is designed for user-visible traceability and lightweight security review.
|
||||||
|
|
||||||
|
Recorded examples include:
|
||||||
|
|
||||||
|
- password login success and failure,
|
||||||
|
- 2FA login success and failure,
|
||||||
|
- refresh-token rotation,
|
||||||
|
- logout,
|
||||||
|
- username and password changes,
|
||||||
|
- TOTP setup, enable, disable, and recovery-code regeneration,
|
||||||
|
- passkey registration, login, removal, and disable,
|
||||||
|
- inventory, location, project, stock, and allocation mutations.
|
||||||
|
|
||||||
|
The log does not store request bodies or secret values. Passwords, TOTP codes, recovery codes, refresh tokens, and WebAuthn payloads are excluded.
|
||||||
|
|
||||||
|
`GET /api/activity` returns the current user's recent activity with bounded `limit` and `offset` pagination. Admin users may request all activity with `?all=true`.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ MiauInv uses SQLite for persistent storage. The schema is initialized in `storag
|
|||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
```
|
```
|
||||||
|
|
||||||
The database stores users, refresh tokens, 2FA recovery codes, passkey credentials, passkey challenge state, inventory items, locations, projects, stock mappings, and project allocations.
|
The database stores users, refresh tokens, 2FA recovery codes, passkey credentials, passkey challenge state, activity log entries, inventory items, locations, projects, stock mappings, and project allocations.
|
||||||
|
|
||||||
## Entity Overview
|
## Entity Overview
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ The database stores users, refresh tokens, 2FA recovery codes, passkey credentia
|
|||||||
[users] 1 ──── N [refresh_tokens]
|
[users] 1 ──── N [refresh_tokens]
|
||||||
[users] 1 ──── N [two_factor_recovery_codes]
|
[users] 1 ──── N [two_factor_recovery_codes]
|
||||||
[users] 1 ──── N [passkey_credentials]
|
[users] 1 ──── N [passkey_credentials]
|
||||||
|
[users] 1 ──── N [activity_logs]
|
||||||
|
|
||||||
[passkey_challenges] stores short-lived WebAuthn ceremony state
|
[passkey_challenges] stores short-lived WebAuthn ceremony state
|
||||||
|
|
||||||
@@ -96,6 +97,30 @@ Stores short-lived server-side WebAuthn session data for registration and login
|
|||||||
|
|
||||||
Challenge rows are consumed once during the finish step and expired rows are cleaned up opportunistically.
|
Challenge rows are consumed once during the finish step and expired rows are cleaned up opportunistically.
|
||||||
|
|
||||||
|
|
||||||
|
### `activity_logs`
|
||||||
|
|
||||||
|
Stores account, authentication, security, and inventory activity metadata. Request bodies, passwords, TOTP codes, recovery codes, passkey payloads, and token values are not stored.
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `id` | `TEXT` | Primary key | Activity row UUID. |
|
||||||
|
| `user_id` | `TEXT` | Not null, default `''` | User associated with the activity. Empty for unknown-user failed login attempts. |
|
||||||
|
| `username` | `TEXT` | Not null, default `''` | Username known at the time of the event. |
|
||||||
|
| `action` | `TEXT` | Not null | Normalized action name, for example `auth.login.succeeded` or `inventory.update`. |
|
||||||
|
| `entity_type` | `TEXT` | Not null, default `''` | Logical entity, for example `auth`, `security`, `item`, `location`, or `project`. |
|
||||||
|
| `entity_id` | `TEXT` | Not null, default `''` | Optional target ID when available. |
|
||||||
|
| `details` | `TEXT` | Not null, default `''` | Short sanitized status summary. |
|
||||||
|
| `method` | `TEXT` | Not null, default `''` | HTTP method. |
|
||||||
|
| `path` | `TEXT` | Not null, default `''` | Request path without query parameters. |
|
||||||
|
| `status_code` | `INTEGER` | Not null, default `0` | Final HTTP status code. |
|
||||||
|
| `success` | `INTEGER` | Not null, default `0` | Boolean success flag derived from the status code. |
|
||||||
|
| `ip_address` | `TEXT` | Not null, default `''` | Client IP address, considering common reverse-proxy headers. |
|
||||||
|
| `user_agent` | `TEXT` | Not null, default `''` | User-Agent metadata. |
|
||||||
|
| `created_at` | `INTEGER` | Not null | Unix timestamp for the event. |
|
||||||
|
|
||||||
|
Indexes are created for per-user timeline lookups, global timestamp ordering, and action filtering. Non-admin users can only read their own activity through `/api/activity`. Admin users can request all activity with `?all=true`.
|
||||||
|
|
||||||
### `items`
|
### `items`
|
||||||
|
|
||||||
Stores tracked inventory items.
|
Stores tracked inventory items.
|
||||||
|
|||||||
91
docs/ENDPOINTS.md
Normal file
91
docs/ENDPOINTS.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# API Endpoints
|
||||||
|
|
||||||
|
This document lists the public page routes and JSON API endpoints exposed by MiauInv. API endpoints that modify account or inventory state require authentication unless explicitly marked as public.
|
||||||
|
|
||||||
|
## Page Routes
|
||||||
|
|
||||||
|
| Route | Authentication | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/` | No | Landing page. |
|
||||||
|
| `/login` | No | Login page with password and passkey login. |
|
||||||
|
| `/register` | Optional | Registration page when registration is enabled. |
|
||||||
|
| `/dashboard` | Yes | Dashboard overview. |
|
||||||
|
| `/inventory` | Yes | Inventory item management. |
|
||||||
|
| `/items` | Yes | Item list view. |
|
||||||
|
| `/locations` | Yes | Location management. |
|
||||||
|
| `/projects` | Yes | Project allocation management. |
|
||||||
|
| `/profile/settings` | Yes | Account, 2FA, and passkey settings. |
|
||||||
|
| `/profile/activity` | Yes | User activity log. |
|
||||||
|
|
||||||
|
## Authentication and Account API
|
||||||
|
|
||||||
|
| Endpoint | Method | Authentication | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/register` | `POST` | No | Create a user when registration is enabled. |
|
||||||
|
| `/api/login` | `POST` | No | Password login. Returns a 2FA challenge if required. |
|
||||||
|
| `/api/login/2fa` | `POST` | No | Complete TOTP or recovery-code login. |
|
||||||
|
| `/api/passkeys/login/options` | `POST` | No | Start discoverable passkey login. |
|
||||||
|
| `/api/passkeys/login/finish` | `POST` | No | Complete passkey login. |
|
||||||
|
| `/api/refresh` | `POST` | No | Rotate a refresh token and issue a new session. |
|
||||||
|
| `/api/logout` | `POST` | Yes | Revoke refresh sessions and clear auth cookies. |
|
||||||
|
| `/api/userinfo` | `GET` | Yes | Return current user metadata and security status. |
|
||||||
|
| `/api/profile` | `GET` | Yes | Alias for current user metadata. |
|
||||||
|
| `/api/account/username` | `POST` | Yes | Change username with password confirmation. |
|
||||||
|
| `/api/account/password` | `POST` | Yes | Change password and refresh the current session. |
|
||||||
|
|
||||||
|
## Two-Factor Authentication API
|
||||||
|
|
||||||
|
| Endpoint | Method | Authentication | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/2fa/setup` | `POST` | Yes | Create a short-lived setup challenge, QR code, and manual setup secret. |
|
||||||
|
| `/api/2fa/enable` | `POST` | Yes | Confirm the setup challenge, enable 2FA, and generate recovery codes. |
|
||||||
|
| `/api/2fa/disable` | `POST` | Yes | Disable 2FA with password and TOTP confirmation. |
|
||||||
|
| `/api/2fa/recovery-codes/regenerate` | `POST` | Yes | Replace recovery codes with password and TOTP confirmation. |
|
||||||
|
|
||||||
|
## Passkey Management API
|
||||||
|
|
||||||
|
| Endpoint | Method | Authentication | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/passkeys` | `GET` | Yes | List registered passkeys. |
|
||||||
|
| `/api/passkeys` | `DELETE` | Yes | Remove a passkey with password confirmation. |
|
||||||
|
| `/api/passkeys/register/options` | `POST` | Yes | Start passkey registration. |
|
||||||
|
| `/api/passkeys/register/finish` | `POST` | Yes | Finish passkey registration and store the credential. |
|
||||||
|
| `/api/passkeys/disable` | `POST` | Yes | Remove all passkeys with password confirmation. |
|
||||||
|
|
||||||
|
## Activity API
|
||||||
|
|
||||||
|
| Endpoint | Method | Authentication | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/activity` | `GET` | Yes | Return recent activity entries for the current user. Admin users may request `?all=true`. |
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Parameter | Default | Max | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `limit` | `50` | `100` | Number of entries to return. |
|
||||||
|
| `offset` | `0` | `100000` | Offset for pagination. |
|
||||||
|
| `all` | `false` | n/a | Admin-only flag for reading all users' activity. |
|
||||||
|
|
||||||
|
## Inventory API
|
||||||
|
|
||||||
|
| Endpoint | Method | Authentication | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/item` | `GET` | Yes | List items or read an item by `id`. |
|
||||||
|
| `/api/item` | `POST` | Yes | Create an item. |
|
||||||
|
| `/api/item` | `PUT` | Yes | Update an item by `id`. |
|
||||||
|
| `/api/item` | `DELETE` | Yes | Delete an item by `id`. |
|
||||||
|
| `/api/location` | `GET` | Yes | List locations, read a location by `id`, or read location contents with `content=true`. |
|
||||||
|
| `/api/location` | `POST` | Yes | Create a location. |
|
||||||
|
| `/api/location` | `PUT` | Yes | Update a location by `id`. |
|
||||||
|
| `/api/location` | `DELETE` | Yes | Delete a location by `id`. |
|
||||||
|
| `/api/project` | `GET` | Yes | List projects, read a project by `id`, or read project allocation details with `details=true`. |
|
||||||
|
| `/api/project` | `POST` | Yes | Create a project. |
|
||||||
|
| `/api/project` | `PUT` | Yes | Update a project by `id`. |
|
||||||
|
| `/api/project` | `DELETE` | Yes | Delete a project by `id`. |
|
||||||
|
| `/api/stock` | `GET` | Yes | List stock rows, optionally filtered by `item_id`. |
|
||||||
|
| `/api/stock` | `POST` | Yes | Add stock to a location. |
|
||||||
|
| `/api/stock` | `DELETE` | Yes | Delete a stock row by `id`. |
|
||||||
|
| `/api/association` | `GET` | Yes | List project-item allocations, optionally filtered by `project_id`. |
|
||||||
|
| `/api/association` | `POST` | Yes | Allocate item quantity to a project. |
|
||||||
|
| `/api/association` | `PUT` | Yes | Update an allocation by `id`. |
|
||||||
|
| `/api/association` | `DELETE` | Yes | Delete an allocation by `id`. |
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Security Notes
|
# Security
|
||||||
|
|
||||||
This document summarizes the current security-relevant behavior of MiauInv. It is intended as implementation documentation, not as a guarantee that the application is production-ready for untrusted public deployments.
|
This document summarizes the current security-relevant behavior of MiauInv. It is intended as implementation documentation, not as a guarantee that the application is production-ready for untrusted public deployments.
|
||||||
|
|
||||||
@@ -80,9 +80,17 @@ When passkeys are added, removed, or disabled, existing refresh sessions are rev
|
|||||||
|
|
||||||
Passkey ceremonies require HTTPS except for localhost. Reverse proxy deployments must preserve the correct public `Host`, `X-Forwarded-Host`, and `X-Forwarded-Proto` information so that the relying party origin and ID match the browser-visible origin.
|
Passkey ceremonies require HTTPS except for localhost. Reverse proxy deployments must preserve the correct public `Host`, `X-Forwarded-Host`, and `X-Forwarded-Proto` information so that the relying party origin and ID match the browser-visible origin.
|
||||||
|
|
||||||
|
## Activity Logging
|
||||||
|
|
||||||
|
MiauInv stores an authenticated activity log for security-relevant and state-changing actions, including login attempts, refresh-token rotation, account changes, 2FA changes, passkey management, logout, and inventory mutations.
|
||||||
|
|
||||||
|
The activity log intentionally stores metadata only: action name, entity type, optional target ID, HTTP method, path, status, success flag, IP address, user agent, and timestamp. Request bodies are not logged, so passwords, TOTP codes, recovery codes, refresh tokens, and WebAuthn payloads are not persisted in the activity table.
|
||||||
|
|
||||||
|
Users can read their own entries from `/profile/activity` and `/api/activity`. Admin users may request all users' activity with `?all=true`. The API enforces authentication, bounds pagination limits, and is protected by rate limiting.
|
||||||
|
|
||||||
## Rate Limiting
|
## Rate Limiting
|
||||||
|
|
||||||
Basic in-memory rate limiting protects login, passkey ceremonies, 2FA, refresh, registration, and sensitive account endpoints.
|
Basic in-memory rate limiting protects login, passkey ceremonies, 2FA, refresh, registration, activity, and sensitive account endpoints.
|
||||||
|
|
||||||
This is suitable for a single-instance private deployment. It is not sufficient for multi-instance deployments because limiter state is process-local. A public or multi-instance deployment should use persistent or distributed rate limiting at the application, reverse proxy, or infrastructure layer.
|
This is suitable for a single-instance private deployment. It is not sufficient for multi-instance deployments because limiter state is process-local. A public or multi-instance deployment should use persistent or distributed rate limiting at the application, reverse proxy, or infrastructure layer.
|
||||||
|
|
||||||
@@ -93,5 +101,4 @@ This is suitable for a single-instance private deployment. It is not sufficient
|
|||||||
- Passkey credential metadata and public-key data are stored in the database after registration.
|
- Passkey credential metadata and public-key data are stored in the database after registration.
|
||||||
- TOTP secrets are not encrypted at rest.
|
- TOTP secrets are not encrypted at rest.
|
||||||
- There is no dedicated session/device management UI yet.
|
- There is no dedicated session/device management UI yet.
|
||||||
- There is no audit log for account security changes yet.
|
|
||||||
- The current rate limiter is process-local and memory-only.
|
- The current rate limiter is process-local and memory-only.
|
||||||
|
|||||||
@@ -291,6 +291,152 @@ tr:hover td {
|
|||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.activity-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-header h1 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-header p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-refresh,
|
||||||
|
.activity-load-more {
|
||||||
|
width: auto;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-controls-card {
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 1.35rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1fr) minmax(260px, 1.25fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-control-panel {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: rgba(17, 24, 39, 0.62);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-control-copy h2 {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0.1rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-control-copy p,
|
||||||
|
.activity-note {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-eyebrow {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-entry-menu {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-entry-option {
|
||||||
|
min-width: 3.2rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-entry-option:hover,
|
||||||
|
.activity-entry-option:focus-visible {
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-entry-option.active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 8px 18px rgba(59, 130, 246, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.22);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-note strong {
|
||||||
|
color: #bfdbfe;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-load-more-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#activity-load-more {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.activity-controls-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-control-panel {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-entry-menu {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-entry-option {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#modal {
|
#modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (document.getElementById('locations-table-body')) loadLocations();
|
if (document.getElementById('locations-table-body')) loadLocations();
|
||||||
if (document.getElementById('projects-table-body')) loadProjects();
|
if (document.getElementById('projects-table-body')) loadProjects();
|
||||||
if (document.getElementById('account-settings-content')) loadAccountSettings();
|
if (document.getElementById('account-settings-content')) loadAccountSettings();
|
||||||
|
if (document.getElementById('activity-log-body')) loadActivityLog(true);
|
||||||
|
|
||||||
setupPasswordVisibilityToggles();
|
setupPasswordVisibilityToggles();
|
||||||
loadProfile();
|
loadProfile();
|
||||||
@@ -1026,6 +1027,99 @@ async function disablePasskeys(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---- ACTIVITY LOG ----
|
||||||
|
let activityOffset = 0;
|
||||||
|
let activityLimit = 50;
|
||||||
|
|
||||||
|
async function loadActivityLog(reset = true) {
|
||||||
|
const tbody = document.getElementById('activity-log-body');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
activityLimit = getSelectedActivityLimit();
|
||||||
|
if (reset) {
|
||||||
|
activityOffset = 0;
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="table-loader">Loading activity...</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiRequest(`/api/activity?limit=${activityLimit}&offset=${activityOffset}`);
|
||||||
|
const entries = data.activity || [];
|
||||||
|
|
||||||
|
if (reset) tbody.innerHTML = '';
|
||||||
|
if (entries.length === 0 && activityOffset === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="table-loader">No activity recorded yet.</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.insertAdjacentHTML('beforeend', entries.map(renderActivityEntry).join(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
activityOffset += entries.length;
|
||||||
|
const loadMore = document.getElementById('activity-load-more');
|
||||||
|
if (loadMore) loadMore.style.display = entries.length === activityLimit ? 'inline-flex' : 'none';
|
||||||
|
} catch (err) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6" class="table-loader" style="color:#fca5a5;">${escapeHTML(err.message || 'Failed to load activity log.')}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedActivityLimit() {
|
||||||
|
const activeButton = document.querySelector('.activity-entry-option.active');
|
||||||
|
if (!activeButton) return activityLimit || 50;
|
||||||
|
return parseInt(activeButton.dataset.limit, 10) || 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActivityLimit(limit) {
|
||||||
|
activityLimit = limit;
|
||||||
|
document.querySelectorAll('.activity-entry-option').forEach((button) => {
|
||||||
|
const isActive = parseInt(button.dataset.limit, 10) === limit;
|
||||||
|
button.classList.toggle('active', isActive);
|
||||||
|
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
loadActivityLog(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMoreActivity() {
|
||||||
|
loadActivityLog(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActivityEntry(entry) {
|
||||||
|
const timestamp = entry.created_at ? new Date(entry.created_at * 1000).toLocaleString() : 'unknown';
|
||||||
|
const statusClass = entry.success ? 'badge success' : 'badge';
|
||||||
|
const statusLabel = entry.success ? 'Success' : 'Failed';
|
||||||
|
const entity = [entry.entity_type, entry.entity_id ? `#${entry.entity_id}` : ''].filter(Boolean).join(' ') || 'account';
|
||||||
|
const client = entry.user_agent ? summarizeUserAgent(entry.user_agent) : 'unknown';
|
||||||
|
const path = entry.path ? `<div style="color:var(--text-muted); font-size:0.8rem; margin-top:0.2rem;">${escapeHTML(entry.method || '')} ${escapeHTML(entry.path)}</div>` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap; color:var(--text-muted);">${escapeHTML(timestamp)}</td>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight:600; color:var(--text);">${escapeHTML(formatActivityAction(entry.action))}</div>
|
||||||
|
${path}
|
||||||
|
</td>
|
||||||
|
<td>${escapeHTML(entity)}</td>
|
||||||
|
<td><span class="${statusClass}">${statusLabel} ${entry.status_code || ''}</span></td>
|
||||||
|
<td style="font-family:monospace; font-size:0.85rem;">${escapeHTML(entry.ip_address || 'unknown')}</td>
|
||||||
|
<td title="${escapeAttr(entry.user_agent || '')}" style="max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text-muted);">${escapeHTML(client)}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActivityAction(action) {
|
||||||
|
if (!action) return 'Unknown action';
|
||||||
|
return action
|
||||||
|
.replace(/^auth\./, 'Auth: ')
|
||||||
|
.replace(/^account\./, 'Account: ')
|
||||||
|
.replace(/^security\./, 'Security: ')
|
||||||
|
.replace(/^inventory\./, 'Inventory: ')
|
||||||
|
.replace(/[._]/g, ' ')
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeUserAgent(userAgent) {
|
||||||
|
if (userAgent.length <= 80) return userAgent;
|
||||||
|
return `${userAgent.slice(0, 77)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHTML(value) {
|
function escapeHTML(value) {
|
||||||
return String(value)
|
return String(value)
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ var accountSettings = template.Must(template.ParseFiles(
|
|||||||
"frontend/htmx/contents/dash/base.html",
|
"frontend/htmx/contents/dash/base.html",
|
||||||
"frontend/htmx/contents/dash/account_settings.html"))
|
"frontend/htmx/contents/dash/account_settings.html"))
|
||||||
|
|
||||||
|
var activity = template.Must(template.ParseFiles(
|
||||||
|
"frontend/htmx/contents/dash/base.html",
|
||||||
|
"frontend/htmx/contents/dash/activity.html"))
|
||||||
|
|
||||||
var home = template.Must(template.ParseFiles("frontend/htmx/home.html"))
|
var home = template.Must(template.ParseFiles("frontend/htmx/home.html"))
|
||||||
|
|
||||||
func Home(w http.ResponseWriter, r *http.Request) {
|
func Home(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -156,6 +160,17 @@ func AccountSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
func Activity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
err := activity.ExecuteTemplate(w, "base.html", struct {
|
||||||
|
Title string
|
||||||
|
}{
|
||||||
|
Title: "Activity Log",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var minifier *minify.M
|
var minifier *minify.M
|
||||||
|
|
||||||
|
|||||||
49
frontend/htmx/contents/dash/activity.html
Normal file
49
frontend/htmx/contents/dash/activity.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<div class="page-header activity-header">
|
||||||
|
<div>
|
||||||
|
<h1>Activity Log</h1>
|
||||||
|
<p>Recent account, security, and inventory actions for your account.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary activity-refresh" onclick="loadActivityLog(true)">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card activity-controls-card">
|
||||||
|
<div class="activity-control-panel">
|
||||||
|
<div class="activity-control-copy">
|
||||||
|
<span class="activity-eyebrow">Display</span>
|
||||||
|
<h2>Entries per page</h2>
|
||||||
|
<p>Choose how many log entries should be loaded at once.</p>
|
||||||
|
</div>
|
||||||
|
<div class="activity-entry-menu" role="group" aria-label="Entries per page">
|
||||||
|
<button type="button" class="activity-entry-option" data-limit="25" aria-pressed="false" onclick="setActivityLimit(25)">25</button>
|
||||||
|
<button type="button" class="activity-entry-option active" data-limit="50" aria-pressed="true" onclick="setActivityLimit(50)">50</button>
|
||||||
|
<button type="button" class="activity-entry-option" data-limit="100" aria-pressed="false" onclick="setActivityLimit(100)">100</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="activity-note">
|
||||||
|
<strong>Privacy:</strong> Sensitive request bodies are never stored. The log keeps request metadata, action type, status, IP address, and user agent.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container activity-table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Client</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="activity-log-body">
|
||||||
|
<tr><td colspan="6" class="table-loader">Loading activity...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="activity-load-more-wrap">
|
||||||
|
<button type="button" id="activity-load-more" class="btn btn-secondary activity-load-more" onclick="loadMoreActivity()">Load more</button>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -26,18 +26,21 @@ func APIRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
var user models.User
|
var user models.User
|
||||||
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
|
||||||
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, "", "", "auth.register.failed", "auth", "", "Invalid request body", http.StatusBadRequest)
|
||||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Username == "" || user.Password == "" {
|
if user.Username == "" || user.Password == "" {
|
||||||
log.Println("POST [api/register] " + r.RemoteAddr + ": Username or Password is empty")
|
log.Println("POST [api/register] " + r.RemoteAddr + ": Username or Password is empty")
|
||||||
|
RecordActivity(r, "", strings.ToLower(strings.TrimSpace(user.Username)), "auth.register.failed", "auth", "", "Username and password required", http.StatusBadRequest)
|
||||||
http.Error(w, "username and password required", http.StatusBadRequest)
|
http.Error(w, "username and password required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(user.Password) > 72 {
|
if len(user.Password) > 72 {
|
||||||
log.Println("POST [api/register] User password too long")
|
log.Println("POST [api/register] User password too long")
|
||||||
|
RecordActivity(r, "", strings.ToLower(strings.TrimSpace(user.Username)), "auth.register.failed", "auth", "", "Password exceeds maximum length", http.StatusUnprocessableEntity)
|
||||||
http.Error(w, "Password exceeds the maximum allowed length of 72 characters", http.StatusUnprocessableEntity)
|
http.Error(w, "Password exceeds the maximum allowed length of 72 characters", http.StatusUnprocessableEntity)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -54,10 +57,12 @@ func APIRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if err := storage.AddUser(&user); err != nil {
|
if err := storage.AddUser(&user); err != nil {
|
||||||
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, "", strings.ToLower(strings.TrimSpace(user.Username)), "auth.register.failed", "auth", "", "User already exists or could not be created", http.StatusBadRequest)
|
||||||
http.Error(w, "user already exists", http.StatusBadRequest)
|
http.Error(w, "user already exists", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RecordActivity(r, user.ID, strings.ToLower(strings.TrimSpace(user.Username)), "auth.register.succeeded", "auth", "", "User registered", http.StatusCreated)
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user")
|
log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user")
|
||||||
}
|
}
|
||||||
@@ -69,6 +74,7 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
|
||||||
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, "", "", "auth.login.failed", "auth", "", "Invalid request", http.StatusBadRequest)
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -76,12 +82,14 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
user, err := storage.GetUserByUsername(creds.Username)
|
user, err := storage.GetUserByUsername(creds.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, "", strings.ToLower(strings.TrimSpace(creds.Username)), "auth.login.failed", "auth", "", "Invalid credentials", http.StatusUnauthorized)
|
||||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !auth.CheckPasswordHash(creds.Password, user.Password) {
|
if !auth.CheckPasswordHash(creds.Password, user.Password) {
|
||||||
log.Println("POST [api/login] " + r.RemoteAddr + ": Invalid credentials")
|
log.Println("POST [api/login] " + r.RemoteAddr + ": Invalid credentials")
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.login.failed", "auth", "", "Invalid credentials", http.StatusUnauthorized)
|
||||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -105,11 +113,13 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
"requires_2fa": true,
|
"requires_2fa": true,
|
||||||
"two_factor_token": twoFactorToken,
|
"two_factor_token": twoFactorToken,
|
||||||
})
|
})
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.login.password_accepted_2fa_required", "auth", "", "Password accepted; 2FA required", http.StatusOK)
|
||||||
log.Println("POST [api/login] " + r.RemoteAddr + ": Password accepted, 2FA required")
|
log.Println("POST [api/login] " + r.RemoteAddr + ": Password accepted, 2FA required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
issueLoginSession(w, r, user)
|
issueLoginSession(w, r, user)
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.login.succeeded", "auth", "", "Password login succeeded", http.StatusOK)
|
||||||
log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in")
|
log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +130,7 @@ func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, "", "", "auth.2fa_login.failed", "auth", "", "Invalid request", http.StatusBadRequest)
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -128,6 +139,7 @@ func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
|
|||||||
claims, err := auth.ValidatePurposeJWT(req.TwoFactorToken, auth.PurposeTwoFactorLogin, secret)
|
claims, err := auth.ValidatePurposeJWT(req.TwoFactorToken, auth.PurposeTwoFactorLogin, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, "", "", "auth.2fa_login.failed", "auth", "", "Invalid or expired 2FA challenge", http.StatusUnauthorized)
|
||||||
http.Error(w, "Invalid or expired 2FA challenge", http.StatusUnauthorized)
|
http.Error(w, "Invalid or expired 2FA challenge", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -155,15 +167,18 @@ func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if !validTOTP && !usedRecoveryCode {
|
if !validTOTP && !usedRecoveryCode {
|
||||||
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Invalid 2FA or recovery code")
|
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Invalid 2FA or recovery code")
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.2fa_login.failed", "auth", "", "Invalid 2FA or recovery code", http.StatusUnauthorized)
|
||||||
http.Error(w, "Invalid 2FA or recovery code", http.StatusUnauthorized)
|
http.Error(w, "Invalid 2FA or recovery code", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
issueLoginSession(w, r, user)
|
issueLoginSession(w, r, user)
|
||||||
if usedRecoveryCode {
|
if usedRecoveryCode {
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.recovery_code_login.succeeded", "auth", "", "Recovery-code login succeeded", http.StatusOK)
|
||||||
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with recovery code")
|
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with recovery code")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.2fa_login.succeeded", "auth", "", "2FA login succeeded", http.StatusOK)
|
||||||
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with 2FA")
|
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with 2FA")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +599,7 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if req.RefreshToken == "" {
|
if req.RefreshToken == "" {
|
||||||
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Missing refresh token")
|
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Missing refresh token")
|
||||||
|
RecordActivity(r, "", "", "auth.refresh.failed", "auth", "", "Missing refresh token", http.StatusUnauthorized)
|
||||||
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
|
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -593,6 +609,7 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
tokenRow, err := storage.GetRefreshToken(hashed)
|
tokenRow, err := storage.GetRefreshToken(hashed)
|
||||||
if err != nil || tokenRow.Revoked || tokenRow.ExpiresAt < time.Now().Unix() {
|
if err != nil || tokenRow.Revoked || tokenRow.ExpiresAt < time.Now().Unix() {
|
||||||
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Invalid refresh token")
|
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Invalid refresh token")
|
||||||
|
RecordActivity(r, tokenRow.UserID, "", "auth.refresh.failed", "auth", "", "Invalid refresh token", http.StatusUnauthorized)
|
||||||
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
|
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -609,6 +626,7 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
issueLoginSession(w, r, user)
|
issueLoginSession(w, r, user)
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.refresh.succeeded", "auth", "", "Refresh token rotated", http.StatusOK)
|
||||||
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token")
|
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
223
handlers/activity.go
Normal file
223
handlers/activity.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"MiauInv/auth"
|
||||||
|
"MiauInv/models"
|
||||||
|
"MiauInv/storage"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type activityResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *activityResponseWriter) WriteHeader(statusCode int) {
|
||||||
|
w.statusCode = statusCode
|
||||||
|
w.ResponseWriter.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *activityResponseWriter) Write(data []byte) (int, error) {
|
||||||
|
if w.statusCode == 0 {
|
||||||
|
w.statusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ActivityMiddleware(entityType string, includeGET bool) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
recorder := &activityResponseWriter{ResponseWriter: w}
|
||||||
|
next.ServeHTTP(recorder, r)
|
||||||
|
|
||||||
|
statusCode := recorder.statusCode
|
||||||
|
if statusCode == 0 {
|
||||||
|
statusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shouldRecordActivity(r, includeGET) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||||
|
if !ok || claims == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := ""
|
||||||
|
if user, err := storage.GetUserById(claims.UserID); err == nil {
|
||||||
|
username = user.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordActivity(r, claims.UserID, username, activityAction(r.Method, r.URL.Path), entityType, activityEntityID(r), activityDetails(statusCode), statusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ActivityLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||||
|
if !ok || claims == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := parseBoundedInt(r.URL.Query().Get("limit"), 50, 1, 100)
|
||||||
|
offset := parseBoundedInt(r.URL.Query().Get("offset"), 0, 0, 100000)
|
||||||
|
includeAll := claims.Role == models.RoleAdmin && strings.EqualFold(r.URL.Query().Get("all"), "true")
|
||||||
|
|
||||||
|
entries, err := storage.ListActivityLogs(claims.UserID, includeAll, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("GET [api/activity] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not load activity log", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"activity": entries,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"all": includeAll,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecordActivity(r *http.Request, userID, username, action, entityType, entityID, details string, statusCode int) {
|
||||||
|
if statusCode == 0 {
|
||||||
|
statusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
entry := models.ActivityLogEntry{
|
||||||
|
UserID: userID,
|
||||||
|
Username: truncateForActivity(username, 120),
|
||||||
|
Action: truncateForActivity(action, 120),
|
||||||
|
EntityType: truncateForActivity(entityType, 80),
|
||||||
|
EntityID: truncateForActivity(entityID, 120),
|
||||||
|
Details: truncateForActivity(details, 500),
|
||||||
|
Method: r.Method,
|
||||||
|
Path: truncateForActivity(r.URL.Path, 255),
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Success: statusCode >= 200 && statusCode < 400,
|
||||||
|
IPAddress: truncateForActivity(clientIP(r), 80),
|
||||||
|
UserAgent: truncateForActivity(r.UserAgent(), 500),
|
||||||
|
}
|
||||||
|
if err := storage.AddActivityLog(entry); err != nil {
|
||||||
|
log.Println("ACTIVITY " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRecordActivity(r *http.Request, includeGET bool) bool {
|
||||||
|
if r.Method == http.MethodOptions || r.Method == http.MethodHead {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if includeGET {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return r.Method != http.MethodGet
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityAction(method, path string) string {
|
||||||
|
path = strings.TrimSuffix(path, "/")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case path == "/api/logout":
|
||||||
|
return "auth.logout"
|
||||||
|
case path == "/api/account/username":
|
||||||
|
return "account.username.update"
|
||||||
|
case path == "/api/account/password":
|
||||||
|
return "account.password.update"
|
||||||
|
case path == "/api/2fa/setup":
|
||||||
|
return "security.2fa.setup"
|
||||||
|
case path == "/api/2fa/enable":
|
||||||
|
return "security.2fa.enable"
|
||||||
|
case path == "/api/2fa/disable":
|
||||||
|
return "security.2fa.disable"
|
||||||
|
case path == "/api/2fa/recovery-codes/regenerate":
|
||||||
|
return "security.2fa.recovery_codes.regenerate"
|
||||||
|
case path == "/api/passkeys/register/options":
|
||||||
|
return "security.passkey.registration.start"
|
||||||
|
case path == "/api/passkeys/register/finish":
|
||||||
|
return "security.passkey.registration.finish"
|
||||||
|
case path == "/api/passkeys/disable":
|
||||||
|
return "security.passkey.disable"
|
||||||
|
case path == "/api/passkeys":
|
||||||
|
if method == http.MethodDelete {
|
||||||
|
return "security.passkey.delete"
|
||||||
|
}
|
||||||
|
return "security.passkey.read"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case http.MethodPost:
|
||||||
|
return "inventory.create"
|
||||||
|
case http.MethodPut:
|
||||||
|
return "inventory.update"
|
||||||
|
case http.MethodDelete:
|
||||||
|
return "inventory.delete"
|
||||||
|
case http.MethodGet:
|
||||||
|
return "inventory.read"
|
||||||
|
default:
|
||||||
|
return strings.ToLower(method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityEntityID(r *http.Request) string {
|
||||||
|
if id := strings.TrimSpace(r.URL.Query().Get("id")); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityDetails(statusCode int) string {
|
||||||
|
if statusCode >= 200 && statusCode < 400 {
|
||||||
|
return "Request completed successfully."
|
||||||
|
}
|
||||||
|
return http.StatusText(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if forwardedFor := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwardedFor != "" {
|
||||||
|
parts := strings.Split(forwardedFor, ",")
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
|
||||||
|
return realIP
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBoundedInt(raw string, fallback, min, max int) int {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if value < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if value > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateForActivity(value string, max int) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
runes := []rune(value)
|
||||||
|
if max <= 0 || len(runes) <= max {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return string(runes[:max])
|
||||||
|
}
|
||||||
@@ -277,6 +277,7 @@ func PasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, "", "", "auth.passkey_login.failed", "auth", "", "Invalid request", http.StatusBadRequest)
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -327,6 +328,7 @@ func PasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.passkey_login.failed", "auth", "", "Could not verify passkey login", http.StatusUnauthorized)
|
||||||
http.Error(w, "Could not verify passkey login", http.StatusUnauthorized)
|
http.Error(w, "Could not verify passkey login", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -338,6 +340,7 @@ func PasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
issueLoginSession(w, r, user)
|
issueLoginSession(w, r, user)
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.passkey_login.succeeded", "auth", "", "Passkey login succeeded", http.StatusOK)
|
||||||
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": Successfully logged in with passkey")
|
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": Successfully logged in with passkey")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
models/activity.go
Normal file
18
models/activity.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type ActivityLogEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
EntityType string `json:"entity_type"`
|
||||||
|
EntityID string `json:"entity_id"`
|
||||||
|
Details string `json:"details"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ func (this *Server) Run() {
|
|||||||
mux.Handle("/locations", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Locations)))
|
mux.Handle("/locations", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Locations)))
|
||||||
mux.Handle("/projects", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Projects)))
|
mux.Handle("/projects", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Projects)))
|
||||||
mux.Handle("/profile/settings", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.AccountSettings)))
|
mux.Handle("/profile/settings", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.AccountSettings)))
|
||||||
|
mux.Handle("/profile/activity", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Activity)))
|
||||||
mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html"))
|
mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html"))
|
||||||
if this.AllowRegistration {
|
if this.AllowRegistration {
|
||||||
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
|
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
|
||||||
@@ -85,34 +86,43 @@ func (this *Server) Run() {
|
|||||||
//
|
//
|
||||||
loginLimiter := newRateLimiter(10, time.Minute, 5*time.Minute)
|
loginLimiter := newRateLimiter(10, time.Minute, 5*time.Minute)
|
||||||
accountLimiter := newRateLimiter(20, time.Minute, 2*time.Minute)
|
accountLimiter := newRateLimiter(20, time.Minute, 2*time.Minute)
|
||||||
|
activityLimiter := newRateLimiter(60, time.Minute, 2*time.Minute)
|
||||||
|
|
||||||
|
authed := func(handler http.HandlerFunc) http.Handler {
|
||||||
|
return auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handler))
|
||||||
|
}
|
||||||
|
audited := func(entityType string, handler http.HandlerFunc) http.Handler {
|
||||||
|
return auth.AuthMiddleware(this.JWTSecret)(handlers.ActivityMiddleware(entityType, false)(http.HandlerFunc(handler)))
|
||||||
|
}
|
||||||
|
|
||||||
mux.Handle("/api/login", loginLimiter.Middleware(http.HandlerFunc(handlers.APILogin)))
|
mux.Handle("/api/login", loginLimiter.Middleware(http.HandlerFunc(handlers.APILogin)))
|
||||||
mux.Handle("/api/login/2fa", loginLimiter.Middleware(http.HandlerFunc(handlers.APILoginTwoFactor)))
|
mux.Handle("/api/login/2fa", loginLimiter.Middleware(http.HandlerFunc(handlers.APILoginTwoFactor)))
|
||||||
mux.Handle("/api/refresh", loginLimiter.Middleware(http.HandlerFunc(handlers.RefreshToken)))
|
mux.Handle("/api/refresh", loginLimiter.Middleware(http.HandlerFunc(handlers.RefreshToken)))
|
||||||
mux.Handle("/api/logout", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Logout)))
|
mux.Handle("/api/logout", auth.AuthMiddleware(this.JWTSecret)(handlers.ActivityMiddleware("auth", true)(http.HandlerFunc(handlers.Logout))))
|
||||||
mux.Handle("/api/profile", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
|
mux.Handle("/api/profile", authed(handlers.UserInfo))
|
||||||
mux.Handle("/api/2fa/setup", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorSetup))))
|
mux.Handle("/api/activity", activityLimiter.Middleware(authed(handlers.ActivityLog)))
|
||||||
mux.Handle("/api/2fa/enable", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorEnable))))
|
mux.Handle("/api/2fa/setup", accountLimiter.Middleware(audited("security", handlers.TwoFactorSetup)))
|
||||||
mux.Handle("/api/2fa/disable", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorDisable))))
|
mux.Handle("/api/2fa/enable", loginLimiter.Middleware(audited("security", handlers.TwoFactorEnable)))
|
||||||
mux.Handle("/api/2fa/recovery-codes/regenerate", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorRegenerateRecoveryCodes))))
|
mux.Handle("/api/2fa/disable", loginLimiter.Middleware(audited("security", handlers.TwoFactorDisable)))
|
||||||
mux.Handle("/api/userinfo", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
|
mux.Handle("/api/2fa/recovery-codes/regenerate", loginLimiter.Middleware(audited("security", handlers.TwoFactorRegenerateRecoveryCodes)))
|
||||||
mux.Handle("/api/account/username", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdateUsername))))
|
mux.Handle("/api/userinfo", authed(handlers.UserInfo))
|
||||||
mux.Handle("/api/account/password", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdatePassword))))
|
mux.Handle("/api/account/username", accountLimiter.Middleware(audited("account", handlers.AccountUpdateUsername)))
|
||||||
mux.Handle("/api/passkeys", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Passkeys))))
|
mux.Handle("/api/account/password", loginLimiter.Middleware(audited("account", handlers.AccountUpdatePassword)))
|
||||||
mux.Handle("/api/passkeys/register/options", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.PasskeyRegisterOptions))))
|
mux.Handle("/api/passkeys", accountLimiter.Middleware(audited("security", handlers.Passkeys)))
|
||||||
mux.Handle("/api/passkeys/register/finish", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.PasskeyRegisterFinish))))
|
mux.Handle("/api/passkeys/register/options", accountLimiter.Middleware(audited("security", handlers.PasskeyRegisterOptions)))
|
||||||
mux.Handle("/api/passkeys/disable", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.PasskeyDisable))))
|
mux.Handle("/api/passkeys/register/finish", accountLimiter.Middleware(audited("security", handlers.PasskeyRegisterFinish)))
|
||||||
|
mux.Handle("/api/passkeys/disable", loginLimiter.Middleware(audited("security", handlers.PasskeyDisable)))
|
||||||
mux.Handle("/api/passkeys/login/options", loginLimiter.Middleware(http.HandlerFunc(handlers.PasskeyLoginOptions)))
|
mux.Handle("/api/passkeys/login/options", loginLimiter.Middleware(http.HandlerFunc(handlers.PasskeyLoginOptions)))
|
||||||
mux.Handle("/api/passkeys/login/finish", loginLimiter.Middleware(http.HandlerFunc(handlers.PasskeyLoginFinish)))
|
mux.Handle("/api/passkeys/login/finish", loginLimiter.Middleware(http.HandlerFunc(handlers.PasskeyLoginFinish)))
|
||||||
if this.AllowRegistration {
|
if this.AllowRegistration {
|
||||||
mux.Handle("/api/register", loginLimiter.Middleware(http.HandlerFunc(handlers.APIRegister)))
|
mux.Handle("/api/register", loginLimiter.Middleware(http.HandlerFunc(handlers.APIRegister)))
|
||||||
}
|
}
|
||||||
|
|
||||||
mux.Handle("/api/item", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Item)))
|
mux.Handle("/api/item", audited("item", handlers.Item))
|
||||||
mux.Handle("/api/location", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Location)))
|
mux.Handle("/api/location", audited("location", handlers.Location))
|
||||||
mux.Handle("/api/project", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Project)))
|
mux.Handle("/api/project", audited("project", handlers.Project))
|
||||||
mux.Handle("/api/stock", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Stock)))
|
mux.Handle("/api/stock", audited("stock", handlers.Stock))
|
||||||
mux.Handle("/api/association", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Associations)))
|
mux.Handle("/api/association", audited("association", handlers.Associations))
|
||||||
|
|
||||||
// Assets
|
// Assets
|
||||||
mux.HandleFunc("/assets/", frontend.Assets)
|
mux.HandleFunc("/assets/", frontend.Assets)
|
||||||
|
|||||||
@@ -74,6 +74,23 @@ func InitDB(filepath string) error {
|
|||||||
expires_at INTEGER NOT NULL
|
expires_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL DEFAULT '',
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL DEFAULT '',
|
||||||
|
entity_id TEXT NOT NULL DEFAULT '',
|
||||||
|
details TEXT NOT NULL DEFAULT '',
|
||||||
|
method TEXT NOT NULL DEFAULT '',
|
||||||
|
path TEXT NOT NULL DEFAULT '',
|
||||||
|
status_code INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ip_address TEXT NOT NULL DEFAULT '',
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS items (
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -121,6 +138,10 @@ func InitDB(filepath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ensureActivityLogIndexes(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +160,97 @@ func ensureUserTwoFactorColumns() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureActivityLogIndexes() error {
|
||||||
|
indexes := []string{
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_activity_logs_user_created ON activity_logs(user_id, created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_activity_logs_created ON activity_logs(created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_activity_logs_action ON activity_logs(action)",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stmt := range indexes {
|
||||||
|
if _, err := DB.Exec(stmt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity logs
|
||||||
|
func AddActivityLog(entry models.ActivityLogEntry) error {
|
||||||
|
if DB == nil {
|
||||||
|
return errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
if entry.ID == "" {
|
||||||
|
entry.ID = utils.GenerateUUID()
|
||||||
|
}
|
||||||
|
if entry.CreatedAt == 0 {
|
||||||
|
entry.CreatedAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
success := 0
|
||||||
|
if entry.Success {
|
||||||
|
success = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := DB.Exec(`
|
||||||
|
INSERT INTO activity_logs(
|
||||||
|
id, user_id, username, action, entity_type, entity_id, details,
|
||||||
|
method, path, status_code, success, ip_address, user_agent, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, entry.ID, entry.UserID, entry.Username, entry.Action, entry.EntityType, entry.EntityID, entry.Details,
|
||||||
|
entry.Method, entry.Path, entry.StatusCode, success, entry.IPAddress, entry.UserAgent, entry.CreatedAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListActivityLogs(userID string, includeAll bool, limit, offset int) ([]models.ActivityLogEntry, error) {
|
||||||
|
if DB == nil {
|
||||||
|
return nil, errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT id, user_id, username, action, entity_type, entity_id, details,
|
||||||
|
method, path, status_code, success, ip_address, user_agent, created_at
|
||||||
|
FROM activity_logs
|
||||||
|
`
|
||||||
|
args := []interface{}{}
|
||||||
|
if !includeAll {
|
||||||
|
query += " WHERE user_id = ?"
|
||||||
|
args = append(args, userID)
|
||||||
|
}
|
||||||
|
query += " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?"
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
rows, err := DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
logs := []models.ActivityLogEntry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var entry models.ActivityLogEntry
|
||||||
|
var success int
|
||||||
|
if err := rows.Scan(
|
||||||
|
&entry.ID, &entry.UserID, &entry.Username, &entry.Action, &entry.EntityType, &entry.EntityID, &entry.Details,
|
||||||
|
&entry.Method, &entry.Path, &entry.StatusCode, &success, &entry.IPAddress, &entry.UserAgent, &entry.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry.Success = success == 1
|
||||||
|
logs = append(logs, entry)
|
||||||
|
}
|
||||||
|
return logs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
func AddUser(user *models.User) error {
|
func AddUser(user *models.User) error {
|
||||||
_, err := DB.Exec("INSERT INTO users(id, username, password, role) VALUES (?, ?, ?, ?)", user.ID, strings.ToLower(user.Username), user.Password, user.Role)
|
_, err := DB.Exec("INSERT INTO users(id, username, password, role) VALUES (?, ?, ?, ?)", user.ID, strings.ToLower(user.Username), user.Password, user.Role)
|
||||||
|
|||||||
Reference in New Issue
Block a user