feat: implement Keycloak SSO integration with dynamic configuration and JIT provisioning
Some checks failed
Build & Push Docker Image to Gitea Registry / build-and-push (push) Failing after 17s
Some checks failed
Build & Push Docker Image to Gitea Registry / build-and-push (push) Failing after 17s
This commit is contained in:
@@ -542,8 +542,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Login Handling
|
// Login Handling
|
||||||
|
$.get('/api/auth/sso/config').done(function(config) {
|
||||||
|
if (config.enabled) {
|
||||||
|
var $ssoBtn = $('<li id="sso-login-li" style="margin-top: 10px; width: 100%; display: block;"><a href="/api/auth/sso/login" id="sso-login-btn" class="button primary" style="background:#8ee6d2 !important; color:#101316 !important; box-shadow:none !important; border-color:#8ee6d2 !important; font-weight:bold; width: 100%; text-align: center;"><i class="fa fa-key"></i> Login mit Keycloak</a></li>');
|
||||||
|
$('#login-form .actions').append($ssoBtn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$.get('/api/auth/me').done(function(u) {
|
$.get('/api/auth/me').done(function(u) {
|
||||||
$('#login-username, #login-password, #login-submit').hide();
|
$('#login-username, #login-password, #login-submit, #sso-login-li').hide();
|
||||||
$('#logout-btn').show().css('display', 'inline-block');
|
$('#logout-btn').show().css('display', 'inline-block');
|
||||||
$('#pw-change-section').show();
|
$('#pw-change-section').show();
|
||||||
loadAvailableFolders();
|
loadAvailableFolders();
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const db = require('./database');
|
const db = require('./database');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { Issuer } = require('openid-client');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const JWT_SECRET = process.env.JWT_SECRET;
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
@@ -183,6 +185,116 @@ app.get('/api/auth/me', (req, res) => {
|
|||||||
res.json(req.user);
|
res.json(req.user);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== KEYCLOAK SSO API ====================
|
||||||
|
|
||||||
|
let oidcClient = null;
|
||||||
|
|
||||||
|
async function getOidcClient() {
|
||||||
|
if (oidcClient) return oidcClient;
|
||||||
|
|
||||||
|
const ssoAuthority = process.env.SSO_AUTHORITY || 'https://auth.mischlabs.de/realms/mischlabs';
|
||||||
|
const ssoClientId = process.env.SSO_CLIENT_ID || 'pocketbase';
|
||||||
|
const ssoClientSecret = process.env.SSO_CLIENT_SECRET;
|
||||||
|
const ssoRedirectUri = process.env.SSO_REDIRECT_URI || 'https://tom.mischlabs.de/api/auth/sso/callback';
|
||||||
|
|
||||||
|
if (!ssoClientSecret) {
|
||||||
|
throw new Error('SSO_CLIENT_SECRET is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuer = await Issuer.discover(ssoAuthority);
|
||||||
|
oidcClient = new issuer.Client({
|
||||||
|
client_id: ssoClientId,
|
||||||
|
client_secret: ssoClientSecret,
|
||||||
|
redirect_uris: [ssoRedirectUri],
|
||||||
|
response_types: ['code'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return oidcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/auth/sso/config', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
enabled: !!(process.env.SSO_CLIENT_SECRET && process.env.SSO_CLIENT_ID && process.env.SSO_AUTHORITY),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/auth/sso/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const client = await getOidcClient();
|
||||||
|
const authorizationUrl = client.authorizationUrl({
|
||||||
|
scope: 'openid email profile',
|
||||||
|
state: 'mischlabs-state',
|
||||||
|
});
|
||||||
|
res.redirect(authorizationUrl);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('SSO Login Error:', err);
|
||||||
|
res.status(500).send('SSO Login Initialisierung fehlgeschlagen: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/auth/sso/callback', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const client = await getOidcClient();
|
||||||
|
const params = client.callbackParams(req);
|
||||||
|
const redirectUri = process.env.SSO_REDIRECT_URI || 'https://tom.mischlabs.de/api/auth/sso/callback';
|
||||||
|
const tokenSet = await client.callback(redirectUri, params, { state: 'mischlabs-state' });
|
||||||
|
const userinfo = await client.userinfo(tokenSet.access_token);
|
||||||
|
|
||||||
|
const username = userinfo.preferred_username || userinfo.name || userinfo.sub;
|
||||||
|
const email = userinfo.email;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return res.status(400).send('Kein Benutzername im OIDC Token gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => {
|
||||||
|
if (err) return res.status(500).send('Datenbankfehler');
|
||||||
|
|
||||||
|
const handleUserLogin = (dbUser) => {
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: dbUser.id, username: dbUser.username, role: dbUser.role, created_at: dbUser.created_at },
|
||||||
|
JWT_SECRET, { expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
res.cookie('token', token, { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 });
|
||||||
|
res.redirect('/admin.html');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
handleUserLogin(user);
|
||||||
|
} else {
|
||||||
|
const randomPassword = crypto.randomBytes(32).toString('hex');
|
||||||
|
const hash = bcrypt.hashSync(randomPassword, 10);
|
||||||
|
|
||||||
|
db.get('SELECT COUNT(*) as count FROM users', [], (countErr, row) => {
|
||||||
|
let role = 'client';
|
||||||
|
if ((row && row.count === 0) || username.toLowerCase() === 'mrdiderot') {
|
||||||
|
role = 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
|
||||||
|
[username, hash, role],
|
||||||
|
function (insertErr) {
|
||||||
|
if (insertErr) {
|
||||||
|
console.error('Failed JIT provisioning:', insertErr.message);
|
||||||
|
return res.status(500).send('Fehler bei der Benutzererstellung');
|
||||||
|
}
|
||||||
|
const newUserId = this.lastID;
|
||||||
|
db.get('SELECT * FROM users WHERE id = ?', [newUserId], (getErr, newUser) => {
|
||||||
|
if (getErr || !newUser) return res.status(500).send('Fehler beim Laden des neuen Benutzers');
|
||||||
|
handleUserLogin(newUser);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('SSO Callback Error:', err);
|
||||||
|
res.status(500).send('SSO Authentifizierung fehlgeschlagen: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/auth/change-password', requireAuth, (req, res) => {
|
app.post('/api/auth/change-password', requireAuth, (req, res) => {
|
||||||
const { old_password, new_password } = req.body;
|
const { old_password, new_password } = req.body;
|
||||||
if (!new_password || new_password.length < 4) {
|
if (!new_password || new_password.length < 4) {
|
||||||
|
|||||||
210
brain.old
Normal file
210
brain.old
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# Brain.md - Projekt-Wissensdatenbank
|
||||||
|
|
||||||
|
## Projektziel
|
||||||
|
Eine eigene Fotografie-Portfolio-Website basierend auf dem Template von rampatra/photography.
|
||||||
|
Keine 1:1 Kopie, sondern eine individuell angepasste Version mit eigenen Anspruechen.
|
||||||
|
|
||||||
|
## Besitzer & Kontakt
|
||||||
|
- **Name**: Tom Misch
|
||||||
|
- **Marke**: Mischkomposition
|
||||||
|
- **Domain**: tom.mischlabs.de
|
||||||
|
- **E-Mail**: tom@mischlabs.de
|
||||||
|
- **Instagram**: @Mischkomposition
|
||||||
|
- **Gitea Repo**: https://git.mischlabs.de/MrDiderot/Photography.git
|
||||||
|
- **Lokaler Pfad**: d:\Vibecoding\Website\Photography
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technologie-Stack
|
||||||
|
|
||||||
|
| Komponente | Technologie |
|
||||||
|
|---|---|
|
||||||
|
| Static Site Generator | **Jekyll** (Ruby-basiert) |
|
||||||
|
| Backend | **Node.js / Express** (Auth, API, Static Serving) |
|
||||||
|
| Datenbank | **SQLite** (Nutzerverwaltung, Bildzuweisung) |
|
||||||
|
| Templating | Liquid Templates |
|
||||||
|
| Styling | SCSS -> kompiliertes CSS |
|
||||||
|
| JS Libraries | jQuery, Poptrox (Lightbox), EXIF.js |
|
||||||
|
| Build-Tool | Gulp (SCSS kompilieren, JS minifizieren, Bilder resizen) |
|
||||||
|
| Hosting | **UGREEN NAS (Docker)** via Node.js Express |
|
||||||
|
| Containerisierung | Docker Multi-Stage (Jekyll Build + Node.js Backend) |
|
||||||
|
| Auto-Update | **Watchtower** (prueft alle 5 Min auf neue Images) |
|
||||||
|
| Fonts | FontAwesome, Google Fonts (Source Sans Pro) |
|
||||||
|
| Paketmanager | npm (Node), Bundler (Ruby) |
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
Photography/
|
||||||
|
├── _config.yml # Zentrale Konfiguration (Titel, Social Links, EXIF etc.)
|
||||||
|
├── _layouts/
|
||||||
|
│ └── default.html # Basis-Layout (Head + Body + Scripts)
|
||||||
|
├── _includes/
|
||||||
|
│ ├── header.html # <head> Bereich: CSS, Fonts
|
||||||
|
│ └── footer.html # Script-Einbindungen (jQuery, Poptrox, EXIF, Main)
|
||||||
|
├── index.html # Hauptseite: Header, Galerie-Grid, Footer/About, Login-Panel
|
||||||
|
├── admin.html # Admin-Dashboard (Nutzerverwaltung, Bildzuweisung)
|
||||||
|
├── backend/
|
||||||
|
│ ├── server.js # Express Backend (Auth, API, Static Files, Port 8090)
|
||||||
|
│ └── database.js # SQLite Datenbank-Setup (Users, User-Images)
|
||||||
|
├── assets/
|
||||||
|
│ ├── css/ # Kompilierte CSS-Dateien (.min.css)
|
||||||
|
│ ├── sass/ # SCSS Quelldateien
|
||||||
|
│ │ ├── base/ # Reset, Typography, Page
|
||||||
|
│ │ ├── components/ # Buttons, Forms, Icons, Panels, Poptrox
|
||||||
|
│ │ ├── layout/ # Header, Footer, Main, Wrapper
|
||||||
|
│ │ └── libs/ # Breakpoints, Mixins, Vars, Functions
|
||||||
|
│ ├── js/ # JavaScript (jQuery, Poptrox, EXIF, Main, Admin)
|
||||||
|
│ ├── fonts/ # FontAwesome Fonts
|
||||||
|
│ └── webfonts/ # FA5 Webfonts
|
||||||
|
├── images/
|
||||||
|
│ ├── fulls/ # Vollaufloesung Bilder
|
||||||
|
│ └── thumbs/ # Thumbnails (512px)
|
||||||
|
├── gulpfile.mjs # Build: SCSS->CSS, JS minify, Image resize
|
||||||
|
├── package.json # Node Dependencies (Express, SQLite, JWT, bcrypt)
|
||||||
|
├── Gemfile # Ruby Dependencies (jekyll)
|
||||||
|
├── Dockerfile # Multi-Stage: Jekyll Build + Node.js Runtime
|
||||||
|
├── docker-compose.yml # Container + Watchtower
|
||||||
|
├── entrypoint.sh # Thumbnail-Generierung + Node.js Start
|
||||||
|
└── nginx.conf # (Legacy, nicht mehr aktiv - Express dient statische Dateien)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wie die Galerie funktioniert (Dynamisch)
|
||||||
|
|
||||||
|
1. **Bilder hochladen**: Fotos in Nextcloud-Ordner `Websitebilder` ablegen
|
||||||
|
2. **Volume-Mount**: Docker mountet den Nextcloud-Ordner als `/app/public/images/fulls/` (read-only)
|
||||||
|
3. **Thumbnails**: `entrypoint.sh` generiert automatisch 512px Thumbnails via ImageMagick
|
||||||
|
4. **Thumbnail-Check**: Alle 60 Sekunden prueft der Container auf neue Bilder
|
||||||
|
5. **Express API**: `/images/fulls/` liefert JSON-Liste der Bilddateien
|
||||||
|
6. **JavaScript (main.js)**: Fetcht die JSON-Liste und baut dynamisch `<article class="thumb">` Elemente
|
||||||
|
7. **Poptrox-Lightbox**: Klick oeffnet Vollbild + EXIF-Daten (clientseitig via exif.js)
|
||||||
|
8. **Download-Button**: In der Lightbox kann jedes Bild heruntergeladen werden
|
||||||
|
|
||||||
|
### Zugriffslogik
|
||||||
|
- **Nicht eingeloggt**: Nur Root-Bilder (direkt in Websitebilder/) sichtbar
|
||||||
|
- **Admin**: Alle Bilder (Root + alle Unterordner) sichtbar
|
||||||
|
- **User (permanent)**: Root-Bilder + zugewiesene Unterordner
|
||||||
|
- **Client (30 Tage)**: Root-Bilder + zugewiesene Unterordner, Account laeuft nach 30 Tagen ab
|
||||||
|
|
||||||
|
### Nextcloud-Integration
|
||||||
|
- **Nextcloud-Pfad**: `/volume1/nextcloud/data/Tom/files/Websitebilder`
|
||||||
|
- **Mount im Container**: `/app/public/images/fulls:ro`
|
||||||
|
- **Thumbnails-Volume**: Docker-Volume `thumbs` (persistent)
|
||||||
|
- **Datenbank-Volume**: Docker-Volume `database` (persistent, SQLite)
|
||||||
|
- **Workflow**: Bild in Nextcloud hochladen -> max. 60s warten -> Seite neu laden -> fertig
|
||||||
|
|
||||||
|
## Login-System & Nutzerverwaltung
|
||||||
|
|
||||||
|
### Architektur
|
||||||
|
- **Backend**: Node.js/Express auf Port 8090
|
||||||
|
- **Auth**: JWT-Token als httpOnly Cookie (7 Tage gueltig)
|
||||||
|
- **Datenbank**: SQLite unter `/app/data/database.sqlite`
|
||||||
|
- **Passwort-Hashing**: bcryptjs (10 Rounds)
|
||||||
|
|
||||||
|
### Rollen
|
||||||
|
| Rolle | Beschreibung | Ablauf |
|
||||||
|
|---|---|---|
|
||||||
|
| `admin` | Voller Zugriff, Nutzerverwaltung, alle Bilder | Nie |
|
||||||
|
| `user` | Root-Bilder + zugewiesene Ordner | Nie |
|
||||||
|
| `client` | Root-Bilder + zugewiesene Ordner | 30 Tage nach Erstellung |
|
||||||
|
|
||||||
|
### Ordner-basierte Bildzuweisung
|
||||||
|
- Bilder direkt in `Websitebilder/` = oeffentlich (fuer alle sichtbar)
|
||||||
|
- Bilder in Unterordnern (z.B. `Websitebilder/Hochzeit/`) = nur fuer zugewiesene Nutzer
|
||||||
|
- Zuweisung erfolgt auf Ordner-Ebene (nicht einzelne Bilder)
|
||||||
|
- Tabelle `user_folders` speichert Zuweisungen (user_id, folder_name)
|
||||||
|
|
||||||
|
### Initialer Admin
|
||||||
|
- Es wird kein festes Standardkonto mehr angelegt.
|
||||||
|
- Bestehende Installationen behalten Nutzer und Rollen im persistenten SQLite-Volume.
|
||||||
|
- Fuer frische Installationen kann einmalig `INITIAL_ADMIN_USERNAME=MrDiderot` zusammen mit `INITIAL_ADMIN_PASSWORD_HASH` gesetzt werden.
|
||||||
|
|
||||||
|
### API-Endpunkte
|
||||||
|
| Endpunkt | Methode | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/auth/login` | POST | Login (username, password) |
|
||||||
|
| `/api/auth/logout` | POST | Logout (Cookie loeschen) |
|
||||||
|
| `/api/auth/me` | GET | Aktueller User |
|
||||||
|
| `/api/auth/change-password` | POST | Eigenes Passwort aendern |
|
||||||
|
| `/api/admin/users` | GET/POST | Nutzer auflisten / erstellen (mit Rolle) |
|
||||||
|
| `/api/admin/users/:id` | DELETE | Nutzer loeschen (nicht sich selbst) |
|
||||||
|
| `/api/admin/users/:id/password` | PUT | Admin aendert User-Passwort |
|
||||||
|
| `/api/admin/folders` | GET | Verfuegbare Unterordner auflisten |
|
||||||
|
| `/api/admin/assign` | GET/POST | Ordner-Zuweisungen anzeigen / setzen |
|
||||||
|
| `/images/fulls/` | GET | Bilderliste (gefiltert nach User-Rolle + Ordnern) |
|
||||||
|
|
||||||
|
## Wichtige Konfiguration (_config.yml)
|
||||||
|
|
||||||
|
- `baseurl`: URL der Website
|
||||||
|
- `title`, `subtitle`, `author`: Seitentitel und Autor
|
||||||
|
- `header.title/subtitle`: Header-Anzeige
|
||||||
|
- `footer.name/bio`: Footer-Infos
|
||||||
|
- `social_urls`: Links zu Social Media
|
||||||
|
- `exif`: Welche EXIF-Tags angezeigt werden (Model, FNumber, ExposureTime, ISO)
|
||||||
|
|
||||||
|
## Build-Befehle
|
||||||
|
|
||||||
|
| Befehl | Aktion |
|
||||||
|
|---|---|
|
||||||
|
| `bundle install` | Ruby Dependencies installieren |
|
||||||
|
| `bundle exec jekyll serve` | Lokaler Dev-Server (nur Frontend) |
|
||||||
|
| `npm install` | Node Dependencies installieren |
|
||||||
|
| `gulp build` | SCSS kompilieren + JS minifizieren |
|
||||||
|
| `gulp resize` | Bilder zu fulls (1024px) + thumbs (512px) |
|
||||||
|
| `gulp` | Alles (build + resize) |
|
||||||
|
|
||||||
|
## Docker / NAS Hosting
|
||||||
|
|
||||||
|
- **NAS**: UGREEN mit UGOS PRO
|
||||||
|
- **Port**: 8090 (intern und extern)
|
||||||
|
- **Domain**: tom.mischlabs.de (via Cloudflare Tunnel)
|
||||||
|
|
||||||
|
### Docker-Dateien
|
||||||
|
- `Dockerfile` - Multi-Stage: Ruby/Jekyll baut Site -> Node.js Express serviert alles
|
||||||
|
- `docker-compose.yml` - Photography Container + Watchtower, Volumes fuer Bilder/Thumbs/DB
|
||||||
|
- `entrypoint.sh` - Thumbnail-Generierung + Node.js Server-Start
|
||||||
|
- `.dockerignore` - Haelt Build-Context klein (kein .git, node_modules etc.)
|
||||||
|
|
||||||
|
### Docker-Befehle (auf NAS via SSH)
|
||||||
|
| Befehl | Aktion |
|
||||||
|
|---|---|
|
||||||
|
| `sudo docker compose up -d` | Container starten |
|
||||||
|
| `sudo docker compose down` | Container stoppen |
|
||||||
|
| `sudo docker compose logs -f photography` | Logs anzeigen |
|
||||||
|
| `sudo docker compose pull && sudo docker compose up -d` | Manuelles Update |
|
||||||
|
|
||||||
|
### Deployment-Workflow (automatisch!)
|
||||||
|
1. Aenderungen lokal machen (Code, Design)
|
||||||
|
2. `gulp build` ausfuehren (JS + CSS minifizieren)
|
||||||
|
3. Git commit & push nach master
|
||||||
|
4. Gitea Actions baut automatisch Docker Image -> lokales Gitea Container Registry
|
||||||
|
5. Watchtower auf NAS erkennt neues Image (alle 5 Min)
|
||||||
|
6. Watchtower aktualisiert Container automatisch
|
||||||
|
7. Fertig - Website aktualisiert ohne SSH!
|
||||||
|
|
||||||
|
## Anpassungs-Roadmap
|
||||||
|
|
||||||
|
- [x] Titel, Subtitle, Autor anpassen (Mischkomposition / Tom Misch)
|
||||||
|
- [x] Social Media Links aktualisieren (Instagram: @Mischkomposition)
|
||||||
|
- [x] Sponsor-Bereich entfernen
|
||||||
|
- [x] Kontaktformular auf tom@mischlabs.de konfiguriert (Formsubmit.co)
|
||||||
|
- [x] Google Analytics vom Originalautor entfernt
|
||||||
|
- [x] Custom Domain: tom.mischlabs.de eingetragen
|
||||||
|
- [x] Nextcloud-Integration (Bilder aus Websitebilder-Ordner)
|
||||||
|
- [x] Dynamische Galerie (JS statt Jekyll-Loop)
|
||||||
|
- [x] Automatische Thumbnail-Generierung
|
||||||
|
- [x] Login-System mit JWT + SQLite
|
||||||
|
- [x] Admin-Dashboard (Nutzerverwaltung, Bildzuweisung)
|
||||||
|
- [x] Kunden-Portal (zugewiesene Bilder + Download)
|
||||||
|
- [x] Watchtower fuer automatische Updates
|
||||||
|
- [x] 3-Rollen-System (admin/user/client mit 30-Tage-Ablauf)
|
||||||
|
- [x] Ordner-basierte Bildzuweisung (Unterordner = private Kundenbilder)
|
||||||
|
- [x] Passwort-Aenderung (Self-Service + Admin kann fuer andere aendern)
|
||||||
|
- [ ] Footer Bio-Text schreiben (optional)
|
||||||
|
- [ ] Farbschema/Design anpassen
|
||||||
|
- [ ] Eigene Kategorien/Alben erstellen (Feature-Erweiterung)
|
||||||
|
- [ ] Admin-Passwort aendern!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Letzte Aktualisierung: 2026-04-12*
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"openid-client": "^5.6.0",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user