22 Commits

Author SHA1 Message Date
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
92e7ea4667 Added more things to dashboard 2026-06-07 02:29:27 +02:00
b93d9382ac made registration disableable 2026-06-07 02:08:49 +02:00
a31c516e8f Fixed quantity bug 2026-06-07 01:54:09 +02:00
32 changed files with 1613 additions and 134 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

@@ -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

@@ -9,10 +9,11 @@ import (
)
type Config struct {
Port string `yaml:"port"`
DatabasePath string `yaml:"database_path"`
CertificatePath string `yaml:"certificate_path"`
PrivateKeyPath string `yaml:"private_key_path"`
Port string `yaml:"port"`
DatabasePath string `yaml:"database_path"`
CertificatePath string `yaml:"certificate_path"`
PrivateKeyPath string `yaml:"private_key_path"`
AllowRegistration bool `yaml:"allow_registration"`
}
const configPath = "./appdata/config.yaml"
@@ -34,10 +35,11 @@ func CheckIfExists() error {
}
defaultConfig := Config{
Port: "8080",
DatabasePath: "./appdata/database.db",
CertificatePath: "./appdata/cert.pem",
PrivateKeyPath: "./appdata/key.pem",
Port: "8080",
DatabasePath: "./appdata/database.db",
CertificatePath: "./appdata/cert.pem",
PrivateKeyPath: "./appdata/key.pem",
AllowRegistration: true,
}
data, err := yaml.Marshal(defaultConfig)

6
deploy.sh Normal file → Executable file
View File

