mirror of
https://github.com/R0m1k3/LiveFlow.git
synced 2026-10-11 17:27:19 +02:00
Authentification : login/mot de passe (admin/admin par défaut)
- Page de connexion, session 7 jours par cookie HMAC signé (secret persisté) - Protection de l'API (401) et du WebSocket (code 4401), / redirige vers /login - Bouton de déconnexion, redirection automatique à l'expiration de session - Identifiants configurables via LIVEFLOW_USER / LIVEFLOW_PASSWORD https://claude.ai/code/session_01YHMp3EKzr4s6o8w1ygxuUe
This commit is contained in:
9 files changed
+199
-7
No files matched your search
@@ -2,6 +2,10 @@
|
|||||||
# adresse. Indispensable pour accéder à l'app depuis un autre appareil.
|
# adresse. Indispensable pour accéder à l'app depuis un autre appareil.
|
||||||
LIVEFLOW_HOST=192.168.1.16
|
LIVEFLOW_HOST=192.168.1.16
|
||||||
|
|
||||||
|
# Identifiants de connexion à l'interface (admin/admin par défaut).
|
||||||
|
LIVEFLOW_USER=admin
|
||||||
|
LIVEFLOW_PASSWORD=admin
|
||||||
|
|
||||||
# Code langue ISO forcé pour la transcription ("fr", "en"...).
|
# Code langue ISO forcé pour la transcription ("fr", "en"...).
|
||||||
# Laisser vide pour la détection automatique de la langue.
|
# Laisser vide pour la détection automatique de la langue.
|
||||||
ASR_LANGUAGE=
|
ASR_LANGUAGE=
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ docker compose -f docker-compose.unraid.yml up -d app
|
|||||||
> Première utilisation : le paquet ghcr.io doit être **public** (GitHub →
|
> Première utilisation : le paquet ghcr.io doit être **public** (GitHub →
|
||||||
> page du dépôt → Packages → liveflow → Package settings → Change visibility).
|
> page du dépôt → Packages → liveflow → Package settings → Change visibility).
|
||||||
|
|
||||||
|
## Authentification
|
||||||
|
|
||||||
|
L'interface est protégée par un identifiant/mot de passe (**admin / admin**
|
||||||
|
par défaut), définis par les variables `LIVEFLOW_USER` et `LIVEFLOW_PASSWORD`
|
||||||
|
du compose. La session dure 7 jours (cookie signé). **Changez le mot de passe
|
||||||
|
par défaut si l'application est accessible depuis internet.**
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Variables d'environnement (fichier `.env` à la racine, voir `.env.example`) :
|
Variables d'environnement (fichier `.env` à la racine, voir `.env.example`) :
|
||||||
|
|||||||
+92
-3
@@ -1,16 +1,21 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
import wave
|
import wave
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from segmenter import SAMPLE_RATE, Segment, SpeechSegmenter
|
from segmenter import SAMPLE_RATE, Segment, SpeechSegmenter
|
||||||
|
|
||||||
@@ -20,14 +25,49 @@ ASR_MODEL = os.environ.get("ASR_MODEL", "Qwen/Qwen3-ASR-1.7B")
|
|||||||
ASR_API_KEY = os.environ.get("ASR_API_KEY", "sk-local")
|
ASR_API_KEY = os.environ.get("ASR_API_KEY", "sk-local")
|
||||||
ASR_LANGUAGE = os.environ.get("ASR_LANGUAGE", "").strip()
|
ASR_LANGUAGE = os.environ.get("ASR_LANGUAGE", "").strip()
|
||||||
|
|
||||||
|
LIVEFLOW_USER = os.environ.get("LIVEFLOW_USER", "admin")
|
||||||
|
LIVEFLOW_PASSWORD = os.environ.get("LIVEFLOW_PASSWORD", "admin")
|
||||||
|
SESSION_TTL = 7 * 24 * 3600 # 7 jours
|
||||||
|
SESSION_COOKIE = "liveflow_session"
|
||||||
|
|
||||||
db: aiosqlite.Connection | None = None
|
db: aiosqlite.Connection | None = None
|
||||||
http: httpx.AsyncClient | None = None
|
http: httpx.AsyncClient | None = None
|
||||||
|
session_secret: bytes = b""
|
||||||
|
|
||||||
|
|
||||||
|
def load_session_secret() -> bytes:
|
||||||
|
"""Secret HMAC persistant pour signer les cookies de session."""
|
||||||
|
path = os.path.join(os.path.dirname(DB_PATH), "session-secret")
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
secret = secrets.token_bytes(32)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(secret)
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
def make_session_token() -> str:
|
||||||
|
expiry = str(int(time.time()) + SESSION_TTL)
|
||||||
|
sig = hmac.new(session_secret, expiry.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return f"{expiry}.{sig}"
|
||||||
|
|
||||||
|
|
||||||
|
def session_valid(token: str) -> bool:
|
||||||
|
try:
|
||||||
|
expiry, sig = token.split(".", 1)
|
||||||
|
expected = hmac.new(session_secret, expiry.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(sig, expected) and time.time() < int(expiry)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
global db, http
|
global db, http, session_secret
|
||||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
session_secret = load_session_secret()
|
||||||
db = await aiosqlite.connect(DB_PATH)
|
db = await aiosqlite.connect(DB_PATH)
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
await db.executescript(
|
await db.executescript(
|
||||||
@@ -57,6 +97,52 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(title="LiveFlow", lifespan=lifespan)
|
app = FastAPI(title="LiveFlow", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- authentification
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def auth_middleware(request: Request, call_next):
|
||||||
|
path = request.url.path
|
||||||
|
authed = session_valid(request.cookies.get(SESSION_COOKIE, ""))
|
||||||
|
if path.startswith("/api") and path != "/api/login" and not authed:
|
||||||
|
return JSONResponse({"detail": "Non authentifié"}, status_code=401)
|
||||||
|
if path == "/" and not authed:
|
||||||
|
return RedirectResponse("/login")
|
||||||
|
if path == "/login" and authed:
|
||||||
|
return RedirectResponse("/")
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginBody(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
async def login_page():
|
||||||
|
return FileResponse("static/login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/login")
|
||||||
|
async def login(body: LoginBody):
|
||||||
|
user_ok = hmac.compare_digest(body.username.encode(), LIVEFLOW_USER.encode())
|
||||||
|
pass_ok = hmac.compare_digest(body.password.encode(), LIVEFLOW_PASSWORD.encode())
|
||||||
|
if not (user_ok and pass_ok):
|
||||||
|
raise HTTPException(401, "Identifiants invalides")
|
||||||
|
resp = JSONResponse({"ok": True})
|
||||||
|
resp.set_cookie(
|
||||||
|
SESSION_COOKIE, make_session_token(),
|
||||||
|
max_age=SESSION_TTL, httponly=True, samesite="lax",
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/logout")
|
||||||
|
async def logout():
|
||||||
|
resp = JSONResponse({"ok": True})
|
||||||
|
resp.delete_cookie(SESSION_COOKIE)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def pcm_to_wav(pcm: bytes) -> bytes:
|
def pcm_to_wav(pcm: bytes) -> bytes:
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
with wave.open(buf, "wb") as w:
|
with wave.open(buf, "wb") as w:
|
||||||
@@ -98,6 +184,9 @@ async def transcribe(pcm: bytes) -> str:
|
|||||||
|
|
||||||
@app.websocket("/ws")
|
@app.websocket("/ws")
|
||||||
async def ws_transcribe(ws: WebSocket):
|
async def ws_transcribe(ws: WebSocket):
|
||||||
|
if not session_valid(ws.cookies.get(SESSION_COOKIE, "")):
|
||||||
|
await ws.close(code=4401)
|
||||||
|
return
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
|
|
||||||
# Premier message : {"type": "start", "title": "..."}
|
# Premier message : {"type": "start", "title": "..."}
|
||||||
|
|||||||
+18
-4
@@ -15,6 +15,16 @@ const state = {
|
|||||||
|
|
||||||
const BATCH_SAMPLES = 4096; // ~256 ms de PCM 16 kHz par message WebSocket
|
const BATCH_SAMPLES = 4096; // ~256 ms de PCM 16 kHz par message WebSocket
|
||||||
|
|
||||||
|
// fetch avec redirection vers la page de connexion si la session a expiré
|
||||||
|
async function api(url, opts) {
|
||||||
|
const resp = await fetch(url, opts);
|
||||||
|
if (resp.status === 401) {
|
||||||
|
location.href = '/login';
|
||||||
|
throw new Error('session expirée');
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------- enregistrement
|
// ----------------------------------------------------------- enregistrement
|
||||||
|
|
||||||
async function startRecording() {
|
async function startRecording() {
|
||||||
@@ -43,7 +53,10 @@ async function startRecording() {
|
|||||||
state.ws = new WebSocket(`${proto}://${location.host}/ws`);
|
state.ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||||
state.ws.onopen = () => state.ws.send(JSON.stringify({ type: 'start', title: $('title').value }));
|
state.ws.onopen = () => state.ws.send(JSON.stringify({ type: 'start', title: $('title').value }));
|
||||||
state.ws.onmessage = onServerMessage;
|
state.ws.onmessage = onServerMessage;
|
||||||
state.ws.onclose = () => { if (state.recording) stopRecording(true); };
|
state.ws.onclose = (e) => {
|
||||||
|
if (e.code === 4401) { location.href = '/login'; return; }
|
||||||
|
if (state.recording) stopRecording(true);
|
||||||
|
};
|
||||||
|
|
||||||
state.audioContext = new AudioContext();
|
state.audioContext = new AudioContext();
|
||||||
await state.audioContext.audioWorklet.addModule('worklet.js');
|
await state.audioContext.audioWorklet.addModule('worklet.js');
|
||||||
@@ -174,7 +187,7 @@ function showExportBar(meetingId) {
|
|||||||
// ----------------------------------------------------------------- réunions
|
// ----------------------------------------------------------------- réunions
|
||||||
|
|
||||||
async function loadMeetings() {
|
async function loadMeetings() {
|
||||||
const meetings = await (await fetch('/api/meetings')).json();
|
const meetings = await (await api('/api/meetings')).json();
|
||||||
const ul = $('meeting-list');
|
const ul = $('meeting-list');
|
||||||
ul.innerHTML = '';
|
ul.innerHTML = '';
|
||||||
for (const m of meetings) {
|
for (const m of meetings) {
|
||||||
@@ -190,7 +203,7 @@ async function loadMeetings() {
|
|||||||
|
|
||||||
async function openMeeting(id) {
|
async function openMeeting(id) {
|
||||||
if (state.recording) return;
|
if (state.recording) return;
|
||||||
const meeting = await (await fetch(`/api/meetings/${id}`)).json();
|
const meeting = await (await api(`/api/meetings/${id}`)).json();
|
||||||
state.currentMeetingId = id;
|
state.currentMeetingId = id;
|
||||||
$('transcript-title').textContent = meeting.title;
|
$('transcript-title').textContent = meeting.title;
|
||||||
clearTranscript();
|
clearTranscript();
|
||||||
@@ -206,7 +219,7 @@ async function openMeeting(id) {
|
|||||||
async function deleteCurrentMeeting() {
|
async function deleteCurrentMeeting() {
|
||||||
if (!state.currentMeetingId || state.recording) return;
|
if (!state.currentMeetingId || state.recording) return;
|
||||||
if (!confirm('Supprimer définitivement cette réunion et sa transcription ?')) return;
|
if (!confirm('Supprimer définitivement cette réunion et sa transcription ?')) return;
|
||||||
await fetch(`/api/meetings/${state.currentMeetingId}`, { method: 'DELETE' });
|
await api(`/api/meetings/${state.currentMeetingId}`, { method: 'DELETE' });
|
||||||
state.currentMeetingId = null;
|
state.currentMeetingId = null;
|
||||||
$('transcript-title').textContent = 'Transcription';
|
$('transcript-title').textContent = 'Transcription';
|
||||||
clearTranscript();
|
clearTranscript();
|
||||||
@@ -226,6 +239,7 @@ async function copyTranscript() {
|
|||||||
// --------------------------------------------------------------------- init
|
// --------------------------------------------------------------------- init
|
||||||
|
|
||||||
$('record-btn').onclick = () => (state.recording ? stopRecording() : startRecording());
|
$('record-btn').onclick = () => (state.recording ? stopRecording() : startRecording());
|
||||||
|
$('logout-btn').onclick = async () => { await fetch('/api/logout', { method: 'POST' }); location.href = '/login'; };
|
||||||
$('copy-btn').onclick = copyTranscript;
|
$('copy-btn').onclick = copyTranscript;
|
||||||
$('delete-btn').onclick = deleteCurrentMeeting;
|
$('delete-btn').onclick = deleteCurrentMeeting;
|
||||||
loadMeetings();
|
loadMeetings();
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1>🎙️ LiveFlow</h1>
|
<h1>🎙️ LiveFlow</h1>
|
||||||
<span id="status" class="badge idle">Prêt</span>
|
<span id="status" class="badge idle">Prêt</span>
|
||||||
|
<button id="logout-btn" title="Se déconnecter">Déconnexion</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LiveFlow — Connexion</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8E%99%EF%B8%8F%3C/text%3E%3C/svg%3E">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<style>
|
||||||
|
body { align-items: center; justify-content: center; }
|
||||||
|
.login-box {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid #262b3a;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 32px;
|
||||||
|
width: min(360px, 90vw);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.login-box h1 { font-size: 1.3rem; text-align: center; margin-bottom: 6px; }
|
||||||
|
.login-box input {
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid #2c3245;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.login-box input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.login-box button { background: var(--accent); font-weight: 600; padding: 11px; }
|
||||||
|
#login-error { color: #ff8589; font-size: 0.85rem; text-align: center; min-height: 1.2em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form class="login-box" id="login-form">
|
||||||
|
<h1>🎙️ LiveFlow</h1>
|
||||||
|
<input id="username" type="text" placeholder="Utilisateur" autocomplete="username" required autofocus>
|
||||||
|
<input id="password" type="password" placeholder="Mot de passe" autocomplete="current-password" required>
|
||||||
|
<button type="submit">Se connecter</button>
|
||||||
|
<div id="login-error"></div>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const resp = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: document.getElementById('username').value,
|
||||||
|
password: document.getElementById('password').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
location.href = '/';
|
||||||
|
} else {
|
||||||
|
document.getElementById('login-error').textContent = 'Identifiants invalides';
|
||||||
|
document.getElementById('password').value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -41,6 +41,14 @@ header h1 { font-size: 1.2rem; }
|
|||||||
.badge.busy { background: #2a2410; color: #ffd166; }
|
.badge.busy { background: #2a2410; color: #ffd166; }
|
||||||
.badge.error { background: #3a181a; color: #ff8589; }
|
.badge.error { background: #3a181a; color: #ff8589; }
|
||||||
|
|
||||||
|
#logout-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
#logout-btn:hover { color: var(--text); }
|
||||||
|
|
||||||
.layout { display: flex; flex: 1; min-height: 0; }
|
.layout { display: flex; flex: 1; min-height: 0; }
|
||||||
|
|
||||||
aside {
|
aside {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ services:
|
|||||||
# "off" si un reverse proxy (Nginx Proxy Manager, SWAG...) gère déjà le
|
# "off" si un reverse proxy (Nginx Proxy Manager, SWAG...) gère déjà le
|
||||||
# HTTPS : l'app sert alors du HTTP simple sur le port 8443.
|
# HTTPS : l'app sert alors du HTTP simple sur le port 8443.
|
||||||
- LIVEFLOW_TLS=off
|
- LIVEFLOW_TLS=off
|
||||||
|
# Identifiants de connexion à l'interface — À CHANGER si exposé sur internet
|
||||||
|
- LIVEFLOW_USER=admin
|
||||||
|
- LIVEFLOW_PASSWORD=admin
|
||||||
- ASR_BASE_URL=http://asr:8000/v1
|
- ASR_BASE_URL=http://asr:8000/v1
|
||||||
- ASR_MODEL=Qwen/Qwen3-ASR-1.7B
|
- ASR_MODEL=Qwen/Qwen3-ASR-1.7B
|
||||||
- ASR_API_KEY=sk-local
|
- ASR_API_KEY=sk-local
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# IP ou nom d'hôte du serveur, pour le certificat HTTPS auto-signé
|
# IP ou nom d'hôte du serveur, pour le certificat HTTPS auto-signé
|
||||||
- LIVEFLOW_HOST=${LIVEFLOW_HOST:-localhost}
|
- LIVEFLOW_HOST=${LIVEFLOW_HOST:-localhost}
|
||||||
|
# Identifiants de connexion à l'interface
|
||||||
|
- LIVEFLOW_USER=${LIVEFLOW_USER:-admin}
|
||||||
|
- LIVEFLOW_PASSWORD=${LIVEFLOW_PASSWORD:-admin}
|
||||||
- ASR_BASE_URL=http://asr:8000/v1
|
- ASR_BASE_URL=http://asr:8000/v1
|
||||||
- ASR_MODEL=Qwen/Qwen3-ASR-1.7B
|
- ASR_MODEL=Qwen/Qwen3-ASR-1.7B
|
||||||
- ASR_API_KEY=${ASR_API_KEY:-sk-local}
|
- ASR_API_KEY=${ASR_API_KEY:-sk-local}
|
||||||
|
|||||||
Reference in new issue
Block a user