20 Commits

Author SHA1 Message Date
ea8ea45c4c started with 2fa support 2026-06-09 22:50:29 +02:00
5485fd135d new version 2026-06-09 14:45:04 +02:00
5558d42bdb fixed #2 2026-06-09 14:44:28 +02:00
b74df36bda fixed #4 2026-06-09 14:40:49 +02:00
918b9a6b74 removed unnecessary comments 2026-06-09 14:36:03 +02:00
6d32ca13ca added more docs 2026-06-09 14:30:46 +02:00
feffff0898 Updated README/fixed #3 2026-06-09 14:21:49 +02:00
5089f94a21 updated README 2026-06-09 13:50:37 +02:00
f5f5da51c8 embedded frontend to docker image 2026-06-09 13:45:03 +02:00
089998d45b updated deploy 2026-06-08 15:35:43 +02:00
e2d51c6cdf updated dockerfile and deploy 2026-06-08 15:30:49 +02:00
0f8c7f57ac fixed #1 2026-06-08 15:27:06 +02:00
8d2be395b9 added .min.* support 2026-06-08 15:03:24 +02:00
339d5a709c dashboard locations and projects will now show items 2026-06-08 14:46:52 +02:00
405502cb20 added ad page 2026-06-08 00:34:54 +02:00
1a454dd201 Updated README.md 2026-06-08 00:25:40 +02:00
15cc33eb23 added README.md 2026-06-08 00:24:08 +02:00
f367188c08 Under construction page and dynamic profile loading 2026-06-08 00:00:45 +02:00
dcc6dc0b98 item and location names will be shown instead of their ids 2026-06-07 23:28:03 +02:00
d771ebba13 logout is now possible again 2026-06-07 23:04:12 +02:00
34 changed files with 2103 additions and 370 deletions

View File

@@ -11,9 +11,15 @@ COPY . .
ARG TARGETOS
ARG TARGETARCH
LABEL org.opencontainers.image.source="https://git.miaurizius.de/miaurizius/miauinv"
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -ldflags "-s -w" -o MiauInv .
FROM scratch
COPY --from=builder /app/MiauInv /MiauInv
COPY --from=builder /app/frontend /frontend
ENTRYPOINT ["/MiauInv"]

265
README.md Normal file
View File

