Soziales Netz im Spiel: Feed, Werbung, Markt, Kalender und Gewerbe
Jeder Spieler waehlt sich ein Handle mit @, Unternehmen und Behoerden bekommen
eigene Profile, fuer die sie Mitarbeiter freigeben.
Verwaltet wird ueber dieselbe Anmeldung wie das Webhosting: wer am IC-Computer
als Anbieter angemeldet ist, richtet Unternehmensprofile ein. Damit gibt es
genau eine Stelle, an der Verwaltungsrechte haengen - ein zweites Rechtesystem
daneben waere eine zweite Stelle, an der man jemanden zu entziehen vergisst.
Bewusst getrennt: wer fuer ein Unternehmen schreiben darf, kann sich nicht
selbst zum Verwalter machen.
Behoben gegenueber dem Ausgangsstand:
- Die Resource startete nicht. Eine harte Abhaengigkeit zeigte auf eine
Resource, die es nicht gibt - das verhindert den Start vollstaendig.
- Das Schema war gegenueber dem Code stehengeblieben: eine Tabelle fehlte
ganz, sechs Spalten fehlten. Jede Registrierung eines Handles scheiterte
deshalb mit einem SQL-Fehler. Zwei Migrationen ziehen das nach.
- Die Identitaet kommt jetzt von ESX statt von einem Charaktersystem, das
hier nicht laeuft. Ohne das findet das Mailsystem die Postfaecher nicht.
- Das Fenster hatte keinen Schliessknopf, nur ESC. Im Computerfenster ist
diese Taste aber schon vergeben.
- Bilder laden mit referrerpolicy="no-referrer": Bilderdienste sperren
Hotlinks anhand der Herkunft, und die eines NUI kennen sie nicht.
- Der Schluessel fuer den Bilder-Upload steht nicht mehr im Code, sondern in
der server.cfg (set bleeter_imgbb_key). Er gehoert nicht in ein oeffentlich
einsehbares Repository.
Neu: Unternehmensprofile und Mitarbeiterfreigabe, ueber Netzwerkereignisse und
Konsolenbefehle. Dazu eine Oberflaeche im IC-Computer, die dieselbe Gestaltung
benutzt - die Stildatei wird dafuer mechanisch gekapselt, statt sie nachzubauen.
Enthaelt README.md mit Einrichtung, Rechten und den Fallstricken.
This commit is contained in:
commit
20076b0aee
41 changed files with 8652 additions and 0 deletions
16
web-backend/.env.example
Normal file
16
web-backend/.env.example
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
PORT=
|
||||
DB_HOST=
|
||||
DB_PORT=
|
||||
DB_USER=
|
||||
DB_PASS=
|
||||
DB_NAME=
|
||||
JWT_SECRET=
|
||||
INTERNAL_API_KEY=
|
||||
IMGBB_KEY=
|
||||
|
||||
# Vorlage. Kopieren nach .env und ausfuellen:
|
||||
# PORT Port des Backends
|
||||
# DB_* Zugang zur Spieldatenbank
|
||||
# JWT_SECRET beliebige lange Zufallszeichenkette
|
||||
# INTERNAL_API_KEY gemeinsames Geheimnis zwischen Backend und Resource
|
||||
# IMGBB_KEY API-Schluessel von imgbb.com fuer Bilduploads
|
||||
38
web-backend/auth.js
Normal file
38
web-backend/auth.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
const jwt = require('jsonwebtoken');
|
||||
const db = require('./db');
|
||||
require('dotenv').config();
|
||||
|
||||
const SECRET = process.env.JWT_SECRET;
|
||||
|
||||
function sign(account) {
|
||||
return jwt.sign(
|
||||
{ aid: account.id, cid: account.char_id },
|
||||
SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
}
|
||||
|
||||
// Middleware: prueft JWT, laedt Account frisch aus DB -> req.account
|
||||
async function requireAuth(req, res, next) {
|
||||
try {
|
||||
const header = req.headers.authorization || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
if (!token) return res.status(401).json({ error: 'no_token' });
|
||||
|
||||
const payload = jwt.verify(token, SECRET);
|
||||
const account = await db.q1(
|
||||
'SELECT * FROM bleeter_accounts WHERE id = ? AND char_id = ?',
|
||||
[payload.aid, payload.cid]
|
||||
);
|
||||
if (!account) return res.status(401).json({ error: 'account_gone' });
|
||||
if (account.status && account.status !== 'active') {
|
||||
return res.status(403).json({ error: 'account_disabled' });
|
||||
}
|
||||
req.account = account;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'invalid_token' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sign, requireAuth };
|
||||
40
web-backend/db.js
Normal file
40
web-backend/db.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
charset: 'utf8mb4_general_ci',
|
||||
dateStrings: true,
|
||||
});
|
||||
|
||||
// Mehrere Zeilen
|
||||
async function q(sql, params = []) {
|
||||
const [rows] = await pool.query(sql, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Genau eine Zeile (oder null)
|
||||
async function q1(sql, params = []) {
|
||||
const rows = await q(sql, params);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// INSERT -> insertId
|
||||
async function insert(sql, params = []) {
|
||||
const [res] = await pool.query(sql, params);
|
||||
return res.insertId;
|
||||
}
|
||||
|
||||
// UPDATE/DELETE -> affectedRows
|
||||
async function exec(sql, params = []) {
|
||||
const [res] = await pool.query(sql, params);
|
||||
return res.affectedRows;
|
||||
}
|
||||
|
||||
module.exports = { pool, q, q1, insert, exec };
|
||||
98
web-backend/lib/account.js
Normal file
98
web-backend/lib/account.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
const db = require('../db');
|
||||
const { decodeBool, canProfileBeBlocked } = require('./permissions');
|
||||
|
||||
// Portiert aus main.lua mapProfile()
|
||||
function mapProfile(row) {
|
||||
if (!row) return null;
|
||||
const profile = {
|
||||
id: row.id,
|
||||
account_id: row.account_id,
|
||||
profile_type: row.profile_type,
|
||||
handle: row.handle,
|
||||
display_name: row.display_name,
|
||||
avatar_url: row.avatar_url || '',
|
||||
banner_url: row.banner_url || '',
|
||||
bio: row.bio || '',
|
||||
location: row.location || '',
|
||||
email_contact: row.email_contact || '',
|
||||
phone_contact: row.phone_contact || '',
|
||||
is_verified: decodeBool(row.is_verified),
|
||||
is_lifeinvader_staff: decodeBool(row.is_lifeinvader_staff),
|
||||
is_active: row.is_active === undefined || row.is_active === null || decodeBool(row.is_active),
|
||||
is_locked: decodeBool(row.is_locked),
|
||||
can_post: decodeBool(row.can_post),
|
||||
can_edit_profile: decodeBool(row.can_edit_profile),
|
||||
can_be_blocked: canProfileBeBlocked(row.profile_type, row.is_lifeinvader_staff),
|
||||
};
|
||||
if (row.followers_count !== undefined) profile.followers_count = Number(row.followers_count) || 0;
|
||||
if (row.following_count !== undefined) profile.following_count = Number(row.following_count) || 0;
|
||||
return profile;
|
||||
}
|
||||
|
||||
// Alle Profile, auf die der Account zugreifen kann (eigene + Mitgliedschaften)
|
||||
async function getAccessibleProfiles(account) {
|
||||
const profiles = [];
|
||||
const seen = new Set();
|
||||
|
||||
const ownRows = await db.q(
|
||||
`SELECT p.*, 1 AS can_post, 1 AS can_edit_profile
|
||||
FROM bleeter_profiles p
|
||||
WHERE p.account_id = ? AND p.is_active = 1 AND p.is_locked = 0
|
||||
ORDER BY FIELD(p.profile_type, 'private','small_business','company','authority','lifeinvader'), p.display_name`,
|
||||
[account.id]
|
||||
);
|
||||
for (const row of ownRows) {
|
||||
const profile = mapProfile(row);
|
||||
profiles.push(profile);
|
||||
seen.add(profile.id);
|
||||
}
|
||||
|
||||
const memberRows = await db.q(
|
||||
`SELECT p.*, m.can_post, m.can_edit_profile
|
||||
FROM bleeter_profile_members m
|
||||
JOIN bleeter_profiles p ON p.id = m.profile_id
|
||||
WHERE m.char_id = ? AND p.is_active = 1 AND p.is_locked = 0
|
||||
ORDER BY p.display_name`,
|
||||
[account.char_id]
|
||||
);
|
||||
for (const row of memberRows) {
|
||||
if (!seen.has(row.id)) {
|
||||
const profile = mapProfile(row);
|
||||
profiles.push(profile);
|
||||
seen.add(profile.id);
|
||||
}
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
// Das aktuell handelnde Profil (per profileId vom Client, sonst erstes).
|
||||
// Gibt { profile, profiles } zurueck. profile=null wenn kein Profil vorhanden.
|
||||
async function resolveActingProfile(account, profileId) {
|
||||
const profiles = await getAccessibleProfiles(account);
|
||||
let profile = null;
|
||||
if (profileId) {
|
||||
profile = profiles.find(p => Number(p.id) === Number(profileId)) || null;
|
||||
}
|
||||
if (!profile) profile = profiles[0] || null;
|
||||
return { profile, profiles };
|
||||
}
|
||||
|
||||
// Zielprofil fuer Follow/Block/Moderation laden.
|
||||
// includeLocked=true fuer Moderationsaktionen.
|
||||
async function findTargetProfile(payload, includeLocked = false) {
|
||||
const id = Number(payload && (payload.targetId || payload.profileId));
|
||||
const handle = payload && payload.handle ? String(payload.handle).toLowerCase() : null;
|
||||
let row = null;
|
||||
if (id) {
|
||||
row = await db.q1('SELECT * FROM bleeter_profiles WHERE id = ?', [id]);
|
||||
} else if (handle) {
|
||||
row = await db.q1('SELECT * FROM bleeter_profiles WHERE handle = ?', [handle]);
|
||||
}
|
||||
if (!row) return null;
|
||||
if (!decodeBool(row.is_active)) return null;
|
||||
if (!includeLocked && decodeBool(row.is_locked)) return null;
|
||||
return mapProfile(row);
|
||||
}
|
||||
|
||||
module.exports = { mapProfile, getAccessibleProfiles, resolveActingProfile, findTargetProfile };
|
||||
39
web-backend/lib/media.js
Normal file
39
web-backend/lib/media.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
require('dotenv').config();
|
||||
|
||||
const ALLOWED_EXT = ['jpg', 'jpeg', 'png'];
|
||||
|
||||
function extFromUrl(url) {
|
||||
const m = /\.([a-z0-9]+)(\?|$)/i.exec(String(url || ''));
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
// Portiert aus adapters/media.lua IsAllowedExternalUrl
|
||||
function isAllowedExternalUrl(url) {
|
||||
url = String(url || '');
|
||||
if (!/^https:\/\//i.test(url)) return { ok: false, reason: 'url_must_be_https' };
|
||||
if (/^https:\/\/i\.ibb\.co\//i.test(url) || /^https:\/\/ibb\.co\//i.test(url)) return { ok: true };
|
||||
const ext = extFromUrl(url);
|
||||
if (ext && ALLOWED_EXT.includes(ext)) return { ok: true };
|
||||
return { ok: false, reason: 'unsupported_image_url' };
|
||||
}
|
||||
|
||||
// Upload eines Buffers zu imgbb -> { url }
|
||||
async function uploadToImgbb(buffer, filename) {
|
||||
const key = process.env.IMGBB_KEY;
|
||||
if (!key) throw new Error('imgbb_key_missing');
|
||||
const form = new FormData();
|
||||
form.append('image', buffer.toString('base64'));
|
||||
if (filename) form.append('name', filename.replace(/\.[^.]+$/, ''));
|
||||
|
||||
const resp = await fetch(`https://api.imgbb.com/1/upload?key=${encodeURIComponent(key)}`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data || !data.success || !data.data || !data.data.url) {
|
||||
throw new Error('imgbb_upload_failed');
|
||||
}
|
||||
return { url: data.data.url };
|
||||
}
|
||||
|
||||
module.exports = { isAllowedExternalUrl, uploadToImgbb, ALLOWED_EXT };
|
||||
39
web-backend/lib/permissions.js
Normal file
39
web-backend/lib/permissions.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Portiert aus bleeter/server/permissions.lua + main.lua
|
||||
|
||||
// Wer darf in welchem Feed posten (feed_type: 'home' | 'advertising')
|
||||
const feedPostRights = {
|
||||
private: { home: true },
|
||||
small_business: { advertising: true },
|
||||
company: { advertising: true },
|
||||
authority: { home: true, advertising: true },
|
||||
lifeinvader: {},
|
||||
};
|
||||
|
||||
function canPost(profileType, feedType) {
|
||||
const rights = feedPostRights[profileType || ''] || {};
|
||||
return rights[feedType || ''] === true;
|
||||
}
|
||||
|
||||
function decodeBool(v) {
|
||||
return v === true || v === 1 || v === '1';
|
||||
}
|
||||
|
||||
// Behoerden / Lifeinvader / Staff koennen nicht blockiert werden
|
||||
function canProfileBeBlocked(profileType, isLifeinvaderStaff) {
|
||||
return profileType !== 'authority'
|
||||
&& profileType !== 'lifeinvader'
|
||||
&& !decodeBool(isLifeinvaderStaff);
|
||||
}
|
||||
|
||||
// Lifeinvader-Moderationsrechte eines Charakters (char_id) aus DB
|
||||
async function hasLifeinvaderPermission(db, charId, permission) {
|
||||
if (!charId) return false;
|
||||
const row = await db.q1(
|
||||
`SELECT allowed FROM bleeter_lifeinvader_permissions
|
||||
WHERE char_id = ? AND permission = ? LIMIT 1`,
|
||||
[charId, permission]
|
||||
);
|
||||
return !!(row && decodeBool(row.allowed));
|
||||
}
|
||||
|
||||
module.exports = { feedPostRights, canPost, decodeBool, canProfileBeBlocked, hasLifeinvaderPermission };
|
||||
281
web-backend/lib/queries.js
Normal file
281
web-backend/lib/queries.js
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
const db = require('../db');
|
||||
const { decodeBool, canProfileBeBlocked } = require('./permissions');
|
||||
|
||||
// ── Kommentare eines Posts ────────────────────────────────────────────────
|
||||
async function loadCommentsForPost(postId, viewerProfileId) {
|
||||
const v = viewerProfileId || 0;
|
||||
const rows = await db.q(
|
||||
`SELECT c.id, c.body, c.created_at, c.author_profile_id,
|
||||
p.handle AS author_handle,
|
||||
(SELECT COUNT(*) FROM bleeter_likes l WHERE l.target_type='comment' AND l.target_id=c.id) AS likes,
|
||||
EXISTS(SELECT 1 FROM bleeter_likes l WHERE l.target_type='comment' AND l.target_id=c.id AND l.profile_id=?) AS liked_by_viewer
|
||||
FROM bleeter_comments c
|
||||
JOIN bleeter_profiles p ON p.id = c.author_profile_id
|
||||
WHERE c.post_id = ? AND c.self_deleted_at IS NULL AND c.hidden_at IS NULL AND c.deleted_at IS NULL
|
||||
AND p.is_active = 1 AND p.is_locked = 0
|
||||
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
|
||||
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
|
||||
ORDER BY c.created_at ASC`,
|
||||
[v, postId, v, v]
|
||||
);
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
author_profile_id: row.author_profile_id,
|
||||
author: row.author_handle,
|
||||
body: row.body,
|
||||
created_at: String(row.created_at || ''),
|
||||
likes: Number(row.likes) || 0,
|
||||
liked_by_viewer: decodeBool(row.liked_by_viewer),
|
||||
can_delete: Number(row.author_profile_id) === Number(viewerProfileId),
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Feed-Posts ────────────────────────────────────────────────────────────
|
||||
async function loadPosts(viewerProfileId, feedType) {
|
||||
const v = viewerProfileId || 0;
|
||||
const params = [v, v, v];
|
||||
let feedClause = '';
|
||||
if (feedType === 'home' || feedType === 'advertising') {
|
||||
feedClause = ' AND posts.feed_type = ?';
|
||||
params.push(feedType);
|
||||
}
|
||||
const rows = await db.q(
|
||||
`SELECT posts.id, posts.feed_type, posts.body, posts.created_at, posts.author_profile_id,
|
||||
media.url AS media_url,
|
||||
author.id AS author_id, author.handle AS author_handle, author.display_name AS author_name,
|
||||
author.avatar_url AS author_avatar, author.is_verified AS author_verified,
|
||||
author.is_lifeinvader_staff AS author_lifeinvader_staff,
|
||||
(SELECT COUNT(*) FROM bleeter_likes l WHERE l.target_type='post' AND l.target_id=posts.id) AS likes,
|
||||
EXISTS(SELECT 1 FROM bleeter_likes l WHERE l.target_type='post' AND l.target_id=posts.id AND l.profile_id=?) AS liked_by_viewer
|
||||
FROM bleeter_posts posts
|
||||
JOIN bleeter_profiles author ON author.id = posts.author_profile_id
|
||||
LEFT JOIN bleeter_media media ON media.id = posts.media_id
|
||||
WHERE posts.self_deleted_at IS NULL AND posts.hidden_at IS NULL AND posts.deleted_at IS NULL
|
||||
AND author.is_active = 1 AND author.is_locked = 0
|
||||
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=author.id)
|
||||
OR (b.blocker_profile_id=author.id AND b.blocked_profile_id=?))
|
||||
${feedClause}
|
||||
ORDER BY posts.created_at DESC
|
||||
LIMIT 80`,
|
||||
params
|
||||
);
|
||||
|
||||
const posts = [];
|
||||
for (const row of rows) {
|
||||
const comments = await loadCommentsForPost(row.id, viewerProfileId);
|
||||
posts.push({
|
||||
id: row.id,
|
||||
feed_type: row.feed_type,
|
||||
body: row.body || '',
|
||||
media_url: row.media_url,
|
||||
created_at: String(row.created_at || ''),
|
||||
likes: Number(row.likes) || 0,
|
||||
liked_by_viewer: decodeBool(row.liked_by_viewer),
|
||||
can_delete: Number(row.author_profile_id) === Number(viewerProfileId),
|
||||
comments: comments.length,
|
||||
comments_list: comments,
|
||||
author: {
|
||||
id: row.author_id,
|
||||
handle: row.author_handle,
|
||||
display_name: row.author_name,
|
||||
avatar_url: row.author_avatar || '',
|
||||
is_verified: decodeBool(row.author_verified),
|
||||
is_lifeinvader_staff: decodeBool(row.author_lifeinvader_staff),
|
||||
},
|
||||
});
|
||||
}
|
||||
return posts;
|
||||
}
|
||||
|
||||
// ── Profil-Verzeichnis (mit Follower-Zahlen) ──────────────────────────────
|
||||
async function loadProfileDirectory(viewerProfileId, includeLocked) {
|
||||
const v = viewerProfileId || 0;
|
||||
const rows = await db.q(
|
||||
`SELECT p.*, 0 AS can_post, 0 AS can_edit_profile,
|
||||
(SELECT COUNT(*) FROM bleeter_follows f WHERE f.followed_profile_id=p.id) AS followers_count,
|
||||
(SELECT COUNT(*) FROM bleeter_follows f WHERE f.follower_profile_id=p.id) AS following_count
|
||||
FROM bleeter_profiles p
|
||||
WHERE p.is_active = 1 AND (? = 1 OR p.is_locked = 0)
|
||||
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
|
||||
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
|
||||
ORDER BY p.display_name
|
||||
LIMIT 180`,
|
||||
[includeLocked ? 1 : 0, v, v]
|
||||
);
|
||||
const { mapProfile } = require('./account');
|
||||
return rows.map(mapProfile);
|
||||
}
|
||||
|
||||
function mapSocialProfile(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
handle: row.handle,
|
||||
display_name: row.display_name,
|
||||
avatar_url: row.avatar_url || '',
|
||||
profile_type: row.profile_type,
|
||||
is_verified: decodeBool(row.is_verified),
|
||||
is_lifeinvader_staff: decodeBool(row.is_lifeinvader_staff),
|
||||
can_be_blocked: canProfileBeBlocked(row.profile_type, row.is_lifeinvader_staff),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Following / Followers / Blocked ───────────────────────────────────────
|
||||
async function loadSocial(activeProfileId) {
|
||||
const a = activeProfileId || 0;
|
||||
const following = await db.q(
|
||||
`SELECT p.id,p.handle,p.display_name,p.avatar_url,p.profile_type,p.is_verified,p.is_lifeinvader_staff
|
||||
FROM bleeter_follows f JOIN bleeter_profiles p ON p.id=f.followed_profile_id
|
||||
WHERE f.follower_profile_id=? AND p.is_active=1 AND p.is_locked=0
|
||||
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
|
||||
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
|
||||
ORDER BY p.display_name`,
|
||||
[a, a, a]
|
||||
);
|
||||
const followers = await db.q(
|
||||
`SELECT p.id,p.handle,p.display_name,p.avatar_url,p.profile_type,p.is_verified,p.is_lifeinvader_staff
|
||||
FROM bleeter_follows f JOIN bleeter_profiles p ON p.id=f.follower_profile_id
|
||||
WHERE f.followed_profile_id=? AND p.is_active=1 AND p.is_locked=0
|
||||
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
|
||||
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
|
||||
ORDER BY p.display_name`,
|
||||
[a, a, a]
|
||||
);
|
||||
const blocked = await db.q(
|
||||
`SELECT p.id,p.handle,p.display_name,p.avatar_url,p.profile_type,p.is_verified,p.is_lifeinvader_staff
|
||||
FROM bleeter_blocks b JOIN bleeter_profiles p ON p.id=b.blocked_profile_id
|
||||
WHERE b.blocker_profile_id=? ORDER BY p.display_name`,
|
||||
[a]
|
||||
);
|
||||
return {
|
||||
following: following.map(mapSocialProfile),
|
||||
followers: followers.map(mapSocialProfile),
|
||||
blocked: blocked.map(mapSocialProfile),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Kalender (28 Tage) ────────────────────────────────────────────────────
|
||||
const germanWeekdays = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
|
||||
|
||||
function dateKeyFromOffset(offset) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + offset);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
function dateTitleFromKey(dateKey) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateKey);
|
||||
if (!m) return dateKey;
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12);
|
||||
return `${germanWeekdays[d.getDay()]} ${m[3]}.${m[2]}.${m[1]}`;
|
||||
}
|
||||
|
||||
async function loadCalendarDays(activeProfileId) {
|
||||
const rows = await db.q(
|
||||
`SELECT e.id, DATE_FORMAT(e.starts_at,'%Y-%m-%d') AS date_key,
|
||||
DATE_FORMAT(e.starts_at,'%H:%i') AS time_text,
|
||||
e.title, e.location, e.author_profile_id, p.handle AS author_handle
|
||||
FROM bleeter_events e JOIN bleeter_profiles p ON p.id=e.author_profile_id
|
||||
WHERE e.deleted_at IS NULL AND e.status='active'
|
||||
AND e.starts_at >= CURDATE() AND e.starts_at < DATE_ADD(CURDATE(), INTERVAL 28 DAY)
|
||||
AND p.is_active=1 AND p.is_locked=0
|
||||
ORDER BY e.starts_at ASC, e.id ASC`
|
||||
);
|
||||
const byDate = {};
|
||||
for (const row of rows) {
|
||||
(byDate[row.date_key] = byDate[row.date_key] || []).push({
|
||||
id: row.id,
|
||||
time: row.time_text,
|
||||
title: row.title,
|
||||
location: row.location || '',
|
||||
author: row.author_handle,
|
||||
can_delete: Number(row.author_profile_id) === Number(activeProfileId),
|
||||
});
|
||||
}
|
||||
const days = [];
|
||||
for (let offset = 0; offset < 28; offset++) {
|
||||
const dateKey = dateKeyFromOffset(offset);
|
||||
days.push({ offset, date: dateKey, title: dateTitleFromKey(dateKey), events: byDate[dateKey] || [] });
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
// ── Gewerbe (Open/Closed) ─────────────────────────────────────────────────
|
||||
async function loadBusinesses(viewerProfileId) {
|
||||
const v = viewerProfileId || 0;
|
||||
const rows = await db.q(
|
||||
`SELECT p.handle,p.display_name,p.avatar_url,p.banner_url,p.is_verified,p.is_lifeinvader_staff,
|
||||
COALESCE(s.status,'closed') AS status
|
||||
FROM bleeter_profiles p
|
||||
LEFT JOIN bleeter_business_status s ON s.profile_id=p.id
|
||||
WHERE p.profile_type IN ('small_business','company','authority')
|
||||
AND p.is_active=1 AND p.is_locked=0
|
||||
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
|
||||
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
|
||||
ORDER BY FIELD(COALESCE(s.status,'closed'),'open','closed'), p.display_name
|
||||
LIMIT 80`,
|
||||
[v, v]
|
||||
);
|
||||
return rows.map(row => ({
|
||||
handle: row.handle,
|
||||
display_name: row.display_name,
|
||||
avatar_url: row.avatar_url || '',
|
||||
banner_url: row.banner_url || '',
|
||||
status: row.status || 'closed',
|
||||
is_verified: decodeBool(row.is_verified),
|
||||
is_lifeinvader_staff: decodeBool(row.is_lifeinvader_staff),
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Marktplatz ────────────────────────────────────────────────────────────
|
||||
async function loadMarketplace(activeProfileId) {
|
||||
const a = activeProfileId || 0;
|
||||
const rows = await db.q(
|
||||
`SELECT m.id,m.title,m.description,m.price_label,m.created_at,m.author_profile_id,
|
||||
media.url AS media_url,
|
||||
p.handle AS author_handle,p.display_name AS author_name,p.avatar_url AS author_avatar,
|
||||
p.email_contact AS author_email,p.is_verified AS author_verified,
|
||||
p.is_lifeinvader_staff AS author_lifeinvader_staff
|
||||
FROM bleeter_marketplace m
|
||||
JOIN bleeter_profiles p ON p.id=m.author_profile_id
|
||||
LEFT JOIN bleeter_media media ON media.id=m.media_id
|
||||
WHERE m.deleted_at IS NULL AND m.status='active' AND p.is_active=1 AND p.is_locked=0
|
||||
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
|
||||
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
|
||||
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT 100`,
|
||||
[a, a]
|
||||
);
|
||||
return rows.map(row => {
|
||||
const priceNumber = Number((String(row.price_label || '').match(/\d+/) || [0])[0]) || 0;
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
body: row.description,
|
||||
price: priceNumber,
|
||||
price_label: row.price_label || '',
|
||||
created_at: String(row.created_at || ''),
|
||||
media_url: row.media_url,
|
||||
can_delete: Number(row.author_profile_id) === Number(activeProfileId),
|
||||
author: {
|
||||
id: row.author_profile_id,
|
||||
handle: row.author_handle,
|
||||
display_name: row.author_name,
|
||||
avatar_url: row.author_avatar || '',
|
||||
email: row.author_email || '',
|
||||
is_verified: decodeBool(row.author_verified),
|
||||
is_lifeinvader_staff: decodeBool(row.author_lifeinvader_staff),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadCommentsForPost, loadPosts, loadProfileDirectory,
|
||||
loadSocial, loadCalendarDays, loadBusinesses, loadMarketplace,
|
||||
};
|
||||
18
web-backend/package.json
Normal file
18
web-backend/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "zc-bleeter-web",
|
||||
"version": "1.0.0",
|
||||
"description": "Bleeter Web-Anwendung (Lifeinvader) – Web-Backend fuer bleeter.naturalbornplayers.de",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.11.0"
|
||||
}
|
||||
}
|
||||
565
web-backend/public/app.js
Normal file
565
web-backend/public/app.js
Normal file
|
|
@ -0,0 +1,565 @@
|
|||
'use strict';
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
const S = {
|
||||
token: localStorage.getItem('bleeter_token') || null,
|
||||
account: null,
|
||||
profiles: [],
|
||||
activeId: Number(localStorage.getItem('bleeter_profile')) || null,
|
||||
canModerate: false,
|
||||
view: 'home',
|
||||
openComments: new Set(),
|
||||
};
|
||||
|
||||
const PROFILE_TYPE_LABEL = {
|
||||
private: 'Privat', small_business: 'Kleingewerbe', company: 'Unternehmen',
|
||||
authority: 'Behörde', lifeinvader: 'Lifeinvader',
|
||||
};
|
||||
const BUSINESS_TYPES = ['small_business', 'company', 'authority'];
|
||||
|
||||
function activeProfile() { return S.profiles.find(p => p.id === S.activeId) || S.profiles[0] || null; }
|
||||
function canPostHere(profile, feedType) {
|
||||
if (!profile) return false;
|
||||
const rights = { private: { home: 1 }, small_business: { advertising: 1 }, company: { advertising: 1 }, authority: { home: 1, advertising: 1 }, lifeinvader: {} };
|
||||
return !!(rights[profile.profile_type] || {})[feedType];
|
||||
}
|
||||
|
||||
// ── API ───────────────────────────────────────────────────────────────────
|
||||
async function api(method, path, body, isForm) {
|
||||
const headers = {};
|
||||
if (S.token) headers['Authorization'] = 'Bearer ' + S.token;
|
||||
let payload;
|
||||
if (isForm) { payload = body; }
|
||||
else if (body !== undefined) { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(body); }
|
||||
const res = await fetch(path, { method, headers, body: payload });
|
||||
if (res.status === 401) { logout(); throw new Error('unauthorized'); }
|
||||
let data = null; try { data = await res.json(); } catch (e) {}
|
||||
if (!res.ok) { const e = new Error((data && data.error) || 'error'); e.data = data; throw e; }
|
||||
return data;
|
||||
}
|
||||
function withProfile(params) { const p = activeProfile(); return Object.assign({ profileId: p ? p.id : '' }, params || {}); }
|
||||
function qs(obj) { return '?' + new URLSearchParams(obj).toString(); }
|
||||
function apiGet(path, params) { return api('GET', path + qs(withProfile(params))); }
|
||||
function apiSend(method, path, body) { return api(method, path, withProfile(body)); }
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
||||
function initials(name) { return String(name || '?').trim().split(/\s+/).slice(0, 2).map(w => w[0] || '').join('').toUpperCase() || '?'; }
|
||||
function avatar(url, name, size) {
|
||||
size = size || 'md';
|
||||
if (url) return `<img class="avatar ${size}" src="${esc(url)}" alt="" onerror="this.replaceWith(h(\`<div class='avatar ${size}'>${esc(initials(name))}</div>\`))">`;
|
||||
return `<div class="avatar ${size}">${esc(initials(name))}</div>`;
|
||||
}
|
||||
function badge(p) {
|
||||
let out = '';
|
||||
if (p.is_verified) out += ' <span class="verified" title="Verifiziert">✔</span>';
|
||||
if (p.is_lifeinvader_staff) out += ' <span class="pill staff">Staff</span>';
|
||||
return out;
|
||||
}
|
||||
function timeAgo(str) {
|
||||
if (!str) return '';
|
||||
const t = new Date(str.replace(' ', 'T')).getTime();
|
||||
if (isNaN(t)) return str;
|
||||
const s = Math.floor((Date.now() - t) / 1000);
|
||||
if (s < 60) return 'gerade eben';
|
||||
if (s < 3600) return Math.floor(s / 60) + ' Min.';
|
||||
if (s < 86400) return Math.floor(s / 3600) + ' Std.';
|
||||
if (s < 604800) return Math.floor(s / 86400) + ' T.';
|
||||
return new Date(t).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||||
}
|
||||
function toast(msg, type) {
|
||||
const t = h(`<div class="toast ${type || ''}">${esc(msg)}</div>`);
|
||||
document.getElementById('toast-layer').appendChild(t);
|
||||
setTimeout(() => { t.style.opacity = '0'; t.style.transition = '0.3s'; setTimeout(() => t.remove(), 300); }, 3200);
|
||||
}
|
||||
const ERR_DE = {
|
||||
invalid_login: 'Handle/Mail oder Passwort falsch.', empty_post: 'Dein Post ist leer.',
|
||||
not_allowed_here: 'Dieses Profil darf hier nicht posten.', media_rejected: 'Bild-URL abgelehnt.',
|
||||
handle_taken: 'Dieses Handle ist bereits vergeben.', reserved: 'Dieses Handle ist reserviert.',
|
||||
already_registered: 'Es existiert bereits ein Privatprofil.', name_required: 'Name darf nicht leer sein.',
|
||||
cannot_block: 'Dieses Profil kann nicht blockiert werden.', no_lifeinvader_rights: 'Keine Lifeinvader-Rechte.',
|
||||
invalid_date: 'Ungültiges Datum.', invalid_time: 'Ungültige Uhrzeit.', title_required: 'Titel fehlt.',
|
||||
not_owner: 'Nur eigene Einträge können bearbeitet werden.', upload_failed: 'Upload fehlgeschlagen.',
|
||||
};
|
||||
function errText(e) { return ERR_DE[e && e.message] || 'Es ist ein Fehler aufgetreten.'; }
|
||||
|
||||
function modal(title, bodyEl, onSubmit, submitLabel) {
|
||||
const layer = document.getElementById('modal-layer');
|
||||
const m = h(`<div class="modal"><h3>${esc(title)}</h3></div>`);
|
||||
m.appendChild(bodyEl);
|
||||
const actions = h(`<div class="modal-actions"><button class="btn btn-ghost" data-x="cancel">Abbrechen</button><button class="btn btn-primary" data-x="ok">${esc(submitLabel || 'Speichern')}</button></div>`);
|
||||
m.appendChild(actions);
|
||||
layer.innerHTML = ''; layer.appendChild(m); layer.classList.add('show');
|
||||
const close = () => { layer.classList.remove('show'); layer.innerHTML = ''; };
|
||||
actions.querySelector('[data-x=cancel]').onclick = close;
|
||||
actions.querySelector('[data-x=ok]').onclick = async () => { try { const ok = await onSubmit(); if (ok !== false) close(); } catch (e) { toast(errText(e), 'error'); } };
|
||||
layer.onclick = (e) => { if (e.target === layer) close(); };
|
||||
return close;
|
||||
}
|
||||
|
||||
// ── Auth ────────────────────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
if (!S.token) return renderLogin();
|
||||
try {
|
||||
const me = await api('GET', '/api/auth/me');
|
||||
S.account = me.account; S.profiles = me.profiles || []; S.canModerate = !!me.can_moderate;
|
||||
if (!S.activeId || !S.profiles.some(p => p.id === S.activeId)) S.activeId = S.profiles[0] ? S.profiles[0].id : null;
|
||||
renderApp();
|
||||
} catch (e) { renderLogin(); }
|
||||
}
|
||||
async function doLogin(login, password, errBox) {
|
||||
try {
|
||||
const r = await api('POST', '/api/auth/login', { login, password });
|
||||
S.token = r.token; localStorage.setItem('bleeter_token', r.token);
|
||||
S.account = r.account; S.profiles = r.profiles || [];
|
||||
S.activeId = S.profiles[0] ? S.profiles[0].id : null;
|
||||
await boot();
|
||||
} catch (e) { errBox.textContent = errText(e); errBox.style.display = 'block'; }
|
||||
}
|
||||
function logout() {
|
||||
S.token = null; S.account = null; S.profiles = [];
|
||||
localStorage.removeItem('bleeter_token'); localStorage.removeItem('bleeter_profile');
|
||||
renderLogin();
|
||||
}
|
||||
function setActive(id) { S.activeId = Number(id); localStorage.setItem('bleeter_profile', S.activeId); go(S.view); }
|
||||
|
||||
// ── Login view ────────────────────────────────────────────────────────────
|
||||
function renderLogin() {
|
||||
document.getElementById('app').innerHTML = '';
|
||||
const wrap = h(`<div id="login-wrap"><div class="login-card">
|
||||
<div class="brand"><div class="brand-logo">🐦</div><div class="brand-name">Bleeter<small>Lifeinvader Network</small></div></div>
|
||||
<h2>Anmelden</h2>
|
||||
<p class="sub">Melde dich mit deinem Handle oder deiner IC-Mail und deinem ingame gesetzten Bleeter-Passwort an.</p>
|
||||
<div class="login-error" id="login-error" style="display:none"></div>
|
||||
<form id="login-form">
|
||||
<label class="field"><span>Handle oder Mail</span><input id="l-login" autocomplete="username" placeholder="@handle oder mail@..." /></label>
|
||||
<label class="field"><span>Passwort</span><input id="l-pass" type="password" autocomplete="current-password" placeholder="••••••••" /></label>
|
||||
<button class="btn btn-primary" style="width:100%;justify-content:center;margin-top:6px">Anmelden</button>
|
||||
</form>
|
||||
<div class="login-hint">Noch kein Passwort? Öffne Bleeter im Spiel und setze mit <b>/bleeterweb <passwort></b> dein Web-Passwort.</div>
|
||||
</div></div>`);
|
||||
document.getElementById('app').appendChild(wrap);
|
||||
const errBox = wrap.querySelector('#login-error');
|
||||
wrap.querySelector('#login-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const login = wrap.querySelector('#l-login').value.trim().replace(/^@/, '');
|
||||
const pass = wrap.querySelector('#l-pass').value;
|
||||
if (!login || !pass) { errBox.textContent = 'Bitte alle Felder ausfüllen.'; errBox.style.display = 'block'; return; }
|
||||
doLogin(login, pass, errBox);
|
||||
});
|
||||
}
|
||||
|
||||
// ── App shell ───────────────────────────────────────────────────────────────
|
||||
const NAV = [
|
||||
{ key: 'home', label: 'Feed', ico: '🏠' },
|
||||
{ key: 'ads', label: 'Werbefeed', ico: '📣' },
|
||||
{ key: 'people', label: 'Leute', ico: '🧭' },
|
||||
{ key: 'social', label: 'Follower', ico: '👥' },
|
||||
{ key: 'market', label: 'Marktplatz', ico: '🛒' },
|
||||
{ key: 'calendar', label: 'Kalender', ico: '📅' },
|
||||
{ key: 'business', label: 'Gewerbe', ico: '💼' },
|
||||
{ key: 'profile', label: 'Mein Profil', ico: '👤' },
|
||||
{ key: 'legal', label: 'Rechtliches', ico: '📘' },
|
||||
];
|
||||
function navItems() { const n = NAV.slice(); if (S.canModerate) n.push({ key: 'moderation', label: 'Moderation', ico: '🛡️' }); return n; }
|
||||
|
||||
function renderApp() {
|
||||
const p = activeProfile();
|
||||
const profOptions = S.profiles.map(pp => `<option value="${pp.id}" ${pp.id === S.activeId ? 'selected' : ''}>${esc(pp.display_name)} · @${esc(pp.handle)}</option>`).join('');
|
||||
const shell = h(`<div id="shell">
|
||||
<aside id="sidebar">
|
||||
<div class="side-brand"><div class="brand-logo">🐦</div><div class="brand-name">Bleeter</div></div>
|
||||
<nav id="nav">${navItems().map(n => `<button class="nav-item ${n.key === S.view ? 'active' : ''}" data-nav="${n.key}"><span class="ico">${n.ico}</span>${n.label}</button>`).join('')}</nav>
|
||||
<div class="nav-spacer"></div>
|
||||
<div class="profile-switch">
|
||||
<div class="cur">${p ? avatar(p.avatar_url, p.display_name, 'md') : ''}<div class="meta">
|
||||
<b>${p ? esc(p.display_name) : 'Kein Profil'}</b><span>${p ? '@' + esc(p.handle) + ' · ' + (PROFILE_TYPE_LABEL[p.profile_type] || '') : ''}</span></div></div>
|
||||
${S.profiles.length > 1 ? `<select id="prof-select">${profOptions}</select>` : ''}
|
||||
<button class="btn btn-ghost btn-sm logout" data-nav="__logout"><span>⏻</span> Abmelden</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main id="main"></main>
|
||||
</div>`);
|
||||
const mob = h(`<div id="mobile-nav">${navItems().slice(0, 5).map(n => `<button class="${n.key === S.view ? 'active' : ''}" data-nav="${n.key}">${n.ico}</button>`).join('')}</div>`);
|
||||
const app = document.getElementById('app'); app.innerHTML = ''; app.appendChild(shell); app.appendChild(mob);
|
||||
|
||||
document.querySelectorAll('[data-nav]').forEach(b => b.addEventListener('click', () => {
|
||||
const k = b.getAttribute('data-nav');
|
||||
if (k === '__logout') return logout();
|
||||
go(k);
|
||||
}));
|
||||
const sel = shell.querySelector('#prof-select'); if (sel) sel.addEventListener('change', e => setActive(e.target.value));
|
||||
go(S.view);
|
||||
}
|
||||
|
||||
function setActiveNav() {
|
||||
document.querySelectorAll('[data-nav]').forEach(b => {
|
||||
const k = b.getAttribute('data-nav'); if (k && k[0] !== '_') b.classList.toggle('active', k === S.view);
|
||||
});
|
||||
}
|
||||
function main() { return document.getElementById('main'); }
|
||||
function loading() { main().innerHTML = `<div class="empty"><div class="big">🐦</div>Lädt…</div>`; }
|
||||
|
||||
// ── Router ────────────────────────────────────────────────────────────────
|
||||
async function go(view) {
|
||||
S.view = view; setActiveNav(); loading();
|
||||
try {
|
||||
if (view === 'home' || view === 'ads') return viewFeed(view === 'ads' ? 'advertising' : 'home');
|
||||
if (view === 'people') return viewPeople();
|
||||
if (view === 'social') return viewSocial();
|
||||
if (view === 'market') return viewMarket();
|
||||
if (view === 'calendar') return viewCalendar();
|
||||
if (view === 'business') return viewBusiness();
|
||||
if (view === 'profile') return viewProfile();
|
||||
if (view === 'legal') return viewLegal();
|
||||
if (view === 'moderation') return viewModeration();
|
||||
} catch (e) { if (e.message !== 'unauthorized') main().innerHTML = `<div class="empty"><div class="big">⚠️</div>${errText(e)}</div>`; }
|
||||
}
|
||||
|
||||
// ── Feed / Werbefeed ─────────────────────────────────────────────────────────
|
||||
function topbar(title, sub) { return `<div class="topbar"><div><h1>${esc(title)}</h1>${sub ? `<div class="sub">${esc(sub)}</div>` : ''}</div></div>`; }
|
||||
|
||||
async function viewFeed(feedType) {
|
||||
const { posts } = await apiGet('/api/feed', { type: feedType });
|
||||
const p = activeProfile();
|
||||
const canPost = canPostHere(p, feedType);
|
||||
const c = h(`<div>${topbar(feedType === 'advertising' ? 'Werbefeed' : 'Feed', feedType === 'advertising' ? 'Angebote & Ankündigungen von Gewerben und Behörden' : 'Was in Los Santos gerade passiert')}</div>`);
|
||||
if (canPost) c.appendChild(composer(feedType));
|
||||
else if (p) c.appendChild(h(`<div class="card muted" style="font-size:.86rem">Als <b>${esc(PROFILE_TYPE_LABEL[p.profile_type])}</b>-Profil kannst du hier nicht posten. ${feedType === 'home' ? 'Wechsle auf ein Behördenprofil.' : 'Wechsle auf ein Gewerbe- oder Behördenprofil.'}</div>`));
|
||||
if (!posts.length) c.appendChild(h(`<div class="empty"><div class="big">🐦</div>Noch keine Bleets hier.</div>`));
|
||||
posts.forEach(post => c.appendChild(postCard(post)));
|
||||
main().innerHTML = ''; main().appendChild(c);
|
||||
}
|
||||
|
||||
function composer(feedType) {
|
||||
const p = activeProfile();
|
||||
const el = h(`<div class="card composer">
|
||||
<div style="display:flex;gap:12px">${avatar(p.avatar_url, p.display_name, 'md')}
|
||||
<div style="flex:1"><textarea maxlength="2000" placeholder="Was gibt's Neues, @${esc(p.handle)}?"></textarea>
|
||||
<input class="media-url" placeholder="Bild-URL (optional, https, .jpg/.png)" style="margin-top:8px;font-size:.85rem" />
|
||||
</div></div>
|
||||
<div class="row"><div class="tools"><span class="char-count">0 / 2000</span></div>
|
||||
<button class="btn btn-primary" data-post>Bleeten</button></div></div>`);
|
||||
const ta = el.querySelector('textarea'), cc = el.querySelector('.char-count');
|
||||
ta.addEventListener('input', () => cc.textContent = `${ta.value.length} / 2000`);
|
||||
el.querySelector('[data-post]').addEventListener('click', async (ev) => {
|
||||
const body = ta.value.trim(), mediaUrl = el.querySelector('.media-url').value.trim();
|
||||
if (!body && !mediaUrl) return toast('Dein Post ist leer.', 'error');
|
||||
ev.target.disabled = true;
|
||||
try { await apiSend('POST', '/api/posts', { feedType, body, mediaUrl }); toast('Gepostet.', 'success'); go(S.view); }
|
||||
catch (e) { toast(errText(e), 'error'); ev.target.disabled = false; }
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function postCard(post) {
|
||||
const a = post.author;
|
||||
const el = h(`<div class="card post" data-post-id="${post.id}">
|
||||
${avatar(a.avatar_url, a.display_name, 'md')}
|
||||
<div class="body">
|
||||
<div class="head"><span class="name">${esc(a.display_name)}</span>${badge(a)}
|
||||
<span class="handle">@${esc(a.handle)}</span><span class="time">· ${timeAgo(post.created_at)}</span></div>
|
||||
${post.body ? `<div class="text">${esc(post.body)}</div>` : ''}
|
||||
${post.media_url ? `<div class="media"><img src="${esc(post.media_url)}" loading="lazy" /></div>` : ''}
|
||||
<div class="actions">
|
||||
<button class="like ${post.liked_by_viewer ? 'liked' : ''}" data-act="like-post" data-id="${post.id}">${post.liked_by_viewer ? '❤️' : '🤍'} <span>${post.likes}</span></button>
|
||||
<button data-act="toggle-comments" data-id="${post.id}">💬 <span>${post.comments}</span></button>
|
||||
${post.can_delete ? `<button class="del" data-act="del-post" data-id="${post.id}">🗑️</button>` : ''}
|
||||
</div>
|
||||
<div class="comments-wrap" data-comments="${post.id}" style="display:${S.openComments.has(post.id) ? 'block' : 'none'}"></div>
|
||||
</div></div>`);
|
||||
if (S.openComments.has(post.id)) renderComments(el.querySelector(`[data-comments="${post.id}"]`), post);
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderComments(box, post) {
|
||||
box.innerHTML = '';
|
||||
const list = h(`<div class="comments"></div>`);
|
||||
(post.comments_list || []).forEach(cm => {
|
||||
const cmEl = h(`<div class="comment">${avatar('', cm.author, 'sm')}<div class="c-body">
|
||||
<div class="chead"><b>@${esc(cm.author)}</b> · ${timeAgo(cm.created_at)}</div>
|
||||
<div>${esc(cm.body)}</div>
|
||||
<div class="actions" style="margin-top:6px;font-size:.8rem">
|
||||
<button class="like ${cm.liked_by_viewer ? 'liked' : ''}" data-act="like-comment" data-id="${cm.id}" data-post="${post.id}">${cm.liked_by_viewer ? '❤️' : '🤍'} <span>${cm.likes}</span></button>
|
||||
${cm.can_delete ? `<button class="del" data-act="del-comment" data-id="${cm.id}" data-post="${post.id}">🗑️</button>` : ''}
|
||||
</div></div></div>`);
|
||||
list.appendChild(cmEl);
|
||||
});
|
||||
box.appendChild(list);
|
||||
const form = h(`<form class="comment-form"><input maxlength="500" placeholder="Kommentieren…" /><button class="btn btn-primary btn-sm">→</button></form>`);
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault(); const inp = form.querySelector('input'); const body = inp.value.trim(); if (!body) return;
|
||||
try { const r = await apiSend('POST', `/api/posts/${post.id}/comments`, { body }); post.comments_list = r.comments; post.comments = r.comments.length; inp.value = ''; renderComments(box, post); refreshCount(post); }
|
||||
catch (err) { toast(errText(err), 'error'); }
|
||||
});
|
||||
box.appendChild(form);
|
||||
}
|
||||
function refreshCount(post) {
|
||||
const btn = document.querySelector(`[data-post-id="${post.id}"] [data-act="toggle-comments"] span`);
|
||||
if (btn) btn.textContent = post.comments;
|
||||
}
|
||||
|
||||
// Delegated actions for feed-like interactions
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-act]'); if (!btn) return;
|
||||
const act = btn.getAttribute('data-act'); const id = Number(btn.getAttribute('data-id'));
|
||||
try {
|
||||
if (act === 'like-post') { await apiSend('POST', `/api/posts/${id}/like`); go(S.view); }
|
||||
else if (act === 'like-comment') { await apiSend('POST', `/api/comments/${id}/like`); go(S.view); }
|
||||
else if (act === 'del-post') { if (confirm('Diesen Post löschen?')) { await apiSend('DELETE', `/api/posts/${id}`); toast('Gelöscht.', 'success'); go(S.view); } }
|
||||
else if (act === 'del-comment') { if (confirm('Kommentar löschen?')) { await apiSend('DELETE', `/api/comments/${id}`); go(S.view); } }
|
||||
else if (act === 'toggle-comments') { S.openComments.has(id) ? S.openComments.delete(id) : S.openComments.add(id); go(S.view); }
|
||||
else if (act === 'follow') { await apiSend('POST', `/api/profiles/${id}/follow`); go(S.view); }
|
||||
else if (act === 'unfollow') { await apiSend('POST', `/api/profiles/${id}/unfollow`); go(S.view); }
|
||||
else if (act === 'block') { if (confirm('Profil blockieren?')) { await apiSend('POST', `/api/profiles/${id}/block`); toast('Blockiert.', 'success'); go(S.view); } }
|
||||
else if (act === 'unblock') { await apiSend('POST', `/api/profiles/${id}/unblock`); go(S.view); }
|
||||
else if (act === 'del-market') { if (confirm('Inserat löschen?')) { await apiSend('DELETE', `/api/marketplace/${id}`); go(S.view); } }
|
||||
else if (act === 'del-event') { if (confirm('Termin löschen?')) { await apiSend('DELETE', `/api/events/${id}`); go(S.view); } }
|
||||
else if (act === 'mod') { await modAction(btn); }
|
||||
} catch (err) { if (err.message !== 'unauthorized') toast(errText(err), 'error'); }
|
||||
});
|
||||
|
||||
// ── Leute (Directory) ───────────────────────────────────────────────────────
|
||||
async function viewPeople() {
|
||||
const [{ profiles }, social] = await Promise.all([apiGet('/api/directory'), apiGet('/api/social')]);
|
||||
const followingIds = new Set(social.following.map(x => x.id));
|
||||
const me = activeProfile();
|
||||
const c = h(`<div>${topbar('Leute', 'Profile entdecken und folgen')}</div>`);
|
||||
const grid = h(`<div class="grid cols-2"></div>`);
|
||||
profiles.filter(p => !me || p.id !== me.id).forEach(p => {
|
||||
const following = followingIds.has(p.id);
|
||||
grid.appendChild(h(`<div class="card person">
|
||||
${avatar(p.avatar_url, p.display_name, 'md')}
|
||||
<div class="meta"><b>${esc(p.display_name)}${badge(p)}</b><span>@${esc(p.handle)} · ${PROFILE_TYPE_LABEL[p.profile_type] || ''} · ${p.followers_count || 0} Follower</span></div>
|
||||
<button class="btn btn-sm ${following ? '' : 'btn-primary'}" data-act="${following ? 'unfollow' : 'follow'}" data-id="${p.id}">${following ? 'Entfolgen' : 'Folgen'}</button>
|
||||
</div>`));
|
||||
});
|
||||
if (!profiles.length) grid.appendChild(h(`<div class="empty">Keine Profile.</div>`));
|
||||
c.appendChild(grid); main().innerHTML = ''; main().appendChild(c);
|
||||
}
|
||||
|
||||
// ── Follower / Social ────────────────────────────────────────────────────────
|
||||
let socialTab = 'following';
|
||||
async function viewSocial() {
|
||||
const social = await apiGet('/api/social');
|
||||
const tabs = [['following', 'Ich folge', social.following], ['followers', 'Follower', social.followers], ['blocked', 'Blockiert', social.blocked]];
|
||||
const c = h(`<div>${topbar('Follower', 'Dein Netzwerk')}
|
||||
<div class="tabs">${tabs.map(([k, l, arr]) => `<button class="tab ${k === socialTab ? 'active' : ''}" data-stab="${k}">${l} (${arr.length})</button>`).join('')}</div></div>`);
|
||||
const listWrap = h(`<div></div>`);
|
||||
const render = () => {
|
||||
listWrap.innerHTML = '';
|
||||
const arr = (tabs.find(t => t[0] === socialTab) || [, , []])[2];
|
||||
if (!arr.length) { listWrap.appendChild(h(`<div class="empty">Niemand hier.</div>`)); return; }
|
||||
const grid = h(`<div class="grid cols-2"></div>`);
|
||||
arr.forEach(p => {
|
||||
let action = '';
|
||||
if (socialTab === 'following') action = `<button class="btn btn-sm" data-act="unfollow" data-id="${p.id}">Entfolgen</button>`;
|
||||
else if (socialTab === 'blocked') action = `<button class="btn btn-sm" data-act="unblock" data-id="${p.id}">Freigeben</button>`;
|
||||
else if (p.can_be_blocked) action = `<button class="btn btn-sm btn-danger" data-act="block" data-id="${p.id}">Blockieren</button>`;
|
||||
grid.appendChild(h(`<div class="card person">${avatar(p.avatar_url, p.display_name, 'md')}
|
||||
<div class="meta"><b>${esc(p.display_name)}${badge(p)}</b><span>@${esc(p.handle)} · ${PROFILE_TYPE_LABEL[p.profile_type] || ''}</span></div>${action}</div>`));
|
||||
});
|
||||
listWrap.appendChild(grid);
|
||||
};
|
||||
c.appendChild(listWrap); render();
|
||||
c.querySelectorAll('[data-stab]').forEach(b => b.addEventListener('click', () => { socialTab = b.getAttribute('data-stab'); c.querySelectorAll('[data-stab]').forEach(x => x.classList.toggle('active', x === b)); render(); }));
|
||||
main().innerHTML = ''; main().appendChild(c);
|
||||
}
|
||||
|
||||
// ── Marktplatz ────────────────────────────────────────────────────────────
|
||||
async function viewMarket() {
|
||||
const { items } = await apiGet('/api/marketplace');
|
||||
const c = h(`<div><div class="topbar"><div><h1>Marktplatz</h1><div class="sub">Angebote der Community</div></div>
|
||||
<button class="btn btn-primary" id="new-market">+ Inserat</button></div></div>`);
|
||||
c.querySelector('#new-market').addEventListener('click', marketModal);
|
||||
if (!items.length) c.appendChild(h(`<div class="empty"><div class="big">🛒</div>Noch keine Inserate.</div>`));
|
||||
const grid = h(`<div class="grid cols-3"></div>`);
|
||||
items.forEach(it => {
|
||||
grid.appendChild(h(`<div class="card market-card">
|
||||
${it.media_url ? `<div class="media"><img src="${esc(it.media_url)}" loading="lazy"></div>` : ''}
|
||||
<b>${esc(it.title)}</b>
|
||||
${it.price_label ? `<div class="price">${esc(it.price_label)}</div>` : ''}
|
||||
<div class="muted" style="font-size:.86rem;margin:6px 0;white-space:pre-wrap">${esc(it.body || '')}</div>
|
||||
<div class="muted" style="font-size:.78rem">@${esc(it.author.handle)}${it.author.email ? ' · ' + esc(it.author.email) : ''}</div>
|
||||
${it.can_delete ? `<button class="btn btn-sm btn-danger" style="margin-top:10px" data-act="del-market" data-id="${it.id}">Löschen</button>` : ''}
|
||||
</div>`));
|
||||
});
|
||||
c.appendChild(grid); main().innerHTML = ''; main().appendChild(c);
|
||||
}
|
||||
function marketModal() {
|
||||
const body = h(`<div>
|
||||
<label class="field"><span>Titel</span><input id="m-title" maxlength="120"></label>
|
||||
<label class="field"><span>Beschreibung</span><textarea id="m-desc" maxlength="2000"></textarea></label>
|
||||
<label class="field"><span>Preis (optional)</span><input id="m-price" maxlength="80" placeholder="z.B. 5.000 $"></label>
|
||||
<label class="field"><span>Bild-URL (optional)</span><input id="m-media" placeholder="https://...jpg"></label></div>`);
|
||||
modal('Neues Inserat', body, async () => {
|
||||
await apiSend('POST', '/api/marketplace', {
|
||||
title: body.querySelector('#m-title').value, description: body.querySelector('#m-desc').value,
|
||||
priceLabel: body.querySelector('#m-price').value, mediaUrl: body.querySelector('#m-media').value,
|
||||
});
|
||||
toast('Inserat erstellt.', 'success'); go('market');
|
||||
}, 'Erstellen');
|
||||
}
|
||||
|
||||
// ── Kalender ────────────────────────────────────────────────────────────────
|
||||
async function viewCalendar() {
|
||||
const { days } = await apiGet('/api/calendar');
|
||||
const p = activeProfile();
|
||||
const canCreate = p && BUSINESS_TYPES.includes(p.profile_type);
|
||||
const c = h(`<div><div class="topbar"><div><h1>Kalender</h1><div class="sub">Veranstaltungen der nächsten 4 Wochen</div></div>
|
||||
${canCreate ? '<button class="btn btn-primary" id="new-event">+ Termin</button>' : ''}</div></div>`);
|
||||
if (canCreate) c.querySelector('#new-event').addEventListener('click', eventModal);
|
||||
const withEvents = days.filter(d => d.events.length);
|
||||
if (!withEvents.length) c.appendChild(h(`<div class="empty"><div class="big">📅</div>Keine anstehenden Termine.</div>`));
|
||||
withEvents.forEach(d => {
|
||||
const day = h(`<div class="card cal-day"><h4>${esc(d.title)}</h4></div>`);
|
||||
d.events.forEach(ev => day.appendChild(h(`<div class="event"><span class="time">${esc(ev.time)}</span>
|
||||
<div style="flex:1"><b>${esc(ev.title)}</b>${ev.location ? `<div class="muted" style="font-size:.82rem">📍 ${esc(ev.location)}</div>` : ''}<div class="dim" style="font-size:.78rem">@${esc(ev.author)}</div></div>
|
||||
${ev.can_delete ? `<button class="btn btn-sm btn-danger" data-act="del-event" data-id="${ev.id}">✕</button>` : ''}</div>`)));
|
||||
c.appendChild(day);
|
||||
});
|
||||
main().innerHTML = ''; main().appendChild(c);
|
||||
}
|
||||
function eventModal() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const body = h(`<div>
|
||||
<label class="field"><span>Titel</span><input id="e-title" maxlength="50"></label>
|
||||
<div class="grid cols-2"><label class="field"><span>Datum</span><input id="e-date" type="date" value="${today}"></label>
|
||||
<label class="field"><span>Uhrzeit</span><input id="e-time" type="time" value="20:00"></label></div>
|
||||
<label class="field"><span>Ort (optional)</span><input id="e-loc" maxlength="50"></label></div>`);
|
||||
modal('Neuer Termin', body, async () => {
|
||||
await apiSend('POST', '/api/events', {
|
||||
title: body.querySelector('#e-title').value, date: body.querySelector('#e-date').value,
|
||||
time: body.querySelector('#e-time').value, location: body.querySelector('#e-loc').value,
|
||||
});
|
||||
toast('Termin erstellt.', 'success'); go('calendar');
|
||||
}, 'Erstellen');
|
||||
}
|
||||
|
||||
// ── Gewerbe ─────────────────────────────────────────────────────────────────
|
||||
async function viewBusiness() {
|
||||
const { businesses } = await apiGet('/api/businesses');
|
||||
const p = activeProfile();
|
||||
const canToggle = p && BUSINESS_TYPES.includes(p.profile_type) && p.can_edit_profile;
|
||||
const mine = canToggle ? businesses.find(b => b.handle === p.handle) : null;
|
||||
const c = h(`<div>${topbar('Gewerbe', 'Geöffnete und geschlossene Betriebe')}</div>`);
|
||||
if (canToggle) {
|
||||
const cur = mine ? mine.status : 'closed';
|
||||
const toggle = h(`<div class="card" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div><b>Dein Betrieb: @${esc(p.handle)}</b><div class="muted" style="font-size:.84rem">Aktueller Status: <span class="pill ${cur}">${cur === 'open' ? 'Geöffnet' : 'Geschlossen'}</span></div></div>
|
||||
<div style="display:flex;gap:8px"><button class="btn btn-sm ${cur === 'open' ? 'btn-primary' : ''}" data-bstatus="open">Öffnen</button>
|
||||
<button class="btn btn-sm ${cur === 'closed' ? 'btn-primary' : ''}" data-bstatus="closed">Schließen</button></div></div>`);
|
||||
toggle.querySelectorAll('[data-bstatus]').forEach(b => b.addEventListener('click', async () => {
|
||||
try { await apiSend('POST', '/api/business/status', { status: b.getAttribute('data-bstatus') }); toast('Status aktualisiert.', 'success'); go('business'); }
|
||||
catch (e) { toast(errText(e), 'error'); }
|
||||
}));
|
||||
c.appendChild(toggle);
|
||||
}
|
||||
const grid = h(`<div class="grid cols-2"></div>`);
|
||||
businesses.forEach(b => grid.appendChild(h(`<div class="card person">${avatar(b.avatar_url, b.display_name, 'md')}
|
||||
<div class="meta"><b>${esc(b.display_name)}${b.is_verified ? ' <span class="verified">✔</span>' : ''}</b><span>@${esc(b.handle)}</span></div>
|
||||
<span class="pill ${b.status}">${b.status === 'open' ? 'Geöffnet' : 'Geschlossen'}</span></div>`)));
|
||||
if (!businesses.length) grid.appendChild(h(`<div class="empty">Keine Gewerbe gelistet.</div>`));
|
||||
c.appendChild(grid); main().innerHTML = ''; main().appendChild(c);
|
||||
}
|
||||
|
||||
// ── Mein Profil ─────────────────────────────────────────────────────────────
|
||||
async function viewProfile() {
|
||||
const p = activeProfile();
|
||||
if (!p) { return renderRegister(); }
|
||||
const data = await apiGet('/api/profiles/' + encodeURIComponent(p.handle));
|
||||
const prof = data.profile;
|
||||
const c = h(`<div>${topbar('Mein Profil', '')}
|
||||
<div class="card profile-head pad-lg">
|
||||
<div class="banner">${prof.banner_url ? `<img src="${esc(prof.banner_url)}">` : ''}</div>
|
||||
<div class="ptop">${avatar(prof.avatar_url, prof.display_name, 'lg')}
|
||||
<div class="pmeta"><h2>${esc(prof.display_name)}${badge(prof)}</h2><div class="muted">@${esc(prof.handle)} · ${PROFILE_TYPE_LABEL[prof.profile_type] || ''}</div></div>
|
||||
${p.can_edit_profile ? '<button class="btn btn-sm" id="edit-profile">Bearbeiten</button>' : ''}</div>
|
||||
${prof.bio ? `<div style="margin:14px 4px;line-height:1.6">${esc(prof.bio)}</div>` : ''}
|
||||
<div class="stat-row"><div class="s"><b>${prof.followers_count || 0}</b><span>Follower</span></div>
|
||||
<div class="s"><b>${prof.following_count || 0}</b><span>Folge ich</span></div>
|
||||
<div class="s"><b>${data.posts.length}</b><span>Bleets</span></div></div>
|
||||
</div></div>`);
|
||||
if (p.can_edit_profile) c.querySelector('#edit-profile').addEventListener('click', () => editProfileModal(prof));
|
||||
if (!data.posts.length) c.appendChild(h(`<div class="empty">Noch keine Bleets.</div>`));
|
||||
data.posts.forEach(post => {
|
||||
c.appendChild(h(`<div class="card post"><div class="body" style="margin-left:0">
|
||||
<div class="head"><span class="handle">${prof.feed_type === 'advertising' ? '📣 Werbung' : ''}</span><span class="time">${timeAgo(post.created_at)}</span></div>
|
||||
${post.body ? `<div class="text">${esc(post.body)}</div>` : ''}
|
||||
${post.media_url ? `<div class="media"><img src="${esc(post.media_url)}" loading="lazy"></div>` : ''}
|
||||
<div class="actions"><span>❤️ ${post.likes}</span><span>💬 ${post.comments}</span></div></div></div>`));
|
||||
});
|
||||
main().innerHTML = ''; main().appendChild(c);
|
||||
}
|
||||
function editProfileModal(prof) {
|
||||
const bioMax = prof.profile_type === 'private' ? 200 : 500;
|
||||
const body = h(`<div>
|
||||
<label class="field"><span>Anzeigename</span><input id="p-name" maxlength="80" value="${esc(prof.display_name)}"></label>
|
||||
<label class="field"><span>Bio (max ${bioMax})</span><textarea id="p-bio" maxlength="${bioMax}">${esc(prof.bio || '')}</textarea></label>
|
||||
<label class="field"><span>Avatar-URL</span><input id="p-avatar" value="${esc(prof.avatar_url || '')}" placeholder="https://...jpg"></label>
|
||||
<label class="field"><span>Banner-URL</span><input id="p-banner" value="${esc(prof.banner_url || '')}" placeholder="https://...jpg"></label></div>`);
|
||||
modal('Profil bearbeiten', body, async () => {
|
||||
await apiSend('PATCH', '/api/profile', {
|
||||
displayName: body.querySelector('#p-name').value, bio: body.querySelector('#p-bio').value,
|
||||
avatarUrl: body.querySelector('#p-avatar').value, bannerUrl: body.querySelector('#p-banner').value,
|
||||
});
|
||||
toast('Profil gespeichert.', 'success'); await boot(); go('profile');
|
||||
});
|
||||
}
|
||||
function renderRegister() {
|
||||
const c = h(`<div>${topbar('Willkommen bei Bleeter', '')}
|
||||
<div class="card pad-lg"><p class="muted" style="margin-bottom:16px">Du hast noch kein Bleeter-Profil. Lege jetzt dein privates Profil an.</p>
|
||||
<label class="field"><span>Handle (@)</span><input id="r-handle" maxlength="32" placeholder="deinname"></label>
|
||||
<label class="field"><span>Anzeigename</span><input id="r-name" maxlength="80"></label>
|
||||
<button class="btn btn-primary" id="r-go">Profil erstellen</button></div></div>`);
|
||||
c.querySelector('#r-go').addEventListener('click', async () => {
|
||||
try {
|
||||
const r = await api('POST', '/api/register', { handle: c.querySelector('#r-handle').value.trim().replace(/^@/, ''), displayName: c.querySelector('#r-name').value.trim() });
|
||||
S.profiles = r.profiles; S.activeId = S.profiles[0].id; toast('Profil erstellt!', 'success'); renderApp();
|
||||
} catch (e) { toast(errText(e), 'error'); }
|
||||
});
|
||||
main().innerHTML = ''; main().appendChild(c);
|
||||
}
|
||||
|
||||
// ── Rechtliches ─────────────────────────────────────────────────────────────
|
||||
function viewLegal() {
|
||||
const c = h(`<div>${topbar('Rechtliches', '')}
|
||||
<div class="card pad-lg legal">
|
||||
<h3>Über Bleeter</h3>
|
||||
<p>Bleeter ist das soziale Netzwerk von Lifeinvader in Los Santos. Alle Inhalte sind fiktiv und Teil des Rollenspiels auf NaturalBornPlayers.</p>
|
||||
<h3>Nutzungsregeln</h3>
|
||||
<p>Es gelten die Serverregeln von NaturalBornPlayers. Beleidigungen, reale Werbung, OOC-Inhalte sowie das Teilen realer personenbezogener Daten sind untersagt.</p>
|
||||
<p>Behörden- und Lifeinvader-Profile können nicht blockiert werden. Verstöße können von Lifeinvader-Mitarbeitern moderiert (Verifizierung, Sperrung) werden.</p>
|
||||
<h3>Inhalte & Haftung</h3>
|
||||
<p>Für Inhalte ist der jeweilige Verfasser (IC) verantwortlich. Bilder werden über einen externen Dienst gehostet.</p>
|
||||
<h3>Impressum (IC)</h3>
|
||||
<p>Lifeinvader Bleeter · Los Santos · vertreten durch die Lifeinvader-Redaktion.</p>
|
||||
</div></div>`);
|
||||
main().innerHTML = ''; main().appendChild(c);
|
||||
}
|
||||
|
||||
// ── Moderation (Lifeinvader) ──────────────────────────────────────────────────
|
||||
async function viewModeration() {
|
||||
const { profiles } = await apiGet('/api/directory');
|
||||
const c = h(`<div>${topbar('Moderation', 'Lifeinvader-Werkzeuge')}
|
||||
<div class="card muted" style="font-size:.85rem">Verifizieren, Staff markieren oder Profile sperren. Aktionen werden protokolliert.</div></div>`);
|
||||
const grid = h(`<div class="grid cols-2"></div>`);
|
||||
profiles.forEach(p => grid.appendChild(h(`<div class="card person" style="flex-wrap:wrap">
|
||||
${avatar(p.avatar_url, p.display_name, 'md')}
|
||||
<div class="meta"><b>${esc(p.display_name)}${badge(p)}</b><span>@${esc(p.handle)} · ${PROFILE_TYPE_LABEL[p.profile_type] || ''}${p.is_locked ? ' · <span style="color:var(--danger)">gesperrt</span>' : ''}</span></div>
|
||||
<div style="display:flex;gap:6px;width:100%;margin-top:6px">
|
||||
<button class="btn btn-sm" data-act="mod" data-modaction="verify" data-id="${p.id}">${p.is_verified ? 'Unverif.' : 'Verifizieren'}</button>
|
||||
<button class="btn btn-sm" data-act="mod" data-modaction="staff" data-id="${p.id}">${p.is_lifeinvader_staff ? 'Staff -' : 'Staff +'}</button>
|
||||
<button class="btn btn-sm btn-danger" data-act="mod" data-modaction="lock" data-id="${p.id}">${p.is_locked ? 'Entsperren' : 'Sperren'}</button>
|
||||
</div></div>`)));
|
||||
c.appendChild(grid); main().innerHTML = ''; main().appendChild(c);
|
||||
}
|
||||
async function modAction(btn) {
|
||||
const action = btn.getAttribute('data-modaction'); const id = Number(btn.getAttribute('data-id'));
|
||||
await apiSend('POST', '/api/moderation/profile', { action, targetId: id });
|
||||
toast('Aktion ausgeführt.', 'success'); go('moderation');
|
||||
}
|
||||
|
||||
// ── Start ────────────────────────────────────────────────────────────────────
|
||||
boot();
|
||||
18
web-backend/public/index.html
Normal file
18
web-backend/public/index.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bleeter · Lifeinvader</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="toast-layer"></div>
|
||||
<div id="modal-layer"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
250
web-backend/public/style.css
Normal file
250
web-backend/public/style.css
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
:root {
|
||||
--bg: #0f1117;
|
||||
--bg-2: #141824;
|
||||
--surface: rgba(26, 30, 44, 0.72);
|
||||
--surface-solid: #1a1e2c;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--accent: #8b5cf6;
|
||||
--accent-2: #a78bfa;
|
||||
--accent-soft: rgba(139, 92, 246, 0.16);
|
||||
--text: #e8eaf0;
|
||||
--text-muted: #9aa0b0;
|
||||
--text-dim: #6b7180;
|
||||
--danger: #f87171;
|
||||
--success: #34d399;
|
||||
--radius: 16px;
|
||||
--blur: blur(18px);
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background:
|
||||
radial-gradient(1100px 600px at 12% -8%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||
radial-gradient(900px 500px at 100% 0%, rgba(99, 102, 241, 0.10), transparent 55%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
input, textarea, select { font-family: inherit; }
|
||||
img { max-width: 100%; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────── */
|
||||
.btn {
|
||||
border: 1px solid var(--border-strong);
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text);
|
||||
padding: 9px 16px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: 0.15s;
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
}
|
||||
.btn:hover { background: rgba(255,255,255,0.09); }
|
||||
.btn-primary { background: var(--accent); border-color: transparent; color: #fff; }
|
||||
.btn-primary:hover { background: var(--accent-2); }
|
||||
.btn-danger { color: var(--danger); border-color: rgba(248,113,113,0.35); }
|
||||
.btn-danger:hover { background: rgba(248,113,113,0.12); }
|
||||
.btn-sm { padding: 6px 12px; font-size: 0.82rem; }
|
||||
.btn-ghost { border-color: transparent; background: transparent; color: var(--text-muted); }
|
||||
.btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.05); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
background: rgba(0,0,0,0.28);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 11px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.92rem;
|
||||
outline: none;
|
||||
transition: 0.15s;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
textarea { resize: vertical; min-height: 84px; line-height: 1.5; }
|
||||
label.field { display: block; margin-bottom: 12px; }
|
||||
label.field > span { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 6px; font-weight: 500; }
|
||||
|
||||
.verified { color: var(--accent-2); }
|
||||
.pill { display:inline-block; padding: 2px 9px; border-radius: 999px; font-size: 0.72rem; font-weight: 600; background: var(--accent-soft); color: var(--accent-2); }
|
||||
.pill.staff { background: rgba(52,211,153,0.14); color: var(--success); }
|
||||
.pill.open { background: rgba(52,211,153,0.15); color: var(--success); }
|
||||
.pill.closed { background: rgba(248,113,113,0.14); color: var(--danger); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.dim { color: var(--text-dim); }
|
||||
|
||||
/* ── Login ───────────────────────────────────────────── */
|
||||
#login-wrap {
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px;
|
||||
}
|
||||
.login-card {
|
||||
width: 100%; max-width: 400px;
|
||||
background: var(--surface); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
||||
border: 1px solid var(--border); border-radius: 22px; padding: 34px 30px;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,0.45);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
||||
.brand-logo { width: 46px; height: 46px; border-radius: 14px; display: grid; place-items: center; font-size: 1.5rem;
|
||||
background: linear-gradient(135deg, var(--accent), #6366f1); box-shadow: 0 8px 24px rgba(139,92,246,0.4); }
|
||||
.brand-name { font-size: 1.5rem; font-weight: 800; letter-spacing: -0.02em; }
|
||||
.brand-name small { display:block; font-size: 0.72rem; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
.login-card h2 { font-size: 1.05rem; margin: 22px 0 4px; }
|
||||
.login-card p.sub { color: var(--text-muted); font-size: 0.86rem; margin-bottom: 20px; }
|
||||
.login-error { background: rgba(248,113,113,0.12); border: 1px solid rgba(248,113,113,0.3); color: #fca5a5;
|
||||
padding: 10px 14px; border-radius: 12px; font-size: 0.85rem; margin-bottom: 14px; }
|
||||
.login-hint { margin-top: 20px; font-size: 0.78rem; color: var(--text-dim); line-height: 1.6; text-align: center; }
|
||||
|
||||
/* ── App layout ──────────────────────────────────────── */
|
||||
#shell { display: grid; grid-template-columns: 264px minmax(0, 1fr); min-height: 100vh; max-width: 1180px; margin: 0 auto; }
|
||||
#sidebar {
|
||||
position: sticky; top: 0; align-self: start; height: 100vh;
|
||||
padding: 22px 16px; display: flex; flex-direction: column; gap: 6px;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.side-brand { display: flex; align-items: center; gap: 10px; padding: 6px 10px 18px; }
|
||||
.side-brand .brand-logo { width: 38px; height: 38px; font-size: 1.25rem; }
|
||||
.side-brand .brand-name { font-size: 1.25rem; }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 13px; padding: 11px 14px; border-radius: 12px;
|
||||
color: var(--text-muted); font-weight: 600; font-size: 0.95rem; transition: 0.15s; border: none; background: none; width: 100%; text-align: left;
|
||||
}
|
||||
.nav-item .ico { width: 22px; text-align: center; font-size: 1.05rem; }
|
||||
.nav-item:hover { background: rgba(255,255,255,0.05); color: var(--text); }
|
||||
.nav-item.active { background: var(--accent-soft); color: var(--accent-2); }
|
||||
.nav-spacer { flex: 1; }
|
||||
|
||||
.profile-switch {
|
||||
margin-top: 10px; border: 1px solid var(--border); border-radius: 14px; padding: 10px; background: rgba(0,0,0,0.2);
|
||||
}
|
||||
.profile-switch .cur { display: flex; align-items: center; gap: 10px; }
|
||||
.avatar { border-radius: 50%; object-fit: cover; background: var(--accent-soft); flex-shrink: 0; display: grid; place-items: center; font-weight: 700; color: var(--accent-2); }
|
||||
.avatar.sm { width: 34px; height: 34px; font-size: 0.85rem; }
|
||||
.avatar.md { width: 46px; height: 46px; font-size: 1rem; }
|
||||
.avatar.lg { width: 84px; height: 84px; font-size: 1.8rem; }
|
||||
.profile-switch .meta { min-width: 0; }
|
||||
.profile-switch .meta b { display: block; font-size: 0.88rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.profile-switch .meta span { font-size: 0.76rem; color: var(--text-muted); }
|
||||
.profile-switch select { margin-top: 9px; padding: 8px 10px; font-size: 0.84rem; }
|
||||
.logout { margin-top: 8px; width: 100%; justify-content: center; }
|
||||
|
||||
/* ── Main column ─────────────────────────────────────── */
|
||||
#main { padding: 0 26px 80px; min-width: 0; }
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 5; padding: 20px 4px 14px; margin-bottom: 8px;
|
||||
background: linear-gradient(var(--bg) 62%, transparent);
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
}
|
||||
.topbar h1 { font-size: 1.35rem; font-weight: 800; letter-spacing: -0.02em; }
|
||||
.topbar .sub { font-size: 0.82rem; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
.card {
|
||||
background: var(--surface); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
||||
border: 1px solid var(--border); border-radius: var(--radius); padding: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card.pad-lg { padding: 22px; }
|
||||
|
||||
/* Composer */
|
||||
.composer textarea { border: none; background: transparent; padding: 6px 2px; min-height: 60px; font-size: 1.02rem; }
|
||||
.composer textarea:focus { box-shadow: none; }
|
||||
.composer .row { display: flex; align-items: center; gap: 10px; justify-content: space-between; margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
.composer .tools { display: flex; align-items: center; gap: 8px; color: var(--text-muted); }
|
||||
.char-count { font-size: 0.78rem; color: var(--text-dim); }
|
||||
|
||||
/* Post */
|
||||
.post { display: flex; gap: 13px; }
|
||||
.post .body { min-width: 0; flex: 1; }
|
||||
.post .head { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||||
.post .head .name { font-weight: 700; }
|
||||
.post .head .handle, .post .head .time { color: var(--text-muted); font-size: 0.85rem; }
|
||||
.post .text { margin: 5px 0 10px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; }
|
||||
.post .media { border-radius: 14px; border: 1px solid var(--border); overflow: hidden; margin: 6px 0 10px; }
|
||||
.post .media img { display: block; width: 100%; }
|
||||
.actions { display: flex; gap: 22px; color: var(--text-muted); font-size: 0.86rem; }
|
||||
.actions button { background: none; border: none; color: inherit; display: inline-flex; align-items: center; gap: 6px; padding: 4px; border-radius: 8px; transition: 0.15s; }
|
||||
.actions button:hover { color: var(--text); }
|
||||
.actions button.liked { color: var(--accent-2); }
|
||||
.actions button.del:hover { color: var(--danger); }
|
||||
|
||||
.comments { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 11px; }
|
||||
.comment { display: flex; gap: 10px; }
|
||||
.comment .c-body { background: rgba(0,0,0,0.22); border: 1px solid var(--border); border-radius: 12px; padding: 8px 12px; flex: 1; min-width: 0; }
|
||||
.comment .c-body .chead { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 2px; }
|
||||
.comment .c-body .chead b { color: var(--text); }
|
||||
.comment-form { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.comment-form input { border-radius: 999px; }
|
||||
|
||||
/* Grids */
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
.person { display: flex; align-items: center; gap: 12px; }
|
||||
.person .meta { min-width: 0; flex: 1; }
|
||||
.person .meta b { display: flex; align-items: center; gap: 6px; }
|
||||
.person .meta span { font-size: 0.8rem; color: var(--text-muted); }
|
||||
|
||||
.market-card .media { height: 150px; border-radius: 12px; overflow: hidden; border: 1px solid var(--border); margin-bottom: 12px; background: rgba(0,0,0,0.25); }
|
||||
.market-card .media img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.market-card .price { color: var(--accent-2); font-weight: 700; }
|
||||
|
||||
.cal-day { }
|
||||
.cal-day h4 { font-size: 0.9rem; margin-bottom: 8px; color: var(--text-muted); }
|
||||
.event { display: flex; gap: 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; margin-bottom: 8px; background: rgba(0,0,0,0.18); }
|
||||
.event .time { font-weight: 700; color: var(--accent-2); min-width: 46px; }
|
||||
|
||||
.empty { text-align: center; color: var(--text-dim); padding: 48px 20px; }
|
||||
.empty .big { font-size: 2.4rem; margin-bottom: 10px; opacity: 0.7; }
|
||||
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.tab { padding: 8px 16px; border-radius: 999px; background: rgba(255,255,255,0.04); border: 1px solid var(--border); color: var(--text-muted); font-weight: 600; font-size: 0.86rem; }
|
||||
.tab.active { background: var(--accent-soft); color: var(--accent-2); border-color: transparent; }
|
||||
|
||||
/* Profile header */
|
||||
.profile-head .banner { height: 130px; border-radius: 14px; background: linear-gradient(135deg, rgba(139,92,246,0.35), rgba(99,102,241,0.25)); border: 1px solid var(--border); overflow: hidden; }
|
||||
.profile-head .banner img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-head .ptop { display: flex; align-items: flex-end; gap: 16px; margin-top: -42px; padding: 0 6px; }
|
||||
.profile-head .ptop .avatar { border: 4px solid var(--surface-solid); }
|
||||
.profile-head .pmeta { flex: 1; padding-bottom: 4px; }
|
||||
.profile-head .pmeta h2 { font-size: 1.3rem; display: flex; align-items: center; gap: 8px; }
|
||||
.stat-row { display: flex; gap: 22px; margin: 12px 4px; }
|
||||
.stat-row .s b { font-size: 1.05rem; }
|
||||
.stat-row .s span { color: var(--text-muted); font-size: 0.82rem; margin-left: 5px; }
|
||||
|
||||
/* Modal + toast */
|
||||
#modal-layer { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 20px; z-index: 60; background: rgba(0,0,0,0.6); }
|
||||
#modal-layer.show { display: flex; }
|
||||
.modal { width: 100%; max-width: 480px; background: var(--surface-solid); border: 1px solid var(--border-strong); border-radius: 20px; padding: 24px; box-shadow: 0 30px 80px rgba(0,0,0,0.5); }
|
||||
.modal h3 { font-size: 1.15rem; margin-bottom: 16px; }
|
||||
.modal .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
|
||||
|
||||
#toast-layer { position: fixed; top: 20px; right: 20px; z-index: 80; display: flex; flex-direction: column; gap: 10px; }
|
||||
.toast { background: var(--surface-solid); border: 1px solid var(--border-strong); border-left: 3px solid var(--accent); padding: 12px 18px; border-radius: 12px; font-size: 0.88rem; box-shadow: 0 12px 40px rgba(0,0,0,0.4); animation: slidein 0.2s ease; max-width: 320px; }
|
||||
.toast.error { border-left-color: var(--danger); }
|
||||
.toast.success { border-left-color: var(--success); }
|
||||
@keyframes slidein { from { transform: translateX(20px); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
|
||||
.legal { line-height: 1.7; color: var(--text-muted); }
|
||||
.legal h3 { color: var(--text); margin: 18px 0 8px; font-size: 1rem; }
|
||||
.legal p { margin-bottom: 10px; }
|
||||
|
||||
/* Mobile */
|
||||
#mobile-nav { display: none; }
|
||||
@media (max-width: 860px) {
|
||||
#shell { grid-template-columns: 1fr; }
|
||||
#sidebar { display: none; }
|
||||
#main { padding: 0 14px 90px; }
|
||||
.grid.cols-2, .grid.cols-3 { grid-template-columns: 1fr; }
|
||||
#mobile-nav { display: flex; position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
|
||||
background: var(--surface); backdrop-filter: var(--blur); border-top: 1px solid var(--border); padding: 8px; justify-content: space-around; }
|
||||
#mobile-nav button { background: none; border: none; color: var(--text-muted); font-size: 1.3rem; padding: 8px 12px; border-radius: 10px; }
|
||||
#mobile-nav button.active { color: var(--accent-2); background: var(--accent-soft); }
|
||||
}
|
||||
65
web-backend/routes/auth.js
Normal file
65
web-backend/routes/auth.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('../db');
|
||||
const { sign, requireAuth } = require('../auth');
|
||||
const { getAccessibleProfiles } = require('../lib/account');
|
||||
const { hasLifeinvaderPermission } = require('../lib/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function publicAccount(account) {
|
||||
return {
|
||||
id: account.id,
|
||||
char_id: account.char_id,
|
||||
mail_address: account.mail_address,
|
||||
phone_number: account.phone_number,
|
||||
};
|
||||
}
|
||||
|
||||
// Login mit Handle ODER Mail + ingame gesetztem Bleeter-Passwort
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const login = String((req.body && req.body.login) || '').trim().toLowerCase();
|
||||
const password = String((req.body && req.body.password) || '');
|
||||
if (!login || !password) return res.status(400).json({ error: 'missing_credentials' });
|
||||
|
||||
let account = null;
|
||||
if (login.includes('@')) {
|
||||
account = await db.q1('SELECT * FROM bleeter_accounts WHERE mail_address = ?', [login]);
|
||||
} else {
|
||||
const profile = await db.q1('SELECT account_id FROM bleeter_profiles WHERE handle = ? LIMIT 1', [login]);
|
||||
if (profile && profile.account_id) {
|
||||
account = await db.q1('SELECT * FROM bleeter_accounts WHERE id = ?', [profile.account_id]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!account || !account.web_password_hash) {
|
||||
return res.status(401).json({ error: 'invalid_login' });
|
||||
}
|
||||
if (account.status && account.status !== 'active') {
|
||||
return res.status(403).json({ error: 'account_disabled' });
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(password, account.web_password_hash);
|
||||
if (!ok) return res.status(401).json({ error: 'invalid_login' });
|
||||
|
||||
const token = sign(account);
|
||||
const profiles = await getAccessibleProfiles(account);
|
||||
res.json({ token, account: publicAccount(account), profiles });
|
||||
} catch (err) {
|
||||
console.error('[bleeter] login error', err);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Aktueller Account + zugaengliche Profile + Moderationsflag
|
||||
router.get('/me', requireAuth, async (req, res) => {
|
||||
const profiles = await getAccessibleProfiles(req.account);
|
||||
const canModerate =
|
||||
(await hasLifeinvaderPermission(db, req.account.char_id, 'profile.verify')) ||
|
||||
(await hasLifeinvaderPermission(db, req.account.char_id, 'profile.staff')) ||
|
||||
(await hasLifeinvaderPermission(db, req.account.char_id, 'profile.lock'));
|
||||
res.json({ account: publicAccount(req.account), profiles, can_moderate: canModerate });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
42
web-backend/routes/business.js
Normal file
42
web-backend/routes/business.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { requireAuth } = require('../auth');
|
||||
const { resolveActingProfile } = require('../lib/account');
|
||||
const { loadBusinesses } = require('../lib/queries');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const BUSINESS_TYPES = ['small_business', 'company', 'authority'];
|
||||
|
||||
async function acting(req) {
|
||||
const profileId = (req.body && req.body.profileId) || req.query.profileId;
|
||||
return resolveActingProfile(req.account, profileId);
|
||||
}
|
||||
|
||||
// GET /api/businesses?profileId=
|
||||
router.get('/businesses', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
res.json({ businesses: await loadBusinesses(profile ? profile.id : 0) });
|
||||
});
|
||||
|
||||
// POST /api/business/status {profileId, status: open|closed} -> eigenes Gewerbe schalten
|
||||
router.post('/business/status', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||
if (!BUSINESS_TYPES.includes(profile.profile_type) || !profile.can_edit_profile) {
|
||||
return res.status(403).json({ error: 'not_allowed' });
|
||||
}
|
||||
const status = req.body.status === 'open' ? 'open' : req.body.status === 'closed' ? 'closed' : null;
|
||||
if (!status) return res.status(400).json({ error: 'invalid_status' });
|
||||
|
||||
await db.exec(
|
||||
`INSERT INTO bleeter_business_status (profile_id, status, source)
|
||||
VALUES (?, ?, 'web')
|
||||
ON DUPLICATE KEY UPDATE status = VALUES(status), source = VALUES(source)`,
|
||||
[profile.id, status]
|
||||
);
|
||||
res.json({ ok: true, status });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
57
web-backend/routes/events.js
Normal file
57
web-backend/routes/events.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { requireAuth } = require('../auth');
|
||||
const { resolveActingProfile } = require('../lib/account');
|
||||
const { loadCalendarDays } = require('../lib/queries');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const BUSINESS_TYPES = ['small_business', 'company', 'authority'];
|
||||
|
||||
async function acting(req) {
|
||||
const profileId = (req.body && req.body.profileId) || req.query.profileId;
|
||||
return resolveActingProfile(req.account, profileId);
|
||||
}
|
||||
|
||||
// GET /api/calendar?profileId=
|
||||
router.get('/calendar', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
res.json({ days: await loadCalendarDays(profile ? profile.id : 0) });
|
||||
});
|
||||
|
||||
// POST /api/events {profileId, date, time, title, location}
|
||||
router.post('/events', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||
if (!BUSINESS_TYPES.includes(profile.profile_type)) return res.status(403).json({ error: 'not_allowed' });
|
||||
|
||||
const date = String(req.body.date || '').trim().slice(0, 10);
|
||||
const time = String(req.body.time || '').trim().slice(0, 5);
|
||||
const title = String(req.body.title || '').trim().slice(0, 50);
|
||||
const location = String(req.body.location || '').trim().slice(0, 50);
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return res.status(400).json({ error: 'invalid_date' });
|
||||
if (!/^\d{2}:\d{2}$/.test(time)) return res.status(400).json({ error: 'invalid_time' });
|
||||
if (!title) return res.status(400).json({ error: 'title_required' });
|
||||
|
||||
await db.insert(
|
||||
'INSERT INTO bleeter_events (author_profile_id, title, location, starts_at) VALUES (?, ?, ?, ?)',
|
||||
[profile.id, title, location || null, `${date} ${time}:00`]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/events/:id {profileId}
|
||||
router.delete('/events/:id', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||
const eventId = Number(req.params.id);
|
||||
const ev = await db.q1('SELECT author_profile_id FROM bleeter_events WHERE id = ? AND deleted_at IS NULL', [eventId]);
|
||||
if (!ev) return res.status(404).json({ error: 'not_found' });
|
||||
if (Number(ev.author_profile_id) !== Number(profile.id)) return res.status(403).json({ error: 'not_owner' });
|
||||
await db.exec("UPDATE bleeter_events SET deleted_at = NOW(), status = 'deleted' WHERE id = ?", [eventId]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
126
web-backend/routes/feed.js
Normal file
126
web-backend/routes/feed.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { requireAuth } = require('../auth');
|
||||
const { resolveActingProfile } = require('../lib/account');
|
||||
const { loadPosts, loadCommentsForPost } = require('../lib/queries');
|
||||
const { canPost } = require('../lib/permissions');
|
||||
const { isAllowedExternalUrl } = require('../lib/media');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// Helfer: aktives Profil aufloesen (profileId aus body/query)
|
||||
async function acting(req) {
|
||||
const profileId = (req.body && req.body.profileId) || req.query.profileId;
|
||||
return resolveActingProfile(req.account, profileId);
|
||||
}
|
||||
|
||||
// GET /api/feed?type=home|advertising&profileId=
|
||||
router.get('/feed', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
const type = req.query.type === 'advertising' ? 'advertising'
|
||||
: req.query.type === 'home' ? 'home' : null;
|
||||
const posts = await loadPosts(profile ? profile.id : 0, type);
|
||||
res.json({ posts });
|
||||
});
|
||||
|
||||
// POST /api/posts {profileId, feedType, body, mediaUrl}
|
||||
router.post('/posts', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||
|
||||
const feedType = String(req.body.feedType || '').trim();
|
||||
const body = String(req.body.body || '').trim().slice(0, 2000);
|
||||
const mediaUrl = String(req.body.mediaUrl || '').trim().slice(0, 500);
|
||||
|
||||
if (!body && !mediaUrl) return res.status(400).json({ error: 'empty_post' });
|
||||
if (feedType !== 'home' && feedType !== 'advertising') return res.status(400).json({ error: 'invalid_feed' });
|
||||
if (!canPost(profile.profile_type, feedType)) return res.status(403).json({ error: 'not_allowed_here' });
|
||||
|
||||
let mediaId = null;
|
||||
if (mediaUrl) {
|
||||
const check = isAllowedExternalUrl(mediaUrl);
|
||||
if (!check.ok) return res.status(400).json({ error: 'media_rejected', reason: check.reason });
|
||||
mediaId = await db.insert(
|
||||
'INSERT INTO bleeter_media (owner_profile_id, source_type, url) VALUES (?, ?, ?)',
|
||||
[profile.id, 'external_url', mediaUrl]
|
||||
);
|
||||
}
|
||||
|
||||
await db.insert(
|
||||
'INSERT INTO bleeter_posts (author_profile_id, feed_type, body, media_id) VALUES (?, ?, ?, ?)',
|
||||
[profile.id, feedType, body, mediaId]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/posts/:id {profileId}
|
||||
router.delete('/posts/:id', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||
const postId = Number(req.params.id);
|
||||
const post = await db.q1('SELECT author_profile_id FROM bleeter_posts WHERE id = ? AND deleted_at IS NULL AND self_deleted_at IS NULL', [postId]);
|
||||
if (!post) return res.status(404).json({ error: 'not_found' });
|
||||
if (Number(post.author_profile_id) !== Number(profile.id)) return res.status(403).json({ error: 'not_owner' });
|
||||
await db.exec('UPDATE bleeter_posts SET self_deleted_at = NOW() WHERE id = ?', [postId]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/posts/:id/like {profileId} -> toggle
|
||||
router.post('/posts/:id/like', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||
await toggleLike(profile.id, 'post', Number(req.params.id));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/posts/:id/comments {profileId, body}
|
||||
router.post('/posts/:id/comments', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||
const postId = Number(req.params.id);
|
||||
const body = String(req.body.body || '').trim().slice(0, 500);
|
||||
if (!body) return res.status(400).json({ error: 'empty_comment' });
|
||||
const post = await db.q1('SELECT id FROM bleeter_posts WHERE id = ? AND deleted_at IS NULL AND self_deleted_at IS NULL AND hidden_at IS NULL', [postId]);
|
||||
if (!post) return res.status(404).json({ error: 'not_found' });
|
||||
await db.insert('INSERT INTO bleeter_comments (post_id, author_profile_id, body) VALUES (?, ?, ?)', [postId, profile.id, body]);
|
||||
const comments = await loadCommentsForPost(postId, profile.id);
|
||||
res.json({ ok: true, comments });
|
||||
});
|
||||
|
||||
// DELETE /api/comments/:id {profileId}
|
||||
router.delete('/comments/:id', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||
const commentId = Number(req.params.id);
|
||||
const c = await db.q1('SELECT author_profile_id, post_id FROM bleeter_comments WHERE id = ? AND deleted_at IS NULL AND self_deleted_at IS NULL', [commentId]);
|
||||
if (!c) return res.status(404).json({ error: 'not_found' });
|
||||
if (Number(c.author_profile_id) !== Number(profile.id)) return res.status(403).json({ error: 'not_owner' });
|
||||
await db.exec('UPDATE bleeter_comments SET self_deleted_at = NOW() WHERE id = ?', [commentId]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/comments/:id/like {profileId} -> toggle
|
||||
router.post('/comments/:id/like', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||
await toggleLike(profile.id, 'comment', Number(req.params.id));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
async function toggleLike(profileId, targetType, targetId) {
|
||||
const existing = await db.q1(
|
||||
'SELECT id FROM bleeter_likes WHERE profile_id = ? AND target_type = ? AND target_id = ?',
|
||||
[profileId, targetType, targetId]
|
||||
);
|
||||
if (existing) {
|
||||
await db.exec('DELETE FROM bleeter_likes WHERE id = ?', [existing.id]);
|
||||
} else {
|
||||
await db.exec(
|
||||
'INSERT IGNORE INTO bleeter_likes (profile_id, target_type, target_id) VALUES (?, ?, ?)',
|
||||
[profileId, targetType, targetId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
41
web-backend/routes/internal.js
Normal file
41
web-backend/routes/internal.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('../db');
|
||||
require('dotenv').config();
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Nur vom FiveM-Server erreichbar (interner Key)
|
||||
function requireInternal(req, res, next) {
|
||||
const key = req.headers['x-internal-key'];
|
||||
if (!key || key !== process.env.INTERNAL_API_KEY) {
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// POST /internal/set-password {charId, password}
|
||||
// Wird ingame vom Spieler ausgeloest; Passwort wird hier gehasht gespeichert.
|
||||
router.post('/set-password', requireInternal, async (req, res) => {
|
||||
try {
|
||||
const charId = String((req.body && req.body.charId) || '').trim();
|
||||
const password = String((req.body && req.body.password) || '');
|
||||
if (!charId) return res.status(400).json({ error: 'missing_char' });
|
||||
if (password.length < 6) return res.status(400).json({ error: 'password_too_short' });
|
||||
|
||||
const account = await db.q1('SELECT id FROM bleeter_accounts WHERE char_id = ?', [charId]);
|
||||
if (!account) return res.status(404).json({ error: 'no_account' });
|
||||
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
await db.exec(
|
||||
'UPDATE bleeter_accounts SET web_password_hash = ?, web_password_updated_at = NOW() WHERE id = ?',
|
||||
[hash, account.id]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[bleeter] set-password error', err.message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
63
web-backend/routes/marketplace.js
Normal file
63
web-backend/routes/marketplace.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { requireAuth } = require('../auth');
|
||||
const { resolveActingProfile } = require('../lib/account');
|
||||
const { loadMarketplace } = require('../lib/queries');
|
||||
const { isAllowedExternalUrl } = require('../lib/media');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
async function acting(req) {
|
||||
const profileId = (req.body && req.body.profileId) || req.query.profileId;
|
||||
return resolveActingProfile(req.account, profileId);
|
||||
}
|
||||
|
||||
// GET /api/marketplace?profileId=
|
||||
router.get('/marketplace', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
res.json({ items: await loadMarketplace(profile ? profile.id : 0) });
|
||||
});
|
||||
|
||||
// POST /api/marketplace {profileId, title, description, priceLabel, mediaUrl}
|
||||
router.post('/marketplace', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||
|
||||
let title = String(req.body.title || '').trim().slice(0, 120);
|
||||
let description = String(req.body.description || '').trim().slice(0, 2000);
|
||||
const priceLabel = String(req.body.priceLabel || '').trim().slice(0, 80);
|
||||
const mediaUrl = String(req.body.mediaUrl || '').trim().slice(0, 500);
|
||||
|
||||
if (!title && !description) return res.status(400).json({ error: 'empty' });
|
||||
if (!description) description = title;
|
||||
if (!title) title = description.slice(0, 120);
|
||||
|
||||
let mediaId = null;
|
||||
if (mediaUrl) {
|
||||
const check = isAllowedExternalUrl(mediaUrl);
|
||||
if (!check.ok) return res.status(400).json({ error: 'media_rejected', reason: check.reason });
|
||||
mediaId = await db.insert('INSERT INTO bleeter_media (owner_profile_id, source_type, url) VALUES (?, ?, ?)', [profile.id, 'external_url', mediaUrl]);
|
||||
}
|
||||
|
||||
await db.insert(
|
||||
`INSERT INTO bleeter_marketplace (author_profile_id, category, title, description, price_label, media_id)
|
||||
VALUES (?, 'general', ?, ?, ?, ?)`,
|
||||
[profile.id, title, description, priceLabel, mediaId]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/marketplace/:id {profileId}
|
||||
router.delete('/marketplace/:id', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||
const entryId = Number(req.params.id);
|
||||
const entry = await db.q1('SELECT author_profile_id FROM bleeter_marketplace WHERE id = ? AND deleted_at IS NULL', [entryId]);
|
||||
if (!entry) return res.status(404).json({ error: 'not_found' });
|
||||
if (Number(entry.author_profile_id) !== Number(profile.id)) return res.status(403).json({ error: 'not_owner' });
|
||||
await db.exec("UPDATE bleeter_marketplace SET deleted_at = NOW(), status = 'deleted' WHERE id = ?", [entryId]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
40
web-backend/routes/media.js
Normal file
40
web-backend/routes/media.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const db = require('../db');
|
||||
const { requireAuth } = require('../auth');
|
||||
const { resolveActingProfile } = require('../lib/account');
|
||||
const { uploadToImgbb, ALLOWED_EXT } = require('../lib/media');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB (Config.Media.maxBytes)
|
||||
});
|
||||
|
||||
// POST /api/media/upload (multipart: image) {profileId}
|
||||
router.post('/media/upload', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const { profile } = await resolveActingProfile(req.account, req.body.profileId);
|
||||
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||
if (!req.file) return res.status(400).json({ error: 'no_file' });
|
||||
|
||||
const ext = (req.file.originalname.split('.').pop() || '').toLowerCase();
|
||||
const mimeOk = /^image\/(jpe?g|png)$/i.test(req.file.mimetype || '');
|
||||
if (!ALLOWED_EXT.includes(ext) && !mimeOk) return res.status(400).json({ error: 'unsupported_type' });
|
||||
|
||||
const { url } = await uploadToImgbb(req.file.buffer, req.file.originalname);
|
||||
await db.insert(
|
||||
`INSERT INTO bleeter_media (owner_profile_id, source_type, url, original_name, mime_type, size_bytes)
|
||||
VALUES (?, 'upload', ?, ?, ?, ?)`,
|
||||
[profile.id, url, req.file.originalname, req.file.mimetype, req.file.size]
|
||||
);
|
||||
res.json({ ok: true, url });
|
||||
} catch (err) {
|
||||
console.error('[bleeter] media upload error', err.message);
|
||||
res.status(500).json({ error: 'upload_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
62
web-backend/routes/moderation.js
Normal file
62
web-backend/routes/moderation.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { requireAuth } = require('../auth');
|
||||
const { resolveActingProfile, findTargetProfile } = require('../lib/account');
|
||||
const { hasLifeinvaderPermission, decodeBool } = require('../lib/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const PERMISSION_BY_ACTION = {
|
||||
verify: 'profile.verify',
|
||||
staff: 'profile.staff',
|
||||
lock: 'profile.lock',
|
||||
};
|
||||
|
||||
async function writeAudit(account, actorProfile, action, targetType, targetId, reason, payload) {
|
||||
await db.insert(
|
||||
`INSERT INTO bleeter_audit_logs (actor_char_id, actor_profile_id, action, target_type, target_id, reason, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[account.char_id, actorProfile ? actorProfile.id : null, action, targetType, targetId, reason || null,
|
||||
payload ? JSON.stringify(payload) : null]
|
||||
);
|
||||
}
|
||||
|
||||
// POST /api/moderation/profile {profileId, action: verify|staff|lock, targetId}
|
||||
router.post('/moderation/profile', async (req, res) => {
|
||||
const { profile } = await resolveActingProfile(req.account, req.body.profileId);
|
||||
const action = String(req.body.action || '').trim();
|
||||
const target = await findTargetProfile({ targetId: req.body.targetId }, true);
|
||||
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
|
||||
|
||||
const permission = PERMISSION_BY_ACTION[action];
|
||||
if (!permission || !(await hasLifeinvaderPermission(db, req.account.char_id, permission))) {
|
||||
return res.status(403).json({ error: 'no_lifeinvader_rights' });
|
||||
}
|
||||
if (action === 'lock' && Number(profile.id) === Number(target.id)) {
|
||||
return res.status(400).json({ error: 'cannot_lock_self' });
|
||||
}
|
||||
|
||||
if (action === 'verify') {
|
||||
const next = !decodeBool(target.is_verified);
|
||||
await db.exec('UPDATE bleeter_profiles SET is_verified = ? WHERE id = ?', [next ? 1 : 0, target.id]);
|
||||
await writeAudit(req.account, profile, next ? 'profile.verify' : 'profile.unverify', 'profile', target.id, null, { handle: target.handle, is_verified: next });
|
||||
return res.json({ ok: true, is_verified: next });
|
||||
}
|
||||
if (action === 'staff') {
|
||||
const next = !decodeBool(target.is_lifeinvader_staff);
|
||||
await db.exec('UPDATE bleeter_profiles SET is_lifeinvader_staff = ? WHERE id = ?', [next ? 1 : 0, target.id]);
|
||||
await writeAudit(req.account, profile, next ? 'profile.staff.add' : 'profile.staff.remove', 'profile', target.id, null, { handle: target.handle, is_lifeinvader_staff: next });
|
||||
return res.json({ ok: true, is_lifeinvader_staff: next });
|
||||
}
|
||||
if (action === 'lock') {
|
||||
const next = !decodeBool(target.is_locked);
|
||||
await db.exec('UPDATE bleeter_profiles SET is_locked = ?, locked_reason = ? WHERE id = ?',
|
||||
[next ? 1 : 0, next ? 'Lifeinvader moderation' : null, target.id]);
|
||||
await writeAudit(req.account, profile, next ? 'profile.lock' : 'profile.unlock', 'profile', target.id, null, { handle: target.handle, is_locked: next });
|
||||
return res.json({ ok: true, is_locked: next });
|
||||
}
|
||||
res.status(400).json({ error: 'unknown_action' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
174
web-backend/routes/profiles.js
Normal file
174
web-backend/routes/profiles.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { requireAuth } = require('../auth');
|
||||
const { resolveActingProfile, findTargetProfile, mapProfile, getAccessibleProfiles } = require('../lib/account');
|
||||
const { loadProfileDirectory, loadSocial } = require('../lib/queries');
|
||||
const { canProfileBeBlocked, decodeBool } = require('../lib/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const HANDLE_RE = /^[a-z0-9._-]+$/;
|
||||
|
||||
async function acting(req) {
|
||||
const profileId = (req.body && req.body.profileId) || req.query.profileId;
|
||||
return resolveActingProfile(req.account, profileId);
|
||||
}
|
||||
|
||||
// GET /api/directory?profileId=
|
||||
router.get('/directory', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
const profiles = await loadProfileDirectory(profile ? profile.id : 0, false);
|
||||
res.json({ profiles });
|
||||
});
|
||||
|
||||
// GET /api/social?profileId=
|
||||
router.get('/social', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
if (!profile) return res.json({ following: [], followers: [], blocked: [] });
|
||||
res.json(await loadSocial(profile.id));
|
||||
});
|
||||
|
||||
// GET /api/profiles/:handle?profileId= -> Profil + eigene Posts
|
||||
router.get('/profiles/:handle', async (req, res) => {
|
||||
const { profile: viewer } = await acting(req);
|
||||
const viewerId = viewer ? viewer.id : 0;
|
||||
const handle = String(req.params.handle || '').toLowerCase();
|
||||
const row = await db.q1(
|
||||
`SELECT p.*,
|
||||
(SELECT COUNT(*) FROM bleeter_follows f WHERE f.followed_profile_id=p.id) AS followers_count,
|
||||
(SELECT COUNT(*) FROM bleeter_follows f WHERE f.follower_profile_id=p.id) AS following_count
|
||||
FROM bleeter_profiles p WHERE p.handle = ? AND p.is_active = 1`,
|
||||
[handle]
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: 'not_found' });
|
||||
const target = mapProfile(row);
|
||||
|
||||
const isFollowing = viewerId ? !!(await db.q1(
|
||||
'SELECT 1 FROM bleeter_follows WHERE follower_profile_id = ? AND followed_profile_id = ? LIMIT 1',
|
||||
[viewerId, target.id]
|
||||
)) : false;
|
||||
const isBlocked = viewerId ? !!(await db.q1(
|
||||
'SELECT 1 FROM bleeter_blocks WHERE blocker_profile_id = ? AND blocked_profile_id = ? LIMIT 1',
|
||||
[viewerId, target.id]
|
||||
)) : false;
|
||||
|
||||
const posts = await db.q(
|
||||
`SELECT posts.id, posts.feed_type, posts.body, posts.created_at, media.url AS media_url,
|
||||
(SELECT COUNT(*) FROM bleeter_likes l WHERE l.target_type='post' AND l.target_id=posts.id) AS likes,
|
||||
(SELECT COUNT(*) FROM bleeter_comments c WHERE c.post_id=posts.id AND c.self_deleted_at IS NULL AND c.hidden_at IS NULL AND c.deleted_at IS NULL) AS comments
|
||||
FROM bleeter_posts posts
|
||||
LEFT JOIN bleeter_media media ON media.id = posts.media_id
|
||||
WHERE posts.author_profile_id = ? AND posts.self_deleted_at IS NULL AND posts.hidden_at IS NULL AND posts.deleted_at IS NULL
|
||||
ORDER BY posts.created_at DESC LIMIT 50`,
|
||||
[target.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
profile: target,
|
||||
is_following: isFollowing,
|
||||
is_blocked: isBlocked,
|
||||
posts: posts.map(p => ({
|
||||
id: p.id, feed_type: p.feed_type, body: p.body || '', media_url: p.media_url,
|
||||
created_at: String(p.created_at || ''), likes: Number(p.likes) || 0, comments: Number(p.comments) || 0,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/profiles/:id/follow {profileId}
|
||||
router.post('/profiles/:id/follow', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
const target = await findTargetProfile({ targetId: req.params.id });
|
||||
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
|
||||
if (Number(profile.id) === Number(target.id)) return res.status(400).json({ error: 'self_follow' });
|
||||
await db.exec('INSERT IGNORE INTO bleeter_follows (follower_profile_id, followed_profile_id) VALUES (?, ?)', [profile.id, target.id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/profiles/:id/unfollow {profileId}
|
||||
router.post('/profiles/:id/unfollow', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
const target = await findTargetProfile({ targetId: req.params.id });
|
||||
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
|
||||
await db.exec('DELETE FROM bleeter_follows WHERE follower_profile_id = ? AND followed_profile_id = ?', [profile.id, target.id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/profiles/:id/block {profileId}
|
||||
router.post('/profiles/:id/block', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
const target = await findTargetProfile({ targetId: req.params.id });
|
||||
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
|
||||
if (Number(profile.id) === Number(target.id)) return res.status(400).json({ error: 'self_block' });
|
||||
if (!canProfileBeBlocked(target.profile_type, target.is_lifeinvader_staff)) return res.status(403).json({ error: 'cannot_block' });
|
||||
await db.exec('INSERT IGNORE INTO bleeter_blocks (blocker_profile_id, blocked_profile_id) VALUES (?, ?)', [profile.id, target.id]);
|
||||
await db.exec(
|
||||
`DELETE FROM bleeter_follows
|
||||
WHERE (follower_profile_id=? AND followed_profile_id=?) OR (follower_profile_id=? AND followed_profile_id=?)`,
|
||||
[profile.id, target.id, target.id, profile.id]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/profiles/:id/unblock {profileId}
|
||||
router.post('/profiles/:id/unblock', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
const target = await findTargetProfile({ targetId: req.params.id });
|
||||
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
|
||||
await db.exec('DELETE FROM bleeter_blocks WHERE blocker_profile_id = ? AND blocked_profile_id = ?', [profile.id, target.id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// PATCH /api/profile {profileId, displayName, avatarUrl, bannerUrl, bio} -> aktives Profil bearbeiten
|
||||
router.patch('/profile', async (req, res) => {
|
||||
const { profile } = await acting(req);
|
||||
if (!profile) return res.status(400).json({ error: 'no_profile' });
|
||||
if (!profile.can_edit_profile) return res.status(403).json({ error: 'not_allowed' });
|
||||
|
||||
const displayName = String(req.body.displayName || '').trim().slice(0, 80);
|
||||
const avatarUrl = String(req.body.avatarUrl || '').trim().slice(0, 255);
|
||||
const bannerUrl = String(req.body.bannerUrl || '').trim().slice(0, 255);
|
||||
const bioMax = profile.profile_type === 'private' ? 200 : 500;
|
||||
const bio = String(req.body.bio || '').trim().slice(0, bioMax);
|
||||
if (!displayName) return res.status(400).json({ error: 'name_required' });
|
||||
|
||||
await db.exec(
|
||||
'UPDATE bleeter_profiles SET display_name = ?, avatar_url = ?, banner_url = ?, bio = ? WHERE id = ?',
|
||||
[displayName, avatarUrl || null, bannerUrl || null, bio, profile.id]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/register {handle, displayName} -> privates Profil anlegen (falls noch keins)
|
||||
router.post('/register', async (req, res) => {
|
||||
const account = req.account;
|
||||
let handle = String(req.body.handle || '').trim().toLowerCase();
|
||||
let displayName = String(req.body.displayName || '').trim().slice(0, 80);
|
||||
|
||||
if (handle.length < 2 || handle.length > 32) return res.status(400).json({ error: 'invalid_handle_length' });
|
||||
if (!HANDLE_RE.test(handle)) return res.status(400).json({ error: 'invalid_handle_chars' });
|
||||
if (!displayName) displayName = handle;
|
||||
|
||||
const existingPrivate = await db.q1(
|
||||
"SELECT id FROM bleeter_profiles WHERE account_id = ? AND profile_type = 'private' LIMIT 1",
|
||||
[account.id]
|
||||
);
|
||||
if (existingPrivate) return res.status(409).json({ error: 'already_registered' });
|
||||
|
||||
const reserved = await db.q1('SELECT 1 FROM bleeter_reserved_handles WHERE handle = ? LIMIT 1', [handle]);
|
||||
if (reserved) return res.status(409).json({ error: 'reserved' });
|
||||
|
||||
const taken = await db.q1('SELECT 1 FROM bleeter_profiles WHERE handle = ? LIMIT 1', [handle]);
|
||||
if (taken) return res.status(409).json({ error: 'handle_taken' });
|
||||
|
||||
await db.insert(
|
||||
`INSERT INTO bleeter_profiles (account_id, profile_type, handle, display_name, email_contact, bio)
|
||||
VALUES (?, 'private', ?, ?, ?, '')`,
|
||||
[account.id, handle, displayName, account.mail_address]
|
||||
);
|
||||
|
||||
const profiles = await getAccessibleProfiles(account);
|
||||
res.json({ ok: true, profiles });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
38
web-backend/server.js
Normal file
38
web-backend/server.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
require('dotenv').config();
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// Health
|
||||
app.get('/api/health', (req, res) => res.json({ ok: true, service: 'zc-bleeter-web' }));
|
||||
|
||||
// Interner Endpunkt (FiveM-Server)
|
||||
app.use('/internal', require('./routes/internal'));
|
||||
|
||||
// Auth
|
||||
app.use('/api/auth', require('./routes/auth'));
|
||||
|
||||
// API
|
||||
app.use('/api', require('./routes/feed'));
|
||||
app.use('/api', require('./routes/profiles'));
|
||||
app.use('/api', require('./routes/marketplace'));
|
||||
app.use('/api', require('./routes/events'));
|
||||
app.use('/api', require('./routes/business'));
|
||||
app.use('/api', require('./routes/moderation'));
|
||||
app.use('/api', require('./routes/media'));
|
||||
|
||||
// Statisches Frontend + SPA-Fallback
|
||||
const publicDir = path.join(__dirname, 'public');
|
||||
app.use(express.static(publicDir));
|
||||
app.get(/^(?!\/api|\/internal).*/, (req, res) => {
|
||||
res.sendFile(path.join(publicDir, 'index.html'));
|
||||
});
|
||||
|
||||
const PORT = Number(process.env.PORT || 4091);
|
||||
app.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`[zc-bleeter-web] Backend laeuft auf 127.0.0.1:${PORT}`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue