257 lines
7.9 KiB
JavaScript
257 lines
7.9 KiB
JavaScript
/**
|
||
* server.js – Express-Backend für Encore.
|
||
*
|
||
* Funktionen:
|
||
* - Proxy für die Ticketmaster Discovery API (API-Key bleibt serverseitig)
|
||
* - Einfache Benutzerverwaltung (In-Memory, kein Persistenz-Layer)
|
||
* - Einladungssystem zwischen registrierten Benutzern
|
||
*
|
||
* Sicherheitshinweise (OWASP):
|
||
* - A02 Cryptographic Failures: Passwörter werden nicht gehasht (bewusste Vereinfachung)
|
||
* - A07 Identification and Authentication Failures: Keine Tokens/Sessions, nur Header-Auth
|
||
* - Der API-Key liegt serverseitig in .env und wird nie an den Client übermittelt
|
||
*/
|
||
|
||
import express from "express";
|
||
import cors from "cors";
|
||
import dotenv from "dotenv";
|
||
|
||
dotenv.config();
|
||
|
||
const app = express();
|
||
|
||
// Middleware: CORS erlaubt Anfragen vom Frontend (localhost), JSON-Body parsen
|
||
app.use(cors());
|
||
app.use(express.json());
|
||
|
||
// =========================
|
||
// ROOT – Health-Check
|
||
// =========================
|
||
app.get("/", (req, res) => {
|
||
res.send("Encore API is running");
|
||
});
|
||
|
||
// =========================
|
||
// TICKETMASTER-PROXY
|
||
// Versteckt den API-Key vor dem Client (A02: Sensitive Data Exposure vermeiden)
|
||
// GET /api/events?city={city}
|
||
// =========================
|
||
const TM_API_KEY = process.env.TM_API_KEY;
|
||
|
||
app.get("/api/events", async (req, res) => {
|
||
const city = req.query.city;
|
||
|
||
if (!city) {
|
||
return res.status(400).json({ message: "Stadt ist erforderlich" });
|
||
}
|
||
|
||
try {
|
||
const url = `https://app.ticketmaster.com/discovery/v2/events.json?apikey=${TM_API_KEY}&city=${encodeURIComponent(city)}`;
|
||
const response = await fetch(url);
|
||
const data = await response.json();
|
||
|
||
res.json(data);
|
||
} catch (error) {
|
||
res.status(500).json({ message: "Fehler beim Laden der Events" });
|
||
}
|
||
});
|
||
|
||
// =========================
|
||
// BENUTZERVERWALTUNG
|
||
// In-Memory-Speicher (Daten gehen bei Serverneustart verloren – bewusste Vereinfachung)
|
||
// =========================
|
||
let users = [];
|
||
|
||
/**
|
||
* POST /api/user – Neuen Benutzer registrieren.
|
||
* Vergibt ein Standard-Passwort "1234" (vereinfacht, kein Hashing).
|
||
* Antwortet mit 201 Created bei Erfolg, 400 bei fehlendem/doppeltem Benutzernamen.
|
||
*/
|
||
app.post("/api/user", (req, res) => {
|
||
const { username } = req.body;
|
||
|
||
if (!username) {
|
||
return res.status(400).json({ message: "Benutzername erforderlich" });
|
||
}
|
||
|
||
if (users.find(u => u.username === username)) {
|
||
return res.status(400).json({ message: "Benutzer existiert bereits" });
|
||
}
|
||
|
||
const password = "1234";
|
||
users.push({ username, password });
|
||
|
||
res.status(201).json({ name: username, password });
|
||
});
|
||
|
||
/**
|
||
* GET /api/user – Benutzer anmelden.
|
||
* Credentials kommen als X-Username und X-Password Header.
|
||
* Antwortet mit 200 OK bei Erfolg, 401 Unauthorized bei falschen Credentials.
|
||
*
|
||
* Hinweis: GET für eine Login-Operation ist unkonventionell (normalerweise POST).
|
||
* Hier bewusst so gewählt, da kein Session/Token-Mechanismus implementiert ist.
|
||
*/
|
||
app.get("/api/user", (req, res) => {
|
||
const username = req.header("X-Username");
|
||
const password = req.header("X-Password");
|
||
|
||
const user = users.find(u => u.username === username && u.password === password);
|
||
|
||
if (!user) return res.sendStatus(401);
|
||
|
||
res.sendStatus(200);
|
||
});
|
||
|
||
// =========================
|
||
// EINLADUNGSSYSTEM
|
||
// Verwaltet Event-Einladungen zwischen Benutzern
|
||
// =========================
|
||
let invitations = [];
|
||
let idCounter = 1;
|
||
|
||
/**
|
||
* Normalisiert Benutzernamen für Vergleiche.
|
||
* Dadurch werden z.B. "Anna" und " anna " als derselbe Benutzer behandelt.
|
||
*/
|
||
function normalizeUsername(username) {
|
||
return String(username || "").trim().toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* Erstellt einen stabilen Event-Schlüssel für den Duplikat-Check.
|
||
* Ticketmaster liefert normalerweise eine id. Falls diese fehlt, verwenden wir URL oder Namen.
|
||
*/
|
||
function getEventKey({ eventId, eventUrl, eventName }) {
|
||
return String(eventId || eventUrl || eventName || "").trim().toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* POST /api/invitation – Einladung senden.
|
||
* Erwartet: Header X-Username (Sender), Body { toUser, eventId, eventName, eventUrl }
|
||
* Antwortet mit 201 Created und dem erstellten Einladungsobjekt.
|
||
*/
|
||
app.post("/api/invitation", (req, res) => {
|
||
if (!req.body) {
|
||
return res.status(400).json({ message: "Request-Body fehlt" });
|
||
}
|
||
|
||
const fromUserRaw = req.header("X-Username");
|
||
const { toUser: toUserRaw, eventId, eventName, eventUrl } = req.body;
|
||
|
||
const fromUser = String(fromUserRaw || "").trim();
|
||
const toUser = String(toUserRaw || "").trim();
|
||
const fromUserKey = normalizeUsername(fromUser);
|
||
const toUserKey = normalizeUsername(toUser);
|
||
const eventKey = getEventKey({ eventId, eventUrl, eventName });
|
||
|
||
if (!fromUser || !toUser) {
|
||
return res.status(400).json({ message: "Sender und Empfänger sind erforderlich" });
|
||
}
|
||
|
||
if (!eventKey) {
|
||
return res.status(400).json({ message: "Event fehlt. Einladung kann nicht gespeichert werden." });
|
||
}
|
||
|
||
// Prüfen ob der Empfänger registriert ist.
|
||
// Case-insensitive, damit "Anna" und "anna" nicht als verschiedene Nutzer behandelt werden.
|
||
const recipient = users.find(u => normalizeUsername(u.username) === toUserKey);
|
||
if (!recipient) {
|
||
return res.status(404).json({ message: `Nutzer "${toUser}" ist nicht registriert.` });
|
||
}
|
||
|
||
// Nicht sich selbst einladen
|
||
if (fromUserKey === toUserKey) {
|
||
return res.status(400).json({ message: "Du kannst dich nicht selbst einladen." });
|
||
}
|
||
|
||
// Doppelte Einladung verhindern:
|
||
// Derselbe Sender darf denselben Empfänger nicht mehrfach zum selben Event einladen.
|
||
// Wichtig: Der Vergleich läuft über normalisierte Keys, nicht über direkte Strings.
|
||
const duplicateInvitation = invitations.find(inv =>
|
||
inv.fromUserKey === fromUserKey &&
|
||
inv.toUserKey === toUserKey &&
|
||
inv.eventKey === eventKey
|
||
);
|
||
|
||
if (duplicateInvitation) {
|
||
return res.status(409).json({
|
||
message: `Du hast "${recipient.username}" bereits zu diesem Event eingeladen.`
|
||
});
|
||
}
|
||
|
||
const invitation = {
|
||
id: idCounter++,
|
||
fromUser,
|
||
fromUserKey,
|
||
toUser: recipient.username,
|
||
toUserKey,
|
||
eventId: eventId || null,
|
||
eventKey,
|
||
eventName,
|
||
eventUrl: eventUrl || null,
|
||
status: "pending"
|
||
};
|
||
|
||
invitations.push(invitation);
|
||
res.status(201).json(invitation);
|
||
});
|
||
|
||
/**
|
||
* GET /api/invitation – Alle Einladungen für einen Benutzer abrufen.
|
||
* Erwartet: Header X-Username (Empfänger)
|
||
* Antwortet mit 200 und Array der Einladungen.
|
||
*/
|
||
app.get("/api/invitation", (req, res) => {
|
||
const user = req.header("X-Username");
|
||
|
||
if (!user) {
|
||
return res.status(400).json({ message: "Benutzername fehlt" });
|
||
}
|
||
|
||
const userKey = normalizeUsername(user);
|
||
const userInvitations = invitations.filter(inv => inv.toUserKey === userKey);
|
||
res.json(userInvitations);
|
||
});
|
||
|
||
/**
|
||
* PUT /api/invitation/:id – Einladungsstatus aktualisieren (annehmen oder ablehnen).
|
||
* Erwartet: Body { status: "accepted" | "declined" }
|
||
* HTTP-Verb PUT gemäss REST: Update einer bestehenden Ressource.
|
||
* Antwortet mit 200 OK und dem aktualisierten Einladungsobjekt.
|
||
*/
|
||
app.put("/api/invitation/:id", (req, res) => {
|
||
const inv = invitations.find(i => i.id == req.params.id);
|
||
|
||
if (!inv) return res.status(404).json({ message: "Einladung nicht gefunden" });
|
||
|
||
const { status } = req.body;
|
||
if (!["accepted", "declined"].includes(status)) {
|
||
return res.status(400).json({ message: "Ungültiger Status. Erlaubt: accepted, declined" });
|
||
}
|
||
|
||
inv.status = status;
|
||
res.json(inv);
|
||
});
|
||
|
||
/**
|
||
* DELETE /api/invitation/:id – Einladung löschen.
|
||
* HTTP-Verb DELETE gemäss REST: Entfernt die Ressource dauerhaft.
|
||
* Antwortet mit 204 No Content bei Erfolg.
|
||
*/
|
||
app.delete("/api/invitation/:id", (req, res) => {
|
||
const index = invitations.findIndex(i => i.id == req.params.id);
|
||
|
||
if (index === -1) return res.status(404).json({ message: "Einladung nicht gefunden" });
|
||
|
||
invitations.splice(index, 1);
|
||
res.sendStatus(204); // 204 No Content – Ressource erfolgreich gelöscht
|
||
});
|
||
|
||
// =========================
|
||
// SERVER STARTEN
|
||
// =========================
|
||
app.listen(3000, () => {
|
||
console.log("Server läuft auf http://localhost:3000");
|
||
});
|