7 Commits

Author SHA1 Message Date
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
13 changed files with 268 additions and 19 deletions

View File

@@ -17,5 +17,9 @@ 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"]

View File

@@ -6,6 +6,7 @@ MiauInv is a secure, light-weight inventory, stock, and project allocation track
* [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)
@@ -16,6 +17,8 @@ MiauInv is a secure, light-weight inventory, stock, and project allocation track
* [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
@@ -41,6 +44,15 @@ MiauInv splits responsibility cleanly across modularized architecture packages:
---
---
## 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.
@@ -129,8 +141,7 @@ Before deployment, you must generate SSL/TLS certificates since MiauInv enforces
mkdir -p appdata
# Generate self-signed certificate and private key
openssl req -x509 -newkey rsa:4096 -keyout appdata/key.pem -out certs/cert.pem -sha256 -days 365 -nodes
openssl req -x509 -newkey rsa:4096 -keyout appdata/key.pem -out appdata/cert.pem -sha256 -days 365 -nodes
```
### Option 1: Native Local Deployment
@@ -142,7 +153,6 @@ openssl req -x509 -newkey rsa:4096 -keyout appdata/key.pem -out certs/cert.pem -
export JWT_SECRET="your_minimum_thirty_two_char_secret_key_here"
go build -o miauinv main.go
./miauinv
```
---
@@ -167,7 +177,6 @@ services:
- JWT_SECRET=SECURE_RANDOM_STRING # Must be at least 32 characters long
volumes:
- ./appdata:/appdata # To edit your configuration files
```
#### 2. Execution Commands
@@ -183,7 +192,74 @@ 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.
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">

View File

@@ -15,8 +15,6 @@ 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) {
// WICHTIG: Wenn der User auf einer öffentlichen Seite ist,
// darf die Middleware KEINEN Auth-Zwang ausüben und nicht redirecten!
if r.URL.Path == "/login" || r.URL.Path == "/register" || r.URL.Path == "/" {
next.ServeHTTP(w, r)
return
@@ -50,14 +48,12 @@ func AuthMiddleware(secret []byte) func(http.Handler) http.Handler {
if strings.HasPrefix(r.URL.Path, "/api/") {
http.Error(w, "Invalid token", http.StatusUnauthorized)
} else {
// Falls das Cookie korrupt oder abgelaufen ist, löschen wir es direkt,
// damit das Frontend sauber merkt, dass es weg ist.
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false, // Erlaubt JS das Auslesen
HttpOnly: false,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}

View File

@@ -1,5 +1,5 @@
sudo docker buildx build \
--platform linux/amd64,linux/arm64 \
-t git.miaurizius.de/miaurizius/miauinv:latest \
-t git.miaurizius.de/miaurizius/miauinv:v1.0.0 \
-t git.miaurizius.de/miaurizius/miauinv:v1.0.1 \
--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

@@ -1,6 +1,7 @@
package frontend
import (
"MiauInv/storage"
"html/template"
"net/http"
"os"
@@ -53,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
@@ -67,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 {

View File

@@ -30,6 +30,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())

View File

@@ -573,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)
@@ -593,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
@@ -607,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)