@@ -0,0 +1,265 @@
# MiauInv - Inventory and Project Management System
MiauInv is a secure, light-weight inventory, stock, and project allocation tracking system written in Go. It utilizes HTMX for a dynamic, reactive single-page experience over traditional server-rendered HTML blocks, backed by an encrypted JWT and dual-token refresh architecture alongside an embedded SQLite instance.
## Table of Contents
* [Technical Specifications](#technical-specifications)
* [Architecture Overview](#architecture-overview)
* [Detailed Documentation](#detailed-documentation)
* [Configuration](#configuration)
* [Configuration File (config.yaml)](#configuration-file-configyaml)
* [Environment Variables](#environment-variables)
* [Route and Endpoint Matrix](#route-and-endpoint-matrix)
* [Frontend Web Routes (HTML Views)](#frontend-web-routes-html-views)
* [Backend API Endpoints (JSON Serialization)](#backend-api-endpoints-json-serialization)
* [Setup and Deployment Tutorial](#setup-and-deployment-tutorial)
* [Prerequisites](#prerequisites)
* [Option 1: Native Local Deployment](#option-1-native-local-deployment)
* [Option 2: Docker Deployment (Recommended)](#option-2-docker-deployment-recommended)
* [Reverse Proxy Integration with Caddy](#reverse-proxy-integration-with-caddy)
* [Images](#images)
## Technical Specifications
* **Backend Language:** Go (Golang 1.22+)
* **Frontend Interactivity:** HTMX (v2.0.4) & Vanilla JavaScript (API Client integration)
* **Database Engine:** SQLite (via standard driver dependencies)
* **Security & Session Layer:** JSON Web Tokens (JWT) with HTTP-Only/Secure dual cookie strategy (Access and Refresh Token Rotation)
* **Styling Architecture:** Modern dark-theme customized native CSS variables (`--bg`, `--card`, `--accent`, `--border`)
* **Transport Security:** Compulsory HTTPS / Native TLS Listener implementation
---
## Architecture Overview
MiauInv splits responsibility cleanly across modularized architecture packages:
* **`main.go`**: Entrypoint initializing components and connecting layers.
* **`server/`**: Configures variables, spins up TLS mechanisms, and exposes route endpoints.
* **`auth/`**: Custom HTTP Middleware interceptors validating JWT signatures and parsing sub-claims.
* **`handlers/`**: Core API Controller actions processing CRUD functions on database entities.
* **`storage/`**: Direct abstraction queries interacting with the underlying SQLite database schema.
* **`frontend/`**: Serving standard static assets and injecting structural data into components.
---
---
## Detailed Documentation
For deep dives into specific subsystems, database layouts, and security mechanisms, please refer to the dedicated documentation files:
* **[Database Schema & Integrity](docs/DATABASE.md):** Comprehensive breakdown of the SQLite table structures, fields, and foreign key relations.
* **[Authentication Architecture](docs/AUTHENTICATION.md):** Detailed explanation of the dual-token rotation flow, JWT lifecycle, and frontend loop protection.
## Configuration
The system uses a combination of a structural JSON configuration file and environment variables for system runtime flags.
### Configuration File (`config.yaml`)
The application automatically creates or reads a configuration file named `config.json` in the working directory on startup.
```yaml
port: "8080"
database_path: ./appdata/database.db
certificate_path: ./appdata/cert.pem
private_key_path: ./appdata/key.pem
allow_registration: true
```
### Environment Variables
For cryptographic functions, a mandatory environment variable must be exported before executing the binary:
| Variable | Type | Description | Minimum Requirement |
| --- | --- | --- | --- |
| `JWT_SECRET` | String | Symmetric secret signature key used to sign access tokens | Minimum 32 characters |
---
## Route and Endpoint Matrix
All communication with the application happens over defined HTTP transport interfaces. The routes are divided into User-Facing Views (HTML renders) and programmatic Data Hooks (JSON APIs).
### Frontend Web Routes (HTML Views)
| Route Path | HTTP Method | Auth Required | Description |
| --- | --- | --- | --- |
| `/` | `GET` | No | Root index landing view. |
| `/login` | `GET` | No | Renders login page component. |
| `/register` | `GET` | No | Registration layout or block alert based on authorization properties. |
| `/dashboard` | `GET` | **Yes** | Aggregated stats layout covering items, projects, and locations. |
| `/inventory` | `GET` | **Yes** | General overview interface managing stock quantities. |
| `/items` | `GET` | **Yes** | Standard component interface targeting primary atomic assets. |
| `/locations` | `GET` | **Yes** | Physical or logical facility structures view. |
| `/projects` | `GET` | **Yes** | Overview interface listing active construction and logistics operations. |
| `/profile/` | `GET` | **Yes** | Component context under development. |
| `/assets/*` | `GET` | No | Serves global minified system design files (`.css`, `.js`). |
### Backend API Endpoints (JSON Serialization)
| Endpoint Path | Method | Auth | Query Parameters | Request/Response Behavior |
| --- | --- | --- | --- | --- |
| `/api/register` | `POST` | No | None | Creates a new user record. Requires plain JSON payload containing raw `username` and `password`. Returns status code `201 Created`. |
| `/api/login` | `POST` | No | None | Performs credential authentication. Sets `access_token` and `refresh_token` as secure, HTTP-Only cookies, and returns user identity metadata. |
| `/api/refresh` | `POST` | No | None | Accepts JSON containing `refresh_token`. Invalidates previous token structures, rotates identities, and hands over a newly active pair. |
| `/api/logout` | `POST` | **Yes** | None | Revokes active database-linked refresh session IDs for the user context and drops current browser state tracking. Returns status `204`. |
| `/api/profile` | `GET` | **Yes** | `id` *(Optional)* | Returns `id`, `username`, and metadata of either the target parameter or active identity mapped from token signatures. |
| `/api/item` | `GET` | **Yes** | `id` *(Optional)* | Empty parameters fetch all available items with aggregated `allocated` and `available` calculations. Providing `id` isolates a specific item. |
| `/api/item` | `POST` | **Yes** | None | Inserts a new tracked inventory item schema definition. |
| `/api/item` | `PUT` | **Yes** | `id` *(Required)* | Modifies values (`name`, `category`, `description`, `total_quantity`) of an active asset by primary key. |
| `/api/item` | `DELETE` | **Yes** | `id` *(Required)* | Removes an entry. SQLite blocks cascade execution if foreign key assignments still exist in stock or project tracking. |
| `/api/location` | `GET` | **Yes** | `id`, `content` | Fetching with `id` and `content=true` extracts an array of items grouped at the facility (`item_id`, `name`, `quantity`). Without `content`, returns details or global catalogs. |
| `/api/location` | `POST` | **Yes** | None | Instantiates a singular distinct location boundary identifier. |
| `/api/location` | `PUT` | **Yes** | `id` *(Required)* | Renames a location while maintaining foreign keys. |
| `/api/location` | `DELETE` | **Yes** | `id` *(Required)* | Destroys location configurations if currently cleared of active items. |
| `/api/project` | `GET` | **Yes** | `id`, `details` | Providing `id` with `details=true` unrolls associated items allocated to that project context. |
| `/api/project` | `POST` | **Yes** | None | Inserts a tracking context entity for targeted hardware allocation. |
| `/api/project` | `PUT` | **Yes** | `id` *(Required)* | Updates a project metadata record definition. |
| `/api/project` | `DELETE` | **Yes** | `id` *(Required)* | Drops an empty project wrapper. |
| `/api/stock` | `GET` | **Yes** | `id` *(Optional)* | Obtains exact relationship matrices between location nodes and items. |
| `/api/stock` | `POST` | **Yes** | None | Links allocations across specific site nodes. |
| `/api/stock` | `PUT` | **Yes** | `id` *(Required)* | Modifies stock metrics directly on specified maps. |
| `/api/stock` | `DELETE` | **Yes** | `id` *(Required)* | Completely severs relationship entry allocations between nodes. |
| `/api/association` | `GET` | **Yes** | `id`, `project_id` | Dumps general configuration links, isolated optionally by operational `project_id`. |
| `/api/association` | `POST` | **Yes** | None | Allocates an inventory batch to a dedicated project infrastructure requirement. |
| `/api/association` | `PUT` | **Yes** | `id` *(Required)* | Alters active quantity indicators inside a specific deployment matrix. |
| `/api/association` | `DELETE` | **Yes** | `id` *(Required)* | Frees up allocations, returning tracking variables back to unassigned states. |
---
## Setup and Deployment Tutorial
### Prerequisites
Before deployment, you must generate SSL/TLS certificates since MiauInv enforces native transport encryption layer communication (or use a bought one).
```bash
# Create directory for certs
mkdir -p appdata
# Generate self-signed certificate and private key
openssl req -x509 -newkey rsa:4096 -keyout appdata/key.pem -out appdata/cert.pem -sha256 -days 365 -nodes
```
### Option 1: Native Local Deployment
1. Make sure your Go environment path variable properties are populated (Go 1.22+).
2. Create your environmental token and execute the initialization routine task:
```bash
export JWT_SECRET="your_minimum_thirty_two_char_secret_key_here"
go build -o miauinv main.go
./miauinv
```
---
### Option 2: Docker Deployment (Recommended)
MiauInv includes container definition orchestrations to package configurations cleanly, binding storage databases outside running containers into persistent volumes.
#### 1. Create the Docker Compose Descriptor
Write the configuration definition mapping layer blocks directly inside a standard file named `docker-compose.yaml`:
```yaml
services:
miauinv:
image: git.miaurizius.de/miaurizius/miauinv:latest
container_name: MiauInv
restart: unless-stopped
ports:
- "8080:8080"
environment:
- JWT_SECRET=SECURE_RANDOM_STRING # Must be at least 32 characters long
volumes:
- ./appdata:/appdata # To edit your configuration files
```
#### 2. Execution Commands
To bring up your background container image instance pipelines, execute the compose environment controls:
```bash
# Build and execute the container in background detached mode
docker-compose up --build -d
# Verify container operation statuses
docker-compose ps
# Monitor execution system logs
docker-compose logs -f
```
Once running successfully via Docker orchestration loops, navigate your web browser context safely to `https://localhost:8080` to interact with your MiauInv control panel workspace.
## Reverse Proxy Integration with Caddy
If you deploy MiauInv behind a global Caddy server, Caddy must act as an HTTPS reverse proxy. Since the MiauInv binary enforces native TLS transport, Caddy needs to be configured to establish a secure backend connection and bypass verification for self-signed backend certificates.
### 1. Docker Compose Network Configuration
Ensure your MiauInv container shares an external network with your Caddy container (e.g., a network named `proxy`). The container does not need to expose public ports since Caddy communicates with it internally over port `8080`.
```yaml
services:
miauinv:
image: git.miaurizius.de/miaurizius/miauinv:latest
container_name: MiauInv
restart: unless-stopped
networks:
- proxy
environment:
- JWT_SECRET=SECURE_RANDOM_STRING
volumes:
- ./appdata:/appdata
networks:
proxy:
external: true
```
### 2. Caddyfile Configuration
Add the following block to your server's `Caddyfile`. The `https://` prefix forces Caddy to use TLS for the backend connection, and `tls_insecure_skip_verify` allows the proxy to accept the internal self-signed certificate generated during the prerequisites step.
```caddy
inv.yourdomain.com {
encode zstd gzip
reverse_proxy https://miauinv:8080 {
transport http {
tls_insecure_skip_verify
}
}
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
}
```
### 3. Apply Configuration
Reload your Caddy instance to apply the reverse proxy routing rules:
```bash
docker compose exec -w /etc/caddy caddy caddy reload
```
## Images
#### Dashboard
<img src="docs/img/dashboard.png">
#### Inventory
<img src="docs/img/inventory.png">
#### Locations
<img src="docs/img/locations.png">
#### Projects
<img src="docs/img/projects.png">

407
ad.html Normal file
View File

@@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiauInv - Modern Inventory & Project Management</title>
<style>
:root {
--bg: #0b0f19;
--card: #111827;
--border: #1f2937;
--text: #f9fafb;
--text-muted: #9ca3af;
--accent: #3b82f6;
--accent-hover: #2563eb;
--success: #10b981;
--code-bg: #1f2937;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
padding: 0;
margin: 0;
}
.navbar {
display: flex;
justify-content: flex-end;
padding: 1rem 2rem;
max-width: 1000px;
margin: 0 auto;
}
.lang-switch {
background: var(--card);
border: 1px solid var(--border);
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;
font-size: 0.85rem;
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;
margin-bottom: 1.25rem;
letter-spacing: -0.02em;
}
.features-list {
list-style: none;
text-align: left;
}
.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;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: bold;
}
pre {
background: var(--bg);
border: 1px solid var(--border);
padding: 1rem 1.25rem;
border-radius: 10px;
font-family: monospace;
font-size: 0.9rem;
color: #e5e7eb;
overflow-x: auto;
white-space: pre;
margin-bottom: 1rem;
}
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; }
html[data-lang="de"] [lang="de"] { display: block; }
html[data-lang="de"] [lang="en"] { display: none; }
html[data-lang="de"] span[lang="de"],
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>
</head>
<body class="lang-en">
<div class="navbar">
<button class="lang-switch" id="langBtn" onclick="toggleLanguage()">DE</button>
</div>
<div class="container">
<header>
<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="de">Ein sicheres, pfeilschnelles und minimalistisches System für deine Lagerbestände und Projekt-Zuweisungen.</p>
<div class="badge-container">
<span class="badge">Go Backend</span>
<span class="badge">HTMX Frontend</span>
<span class="badge">SQLite Inside</span>
<span class="badge green">Docker Ready</span>
</div>
</header>
<div class="grid">
<div class="card">
<h2 lang="en">Why MiauInv?</h2>
<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 class="card">
<h2 lang="en">Security & Tech Stack</h2>
<h2 lang="de">Sicherheit & Tech</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">Dual-Token Security:</strong><strong lang="de">Dual-Token Security:</strong>
<span lang="en"> Uses robust cryptographically signed JWT cookies combined with seamless backend refresh token rotations.</span>
<span lang="de"> Zugriffsschutz über sichere, verschlüsselte JWT-Cookies inkl. Refresh-Token-Rotation.</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">Native TLS Listener:</strong><strong lang="de">Native TLS Listener:</strong>
<span lang="en"> Out-of-the-box enforced HTTPS transport layer security powered directly by Go's core networking.</span>
<span lang="de"> Enforcierte HTTPS-Verschlüsselung direkt aus dem Go-Core heraus.</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">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">
<h2 lang="en">Install Your Server</h2>
<h2 lang="de">Server installieren</h2>
<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">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:
miauinv:
image: git.miaurizius.de/miaurizius/miauinv:latest
container_name: MiauInv
restart: unless-stopped
ports:
- "8080:8080"
environment:
- JWT_SECRET=SECURE_RANDOM_STRING_HERE_MIN_32_CHARS
volumes:
- ./appdata:/appdata</pre>
</div>
<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>
<p lang="en">&copy; 2026 Maurice Larivière.</p>
<p lang="de">&copy; 2026 Maurice Larivière.</p>
</footer>
</div>
<script>
function setLanguage(lang) {
document.documentElement.setAttribute('data-lang', lang);
document.getElementById('langBtn').innerText = lang === 'en' ? 'DE' : 'EN';
localStorage.setItem('miauinv-lang', lang);
}
function toggleLanguage() {
const currentLang = document.documentElement.getAttribute('data-lang') || 'en';
const nextLang = currentLang === 'en' ? 'de' : 'en';
setLanguage(nextLang);
}
const savedLang = localStorage.getItem('miauinv-lang') || 'en';
setLanguage(savedLang);
</script>
</body>
</html>

View File

@@ -1,6 +1,7 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -12,6 +13,12 @@ type Claims struct {
jwt.RegisteredClaims
}
type PurposeClaims struct {
UserID string `json:"user_id"`
Purpose string `json:"purpose"`
jwt.RegisteredClaims
}
func GenerateJWT(userID, role string, secret []byte) (string, error) {
claims := Claims{
UserID: userID,
@@ -25,8 +32,26 @@ func GenerateJWT(userID, role string, secret []byte) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
func GeneratePurposeJWT(userID, purpose string, secret []byte, ttl time.Duration) (string, error) {
claims := PurposeClaims{
UserID: userID,
Purpose: purpose,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
func ValidateJWT(tokenStr string, secret []byte) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return secret, nil
})
if err != nil {
@@ -35,7 +60,29 @@ func ValidateJWT(tokenStr string, secret []byte) (*Claims, error) {
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, err
return nil, errors.New("invalid token")
}
return claims, nil
}
func ValidatePurposeJWT(tokenStr, expectedPurpose string, secret []byte) (*PurposeClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &PurposeClaims{}, func(token *jwt.Token) (interface{}, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*PurposeClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
if claims.Purpose != expectedPurpose {
return nil, errors.New("invalid token purpose")
}
return claims, nil

View File

@@ -10,9 +10,16 @@ type contextKey string
const UserContextKey contextKey = contextKey("user")
// middleware.go
func AuthMiddleware(secret []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/login" || r.URL.Path == "/register" || r.URL.Path == "/" {
next.ServeHTTP(w, r)
return
}
tokenStr := ""
authHeader := r.Header.Get("Authorization")
@@ -37,22 +44,23 @@ func AuthMiddleware(secret []byte) func(http.Handler) http.Handler {
}
claims, err := ValidateJWT(tokenStr, secret)
if err != nil {
if strings.HasPrefix(r.URL.Path, "/api/") {
http.Error(w, "Invalid token", http.StatusUnauthorized)
} else {
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
return
}
ctx := context.WithValue(
r.Context(),
UserContextKey,
claims,
)
ctx := context.WithValue(r.Context(), UserContextKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -1 +0,0 @@
sudo docker buildx build --platform linux/amd64,linux/arm64 -t git.miaurizius.de/miaurizius/miauinv:latest --push .

51
docs/AUTHENTICATION.md Normal file
View File

@@ -0,0 +1,51 @@
# Authentication Architecture
MiauInv implements a stateless JSON Web Token (JWT) architecture combined with a persistent database-backed Refresh Token mechanism to provide high security alongside seamless session retention.
## Token Lifetime and Properties
| Token Type | Transport Vector | Storage Location | Lifetime | Purpose |
| --- | --- | --- | --- | --- |
| **Access Token** | HTTP-Only Cookie & Auth Header | Memory / Browser Cookies | 15 Minutes | Signed payload validating current session identity for immediate API interaction. |
| **Refresh Token** | Secure Cookie & JSON Payload | LocalStorage / Secure Cookies | 7 Days | Long-lived high-entropy string used to request a new token pair when the Access Token expires. |
---
## Token Rotation and Flow
The application coordinates token validation through cooperative interactions between Go authentication middlewares and the frontend runtime environment.
### 1. Normal Authenticated Requests
During standard interaction loops, the Go server intercepts requests via auth middleware. It checks the incoming context for validity in the following order:
1. `Authorization: Bearer <token>` request header.
2. `access_token` cookie values.
If a valid, unexpired Access Token is recovered, the middleware parses the claims (ID, username, role) and injects them into the request context before execution routes fire.
### 2. Token Refresh Flow
When an Access Token expires mid-session, the following workflow occurs automatically:
1. The backend rejects an API call or routing intent with an HTTP state indicating token expiration.
2. The frontend execution scope identifies the expiration status and reads the `refresh_token` from storage assets.
3. The client submits a POST request containing the token payload to `/api/refresh`.
4. The backend verifies the signature, looks up the hash inside the `refresh_tokens` table, and verifies that `revoked == 0` and `expires_at > now`.
5. If the validation succeeds, a brand-new Access Token and a rotated Refresh Token pair are generated, saved to secure cookies/storage, and the user session continues without explicit re-authentication.
---
## Security Mitigations
### Loop Protection
To prevent broken, expired, or malformed credentials from triggering infinite network refresh loops (which degrade browser performance and strain backend lookup performance), the frontend utilizes an explicit safety lock.
```
Token Expired -> Check 'is_refreshing' flag -> True -> Clear Auth & Force Login
-> False -> Set flag 'true' -> Send Request
```
Before issuing an evaluation request to `/api/refresh`, the application checks a temporary session variable (`is_refreshing` within `sessionStorage`). If the flag is already set to `true`, the loop protection triggers a hard clearance routine via `clearAllAuth()`, drops all token storage records, and routes the user back to the primary login view safely.
### Database Revocation
Refresh sessions can be killed immediately from the server side. When a user requests `/api/logout`, the backend switches the corresponding row state within the `refresh_tokens` database container to `revoked = 1`. Any subsequent rotation requests relying on that token family are automatically dropped, protecting against stolen credential replay attacks.

97
docs/DATABASE.md Normal file
View File

@@ -0,0 +1,97 @@
# Database Documentation
MiauInv utilizes an embedded SQLite database instance for persistent data storage. Foreign key constraints are strictly enforced at the database level.
## Configuration
To ensure data integrity, every database connection initialization explicitly executes the following command before handling queries:
```sql
PRAGMA foreign_keys = ON;
```
---
## Schema Architecture
### Entity-Relationship Summary
The database consists of primary entity tables (`users`, `items`, `locations`, `projects`) and relational junction tables (`stock`, `project_items`, `refresh_tokens`) designed to track stock distribution and access sessions.
```
[users] <--- (1:N) ---> [refresh_tokens]
[items] <--- (1:N) ---> [stock] <--- (N:1) ---> [locations]
[items] <--- (1:N) ---> [project_items] <--- (N:1) ---> [projects]
```
---
## Table Definitions
### 1. users
Stores user credentials and operational roles within the system.
* **id (TEXT, PK):** Unique UUID
* **username (TEXT, Unique):** Unique account identifier.
* **password (TEXT):** Hashed user password.
* **role (TEXT):** Access control flag (e.g., admin, user).
### 2. refresh_tokens
Tracks valid extended sessions linked to specific user accounts.
* **id (TEXT, PK):** Unique identifier.
* **user_id (TEXT, FK):** References `users(id)`.
* **token_hash (TEXT):** Cryptographic hash of the active refresh token.
* **expires_at (INTEGER):** Unix timestamp indicating token expiration.
* **created_at (INTEGER):** Unix timestamp indicating session creation.
* **revoked (INTEGER):** Boolean flag (0 or 1) indicating if the session was manually invalidated.
* **device_info (TEXT, Optional):** Client metadata for auditing.
### 3. items
Represents individual tracked assets.
* **id (INTEGER, PK, Autoincrement):** Primary key.
* **name (TEXT):** Asset designation.
* **category (TEXT, Optional):** Grouping classification.
* **description (TEXT, Optional):** Detailed asset context.
* **total_quantity (INTEGER):** Absolute global stock baseline counter.
### 4. locations
Defines logical or physical facilities.
* **id (INTEGER, PK, Autoincrement):** Primary key.
* **name (TEXT, Unique):** Unique facility naming constraint.
### 5. projects
Defines distinct tasks or allocation targets.
* **id (INTEGER, PK, Autoincrement):** Primary key.
* **name (TEXT, Unique):** Unique operational tracking name.
* **description (TEXT, Optional):** Scope description.
### 6. stock
Junction table mapping physical asset distributions across facilities.
* **id (INTEGER, PK, Autoincrement):** Primary key.
* **item_id (INTEGER, FK):** References `items(id)`.
* **location_id (INTEGER, FK):** References `locations(id)`.
* **quantity (INTEGER):** Specific quantity present at this location node.
### 7. project_items
Junction table tracking asset assignments dedicated to specific ongoing project environments.
* **id (INTEGER, PK, Autoincrement):** Primary key.
* **item_id (INTEGER, FK):** References `items(id)`.
* **project_id (INTEGER, FK):** References `projects(id)`.
* **quantity (INTEGER):** Quantity allocated to this project context.
---
## Data Integrity Constraints
* **Foreign Keys:** Because standard `ON DELETE` cascades are not defined explicitly in the schema rules, SQLite blocks parent deletion actions if dependent rows exist in `stock` or `project_items`. You must clear out stock allocations and project associations manually before deleting an item, location, or project.
* **Uniqueness:** String uniqueness constraints protect against duplicate namespace registration on `users(username)`, `locations(name)`, and `projects(name)`.

BIN
docs/img/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
docs/img/inventory.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
docs/img/locations.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
docs/img/projects.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -0,0 +1,108 @@
:root {
--bg: #111827;
--card: #1f2937;
--border: #374151;
--text: #f9fafb;
--text-muted: #9ca3af;
--accent: #3b82f6;
--accent-hover: #2563eb;
--success: #10b981;
--error: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
}
body:not(.dashboard-layout) {
justify-content: center;
align-items: center;
padding: 1rem;
}
.card {
width: 100%;
max-width: 400px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2.5rem 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
text-align: center;
}
.card h1 {
margin: 0 0 0.5rem 0;
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.025em;
}
.card .subtitle {
color: var(--text-muted);
font-size: 0.95rem;
margin-bottom: 2rem;
}
.btn {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 0.85rem;
font-size: 1rem;
font-weight: 600;
border-radius: 10px;
border: none;
cursor: pointer;
text-decoration: none;
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
}
.btn:active {
transform: scale(0.98);
}
.btn-secondary {
background: #1f2937;
color: white;
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--accent);
border-color: var(--accent);
}
.footer-text {
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--text-muted);
}
.message {
margin-top: 1.25rem;
padding: 0.85rem 1rem;
border-radius: 10px;
font-size: 0.9rem;
line-height: 1.4;
text-align: left;
}
.message.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
display: block;
}

View File

@@ -0,0 +1,24 @@
.construction-icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
background: rgba(59, 130, 246, 0.1); /* Nutzt die var(--accent) Transparenz */
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 50%;
margin-bottom: 1.5rem;
}
.gear-icon {
animation: gearRotate 8s linear infinite;
}
@keyframes gearRotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -4,6 +4,7 @@ document.addEventListener("DOMContentLoaded", () => {
const dropdownMenu = document.getElementById('dropdown-menu');
const menuBtn = document.getElementById('menu-btn');
const mainNav = document.getElementById('main-nav');
const logoutBtn = document.getElementById('logout-btn');
if (profileBtn && dropdownMenu) {
profileBtn.addEventListener('click', (e) => {
@@ -22,7 +23,15 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
window.addEventListener('click', () => {
logoutBtn.addEventListener('click', () => {
console.log("Logout")
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
document.cookie = "access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
document.cookie = "refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
});
document.addEventListener('click', () => {
if (dropdownMenu) dropdownMenu.classList.remove('show');
if (mainNav) mainNav.classList.remove('show');
});
@@ -30,6 +39,8 @@ document.addEventListener("DOMContentLoaded", () => {
if (document.getElementById('items-table-body')) loadItems();
if (document.getElementById('locations-table-body')) loadLocations();
if (document.getElementById('projects-table-body')) loadProjects();
loadProfile();
});
function closeModal(id) {
@@ -279,17 +290,18 @@ async function reloadStockTable(itemId) {
return;
}
data.stock.forEach(st => {
for (const st of data.stock) {
const locData = await apiRequest(`/api/location?id=${st.location_id}`);
tbody.innerHTML += `
<tr>
<td>${st.location_name || `Location #${st.location_id}`}</td>
<td>${locData.name || `Location #${st.location_id}`}</td>
<td><span class="badge success">${st.quantity}</span></td>
<td style="text-align: right;">
<button class="btn btn-secondary danger-btn" style="padding: 0.2rem 0.5rem; font-size: 0.75rem;" onclick="deleteStock(${st.id}, ${itemId})">Del</button>
</td>
</tr>
`;
});
}
} catch (e) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">Failed to load.</td></tr>';
}
@@ -349,17 +361,19 @@ async function reloadAssociationTable(projectId) {
return;
}
data.associations.forEach(asc => {
for (const asc of data.associations) {
const itemData = await apiRequest(`/api/item?id=${asc.item_id}`);
console.log(itemData)
tbody.innerHTML += `
<tr>
<td>${asc.item_name || `Item #${asc.item_id}`}</td>
<td>${itemData.name || `Item #${asc.item_id}`}</td>
<td><span class="badge success">${asc.quantity}</span></td>
<td style="text-align: right;">
<button class="btn btn-secondary danger-btn" style="padding: 0.2rem 0.5rem; font-size: 0.75rem;" onclick="deleteAssociation(${asc.id}, ${projectId})">Del</button>
</td>
</tr>
`;
});
}
} catch (e) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">Failed to load.</td></tr>';
}
@@ -452,4 +466,19 @@ async function openDashboardModal(url, title, dataKey) {
} catch (e) {
tbody.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
}
}
// ---- PROFILE ----
async function loadProfile() {
const avatar = document.getElementById("avatar");
const username = document.getElementById('username');
try {
const data = await apiRequest('/api/profile');
avatar.innerText = data.username[0].toLocaleUpperCase();
username.innerText = data.username;
} catch (e) {
username.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
}
}

View File

@@ -13,23 +13,20 @@
return null;
}
const cookieAccessToken = getCookie("access_token");
const cookieRefreshToken = getCookie("refresh_token");
const localAccessToken = localStorage.getItem("access_token");
const localRefreshToken = localStorage.getItem("refresh_token");
const accessToken = cookieAccessToken || localAccessToken;
const refreshToken = cookieRefreshToken || localRefreshToken;
if (!accessToken && !refreshToken) {
return;
function clearAllAuth() {
console.log("Clearing all auth remnants from everywhere...");
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
sessionStorage.removeItem("is_refreshing");
document.cookie = "access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; Secure; SameSite=Lax;";
document.cookie = "refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; Secure; SameSite=Lax;";
}
async function tryTokenRefresh() {
async function tryTokenRefresh(refreshToken) {
if (!refreshToken) return false;
try {
console.log("Sending refresh token to /api/refresh...");
const response = await fetch("/api/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -38,72 +35,94 @@
if (response.ok) {
const data = await response.json();
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
document.cookie = `access_token=${data.access_token}; path=/; max-age=900; SameSite=Lax; Secure`;
document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=604800; SameSite=Lax; Secure`;
return true;
}
} catch (err) {
console.error("Refresh request failed:", err);
}
document.cookie = "access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
document.cookie = "refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
return false;
}
async function checkAuth() {
console.log("Auth check started...");
console.log("AccessToken present:", !!accessToken);
console.log("RefreshToken present:", !!refreshToken);
const cookieAccessToken = getCookie("access_token");
const cookieRefreshToken = getCookie("refresh_token");
const localAccessToken = localStorage.getItem("access_token");
const localRefreshToken = localStorage.getItem("refresh_token");
if (!cookieAccessToken && accessToken) {
console.log("Access token cookie missing, but present in localStorage. Forcing refresh...");
} else if (accessToken) {
const accessToken = cookieAccessToken || localAccessToken;
const refreshToken = cookieRefreshToken || localRefreshToken;
console.log("Auth check started...");
console.log("AccessToken available (Cookie/Local):", !!cookieAccessToken, "/", !!localAccessToken);
console.log("RefreshToken available (Cookie/Local):", !!cookieRefreshToken, "/", !!localRefreshToken);
if (!accessToken && !refreshToken) {
console.log("No tokens found in cookies or localStorage. User is a guest.");
return;
}
if (accessToken) {
try {
console.log("Attempting ping with access token...");
const response = await fetch("/api/ping", {
console.log("Validating token against /api/userinfo...");
const response = await fetch("/api/userinfo", {
method: "GET",
headers: { "Authorization": `Bearer ${accessToken}` }
});
if (response.ok) {
console.log("Ping successful! Redirecting to dashboard...");
console.log("Token is perfectly valid!");
sessionStorage.removeItem("is_refreshing");
if (!cookieAccessToken) {
document.cookie = `access_token=${accessToken}; path=/; max-age=900; SameSite=Lax; Secure`;
}
if (!cookieRefreshToken && localRefreshToken) {
document.cookie = `refresh_token=${localRefreshToken}; path=/; max-age=604800; SameSite=Lax; Secure`;
}
console.log("Redirecting to dashboard...");
window.location.href = "/dashboard";
return;
} else {
console.log("Ping failed. Status:", response.status);
console.log("Token rejected by /api/userinfo. Status:", response.status);
}
} catch (err) {
console.error("Network error during ping:", err);
console.error("Network error during token verification:", err);
}
}
if (sessionStorage.getItem("is_refreshing") === "true") {
console.warn("Loop protection triggered! Tokens appear to be corrupt. Clearing storage.");
clearAllAuth();
return;
}
if (refreshToken) {
console.log("Starting token refresh to rebuild cookies...");
const refreshSuccessful = await tryTokenRefresh();
console.log("Access token has expired. Starting refresh process...");
sessionStorage.setItem("is_refreshing", "true");
const refreshSuccessful = await tryTokenRefresh(refreshToken);
if (refreshSuccessful) {
console.log("Refresh successful! Redirecting to dashboard...");
window.location.href = "/dashboard";
console.log("Refresh successful! Reloading page to apply cookies...");
window.location.reload();
return;
} else {
console.log("Refresh failed. Staying on login.");
console.log("Refresh failed. Refresh token has also expired.");
}
} else {
console.log("No refresh token present. User must log in normally.");
console.log("No refresh token available for recovery.");
}
console.log("Authentication completely failed. Clearing remnants...");
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
document.cookie = "access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
document.cookie = "refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
clearAllAuth();
console.log("Authentication completely failed. User remains on login page.");
}
checkAuth();
setTimeout(checkAuth, 50);
})();

View File

@@ -2,22 +2,63 @@
document.addEventListener("DOMContentLoaded", () => {
const form = document.getElementById("login-form");
const errorBox = document.getElementById("error");
const usernameInput = document.getElementById("username");
const passwordInput = document.getElementById("password");
const twoFactorInput = document.getElementById("two-factor-code");
const twoFactorGroup = document.getElementById("two-factor-group");
const submitButton = document.getElementById("login-submit");
let pendingTwoFactorToken = null;
if (!form) return;
function showError(message) {
errorBox.textContent = message || "Login failed.";
errorBox.style.display = "block";
}
function storeTokens(data) {
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
}
function switchToTwoFactorMode(token) {
pendingTwoFactorToken = token;
usernameInput.disabled = true;
passwordInput.disabled = true;
twoFactorGroup.style.display = "block";
twoFactorInput.required = true;
twoFactorInput.focus();
submitButton.textContent = "Verify code";
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorBox.style.display = "none";
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
submitButton.disabled = true;
try {
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
let response;
if (pendingTwoFactorToken) {
response = await fetch("/api/login/2fa", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
two_factor_token: pendingTwoFactorToken,
code: twoFactorInput.value.trim()
})
});
} else {
response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: usernameInput.value,
password: passwordInput.value
})
});
}
if (!response.ok) {
const text = await response.text();
@@ -26,17 +67,17 @@ document.addEventListener("DOMContentLoaded", () => {
const data = await response.json();
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
document.cookie = `access_token=${data.access_token}; path=/; max-age=900; SameSite=Lax; Secure`;
document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=604800; SameSite=Lax; Secure`;
if (data.requires_2fa) {
switchToTwoFactorMode(data.two_factor_token);
return;
}
storeTokens(data);
window.location.href = "/dashboard";
} catch (err) {
errorBox.textContent = err.message || "Login failed.";
errorBox.style.display = "block";
showError(err.message);
} finally {
submitButton.disabled = false;
}
});
});
});

View File

@@ -1,10 +1,16 @@
package frontend
import (
"MiauInv/storage"
"html/template"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/css"
"github.com/tdewolff/minify/v2/js"
)
var dashboard = template.Must(template.ParseFiles(
@@ -48,7 +54,28 @@ func Home(w http.ResponseWriter, r *http.Request) {
func Dashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := dashboard.ExecuteTemplate(w, "base.html", struct {
var itemHive, projectHive, locationHive int
err := storage.DB.QueryRow("SELECT COUNT(*) FROM items").Scan(&itemHive)
if err != nil {
http.Error(w, "Failed to count items", http.StatusInternalServerError)
return
}
err = storage.DB.QueryRow("SELECT COUNT(*) FROM projects").Scan(&projectHive)
if err != nil {
http.Error(w, "Failed to count projects", http.StatusInternalServerError)
return
}
err = storage.DB.QueryRow("SELECT COUNT(*) FROM locations").Scan(&locationHive)
if err != nil {
http.Error(w, "Failed to count locations", http.StatusInternalServerError)
return
}
err = dashboard.ExecuteTemplate(w, "base.html", struct {
Title string
Stats struct {
Items int
@@ -62,9 +89,9 @@ func Dashboard(w http.ResponseWriter, r *http.Request) {
Projects int
Locations int
}{
Items: 1,
Projects: 1,
Locations: 3,
Items: itemHive,
Projects: projectHive,
Locations: locationHive,
},
})
if err != nil {
@@ -116,7 +143,50 @@ func Projects(w http.ResponseWriter, r *http.Request) {
}
}
var minifier *minify.M
func init() {
minifier = minify.New()
// Füge die Minifier für CSS und JS hinzu
minifier.AddFunc("text/css", css.Minify)
minifier.AddFunc("text/javascript", js.Minify)
}
func Assets(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/assets/")
http.ServeFile(w, r, filepath.Join("frontend/assets", path))
fullPath := filepath.Join("frontend/assets", path)
w.Header().Set("Cache-Control", "public, max-age=3600")
var mimeType string
if strings.HasSuffix(path, ".min.js") {
mimeType = "text/javascript"
fullPath = strings.Replace(fullPath, ".min.js", ".js", 1)
} else if strings.HasSuffix(path, ".min.css") {
mimeType = "text/css"
fullPath = strings.Replace(fullPath, ".min.css", ".css", 1)
}
info, err := os.Stat(fullPath)
if err != nil || info.IsDir() {
http.Error(w, "Asset not found", http.StatusNotFound)
return
}
if mimeType != "" {
content, err := os.ReadFile(fullPath)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
minifiedContent, err := minifier.Bytes(mimeType, content)
if err == nil {
w.Header().Set("Content-Type", mimeType)
w.Write(minifiedContent)
return
}
}
http.ServeFile(w, r, fullPath)
}

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Page Not Found | MiauInv</title>
<link rel="stylesheet" href="/assets/css/theme.css">
<link rel="stylesheet" href="/assets/css/error404.css">
<link rel="stylesheet" href="/assets/css/theme.min.css">
<link rel="stylesheet" href="/assets/css/error404.min.css">
</head>
<body>

View File

@@ -1,14 +1,13 @@
{{ define "base.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }} | MiauInv</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="/assets/js/api.js"></script>
<link rel="stylesheet" href="/assets/css/theme.css">
<link rel="stylesheet" href="/assets/css/dashboard.css">
<script src="/assets/js/api.min.js"></script>
<link rel="stylesheet" href="/assets/css/theme.min.css">
<link rel="stylesheet" href="/assets/css/dashboard.min.css">
</head>
<body class="dashboard-layout">
@@ -31,15 +30,15 @@
<div class="profile-dropdown">
<button class="profile-trigger" id="profile-btn">
<div class="avatar">M</div>
<span class="username">Admin</span>
<div id="avatar" class="avatar">M</div>
<span id="username" class="username">Loading...</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
<div class="dropdown-menu" id="dropdown-menu">
<a href="/profile/settings">Account Settings</a>
<a href="/profile/activity">Activity Log</a>
<hr class="dropdown-divider">
<button class="logout-btn"
<button id="logout-btn" class="logout-btn"
hx-post="/api/logout"
hx-on::after-request="window.location.href='/login'">
Log Out
@@ -53,7 +52,6 @@
{{ template "content" . }}
</main>
<script src="/assets/js/api.js"></script>
</body>
</html>
{{ end }}

View File

@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiauInv | Private Instance</title>
<script src="/assets/js/auth.js"></script>
<link rel="stylesheet" href="/assets/css/theme.css">
<link rel="stylesheet" href="/assets/css/home.css">
<link rel="stylesheet" href="/assets/css/theme.min.css">
<link rel="stylesheet" href="/assets/css/home.min.css">
</head>
<body>

View File

@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign In | MiauInv</title>
<script src="/assets/js/auth.js" defer></script>
<script src="/assets/js/login.js" defer></script>
<script src="/assets/js/auth.min.js" defer></script>
<script src="/assets/js/login.min.js" defer></script>
<link rel="stylesheet" href="/assets/css/theme.css">
<link rel="stylesheet" href="/assets/css/theme.min.css">
</head>
<body>
@@ -24,12 +24,18 @@
<input type="text" id="username" placeholder="Username" autocomplete="username" required>
</div>
<div class="form-group">
<div class="form-group" id="password-group">
<label for="password" class="sr-only">Password</label>
<input type="password" id="password" placeholder="Password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary">Sign In</button>
<div class="form-group" id="two-factor-group" style="display: none;">
<label for="two-factor-code" class="sr-only">2FA code</label>
<input type="text" id="two-factor-code" placeholder="Authenticator or recovery code" autocomplete="one-time-code" inputmode="text" pattern="[0-9A-Za-z\- ]*">
<p class="subtitle" style="margin-top: 0.75rem;">Enter your 6-digit authenticator code or one recovery code.</p>
</div>
<button type="submit" id="login-submit" class="btn btn-primary">Sign In</button>
</form>
<div id="error" class="message error"></div>

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registration Disabled - MiauInv</title>
<link rel="stylesheet" href="/assets/css/register-blocked.min.css">
</head>
<body>
<div class="card">
<h1>Registration</h1>
<div class="subtitle">Create a new account</div>
<div class="message error">
<strong>Access Denied:</strong> Public registration is currently disabled for this system. Please contact your system administrator to request an account.
</div>
<div class="footer-text">
<a class="btn btn-secondary" href="/login" style="margin-top: 1.5rem;">Back to Login</a>
</div>
</div>
</body>
</html>

View File

@@ -4,8 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register | MiauInv</title>
<script src="/assets/js/auth.js"></script>
<link rel="stylesheet" href="/assets/css/theme.css">
<script src="/assets/js/auth.min.js"></script>
<script src="/assets/js/register.min.js"></script>
<link rel="stylesheet" href="/assets/css/theme.min.css">
</head>
<body>
@@ -36,6 +37,5 @@
</p>
</main>
<script src="/assets/js/register.js"></script>
</body>
</html>

View File

@@ -1,134 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registration Disabled - MiauInv</title>
<style>
:root {
--bg: #111827;
--card: #1f2937;
--border: #374151;
--text: #f9fafb;
--text-muted: #9ca3af;
--accent: #3b82f6;
--accent-hover: #2563eb;
--success: #10b981;
--error: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
}
body:not(.dashboard-layout) {
justify-content: center;
align-items: center;
padding: 1rem;
}
.card {
width: 100%;
max-width: 400px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2.5rem 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
text-align: center;
}
.card h1 {
margin: 0 0 0.5rem 0;
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.025em;
}
.card .subtitle {
color: var(--text-muted);
font-size: 0.95rem;
margin-bottom: 2rem;
}
.btn {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 0.85rem;
font-size: 1rem;
font-weight: 600;
border-radius: 10px;
border: none;
cursor: pointer;
text-decoration: none;
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
}
.btn:active {
transform: scale(0.98);
}
.btn-secondary {
background: #1f2937;
color: white;
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--accent);
border-color: var(--accent);
}
.footer-text {
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--text-muted);
}
.message {
margin-top: 1.25rem;
padding: 0.85rem 1rem;
border-radius: 10px;
font-size: 0.9rem;
line-height: 1.4;
text-align: left;
}
.message.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
display: block;
}
</style>
</head>
<body>
<div class="card">
<h1>Registration</h1>
<div class="subtitle">Create a new account</div>
<div class="message error">
<strong>Access Denied:</strong> Public registration is currently disabled for this system. Please contact your system administrator to request an account.
</div>
<div class="footer-text">
<a class="btn btn-secondary" href="/login" style="margin-top: 1.5rem;">Back to Login</a>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Feature Under Development | MiauInv</title>
<link rel="stylesheet" href="/assets/css/theme.min.css">
<link rel="stylesheet" href="/assets/css/under-construction.min.css">
</head>
<body>
<div class="card">
<div class="construction-icon-wrapper">
<svg class="gear-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</div>
<h1>Coming Soon</h1>
<div class="subtitle">Feature Under Development</div>
<div class="message success" style="display: block; margin-bottom: 2rem;">
<strong>Development in progress!</strong><br>
Our team is actively working on this feature to bring you the best experience possible.
</div>
<a href="/dashboard" class="btn btn-secondary">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px;"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
Back to Dashboard
</a>
<div class="footer-text">
Need urgent assistance? <a href="mailto:maurice@miaurizius.de">Contact Developer</a>
</div>
</div>
</body>
</html>

6
go.mod
View File

@@ -5,15 +5,19 @@ go 1.26
require (
github.com/glebarez/go-sqlite v1.22.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.5.0
github.com/pquerna/otp v1.5.0
github.com/tdewolff/minify/v2 v2.24.13
golang.org/x/crypto v0.52.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect
golang.org/x/sys v0.45.0 // indirect
modernc.org/libc v1.37.6 // indirect
modernc.org/mathutil v1.6.0 // indirect

18
go.sum
View File

@@ -1,3 +1,7 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@@ -10,8 +14,22 @@ github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -2,18 +2,23 @@ package handlers
import (
"MiauInv/auth"
"MiauInv/config"
"MiauInv/models"
"MiauInv/storage"
"MiauInv/util"
utils "MiauInv/util"
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"image/png"
"log"
"net/http"
"os"
"strings"
"time"
)
var cfg, _ = config.LoadConfig()
"github.com/pquerna/otp/totp"
)
func APIRegister(w http.ResponseWriter, r *http.Request) {
var user models.User
@@ -29,6 +34,12 @@ func APIRegister(w http.ResponseWriter, r *http.Request) {
return
}
if len(user.Password) > 72 {
log.Println("POST [api/register] User password too long")
http.Error(w, "Password exceeds the maximum allowed length of 72 characters", http.StatusUnprocessableEntity)
return
}
hashed, err := auth.HashPassword(user.Password)
if err != nil {
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
@@ -48,6 +59,7 @@ func APIRegister(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user")
}
func APILogin(w http.ResponseWriter, r *http.Request) {
var creds struct {
Username string `json:"username"`
@@ -79,76 +91,300 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
return
}
accessToken, err := auth.GenerateJWT(user.ID, user.Role, secret)
if err != nil {
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate token", http.StatusInternalServerError)
if user.TwoFactorEnabled {
twoFactorToken, err := auth.GeneratePurposeJWT(user.ID, "2fa_login", secret, 5*time.Minute)
if err != nil {
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate 2FA challenge", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"requires_2fa": true,
"two_factor_token": twoFactorToken,
})
log.Println("POST [api/login] " + r.RemoteAddr + ": Password accepted, 2FA required")
return
}
refreshTokenPlain, err := utils.GenerateRefreshToken()
if err != nil {
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
return
}
refreshHash := utils.HashToken(refreshTokenPlain)
refreshID := utils.GenerateUUID()
refreshExpires := time.Now().Add(7 * 24 * time.Hour).Unix() // expiry: 7 days
deviceInfo := r.Header.Get("User-Agent")
if err := storage.AddRefreshToken(&models.RefreshToken{
ID: refreshID,
UserID: user.ID,
Token: refreshHash,
ExpiresAt: refreshExpires,
DeviceInfo: deviceInfo,
CreatedAt: time.Now().Unix(),
Revoked: false,
}); err != nil {
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "could not save refresh token", http.StatusInternalServerError)
return
}
// Return access + refresh token (refresh in plain for client to store securely)
resp := map[string]interface{}{
"access_token": accessToken,
"refresh_token": refreshTokenPlain,
"user": map[string]interface{}{
"id": user.ID,
"username": user.Username,
"role": user.Role,
},
}
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: accessToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: refreshTokenPlain,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(resp)
if err != nil {
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
issueLoginSession(w, r, user)
log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in")
}
func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
var req struct {
TwoFactorToken string `json:"two_factor_token"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
secret := []byte(os.Getenv("JWT_SECRET"))
claims, err := auth.ValidatePurposeJWT(req.TwoFactorToken, "2fa_login", secret)
if err != nil {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid or expired 2FA challenge", http.StatusUnauthorized)
return
}
user, err := storage.GetUserById(claims.UserID)
if err != nil || !user.TwoFactorEnabled || user.TwoFactorSecret == "" {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": 2FA not available for user")
http.Error(w, "Invalid 2FA state", http.StatusUnauthorized)
return
}
code := strings.TrimSpace(req.Code)
validTOTP := totp.Validate(code, user.TwoFactorSecret)
usedRecoveryCode := false
if !validTOTP {
recoveryCodeHash := utils.HashToken(normalizeRecoveryCode(code))
usedRecoveryCode, err = storage.UseUserRecoveryCode(user.ID, recoveryCodeHash)
if err != nil {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not validate recovery code", http.StatusInternalServerError)
return
}
}
if !validTOTP && !usedRecoveryCode {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Invalid 2FA or recovery code")
http.Error(w, "Invalid 2FA or recovery code", http.StatusUnauthorized)
return
}
issueLoginSession(w, r, user)
if usedRecoveryCode {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with recovery code")
return
}
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with 2FA")
}
func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if user.TwoFactorEnabled {
http.Error(w, "2FA is already enabled", http.StatusConflict)
return
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "MiauInv",
AccountName: user.Username,
SecretSize: 20,
})
if err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate 2FA secret", http.StatusInternalServerError)
return
}
if err := storage.SetUserTwoFactorSecret(user.ID, key.Secret()); err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not save 2FA secret", http.StatusInternalServerError)
return
}
img, err := key.Image(220, 220)
if err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate QR code", http.StatusInternalServerError)
return
}
var qr bytes.Buffer
if err := png.Encode(&qr, img); err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not encode QR code", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"secret": key.Secret(),
"otpauth_url": key.URL(),
"qr_code": "data:image/png;base64," + base64.StdEncoding.EncodeToString(qr.Bytes()),
})
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": Created 2FA setup challenge")
}
func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if user.TwoFactorSecret == "" {
http.Error(w, "2FA setup has not been started", http.StatusBadRequest)
return
}
if !totp.Validate(strings.TrimSpace(req.Code), user.TwoFactorSecret) {
http.Error(w, "Invalid 2FA code", http.StatusUnauthorized)
return
}
recoveryCodes, recoveryCodeHashes, err := generateRecoveryCodes(10)
if err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate recovery codes", http.StatusInternalServerError)
return
}
if err := storage.EnableUserTwoFactorWithRecoveryCodes(user.ID, recoveryCodeHashes); err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not enable 2FA", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"two_factor_enabled": true,
"recovery_codes": recoveryCodes,
})
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": Enabled 2FA and generated recovery codes")
}
func TwoFactorDisable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Password string `json:"password"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
if user.TwoFactorEnabled && !totp.Validate(strings.TrimSpace(req.Code), user.TwoFactorSecret) {
http.Error(w, "Invalid 2FA code", http.StatusUnauthorized)
return
}
if err := storage.DisableUserTwoFactor(user.ID); err != nil {
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not disable 2FA", http.StatusInternalServerError)
return
}
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not revoke sessions", http.StatusInternalServerError)
return
}
clearAuthCookies(w)
writeJSON(w, http.StatusOK, map[string]interface{}{"two_factor_enabled": false})
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": Disabled 2FA")
}
func TwoFactorRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Password string `json:"password"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !user.TwoFactorEnabled || user.TwoFactorSecret == "" {
http.Error(w, "2FA is not enabled", http.StatusBadRequest)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
if !totp.Validate(strings.TrimSpace(req.Code), user.TwoFactorSecret) {
http.Error(w, "Invalid 2FA code", http.StatusUnauthorized)
return
}
recoveryCodes, recoveryCodeHashes, err := generateRecoveryCodes(10)
if err != nil {
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate recovery codes", http.StatusInternalServerError)
return
}
if err := storage.ReplaceUserRecoveryCodes(user.ID, recoveryCodeHashes); err != nil {
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not save recovery codes", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"recovery_codes": recoveryCodes,
})
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": Regenerated recovery codes")
}
func Logout(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
err := storage.RevokeAllRefreshTokensForUser(claims.UserID)
@@ -157,8 +393,10 @@ func Logout(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(204)
clearAuthCookies(w)
w.WriteHeader(http.StatusNoContent)
}
func TestHandler(w http.ResponseWriter, r *http.Request) {
claims, _ := utils.IsLoggedIn(w, r)
@@ -174,13 +412,24 @@ func TestHandler(w http.ResponseWriter, r *http.Request) {
}
log.Println("GET [api/ping] " + r.RemoteAddr + ": Successfully tested connection")
}
func RefreshToken(w http.ResponseWriter, r *http.Request) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
if req.RefreshToken == "" {
cookie, err := r.Cookie("refresh_token")
if err == nil {
req.RefreshToken = cookie.Value
}
}
if req.RefreshToken == "" {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Missing refresh token")
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
return
}
@@ -197,43 +446,17 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
log.Println(err)
}
newToken, _ := utils.GenerateRefreshToken()
newHash := utils.HashToken(newToken)
newExpires := time.Now().Add(7 * 24 * time.Hour).Unix() //7 days
newID := utils.GenerateUUID()
deviceInfo := r.Header.Get("User-Agent")
if err = storage.AddRefreshToken(&models.RefreshToken{
ID: newID,
UserID: tokenRow.UserID,
Token: newHash,
ExpiresAt: newExpires,
CreatedAt: time.Now().Unix(),
Revoked: false,
DeviceInfo: deviceInfo,
}); err != nil {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate new refresh token", http.StatusInternalServerError)
return
}
user, err := storage.GetUserById(tokenRow.UserID)
if err != nil {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
accessToken, _ := auth.GenerateJWT(tokenRow.UserID, user.Role, []byte(os.Getenv("JWT_SECRET")))
if err = json.NewEncoder(w).Encode(map[string]string{
"access_token": accessToken,
"refresh_token": newToken,
}); err != nil {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
issueLoginSession(w, r, user)
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token")
}
func UserInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Method " + r.Method + " not allowed")
@@ -242,21 +465,197 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
}
query := r.URL.Query()
idParam := query.Get("id")
if idParam == "" {
tokenStr := ""
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr = strings.TrimPrefix(authHeader, "Bearer ")
}
if tokenStr == "" {
cookie, err := r.Cookie("access_token")
if err == nil {
tokenStr = cookie.Value
}
}
if tokenStr == "" {
http.Error(w, "Missing token", http.StatusUnauthorized)
return
}
claims, err := auth.ValidateJWT(tokenStr, models.JWTSecret)
if err != nil {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Unauthorized: Invalid or expired token", http.StatusUnauthorized)
return
}
idParam = claims.UserID
}
user, err := storage.GetUserById(idParam)
if err != nil {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": User " + idParam + " not found")
http.Error(w, "User not found", http.StatusNotFound)
return
}
recoveryCodesRemaining := 0
if user.TwoFactorEnabled {
if count, err := storage.CountUnusedRecoveryCodes(user.ID); err == nil {
recoveryCodesRemaining = count
}
}
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(map[string]interface{}{
"id": user.ID,
"name": user.Username,
"avatar_url": "",
"id": user.ID,
"username": user.Username,
"avatar_url": "",
"two_factor_enabled": user.TwoFactorEnabled,
"recovery_codes_remaining": recoveryCodesRemaining,
})
if err != nil {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
return
}
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info")
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info of " + user.Username + " (" + user.ID + ")")
}
func issueLoginSession(w http.ResponseWriter, r *http.Request, user models.User) {
secret := []byte(os.Getenv("JWT_SECRET"))
if len(secret) == 0 {
log.Println("AUTH " + r.RemoteAddr + ": Server misconfiguration")
http.Error(w, "Server misconfiguration", http.StatusInternalServerError)
return
}
accessToken, err := auth.GenerateJWT(user.ID, user.Role, secret)
if err != nil {
log.Println("AUTH " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate token", http.StatusInternalServerError)
return
}
refreshTokenPlain, err := utils.GenerateRefreshToken()
if err != nil {
log.Println("AUTH " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
return
}
refreshExpires := time.Now().Add(7 * 24 * time.Hour).Unix()
if err := storage.AddRefreshToken(&models.RefreshToken{
ID: utils.GenerateUUID(),
UserID: user.ID,
Token: utils.HashToken(refreshTokenPlain),
ExpiresAt: refreshExpires,
DeviceInfo: r.Header.Get("User-Agent"),
CreatedAt: time.Now().Unix(),
Revoked: false,
}); err != nil {
log.Println("AUTH " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "could not save refresh token", http.StatusInternalServerError)
return
}
setAuthCookies(w, accessToken, refreshTokenPlain)
writeJSON(w, http.StatusOK, map[string]interface{}{
"access_token": accessToken,
"refresh_token": refreshTokenPlain,
"user": map[string]interface{}{
"id": user.ID,
"username": user.Username,
"role": user.Role,
"two_factor_enabled": user.TwoFactorEnabled,
},
})
}
func generateRecoveryCodes(count int) ([]string, []string, error) {
codes := make([]string, 0, count)
hashes := make([]string, 0, count)
seen := make(map[string]struct{}, count)
for len(codes) < count {
code, err := generateRecoveryCode()
if err != nil {
return nil, nil, err
}
normalized := normalizeRecoveryCode(code)
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
codes = append(codes, code)
hashes = append(hashes, utils.HashToken(normalized))
}
return codes, hashes, nil
}
func generateRecoveryCode() (string, error) {
bytes := make([]byte, 10)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
raw := hex.EncodeToString(bytes)
return raw[0:5] + "-" + raw[5:10] + "-" + raw[10:15] + "-" + raw[15:20], nil
}
func normalizeRecoveryCode(code string) string {
code = strings.TrimSpace(code)
code = strings.ReplaceAll(code, "-", "")
code = strings.ReplaceAll(code, " ", "")
return strings.ToLower(code)
}
func setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) {
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: accessToken,
Path: "/",
MaxAge: 15 * 60,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: refreshToken,
Path: "/",
MaxAge: 7 * 24 * 60 * 60,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
func clearAuthCookies(w http.ResponseWriter) {
for _, name := range []string{"access_token", "refresh_token"} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Println("JSON response error: " + err.Error())
}
}

View File

@@ -21,14 +21,20 @@ func Location(w http.ResponseWriter, r *http.Request) {
contentMode := r.URL.Query().Get("content")
if idStr != "" && contentMode == "true" {
_, _ = strconv.Atoi(idStr)
locationID, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid location ID", http.StatusBadRequest)
return
}
query := `
SELECT s.item_id, i.name, s.quantity
FROM stock s
JOIN items i ON s.item_id = i.id
WHERE s.location_id = ? AND s.quantity > 0
`
rows, err := storage.DB.Query(query)
SELECT s.item_id, i.name, s.quantity
FROM stock s
JOIN items i ON s.item_id = i.id
WHERE s.location_id = ? AND s.quantity > 0
`
rows, err := storage.DB.Query(query, locationID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
@@ -38,9 +44,13 @@ func Location(w http.ResponseWriter, r *http.Request) {
var contents []models.LocationContent
for rows.Next() {
var c models.LocationContent
rows.Scan(&c.ItemID, &c.ItemName, &c.Quantity)
if err := rows.Scan(&c.ItemID, &c.ItemName, &c.Quantity); err != nil {
http.Error(w, "Row scan error", http.StatusInternalServerError)
return
}
contents = append(contents, c)
}
json.NewEncoder(w).Encode(map[string]interface{}{"contents": contents})
return
}
@@ -214,6 +224,34 @@ func Item(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
idStr := r.URL.Query().Get("id")
if idStr != "" {
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID parameter", http.StatusBadRequest)
return
}
var item models.Item
err = storage.DB.QueryRow("SELECT id, name, category, description, total_quantity FROM items WHERE id = ?", id).Scan(&item.ID, &item.Name, &item.Category, &item.Description, &item.TotalQuantity)
if err != nil {
if err == sql.ErrNoRows {
log.Println("GET [api/locations] " + r.RemoteAddr + ": Location not found (ID " + idStr + ")")
http.Error(w, "Location not found", http.StatusNotFound)
return
}
log.Println("GET [api/item] DB Error: " + err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(item)
log.Println("GET [api/item] " + r.RemoteAddr + ": Successfully retrieved item ID " + idStr)
return
}
query := `
SELECT
i.id, i.name, i.category, i.description,
@@ -320,14 +358,20 @@ func Project(w http.ResponseWriter, r *http.Request) {
detailsMode := r.URL.Query().Get("details")
if idStr != "" && detailsMode == "true" {
_, _ = strconv.Atoi(idStr)
projectID, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid project ID", http.StatusBadRequest)
return
}
query := `
SELECT pi.item_id, i.name, pi.quantity
FROM project_items pi
JOIN items i ON pi.item_id = i.id
WHERE pi.project_id = ?
`
rows, err := storage.DB.Query(query)
SELECT pi.item_id, i.name, pi.quantity
FROM project_items pi
JOIN items i ON pi.item_id = i.id
WHERE pi.project_id = ?
`
rows, err := storage.DB.Query(query, projectID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
@@ -337,9 +381,13 @@ func Project(w http.ResponseWriter, r *http.Request) {
var details []models.ProjectDetailItem
for rows.Next() {
var d models.ProjectDetailItem
rows.Scan(&d.ItemID, &d.ItemName, &d.Quantity)
if err := rows.Scan(&d.ItemID, &d.ItemName, &d.Quantity); err != nil {
http.Error(w, "Row scan error", http.StatusInternalServerError)
return
}
details = append(details, d)
}
json.NewEncoder(w).Encode(map[string]interface{}{"items": details})
return
}
@@ -525,7 +573,6 @@ func Associations(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
projectIDStr := r.URL.Query().Get("project_id")
// Optionaler Filter: Alle Items für ein bestimmtes Projekt holen (?project_id=X)
if projectIDStr != "" {
pID, _ := strconv.Atoi(projectIDStr)
rows, err := storage.DB.Query("SELECT id, item_id, project_id, quantity FROM project_items WHERE project_id = ?", pID)
@@ -545,7 +592,6 @@ func Associations(w http.ResponseWriter, r *http.Request) {
return
}
// Einzelne Assoziation anhand der Tabellen-ID (?id=X)
if idStr != "" {
id, _ := strconv.Atoi(idStr)
var pi models.ProjectItem
@@ -559,7 +605,6 @@ func Associations(w http.ResponseWriter, r *http.Request) {
return
}
// Gar kein Parameter -> Komplett-Dump aller Zuweisungen
rows, err := storage.DB.Query("SELECT id, item_id, project_id, quantity FROM project_items")
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)

View File

@@ -1,5 +1,7 @@
package models
var JWTSecret []byte
// Roles
const (
RoleUser = "user"

View File

@@ -1,8 +1,10 @@
package models
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
TwoFactorEnabled bool `json:"two_factor_enabled"`
TwoFactorSecret string `json:"-"`
}

View File

@@ -5,6 +5,7 @@ import (
"MiauInv/config"
"MiauInv/frontend"
"MiauInv/handlers"
"MiauInv/models"
utils "MiauInv/util"
"log"
"net/http"
@@ -44,6 +45,8 @@ func InitServer() *Server {
return nil
}
models.JWTSecret = []byte(jwtSecret)
return &Server{
Port: cfg.Port,
JWTSecret: []byte(jwtSecret),
@@ -68,18 +71,26 @@ func (this *Server) Run() {
mux.Handle("/items", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Items)))
mux.Handle("/locations", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Locations)))
mux.Handle("/projects", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Projects)))
mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html"))
if this.AllowRegistration {
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
} else {
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register_blocked.html"))
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register-blocked.html"))
}
//
// API
//
mux.HandleFunc("/api/login", handlers.APILogin)
mux.HandleFunc("/api/login/2fa", handlers.APILoginTwoFactor)
mux.HandleFunc("/api/refresh", handlers.RefreshToken)
mux.Handle("/api/logout", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Logout)))
mux.Handle("/api/profile", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
mux.Handle("/api/2fa/setup", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorSetup)))
mux.Handle("/api/2fa/enable", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorEnable)))
mux.Handle("/api/2fa/disable", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorDisable)))
mux.Handle("/api/2fa/recovery-codes/regenerate", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorRegenerateRecoveryCodes)))
mux.Handle("/api/userinfo", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
if this.AllowRegistration {
mux.HandleFunc("/api/register", handlers.APIRegister)
}

View File

@@ -2,10 +2,12 @@ package storage
import (
"MiauInv/models"
utils "MiauInv/util"
"database/sql"
"errors"
"log"
"strings"
"time"
_ "github.com/glebarez/go-sqlite"
)
@@ -27,7 +29,9 @@ func InitDB(filepath string) error {
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
role TEXT NOT NULL
role TEXT NOT NULL,
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
two_factor_secret TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
@@ -41,6 +45,16 @@ func InitDB(filepath string) error {
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
code_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
used_at INTEGER DEFAULT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(user_id, code_hash)
);
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
@@ -84,7 +98,26 @@ func InitDB(filepath string) error {
log.Fatal(err)
}
return err
if err := ensureUserTwoFactorColumns(); err != nil {
return err
}
return nil
}
func ensureUserTwoFactorColumns() error {
migrations := []string{
"ALTER TABLE users ADD COLUMN two_factor_enabled INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN two_factor_secret TEXT NOT NULL DEFAULT ''",
}
for _, migration := range migrations {
_, err := DB.Exec(migration)
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
return err
}
}
return nil
}
// Users
@@ -93,18 +126,131 @@ func AddUser(user *models.User) error {
return err
}
func GetUserByUsername(username string) (models.User, error) {
row := DB.QueryRow("SELECT * FROM users WHERE username = ?", strings.ToLower(username))
var user models.User
err := row.Scan(&user.ID, &user.Username, &user.Password, &user.Role)
return user, err
row := DB.QueryRow(`
SELECT id, username, password, role, two_factor_enabled, two_factor_secret
FROM users
WHERE username = ?
`, strings.ToLower(username))
return scanUser(row)
}
func GetUserById(id string) (models.User, error) {
row := DB.QueryRow("SELECT * FROM users WHERE id = ?", id)
row := DB.QueryRow(`
SELECT id, username, password, role, two_factor_enabled, two_factor_secret
FROM users
WHERE id = ?
`, id)
return scanUser(row)
}
func scanUser(row *sql.Row) (models.User, error) {
var user models.User
err := row.Scan(&user.ID, &user.Username, &user.Password, &user.Role)
var twoFactorEnabled int
err := row.Scan(&user.ID, &user.Username, &user.Password, &user.Role, &twoFactorEnabled, &user.TwoFactorSecret)
user.TwoFactorEnabled = twoFactorEnabled == 1
return user, err
}
func SetUserTwoFactorSecret(userID, secret string) error {
_, err := DB.Exec("UPDATE users SET two_factor_secret = ? WHERE id = ?", secret, userID)
return err
}
func EnableUserTwoFactorWithRecoveryCodes(userID string, recoveryCodeHashes []string) error {
tx, err := DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM two_factor_recovery_codes WHERE user_id = ?", userID); err != nil {
return err
}
now := time.Now().Unix()
for _, codeHash := range recoveryCodeHashes {
if _, err := tx.Exec(`
INSERT INTO two_factor_recovery_codes(id, user_id, code_hash, created_at)
VALUES (?, ?, ?, ?)
`, utils.GenerateUUID(), userID, codeHash, now); err != nil {
return err
}
}
if _, err := tx.Exec("UPDATE users SET two_factor_enabled = 1 WHERE id = ?", userID); err != nil {
return err
}
return tx.Commit()
}
func DisableUserTwoFactor(userID string) error {
tx, err := DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("UPDATE users SET two_factor_enabled = 0, two_factor_secret = '' WHERE id = ?", userID); err != nil {
return err
}
if _, err := tx.Exec("DELETE FROM two_factor_recovery_codes WHERE user_id = ?", userID); err != nil {
return err
}
return tx.Commit()
}
func ReplaceUserRecoveryCodes(userID string, recoveryCodeHashes []string) error {
tx, err := DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM two_factor_recovery_codes WHERE user_id = ?", userID); err != nil {
return err
}
now := time.Now().Unix()
for _, codeHash := range recoveryCodeHashes {
if _, err := tx.Exec(`
INSERT INTO two_factor_recovery_codes(id, user_id, code_hash, created_at)
VALUES (?, ?, ?, ?)
`, utils.GenerateUUID(), userID, codeHash, now); err != nil {
return err
}
}
return tx.Commit()
}
func UseUserRecoveryCode(userID, codeHash string) (bool, error) {
res, err := DB.Exec(`
UPDATE two_factor_recovery_codes
SET used_at = ?
WHERE user_id = ? AND code_hash = ? AND used_at IS NULL
`, time.Now().Unix(), userID, codeHash)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
if err != nil {
return false, err
}
return n == 1, nil
}
func CountUnusedRecoveryCodes(userID string) (int, error) {
var count int
err := DB.QueryRow(`
SELECT COUNT(*)
FROM two_factor_recovery_codes
WHERE user_id = ? AND used_at IS NULL
`, userID).Scan(&count)
return count, err
}
// Refresh Tokens
func AddRefreshToken(token *models.RefreshToken) error {
_, err := DB.Exec("INSERT INTO refresh_tokens(id, user_id, token_hash, expires_at, created_at, revoked, device_info) VALUES (?, ?, ?, ?, ?, ?, ?)",