d4rkbot: API v1 mit Key-System, Env-Diät, Rebranding
- API-Keys (SHA-256-Hash, Scopes, last_used) — Verwaltung auf der Setup-Seite, Klartext-Key wird genau einmal angezeigt - /api/v1: message, dm, roles (add/remove), member/:id, stats — Bearer-Auth mit Scope-Prüfung, Embed-Sanitizing, README-Doku mit Python-Beispiel - Env-Diät: PUBLIC_URL + GITEA_URL jetzt Settings (Env nur Fallback), OAuth-Redirect dynamisch; Env enthält nur noch Secrets/Bootstrap - Rebranding ecobot → d4rkbot (Packages, Container, Cookies, README); Volume-Name bleibt ecobot_data (Datenerhalt), Portainer-Stack-Name bleibt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// Release-Ankündigungen: großes Embed in den konfigurierten Kanal (Setup-Seite)
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
|
||||
import { releaseChannelId } from '../runtime-settings.js';
|
||||
import { releaseChannelId, publicUrl } from '../runtime-settings.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const BRAND_ORANGE = 0xff4d00;
|
||||
@@ -36,7 +36,7 @@ export async function postReleaseEmbed(client, release) {
|
||||
|
||||
const buttons = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel('Changelog').setEmoji('📋')
|
||||
.setURL(`${config.publicUrl}/changelog`)
|
||||
.setURL(`${publicUrl()}/changelog`)
|
||||
);
|
||||
if (release.url) {
|
||||
buttons.addComponents(
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ export async function watchdogTick(client, { failsBeforeAlert = FAILS_BEFORE_ALE
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
headers: { 'User-Agent': 'ecobot-watchdog/1.0' },
|
||||
headers: { 'User-Agent': 'd4rkbot-watchdog/1.0' },
|
||||
});
|
||||
ok = res.status < 500;
|
||||
detail = `HTTP ${res.status}`;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// eine Zusammenfassung der Woche in den Devlog-Kanal.
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import { weeklyStats, getSetting, setSetting } from '../db.js';
|
||||
import { devlogChannelId } from '../runtime-settings.js';
|
||||
import { devlogChannelId, publicUrl } from '../runtime-settings.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const BRAND_YELLOW = 0xf5c518;
|
||||
@@ -48,7 +48,7 @@ export async function postWeeklyRecap(client) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(BRAND_YELLOW)
|
||||
.setTitle('📊 Wochen-Rückblick')
|
||||
.setURL(`${config.publicUrl}/devlogs`)
|
||||
.setURL(`${publicUrl()}/devlogs`)
|
||||
.setDescription(
|
||||
`\`\`\`\n${buildBars(stats.perDay)}\n\`\`\``
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SQLite-Anbindung (better-sqlite3, synchron & schnell) — Schema wird beim Start angelegt
|
||||
import Database from 'better-sqlite3';
|
||||
import crypto from 'node:crypto';
|
||||
import { mkdirSync, statSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { config } from './config.js';
|
||||
@@ -113,6 +114,56 @@ export function takeBugReport(repo, issueNumber) {
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
// API-Keys für externe Skripte (/api/v1/*) — nur der SHA-256-Hash wird gespeichert
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
scopes TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_used_at TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
const insertApiKey = db.prepare(
|
||||
'INSERT INTO api_keys (name, key_hash, scopes) VALUES (?, ?, ?)'
|
||||
);
|
||||
const findApiKeyStmt = db.prepare('SELECT * FROM api_keys WHERE key_hash = ?');
|
||||
const touchApiKeyStmt = db.prepare(
|
||||
`UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?`
|
||||
);
|
||||
const listApiKeysStmt = db.prepare(
|
||||
'SELECT id, name, scopes, created_at, last_used_at FROM api_keys ORDER BY id'
|
||||
);
|
||||
const deleteApiKeyStmt = db.prepare('DELETE FROM api_keys WHERE id = ?');
|
||||
|
||||
const hashKey = (key) => crypto.createHash('sha256').update(key).digest('hex');
|
||||
|
||||
/** Neuen Key erzeugen — der Klartext-Key wird nur einmal zurückgegeben! */
|
||||
export function createApiKey(name, scopes) {
|
||||
const key = `d4rk_${crypto.randomBytes(24).toString('hex')}`;
|
||||
const info = insertApiKey.run(name, hashKey(key), scopes.join(','));
|
||||
return { id: info.lastInsertRowid, key };
|
||||
}
|
||||
|
||||
/** Key prüfen: gibt { id, name, scopes: [] } zurück oder null; aktualisiert last_used */
|
||||
export function verifyApiKey(key) {
|
||||
if (!key?.startsWith('d4rk_')) return null;
|
||||
const row = findApiKeyStmt.get(hashKey(key));
|
||||
if (!row) return null;
|
||||
touchApiKeyStmt.run(row.id);
|
||||
return { id: row.id, name: row.name, scopes: row.scopes.split(',') };
|
||||
}
|
||||
|
||||
export function listApiKeys() {
|
||||
return listApiKeysStmt.all();
|
||||
}
|
||||
|
||||
export function deleteApiKey(id) {
|
||||
return deleteApiKeyStmt.run(id).changes > 0;
|
||||
}
|
||||
|
||||
// Laufzeit-Einstellungen (Settings-Seite im Webinterface) — überschreiben Env-Defaults
|
||||
db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
||||
const getSettingStmt = db.prepare('SELECT value FROM settings WHERE key = ?');
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
// Minimaler Gitea-API-Client (Token-Auth) — für /bug → Issues
|
||||
import { config } from './config.js';
|
||||
import { giteaUrl } from './runtime-settings.js';
|
||||
|
||||
function headers(extra = {}) {
|
||||
return {
|
||||
@@ -11,7 +12,7 @@ function headers(extra = {}) {
|
||||
|
||||
/** Issue anlegen; gibt { number, html_url } zurück */
|
||||
export async function createIssue(repo, title, body) {
|
||||
const res = await fetch(`${config.giteaUrl}/api/v1/repos/${repo}/issues`, {
|
||||
const res = await fetch(`${giteaUrl()}/api/v1/repos/${repo}/issues`, {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ title, body }),
|
||||
@@ -28,7 +29,7 @@ export async function uploadIssueAsset(repo, issueNumber, filename, buffer) {
|
||||
const form = new FormData();
|
||||
form.append('attachment', new Blob([buffer]), filename);
|
||||
const res = await fetch(
|
||||
`${config.giteaUrl}/api/v1/repos/${repo}/issues/${issueNumber}/assets?name=${encodeURIComponent(filename)}`,
|
||||
`${giteaUrl()}/api/v1/repos/${repo}/issues/${issueNumber}/assets?name=${encodeURIComponent(filename)}`,
|
||||
{ method: 'POST', headers: headers(), body: form }
|
||||
);
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -11,6 +11,16 @@ export function devlogChannelId() {
|
||||
return getSetting('devlog_channel_id') || config.devlogChannelId;
|
||||
}
|
||||
|
||||
/** Öffentliche Basis-URL (Buttons, RSS, OAuth-Redirect) */
|
||||
export function publicUrl() {
|
||||
return (getSetting('public_url') || config.publicUrl).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/** Gitea-Basis-URL (API für /bug) */
|
||||
export function giteaUrl() {
|
||||
return (getSetting('gitea_url') || config.giteaUrl).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/** Release-Ankündigungs-Kanal — nur per Settings-Seite, leer = Feature aus */
|
||||
export function releaseChannelId() {
|
||||
return getSetting('release_channel_id') || null;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Öffentliche v1-API für eigene Skripte/Dienste (devlog.py, Platform, FiveM, CI …)
|
||||
// Auth: Authorization: Bearer d4rk_<key> — Keys + Scopes über die Setup-Seite.
|
||||
import { verifyApiKey, archiveStats } from '../db.js';
|
||||
|
||||
/** Bearer-Key prüfen; bei Erfolg Key-Info zurückgeben, sonst Fehler-Antwort senden */
|
||||
function requireScope(request, reply, scope) {
|
||||
const header = request.headers.authorization ?? '';
|
||||
const key = header.startsWith('Bearer ') ? header.slice(7).trim() : null;
|
||||
const info = key ? verifyApiKey(key) : null;
|
||||
if (!info) {
|
||||
reply.code(401).send({ error: 'invalid api key' });
|
||||
return null;
|
||||
}
|
||||
if (!info.scopes.includes(scope) && !info.scopes.includes('*')) {
|
||||
reply.code(403).send({ error: `scope '${scope}' required` });
|
||||
return null;
|
||||
}
|
||||
request.log.info(`[api-v1] ${info.name} → ${request.method} ${request.url}`);
|
||||
return info;
|
||||
}
|
||||
|
||||
/** Nur erlaubte Embed-Felder durchreichen (kein Blind-Passthrough) */
|
||||
function sanitizeEmbed(raw) {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const embed = {};
|
||||
for (const key of ['title', 'description', 'url', 'color', 'timestamp']) {
|
||||
if (raw[key] !== undefined) embed[key] = raw[key];
|
||||
}
|
||||
if (raw.footer?.text) embed.footer = { text: String(raw.footer.text) };
|
||||
if (raw.image?.url) embed.image = { url: String(raw.image.url) };
|
||||
if (raw.thumbnail?.url) embed.thumbnail = { url: String(raw.thumbnail.url) };
|
||||
if (Array.isArray(raw.fields)) {
|
||||
embed.fields = raw.fields.slice(0, 25).map((f) => ({
|
||||
name: String(f.name ?? ''), value: String(f.value ?? ''), inline: Boolean(f.inline),
|
||||
}));
|
||||
}
|
||||
return Object.keys(embed).length > 0 ? embed : null;
|
||||
}
|
||||
|
||||
export function registerApiV1(app, client) {
|
||||
// Nachricht/Embed in einen Kanal posten
|
||||
app.post('/api/v1/message', async (request, reply) => {
|
||||
if (!requireScope(request, reply, 'message')) return;
|
||||
|
||||
const { channel_id: channelId, content, embed } = request.body ?? {};
|
||||
const cleanEmbed = sanitizeEmbed(embed);
|
||||
if (!channelId || (!content && !cleanEmbed)) {
|
||||
return reply.code(400).send({ error: 'channel_id und content oder embed nötig' });
|
||||
}
|
||||
const channel = await client.channels.fetch(String(channelId)).catch(() => null);
|
||||
if (!channel?.isTextBased()) {
|
||||
return reply.code(404).send({ error: 'Kanal nicht gefunden' });
|
||||
}
|
||||
const message = await channel.send({
|
||||
...(content ? { content: String(content).slice(0, 2000) } : {}),
|
||||
...(cleanEmbed ? { embeds: [cleanEmbed] } : {}),
|
||||
});
|
||||
return { ok: true, message_id: message.id };
|
||||
});
|
||||
|
||||
// Direktnachricht an einen User
|
||||
app.post('/api/v1/dm', async (request, reply) => {
|
||||
if (!requireScope(request, reply, 'dm')) return;
|
||||
|
||||
const { user_id: userId, content } = request.body ?? {};
|
||||
if (!userId || !content) {
|
||||
return reply.code(400).send({ error: 'user_id und content nötig' });
|
||||
}
|
||||
const user = await client.users.fetch(String(userId)).catch(() => null);
|
||||
if (!user) return reply.code(404).send({ error: 'User nicht gefunden' });
|
||||
try {
|
||||
await user.send(String(content).slice(0, 2000));
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return reply.code(502).send({ error: 'DM nicht zustellbar (DMs deaktiviert?)' });
|
||||
}
|
||||
});
|
||||
|
||||
// Rolle geben/nehmen (z. B. Shop-Kauf → Kunden-Rolle)
|
||||
app.post('/api/v1/roles', async (request, reply) => {
|
||||
if (!requireScope(request, reply, 'roles')) return;
|
||||
|
||||
const { user_id: userId, role_id: roleId, action } = request.body ?? {};
|
||||
if (!userId || !roleId || !['add', 'remove'].includes(action)) {
|
||||
return reply.code(400).send({ error: "user_id, role_id und action ('add'|'remove') nötig" });
|
||||
}
|
||||
const guild = [...client.guilds.cache.values()].find((g) => g.roles.cache.has(String(roleId)));
|
||||
if (!guild) return reply.code(404).send({ error: 'Rolle nicht gefunden' });
|
||||
const member = await guild.members.fetch(String(userId)).catch(() => null);
|
||||
if (!member) return reply.code(404).send({ error: 'User nicht auf dem Server' });
|
||||
try {
|
||||
if (action === 'add') await member.roles.add(String(roleId));
|
||||
else await member.roles.remove(String(roleId));
|
||||
return { ok: true, action, user_id: userId, role_id: roleId };
|
||||
} catch {
|
||||
return reply.code(502).send({ error: 'Rolle nicht änderbar (Rechte/Rollen-Reihenfolge prüfen)' });
|
||||
}
|
||||
});
|
||||
|
||||
// Member-Info (für Login-/Berechtigungs-Checks externer Dienste)
|
||||
app.get('/api/v1/member/:id', async (request, reply) => {
|
||||
if (!requireScope(request, reply, 'read')) return;
|
||||
|
||||
for (const guild of client.guilds.cache.values()) {
|
||||
const member = await guild.members.fetch(String(request.params.id)).catch(() => null);
|
||||
if (member) {
|
||||
return {
|
||||
id: member.id,
|
||||
username: member.user?.username ?? null,
|
||||
display_name: member.displayName ?? null,
|
||||
guild: guild.name,
|
||||
joined_at: member.joinedAt?.toISOString() ?? null,
|
||||
roles: member.roles.cache
|
||||
.filter((r) => r.id !== guild.id)
|
||||
.map((r) => ({ id: r.id, name: r.name })),
|
||||
};
|
||||
}
|
||||
}
|
||||
return reply.code(404).send({ error: 'nicht auf dem Server' });
|
||||
});
|
||||
|
||||
// Statistiken (Dashboards)
|
||||
app.get('/api/v1/stats', async (request, reply) => {
|
||||
if (!requireScope(request, reply, 'read')) return;
|
||||
|
||||
return {
|
||||
...archiveStats(),
|
||||
guilds: client.guilds.cache.size,
|
||||
uptime_seconds: Math.floor(process.uptime()),
|
||||
};
|
||||
});
|
||||
}
|
||||
+48
-4
@@ -1,9 +1,12 @@
|
||||
// REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import { listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats, getSetting, setSetting } from '../db.js';
|
||||
import {
|
||||
listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats,
|
||||
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
|
||||
} from '../db.js';
|
||||
import { config } from '../config.js';
|
||||
import { removeDevlog } from '../bot/devlog-archive.js';
|
||||
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId } from '../runtime-settings.js';
|
||||
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl } from '../runtime-settings.js';
|
||||
import { getSessionUser, isAdmin } from './auth.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -62,7 +65,7 @@ export function registerApiRoutes(app, client) {
|
||||
const title = `Devlog ${dateFmt.format(new Date(d.posted_at))}${project ? ` — ${project}` : ''}`;
|
||||
return ` <item>
|
||||
<title>${esc(title)}</title>
|
||||
<link>${config.publicUrl}/devlogs</link>
|
||||
<link>${publicUrl()}/devlogs</link>
|
||||
<guid isPermaLink="false">${esc(d.message_id)}</guid>
|
||||
<pubDate>${new Date(d.posted_at).toUTCString()}</pubDate>
|
||||
<description>${esc(d.content)}</description>
|
||||
@@ -74,7 +77,7 @@ export function registerApiRoutes(app, client) {
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>D4RKST3R // DEVLOG</title>
|
||||
<link>${config.publicUrl}/devlogs</link>
|
||||
<link>${publicUrl()}/devlogs</link>
|
||||
<description>Entwicklungs-Updates, automatisch archiviert.</description>
|
||||
<language>de</language>
|
||||
${rssItems}
|
||||
@@ -142,6 +145,8 @@ ${rssItems}
|
||||
devlog_threads_enabled: getSetting('devlog_threads_enabled') !== '0',
|
||||
bug_report_repo: getSetting('bug_report_repo') ?? 'D4rkst3r/EcoGame',
|
||||
watchdog_urls: getSetting('watchdog_urls') ?? '',
|
||||
public_url: publicUrl(),
|
||||
gitea_url: getSetting('gitea_url') ?? config.giteaUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,11 +214,50 @@ ${rssItems}
|
||||
setSetting(key, String(body[key]).trim());
|
||||
}
|
||||
}
|
||||
// URLs: müssen mit http(s) beginnen
|
||||
for (const key of ['public_url', 'gitea_url']) {
|
||||
if (body[key] === undefined) continue;
|
||||
const value = String(body[key]).trim().replace(/\/$/, '');
|
||||
if (!/^https?:\/\/.+/.test(value)) {
|
||||
return reply.code(400).send({ error: `${key}: muss mit http(s):// beginnen` });
|
||||
}
|
||||
setSetting(key, value);
|
||||
}
|
||||
|
||||
request.log.info('Settings per Web-UI aktualisiert');
|
||||
return { ok: true, settings: currentSettings() };
|
||||
});
|
||||
|
||||
// --- API-Keys (Admin) — für /api/v1/* ---
|
||||
|
||||
const VALID_SCOPES = ['message', 'dm', 'roles', 'read'];
|
||||
|
||||
app.get('/api/apikeys', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
return { keys: listApiKeys() };
|
||||
});
|
||||
|
||||
app.post('/api/apikeys', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
|
||||
const name = String(request.body?.name ?? '').trim();
|
||||
const scopes = (request.body?.scopes ?? []).filter((s) => VALID_SCOPES.includes(s));
|
||||
if (!name || scopes.length === 0) {
|
||||
return reply.code(400).send({ error: 'name und mindestens ein Scope nötig' });
|
||||
}
|
||||
const { id, key } = createApiKey(name, scopes);
|
||||
request.log.info(`API-Key '${name}' erstellt (Scopes: ${scopes.join(',')})`);
|
||||
// Klartext-Key nur in dieser einen Antwort!
|
||||
return { id, key, name, scopes };
|
||||
});
|
||||
|
||||
app.delete('/api/apikeys/:id', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
const deleted = deleteApiKey(Number(request.params.id));
|
||||
request.log.info(`API-Key ${request.params.id} widerrufen: ${deleted}`);
|
||||
return { deleted };
|
||||
});
|
||||
|
||||
// Test-Embed in den konfigurierten Kanal senden (prüft die Kanal-Wahl ohne Push/Devlog)
|
||||
app.post('/api/settings/test/:target', async (request, reply) => {
|
||||
if (requireAdmin(request, reply)) return;
|
||||
|
||||
+7
-5
@@ -1,12 +1,14 @@
|
||||
// Discord-OAuth2-Login: /auth/login → Discord → /auth/callback → signiertes Session-Cookie
|
||||
import crypto from 'node:crypto';
|
||||
import { config } from '../config.js';
|
||||
import { publicUrl } from '../runtime-settings.js';
|
||||
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
const SESSION_COOKIE = 'ecobot_session';
|
||||
const STATE_COOKIE = 'ecobot_oauth_state';
|
||||
const SESSION_COOKIE = 'd4rkbot_session';
|
||||
const STATE_COOKIE = 'd4rkbot_oauth_state';
|
||||
|
||||
const redirectUri = `${config.publicUrl}/auth/callback`;
|
||||
// Dynamisch, damit die Setup-Seite die URL ändern kann (Redirect auch im Dev-Portal eintragen!)
|
||||
const redirectUri = () => `${publicUrl()}/auth/callback`;
|
||||
|
||||
/** Eingeloggten User aus dem signierten Session-Cookie lesen (null wenn nicht eingeloggt) */
|
||||
export function getSessionUser(request) {
|
||||
@@ -32,7 +34,7 @@ export function registerAuthRoutes(app) {
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.discordClientId,
|
||||
redirect_uri: redirectUri,
|
||||
redirect_uri: redirectUri(),
|
||||
response_type: 'code',
|
||||
scope: 'identify',
|
||||
state,
|
||||
@@ -62,7 +64,7 @@ export function registerAuthRoutes(app) {
|
||||
client_secret: config.discordClientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
redirect_uri: redirectUri(),
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { join } from 'node:path';
|
||||
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
|
||||
import { config } from '../config.js';
|
||||
import { saveDevlog } from '../db.js';
|
||||
import { devlogChannelId, devlogPingRoleId, devlogThreadsEnabled } from '../runtime-settings.js';
|
||||
import { devlogChannelId, devlogPingRoleId, devlogThreadsEnabled, publicUrl } from '../runtime-settings.js';
|
||||
import { imagesDir } from '../bot/devlog-archive.js';
|
||||
|
||||
const BRAND_YELLOW = 0xf5c518;
|
||||
@@ -90,7 +90,7 @@ export function registerDevlogEndpoint(app, client) {
|
||||
}
|
||||
|
||||
// Gebrandetes Embed; bis zu 4 Bilder als Grid (Discord gruppiert Embeds mit gleicher URL)
|
||||
const groupUrl = `${config.publicUrl}/devlogs`;
|
||||
const groupUrl = `${publicUrl()}/devlogs`;
|
||||
const dateStr = new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
||||
}).format(new Date());
|
||||
|
||||
@@ -14,6 +14,7 @@ import { postPushEmbed } from '../bot/commit-feed.js';
|
||||
import { postReleaseEmbed } from '../bot/release-feed.js';
|
||||
import { registerAuthRoutes } from './auth.js';
|
||||
import { registerApiRoutes } from './api.js';
|
||||
import { registerApiV1 } from './api-v1.js';
|
||||
import { registerDevlogEndpoint } from './devlog-endpoint.js';
|
||||
import { imagesDir } from '../bot/devlog-archive.js';
|
||||
|
||||
@@ -62,6 +63,7 @@ export async function startWebServer(client) {
|
||||
|
||||
registerAuthRoutes(app);
|
||||
registerApiRoutes(app, client);
|
||||
registerApiV1(app, client);
|
||||
registerDevlogEndpoint(app, client);
|
||||
|
||||
// Healthcheck (für Portainer/NPM)
|
||||
|
||||
Reference in New Issue
Block a user