9 Commits

Author SHA1 Message Date
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
24 changed files with 1049 additions and 218 deletions

View File

@@ -11,6 +11,8 @@ 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 .

189
README.md Normal file
View File

@@ -0,0 +1,189 @@
# 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)
* [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)
## 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.
---
## 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 certs/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.

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,18 @@ 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) {
// 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
}
tokenStr := ""
authHeader := r.Header.Get("Authorization")
@@ -37,22 +46,25 @@ 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 {
// 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
})
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))
})
}

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.0 \
--push .

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

@@ -39,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) {
@@ -464,4 +466,19 @@ async function openDashboardModal(url, title, dataKey) {
} catch (e) {
tbody.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
}
}
// ---- PROFILE ----
async function loadProfile() {
const avatar = document.getElementById("avatar");
const username = document.getElementById('username');
try {
const data = await apiRequest('/api/profile');
avatar.innerText = data.username[0].toLocaleUpperCase();
username.innerText = data.username;
} catch (e) {
username.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
}
}

View File

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

View File

@@ -3,8 +3,13 @@ package frontend
import (
"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(
@@ -116,7 +121,50 @@ func Projects(w http.ResponseWriter, r *http.Request) {
}
}
var minifier *minify.M
func init() {
minifier = minify.New()
// Füge die Minifier für CSS und JS hinzu
minifier.AddFunc("text/css", css.Minify)
minifier.AddFunc("text/javascript", js.Minify)
}
func Assets(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/assets/")
http.ServeFile(w, r, filepath.Join("frontend/assets", path))
fullPath := filepath.Join("frontend/assets", path)
w.Header().Set("Cache-Control", "public, max-age=3600")
var mimeType string
if strings.HasSuffix(path, ".min.js") {
mimeType = "text/javascript"
fullPath = strings.Replace(fullPath, ".min.js", ".js", 1)
} else if strings.HasSuffix(path, ".min.css") {
mimeType = "text/css"
fullPath = strings.Replace(fullPath, ".min.css", ".css", 1)
}
info, err := os.Stat(fullPath)
if err != nil || info.IsDir() {
http.Error(w, "Asset not found", http.StatusNotFound)
return
}
if mimeType != "" {
content, err := os.ReadFile(fullPath)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
minifiedContent, err := minifier.Bytes(mimeType, content)
if err == nil {
w.Header().Set("Content-Type", mimeType)
w.Write(minifiedContent)
return
}
}
http.ServeFile(w, r, fullPath)
}

View File

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

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }} | MiauInv</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="/assets/js/api.js"></script>
<link rel="stylesheet" href="/assets/css/theme.css">
<link rel="stylesheet" href="/assets/css/dashboard.css">
<script src="/assets/js/api.min.js"></script>
<link rel="stylesheet" href="/assets/css/theme.min.css">
<link rel="stylesheet" href="/assets/css/dashboard.min.css">
</head>
<body class="dashboard-layout">
@@ -30,8 +30,8 @@
<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">

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

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

View File

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

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"
)
@@ -242,6 +243,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 +284,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

@@ -21,14 +21,20 @@ func Location(w http.ResponseWriter, r *http.Request) {
contentMode := r.URL.Query().Get("content")
if idStr != "" && contentMode == "true" {
_, _ = strconv.Atoi(idStr)
locationID, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid location ID", http.StatusBadRequest)
return
}
query := `
SELECT s.item_id, i.name, s.quantity
FROM stock s
JOIN items i ON s.item_id = i.id
WHERE s.location_id = ? AND s.quantity > 0
`
rows, err := storage.DB.Query(query)
SELECT s.item_id, i.name, s.quantity
FROM stock s
JOIN items i ON s.item_id = i.id
WHERE s.location_id = ? AND s.quantity > 0
`
rows, err := storage.DB.Query(query, locationID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
@@ -38,9 +44,13 @@ func Location(w http.ResponseWriter, r *http.Request) {
var contents []models.LocationContent
for rows.Next() {
var c models.LocationContent
rows.Scan(&c.ItemID, &c.ItemName, &c.Quantity)
if err := rows.Scan(&c.ItemID, &c.ItemName, &c.Quantity); err != nil {
http.Error(w, "Row scan error", http.StatusInternalServerError)
return
}
contents = append(contents, c)
}
json.NewEncoder(w).Encode(map[string]interface{}{"contents": contents})
return
}
@@ -348,14 +358,20 @@ func Project(w http.ResponseWriter, r *http.Request) {
detailsMode := r.URL.Query().Get("details")
if idStr != "" && detailsMode == "true" {
_, _ = strconv.Atoi(idStr)
projectID, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid project ID", http.StatusBadRequest)
return
}
query := `
SELECT pi.item_id, i.name, pi.quantity
FROM project_items pi
JOIN items i ON pi.item_id = i.id
WHERE pi.project_id = ?
`
rows, err := storage.DB.Query(query)
SELECT pi.item_id, i.name, pi.quantity
FROM project_items pi
JOIN items i ON pi.item_id = i.id
WHERE pi.project_id = ?
`
rows, err := storage.DB.Query(query, projectID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
@@ -365,9 +381,13 @@ func Project(w http.ResponseWriter, r *http.Request) {
var details []models.ProjectDetailItem
for rows.Next() {
var d models.ProjectDetailItem
rows.Scan(&d.ItemID, &d.ItemName, &d.Quantity)
if err := rows.Scan(&d.ItemID, &d.ItemName, &d.Quantity); err != nil {
http.Error(w, "Row scan error", http.StatusInternalServerError)
return
}
details = append(details, d)
}
json.NewEncoder(w).Encode(map[string]interface{}{"items": details})
return
}

View File

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

View File

@@ -5,6 +5,7 @@ import (
"MiauInv/config"
"MiauInv/frontend"
"MiauInv/handlers"
"MiauInv/models"
utils "MiauInv/util"
"log"
"net/http"
@@ -44,6 +45,8 @@ func InitServer() *Server {
return nil
}
models.JWTSecret = []byte(jwtSecret)
return &Server{
Port: cfg.Port,
JWTSecret: []byte(jwtSecret),
@@ -68,10 +71,11 @@ func (this *Server) Run() {
mux.Handle("/items", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Items)))
mux.Handle("/locations", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Locations)))
mux.Handle("/projects", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Projects)))
mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html"))
if this.AllowRegistration {
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
} else {
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register_blocked.html"))
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register-blocked.html"))
}
//
@@ -80,6 +84,8 @@ func (this *Server) Run() {
mux.HandleFunc("/api/login", handlers.APILogin)
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)
}