@@ -1 +1,5 @@
sudo docker buildx build --platform linux/amd64,linux/arm64 -t git.miaurizius.de/miaurizius/miauinv:latest --push .
sudo docker buildx build \
--platform linux/amd64,linux/arm64 \
-t git.miaurizius.de/miaurizius/miauinv:latest \
-t git.miaurizius.de/miaurizius/miauinv:v1.0.2 \
--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) {
@@ -96,7 +107,7 @@ function editItem(item) {
document.getElementById('item-name').value = item.name;
document.getElementById('item-category').value = item.category;
document.getElementById('item-desc').value = item.description;
document.getElementById('item-qty').value = item.total_quantity;
//document.getElementById('item-qty').value = item.total_quantity;
document.getElementById('item-modal-title').innerText = 'Edit Item';
document.getElementById('item-modal').classList.add('show');
}
@@ -108,7 +119,7 @@ async function saveItem(event) {
name: document.getElementById('item-name').value,
category: document.getElementById('item-category').value,
description: document.getElementById('item-desc').value,
total_quantity: parseInt(document.getElementById('item-qty').value, 10)
//total_quantity: parseInt(document.getElementById('item-qty').value, 10)
};
const method = id ? 'PUT' : 'POST';
@@ -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>';
}
@@ -384,4 +398,87 @@ async function deleteAssociation(assocId, projectId) {
await apiRequest(`/api/association?id=${assocId}`, 'DELETE');
await reloadAssociationTable(projectId);
}
}
async function handleDashboardView() {
const locTbody = document.getElementById('dash-locations-body');
const projTbody = document.getElementById('dash-projects-body');
if (!locTbody && !projTbody) return;
try {
const locData = await apiRequest('/api/location');
if (locTbody && locData && locData.locations) {
locTbody.innerHTML = '';
if (locData.locations.length === 0) {
locTbody.innerHTML = '<tr><td style="color: var(--text-muted); padding: 1rem;">No locations found.</td></tr>';
} else {
locData.locations.forEach(loc => {
const tr = document.createElement('tr');
tr.innerHTML = `<td style="cursor: pointer; color: var(--accent); font-weight: 500; padding: 0.75rem 1rem;">${loc.name}</td>`;
tr.onclick = () => openDashboardModal(`/api/location?id=${loc.id}&content=true`, `Items in ${loc.name}`, 'contents');
locTbody.appendChild(tr);
});
}
}
const projData = await apiRequest('/api/project');
if (projTbody && projData && projData.projects) {
projTbody.innerHTML = '';
if (projData.projects.length === 0) {
projTbody.innerHTML = '<tr><td style="color: var(--text-muted); padding: 1rem;">No projects found.</td></tr>';
} else {
projData.projects.forEach(p => {
const tr = document.createElement('tr');
tr.innerHTML = `<td style="cursor: pointer; color: var(--accent); font-weight: 500; padding: 0.75rem 1rem;">${p.name}</td>`;
tr.onclick = () => openDashboardModal(`/api/project?id=${p.id}&details=true`, `Items assigned to ${p.name}`, 'items');
projTbody.appendChild(tr);
});
}
}
} catch (err) {
console.error(err);
}
}
async function openDashboardModal(url, title, dataKey) {
document.getElementById('dash-modal-title').innerText = title;
const tbody = document.getElementById('dash-modal-body');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="2" style="text-align:center; padding:1.5rem; color:var(--text-muted);">Loading...</td></tr>';
document.getElementById('dash-details-modal').classList.add('show');
try {
const data = await apiRequest(url);
tbody.innerHTML = '';
const list = data[dataKey] || [];
if (list.length === 0) {
tbody.innerHTML = '<tr><td colspan="2" style="color:var(--text-muted); text-align:center; padding:1.5rem;">No active items found.</td></tr>';
return;
}
list.forEach(i => {
tbody.innerHTML += `
<tr>
<td style="color:var(--text); padding:0.75rem 1rem;">${i.item_name}</td>
<td style="text-align:right; padding:0.75rem 1rem;"><span class="badge success">${i.quantity}</span></td>
</tr>
`;
});
} 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

@@ -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(
@@ -39,7 +45,7 @@ func Home(w http.ResponseWriter, r *http.Request) {
err := home.Execute(w, struct {
Name string
}{
Name: "Miau",
Name: "Home",
})
if err != nil {
return
@@ -48,10 +54,45 @@ 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
Projects int
Locations int
}
}{
Title: "Miau",
Title: "Dashboard",
Stats: struct {
Items int
Projects int
Locations int
}{
Items: itemHive,
Projects: projectHive,
Locations: locationHive,
},
})
if err != nil {
return
@@ -62,7 +103,7 @@ func Inventory(w http.ResponseWriter, r *http.Request) {
err := inventory.ExecuteTemplate(w, "base.html", struct {
Title string
}{
Title: "Miau",
Title: "Inventory",
})
if err != nil {
return
@@ -73,7 +114,7 @@ func Items(w http.ResponseWriter, r *http.Request) {
err := item.ExecuteTemplate(w, "base.html", struct {
Title string
}{
Title: "Miau",
Title: "Items",
})
if err != nil {
return
@@ -84,7 +125,7 @@ func Locations(w http.ResponseWriter, r *http.Request) {
err := locations.ExecuteTemplate(w, "base.html", struct {
Title string
}{
Title: "Miau",
Title: "Locations",
})
if err != nil {
return
@@ -95,14 +136,57 @@ func Projects(w http.ResponseWriter, r *http.Request) {
err := projects.ExecuteTemplate(w, "base.html", struct {
Title string
}{
Title: "Miau",
Title: "Projects",
})
if err != nil {
return
}
}
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,13 +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>
<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">
@@ -30,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
@@ -52,7 +52,6 @@
{{ template "content" . }}
</main>
<script src="/assets/js/api.js"></script>
</body>
</html>
{{ end }}

View File

@@ -3,35 +3,108 @@
<h1>Dashboard Overview</h1>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon" style="background: rgba(59, 130, 246, 0.1); color: var(--accent);">
<div class="stats-grid" style="margin-bottom: 2rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem;">
<div class="stat-card" style="background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; display: flex; align-items: center; gap: 1rem;">
<div class="stat-icon" style="background: rgba(59, 130, 246, 0.1); color: var(--accent); padding: 0.75rem; border-radius: 10px; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg>
</div>
<div class="stat-info">
<p>Total Items</p>
<h2>{{ .Stats.Items }}</h2>
<p style="color: var(--text-muted); font-size: 0.9rem; margin: 0;">Total Items</p>
<h2 style="font-size: 1.5rem; font-weight: 700; margin: 0;">
{{ if .Stats }}{{ .Stats.Items }}{{ else }}0{{ end }}
</h2>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(16, 185, 129, 0.1); color: var(--success);">
<div class="stat-card" style="background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; display: flex; align-items: center; gap: 1rem;">
<div class="stat-icon" style="background: rgba(16, 185, 129, 0.1); color: var(--success); padding: 0.75rem; border-radius: 10px; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
</div>
<div class="stat-info">
<p>Active Projects</p>
<h2>{{ .Stats.Projects }}</h2>
<p style="color: var(--text-muted); font-size: 0.9rem; margin: 0;">Active Projects</p>
<h2 style="font-size: 1.5rem; font-weight: 700; margin: 0;">
{{ if .Stats }}{{ .Stats.Projects }}{{ else }}0{{ end }}
</h2>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(245, 158, 11, 0.1); color: #f59e0b;">
<div class="stat-card" style="background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; display: flex; align-items: center; gap: 1rem;">
<div class="stat-icon" style="background: rgba(245, 158, 11, 0.1); color: #f59e0b; padding: 0.75rem; border-radius: 10px; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
</div>
<div class="stat-info">
<p>Locations</p>
<h2>{{ .Stats.Locations }}</h2>
<p style="color: var(--text-muted); font-size: 0.9rem; margin: 0;">Locations</p>
<h2 style="font-size: 1.5rem; font-weight: 700; margin: 0;">
{{ if .Stats }}{{ .Stats.Locations }}{{ else }}0{{ end }}
</h2>
</div>
</div>
</div>
<div class="modal-split">
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
<h2 style="font-size: 1.25rem; margin-bottom: 1rem; color: var(--text);">Locations</h2>
<div class="table-container">
<table class="inner-table" style="margin-top: 0; border-radius: 8px; width: 100%;">
<thead>
<tr>
<th style="text-align: left; padding: 0.75rem 1rem; background: #111827;">Name</th>
</tr>
</thead>
<tbody id="dash-locations-body">
<tr><td class="table-loader" style="padding: 1.5rem; text-align: center; color: var(--text-muted);">Loading locations...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
<h2 style="font-size: 1.25rem; margin-bottom: 1rem; color: var(--text);">Active Projects</h2>
<div class="table-container">
<table class="inner-table" style="margin-top: 0; border-radius: 8px; width: 100%;">
<thead>
<tr>
<th style="text-align: left; padding: 0.75rem 1rem; background: #111827;">Project Name</th>
</tr>
</thead>
<tbody id="dash-projects-body">
<tr><td class="table-loader" style="padding: 1.5rem; text-align: center; color: var(--text-muted);">Loading projects...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="dash-details-modal" class="modal">
<div class="modal-content">
<h2 id="dash-modal-title">Details</h2>
<div class="table-container">
<table class="inner-table" style="width: 100%;">
<thead>
<tr>
<th style="text-align: left; padding: 0.75rem 1rem;">Item</th>
<th style="text-align: right; width: 100px; padding: 0.75rem 1rem;">Quantity</th>
</tr>
</thead>
<tbody id="dash-modal-body"></tbody>
</table>
</div>
<div class="button-group">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('dash-details-modal').classList.remove('show')">Close</button>
</div>
</div>
</div>
<script>
if (typeof handleDashboardView === "function") {
handleDashboardView();
}
if (window.htmx) {
htmx.on("htmx:afterOnLoad", function() {
if (typeof handleDashboardView === "function") {
handleDashboardView();
}
});
}
</script>
{{ 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>

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

@@ -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>

4
go.mod
View File

@@ -5,15 +5,17 @@ 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/tdewolff/minify/v2 v2.24.13
golang.org/x/crypto v0.52.0
gopkg.in/yaml.v3 v3.0.1
)
require (
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

7
go.sum
View File

@@ -12,6 +12,13 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/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

@@ -10,6 +10,7 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
)
@@ -29,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())
@@ -242,6 +249,38 @@ 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")
@@ -251,12 +290,12 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(map[string]interface{}{
"id": user.ID,
"name": user.Username,
"username": user.Username,
"avatar_url": "",
})
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 + ")")
}

View File

@@ -18,6 +18,42 @@ func Location(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
idStr := r.URL.Query().Get("id")
contentMode := r.URL.Query().Get("content")
if idStr != "" && contentMode == "true" {
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, locationID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var contents []models.LocationContent
for rows.Next() {
var c models.LocationContent
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
}
if idStr != "" {
id, err := strconv.Atoi(idStr)
@@ -188,9 +224,9 @@ func Item(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
idStr := r.URL.Query().Get("id")
// Read One
if idStr != "" {
id, err := strconv.Atoi(idStr)
if err != nil {
@@ -199,35 +235,50 @@ func Item(w http.ResponseWriter, r *http.Request) {
}
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)
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 {
http.Error(w, "Item not found", http.StatusNotFound)
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
}
// Read All
rows, err := storage.DB.Query("SELECT id, name, category, description, total_quantity FROM items ORDER BY name ASC")
query := `
SELECT
i.id, i.name, i.category, i.description,
COALESCE((SELECT SUM(quantity) FROM stock WHERE item_id = i.id), 0) as total_qty,
COALESCE((SELECT SUM(quantity) FROM project_items WHERE item_id = i.id), 0) as allocated_qty
FROM items i
ORDER BY i.name ASC
`
rows, err := storage.DB.Query(query)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
items := []models.Item{}
var itemsExtended []models.ItemExtended
for rows.Next() {
var item models.Item
rows.Scan(&item.ID, &item.Name, &item.Category, &item.Description, &item.TotalQuantity)
items = append(items, item)
var ie models.ItemExtended
err := rows.Scan(&ie.ID, &ie.Name, &ie.Category, &ie.Description, &ie.TotalQuantity, &ie.AllocatedQty)
if err != nil {
continue
}
ie.AvailableQty = ie.TotalQuantity - ie.AllocatedQty
itemsExtended = append(itemsExtended, ie)
}
json.NewEncoder(w).Encode(map[string]interface{}{"items": items})
json.NewEncoder(w).Encode(map[string]interface{}{"items": itemsExtended})
return
case http.MethodPost:
var body models.Item
@@ -304,6 +355,43 @@ func Project(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
idStr := r.URL.Query().Get("id")
detailsMode := r.URL.Query().Get("details")
if idStr != "" && detailsMode == "true" {
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, projectID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var details []models.ProjectDetailItem
for rows.Next() {
var d models.ProjectDetailItem
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
}
if idStr != "" {
id, _ := strconv.Atoi(idStr)
var p models.Project
@@ -485,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)
@@ -505,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
@@ -519,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

@@ -42,3 +42,25 @@ type ProjectItem struct {
ProjectID int `json:"project_id"`
Quantity int `json:"quantity"`
}
type ItemExtended struct {
ID int `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
TotalQuantity int `json:"total_quantity"` // Summe aus allen Stock-Einträgen
AllocatedQty int `json:"allocated_quantity"` // Summe aus allen Projekt-Zuweisungen
AvailableQty int `json:"available_quantity"` // Total - Allocated
}
type LocationContent struct {
ItemID int `json:"item_id"`
ItemName string `json:"item_name"`
Quantity int `json:"quantity"`
}
type ProjectDetailItem struct {
ItemID int `json:"item_id"`
ItemName string `json:"item_name"`
Quantity int `json:"quantity"`
}

View File

@@ -5,6 +5,7 @@ import (
"MiauInv/config"
"MiauInv/frontend"
"MiauInv/handlers"
"MiauInv/models"
utils "MiauInv/util"
"log"
"net/http"
@@ -12,11 +13,12 @@ import (
)
type Server struct {
Port string
JWTSecret []byte
DatabasePath string
CertificatePath string
PrivateKeyPath string
Port string
JWTSecret []byte
DatabasePath string
CertificatePath string
PrivateKeyPath string
AllowRegistration bool
}
func InitServer() *Server {
@@ -43,12 +45,15 @@ func InitServer() *Server {
return nil
}
models.JWTSecret = []byte(jwtSecret)
return &Server{
Port: cfg.Port,
JWTSecret: []byte(jwtSecret),
DatabasePath: cfg.DatabasePath,
CertificatePath: cfg.CertificatePath,
PrivateKeyPath: cfg.PrivateKeyPath,
Port: cfg.Port,
JWTSecret: []byte(jwtSecret),
DatabasePath: cfg.DatabasePath,
CertificatePath: cfg.CertificatePath,
PrivateKeyPath: cfg.PrivateKeyPath,
AllowRegistration: cfg.AllowRegistration,
}
}
@@ -61,21 +66,29 @@ func (this *Server) Run() {
//
mux.HandleFunc("/", frontend.Home)
mux.HandleFunc("/login", utils.RenderFile("frontend/htmx/login.html"))
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
mux.Handle("/dashboard", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Dashboard)))
mux.Handle("/inventory", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Inventory)))
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"))
}
//
// API
//
mux.HandleFunc("/api/login", handlers.APILogin)
mux.HandleFunc("/api/register", handlers.APIRegister)
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.HandleFunc("/api/userinfo", handlers.UserInfo)
if this.AllowRegistration {
mux.HandleFunc("/api/register", handlers.APIRegister)
}
mux.Handle("/api/item", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Item)))
mux.Handle("/api/location", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Location)))