Con questo ariticolo in cui spiegherò come implementare il multiutente per il DB con relativo PopUp di Login, concluderò questa serie di articoli.
L’idea di base è quella di rendere questo DB utilizzabile da un numero limitato di utenti, non prevederò quindi un form di registrazione per chiunque voglia registrarsi. La creazione dell’utente la farà manualmente il gestore del DB che comunicherà al futuro utente login e password (di libera scelta).
Fase 1: creare la tabella utenti, creare gli utenti, modificare gli attributi utenti, impostare una password in chiaro e oscurata.
Entrare in PostgreSQL con il comando:
docker exec -it pokemon-postgres psql -U pokemon -d pokemon
digitare:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
questo comando potrebbe essere stato già dato in articoli precedenti e generare quindi un errore del tipo "ERROR: relation "users" already exists" provate quindi a dare il comando:
\d users
per visualizzare la struttura delle tabelle del DB e poi:
SELECT
id,
username,
email,
is_admin
FROM users;
dovrebbe comparire solo l'utente paolo o altro nome se l'avete cambiato. Per inserire altri utenti per esempio pietro il comando è:
INSERT INTO users (
username,
email,
password_hash,
display_name
)
VALUES
(
'pietro',
'pietro@local',
'TEMP',
'Pietro'
);
per visualizzare l'elenco utenti dare il comando:
SELECT id, username, email, is_admin FROM users;
Dovrebbero esserci due utenti Paolo e Pietro, se volete cambiare dei parametri ad un utente il comando è:
Cambiare username:
UPDATE users
SET username = 'nuovo_username'
WHERE username = 'vecchio_username';
Cambiare email:
UPDATE users
SET email = 'nuova@email.it'
WHERE username = 'pietro';
Cambiare display_name:
UPDATE users
SET display_name = 'Pietro Rossi'
WHERE username = 'pietro';
Cambiare password:
UPDATE users
SET password_hash = '$2b$12$A1B2C3D4'
WHERE username = 'paolo';
Eliminare un utente:
DELETE FROM users
WHERE username = 'test';
Prima di procedere è necessario fare un piccolo passo di sicurezza per far in modo che le password non siano visibili in chiaro ma con hash. uscire dal comando "pokemon=#" con exit e dare il comando:
apt update
apt install -y python3-bcrypt
poi:
python3
import bcrypt
password = "Paolo123"
hashed = bcrypt.hashpw(
password.encode(),
bcrypt.gensalt()
)
print(hashed.decode())
Nel campo password scrivi pa password che vuoi uscurare e dopo aver eseguito i comandi Otterrai qualcosa tipo:
$2b$12$A1B2C3D4
copiare tutta la passwor ottenuta e rientrare in PostgreSQL con il comando:
docker exec -it pokemon-postgres psql -U pokemon -d pokemon
digitare:
UPDATE users
SET password_hash = '$2b$12$PR4S04QkiMS1VfKHz29TNuj0SNE23rqjiJ7mQJr1CsL/luuKgKbya'
WHERE username = 'paolo';
Dopo queste modifiche il mio container è andato in crash per farlo ripartire ho dovuto modificare il file database.py con il comando:
nano /opt/pokemon/backend/app/database.py
e sostituire la riga:
DATABASE_URL = "postgresql://pokemon:pokemon123@postgres:5432/pokemon"
con:
DATABASE_URL = "postgresql+psycopg2://pokemon:pokemon123@postgres:5432/pokemon"
Modificare il file requirements.txt:
nano /opt/pokemon/backend/requirements.txt
sostituire il contenuto con:
fastapi
uvicorn[standard]
sqlalchemy
psycopg2-binary
requests
bcrypt
itsdangerous
Per poi ricompilare:
cd /opt/pokemon
docker compose build --no-cache
docker compose up -d
Fase 2: Creare la pagina di login e logout con protezione delle altre da accessi diretti
Creare la pagina di login:
nano /opt/pokemon/backend/app/static/login.html
con questo contenuto:
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<title>Login - Pokemon DB</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="header">
<div class="logo">Pokemon DB by Paolo Tuttoilmondo</div>
</div>
<div style="display:flex; justify-content:center; align-items:center; min-height:calc(100vh - 70px);">
<div class="set-info" style="width:400px; max-width:90vw; padding:30px;">
<h2 style="text-align:center; margin-bottom:30px;">Accesso</h2>
<div class="form-group">
<label>Username</label>
<input id="username" type="text" autocomplete="username">
</div>
<div class="form-group">
<label>Password</label>
<input id="password" type="password" autocomplete="current-password">
</div>
<button id="loginButton" style=" width:100%; margin-top:20px;">Accedi</button>
<div id="loginMessage" style="margin-top:15px; text-align:center; font-weight:bold; color:#d32f2f;"></div>
</div>
</div>
<script>
document
.getElementById('loginButton')
.addEventListener('click',
async function () {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const response = await fetch('/login',
{
method: 'POST',
headers: {
'Content-Type':
'application/json'
},
body: JSON.stringify({
username,
password
})
}
);
const result = await response.json();
if (result.success) {
window.location = '/';
} else {
document.getElementById('loginMessage').textContent = 'Username o password non validi';
}
}
);
document
.getElementById('password')
.addEventListener(
'keydown',
function(event) {
if (
event.key === 'Enter'
) {
document
.getElementById(
'loginButton'
)
.click();
}
}
);
</script>
</body>
</html>
Le modifiche ai file index.html e main.py sono radicali, quindi vi darò l'intero codice cimpleto:
Main.py:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import bcrypt
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from app.database import engine
from starlette.middleware.sessions import SessionMiddleware
from fastapi import Request
app = FastAPI(
title="Pokemon Collection API",
docs_url=None,
redoc_url=None,
openapi_url=None
)
app.add_middleware(
SessionMiddleware,
secret_key="pokemon-db-2026"
)
app.mount(
"/static",
StaticFiles(directory="app/static"),
name="static"
)
class CollectionAdd(BaseModel):
card_id: str
language: str
condition: str
variant: str
graded: bool = False
quantity: int = 1
class CollectionUpdate(BaseModel):
quantity: int
language: str
condition: str
variant: str
graded: bool
class LoginRequest(BaseModel):
username: str
password: str
@app.delete("/collection/{item_id}")
def delete_collection_item(
item_id: int,
request: Request
):
user_id = request.session.get(
"user_id"
)
if not user_id:
raise HTTPException(
status_code=401,
detail="Not authenticated"
)
with engine.connect() as conn:
result = conn.execute(
text("""
DELETE FROM collection
WHERE id = :item_id
AND user_id = :user_id
"""),
{
"item_id": item_id,
"user_id": user_id
}
)
conn.commit()
return {
"success": True,
"deleted_id": item_id
}
@app.get("/")
def root(request: Request):
if request.session.get("user_id"):
return FileResponse(
"app/static/index.html"
)
return FileResponse(
"app/static/login.html"
)
@app.get("/cards")
def get_cards():
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT *
FROM cards
ORDER BY name
LIMIT 100
""")
)
rows = result.mappings().all()
return rows
@app.get("/collection")
def get_collection(request: Request):
user_id = request.session.get(
"user_id"
)
if not user_id:
raise HTTPException(
status_code=401,
detail="Not authenticated"
)
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT
col.id,
col.card_id,
c.name,
c.card_number,
s.name AS set_name,
col.quantity,
col.language,
col.condition,
col.variant,
col.graded
FROM collection col
JOIN cards c
ON c.id = col.card_id
JOIN sets s
ON s.id = c.set_id
WHERE col.user_id = :user_id
ORDER BY c.name
"""),
{
"user_id": user_id
}
)
return result.mappings().all()
@app.get("/current-user")
def current_user(request: Request):
return {
"user_id":
request.session.get("user_id"),
"username":
request.session.get("username")
}
@app.get("/dashboard")
def dashboard():
return FileResponse(
"app/static/dashboard.html"
)
@app.get("/index")
def index(request: Request):
if not request.session.get(
"user_id"
):
return FileResponse(
"app/static/login.html"
)
return FileResponse(
"app/static/index.html"
)
@app.get("/logout")
def logout(request: Request):
request.session.clear()
return {
"success": True
}
@app.get("/me")
def me(request: Request):
return {
"user_id":
request.session.get(
"user_id"
),
"username":
request.session.get(
"username"
)
}
@app.get("/health")
def health():
return {
"status": "ok"
}
@app.get("/sets")
def get_sets():
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT *
FROM sets
ORDER BY release_date DESC
""")
)
rows = result.mappings().all()
return rows
@app.get("/set/{set_id}/cards")
def get_set_cards(
set_id: str,
request: Request
):
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(
status_code=401,
detail="Not authenticated"
)
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT
c.id,
c.name,
c.card_number,
c.rarity,
c.image_small,
c.image_large,
s.name AS set_name,
s.total_cards,
EXISTS (
SELECT 1
FROM collection col
WHERE col.card_id = c.id
AND col.user_id = :user_id
) AS owned
FROM cards c
JOIN sets s
ON s.id = c.set_id
WHERE c.set_id = :set_id
ORDER BY
CASE
WHEN c.card_number ~ '^[0-9]+$'
THEN c.card_number::integer
END NULLS LAST,
c.card_number
"""),
{
"set_id": set_id,
"user_id": user_id
}
)
return result.mappings().all()
@app.get("/set/{set_id}/info")
def get_set_info(set_id: str):
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT
id,
name,
printed_total
FROM sets
WHERE id = :set_id
"""),
{"set_id": set_id}
)
return result.mappings().first()
@app.get("/stats")
def get_stats(request: Request):
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(
status_code=401,
detail="Not authenticated"
)
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT
COALESCE(SUM(quantity), 0) AS total_cards,
COUNT(*) AS unique_cards,
COUNT(DISTINCT c.set_id) AS sets_started
FROM collection col
JOIN cards c
ON c.id = col.card_id
WHERE col.user_id = :user_id
"""),
{"user_id": user_id}
)
stats = result.mappings().first()
return stats
@app.get("/stats/sets")
def get_set_stats(request: Request):
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(
status_code=401,
detail="Not authenticated"
)
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT
s.id,
s.name,
s.release_date,
s.total_cards,
COUNT(
DISTINCT CASE
WHEN col.id IS NOT NULL
THEN c.id
END
) AS owned_cards,
ROUND(
COUNT(
DISTINCT CASE
WHEN col.id IS NOT NULL
THEN c.id
END
) * 100.0 / s.total_cards,
1
) AS completion
FROM sets s
LEFT JOIN cards c
ON c.set_id = s.id
LEFT JOIN collection col
ON col.card_id = c.id
AND col.user_id = :user_id
GROUP BY
s.id,
s.name,
s.release_date,
s.total_cards
ORDER BY
completion DESC,
owned_cards DESC,
s.release_date DESC,
s.name
"""),
{
"user_id": user_id
}
)
return result.mappings().all()
@app.get("/users")
def get_users():
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT
id,
username,
display_name,
is_admin
FROM users
ORDER BY id
""")
)
rows = result.mappings().all()
return rows
@app.post("/collection/add")
def add_to_collection(
item: CollectionAdd,
request: Request
):
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(
status_code=401,
detail="Not authenticated"
)
with engine.connect() as conn:
conn.execute(
text("""
INSERT INTO collection (
user_id,
card_id,
quantity,
language,
condition,
variant,
graded
)
VALUES (
:user_id,
:card_id,
:quantity,
:language,
:condition,
:variant,
:graded
)
ON CONFLICT (
user_id,
card_id,
language,
condition,
variant,
graded
)
DO UPDATE
SET quantity =
collection.quantity +
EXCLUDED.quantity
"""),
{
"user_id": user_id,
"card_id": item.card_id,
"quantity": item.quantity,
"language": item.language,
"condition": item.condition,
"variant": item.variant,
"graded": item.graded
}
)
conn.commit()
return {
"success": True,
"card_id": item.card_id
}
@app.post("/login")
def login(
login_request: LoginRequest,
request: Request
):
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT
id,
username,
password_hash
FROM users
WHERE username = :username
"""),
{
"username": login_request.username
}
)
user = result.mappings().first()
if not user:
return {
"success": False
}
if not bcrypt.checkpw(
login_request.password.encode(),
user["password_hash"].encode()
):
return {
"success": False
}
request.session["user_id"] = user["id"]
request.session["username"] = user["username"]
return {
"success": True,
"user_id": user["id"],
"username": user["username"]
}
@app.put("/collection/{item_id}")
def update_collection_item(
item_id: int,
item: CollectionUpdate,
request: Request
):
user_id = request.session.get(
"user_id"
)
if not user_id:
raise HTTPException(
status_code=401,
detail="Not authenticated"
)
with engine.connect() as conn:
conn.execute(
text("""
UPDATE collection
SET
quantity = :quantity,
language = :language,
condition = :condition,
variant = :variant,
graded = :graded
WHERE id = :item_id
AND user_id = :user_id
"""),
{
"item_id": item_id,
"user_id": user_id,
"quantity": item.quantity,
"language": item.language,
"condition": item.condition,
"variant": item.variant,
"graded": item.graded
}
)
conn.commit()
return {
"success": True,
"updated_id": item_id
}
Index.html:
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<title>Pokemon DB by Paolo Tuttoilmondo</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="header">
<div class="logo">Pokemon DB by Paolo Tuttoilmondo</div>
<div class="user"><span id="currentUsername"></span>
<button id="logoutButton" onclick="logout()">Logout</button>
</div>
</div>
<div class="container">
<div class="toolbar">
<button onclick="window.location='/static/dashboard.html'">Statistiche</button>
<div class="toolbar-search">
<input type="text" id="searchBox" placeholder="Cerca carta..."></div>
</div>
<div class="set-info"><label>Set: </label>
<img id="setSymbol" style="width:32px; height:32px; vertical-align:middle; margin:0 10px;">
<select id="setSelect"></select>
</div>
<div class="set-info">
<h2 style="display:flex; align-items:center; gap:10px;">
<img id="setTitleSymbol" style="width:40px; height:40px;">
<span id="setTitle"></span>
</h2>
</div>
<div id="card-grid" class="card-grid"></div>
</div>
<script>
let allCards = [];
let selectedCardIndex = -1;
let selectedCard = null;
let selectedCollectionItem = null;
let currentUser = null;
async function loadCards(setId = 'me5') {
const response = await fetch(`/set/${setId}/cards`);
const cards = await response.json();
allCards = cards;
renderCards(cards);
}
async function loadCollectionData() {
const response = await fetch(`/collection`);
return await response.json();
}
async function loadCurrentUser() {
const response = await fetch('/current-user');
currentUser = await response.json();
document.getElementById('currentUsername').textContent = currentUser.username;
}
async function logout() {
await fetch('/logout');
window.location = '/static/login.html';
}
async function loadSets() {
const response = await fetch('/sets');
const sets = await response.json();
const select = document.getElementById('setSelect');
select.innerHTML = '';
sets.forEach((set, index) => {
const option = document.createElement('option');
option.value = set.id;
const year = set.release_date.substring(0, 4);
option.textContent = `${year} - ${set.name}`;
if (index === 0) {
option.selected = true;
document.getElementById('setTitle').textContent = `${year} - ${set.name}`;
document.getElementById('setSymbol').src = set.symbol_url;
document.getElementById('setTitleSymbol').src = set.symbol_url;
}
select.appendChild(option);
});
select.addEventListener('change', () => {
const selectedSet = sets.find(s => s.id === select.value);
document.getElementById('setTitle').textContent = select.options[select.selectedIndex].text;
document.getElementById('setSymbol').src = selectedSet.symbol_url;
document.getElementById('setTitleSymbol').src = selectedSet.symbol_url;
loadCards(select.value);
});
if (sets.length > 0) {
loadCards(sets[0].id);
}
}
async function openPopup(index) {
selectedCardIndex = index;
selectedCard = allCards[index];
const collection = await loadCollectionData();
selectedCollectionItem = collection.find(item => item.card_id === selectedCard.id);
if (selectedCollectionItem) {
document.getElementById('standardLanguage').value = selectedCollectionItem.language;
document.getElementById('standardCondition').value = selectedCollectionItem.condition;
document.getElementById('standardQuantity').value = selectedCollectionItem.quantity;
} else {
document.getElementById('standardLanguage').value = 'IT';
document.getElementById('standardCondition').value = 'NM';
document.getElementById('standardQuantity').value = 0;
}
document.getElementById('popupTitle').textContent = selectedCard.name;
document.getElementById('popupImage').src = selectedCard.image_small.replace('.png', '_hires.png');
document.getElementById('popupNumber').textContent = selectedCard.card_number.toString().padStart(3, '0');
document.getElementById('popupTotal').textContent = selectedCard.total_cards;
document.getElementById('popupSet').textContent = selectedCard.set_name;
document.getElementById('popupRarity').textContent = selectedCard.rarity || '';
document.getElementById('popup').style.display = 'flex';
}
async function saveCard() {
const payload = {
quantity: parseInt(
document.getElementById(
'standardQuantity'
).value
),
language:
document.getElementById(
'standardLanguage'
).value,
condition:
document.getElementById(
'standardCondition'
).value,
variant: 'Standard',
graded: false
};
const quantity = parseInt(
document.getElementById(
'standardQuantity'
).value
);
let response;
if (selectedCollectionItem && quantity === 0) {
response = await fetch(
`/collection/${selectedCollectionItem.id}`,
{
method: 'DELETE'
}
);
selectedCollectionItem = null;
} else if (selectedCollectionItem) {
response = await fetch(
`/collection/${selectedCollectionItem.id}`,
{
method: 'PUT',
headers: {
'Content-Type':
'application/json'
},
body: JSON.stringify(payload)
}
);
} else {
response = await fetch(
'/collection/add',
{
method: 'POST',
headers: {
'Content-Type':
'application/json'
},
body: JSON.stringify({
card_id: selectedCard.id,
...payload
})
}
);
}
const result = await response.json();
console.log(result);
loadCards(
document.getElementById(
'setSelect'
).value
);
}
function previousCard() {
if (selectedCardIndex <= 0) {
return;
}
openPopup(selectedCardIndex - 1);
}
function nextCard() {
if (selectedCardIndex >= allCards.length - 1) {
return;
}
openPopup(selectedCardIndex + 1);
}
function closePopup() {
document.getElementById('popup').style.display =
'none';
}
function renderCards(cards) {
const grid =
document.getElementById(
'card-grid'
);
grid.innerHTML = '';
cards.forEach((card) => {
const originalIndex =
allCards.findIndex(
c => c.id === card.id
);
const div =
document.createElement('div');
div.className =
card.owned
? 'card'
: 'card not-owned';
div.onclick =
() => openPopup(originalIndex);
div.innerHTML = `
<img src="${card.image_small}" alt="${card.name}">
<div class="card-body">
<div class="card-number">
#${card.card_number}
</div>
<div class="card-name">
${card.name}
</div>
<div class="card-rarity">
${card.rarity || ''}
</div>
</div>
`;
grid.appendChild(div);
});
}
document
.getElementById('searchBox')
.addEventListener(
'input',
function () {
const search =
this.value
.toLowerCase()
.trim();
const filtered =
allCards.filter(card =>
card.name
.toLowerCase()
.includes(search)
||
card.card_number
.toString()
.includes(search)
);
renderCards(filtered);
}
);
(async () => {
await loadCurrentUser();
loadSets();
})();
document.addEventListener(
'keydown',
function(event) {
const popup =
document.getElementById('popup');
if (
popup.style.display !== 'flex'
) {
return;
}
if (event.key === 'ArrowLeft') {
previousCard();
}
if (event.key === 'ArrowRight') {
nextCard();
}
if (event.key === 'ArrowUp') {
event.preventDefault();
const qty =
document.getElementById(
'standardQuantity'
);
qty.value =
parseInt(qty.value || 0) + 1;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
const qty =
document.getElementById(
'standardQuantity'
);
qty.value =
Math.max(
0,
parseInt(qty.value || 0) - 1
);
}
if (event.key === 'Enter') {
event.preventDefault();
saveCard();
}
}
);
</script>
<div id="popup" class="popup-overlay">
<div class="popup">
<h2 id="popupTitleBar">
<span id="popupTitle"></span>
| Set: <span id="popupSet"></span>
| Numero: <span id="popupNumber"></span> / <span id="popupTotal">120</span>
| Rarità: <span id="popupRarity"></span>
</h2>
<div class="popup-top">
<img id="popupImage">
<div class="popup-right">
<h3>STANDARD</h3>
<div class="form-group">
<label>Lingua</label>
<select id="standardLanguage">
<option value="IT">Italiano</option>
<option value="EN">Inglese</option>
<option value="JP">Giapponese</option>
</select>
</div>
<div class="form-group">
<label>Condizione</label>
<select id="standardCondition">
<option value="NM">Near Mint</option>
<option value="LP">Light Played</option>
<option value="MP">Moderately Played</option>
<option value="HP">Heavily Played</option>
</select>
</div>
<div class="form-group">
<label>Quantità</label>
<input
id="standardQuantity"
type="number"
value="0"
min="0"
>
</div>
<button id="addStandardButton" onclick="saveCard()">Salva</button>
</div>
</div>
<hr>
<div style="display:flex; justify-content:space-between; margin-top:20px;">
<button onclick="previousCard()">
← Carta precedente
</button>
<button onclick="nextCard()">
Carta successiva →
</button>
</div>
<hr>
<button onclick="closePopup()">
Chiudi
</button>
</div>
</div>
</body>
</html>
Aprire il CSS e sostituire .user con:
.user {
display: flex;
align-items: center;
gap: 12px;
color: white;
font-size: 22px;
font-weight: bold;
}
aggiungere in coda al file:
#logoutButton {
background: transparent;
border: 2px solid rgba(255,255,255,0.4);
border-radius: 8px;
padding: 6px 12px;
color: white;
font-size: 14px;
font-weight: bold;
cursor: pointer;
box-shadow: none;
}
#logoutButton:hover {
background: rgba(255,255,255,0.15);
transform: none;
}
#logoutButton:active {
transform: none;
box-shadow: none;
}
Ricompilare
Adesso se vi collegate alla pagina http://IP_SERVER:8000 comparirà la schermata di login, inserendo utente e password precedentemente scelti, potrete accedere alla home page. Attenzione però che al momento è possibile bypassare questo controllo, collegandosi direttamente a index.html o ad altre pagine.
Con questo articolo ho concluso questa guida che spero sia stata utile. Fatemi sapere cosa ne pensate scrivendo un commento.
Lascia un commento
Devi essere connesso per inviare un commento.