From c3ac407e2e5a3c8e4c9f93569b86e5bb417b1070 Mon Sep 17 00:00:00 2001 From: Serverlele30 <46974281+Serverlele30@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:53:08 +0200 Subject: [PATCH] Add files via upload --- README.md | 57 +++++++- api/contacts.js | 50 +++++++ api/login.js | 21 +++ api/poll.js | 45 ++++++ api/register.js | 21 +++ api/send.js | 25 ++++ api/setup.js | 37 +++++ lib/db.js | 86 ++++++++++++ package.json | 10 ++ public/index.html | 346 ++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 api/contacts.js create mode 100644 api/login.js create mode 100644 api/poll.js create mode 100644 api/register.js create mode 100644 api/send.js create mode 100644 api/setup.js create mode 100644 lib/db.js create mode 100644 package.json create mode 100644 public/index.html diff --git a/README.md b/README.md index 225f3c0..39a04b4 100644 --- a/README.md +++ b/README.md @@ -1 +1,56 @@ -# VitaChat \ No newline at end of file +# VitaChat API auf Vercel + +Serverless-Backend (Node.js) + Neon Postgres. Ersetzt das alte PHP/SQLite-Setup. +Das Protokoll ist unverändert — der Vita-Client braucht nur eine neue URL. + +## Einrichtung (~10 Minuten) + +### 1. Projekt anlegen +Diesen Ordner in ein GitHub-Repo pushen, dann auf vercel.com "Add New Project" +und das Repo importieren. Framework: "Other" (wird meist automatisch erkannt). + +Alternativ ohne GitHub: + npm i -g vercel + vercel # im Projektordner, folgt dem Assistenten + +### 2. Datenbank verbinden +Im Vercel-Dashboard: Projekt -> Storage -> Create Database -> Neon (Postgres). +Vercel legt die Variable DATABASE_URL automatisch an. Danach EINMAL neu +deployen, damit die Variable in den Funktionen ankommt (Deployments -> Redeploy). + +### 3. Tabellen anlegen +Einmal im Browser aufrufen: + https://.vercel.app/api/setup +Erwartet: OK|Datenbank bereit + +### 4. Testen + curl -d "user=test&pass=1234" https://.vercel.app/api/register + curl -d "user=test&pass=1234" https://.vercel.app/api/login + +Web-Client: https://.vercel.app/ + +### 5. Vita-Client anpassen +In client/src/net.h die Basis-URL ersetzen: + + #define API_BASE "https://.vercel.app/api" + +Dann neu bauen: + cd ~/vitachat/client/build && make + +## Struktur + api/setup.js Tabellen anlegen (einmalig) + api/register.js POST user, pass + api/login.js POST user, pass -> OK| + api/send.js POST token, to, text -> OK| + api/poll.js GET token, with, since -> MSG|id|from|ts|text + api/contacts.js GET token -> USER|name|online|unread|last_ts|preview + lib/db.js DB-Verbindung, Passwort-Hashing (scrypt), Protokoll-Helfer + public/index.html Web-Client (wird unter / ausgeliefert) + +## Hinweise +- Passwörter: scrypt aus Nodes crypto-Modul, keine Extra-Abhängigkeit. +- Fehler kommen als "ERR|" mit HTTP 200, damit der Vita-Client die + Zeile in jedem Fall lesen kann. +- Neon-Freetier schläft nach Inaktivität ein; der erste Aufruf danach + dauert ~1 s. Durch das Polling der App bleibt sie im Betrieb wach. +- Logs bei Problemen: Vercel-Dashboard -> Projekt -> Logs. diff --git a/api/contacts.js b/api/contacts.js new file mode 100644 index 0000000..0cfe659 --- /dev/null +++ b/api/contacts.js @@ -0,0 +1,50 @@ +import { sql, ok, fail, auth, clean, ONLINE_SECS, PREVIEW_LEN } from '../lib/db.js'; + +export default async function handler(req, res) { + try { + const me = await auth(req, res); + if (!me) return; + + const now = Math.floor(Date.now() / 1000); + + // Alles in EINER Abfrage – auf Serverless zählt jede Round-Trip-Zeit. + const rows = await sql` + SELECT u.name, u.last_seen, + COALESCE(lm.ts, 0) AS last_ts, + lm.text AS last_text, + lm.sender AS last_sender, + COALESCE(un.cnt, 0) AS unread + FROM users u + LEFT JOIN LATERAL ( + SELECT m.ts, m.text, m.sender FROM messages m + WHERE (m.sender = ${me.id} AND m.recipient = u.id) + OR (m.sender = u.id AND m.recipient = ${me.id}) + ORDER BY m.id DESC LIMIT 1 + ) lm ON TRUE + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS cnt FROM messages m + WHERE m.sender = u.id AND m.recipient = ${me.id} + AND m.id > COALESCE( + (SELECT last_id FROM reads WHERE user_id = ${me.id} AND peer_id = u.id), 0) + ) un ON TRUE + WHERE u.id <> ${me.id} + ORDER BY last_ts DESC, LOWER(u.name) ASC`; + + const body = rows.map(r => { + const online = (now - Number(r.last_seen) < ONLINE_SECS) ? 1 : 0; + + let preview = ''; + if (r.last_text) { + let txt = r.last_text.slice(0, PREVIEW_LEN); + if (r.last_text.length > PREVIEW_LEN) txt += '…'; + preview = (r.last_sender === me.id ? 'Du: ' : '') + txt; + } + + return `USER|${clean(r.name)}|${online}|${r.unread}|${r.last_ts}|${clean(preview)}`; + }).join('\n'); + + ok(res, body ? body + '\n' : ''); + } catch (e) { + fail(res, 'Serverfehler: ' + e.message); + } +} diff --git a/api/login.js b/api/login.js new file mode 100644 index 0000000..4e20025 --- /dev/null +++ b/api/login.js @@ -0,0 +1,21 @@ +import { sql, param, ok, fail, verifyPassword, newToken } from '../lib/db.js'; + +export default async function handler(req, res) { + const name = param(req, 'user'); + const pass = param(req, 'pass'); + + try { + const rows = await sql` + SELECT id, pass_hash FROM users WHERE LOWER(name) = LOWER(${name})`; + if (!rows.length || !verifyPassword(pass, rows[0].pass_hash)) + return fail(res, 'Login fehlgeschlagen'); + + const token = newToken(); + const now = Math.floor(Date.now() / 1000); + await sql`UPDATE users SET token = ${token}, last_seen = ${now} WHERE id = ${rows[0].id}`; + + ok(res, `OK|${token}\n`); + } catch (e) { + fail(res, 'Serverfehler: ' + e.message); + } +} diff --git a/api/poll.js b/api/poll.js new file mode 100644 index 0000000..495df37 --- /dev/null +++ b/api/poll.js @@ -0,0 +1,45 @@ +import { sql, param, ok, fail, auth, clean, MAX_POLL } from '../lib/db.js'; + +export default async function handler(req, res) { + try { + const me = await auth(req, res); + if (!me) return; + + const withName = param(req, 'with'); + const since = parseInt(param(req, 'since'), 10) || 0; + + const other = await sql`SELECT id FROM users WHERE LOWER(name) = LOWER(${withName})`; + if (!other.length) return fail(res, 'Nutzer unbekannt'); + const peer = other[0].id; + + const msgs = await sql` + SELECT m.id, u.name AS sender_name, m.ts, m.text + FROM messages m JOIN users u ON u.id = m.sender + WHERE m.id > ${since} + AND ((m.sender = ${me.id} AND m.recipient = ${peer}) + OR (m.sender = ${peer} AND m.recipient = ${me.id})) + ORDER BY m.id ASC + LIMIT ${MAX_POLL}`; + + const body = msgs + .map(m => `MSG|${m.id}|${clean(m.sender_name)}|${m.ts}|${clean(m.text)}`) + .join('\n'); + + // Chat als gelesen markieren + const maxRow = await sql` + SELECT COALESCE(MAX(id), 0) AS max_id FROM messages + WHERE (sender = ${me.id} AND recipient = ${peer}) + OR (sender = ${peer} AND recipient = ${me.id})`; + const maxId = maxRow[0].max_id; + + await sql` + INSERT INTO reads (user_id, peer_id, last_id) + VALUES (${me.id}, ${peer}, ${maxId}) + ON CONFLICT (user_id, peer_id) + DO UPDATE SET last_id = GREATEST(reads.last_id, ${maxId})`; + + ok(res, body ? body + '\n' : ''); + } catch (e) { + fail(res, 'Serverfehler: ' + e.message); + } +} diff --git a/api/register.js b/api/register.js new file mode 100644 index 0000000..e924df4 --- /dev/null +++ b/api/register.js @@ -0,0 +1,21 @@ +import { sql, param, ok, fail, hashPassword } from '../lib/db.js'; + +export default async function handler(req, res) { + const name = param(req, 'user'); + const pass = param(req, 'pass'); + + if (!/^[A-Za-z0-9_-]{2,20}$/.test(name)) + return fail(res, 'Name: 2-20 Zeichen, a-z 0-9 _ -'); + if (pass.length < 4) + return fail(res, 'Passwort zu kurz (min. 4)'); + + try { + const exists = await sql`SELECT id FROM users WHERE LOWER(name) = LOWER(${name})`; + if (exists.length) return fail(res, 'Name bereits vergeben'); + + await sql`INSERT INTO users (name, pass_hash) VALUES (${name}, ${hashPassword(pass)})`; + ok(res, 'OK|registriert\n'); + } catch (e) { + fail(res, 'Serverfehler: ' + e.message); + } +} diff --git a/api/send.js b/api/send.js new file mode 100644 index 0000000..336f53e --- /dev/null +++ b/api/send.js @@ -0,0 +1,25 @@ +import { sql, param, ok, fail, auth, MAX_TEXT_LEN } from '../lib/db.js'; + +export default async function handler(req, res) { + try { + const me = await auth(req, res); + if (!me) return; + + const to = param(req, 'to'); + const text = param(req, 'text').slice(0, MAX_TEXT_LEN); + if (!text) return fail(res, 'Leere Nachricht'); + + const rcpt = await sql`SELECT id FROM users WHERE LOWER(name) = LOWER(${to})`; + if (!rcpt.length) return fail(res, 'Empfaenger unbekannt'); + + const now = Math.floor(Date.now() / 1000); + const ins = await sql` + INSERT INTO messages (sender, recipient, ts, text) + VALUES (${me.id}, ${rcpt[0].id}, ${now}, ${text}) + RETURNING id`; + + ok(res, `OK|${ins[0].id}\n`); + } catch (e) { + fail(res, 'Serverfehler: ' + e.message); + } +} diff --git a/api/setup.js b/api/setup.js new file mode 100644 index 0000000..5a7b57b --- /dev/null +++ b/api/setup.js @@ -0,0 +1,37 @@ +// Einmalig aufrufen: https://.vercel.app/api/setup +// Legt die Tabellen an. Mehrfaches Aufrufen ist unschädlich. +import { sql, ok, fail } from '../lib/db.js'; + +export default async function handler(req, res) { + try { + await sql`CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + pass_hash TEXT NOT NULL, + token TEXT, + last_seen BIGINT DEFAULT 0 + )`; + // Namen ohne Rücksicht auf Groß-/Kleinschreibung eindeutig halten + await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_name ON users (LOWER(name))`; + + await sql`CREATE TABLE IF NOT EXISTS messages ( + id SERIAL PRIMARY KEY, + sender INTEGER NOT NULL, + recipient INTEGER NOT NULL, + ts BIGINT NOT NULL, + text TEXT NOT NULL + )`; + await sql`CREATE INDEX IF NOT EXISTS idx_msg ON messages (recipient, sender, id)`; + + await sql`CREATE TABLE IF NOT EXISTS reads ( + user_id INTEGER NOT NULL, + peer_id INTEGER NOT NULL, + last_id INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, peer_id) + )`; + + ok(res, 'OK|Datenbank bereit\n'); + } catch (e) { + fail(res, 'Setup fehlgeschlagen: ' + e.message); + } +} diff --git a/lib/db.js b/lib/db.js new file mode 100644 index 0000000..5ef3a3d --- /dev/null +++ b/lib/db.js @@ -0,0 +1,86 @@ +// Gemeinsame Helfer für alle vitaAPI-Endpunkte (Vercel Serverless + Neon Postgres) +import { neon } from '@neondatabase/serverless'; +import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; + +export const sql = neon(process.env.DATABASE_URL); + +export const ONLINE_SECS = 60; +export const MAX_TEXT_LEN = 500; +export const MAX_POLL = 50; +export const PREVIEW_LEN = 38; + +/* ---------------- Antwort-Protokoll (zeilenbasiert, Pipe-getrennt) ---------------- */ + +export function textHead(res) { + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); +} + +export function ok(res, body = '') { + textHead(res); + res.status(200).send(body); +} + +export function fail(res, msg) { + textHead(res); + res.status(200).send(`ERR|${msg}\n`); // 200, damit der Vita-Client die Zeile sicher liest +} + +/** Pipes und Zeilenumbrüche aus Nutzdaten entfernen, damit das Protokoll heil bleibt. */ +export function clean(s) { + return String(s).replace(/\|/g, '¦').replace(/[\r\n]+/g, ' '); +} + +/* ---------------- Parameter (GET-Query oder POST-Formulardaten) ---------------- */ + +export function param(req, key) { + let v = req.query?.[key]; + if (v === undefined && req.body) { + if (typeof req.body === 'string') { + // Fallback, falls Vercel den Body nicht geparst hat + v = new URLSearchParams(req.body).get(key); + } else { + v = req.body[key]; + } + } + return (v ?? '').toString().trim(); +} + +/* ---------------- Passwörter (scrypt aus Node, keine Extra-Abhängigkeit) ---------------- */ + +export function hashPassword(pass) { + const salt = randomBytes(16); + const hash = scryptSync(pass, salt, 64); + return `${salt.toString('hex')}:${hash.toString('hex')}`; +} + +export function verifyPassword(pass, stored) { + try { + const [saltHex, hashHex] = String(stored).split(':'); + if (!saltHex || !hashHex) return false; + const expected = Buffer.from(hashHex, 'hex'); + const actual = scryptSync(pass, Buffer.from(saltHex, 'hex'), expected.length); + return timingSafeEqual(expected, actual); + } catch { + return false; + } +} + +export function newToken() { + return randomBytes(16).toString('hex'); +} + +/* ---------------- Authentifizierung ---------------- */ + +/** Gibt den Nutzer zurück oder null (dann hat der Aufrufer bereits geantwortet). */ +export async function auth(req, res) { + const token = param(req, 'token'); + if (!token) { fail(res, 'Kein Token'); return null; } + + const rows = await sql`SELECT id, name FROM users WHERE token = ${token}`; + if (!rows.length) { fail(res, 'Ungueltiges Token'); return null; } + + const now = Math.floor(Date.now() / 1000); + await sql`UPDATE users SET last_seen = ${now} WHERE id = ${rows[0].id}`; + return rows[0]; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..aeba4bc --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "vitachat-api", + "version": "3.0.0", + "private": true, + "type": "module", + "description": "Messenger-Backend fuer die PS Vita (Vercel Serverless + Neon Postgres)", + "dependencies": { + "@neondatabase/serverless": "^0.10.4" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..8d5e20b --- /dev/null +++ b/public/index.html @@ -0,0 +1,346 @@ + + + + + +VitaChat Web + + + + +
+
+

VitaChat

+
Web-Testclient
+ + + + +
+
+
+ +
+ +
+
+ Kontakt wählen + + +
+
Wähle links einen Kontakt aus
+
+ + +
+
+
+ + + +