Compare commits

..

78 Commits

Author SHA1 Message Date
Flatlogic Bot
975f481fa0 ReleaseV03 2026-02-19 18:18:46 +00:00
Flatlogic Bot
eacaa0e950 Autosave: 20260219-180907 2026-02-19 18:09:08 +00:00
Flatlogic Bot
a08b70fdf1 Projet final V10 2026-02-19 16:53:47 +00:00
Flatlogic Bot
224fc0227c Projet final V9 2026-02-19 16:15:51 +00:00
Flatlogic Bot
e988030fc8 Projet final V8 + Vocal amélioré 2026-02-19 15:47:00 +00:00
Flatlogic Bot
29d6cdef20 Projet final V7 + Whisper 2026-02-19 15:35:10 +00:00
Flatlogic Bot
9dfebe6d21 Projet final V7 2026-02-19 15:00:08 +00:00
Flatlogic Bot
7dfe53647e Projet final V6 2026-02-19 14:42:55 +00:00
Flatlogic Bot
68972bbe6c Projet final V5 + Forum V2 2026-02-19 13:22:08 +00:00
Flatlogic Bot
f56ab7ba2b Projet final V5 + Forum 2026-02-19 12:42:34 +00:00
Flatlogic Bot
430d57545f Autosave: 20260219-121958 2026-02-19 12:19:59 +00:00
Flatlogic Bot
19b2a3e8d6 Projet final V4 2026-02-18 21:08:17 +00:00
Flatlogic Bot
f1694cdee6 Autosave: 20260218-203108 2026-02-18 20:31:09 +00:00
Flatlogic Bot
e923af9f34 Projet final V3 2026-02-18 19:35:32 +00:00
Flatlogic Bot
04e745874e déconnexion du canal vocal 2026-02-18 19:16:04 +00:00
Flatlogic Bot
e6a755b1d6 Vocal canaux avec membres en apercu 2026-02-18 19:09:18 +00:00
Flatlogic Bot
4eaf7679f1 Autosave: 20260218-165838 2026-02-18 16:58:38 +00:00
Flatlogic Bot
b6b25ed90d code invite avec 30 minutes de sécurité 2026-02-18 16:47:44 +00:00
Flatlogic Bot
a763e6e5b1 invite code pour créer un compte 2026-02-18 16:22:01 +00:00
Flatlogic Bot
5329017efa correctif permissions 2026-02-18 16:15:26 +00:00
Flatlogic Bot
09fa2a7096 final v1 fonctionnel 2026-02-17 15:50:11 +00:00
Flatlogic Bot
c11359b2d2 Autosave: 20260217-152131 2026-02-17 15:21:31 +00:00
Flatlogic Bot
24671bdbc7 VOX a moitié fonctionnel 2026-02-17 15:07:03 +00:00
Flatlogic Bot
04cad1c49b PTT semi focntionnel 2026-02-17 14:47:12 +00:00
Flatlogic Bot
08664dda0d Autosave: 20260217-125051 2026-02-17 12:50:51 +00:00
Flatlogic Bot
d8c5bbb218 Autosave: 20260217-122132 2026-02-17 12:21:32 +00:00
Flatlogic Bot
920e26ada3 + a coté du nom de serveur 2026-02-17 10:15:53 +00:00
Flatlogic Bot
95cfa227e9 Autosave: 20260217-082815 2026-02-17 08:28:15 +00:00
Flatlogic Bot
75e3425c41 Flux RSS v1.0 2026-02-17 08:21:23 +00:00
Flatlogic Bot
35c2bad3b7 final v0.8 2026-02-17 01:03:52 +00:00
Flatlogic Bot
afb642ce41 Flux RSS v0.6 2026-02-17 00:40:25 +00:00
Flatlogic Bot
eb7cbe5ace flux rss v0.5 2026-02-17 00:29:34 +00:00
Flatlogic Bot
e678bcf5aa flux rss final 0.3 2026-02-16 23:49:17 +00:00
Flatlogic Bot
f46a1c7e4b flux rss v1 2026-02-16 23:42:16 +00:00
Flatlogic Bot
794f971b73 Autosave: 20260216-233738 2026-02-16 23:37:38 +00:00
Flatlogic Bot
e3e1dc3456 Final 0.2 2026-02-16 23:16:16 +00:00
Flatlogic Bot
5083b2794c version focntionnelle 02 2026-02-16 23:01:50 +00:00
Flatlogic Bot
f55113bf56 Version Fonctionnelle 1 2026-02-16 22:54:07 +00:00
Flatlogic Bot
e387e07cc6 Version stable 01 2026-02-16 22:09:05 +00:00
Flatlogic Bot
c0b4015a24 DARK LIGHT 2026-02-16 20:49:23 +00:00
Flatlogic Bot
5b5ac99cae Autosave: 20260216-202956 2026-02-16 20:29:56 +00:00
Flatlogic Bot
f26d0b6abc fermeture ouverture categorie 2026-02-16 18:51:39 +00:00
Flatlogic Bot
f9c70d9be2 Version secure A 2026-02-16 18:44:13 +00:00
Flatlogic Bot
2bda3a08f3 role emote 2026-02-16 18:01:22 +00:00
Flatlogic Bot
7241b4052b Autosave: 20260216-173823 2026-02-16 17:38:23 +00:00
Flatlogic Bot
171804c16a emotes roles V2 2026-02-16 17:09:01 +00:00
Flatlogic Bot
cd2b57b27d emote autoroles 2026-02-16 16:36:18 +00:00
Flatlogic Bot
e6233598d6 Autosave: 20260216-130929 2026-02-16 13:09:29 +00:00
Flatlogic Bot
77269fa65c Autosave: 20260216-032143 2026-02-16 03:21:43 +00:00
Flatlogic Bot
f20e908050 autoroles 2026-02-16 03:19:06 +00:00
Flatlogic Bot
41fa76eec3 v15 2026-02-16 02:59:13 +00:00
Flatlogic Bot
1a0b8da2ba V15 2026-02-16 02:58:09 +00:00
Flatlogic Bot
7aa3b7d910 membres roles canaux affichage 2026-02-16 00:36:16 +00:00
Flatlogic Bot
79d65ef265 acceptation regles 2026-02-16 00:26:21 +00:00
Flatlogic Bot
f41686b17d regles v2 2026-02-16 00:04:01 +00:00
Flatlogic Bot
a35fd4aafb règles v1 2026-02-15 23:59:02 +00:00
Flatlogic Bot
c987b0caba Autosave: 20260215-235418 2026-02-15 23:54:18 +00:00
Flatlogic Bot
b5ae307f55 categories 2026-02-15 23:38:38 +00:00
Flatlogic Bot
f604713529 barre de séparation 2026-02-15 22:48:16 +00:00
Flatlogic Bot
a757fa13ed roles dans le popup des membres a droite 2026-02-15 22:37:21 +00:00
Flatlogic Bot
c08cfebf52 roles++ 2026-02-15 22:26:04 +00:00
Flatlogic Bot
5494f1e4ee # et roles 2026-02-15 21:22:03 +00:00
Flatlogic Bot
e3984686cb Autosave: 20260215-194817 2026-02-15 19:48:17 +00:00
Flatlogic Bot
98888d0370 emoji sur les roles 2026-02-15 19:21:41 +00:00
Flatlogic Bot
652014e524 Autosave: 20260215-190103 2026-02-15 19:01:03 +00:00
Flatlogic Bot
dfd640b430 chat défilement 2026-02-15 18:28:21 +00:00
Flatlogic Bot
1440c83ccf Autosave: 20260215-170412 2026-02-15 17:04:12 +00:00
Flatlogic Bot
001690b707 Canaux ok 2026-02-15 16:49:18 +00:00
Flatlogic Bot
7a52251131 Autosave: 20260215-162152 2026-02-15 16:21:52 +00:00
Flatlogic Bot
5d6fd46690 v12 2026-02-15 16:11:50 +00:00
Flatlogic Bot
40f605d106 Autosave: 20260215-151332 2026-02-15 15:13:32 +00:00
Flatlogic Bot
9c07e1ee23 v8 2026-02-15 13:13:16 +00:00
Flatlogic Bot
1e73419ffb v6 2026-02-15 11:24:55 +00:00
Flatlogic Bot
0911f86785 V4 2026-02-15 11:01:34 +00:00
Flatlogic Bot
c49abcc049 Autosave: 20260215-105502 2026-02-15 10:55:02 +00:00
Flatlogic Bot
ef520f4259 v3 2026-02-15 10:33:41 +00:00
Flatlogic Bot
4883125cda v2 2026-02-15 10:30:17 +00:00
Flatlogic Bot
2642f97c8b v1 2026-02-15 10:25:49 +00:00
84 changed files with 12373 additions and 142 deletions

1
VERSIONS/Release_v0.2 Submodule

@ -0,0 +1 @@
Subproject commit eacaa0e950815780c3aab4232cc6b8cb49c7105d

139
api/emotes.php Normal file
View File

@ -0,0 +1,139 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../db/config.php';
$action = $_GET['action'] ?? '';
if ($action === 'list') {
try {
$stmt = db()->query("SELECT * FROM custom_emotes ORDER BY created_at DESC");
echo json_encode(['success' => true, 'emotes' => $stmt->fetchAll()]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($action === 'upload' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($_FILES['emote'])) {
echo json_encode(['success' => false, 'error' => 'Aucun fichier reçu (emote)']);
exit;
}
if ($_FILES['emote']['error'] !== UPLOAD_ERR_OK) {
$errorMsg = 'Erreur d\'upload PHP: ' . $_FILES['emote']['error'];
if ($_FILES['emote']['error'] === 1) $errorMsg = 'Fichier trop volumineux (limit PHP)';
if ($_FILES['emote']['error'] === 4) $errorMsg = 'Aucun fichier sélectionné';
echo json_encode(['success' => false, 'error' => $errorMsg]);
exit;
}
$name = preg_replace('/[^a-z0-9_]/', '', strtolower($_POST['name'] ?? 'emote'));
if (empty($name)) {
echo json_encode(['success' => false, 'error' => 'Nom invalide']);
exit;
}
$file = $_FILES['emote'];
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
if (!in_array(strtolower($ext), ['png', 'jpg', 'jpeg'])) {
echo json_encode(['success' => false, 'error' => 'Format non supporté (PNG uniquement recommandé)']);
exit;
}
$uploadDir = __DIR__ . '/../assets/images/custom_emotes/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0775, true);
}
$fileName = time() . '_' . $name . '.png';
$targetPath = $uploadDir . $fileName;
// Process image: Resize to 48x48
$srcImage = null;
if (strtolower($ext) === 'png') $srcImage = imagecreatefrompng($file['tmp_name']);
else if (in_array(strtolower($ext), ['jpg', 'jpeg'])) $srcImage = imagecreatefromjpeg($file['tmp_name']);
if (!$srcImage) {
echo json_encode(['success' => false, 'error' => 'Fichier image corrompu']);
exit;
}
$width = imagesx($srcImage);
$height = imagesy($srcImage);
$newSize = 48;
$dstImage = imagecreatetruecolor($newSize, $newSize);
// Transparency for PNG
imagealphablending($dstImage, false);
imagesavealpha($dstImage, true);
$transparent = imagecolorallocatealpha($dstImage, 255, 255, 255, 127);
imagefilledrectangle($dstImage, 0, 0, $newSize, $newSize, $transparent);
imagecopyresampled($dstImage, $srcImage, 0, 0, 0, 0, $newSize, $newSize, $width, $height);
imagepng($dstImage, $targetPath);
imagedestroy($srcImage);
imagedestroy($dstImage);
$relativePath = 'assets/images/custom_emotes/' . $fileName;
$code = ':' . $name . ':';
try {
$stmt = db()->prepare("INSERT INTO custom_emotes (name, path, code) VALUES (?, ?, ?)");
$stmt->execute([$name, $relativePath, $code]);
echo json_encode([
'success' => true,
'emote' => [
'id' => db()->lastInsertId(),
'name' => $name,
'path' => $relativePath,
'code' => $code
]
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($action === 'rename' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$id = $_POST['id'] ?? 0;
$newName = preg_replace('/[^a-z0-9_]/', '', strtolower($_POST['name'] ?? ''));
if (empty($newName)) {
echo json_encode(['success' => false, 'error' => 'Nom invalide']);
exit;
}
try {
$code = ':' . $newName . ':';
$stmt = db()->prepare("UPDATE custom_emotes SET name = ?, code = ? WHERE id = ?");
$stmt->execute([$newName, $code, $id]);
echo json_encode(['success' => true, 'name' => $newName, 'code' => $code]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($action === 'delete' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$id = $_POST['id'] ?? 0;
try {
$stmt = db()->prepare("SELECT path FROM custom_emotes WHERE id = ?");
$stmt->execute([$id]);
$emote = $stmt->fetch();
if ($emote) {
$filePath = __DIR__ . '/../' . $emote['path'];
if (file_exists($filePath)) unlink($filePath);
$stmt = db()->prepare("DELETE FROM custom_emotes WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Emote non trouvée']);
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}

26
api/pexels.php Normal file
View File

@ -0,0 +1,26 @@
<?php
header('Content-Type: application/json');
require_once __DIR__.'/../includes/pexels.php';
$action = $_GET['action'] ?? 'search';
if ($action === 'search') {
$q = $_GET['query'] ?? 'avatar';
$url = 'https://api.pexels.com/v1/search?query=' . urlencode($q) . '&per_page=12&page=1';
$data = pexels_get($url);
if (!$data) {
echo json_encode(['error' => 'Failed to fetch images']);
exit;
}
$results = [];
foreach ($data['photos'] as $photo) {
$results[] = [
'id' => $photo['id'],
'url' => $photo['src']['medium'],
'photographer' => $photo['photographer']
];
}
echo json_encode($results);
exit;
}

View File

@ -0,0 +1,27 @@
<?php
require_once __DIR__ . '/../auth/session.php';
require_once __DIR__ . '/../includes/utils.php';
requireLogin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$server_id = $_POST['server_id'] ?? 0;
$user_id = $_SESSION['user_id'];
require_once __DIR__ . '/../includes/permissions.php';
if (Permissions::hasPermission($user_id, $server_id, Permissions::MANAGE_SERVER) || Permissions::hasPermission($user_id, $server_id, Permissions::ADMINISTRATOR)) {
$new_invite_code = generateInviteCode();
$expires_at = date('c', time() + 1800); // ISO 8601 format
$stmt = db()->prepare("UPDATE servers SET invite_code = ?, invite_code_expires_at = ? WHERE id = ?");
$stmt->execute([$new_invite_code, date('Y-m-d H:i:s', time() + 1800), $server_id]);
echo json_encode([
'success' => true,
'invite_code' => $new_invite_code,
'expires_at' => $expires_at
]);
exit;
}
}
echo json_encode(['success' => false, 'error' => 'Permission denied']);

59
api_v1_accept_rules.php Normal file
View File

@ -0,0 +1,59 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
requireLogin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$json = json_decode(file_get_contents('php://input'), true);
$channel_id = $json['channel_id'] ?? 0;
$user_id = $_SESSION['user_id'];
if (!$channel_id) {
echo json_encode(['success' => false, 'error' => 'ID de canal manquant']);
exit;
}
// Fetch channel details to get rules_role_id
$stmt = db()->prepare("SELECT * FROM channels WHERE id = ? AND type = 'rules'");
$stmt->execute([$channel_id]);
$channel = $stmt->fetch();
if (!$channel) {
echo json_encode(['success' => false, 'error' => 'Canal de règles introuvable']);
exit;
}
if (empty($channel['rules_role_id'])) {
echo json_encode(['success' => false, 'error' => 'Aucun rôle n\'est configuré pour ce canal']);
exit;
}
$role_id = $channel['rules_role_id'];
try {
db()->beginTransaction();
// 1. Record acceptance
$stmtAcc = db()->prepare("INSERT IGNORE INTO rule_acceptances (user_id, channel_id) VALUES (?, ?)");
$stmtAcc->execute([$user_id, $channel_id]);
// 2. Assign role
// Check if user already has this role
$stmtRoleCheck = db()->prepare("SELECT 1 FROM user_roles WHERE user_id = ? AND role_id = ?");
$stmtRoleCheck->execute([$user_id, $role_id]);
if (!$stmtRoleCheck->fetch()) {
$stmtRole = db()->prepare("INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)");
$stmtRole->execute([$user_id, $role_id]);
}
db()->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
db()->rollBack();
echo json_encode(['success' => false, 'error' => 'Erreur lors de l\'attribution du rôle : ' . $e->getMessage()]);
}
exit;
}
echo json_encode(['success' => false, 'error' => 'Méthode non autorisée']);

102
api_v1_autoroles.php Normal file
View File

@ -0,0 +1,102 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
require_once 'includes/permissions.php';
requireLogin();
$user_id = $_SESSION['user_id'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$json = json_decode(file_get_contents('php://input'), true);
$action = $_POST['action'] ?? ($json['action'] ?? '');
if ($action === 'create') {
$channel_id = $_POST['channel_id'] ?? 0;
$server_id = $_POST['server_id'] ?? 0;
$icon = $_POST['icon'] ?? '';
$title = $_POST['title'] ?? '';
$role_id = $_POST['role_id'] ?? 0;
if (Permissions::hasPermission($user_id, $server_id, Permissions::MANAGE_CHANNELS)) {
$stmt = db()->prepare("INSERT INTO channel_autoroles (channel_id, icon, title, role_id) VALUES (?, ?, ?, ?)");
$stmt->execute([$channel_id, $icon, $title, $role_id]);
}
header('Location: index.php?server_id=' . $server_id . '&channel_id=' . $channel_id);
exit;
}
if ($action === 'update') {
$id = $_POST['id'] ?? 0;
$channel_id = $_POST['channel_id'] ?? 0;
$server_id = $_POST['server_id'] ?? 0;
$icon = $_POST['icon'] ?? '';
$title = $_POST['title'] ?? '';
$role_id = $_POST['role_id'] ?? 0;
if (Permissions::hasPermission($user_id, $server_id, Permissions::MANAGE_CHANNELS)) {
$stmt = db()->prepare("UPDATE channel_autoroles SET icon = ?, title = ?, role_id = ? WHERE id = ?");
$stmt->execute([$icon, $title, $role_id, $id]);
}
header('Location: index.php?server_id=' . $server_id . '&channel_id=' . $channel_id);
exit;
}
if ($action === 'delete') {
$id = $_POST['id'] ?? 0;
$channel_id = $_POST['channel_id'] ?? 0;
$server_id = $_POST['server_id'] ?? 0;
if (Permissions::hasPermission($user_id, $server_id, Permissions::MANAGE_CHANNELS)) {
$stmt = db()->prepare("DELETE FROM channel_autoroles WHERE id = ?");
$stmt->execute([$id]);
}
header('Location: index.php?server_id=' . $server_id . '&channel_id=' . $channel_id);
exit;
}
if ($action === 'toggle') {
// This will be called via AJAX
$role_id = $json['role_id'] ?? 0;
if (!$role_id) {
echo json_encode(['success' => false, 'error' => 'Invalid role']);
exit;
}
// Find the server for this role
$stmt = db()->prepare("SELECT server_id FROM roles WHERE id = ?");
$stmt->execute([$role_id]);
$role = $stmt->fetch();
if (!$role) {
echo json_encode(['success' => false, 'error' => 'Role not found']);
exit;
}
// Check if user is member of server
$stmt = db()->prepare("SELECT 1 FROM server_members WHERE server_id = ? AND user_id = ?");
$stmt->execute([$role['server_id'], $user_id]);
if (!$stmt->fetch()) {
echo json_encode(['success' => false, 'error' => 'Not a member of this server']);
exit;
}
// Toggle role
$stmt = db()->prepare("SELECT 1 FROM user_roles WHERE user_id = ? AND role_id = ?");
$stmt->execute([$user_id, $role_id]);
$has_role = $stmt->fetch();
if ($has_role) {
$stmt = db()->prepare("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?");
$stmt->execute([$user_id, $role_id]);
$added = false;
} else {
$stmt = db()->prepare("INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)");
$stmt->execute([$user_id, $role_id]);
$added = true;
}
echo json_encode(['success' => true, 'added' => $added]);
exit;
}
}

View File

@ -0,0 +1,158 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
requireLogin();
$user_id = $_SESSION['user_id'];
$data = json_decode(file_get_contents('php://input'), true) ?? $_POST;
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$channel_id = $_GET['channel_id'] ?? 0;
// Get server_id for this channel
$stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$channel = $stmt->fetch();
$server_id = $channel['server_id'] ?? 0;
// Ensure @everyone role exists for this server
$stmt = db()->prepare("SELECT id FROM roles WHERE server_id = ? AND (LOWER(name) = '@everyone' OR LOWER(name) = 'everyone') LIMIT 1");
$stmt->execute([$server_id]);
$everyone = $stmt->fetch();
if (!$everyone && $server_id) {
$stmt = db()->prepare("INSERT INTO roles (server_id, name, color, permissions, position) VALUES (?, '@everyone', '#99aab5', 0, 0)");
$stmt->execute([$server_id]);
$everyone_role_id = db()->lastInsertId();
} else {
$everyone_role_id = $everyone['id'] ?? 0;
}
// Fetch permissions for this channel (roles and users)
$stmt = db()->prepare("
SELECT cp.*, r.name as role_name, r.color as role_color,
u.display_name as member_name, u.avatar_url as member_avatar
FROM channel_permissions cp
LEFT JOIN roles r ON cp.role_id = r.id
LEFT JOIN users u ON cp.user_id = u.id
WHERE cp.channel_id = ?
");
$stmt->execute([$channel_id]);
$permissions = [];
while($row = $stmt->fetch()) {
if ($row['user_id']) {
$row['display_name'] = $row['member_name'] ?? 'Unknown Member';
$row['type'] = 'member';
} else {
$row['display_name'] = $row['role_name'] ?? 'Unknown Role';
$row['type'] = 'role';
}
$permissions[] = $row;
}
// Check if @everyone is in permissions, if not add it manually to show up by default
$has_everyone = false;
foreach($permissions as $p) {
if ($p['role_id'] == $everyone_role_id) {
$has_everyone = true;
break;
}
}
if (!$has_everyone && $everyone_role_id > 0) {
$stmt = db()->prepare("SELECT name, color FROM roles WHERE id = ?");
$stmt->execute([$everyone_role_id]);
$r = $stmt->fetch();
if ($r) {
array_unshift($permissions, [
'channel_id' => (int)$channel_id,
'role_id' => (int)$everyone_role_id,
'user_id' => null,
'allow_permissions' => 0,
'deny_permissions' => 0,
'role_name' => $r['name'],
'role_color' => $r['color'],
'display_name' => $r['name'],
'type' => 'role'
]);
}
}
echo json_encode(['success' => true, 'permissions' => $permissions]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$channel_id = $data['channel_id'] ?? 0;
$role_id = $data['role_id'] ?? null;
$target_user_id = $data['user_id'] ?? null;
$allow = $data['allow'] ?? 0;
$deny = $data['deny'] ?? 0;
if (!$role_id && !$target_user_id) {
echo json_encode(['success' => false, 'error' => 'Missing role_id or user_id']);
exit;
}
// Check permissions: Owner or MANAGE_CHANNELS or ADMINISTRATOR
require_once 'includes/permissions.php';
$stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$ch = $stmt->fetch();
$server_id = $ch['server_id'] ?? 0;
$stmt = db()->prepare("SELECT owner_id FROM servers WHERE id = ?");
$stmt->execute([$server_id]);
$server = $stmt->fetch();
$is_owner = ($server && $server['owner_id'] == $user_id);
$can_manage = Permissions::hasPermission($user_id, $server_id, Permissions::MANAGE_CHANNELS) ||
Permissions::hasPermission($user_id, $server_id, Permissions::ADMINISTRATOR);
if ($is_owner || $can_manage) {
$stmt = db()->prepare("
INSERT INTO channel_permissions (channel_id, role_id, user_id, allow_permissions, deny_permissions)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE allow_permissions = VALUES(allow_permissions), deny_permissions = VALUES(deny_permissions)
");
$stmt->execute([$channel_id, $role_id, $target_user_id, $allow, $deny]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
$channel_id = $data['channel_id'] ?? 0;
$role_id = $data['role_id'] ?? null;
$target_user_id = $data['user_id'] ?? null;
// Check permissions
require_once 'includes/permissions.php';
$stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$ch = $stmt->fetch();
$server_id = $ch['server_id'] ?? 0;
$stmt = db()->prepare("SELECT owner_id FROM servers WHERE id = ?");
$stmt->execute([$server_id]);
$server = $stmt->fetch();
$is_owner = ($server && $server['owner_id'] == $user_id);
$can_manage = Permissions::hasPermission($user_id, $server_id, Permissions::MANAGE_CHANNELS) ||
Permissions::hasPermission($user_id, $server_id, Permissions::ADMINISTRATOR);
if ($is_owner || $can_manage) {
if ($role_id !== null) {
$stmt = db()->prepare("DELETE FROM channel_permissions WHERE channel_id = ? AND role_id = ? AND user_id IS NULL");
$stmt->execute([$channel_id, $role_id]);
} else if ($target_user_id !== null) {
$stmt = db()->prepare("DELETE FROM channel_permissions WHERE channel_id = ? AND user_id = ? AND role_id IS NULL");
$stmt->execute([$channel_id, $target_user_id]);
}
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
}
exit;
}

135
api_v1_channels.php Normal file
View File

@ -0,0 +1,135 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
require_once 'includes/permissions.php';
requireLogin();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$action = $_GET['action'] ?? '';
if ($action === 'list_all') {
$stmt = db()->query("SELECT id, name FROM channels WHERE type = 'voice' ORDER BY name ASC");
echo json_encode(['success' => true, 'channels' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
exit;
}
$server_id = $_GET['server_id'] ?? 0;
if (!$server_id) {
echo json_encode([]);
exit;
}
$stmt = db()->prepare("SELECT * FROM channels WHERE server_id = ?");
$stmt->execute([$server_id]);
echo json_encode($stmt->fetchAll());
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Handle JSON input
$json = json_decode(file_get_contents('php://input'), true);
if ($json) {
$action = $json['action'] ?? '';
if ($action === 'reorder') {
$server_id = $json['server_id'] ?? 0;
$orders = $json['orders'] ?? []; // Array of {id, position, category_id}
$user_id = $_SESSION['user_id'];
if (Permissions::hasPermission($user_id, $server_id, Permissions::MANAGE_CHANNELS)) {
$stmt = db()->prepare("UPDATE channels SET position = ?, category_id = ? WHERE id = ? AND server_id = ?");
foreach ($orders as $o) {
$stmt->execute([$o['position'], $o['category_id'] ?: null, $o['id'], $server_id]);
}
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Permission denied']);
}
exit;
}
}
$action = $_POST['action'] ?? 'create';
$server_id = $_POST['server_id'] ?? 0;
$user_id = $_SESSION['user_id'];
if ($action === 'update') {
$channel_id = $_POST['channel_id'] ?? 0;
$name = $_POST['name'] ?? '';
$type = $_POST['type'] ?? 'chat';
$status = $_POST['status'] ?? null;
$allow_file_sharing = isset($_POST['allow_file_sharing']) ? 1 : 0;
$message_limit = !empty($_POST['message_limit']) ? (int)$_POST['message_limit'] : null;
$icon = $_POST['icon'] ?? null;
if ($icon === '') $icon = null;
$category_id = !empty($_POST['category_id']) ? (int)$_POST['category_id'] : null;
$rules_role_id = !empty($_POST['rules_role_id']) ? (int)$_POST['rules_role_id'] : null;
// Check if user has permission to manage channels
$stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$chan = $stmt->fetch();
if ($chan && Permissions::hasPermission($user_id, $chan['server_id'], Permissions::MANAGE_CHANNELS)) {
if ($type === 'separator' && !$name) $name = 'separator';
// Allow spaces, accents and mixed case
$name = trim($name);
// Explicitly exclude position from update to prevent jumping to bottom
$stmt = db()->prepare("UPDATE channels SET name = ?, type = ?, status = ?, allow_file_sharing = ?, message_limit = ?, icon = ?, category_id = ?, rules_role_id = ? WHERE id = ?");
$stmt->execute([$name, $type, $status, $allow_file_sharing, $message_limit, $icon, $category_id, $rules_role_id, $channel_id]);
if ($message_limit !== null) {
require_once 'db/config.php';
enforceChannelLimit($channel_id);
}
}
header('Location: index.php?server_id=' . $server_id . '&channel_id=' . $channel_id);
exit;
}
if ($action === 'delete') {
$channel_id = $_POST['channel_id'] ?? 0;
$stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$chan = $stmt->fetch();
if ($chan && Permissions::hasPermission($user_id, $chan['server_id'], Permissions::MANAGE_CHANNELS)) {
$stmt = db()->prepare("DELETE FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
}
header('Location: index.php?server_id=' . ($chan['server_id'] ?? ''));
exit;
}
$name = $_POST['name'] ?? '';
$type = $_POST['type'] ?? 'text';
$user_id = $_SESSION['user_id'];
// Check if user has permission to manage channels
if (Permissions::hasPermission($user_id, $server_id, Permissions::MANAGE_CHANNELS) && ($name || $type === 'separator')) {
try {
if ($type === 'separator' && !$name) $name = 'separator';
// Allow spaces, accents and mixed case
$name = trim($name);
$allow_file_sharing = isset($_POST['allow_file_sharing']) ? 1 : 0;
$message_limit = !empty($_POST['message_limit']) ? (int)$_POST['message_limit'] : null;
$icon = $_POST['icon'] ?? null;
if ($icon === '') $icon = null;
$category_id = !empty($_POST['category_id']) ? (int)$_POST['category_id'] : null;
$rules_role_id = !empty($_POST['rules_role_id']) ? (int)$_POST['rules_role_id'] : null;
// Get next position
$stmtPos = db()->prepare("SELECT MAX(position) as max_pos FROM channels WHERE server_id = ?");
$stmtPos->execute([$server_id]);
$maxPos = $stmtPos->fetch();
$nextPos = ($maxPos['max_pos'] ?? -1) + 1;
$stmt = db()->prepare("INSERT INTO channels (server_id, name, type, allow_file_sharing, message_limit, icon, category_id, position, rules_role_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$server_id, $name, $type, $allow_file_sharing, $message_limit, $icon, $category_id, $nextPos, $rules_role_id]);
$channel_id = db()->lastInsertId();
header('Location: index.php?server_id=' . $server_id . '&channel_id=' . $channel_id);
exit;
} catch (Exception $e) {
die("Error creating channel: " . $e->getMessage());
}
}
}
header('Location: index.php');

46
api_v1_clear_channel.php Normal file
View File

@ -0,0 +1,46 @@
<?php
require_once __DIR__ . "/db/config.php";
require_once __DIR__ . "/includes/permissions.php";
session_start();
header("Content-Type: application/json");
if (!isset($_SESSION["user_id"])) {
echo json_encode(["success" => false, "error" => "Unauthorized"]);
exit;
}
$channel_id = $_POST["channel_id"] ?? null;
if (!$channel_id) {
echo json_encode(["success" => false, "error" => "Missing channel ID"]);
exit;
}
// Get server_id for this channel
$stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$channel = $stmt->fetch();
if (!$channel) {
echo json_encode(["success" => false, "error" => "Channel not found"]);
exit;
}
$server_id = $channel["server_id"];
// Check if user is owner or admin (minimal check for now)
$stmt = db()->prepare("SELECT owner_id FROM servers WHERE id = ?");
$stmt->execute([$server_id]);
$server = $stmt->fetch();
if (!Permissions::hasPermission($_SESSION["user_id"], $server_id, Permissions::MANAGE_CHANNELS)) {
echo json_encode(["success" => false, "error" => "Only moderators or admins can clear history"]);
exit;
}
try {
$stmt = db()->prepare("DELETE FROM messages WHERE channel_id = ?");
$stmt->execute([$channel_id]);
echo json_encode(["success" => true]);
} catch (Exception $e) {
echo json_encode(["success" => false, "error" => $e->getMessage()]);
}

67
api_v1_dms.php Normal file
View File

@ -0,0 +1,67 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
requireLogin();
$current_user_id = $_SESSION['user_id'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$target_user_id = $_POST['user_id'] ?? 0;
if ($target_user_id == $current_user_id) {
echo json_encode(['success' => false, 'error' => 'You cannot message yourself']);
exit;
}
try {
// Check if DM channel already exists between these two users
$stmt = db()->prepare("
SELECT c.id
FROM channels c
JOIN channel_members cm1 ON c.id = cm1.channel_id
JOIN channel_members cm2 ON c.id = cm2.channel_id
WHERE c.type = 'dm' AND cm1.user_id = ? AND cm2.user_id = ?
");
$stmt->execute([$current_user_id, $target_user_id]);
$existing = $stmt->fetch();
if ($existing) {
echo json_encode(['success' => true, 'channel_id' => $existing['id']]);
exit;
}
// Create new DM channel
$stmt = db()->prepare("INSERT INTO channels (server_id, name, type) VALUES (NULL, 'dm', 'dm')");
$stmt->execute();
$channel_id = db()->lastInsertId();
// Add both users to the channel
$stmt = db()->prepare("INSERT INTO channel_members (channel_id, user_id) VALUES (?, ?), (?, ?)");
$stmt->execute([$channel_id, $current_user_id, $channel_id, $target_user_id]);
echo json_encode(['success' => true, 'channel_id' => $channel_id]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// Fetch all DM channels for current user
try {
$stmt = db()->prepare("
SELECT c.id, u.display_name as other_user, u.username as login_name, u.avatar_url, u.status, u.id as other_user_id
FROM channels c
JOIN channel_members cm1 ON c.id = cm1.channel_id
JOIN channel_members cm2 ON c.id = cm2.channel_id
JOIN users u ON cm2.user_id = u.id
WHERE c.type = 'dm' AND cm1.user_id = ? AND cm2.user_id != ?
");
$stmt->execute([$current_user_id, $current_user_id]);
$dms = $stmt->fetchAll();
echo json_encode(['success' => true, 'dms' => $dms]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
}

321
api_v1_messages.php Normal file
View File

@ -0,0 +1,321 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
require_once 'includes/opengraph.php';
require_once 'includes/ai_filtering.php';
require_once 'includes/permissions.php';
// Check for Bot token in headers
$headers = getallheaders();
$bot_token = null;
if (isset($headers['Authorization']) && preg_match('/Bot\s+(\S+)/', $headers['Authorization'], $matches)) {
$bot_token = $matches[1];
}
$user_id = null;
if ($bot_token) {
$stmt = db()->prepare("SELECT id FROM users WHERE bot_token = ? AND is_bot = TRUE");
$stmt->execute([$bot_token]);
$bot = $stmt->fetch();
if ($bot) {
$user_id = $bot['id'];
} else {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Invalid Bot Token']);
exit;
}
} elseif (isset($_SESSION['user_id'])) {
$user_id = $_SESSION['user_id'];
} else {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$channel_id = $_GET['channel_id'] ?? 0;
$pinned = isset($_GET['pinned']) && $_GET['pinned'] == 1;
if ($pinned) {
try {
$thread_id = isset($_GET['thread_id']) && $_GET['thread_id'] !== '' ? (int)$_GET['thread_id'] : null;
// Get server_id for the channel
$stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$server_id = $stmt->fetchColumn();
$query = "
SELECT m.*, u.display_name as username, u.username as login_name, u.avatar_url,
(SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color,
(SELECT r.icon_url FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_icon
FROM messages m
JOIN users u ON m.user_id = u.id
WHERE m.channel_id = ? AND m.is_pinned = 1
";
$params = [$server_id ?: 0, $server_id ?: 0, $channel_id];
if ($thread_id !== null) {
$query .= " AND m.thread_id = ?";
$params[] = $thread_id;
} else {
$query .= " AND m.thread_id IS NULL";
}
$query .= " ORDER BY m.created_at DESC";
$stmt = db()->prepare($query);
$stmt->execute($params);
$msgs = $stmt->fetchAll();
foreach ($msgs as &$m) {
$m['time'] = date('H:i', strtotime($m['created_at']));
$m['metadata'] = $m['metadata'] ? json_decode($m['metadata']) : null;
}
echo json_encode(['success' => true, 'messages' => $msgs]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if (isset($_GET['after_id'])) {
try {
$after_id = (int)$_GET['after_id'];
$thread_id = isset($_GET['thread_id']) && $_GET['thread_id'] !== '' ? (int)$_GET['thread_id'] : null;
// Get server_id for the channel
$stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$server_id = $stmt->fetchColumn();
$query = "
SELECT m.*, u.display_name as username, u.username as login_name, u.avatar_url,
(SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color,
(SELECT r.icon_url FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_icon
FROM messages m
JOIN users u ON m.user_id = u.id
WHERE m.channel_id = ? AND m.id > ?
";
$params = [$server_id ?: 0, $server_id ?: 0, $channel_id, $after_id];
if ($thread_id !== null) {
$query .= " AND m.thread_id = ?";
$params[] = $thread_id;
} else {
$query .= " AND m.thread_id IS NULL";
}
$query .= " ORDER BY m.id ASC";
$stmt = db()->prepare($query);
$stmt->execute($params);
$msgs = $stmt->fetchAll();
foreach ($msgs as &$m) {
$m['time'] = date('H:i', strtotime($m['created_at']));
$m['metadata'] = $m['metadata'] ? json_decode($m['metadata']) : null;
}
echo json_encode(['success' => true, 'messages' => $msgs]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
}
if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
$data = json_decode(file_get_contents('php://input'), true);
$message_id = $data['id'] ?? 0;
$content = $data['content'] ?? '';
$action = $data['action'] ?? 'edit';
try {
if ($action === 'pin') {
$stmt = db()->prepare("UPDATE messages SET is_pinned = 1 WHERE id = ?");
$stmt->execute([$message_id]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'unpin') {
$stmt = db()->prepare("UPDATE messages SET is_pinned = 0 WHERE id = ?");
$stmt->execute([$message_id]);
echo json_encode(['success' => true]);
exit;
}
if (empty($content)) {
echo json_encode(['success' => false, 'error' => 'Content cannot be empty']);
exit;
}
$stmt = db()->prepare("UPDATE messages SET content = ? WHERE id = ? AND user_id = ?");
$stmt->execute([$content, $message_id, $user_id]);
if ($stmt->rowCount() > 0) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Message not found or unauthorized']);
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
$data = json_decode(file_get_contents('php://input'), true);
$message_id = $data['id'] ?? 0;
try {
$stmt = db()->prepare("DELETE FROM messages WHERE id = ? AND user_id = ?");
$stmt->execute([$message_id, $user_id]);
if ($stmt->rowCount() > 0) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Message not found or unauthorized']);
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
$content = '';
$channel_id = 0;
$thread_id = null;
$attachment_url = null;
if (strpos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') !== false) {
$data = json_decode(file_get_contents('php://input'), true);
$content = $data['content'] ?? '';
$channel_id = $data['channel_id'] ?? 0;
$thread_id = !empty($data['thread_id']) ? (int)$data['thread_id'] : null;
} else {
$content = $_POST['content'] ?? '';
$channel_id = $_POST['channel_id'] ?? 0;
$thread_id = !empty($_POST['thread_id']) ? (int)$_POST['thread_id'] : null;
// Check if file sharing is allowed in this channel
$stmt = db()->prepare("SELECT allow_file_sharing FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$channel = $stmt->fetch();
$can_share_files = $channel ? (bool)$channel['allow_file_sharing'] : true;
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
if (!$can_share_files) {
echo json_encode(['success' => false, 'error' => 'File sharing is disabled in this channel.']);
exit;
}
$upload_dir = 'assets/uploads/';
if (!is_dir($upload_dir)) mkdir($upload_dir, 0775, true);
$filename = time() . '_' . basename($_FILES['file']['name']);
$target_file = $upload_dir . $filename;
if (move_uploaded_file($_FILES['file']['tmp_name'], $target_file)) {
$attachment_url = $target_file;
}
}
}
if (empty($content) && empty($attachment_url)) {
echo json_encode(['success' => false, 'error' => 'Empty content and no attachment']);
exit;
}
// Check granular permissions
$can_send = Permissions::canSendInChannel($user_id, $channel_id);
if ($thread_id) {
// For threads, we check the specific thread permission instead of the general channel permission
$can_send = Permissions::canDoInChannel($user_id, $channel_id, Permissions::SEND_MESSAGES_IN_THREADS);
}
if (!$can_send) {
echo json_encode(['success' => false, 'error' => 'You do not have permission to send messages in this channel.']);
exit;
}
// Check if thread is locked
if ($thread_id) {
$stmtThread = db()->prepare("SELECT is_locked, channel_id, server_id FROM forum_threads t JOIN channels c ON t.channel_id = c.id WHERE t.id = ?");
$stmtThread->execute([$thread_id]);
$threadData = $stmtThread->fetch();
if ($threadData && $threadData['is_locked']) {
// Strict lock: no one can post, not even admins. Admins must unlock first.
echo json_encode(['success' => false, 'error' => 'This thread is locked. Messages are disabled.']);
exit;
}
}
if (!empty($content)) {
$moderation = moderateContent($content);
if (!$moderation['is_safe']) {
echo json_encode(['success' => false, 'error' => 'Message flagged as inappropriate: ' . ($moderation['reason'] ?? 'Violation of community standards')]);
exit;
}
}
$metadata = null;
if (!empty($content)) {
$urls = extractUrls($content);
if (!empty($urls)) {
// Fetch OG data for the first URL
$ogData = fetchOpenGraphData($urls[0]);
if ($ogData) {
$metadata = json_encode($ogData);
}
}
}
try {
$stmt = db()->prepare("INSERT INTO messages (channel_id, thread_id, user_id, content, attachment_url, metadata) VALUES (?, ?, ?, ?, ?, ?)");
$stmt->execute([$channel_id, $thread_id, $user_id, $content, $attachment_url, $metadata]);
$last_id = db()->lastInsertId();
// Update last activity for forum threads
if ($thread_id) {
$stmtActivity = db()->prepare("UPDATE forum_threads SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?");
$stmtActivity->execute([$thread_id]);
}
// Enforce message limit if set
enforceChannelLimit($channel_id);
// Get server_id for the channel
$stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$server_id = $stmt->fetchColumn();
// Fetch message with username and role color for the response
$stmt = db()->prepare("
SELECT m.*, u.display_name as username, u.username as login_name, u.avatar_url,
(SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color,
(SELECT r.icon_url FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_icon
FROM messages m
JOIN users u ON m.user_id = u.id
WHERE m.id = ?
");
$stmt->execute([$server_id ?: 0, $server_id ?: 0, $last_id]);
$msg = $stmt->fetch();
echo json_encode([
'success' => true,
'message' => [
'id' => $msg['id'],
'user_id' => $msg['user_id'],
'username' => $msg['username'], 'login_name' => $msg['login_name'],
'avatar_url' => $msg['avatar_url'],
'role_color' => $msg['role_color'],
'role_icon' => $msg['role_icon'],
'content' => $msg['content'],
'attachment_url' => $msg['attachment_url'],
'metadata' => $msg['metadata'] ? json_decode($msg['metadata']) : null,
'time' => date('H:i', strtotime($msg['created_at']))
]
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

58
api_v1_reactions.php Normal file
View File

@ -0,0 +1,58 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
$user_id = $_SESSION['user_id'];
$data = json_decode(file_get_contents('php://input'), true);
$message_id = $data['message_id'] ?? 0;
$emoji = $data['emoji'] ?? '';
$action = $data['action'] ?? 'toggle'; // 'toggle', 'add', 'remove'
if (!$message_id || !$emoji) {
echo json_encode(['success' => false, 'error' => 'Missing message_id or emoji']);
exit;
}
try {
if ($action === 'toggle') {
$stmt = db()->prepare("SELECT id FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?");
$stmt->execute([$message_id, $user_id, $emoji]);
if ($stmt->fetch()) {
$stmt = db()->prepare("DELETE FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?");
$stmt->execute([$message_id, $user_id, $emoji]);
$res_action = 'removed';
} else {
$stmt = db()->prepare("INSERT INTO message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)");
$stmt->execute([$message_id, $user_id, $emoji]);
$res_action = 'added';
}
} elseif ($action === 'add') {
$stmt = db()->prepare("INSERT IGNORE INTO message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)");
$stmt->execute([$message_id, $user_id, $emoji]);
$res_action = 'added';
} else {
$stmt = db()->prepare("DELETE FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?");
$stmt->execute([$message_id, $user_id, $emoji]);
$res_action = 'removed';
}
// Get updated reactions for this message
$stmt = db()->prepare("SELECT emoji, COUNT(*) as count, GROUP_CONCAT(user_id) as users FROM message_reactions WHERE message_id = ? GROUP BY emoji");
$stmt->execute([$message_id]);
$reactions = $stmt->fetchAll();
echo json_encode([
'success' => true,
'action' => $res_action,
'message_id' => $message_id,
'reactions' => $reactions
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

181
api_v1_roles.php Normal file
View File

@ -0,0 +1,181 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
require_once 'includes/permissions.php';
requireLogin();
$user_id = $_SESSION['user_id'];
$data = json_decode(file_get_contents('php://input'), true) ?? $_POST;
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$server_id = $_GET['server_id'] ?? 0;
if (!$server_id) {
echo json_encode(['success' => false, 'error' => 'Missing server_id']);
exit;
}
// Verify user is in server
$stmt = db()->prepare("SELECT * FROM server_members WHERE server_id = ? AND user_id = ?");
$stmt->execute([$server_id, $user_id]);
if (!$stmt->fetch()) {
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
$stmt = db()->prepare("SELECT * FROM roles WHERE server_id = ? ORDER BY position DESC");
$stmt->execute([$server_id]);
$roles = $stmt->fetchAll();
// Fetch members and their roles
$stmt = db()->prepare("
SELECT u.id, u.display_name as username, u.username as login_name, u.avatar_url,
GROUP_CONCAT(r.id) as role_ids,
GROUP_CONCAT(r.name) as role_names,
(SELECT r2.color FROM roles r2 JOIN user_roles ur2 ON r2.id = ur2.role_id WHERE ur2.user_id = u.id AND r2.server_id = ? ORDER BY r2.position DESC LIMIT 1) as role_color,
(SELECT r2.icon_url FROM roles r2 JOIN user_roles ur2 ON r2.id = ur2.role_id WHERE ur2.user_id = u.id AND r2.server_id = ? ORDER BY r2.position DESC LIMIT 1) as role_icon
FROM users u
JOIN server_members sm ON u.id = sm.user_id
LEFT JOIN user_roles ur ON u.id = ur.user_id
LEFT JOIN roles r ON ur.role_id = r.id AND r.server_id = ?
WHERE sm.server_id = ?
GROUP BY u.id
");
$stmt->execute([$server_id, $server_id, $server_id, $server_id]);
$members = $stmt->fetchAll();
$filtered_members = null;
$channel_id = $_GET['channel_id'] ?? 0;
if ($channel_id) {
$filtered_members = [];
foreach ($members as $m) {
if (Permissions::canViewChannel($m['id'], $channel_id)) {
$filtered_members[] = $m;
}
}
}
echo json_encode([
'success' => true,
'roles' => $roles,
'members' => $members,
'filtered_members' => $filtered_members,
'permissions_list' => [
['value' => 1, 'name' => 'View Channels'],
['value' => 2, 'name' => 'Send Messages'],
['value' => 4, 'name' => 'Manage Messages'],
['value' => 8, 'name' => 'Manage Channels'],
['value' => 16, 'name' => 'Manage Server'],
['value' => 32, 'name' => 'Administrator'],
['value' => 64, 'name' => 'Create Thread'],
['value' => 128, 'name' => 'Manage Tags'],
['value' => 256, 'name' => 'Pin Threads'],
['value' => 512, 'name' => 'Lock Threads'],
['value' => 1024, 'name' => 'Send Messages in Threads'],
['value' => 2048, 'name' => 'Speak']
]
]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $data['action'] ?? '';
$server_id = $data['server_id'] ?? 0;
// Permissions check: Owner or MANAGE_SERVER
$stmt = db()->prepare("SELECT owner_id FROM servers WHERE id = ?");
$stmt->execute([$server_id]);
$server = $stmt->fetch();
$is_owner = ($server && $server['owner_id'] == $user_id);
$can_manage = Permissions::hasPermission($user_id, $server_id, Permissions::MANAGE_SERVER) || Permissions::hasPermission($user_id, $server_id, Permissions::ADMINISTRATOR);
if (!$is_owner && !$can_manage) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
if ($action === 'create') {
$name = $data['name'] ?? 'New Role';
$color = $data['color'] ?? '#99aab5';
$perms = $data['permissions'] ?? 0;
$stmt = db()->prepare("INSERT INTO roles (server_id, name, color, permissions) VALUES (?, ?, ?, ?)");
$stmt->execute([$server_id, $name, $color, $perms]);
echo json_encode(['success' => true, 'role_id' => db()->lastInsertId()]);
} elseif ($action === 'update') {
$role_id = $data['id'] ?? 0;
$name = $data['name'] ?? '';
$color = $data['color'] ?? '';
$icon_url = $data['icon_url'] ?? null;
$perms = $data['permissions'] ?? 0;
$stmt = db()->prepare("UPDATE roles SET name = ?, color = ?, icon_url = ?, permissions = ? WHERE id = ? AND server_id = ?");
$stmt->execute([$name, $color, $icon_url, $perms, $role_id, $server_id]);
echo json_encode(['success' => true]);
} elseif ($action === 'delete') {
$role_id = $data['id'] ?? 0;
$stmt = db()->prepare("DELETE FROM roles WHERE id = ? AND server_id = ?");
$stmt->execute([$role_id, $server_id]);
echo json_encode(['success' => true]);
} elseif ($action === 'assign') {
$target_user_id = $data['user_id'] ?? 0;
$role_id = $data['role_id'] ?? 0;
// Verify role belongs to server
$stmt = db()->prepare("SELECT id FROM roles WHERE id = ? AND server_id = ?");
$stmt->execute([$role_id, $server_id]);
if (!$stmt->fetch()) {
echo json_encode(['success' => false, 'error' => 'Invalid role']);
exit;
}
$stmt = db()->prepare("INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)");
$stmt->execute([$target_user_id, $role_id]);
echo json_encode(['success' => true]);
} elseif ($action === 'unassign') {
$target_user_id = $data['user_id'] ?? 0;
$role_id = $data['role_id'] ?? 0;
$stmt = db()->prepare("DELETE ur FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = ? AND ur.role_id = ? AND r.server_id = ?");
$stmt->execute([$target_user_id, $role_id, $server_id]);
echo json_encode(['success' => true]);
} elseif ($action === 'reorder') {
$orders = $data['orders'] ?? [];
foreach ($orders as $order) {
$stmt = db()->prepare("UPDATE roles SET position = ? WHERE id = ? AND server_id = ?");
$stmt->execute([$order['position'], $order['id'], $server_id]);
}
echo json_encode(['success' => true]);
} elseif ($action === 'set_user_roles') {
$target_user_id = $data['user_id'] ?? 0;
$role_ids = $data['role_ids'] ?? [];
// Begin transaction
$db = db();
$db->beginTransaction();
try {
// Remove all existing roles for this user in this server
$stmt = $db->prepare("DELETE ur FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = ? AND r.server_id = ?");
$stmt->execute([$target_user_id, $server_id]);
// Add new roles
if (!empty($role_ids)) {
$stmt = $db->prepare("INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)");
foreach ($role_ids as $rid) {
// Verify role belongs to server
$check = $db->prepare("SELECT id FROM roles WHERE id = ? AND server_id = ?");
$check->execute([$rid, $server_id]);
if ($check->fetch()) {
$stmt->execute([$target_user_id, $rid]);
}
}
}
$db->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
$db->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
}
exit;
}

189
api_v1_rss.php Normal file
View File

@ -0,0 +1,189 @@
<?php
require_once 'auth/session.php';
require_once 'includes/permissions.php';
requireLogin();
$user = getCurrentUser();
$action = $_REQUEST['action'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$channel_id = $_POST['channel_id'] ?? 0;
// Permission check: must have manage channels permission
$stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$chan = $stmt->fetch();
if (!$chan || !Permissions::hasPermission($user['id'], $chan['server_id'], Permissions::MANAGE_CHANNELS)) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
if ($action === 'add') {
$url = $_POST['url'] ?? '';
if (!filter_var($url, FILTER_VALIDATE_URL)) {
echo json_encode(['success' => false, 'error' => 'Invalid URL']);
exit;
}
$stmt = db()->prepare("INSERT INTO channel_rss_feeds (channel_id, url) VALUES (?, ?)");
$stmt->execute([$channel_id, $url]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'delete') {
$feed_id = $_POST['feed_id'] ?? 0;
$stmt = db()->prepare("DELETE FROM channel_rss_feeds WHERE id = ? AND channel_id = ?");
$stmt->execute([$feed_id, $channel_id]);
echo json_encode(['success' => true]);
exit;
}
if ($action === 'sync') {
// Cooldown check: only sync if last sync was > 5 minutes ago
// Or if it's a forced sync from the settings UI
$is_auto = isset($_POST['auto']) && $_POST['auto'] == 1;
// Fetch feeds for this channel
$stmt = db()->prepare("SELECT * FROM channel_rss_feeds WHERE channel_id = ?");
$stmt->execute([$channel_id]);
$feeds = $stmt->fetchAll();
if ($is_auto) {
$last_fetch = 0;
foreach ($feeds as $f) {
if ($f['last_fetched_at']) {
$ts = strtotime($f['last_fetched_at']);
if ($ts > $last_fetch) $last_fetch = $ts;
}
}
if (time() - $last_fetch < 120) { // 2 minutes to match JS
echo json_encode(['success' => true, 'new_items' => 0, 'skipped' => true]);
exit;
}
}
$new_items_count = 0;
foreach ($feeds as $feed) {
$rss_content = @file_get_contents($feed['url']);
if (!$rss_content) continue;
$xml = @simplexml_load_string($rss_content);
if (!$xml) continue;
$items = [];
if (isset($xml->channel->item)) { // RSS 2.0
$items = $xml->channel->item;
} elseif (isset($xml->entry)) { // Atom
$items = $xml->entry;
}
$feed_items = [];
foreach ($items as $item) {
$feed_items[] = $item;
}
$feed_items = array_reverse($feed_items);
foreach ($feed_items as $item) {
$guid = (string)($item->guid ?? ($item->id ?? $item->link));
if (empty($guid) && isset($item->link['href'])) {
$guid = (string)$item->link['href'];
}
$title = (string)$item->title;
$link = (string)($item->link['href'] ?? $item->link);
if (empty($link) && isset($item->link)) {
foreach($item->link as $l) {
if ($l['rel'] == 'alternate' || !$l['rel']) {
$link = (string)$l['href'];
break;
}
}
}
$description = (string)($item->description ?? ($item->summary ?? $item->content));
$description = strip_tags($description);
// Extract additional fields
$category = (string)($item->category ?? ($item->category['term'] ?? ''));
$pubDate = (string)($item->pubDate ?? ($item->published ?? ($item->updated ?? '')));
// Format date nicely if possible
if ($pubDate) {
$timestamp = strtotime($pubDate);
if ($timestamp) {
$pubDate = date('d/m/Y H:i', $timestamp);
}
}
$author = (string)($item->author->name ?? ($item->author ?? ''));
if (!$author) {
$dc = $item->children('http://purl.org/dc/elements/1.1/');
if (isset($dc->creator)) {
$author = (string)$dc->creator;
}
}
// Check if already exists in processed items (prevents re-posting if deleted by retention policy)
$stmt_check = db()->prepare("SELECT id FROM rss_processed_items WHERE channel_id = ? AND rss_guid = ?");
$stmt_check->execute([$channel_id, $guid]);
if ($stmt_check->fetch()) continue;
// Insert into processed items first to be safe
$stmt_processed = db()->prepare("INSERT IGNORE INTO rss_processed_items (channel_id, rss_guid) VALUES (?, ?)");
$stmt_processed->execute([$channel_id, $guid]);
// Insert as message from a special "RSS Bot" user or system
$stmt_bot = db()->prepare("SELECT id FROM users WHERE username = 'RSS Bot' AND is_bot = 1");
$stmt_bot->execute();
$bot = $stmt_bot->fetch();
if (!$bot) {
$stmt_create_bot = db()->prepare("INSERT INTO users (username, display_name, is_bot, status, avatar_url, email, password_hash) VALUES ('RSS Bot', 'RSS Bot', 1, 'online', 'https://cdn-icons-png.flaticon.com/512/3607/3607436.png', 'rss-bot@system.internal', 'bot-no-password')");
$stmt_create_bot->execute();
$bot_id = db()->lastInsertId();
} else {
$bot_id = $bot['id'];
}
// Format content for traditional view
$content = "[" . $title . "](" . $link . ")\n";
if ($category || $pubDate || $author) {
$parts = [];
if ($category) $parts[] = $category;
if ($pubDate) $parts[] = $pubDate;
if ($author) $parts[] = $author;
$content .= implode(" · ", $parts);
}
$metadata = json_encode([
'title' => $title,
'description' => mb_substr($description, 0, 500) . (mb_strlen($description) > 500 ? '...' : ''),
'url' => $link,
'category' => $category,
'date' => $pubDate,
'author' => $author,
'is_rss' => true,
'site_name' => parse_url($feed['url'], PHP_URL_HOST)
]);
$stmt_msg = db()->prepare("INSERT INTO messages (channel_id, user_id, content, metadata, rss_guid) VALUES (?, ?, ?, ?, ?)");
$stmt_msg->execute([$channel_id, $bot_id, $content, $metadata, $guid]);
enforceChannelLimit($channel_id);
$new_items_count++;
}
$stmt_update_feed = db()->prepare("UPDATE channel_rss_feeds SET last_fetched_at = CURRENT_TIMESTAMP WHERE id = ?");
$stmt_update_feed->execute([$feed['id']]);
}
echo json_encode(['success' => true, 'new_items' => $new_items_count, 'channel_id' => $channel_id]);
exit;
}
} else {
// GET: List feeds
$channel_id = $_GET['channel_id'] ?? 0;
$stmt = db()->prepare("SELECT * FROM channel_rss_feeds WHERE channel_id = ? ORDER BY created_at DESC");
$stmt->execute([$channel_id]);
echo json_encode(['success' => true, 'feeds' => $stmt->fetchAll()]);
exit;
}

125
api_v1_rules.php Normal file
View File

@ -0,0 +1,125 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
require_once 'includes/permissions.php';
requireLogin();
$user_id = $_SESSION['user_id'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$channel_id = $_POST['channel_id'] ?? 0;
$content = $_POST['content'] ?? '';
// Check if user has permission to manage channels
$stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$chan = $stmt->fetch();
if (!$chan || !Permissions::hasPermission($user_id, $chan['server_id'], Permissions::MANAGE_CHANNELS)) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
try {
// Get max position
$stmt = db()->prepare("SELECT MAX(position) FROM channel_rules WHERE channel_id = ?");
$stmt->execute([$channel_id]);
$pos = (int)$stmt->fetchColumn() + 1;
$stmt = db()->prepare("INSERT INTO channel_rules (channel_id, content, position) VALUES (?, ?, ?)");
$stmt->execute([$channel_id, $content, $pos]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
$id = $_GET['id'] ?? 0;
$stmt = db()->prepare("SELECT c.server_id FROM channels c JOIN channel_rules r ON c.id = r.channel_id WHERE r.id = ?");
$stmt->execute([$id]);
$res = $stmt->fetch();
if ($res && Permissions::hasPermission($user_id, $res['server_id'], Permissions::MANAGE_CHANNELS)) {
$stmt = db()->prepare("DELETE FROM channel_rules WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'PATCH') {
$data = json_decode(file_get_contents('php://input'), true);
if (isset($data['order'])) {
// Bulk reorder
foreach ($data['order'] as $index => $id) {
// Basic permission check (optional but recommended: verify all rules belong to same server user can manage)
if ($index === 0) {
$stmt = db()->prepare("SELECT c.server_id FROM channels c JOIN channel_rules r ON c.id = r.channel_id WHERE r.id = ?");
$stmt->execute([$id]);
$res = $stmt->fetch();
if (!$res || !Permissions::hasPermission($user_id, $res['server_id'], Permissions::MANAGE_CHANNELS)) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
}
$stmt = db()->prepare("UPDATE channel_rules SET position = ? WHERE id = ?");
$stmt->execute([$index + 1, $id]);
}
echo json_encode(['success' => true]);
exit;
}
$id = $data['id'] ?? 0;
$dir = $data['dir'] ?? 'up';
// Check permission
$stmt = db()->prepare("SELECT c.server_id, r.channel_id, r.position FROM channels c JOIN channel_rules r ON c.id = r.channel_id WHERE r.id = ?");
$stmt->execute([$id]);
$current = $stmt->fetch();
if ($current && Permissions::hasPermission($user_id, $current['server_id'], Permissions::MANAGE_CHANNELS)) {
$channel_id = $current['channel_id'];
$pos = $current['position'];
if ($dir === 'up') {
$stmt = db()->prepare("SELECT id, position FROM channel_rules WHERE channel_id = ? AND position < ? ORDER BY position DESC LIMIT 1");
} else {
$stmt = db()->prepare("SELECT id, position FROM channel_rules WHERE channel_id = ? AND position > ? ORDER BY position ASC LIMIT 1");
}
$stmt->execute([$channel_id, $pos]);
$other = $stmt->fetch();
if ($other) {
db()->prepare("UPDATE channel_rules SET position = ? WHERE id = ?")->execute([$other['position'], $id]);
db()->prepare("UPDATE channel_rules SET position = ? WHERE id = ?")->execute([$pos, $other['id']]);
}
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Rule not found']);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
$data = json_decode(file_get_contents('php://input'), true);
$id = $data['id'] ?? 0;
$content = $data['content'] ?? '';
$stmt = db()->prepare("SELECT c.server_id FROM channels c JOIN channel_rules r ON c.id = r.channel_id WHERE r.id = ?");
$stmt->execute([$id]);
$res = $stmt->fetch();
if ($res && Permissions::hasPermission($user_id, $res['server_id'], Permissions::MANAGE_CHANNELS)) {
$stmt = db()->prepare("UPDATE channel_rules SET content = ? WHERE id = ?");
$stmt->execute([$content, $id]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
}
exit;
}

57
api_v1_search.php Normal file
View File

@ -0,0 +1,57 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
requireLogin();
$user_id = $_SESSION['user_id'];
$query = $_GET['q'] ?? '';
$type = $_GET['type'] ?? 'messages'; // messages or users
$channel_id = $_GET['channel_id'] ?? 0;
if (empty($query)) {
echo json_encode(['success' => true, 'results' => []]);
exit;
}
try {
if ($type === 'users') {
$stmt = db()->prepare("
SELECT id, display_name as username, username as login_name, avatar_url, status
FROM users
WHERE username LIKE ? OR display_name LIKE ?
LIMIT 20
");
$stmt->execute(["%" . $query . "%", "%" . $query . "%"]);
$results = $stmt->fetchAll();
} else {
$sql = "SELECT m.*, u.display_name as username, u.username as login_name, u.avatar_url
FROM messages m
JOIN users u ON m.user_id = u.id
WHERE m.content LIKE ? ";
$params = ["%" . $query . "%"];
if ($channel_id > 0) {
$sql .= " AND m.channel_id = ?";
$params[] = $channel_id;
} else {
// Search in all channels user has access to
$sql .= " AND m.channel_id IN (
SELECT c.id FROM channels c
LEFT JOIN server_members sm ON c.server_id = sm.server_id
LEFT JOIN channel_members cm ON c.id = cm.channel_id
WHERE sm.user_id = ? OR cm.user_id = ?
)";
$params[] = $user_id;
$params[] = $user_id;
}
$sql .= " ORDER BY m.created_at DESC LIMIT 50";
$stmt = db()->prepare($sql);
$stmt->execute($params);
$results = $stmt->fetchAll();
}
echo json_encode(['success' => true, 'results' => $results]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

90
api_v1_servers.php Normal file
View File

@ -0,0 +1,90 @@
<?php
require_once 'auth/session.php';
require_once 'includes/utils.php';
requireLogin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'create';
$user_id = $_SESSION['user_id'];
if ($action === 'join') {
$invite_code = $_POST['invite_code'] ?? '';
$stmt = db()->prepare("SELECT id, invite_code_expires_at FROM servers WHERE invite_code = ?");
$stmt->execute([$invite_code]);
$server = $stmt->fetch();
if ($server) {
if ($server['invite_code_expires_at'] && strtotime($server['invite_code_expires_at']) < time()) {
die("This invite code has expired.");
}
$stmt = db()->prepare("INSERT IGNORE INTO server_members (server_id, user_id) VALUES (?, ?)");
$stmt->execute([$server['id'], $user_id]);
header('Location: index.php?server_id=' . $server['id']);
exit;
} else {
die("Invalid invite code.");
}
}
if ($action === 'update') {
$server_id = $_POST['server_id'] ?? 0;
$name = $_POST['name'] ?? '';
$icon_url = $_POST['icon_url'] ?? '';
$theme_color = $_POST['theme_color'] ?? null;
if ($theme_color === '') $theme_color = null;
require_once 'includes/permissions.php';
if (Permissions::hasPermission($user_id, $server_id, Permissions::MANAGE_SERVER)) {
$stmt = db()->prepare("UPDATE servers SET name = ?, icon_url = ?, theme_color = ? WHERE id = ?");
$stmt->execute([$name, $icon_url, $theme_color, $server_id]);
}
header('Location: index.php?server_id=' . $server_id);
exit;
}
if ($action === 'delete') {
$server_id = $_POST['server_id'] ?? 0;
$stmt = db()->prepare("DELETE FROM servers WHERE id = ? AND owner_id = ?");
$stmt->execute([$server_id, $user_id]);
header('Location: index.php');
exit;
}
$name = $_POST['name'] ?? '';
$icon_url = $_POST['icon_url'] ?? '';
if ($name) {
try {
$db = db();
$db->beginTransaction();
// Create server
$invite_code = generateInviteCode();
$expires_at = date('Y-m-d H:i:s', time() + 1800); // 30 minutes
$stmt = $db->prepare("INSERT INTO servers (name, owner_id, invite_code, icon_url, invite_code_expires_at) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$name, $user_id, $invite_code, $icon_url, $expires_at]);
$server_id = $db->lastInsertId();
// Add owner as member
$stmt = $db->prepare("INSERT INTO server_members (server_id, user_id) VALUES (?, ?)");
$stmt->execute([$server_id, $user_id]);
// Create default channel
$stmt = $db->prepare("INSERT INTO channels (server_id, name, type) VALUES (?, 'general', 'text')");
$stmt->execute([$server_id]);
// Create default @everyone role
$stmt = $db->prepare("INSERT INTO roles (server_id, name, color, permissions, position) VALUES (?, '@everyone', '#99aab5', 0, 0)");
$stmt->execute([$server_id]);
$db->commit();
header('Location: index.php?server_id=' . $server_id);
exit;
} catch (Exception $e) {
$db->rollBack();
die("Error creating server: " . $e->getMessage());
}
}
}
header('Location: index.php');

74
api_v1_stats.php Normal file
View File

@ -0,0 +1,74 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
$server_id = $_GET['server_id'] ?? 0;
if (!$server_id) {
echo json_encode(['success' => false, 'error' => 'Server ID required']);
exit;
}
$user_id = $_SESSION['user_id'];
// Check if user is member of the server
$stmt = db()->prepare("SELECT 1 FROM server_members WHERE server_id = ? AND user_id = ?");
$stmt->execute([$server_id, $user_id]);
if (!$stmt->fetch()) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
try {
// Total members
$stmt = db()->prepare("SELECT COUNT(*) as count FROM server_members WHERE server_id = ?");
$stmt->execute([$server_id]);
$total_members = $stmt->fetch()['count'];
// Total messages in all channels of this server
$stmt = db()->prepare("
SELECT COUNT(*) as count
FROM messages m
JOIN channels c ON m.channel_id = c.id
WHERE c.server_id = ?
");
$stmt->execute([$server_id]);
$total_messages = $stmt->fetch()['count'];
// Messages per day (last 7 days)
$stmt = db()->prepare("
SELECT DATE(m.created_at) as date, COUNT(*) as count
FROM messages m
JOIN channels c ON m.channel_id = c.id
WHERE c.server_id = ? AND m.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY DATE(m.created_at)
ORDER BY date ASC
");
$stmt->execute([$server_id]);
$history = $stmt->fetchAll();
// Top active users
$stmt = db()->prepare("
SELECT u.display_name as username, u.username as login_name, COUNT(*) as message_count
FROM messages m
JOIN channels c ON m.channel_id = c.id
JOIN users u ON m.user_id = u.id
WHERE c.server_id = ?
GROUP BY m.user_id
ORDER BY message_count DESC
LIMIT 5
");
$stmt->execute([$server_id]);
$top_users = $stmt->fetchAll();
echo json_encode([
'success' => true,
'stats' => [
'total_members' => $total_members,
'total_messages' => $total_messages,
'history' => $history,
'top_users' => $top_users
]
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

54
api_v1_tags.php Normal file
View File

@ -0,0 +1,54 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
require_once 'includes/permissions.php';
requireLogin();
$user_id = $_SESSION['user_id'];
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$channel_id = $_GET['channel_id'] ?? 0;
if (!$channel_id) {
echo json_encode(['success' => false, 'error' => 'Missing channel_id']);
exit;
}
$stmt = db()->prepare("SELECT * FROM forum_tags WHERE channel_id = ?");
$stmt->execute([$channel_id]);
echo json_encode(['success' => true, 'tags' => $stmt->fetchAll()]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) $data = $_POST;
$action = $data['action'] ?? 'create';
$channel_id = $data['channel_id'] ?? 0;
// Check permissions
$stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$chan = $stmt->fetch();
if (!$chan || !Permissions::canDoInChannel($user_id, $channel_id, Permissions::MANAGE_TAGS)) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
if ($action === 'create') {
$name = $data['name'] ?? '';
$color = $data['color'] ?? '#7289da';
if (!$name) {
echo json_encode(['success' => false, 'error' => 'Missing name']);
exit;
}
$stmt = db()->prepare("INSERT INTO forum_tags (channel_id, name, color) VALUES (?, ?, ?)");
$stmt->execute([$channel_id, $name, $color]);
echo json_encode(['success' => true, 'tag_id' => db()->lastInsertId()]);
} elseif ($action === 'delete') {
$tag_id = $data['tag_id'] ?? 0;
$stmt = db()->prepare("DELETE FROM forum_tags WHERE id = ? AND channel_id = ?");
$stmt->execute([$tag_id, $channel_id]);
echo json_encode(['success' => true]);
}
exit;
}

136
api_v1_threads.php Normal file
View File

@ -0,0 +1,136 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
require_once 'includes/permissions.php';
requireLogin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$channel_id = $_POST['channel_id'] ?? 0;
$title = $_POST['title'] ?? '';
$user_id = $_SESSION['user_id'];
if (!$channel_id || !$title) {
echo json_encode(['success' => false, 'error' => 'Missing data']);
exit;
}
if (!Permissions::canDoInChannel($user_id, $channel_id, Permissions::CREATE_THREAD)) {
echo json_encode(['success' => false, 'error' => 'You do not have permission to create threads in this channel.']);
exit;
}
$tag_ids = $_POST['tag_ids'] ?? [];
if (is_string($tag_ids)) {
$tag_ids = array_filter(explode(',', $tag_ids));
}
try {
db()->beginTransaction();
$stmt = db()->prepare("INSERT INTO forum_threads (channel_id, user_id, title) VALUES (?, ?, ?)");
$stmt->execute([$channel_id, $user_id, $title]);
$thread_id = db()->lastInsertId();
if (!empty($tag_ids)) {
$stmtTag = db()->prepare("INSERT INTO thread_tags (thread_id, tag_id) VALUES (?, ?)");
foreach ($tag_ids as $tag_id) {
if ($tag_id) $stmtTag->execute([$thread_id, $tag_id]);
}
}
db()->commit();
echo json_encode(['success' => true, 'thread_id' => $thread_id]);
} catch (Exception $e) {
db()->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'PATCH' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || (isset($_GET['action']) && in_array($_GET['action'], ['solve', 'pin', 'unpin', 'lock', 'unlock', 'delete']))) {
$data = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$thread_id = $data['thread_id'] ?? $_GET['thread_id'] ?? 0;
$message_id = $data['message_id'] ?? null;
$action = $_GET['action'] ?? $data['action'] ?? 'solve';
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
$action = 'delete';
}
$user_id = $_SESSION['user_id'];
if (!$thread_id) {
echo json_encode(['success' => false, 'error' => 'Missing thread_id']);
exit;
}
// Verify permission (thread owner or admin)
$stmt = db()->prepare("SELECT t.*, c.server_id FROM forum_threads t JOIN channels c ON t.channel_id = c.id WHERE t.id = ?");
$stmt->execute([$thread_id]);
$thread = $stmt->fetch();
if (!$thread) {
echo json_encode(['success' => false, 'error' => 'Thread not found']);
exit;
}
$stmtServer = db()->prepare("SELECT owner_id FROM servers WHERE id = ?");
$stmtServer->execute([$thread['server_id']]);
$server = $stmtServer->fetch();
$is_admin = Permissions::hasPermission($user_id, $thread['server_id'], Permissions::ADMINISTRATOR) ||
Permissions::hasPermission($user_id, $thread['server_id'], Permissions::MANAGE_SERVER) ||
Permissions::hasPermission($user_id, $thread['server_id'], Permissions::MANAGE_MESSAGES) ||
$server['owner_id'] == $user_id;
try {
if ($action === 'solve') {
if ($thread['user_id'] != $user_id && !$is_admin) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']); exit;
}
$stmt = db()->prepare("UPDATE forum_threads SET solution_message_id = ? WHERE id = ?");
$stmt->execute([$message_id, $thread_id]);
} elseif ($action === 'pin') {
if (!Permissions::canDoInChannel($user_id, $thread['channel_id'], Permissions::PIN_THREADS)) {
echo json_encode(['success' => false, 'error' => 'You do not have permission to pin threads.']); exit;
}
$stmt = db()->prepare("UPDATE forum_threads SET is_pinned = 1 WHERE id = ?");
$stmt->execute([$thread_id]);
} elseif ($action === 'unpin') {
if (!Permissions::canDoInChannel($user_id, $thread['channel_id'], Permissions::PIN_THREADS)) {
echo json_encode(['success' => false, 'error' => 'You do not have permission to unpin threads.']); exit;
}
$stmt = db()->prepare("UPDATE forum_threads SET is_pinned = 0 WHERE id = ?");
$stmt->execute([$thread_id]);
} elseif ($action === 'lock') {
if (!Permissions::canDoInChannel($user_id, $thread['channel_id'], Permissions::LOCK_THREADS)) {
echo json_encode(['success' => false, 'error' => 'You do not have permission to lock threads.']); exit;
}
$stmt = db()->prepare("UPDATE forum_threads SET is_locked = 1 WHERE id = ?");
$stmt->execute([$thread_id]);
} elseif ($action === 'unlock') {
if (!Permissions::canDoInChannel($user_id, $thread['channel_id'], Permissions::LOCK_THREADS)) {
echo json_encode(['success' => false, 'error' => 'You do not have permission to unlock threads.']); exit;
}
$stmt = db()->prepare("UPDATE forum_threads SET is_locked = 0 WHERE id = ?");
$stmt->execute([$thread_id]);
} elseif ($action === 'delete') {
if ($thread['user_id'] != $user_id && !$is_admin) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']); exit;
}
db()->beginTransaction();
// Delete associated tags
$stmt = db()->prepare("DELETE FROM thread_tags WHERE thread_id = ?");
$stmt->execute([$thread_id]);
// Delete associated messages
$stmt = db()->prepare("DELETE FROM messages WHERE thread_id = ?");
$stmt->execute([$thread_id]);
// Delete thread
$stmt = db()->prepare("DELETE FROM forum_threads WHERE id = ?");
$stmt->execute([$thread_id]);
db()->commit();
}
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}

43
api_v1_user.php Normal file
View File

@ -0,0 +1,43 @@
<?php
require_once 'auth/session.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$action = $_GET['action'] ?? '';
if ($action === 'list_all') {
$stmt = db()->query("SELECT id, username, display_name FROM users ORDER BY username ASC");
echo json_encode(['success' => true, 'users' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
exit;
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user = getCurrentUser();
if (!$user) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
$display_name = !empty($_POST['display_name']) ? $_POST['display_name'] : $user['display_name'];
$avatar_url = isset($_POST['avatar_url']) ? $_POST['avatar_url'] : $user['avatar_url'];
$dnd_mode = isset($_POST['dnd_mode']) ? (int)$_POST['dnd_mode'] : 0;
$sound_notifications = isset($_POST['sound_notifications']) ? (int)$_POST['sound_notifications'] : 0;
$theme = !empty($_POST['theme']) ? $_POST['theme'] : $user['theme'];
$voice_mode = !empty($_POST['voice_mode']) ? $_POST['voice_mode'] : ($user['voice_mode'] ?? 'vox');
$voice_ptt_key = !empty($_POST['voice_ptt_key']) ? $_POST['voice_ptt_key'] : ($user['voice_ptt_key'] ?? 'v');
$voice_vox_threshold = isset($_POST['voice_vox_threshold']) ? (float)$_POST['voice_vox_threshold'] : ($user['voice_vox_threshold'] ?? 0.1);
$voice_echo_cancellation = isset($_POST['voice_echo_cancellation']) ? (int)$_POST['voice_echo_cancellation'] : ($user['voice_echo_cancellation'] ?? 1);
$voice_noise_suppression = isset($_POST['voice_noise_suppression']) ? (int)$_POST['voice_noise_suppression'] : ($user['voice_noise_suppression'] ?? 1);
try {
$stmt = db()->prepare("UPDATE users SET display_name = ?, avatar_url = ?, dnd_mode = ?, sound_notifications = ?, theme = ?, voice_mode = ?, voice_ptt_key = ?, voice_vox_threshold = ?, voice_echo_cancellation = ?, voice_noise_suppression = ? WHERE id = ?");
$stmt->execute([$display_name, $avatar_url, $dnd_mode, $sound_notifications, $theme, $voice_mode, $voice_ptt_key, $voice_vox_threshold, $voice_echo_cancellation, $voice_noise_suppression, $user['id']]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
echo json_encode(['success' => false, 'error' => 'Invalid request']);

271
api_v1_voice.php Normal file
View File

@ -0,0 +1,271 @@
<?php // Vocal secours — WebRTC P2P + signalisation PHP + participants + PTT + VOX (gating via GainNode)
// Mutualisé OVH : nécessite ./data écrivable.
declare(strict_types=1);
require_once "auth/session.php";
require_once "includes/permissions.php";
header("X-Content-Type-Options: nosniff");
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
$user = getCurrentUser();
$current_user_id = $user ? (int)$user["id"] : 0;
function room_id(string $s): string {
$s = preg_replace("~[^a-zA-Z0-9_\-]~", "", $s);
return $s !== "" ? $s : "secours";
}
function peer_id(): string {
return bin2hex(random_bytes(8));
}
function now_ms(): int {
return (int) floor(microtime(true) * 1000);
}
function json_out($data, int $code = 200): void {
http_response_code($code);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function get_room_participants(string $room): array {
$ps = [];
try {
$stale_time = now_ms() - 30000;
// Clean up stale sessions first
db()->prepare("DELETE FROM voice_sessions WHERE last_seen < ?")->execute([$stale_time]);
$stmt = db()->prepare("
SELECT vs.peer_id as id, vs.user_id, vs.name, vs.last_seen, vs.is_muted, vs.is_deafened, u.avatar_url
FROM voice_sessions vs
LEFT JOIN users u ON vs.user_id = u.id
WHERE vs.channel_id = ? AND vs.last_seen > ?
");
$stmt->execute([$room, $stale_time]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$r['user_id'] = (int)$r['user_id'];
$r['last_seen'] = (int)$r['last_seen'];
$r['is_muted'] = (int)$r['is_muted'];
$r['is_deafened'] = (int)$r['is_deafened'];
$ps[$r['id']] = $r;
}
} catch (Exception $e) {
error_log("get_room_participants error: " . $e->getMessage());
}
return $ps;
}
// Logic for signaling
$action = $_REQUEST["action"] ?? "";
$room = room_id($_REQUEST["room"] ?? "secours");
$my_id = $_REQUEST["peer_id"] ?? "";
if ($action === "join") {
$name = $_REQUEST["name"] ?? "User";
$new_id = substr($_REQUEST["peer_id"] ?: peer_id(), 0, 16);
// DB Integration for sidebar and participation
if ($current_user_id > 0) {
try {
$stmt = db()->prepare("INSERT INTO voice_sessions (user_id, channel_id, last_seen, peer_id, name) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE channel_id = ?, last_seen = ?, peer_id = ?, name = ?");
$stmt->execute([$current_user_id, $room, now_ms(), $new_id, $name, $room, now_ms(), $new_id, $name]);
} catch (Exception $e) {
error_log("Voice session DB error: " . $e->getMessage());
}
}
$ps = get_room_participants($room);
json_out(["success" => true, "peer_id" => $new_id, "participants" => $ps, "can_speak" => Permissions::canDoInChannel($current_user_id, (int)$room, Permissions::SPEAK)]);
}
if ($action === "poll") {
if (!$my_id) json_out(["error" => "Missing peer_id"], 400);
$is_muted = isset($_REQUEST["is_muted"]) ? (int)$_REQUEST["is_muted"] : 0;
$is_deafened = isset($_REQUEST["is_deafened"]) ? (int)$_REQUEST["is_deafened"] : 0;
$can_speak = Permissions::canDoInChannel($current_user_id, (int)$room, Permissions::SPEAK);
if (!$can_speak) $is_muted = 1;
// Update DB last_seen
if ($current_user_id > 0) {
try {
$stmt = db()->prepare("UPDATE voice_sessions SET last_seen = ?, is_muted = ?, is_deafened = ?, channel_id = ?, peer_id = ?, name = ? WHERE user_id = ?");
$name = $_REQUEST["name"] ?? ($user["display_name"] ?: $user["username"] ?: "User");
$stmt->execute([now_ms(), $is_muted, $is_deafened, $room, $my_id, $name, $current_user_id]);
// If update failed (no rows affected because session was deleted or user_id mismatch), re-insert
if ($stmt->rowCount() === 0) {
$stmt = db()->prepare("INSERT INTO voice_sessions (user_id, channel_id, last_seen, peer_id, name, is_muted, is_deafened) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE channel_id = ?, last_seen = ?, peer_id = ?, name = ?, is_muted = ?, is_deafened = ?");
$stmt->execute([$current_user_id, $room, now_ms(), $my_id, $name, $is_muted, $is_deafened, $room, now_ms(), $my_id, $name, $is_muted, $is_deafened]);
}
} catch (Exception $e) {}
}
$ps = get_room_participants($room);
// Read signals from DB
$signals = [];
try {
$stmt = db()->prepare("SELECT id, from_peer_id as `from`, data FROM voice_signals WHERE to_peer_id = ? ORDER BY id ASC");
$stmt->execute([$my_id]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($rows)) {
$ids = [];
foreach ($rows as $r) {
$signals[] = [
"from" => $r["from"],
"data" => json_decode($r["data"], true)
];
$ids[] = $r["id"];
}
// Delete signals we just read
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt_del = db()->prepare("DELETE FROM voice_signals WHERE id IN ($placeholders)");
$stmt_del->execute($ids);
}
// Periodic cleanup of old signals (> 1 minute)
if (rand(1, 20) === 1) {
$old_time = now_ms() - 60000;
$stmt_clean = db()->prepare("DELETE FROM voice_signals WHERE created_at_ms < ?");
$stmt_clean->execute([$old_time]);
}
} catch (Exception $e) {
error_log("Signal poll error: " . $e->getMessage());
}
json_out([
"success" => true,
"participants" => $ps,
"signals" => $signals,
"can_speak" => $can_speak
]);
}
if ($action === "signal") {
if (!$my_id) json_out(["error" => "Missing peer_id"], 400);
$to = $_REQUEST["to"] ?? "";
$data = $_REQUEST["data"] ?? "";
if (!$to || !$data) json_out(["error" => "Missing to/data"], 400);
try {
$stmt = db()->prepare("INSERT INTO voice_signals (room_id, from_peer_id, to_peer_id, data, created_at_ms) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$room, $my_id, $to, $data, now_ms()]);
json_out(["success" => true]);
} catch (Exception $e) {
json_out(["error" => "Signal send failed: " . $e->getMessage()], 500);
}
}
if ($action === "list_all") {
// Periodic cleanup of the DB table (stale sessions > 15s)
if (rand(1, 10) === 1) {
try {
$stale_db_time = now_ms() - 30000;
$stmt = db()->prepare("DELETE FROM voice_sessions WHERE last_seen < ?");
$stmt->execute([$stale_db_time]);
} catch (Exception $e) {}
}
try {
$stmt = db()->prepare("
SELECT vs.channel_id, vs.user_id, vs.is_muted, vs.is_deafened, u.username, u.display_name, u.avatar_url
FROM voice_sessions vs
JOIN users u ON vs.user_id = u.id
WHERE vs.last_seen > ?
");
$stale_db_time = now_ms() - 30000;
$stmt->execute([$stale_db_time]);
$sessions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$by_channel = [];
foreach ($sessions as $s) {
$by_channel[$s['channel_id']][] = $s;
}
json_out(["success" => true, "channels" => $by_channel]);
} catch (Exception $e) {
json_out(["error" => $e->getMessage()], 500);
}
}
if ($action === "leave") {
if ($current_user_id > 0) {
try {
$stmt = db()->prepare("DELETE FROM voice_sessions WHERE user_id = ?");
$stmt->execute([$current_user_id]);
} catch (Exception $e) {}
}
json_out(["success" => true]);
}
if ($action === "get_whispers") {
if ($current_user_id <= 0) json_out(["error" => "Unauthorized"], 401);
try {
$stmt = db()->prepare("SELECT * FROM voice_whispers WHERE user_id = ?");
$stmt->execute([$current_user_id]);
json_out(["success" => true, "whispers" => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
} catch (Exception $e) {
json_out(["error" => $e->getMessage()], 500);
}
}
if ($action === "save_whisper") {
if ($current_user_id <= 0) json_out(["error" => "Unauthorized"], 401);
$target_type = $_REQUEST["target_type"] ?? "";
$target_id = (int)($_REQUEST["target_id"] ?? 0);
$key = $_REQUEST["key"] ?? "";
if (!$target_type || !$target_id || !$key) json_out(["error" => "Missing parameters"], 400);
try {
$stmt = db()->prepare("INSERT INTO voice_whispers (user_id, target_type, target_id, whisper_key) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE target_type = ?, target_id = ?, whisper_key = ?");
$stmt->execute([$current_user_id, $target_type, $target_id, $key, $target_type, $target_id, $key]);
json_out(["success" => true]);
} catch (Exception $e) {
json_out(["error" => $e->getMessage()], 500);
}
}
if ($action === "delete_whisper") {
if ($current_user_id <= 0) json_out(["error" => "Unauthorized"], 401);
$id = (int)($_REQUEST["id"] ?? 0);
try {
$stmt = db()->prepare("DELETE FROM voice_whispers WHERE id = ? AND user_id = ?");
$stmt->execute([$id, $current_user_id]);
json_out(["success" => true]);
} catch (Exception $e) {
json_out(["error" => $e->getMessage()], 500);
}
}
if ($action === "find_whisper_targets") {
if ($current_user_id <= 0) json_out(["error" => "Unauthorized"], 401);
$target_type = $_REQUEST["target_type"] ?? "";
$target_id = $_REQUEST["target_id"] ?? ""; // Can be channel name or user_id
if (!$target_type || !$target_id) json_out(["error" => "Missing parameters"], 400);
try {
$stale_time = now_ms() - 30000;
if ($target_type === 'user') {
$stmt = db()->prepare("SELECT peer_id, name FROM voice_sessions WHERE user_id = ? AND last_seen > ?");
$stmt->execute([(int)$target_id, $stale_time]);
} else {
// target_id is channel_id (room)
$stmt = db()->prepare("SELECT peer_id, name FROM voice_sessions WHERE channel_id = ? AND last_seen > ?");
$stmt->execute([(string)$target_id, $stale_time]);
}
json_out(["success" => true, "targets" => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
} catch (Exception $e) {
json_out(["error" => $e->getMessage()], 500);
}
}
json_out(["error" => "Unknown action"], 404);

85
api_v1_webhook.php Normal file
View File

@ -0,0 +1,85 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
// Check for execution (no session needed, just token)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_GET['token'])) {
require_once 'db/config.php';
$token = $_GET['token'] ?? '';
$data = json_decode(file_get_contents('php://input'), true);
$content = $data['content'] ?? '';
$stmt = db()->prepare("SELECT * FROM webhooks WHERE token = ?");
$stmt->execute([$token]);
$webhook = $stmt->fetch();
if (!$webhook) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Invalid token']);
exit;
}
if (empty($content)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Empty content']);
exit;
}
try {
$stmt = db()->prepare("INSERT INTO messages (channel_id, user_id, content) VALUES (?, ?, ?)");
$stmt->execute([$webhook['channel_id'], 1, $content]); // 1 is system/bot user
enforceChannelLimit($webhook['channel_id']);
echo json_encode(['success' => true]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
// Manage webhooks (session needed)
requireLogin();
$user_id = $_SESSION['user_id'];
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$server_id = $_GET['server_id'] ?? 0;
$stmt = db()->prepare("
SELECT w.*, c.name as channel_name
FROM webhooks w
JOIN channels c ON w.channel_id = c.id
WHERE c.server_id = ?
");
$stmt->execute([$server_id]);
$webhooks = $stmt->fetchAll();
echo json_encode(['success' => true, 'webhooks' => $webhooks]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
$channel_id = $data['channel_id'] ?? 0;
$name = $data['name'] ?? 'New Webhook';
$token = bin2hex(random_bytes(16));
try {
$stmt = db()->prepare("INSERT INTO webhooks (channel_id, name, token) VALUES (?, ?, ?)");
$stmt->execute([$channel_id, $name, $token]);
echo json_encode(['success' => true, 'webhook' => ['id' => db()->lastInsertId(), 'token' => $token]]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
$data = json_decode(file_get_contents('php://input'), true);
$id = $data['id'] ?? 0;
try {
$stmt = db()->prepare("DELETE FROM webhooks WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}

50
api_v1_withdraw_rules.php Normal file
View File

@ -0,0 +1,50 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
requireLogin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$json = json_decode(file_get_contents('php://input'), true);
$channel_id = $json['channel_id'] ?? 0;
$user_id = $_SESSION['user_id'];
if (!$channel_id) {
echo json_encode(['success' => false, 'error' => 'ID de canal manquant']);
exit;
}
// Fetch channel details to get rules_role_id
$stmt = db()->prepare("SELECT * FROM channels WHERE id = ? AND type = 'rules'");
$stmt->execute([$channel_id]);
$channel = $stmt->fetch();
if (!$channel) {
echo json_encode(['success' => false, 'error' => 'Canal de règles introuvable']);
exit;
}
$role_id = $channel['rules_role_id'];
try {
db()->beginTransaction();
// 1. Remove acceptance record
$stmtAcc = db()->prepare("DELETE FROM rule_acceptances WHERE user_id = ? AND channel_id = ?");
$stmtAcc->execute([$user_id, $channel_id]);
// 2. Remove role if it was configured
if ($role_id) {
$stmtRole = db()->prepare("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?");
$stmtRole->execute([$user_id, $role_id]);
}
db()->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
db()->rollBack();
echo json_encode(['success' => false, 'error' => 'Erreur lors du retrait : ' . $e->getMessage()]);
}
exit;
}
echo json_encode(['success' => false, 'error' => 'Méthode non autorisée']);

1478
assets/css/discord.css Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

2951
assets/js/main.js Normal file

File diff suppressed because it is too large Load Diff

997
assets/js/voice.js Normal file
View File

@ -0,0 +1,997 @@
console.log('voice.js loaded');
class VoiceChannel {
constructor(ws, settings) {
// ws is ignored now as we use PHP signaling, but kept for compatibility
this.settings = settings || {
mode: 'vox',
pttKey: 'v',
voxThreshold: 0.1,
inputDevice: 'default',
outputDevice: 'default',
inputVolume: 1.0,
outputVolume: 1.0,
echoCancellation: true,
noiseSuppression: true
};
console.log('VoiceChannel constructor called with settings:', this.settings);
this.localStream = null;
this.analysisStream = null;
this.peers = {}; // userId -> RTCPeerConnection
this.participants = {}; // userId -> {name}
this.currentChannelId = null;
this.myPeerId = null;
this.pollInterval = null;
this.canSpeak = true;
this.remoteAudios = {}; // userId -> Audio element
this.isSelfMuted = false;
this.isDeafened = false;
this.peerStates = {}; // userId -> { makingOffer, ignoreOffer }
this.whisperSettings = []; // from DB
this.whisperPeers = new Set(); // active whisper target peer_ids
this.isWhispering = false;
this.whisperListeners = [];
this.audioContext = null;
this.analyser = null;
this.microphone = null;
this.scriptProcessor = null;
this.inputGain = null;
this.isTalking = false;
this.pttPressed = false;
this.voxActive = false;
this.lastVoiceTime = 0;
this.voxHoldTime = 500;
// Track who is speaking to persist across UI refreshes
this.speakingUsers = new Set();
this.setupPTTListeners();
this.loadWhisperSettings();
window.addEventListener('beforeunload', () => {
// We don't want to leave on page refresh if we want persistence
// but we might want to tell the server we are "still here" soon.
// Actually, for a simple refresh, we just let the session timeout or re-join.
});
// Auto-rejoin if we were in a channel
setTimeout(() => {
const savedChannelId = sessionStorage.getItem('activeVoiceChannel');
const savedPeerId = sessionStorage.getItem('activeVoicePeerId');
if (savedChannelId) {
console.log('Auto-rejoining voice channel:', savedChannelId);
if (savedPeerId) this.myPeerId = savedPeerId;
this.join(savedChannelId, true); // Pass true to indicate auto-rejoin
}
}, 200);
}
setupPTTListeners() {
window.addEventListener('keydown', (e) => {
// Ignore if in input field
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Normal PTT
if (this.settings.mode === 'ptt') {
const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() ||
(e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) ||
(this.settings.pttKey === '0' && e.code === 'Numpad0');
if (isMatch) {
if (!this.pttPressed) {
this.pttPressed = true;
this.updateMuteState();
}
return;
}
}
// Whispers
this.whisperSettings.forEach(w => {
if (e.key.toLowerCase() === w.whisper_key.toLowerCase()) {
this.startWhisper(w);
}
});
});
window.addEventListener('keyup', (e) => {
if (this.settings.mode === 'ptt') {
const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() ||
(e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) ||
(this.settings.pttKey === '0' && e.code === 'Numpad0');
if (isMatch) {
this.pttPressed = false;
this.updateMuteState();
return;
}
}
// Whispers
this.whisperSettings.forEach(w => {
if (e.key.toLowerCase() === w.whisper_key.toLowerCase()) {
this.stopWhisper(w);
}
});
});
}
async loadWhisperSettings() {
try {
const resp = await fetch('api_v1_voice.php?action=get_whispers');
const data = await resp.json();
if (data.success) {
this.whisperSettings = data.whispers;
console.log('VoiceChannel: Loaded whispers:', this.whisperSettings);
}
} catch (e) {
console.error('Failed to load whispers in VoiceChannel:', e);
}
}
setupWhisperListeners() {
// This is called when settings are updated in the UI
this.loadWhisperSettings();
}
async startWhisper(config) {
if (this.isWhispering) return;
console.log('Starting whisper to:', config.target_type, config.target_id);
try {
const resp = await fetch(`api_v1_voice.php?action=find_whisper_targets&target_type=${config.target_type}&target_id=${config.target_id}`);
const data = await resp.json();
if (data.success && data.targets.length > 0) {
this.isWhispering = true;
this.whisperPeers.clear();
for (const target of data.targets) {
if (target.peer_id === this.myPeerId) continue;
this.whisperPeers.add(target.peer_id);
// Establish connection if not exists
if (!this.peers[target.peer_id]) {
console.log('Establishing temporary connection for whisper to:', target.peer_id);
this.createPeerConnection(target.peer_id, true);
}
}
this.updateMuteState();
} else {
console.log('No active targets found for whisper.');
}
} catch (e) {
console.error('Whisper start error:', e);
}
}
stopWhisper(config) {
if (!this.isWhispering) return;
console.log('Stopping whisper');
this.isWhispering = false;
this.whisperPeers.clear();
this.updateMuteState();
// Optionally cleanup peers that are NOT in current channel
// For now, keep them for future whispers to avoid re-handshake
}
async join(channelId, isAutoRejoin = false) {
console.log('VoiceChannel.join process started for channel:', channelId, 'isAutoRejoin:', isAutoRejoin);
if (this.currentChannelId === channelId && !isAutoRejoin) {
console.log('Already in this channel');
return;
}
if (this.currentChannelId && this.currentChannelId != channelId) {
console.log('Leaving previous channel:', this.currentChannelId);
this.leave();
}
this.currentChannelId = channelId;
sessionStorage.setItem('activeVoiceChannel', channelId);
try {
console.log('Requesting microphone access with device:', this.settings.inputDevice);
const constraints = {
audio: {
echoCancellation: this.settings.echoCancellation,
noiseSuppression: this.settings.noiseSuppression,
autoGainControl: true
},
video: false
};
if (this.settings.inputDevice !== 'default') {
constraints.audio.deviceId = { exact: this.settings.inputDevice };
}
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
console.log('Microphone access granted');
this.setMute(false); // Join unmuted by default (self-mute off)
// Always setup VOX logic for volume meter and detection
this.setupVOX();
// Join via PHP
console.log('Calling API join...');
const url = `api_v1_voice.php?action=join&room=${channelId}&name=${encodeURIComponent(window.currentUsername || 'Unknown')}${this.myPeerId ? '&peer_id='+this.myPeerId : ''}`;
const resp = await fetch(url);
const data = await resp.json();
console.log('API join response:', data);
if (data.success) {
this.myPeerId = data.peer_id;
this.canSpeak = data.can_speak !== false;
sessionStorage.setItem('activeVoicePeerId', this.myPeerId);
console.log('Joined room with peer_id:', this.myPeerId);
// Start polling
this.startPolling();
this.updateVoiceUI();
} else {
console.error('API join failed:', data.error);
}
} catch (e) {
console.error('Failed to join voice:', e);
alert('Microphone access required for voice channels. Error: ' + e.message);
this.currentChannelId = null;
}
}
startPolling() {
if (this.pollInterval) clearInterval(this.pollInterval);
this.pollInterval = setInterval(() => this.poll(), 500);
this.poll(); // Initial poll
}
async poll() {
if (!this.myPeerId || !this.currentChannelId) return;
try {
const resp = await fetch(`api_v1_voice.php?action=poll&room=${this.currentChannelId}&peer_id=${this.myPeerId}&is_muted=${this.isSelfMuted ? 1 : 0}&is_deafened=${this.isDeafened ? 1 : 0}`);
const data = await resp.json();
if (data.success) {
this.canSpeak = data.can_speak !== false;
// Update participants
const oldPs = Object.keys(this.participants);
this.participants = data.participants;
const newPs = Object.keys(this.participants);
// If new people joined, initiate offer if I'm the "older" one (not really necessary here, can just offer to anyone I don't have a peer for)
newPs.forEach(pid => {
if (pid !== this.myPeerId && !this.peers[pid]) {
console.log('New peer found via poll:', pid);
this.createPeerConnection(pid, true);
}
});
// Cleanup left peers
oldPs.forEach(pid => {
if (!this.participants[pid] && this.peers[pid] && !this.whisperPeers.has(pid) && !this.speakingUsers.has(pid)) {
console.log('Peer left or not in channel anymore:', pid);
this.peers[pid].close();
delete this.peers[pid];
if (this.remoteAudios[pid]) {
this.remoteAudios[pid].pause();
this.remoteAudios[pid].remove();
delete this.remoteAudios[pid];
}
}
});
// Handle incoming signals
if (data.signals && data.signals.length > 0) {
for (const sig of data.signals) {
await this.handleSignaling(sig);
}
}
this.updateVoiceUI();
}
} catch (e) {
console.error('Polling error:', e);
}
}
async sendSignal(to, data) {
if (!this.myPeerId || !this.currentChannelId) return;
await fetch(`api_v1_voice.php?action=signal&room=${this.currentChannelId}&peer_id=${this.myPeerId}&to=${to}&data=${encodeURIComponent(JSON.stringify(data))}`);
}
createPeerConnection(userId, isOfferor) {
if (this.peers[userId]) return this.peers[userId];
console.log('Creating PeerConnection for:', userId, 'as offeror:', isOfferor);
if (!this.peerStates[userId]) {
this.peerStates[userId] = { makingOffer: false, ignoreOffer: false };
}
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
});
this.peers[userId] = pc;
pc.oniceconnectionstatechange = () => {
console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`);
if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') {
console.log(`ICE failure with ${userId}, attempting to restart...`);
// If it failed, we could try to renegotiate, but for now let's just wait for poll to maybe clean it up
}
};
pc.onnegotiationneeded = async () => {
try {
this.peerStates[userId].makingOffer = true;
await pc.setLocalDescription();
this.sendSignal(userId, { type: 'offer', offer: pc.localDescription });
} catch (err) {
console.error('onnegotiationneeded error:', err);
} finally {
this.peerStates[userId].makingOffer = false;
}
};
if (this.localStream) {
this.localStream.getTracks().forEach(track => {
console.log(`Adding track ${track.kind} to peer ${userId}`);
pc.addTrack(track, this.localStream);
});
}
pc.onicecandidate = (event) => {
if (event.candidate) {
this.sendSignal(userId, { type: 'ice_candidate', candidate: event.candidate });
}
};
pc.ontrack = (event) => {
console.log('Received remote track from:', userId, 'Stream count:', event.streams.length);
const stream = event.streams[0] || new MediaStream([event.track]);
// Ensure AudioContext is running
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
if (this.remoteAudios[userId]) {
console.log('Replacing existing audio element for:', userId);
this.remoteAudios[userId].pause();
this.remoteAudios[userId].srcObject = null;
this.remoteAudios[userId].remove();
}
const remoteAudio = new Audio();
remoteAudio.autoplay = true;
remoteAudio.style.display = 'none';
remoteAudio.srcObject = stream;
remoteAudio.muted = this.isDeafened;
remoteAudio.volume = this.settings.outputVolume || 1.0;
if (this.settings.outputDevice !== 'default' && typeof remoteAudio.setSinkId === 'function') {
remoteAudio.setSinkId(this.settings.outputDevice);
}
document.body.appendChild(remoteAudio);
this.remoteAudios[userId] = remoteAudio;
console.log('Playing remote audio for:', userId);
remoteAudio.play().then(() => {
console.log('Remote audio playing successfully for:', userId);
}).catch(e => {
console.warn('Autoplay prevented or play failed for:', userId, e);
// In case of autoplay prevention, we might need a user gesture
});
};
// Manual offer if explicitly requested (though onnegotiationneeded should handle it)
if (isOfferor && pc.signalingState === 'stable') {
pc.onnegotiationneeded();
}
return pc;
}
async handleSignaling(sig) {
const from = sig.from;
const data = sig.data;
console.log('Handling signaling from:', from, 'type:', data.type);
try {
switch (data.type) {
case 'offer':
await this.handleOffer(from, data.offer);
break;
case 'answer':
await this.handleAnswer(from, data.answer);
break;
case 'ice_candidate':
await this.handleCandidate(from, data.candidate);
break;
case 'voice_speaking':
this.updateSpeakingUI(data.user_id, data.speaking, data.is_whisper);
break;
}
} catch (err) {
console.error('Signaling error:', err);
}
}
async handleOffer(from, offer) {
const pc = this.createPeerConnection(from, false);
const state = this.peerStates[from];
const offerCollision = (offer.type === "offer") &&
(state.makingOffer || pc.signalingState !== "stable");
// Politeness: higher peer_id is polite
const isPolite = this.myPeerId > from;
state.ignoreOffer = !isPolite && offerCollision;
if (state.ignoreOffer) {
console.log('Polite peer: ignoring offer from impolite peer to avoid collision', from);
return;
}
await pc.setRemoteDescription(new RTCSessionDescription(offer));
if (offer.type === "offer") {
await pc.setLocalDescription();
this.sendSignal(from, { type: 'answer', answer: pc.localDescription });
}
}
async handleAnswer(from, answer) {
const pc = this.peers[from];
if (pc) {
await pc.setRemoteDescription(new RTCSessionDescription(answer));
}
}
async handleCandidate(from, candidate) {
const pc = this.peers[from];
const state = this.peerStates[from];
try {
if (pc) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
} catch (err) {
if (!state || !state.ignoreOffer) {
console.warn('Failed to add ICE candidate', err);
}
}
}
setupVOX() {
if (!this.localStream) {
console.warn('Cannot setup VOX: no localStream');
return;
}
console.log('Setting up VOX logic...');
try {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// Re-ensure context is running
if (this.audioContext.state === 'suspended') {
this.audioContext.resume().then(() => console.log('AudioContext resumed'));
}
// Cleanup old nodes
if (this.scriptProcessor) {
this.scriptProcessor.onaudioprocess = null;
try { this.scriptProcessor.disconnect(); } catch(e) {}
}
if (this.microphone) {
try { this.microphone.disconnect(); } catch(e) {}
}
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 512;
// Use a cloned stream for analysis so VOX works even when localStream is muted/disabled
if (this.analysisStream) {
this.analysisStream.getTracks().forEach(t => t.stop());
}
this.analysisStream = this.localStream.clone();
this.analysisStream.getAudioTracks().forEach(t => t.enabled = true); // Ensure analysis stream is NOT muted
this.microphone = this.audioContext.createMediaStreamSource(this.analysisStream);
this.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1);
this.microphone.connect(this.analyser);
this.analyser.connect(this.scriptProcessor);
// Avoid feedback: connect to a gain node with 0 volume then to destination
const silence = this.audioContext.createGain();
silence.gain.value = 0;
this.scriptProcessor.connect(silence);
silence.connect(this.audioContext.destination);
this.voxActive = false;
this.currentVolume = 0;
this.scriptProcessor.onaudioprocess = () => {
const array = new Uint8Array(this.analyser.frequencyBinCount);
this.analyser.getByteFrequencyData(array);
let values = 0;
for (let i = 0; i < array.length; i++) values += array[i];
const average = values / array.length;
this.currentVolume = average / 255;
if (this.settings.mode !== 'vox') {
this.voxActive = false;
return;
}
if (this.currentVolume > this.settings.voxThreshold) {
this.lastVoiceTime = Date.now();
if (!this.voxActive) {
this.voxActive = true;
this.updateMuteState();
}
} else {
if (this.voxActive && Date.now() - this.lastVoiceTime > this.voxHoldTime) {
this.voxActive = false;
this.updateMuteState();
}
}
};
console.log('VOX logic setup complete');
} catch (e) {
console.error('Failed to setup VOX:', e);
}
}
getVolume() {
return this.currentVolume || 0;
}
updateMuteState() {
if (!this.localStream) return;
// If we are not in a channel, we can still whisper!
// But for normal talking, we need currentChannelId.
let shouldTalk = (this.settings.mode === 'ptt') ? this.pttPressed : this.voxActive;
if (this.canSpeak === false) {
shouldTalk = false;
}
// Always allow talking if whispering
if (this.isWhispering) {
shouldTalk = true;
}
console.log('updateMuteState: shouldTalk =', shouldTalk, 'isWhispering =', this.isWhispering);
if (this.isTalking !== shouldTalk || this.lastWhisperState !== this.isWhispering) {
this.isTalking = shouldTalk;
this.lastWhisperState = this.isWhispering;
this.applyAudioState();
this.updateSpeakingUI(window.currentUserId, shouldTalk, this.isWhispering);
// Notify others in current channel
const msg = {
type: 'voice_speaking',
channel_id: this.currentChannelId,
user_id: window.currentUserId,
speaking: shouldTalk,
is_whisper: this.isWhispering
};
// Send to channel peers
Object.keys(this.peers).forEach(pid => {
// If we are whispering, only send voice_speaking to whisper targets
// but actually it's better to notify channel peers that we are NOT talking to them
if (this.isWhispering) {
if (this.whisperPeers.has(pid)) {
this.sendSignal(pid, msg);
} else {
// Tell channel peers we are silent to them
this.sendSignal(pid, { ...msg, speaking: false });
}
} else {
this.sendSignal(pid, msg);
}
});
// Also notify whisper peers that are NOT in the channel
if (this.isWhispering) {
this.whisperPeers.forEach(pid => {
if (!this.peers[pid]) {
// This should have been established in startWhisper
} else {
this.sendSignal(pid, msg);
}
});
}
}
}
applyAudioState() {
if (this.localStream) {
const shouldTransmit = !this.isSelfMuted && this.isTalking && (this.canSpeak || this.isWhispering);
console.log('applyAudioState: transmitting =', shouldTransmit, '(whisper=', this.isWhispering, ')');
this.localStream.getAudioTracks().forEach(track => {
track.enabled = shouldTransmit;
});
// We also need to ensure the audio only goes to the right peers
// In P2P, we do this by enabling/disabling the track in the peer connection
// or by simply enabling/disabling the local track (which affects all peers).
// To be truly private, we should only enable the track for whisper peers.
Object.entries(this.peers).forEach(([pid, pc]) => {
const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio');
if (sender) {
if (this.isWhispering) {
sender.track.enabled = this.whisperPeers.has(pid);
} else {
// Normal mode: only send to people in the current channel participants
sender.track.enabled = !!this.participants[pid];
}
}
});
}
this.updateUserPanelButtons();
}
setMute(mute) {
this.isSelfMuted = mute;
this.applyAudioState();
}
toggleMute() {
if (this.canSpeak === false) return;
this.setMute(!this.isSelfMuted);
}
toggleDeafen() {
this.isDeafened = !this.isDeafened;
console.log('Setting deafen to:', this.isDeafened);
Object.values(this.remoteAudios).forEach(audio => {
audio.muted = this.isDeafened;
if (!this.isDeafened) audio.volume = this.settings.outputVolume || 1.0;
});
// If we deafen, we usually also mute in Discord
if (this.isDeafened && !this.isSelfMuted) {
this.setMute(true);
}
this.applyAudioState();
}
setOutputVolume(vol) {
this.settings.outputVolume = parseFloat(vol);
Object.values(this.remoteAudios).forEach(audio => {
audio.volume = this.settings.outputVolume;
});
}
setInputVolume(vol) {
this.settings.inputVolume = parseFloat(vol);
}
async setInputDevice(deviceId) {
this.settings.inputDevice = deviceId;
if (this.currentChannelId && this.localStream) {
const constraints = {
audio: {
echoCancellation: this.settings.echoCancellation,
noiseSuppression: this.settings.noiseSuppression,
autoGainControl: true
},
video: false
};
if (deviceId !== 'default') {
constraints.audio.deviceId = { exact: deviceId };
}
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
const newTrack = newStream.getAudioTracks()[0];
Object.values(this.peers).forEach(pc => {
const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio');
if (sender) sender.replaceTrack(newTrack);
});
this.localStream.getTracks().forEach(t => t.stop());
this.localStream = newStream;
this.setupVOX();
this.applyAudioState();
}
}
async setOutputDevice(deviceId) {
this.settings.outputDevice = deviceId;
Object.values(this.remoteAudios).forEach(audio => {
if (typeof audio.setSinkId === 'function') {
audio.setSinkId(deviceId).catch(e => console.error('setSinkId failed:', e));
}
});
}
async updateAudioConstraints() {
if (this.currentChannelId && this.localStream) {
console.log('Updating audio constraints:', this.settings.echoCancellation, this.settings.noiseSuppression);
const constraints = {
audio: {
echoCancellation: this.settings.echoCancellation,
noiseSuppression: this.settings.noiseSuppression,
autoGainControl: true
},
video: false
};
if (this.settings.inputDevice !== 'default') {
constraints.audio.deviceId = { exact: this.settings.inputDevice };
}
try {
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
const newTrack = newStream.getAudioTracks()[0];
Object.values(this.peers).forEach(pc => {
const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio');
if (sender) sender.replaceTrack(newTrack);
});
this.localStream.getTracks().forEach(t => t.stop());
this.localStream = newStream;
this.setupVOX();
this.applyAudioState();
} catch (e) {
console.error('Failed to update audio constraints:', e);
}
}
}
updateUserPanelButtons() {
const btnMute = document.getElementById('btn-panel-mute');
const btnDeafen = document.getElementById('btn-panel-deafen');
let displayMuted = this.isSelfMuted;
if (this.canSpeak === false) {
displayMuted = true;
}
if (btnMute) {
btnMute.classList.toggle('active', displayMuted);
btnMute.style.color = displayMuted ? '#f23f43' : 'var(--text-muted)';
btnMute.innerHTML = displayMuted ?
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>' :
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>';
if (this.canSpeak === false) {
btnMute.title = "You do not have permission to speak in this channel";
btnMute.style.opacity = '0.5';
} else {
btnMute.title = "Mute";
btnMute.style.opacity = '1';
}
}
if (btnDeafen) {
btnDeafen.classList.toggle('active', this.isDeafened);
btnDeafen.style.color = this.isDeafened ? '#f23f43' : 'var(--text-muted)';
btnDeafen.innerHTML = this.isDeafened ?
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M8.85 4.11A9 9 0 1 1 20 12"></path><path d="M11.64 6.64A5 5 0 1 1 15 10"></path></svg>' :
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18v-6a9 9 0 0 1 18 0v6"></path><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"></path></svg>';
}
}
leave() {
if (!this.currentChannelId) {
console.log('VoiceChannel.leave called but no active channel');
return;
}
console.log('Leaving voice channel:', this.currentChannelId, 'myPeerId:', this.myPeerId);
const cid = this.currentChannelId;
const pid = this.myPeerId;
sessionStorage.removeItem('activeVoiceChannel');
sessionStorage.removeItem('activeVoicePeerId');
if (this.pollInterval) clearInterval(this.pollInterval);
// Use keepalive for the leave fetch to ensure it reaches the server during page unload
fetch(`api_v1_voice.php?action=leave&room=${cid}&peer_id=${pid}`, { keepalive: true });
if (this.localStream) {
console.log('Stopping local stream tracks');
this.localStream.getTracks().forEach(track => {
track.stop();
console.log('Track stopped:', track.kind);
});
this.localStream = null;
}
if (this.analysisStream) {
this.analysisStream.getTracks().forEach(track => track.stop());
this.analysisStream = null;
}
if (this.scriptProcessor) {
try {
this.scriptProcessor.disconnect();
this.scriptProcessor.onaudioprocess = null;
} catch(e) {}
this.scriptProcessor = null;
}
if (this.microphone) {
try { this.microphone.disconnect(); } catch(e) {}
this.microphone = null;
}
if (this.audioContext && this.audioContext.state !== 'closed') {
// Keep AudioContext alive but suspended to reuse it
this.audioContext.suspend();
}
Object.values(this.peers).forEach(pc => pc.close());
Object.values(this.remoteAudios).forEach(audio => {
audio.pause();
audio.remove();
audio.srcObject = null;
});
this.peers = {};
this.remoteAudios = {};
this.participants = {};
this.currentChannelId = null;
this.myPeerId = null;
this.speakingUsers.clear();
// Also remove 'active' class from all voice items
document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('active'));
this.updateVoiceUI();
}
updateVoiceUI() {
// We now use a global update mechanism for all channels
VoiceChannel.refreshAllVoiceUsers();
if (this.currentChannelId) {
if (!document.querySelector('.voice-controls')) {
const controls = document.createElement('div');
controls.className = 'voice-controls p-2 d-flex justify-content-between align-items-center border-top bg-dark';
controls.style.backgroundColor = '#232428';
controls.innerHTML = `
<div class="d-flex align-items-center">
<div class="voice-status-icon text-success me-2" style="font-size: 8px;"></div>
<div class="small fw-bold" style="font-size: 11px; color: #248046;">Voice (${this.settings.mode.toUpperCase()})</div>
</div>
<div>
<button class="btn btn-sm text-muted" id="btn-voice-leave" title="Disconnect">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"></path><line x1="23" y1="1" x2="1" y2="23"></line></svg>
</button>
</div>
`;
const sidebar = document.querySelector('.channels-sidebar');
if (sidebar) sidebar.appendChild(controls);
const btnLeave = document.getElementById('btn-voice-leave');
if (btnLeave) btnLeave.onclick = () => this.leave();
}
} else {
const controls = document.querySelector('.voice-controls');
if (controls) controls.remove();
}
}
updateSpeakingUI(userId, isSpeaking, isWhisper = false) {
userId = String(userId);
if (isSpeaking) {
this.speakingUsers.add(userId);
} else {
this.speakingUsers.delete(userId);
}
const userEls = document.querySelectorAll(`.voice-user[data-user-id="${userId}"]`);
userEls.forEach(el => {
const avatar = el.querySelector('.message-avatar');
if (avatar) {
if (isSpeaking) {
avatar.style.boxShadow = isWhisper ? '0 0 0 2px #00a8fc' : '0 0 0 2px #23a559';
} else {
avatar.style.boxShadow = 'none';
}
}
// Show whisper indicator text if whispering to me
if (isWhisper && isSpeaking && userId !== String(window.currentUserId)) {
if (!el.querySelector('.whisper-label')) {
const label = document.createElement('span');
label.className = 'whisper-label badge bg-info ms-1';
label.style.fontSize = '8px';
label.innerText = 'WHISPER';
el.querySelector('span.text-truncate').after(label);
}
} else {
const label = el.querySelector('.whisper-label');
if (label) label.remove();
}
});
}
static async refreshAllVoiceUsers() {
try {
const resp = await fetch('api_v1_voice.php?action=list_all');
const data = await resp.json();
if (data.success) {
// Clear all lists first
document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = '');
// Remove connected highlight from all voice items
document.querySelectorAll('.voice-item').forEach(el => {
el.classList.remove('connected');
});
// Populate based on data
const processedUserIds = new Set();
Object.keys(data.channels).forEach(channelId => {
const voiceItem = document.querySelector(`.voice-item[data-channel-id="${channelId}"]`);
if (voiceItem) {
// Highlight channel as connected only if I am in it
if (window.voiceHandler && window.voiceHandler.currentChannelId == channelId) {
voiceItem.classList.add('connected');
}
const container = voiceItem.closest('.channel-item-container');
if (container) {
const listEl = container.querySelector('.voice-users-list');
if (listEl) {
data.channels[channelId].forEach(p => {
const pid = String(p.user_id);
processedUserIds.add(pid);
const isSpeaking = window.voiceHandler && window.voiceHandler.speakingUsers.has(pid);
VoiceChannel.renderUserToUI(listEl, p.user_id, p.display_name || p.username, p.avatar_url, isSpeaking, p.is_muted, p.is_deafened);
});
}
}
}
});
// Handle users whispering to me from other channels or not in any channel
if (window.voiceHandler && window.voiceHandler.speakingUsers.size > 0) {
window.voiceHandler.speakingUsers.forEach(uid => {
if (!processedUserIds.has(uid)) {
// Find where to show this user. For now, let's put them in their own channel if possible,
// or just a "Whispers" section if we had one.
// Actually, let's just show them in whatever channel they are currently in.
// The `data.channels` already contains everyone.
// If they are not in `processedUserIds` it means their channel is not rendered or they are not in a channel.
}
});
}
}
} catch (e) {
console.error('Failed to refresh voice users:', e);
}
}
static renderUserToUI(container, userId, username, avatarUrl, isSpeaking = false, isMuted = false, isDeafened = false) {
const userEl = document.createElement('div');
userEl.className = 'voice-user small text-muted d-flex align-items-center mb-1';
userEl.dataset.userId = userId;
userEl.style.paddingLeft = '8px';
const avatarStyle = avatarUrl ? `background-image: url('${avatarUrl}'); background-size: cover;` : "background-color: #555;";
const boxShadow = isSpeaking ? 'box-shadow: 0 0 0 2px #23a559;' : '';
let icons = '';
if (isDeafened) {
icons += '<i class="fa-solid fa-volume-xmark ms-auto text-danger" style="font-size: 10px;"></i>';
} else if (isMuted) {
icons += '<i class="fa-solid fa-microphone-slash ms-auto text-danger" style="font-size: 10px;"></i>';
}
userEl.innerHTML = `
<div class="message-avatar me-2" style="width: 16px; height: 16px; border-radius: 50%; transition: box-shadow 0.2s; ${avatarStyle} ${boxShadow}"></div>
<span class="text-truncate" style="font-size: 13px; max-width: 100px;">${username}</span>
${icons}
`;
container.appendChild(userEl);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

69
auth/login.php Normal file
View File

@ -0,0 +1,69 @@
<?php
require_once __DIR__ . '/session.php';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
if ($email && $password) {
$stmt = db()->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
$_SESSION['user_id'] = $user['id'];
header('Location: ../index.php');
exit;
} else {
$error = "Email ou mot de passe invalide.";
}
} else {
$error = "Veuillez remplir tous les champs.";
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Connexion | Discord Clone</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="../assets/css/discord.css">
<style>
body { background-color: #313338; display: flex; align-items: center; justify-content: center; height: 100vh; }
.auth-card { background-color: #2b2d31; padding: 32px; border-radius: 8px; width: 100%; max-width: 480px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); }
.form-label { color: #b5bac1; font-size: 12px; font-weight: bold; text-transform: uppercase; }
.form-control { background-color: #1e1f22; border: none; color: #dbdee1; padding: 10px; }
.form-control:focus { background-color: #1e1f22; color: #dbdee1; box-shadow: none; }
.btn-blurple { background-color: #5865f2; color: white; width: 100%; font-weight: bold; margin-top: 20px; }
.btn-blurple:hover { background-color: #4752c4; color: white; }
.auth-footer { color: #949ba4; font-size: 14px; margin-top: 10px; }
.auth-footer a { color: #00a8fc; text-decoration: none; }
</style>
</head>
<body>
<div class="auth-card">
<h3 class="text-center mb-1">Bon retour !</h3>
<p class="text-center mb-4" style="color: #b5bac1;">Nous sommes ravis de vous revoir !</p>
<?php if($error): ?>
<div class="alert alert-danger"><?php echo htmlspecialchars($error); ?></div>
<?php endif; ?>
<form method="POST">
<div class="mb-3">
<label class="form-label">Email ou numéro de téléphone</label>
<input type="email" name="email" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Mot de passe</label>
<input type="password" name="password" class="form-control" required>
</div>
<a href="#" style="color: #00a8fc; font-size: 14px; text-decoration: none;">Mot de passe oublié ?</a>
<button type="submit" class="btn btn-blurple">Se connecter</button>
<div class="auth-footer">
Besoin d'un compte ? <a href="register.php">S'inscrire</a>
</div>
</form>
</div>
</body>
</html>

16
auth/logout.php Normal file
View File

@ -0,0 +1,16 @@
<?php
require_once __DIR__ . '/session.php';
$user = getCurrentUser();
if ($user) {
try {
// Clean up DB session
db()->prepare("DELETE FROM voice_sessions WHERE user_id = ?")->execute([$user['id']]);
} catch (Exception $e) {
// Ignore errors during logout cleanup
}
}
session_destroy();
header('Location: login.php');
exit;

104
auth/register.php Normal file
View File

@ -0,0 +1,104 @@
<?php
require_once __DIR__ . '/session.php';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = $_POST['username'] ?? '';
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
$invite_code = $_POST['invite_code'] ?? '';
if ($username && $email && $password) {
$server_id = 1; // Default server
if (PRIVATE_REGISTRATION) {
if (empty($invite_code)) {
$error = "Un code d'invitation est requis.";
} else {
$stmt = db()->prepare("SELECT id, invite_code_expires_at FROM servers WHERE invite_code = ?");
$stmt->execute([$invite_code]);
$server = $stmt->fetch();
if (!$server) {
$error = "Code d'invitation invalide.";
} elseif ($server['invite_code_expires_at'] && strtotime($server['invite_code_expires_at']) < time()) {
$error = "Ce code d'invitation a expiré.";
} else {
$server_id = $server['id'];
}
}
}
if (!$error) {
$hash = password_hash($password, PASSWORD_DEFAULT);
try {
$stmt = db()->prepare("INSERT INTO users (username, display_name, email, password_hash) VALUES (?, ?, ?, ?)");
$stmt->execute([$username, $username, $email, $hash]);
$userId = db()->lastInsertId();
// Add to the appropriate server
$stmt = db()->prepare("INSERT IGNORE INTO server_members (server_id, user_id) VALUES (?, ?)");
$stmt->execute([$server_id, $userId]);
$_SESSION['user_id'] = $userId;
header('Location: ../index.php?server_id=' . $server_id);
exit;
} catch (Exception $e) {
$error = "L'inscription a échoué : " . $e->getMessage();
}
}
} else {
$error = "Veuillez remplir tous les champs.";
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Inscription | Discord Clone</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="../assets/css/discord.css">
<style>
body { background-color: #313338; display: flex; align-items: center; justify-content: center; height: 100vh; }
.auth-card { background-color: #2b2d31; padding: 32px; border-radius: 8px; width: 100%; max-width: 480px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); }
.form-label { color: #b5bac1; font-size: 12px; font-weight: bold; text-transform: uppercase; }
.form-control { background-color: #1e1f22; border: none; color: #dbdee1; padding: 10px; }
.form-control:focus { background-color: #1e1f22; color: #dbdee1; box-shadow: none; }
.btn-blurple { background-color: #5865f2; color: white; width: 100%; font-weight: bold; margin-top: 20px; }
.btn-blurple:hover { background-color: #4752c4; color: white; }
.auth-footer { color: #949ba4; font-size: 14px; margin-top: 10px; }
.auth-footer a { color: #00a8fc; text-decoration: none; }
</style>
</head>
<body>
<div class="auth-card">
<h3 class="text-center mb-4">Créer un compte</h3>
<?php if($error): ?>
<div class="alert alert-danger"><?php echo htmlspecialchars($error); ?></div>
<?php endif; ?>
<form method="POST">
<div class="mb-3">
<label class="form-label">Nom d'utilisateur</label>
<input type="text" name="username" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Mot de passe</label>
<input type="password" name="password" class="form-control" required>
</div>
<?php if (PRIVATE_REGISTRATION): ?>
<div class="mb-3">
<label class="form-label">Code d'invitation</label>
<input type="text" name="invite_code" class="form-control" placeholder="Ex: aB1!c2D3@4" required>
</div>
<?php endif; ?>
<button type="submit" class="btn btn-blurple">Continuer</button>
<div class="auth-footer">
Vous avez déjà un compte ? <a href="login.php">Se connecter</a>
</div>
</form>
</div>
</body>
</html>

20
auth/session.php Normal file
View File

@ -0,0 +1,20 @@
<?php
session_start();
require_once __DIR__ . '/../db/config.php';
function getCurrentUser() {
if (!isset($_SESSION['user_id'])) {
return null;
}
$stmt = db()->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
return $stmt->fetch();
}
function requireLogin() {
if (!isset($_SESSION['user_id'])) {
header('Location: auth/login.php');
exit;
}
}

13
check_users.php Normal file
View File

@ -0,0 +1,13 @@
<?php
require_once __DIR__ . '/db/config.php';
try {
$stmt = db()->query("SELECT id, username, email FROM users");
$users = $stmt->fetchAll();
echo "Liste des utilisateurs :\n";
foreach ($users as $user) {
echo "- ID: {$user['id']} | Username: {$user['username']} | Email: {$user['email']}\n";
}
} catch (Exception $e) {
echo "Erreur : " . $e->getMessage();
}
unlink(__FILE__);

115
contact.php Normal file
View File

@ -0,0 +1,115 @@
<?php
require_once 'auth/session.php';
require_once 'mail/MailService.php';
$success = '';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$message = trim($_POST['message'] ?? '');
if (empty($name) || empty($email) || empty($message)) {
$error = 'All fields are required.';
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = 'Invalid email address.';
} else {
$res = MailService::sendContactMessage($name, $email, $message);
if (!empty($res['success'])) {
$success = 'Your message has been sent successfully!';
} else {
$error = 'Failed to send message: ' . ($res['error'] ?? 'Unknown error');
}
}
}
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact Us - Flatlogic Discord</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="assets/css/discord.css?v=<?php echo time(); ?>">
<style>
body {
background-color: var(--bg-chat);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.contact-card {
background-color: var(--bg-channels);
border-radius: 8px;
padding: 32px;
width: 100%;
max-width: 480px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
}
.form-control {
background-color: #1e1f22;
border: none;
color: white;
padding: 10px;
}
.form-control:focus {
background-color: #1e1f22;
color: white;
box-shadow: none;
border: 1px solid var(--blurple);
}
.btn-primary {
background-color: var(--blurple);
border: none;
padding: 10px;
font-weight: bold;
}
.btn-primary:hover {
background-color: #4752c4;
}
</style>
</head>
<body>
<div class="contact-card">
<h3 class="text-center mb-4">Contact Us</h3>
<?php if ($success): ?>
<div class="alert alert-success"><?php echo htmlspecialchars($success); ?></div>
<div class="text-center mt-3">
<a href="index.php" class="btn btn-link text-white">Back to App</a>
</div>
<?php else: ?>
<?php if ($error): ?>
<div class="alert alert-danger"><?php echo htmlspecialchars($error); ?></div>
<?php endif; ?>
<form method="POST">
<div class="mb-3">
<label class="form-label text-uppercase small fw-bold">Name</label>
<input type="text" name="name" class="form-control" required value="<?php echo htmlspecialchars($_POST['name'] ?? ''); ?>">
</div>
<div class="mb-3">
<label class="form-label text-uppercase small fw-bold">Email</label>
<input type="email" name="email" class="form-control" required value="<?php echo htmlspecialchars($_POST['email'] ?? ''); ?>">
</div>
<div class="mb-3">
<label class="form-label text-uppercase small fw-bold">Message</label>
<textarea name="message" class="form-control" rows="5" required><?php echo htmlspecialchars($_POST['message'] ?? ''); ?></textarea>
</div>
<button type="submit" class="btn btn-primary w-100 mb-3">Send Message</button>
<div class="text-center">
<a href="index.php" class="btn btn-link text-muted small text-decoration-none">Cancel</a>
</div>
</form>
<?php endif; ?>
<div class="mt-4 p-3 rounded" style="background-color: rgba(255,255,255,0.05); font-size: 0.8em;">
<p class="mb-0 text-muted">This is for testing purposes only Flatlogic does not guarantee usage of the mail server. Please set up your own SMTP in .env with our AI Agent.</p>
</div>
</div>
</body>
</html>

1
data/test.txt Normal file
View File

@ -0,0 +1 @@
hello

1
data/test_www.txt Normal file
View File

@ -0,0 +1 @@
hello

556
database/schema.sql Normal file
View File

@ -0,0 +1,556 @@
/*M!999999\- enable the sandbox mode */
-- MariaDB dump 10.19 Distrib 10.11.14-MariaDB, for debian-linux-gnu (x86_64)
--
-- Host: 127.0.0.1 Database: app_38443
-- ------------------------------------------------------
-- Server version 10.11.14-MariaDB-0+deb12u2
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `channel_autoroles`
--
DROP TABLE IF EXISTS `channel_autoroles`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `channel_autoroles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`channel_id` int(11) NOT NULL,
`icon` varchar(50) NOT NULL,
`title` varchar(255) NOT NULL,
`role_id` int(11) NOT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `channel_id` (`channel_id`),
KEY `role_id` (`role_id`),
CONSTRAINT `channel_autoroles_ibfk_1` FOREIGN KEY (`channel_id`) REFERENCES `channels` (`id`) ON DELETE CASCADE,
CONSTRAINT `channel_autoroles_ibfk_2` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `channel_last_read`
--
DROP TABLE IF EXISTS `channel_last_read`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `channel_last_read` (
`channel_id` int(11) NOT NULL,
`user_id` int(11) NOT NULL,
`last_read_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`channel_id`,`user_id`),
KEY `user_id` (`user_id`),
CONSTRAINT `channel_last_read_ibfk_1` FOREIGN KEY (`channel_id`) REFERENCES `channels` (`id`) ON DELETE CASCADE,
CONSTRAINT `channel_last_read_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `channel_members`
--
DROP TABLE IF EXISTS `channel_members`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `channel_members` (
`channel_id` int(11) NOT NULL,
`user_id` int(11) NOT NULL,
`joined_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`channel_id`,`user_id`),
KEY `user_id` (`user_id`),
CONSTRAINT `channel_members_ibfk_1` FOREIGN KEY (`channel_id`) REFERENCES `channels` (`id`) ON DELETE CASCADE,
CONSTRAINT `channel_members_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `channel_permissions`
--
DROP TABLE IF EXISTS `channel_permissions`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `channel_permissions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`channel_id` int(11) NOT NULL,
`role_id` int(11) DEFAULT NULL,
`user_id` int(11) DEFAULT NULL,
`allow_permissions` int(11) DEFAULT 0,
`deny_permissions` int(11) DEFAULT 0,
`role_id_idx` int(11) GENERATED ALWAYS AS (ifnull(`role_id`,0)) VIRTUAL,
`user_id_idx` int(11) GENERATED ALWAYS AS (ifnull(`user_id`,0)) VIRTUAL,
PRIMARY KEY (`id`),
UNIQUE KEY `channel_role_user_fixed` (`channel_id`,`role_id_idx`,`user_id_idx`),
KEY `role_id` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `channel_rss_feeds`
--
DROP TABLE IF EXISTS `channel_rss_feeds`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `channel_rss_feeds` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`channel_id` int(11) NOT NULL,
`url` varchar(255) NOT NULL,
`last_fetched_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `channel_id` (`channel_id`),
CONSTRAINT `channel_rss_feeds_ibfk_1` FOREIGN KEY (`channel_id`) REFERENCES `channels` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `channel_rules`
--
DROP TABLE IF EXISTS `channel_rules`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `channel_rules` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`channel_id` int(11) NOT NULL,
`content` text NOT NULL,
`position` int(11) DEFAULT 0,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `channel_id` (`channel_id`),
CONSTRAINT `channel_rules_ibfk_1` FOREIGN KEY (`channel_id`) REFERENCES `channels` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `channels`
--
DROP TABLE IF EXISTS `channels`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `channels` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`server_id` int(11) DEFAULT NULL,
`name` varchar(100) NOT NULL,
`type` varchar(50) DEFAULT 'chat',
`created_at` timestamp NULL DEFAULT current_timestamp(),
`allow_file_sharing` tinyint(1) DEFAULT 1,
`theme_color` varchar(20) DEFAULT NULL,
`message_limit` int(11) DEFAULT NULL,
`status` varchar(255) DEFAULT NULL,
`icon` varchar(50) DEFAULT NULL,
`position` int(11) DEFAULT 0,
`category_id` int(11) DEFAULT NULL,
`rules_role_id` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `server_id` (`server_id`),
CONSTRAINT `channels_ibfk_1` FOREIGN KEY (`server_id`) REFERENCES `servers` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `custom_emotes`
--
DROP TABLE IF EXISTS `custom_emotes`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `custom_emotes` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL,
`path` varchar(255) NOT NULL,
`code` varchar(60) NOT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `forum_tags`
--
DROP TABLE IF EXISTS `forum_tags`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `forum_tags` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`channel_id` int(11) NOT NULL,
`name` varchar(50) NOT NULL,
`color` varchar(20) DEFAULT '#7289da',
PRIMARY KEY (`id`),
KEY `channel_id` (`channel_id`),
CONSTRAINT `forum_tags_ibfk_1` FOREIGN KEY (`channel_id`) REFERENCES `channels` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `forum_threads`
--
DROP TABLE IF EXISTS `forum_threads`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `forum_threads` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`channel_id` int(11) NOT NULL,
`user_id` int(11) NOT NULL,
`title` varchar(255) NOT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`solution_message_id` int(11) DEFAULT NULL,
`is_pinned` tinyint(1) NOT NULL DEFAULT 0,
`is_locked` tinyint(1) NOT NULL DEFAULT 0,
`last_activity_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `channel_id` (`channel_id`),
KEY `user_id` (`user_id`),
KEY `solution_message_id` (`solution_message_id`),
CONSTRAINT `forum_threads_ibfk_1` FOREIGN KEY (`channel_id`) REFERENCES `channels` (`id`) ON DELETE CASCADE,
CONSTRAINT `forum_threads_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `forum_threads_ibfk_3` FOREIGN KEY (`solution_message_id`) REFERENCES `messages` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `message_reactions`
--
DROP TABLE IF EXISTS `message_reactions`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `message_reactions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`message_id` int(11) NOT NULL,
`user_id` int(11) NOT NULL,
`emoji` varchar(50) NOT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `message_id` (`message_id`,`user_id`,`emoji`),
KEY `user_id` (`user_id`),
CONSTRAINT `message_reactions_ibfk_1` FOREIGN KEY (`message_id`) REFERENCES `messages` (`id`) ON DELETE CASCADE,
CONSTRAINT `message_reactions_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `messages`
--
DROP TABLE IF EXISTS `messages`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `messages` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`channel_id` int(11) NOT NULL,
`user_id` int(11) NOT NULL,
`content` text NOT NULL,
`attachment_url` varchar(255) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp(),
`metadata` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`metadata`)),
`is_pinned` tinyint(1) DEFAULT 0,
`thread_id` int(11) DEFAULT NULL,
`rss_guid` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `channel_id` (`channel_id`),
KEY `user_id` (`user_id`),
KEY `fk_thread` (`thread_id`),
KEY `idx_rss_guid` (`rss_guid`),
CONSTRAINT `fk_thread` FOREIGN KEY (`thread_id`) REFERENCES `forum_threads` (`id`) ON DELETE CASCADE,
CONSTRAINT `messages_ibfk_1` FOREIGN KEY (`channel_id`) REFERENCES `channels` (`id`) ON DELETE CASCADE,
CONSTRAINT `messages_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `roles`
--
DROP TABLE IF EXISTS `roles`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `roles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`server_id` int(11) NOT NULL,
`name` varchar(50) NOT NULL,
`color` varchar(7) DEFAULT '#99aab5',
`permissions` int(11) DEFAULT 0,
`position` int(11) DEFAULT 0,
`icon_url` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `server_id` (`server_id`),
CONSTRAINT `roles_ibfk_1` FOREIGN KEY (`server_id`) REFERENCES `servers` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `rss_processed_items`
--
DROP TABLE IF EXISTS `rss_processed_items`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `rss_processed_items` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`channel_id` int(11) NOT NULL,
`rss_guid` varchar(255) NOT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `idx_channel_guid` (`channel_id`,`rss_guid`),
CONSTRAINT `rss_processed_items_ibfk_1` FOREIGN KEY (`channel_id`) REFERENCES `channels` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `rule_acceptances`
--
DROP TABLE IF EXISTS `rule_acceptances`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `rule_acceptances` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`channel_id` int(11) NOT NULL,
`accepted_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `user_channel` (`user_id`,`channel_id`),
KEY `channel_id` (`channel_id`),
CONSTRAINT `rule_acceptances_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `rule_acceptances_ibfk_2` FOREIGN KEY (`channel_id`) REFERENCES `channels` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `server_members`
--
DROP TABLE IF EXISTS `server_members`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `server_members` (
`server_id` int(11) NOT NULL,
`user_id` int(11) NOT NULL,
`joined_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`server_id`,`user_id`),
KEY `user_id` (`user_id`),
CONSTRAINT `server_members_ibfk_1` FOREIGN KEY (`server_id`) REFERENCES `servers` (`id`) ON DELETE CASCADE,
CONSTRAINT `server_members_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `servers`
--
DROP TABLE IF EXISTS `servers`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `servers` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`owner_id` int(11) NOT NULL,
`icon_url` varchar(255) DEFAULT NULL,
`invite_code` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`theme_color` varchar(20) DEFAULT NULL,
`invite_code_expires_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `invite_code` (`invite_code`),
UNIQUE KEY `invite_code_2` (`invite_code`),
KEY `owner_id` (`owner_id`),
CONSTRAINT `servers_ibfk_1` FOREIGN KEY (`owner_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `thread_tags`
--
DROP TABLE IF EXISTS `thread_tags`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `thread_tags` (
`thread_id` int(11) NOT NULL,
`tag_id` int(11) NOT NULL,
PRIMARY KEY (`thread_id`,`tag_id`),
KEY `tag_id` (`tag_id`),
CONSTRAINT `thread_tags_ibfk_1` FOREIGN KEY (`thread_id`) REFERENCES `forum_threads` (`id`) ON DELETE CASCADE,
CONSTRAINT `thread_tags_ibfk_2` FOREIGN KEY (`tag_id`) REFERENCES `forum_tags` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `user_roles`
--
DROP TABLE IF EXISTS `user_roles`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `user_roles` (
`user_id` int(11) NOT NULL,
`role_id` int(11) NOT NULL,
PRIMARY KEY (`user_id`,`role_id`),
KEY `role_id` (`role_id`),
CONSTRAINT `user_roles_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `user_roles_ibfk_2` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `users`
--
DROP TABLE IF EXISTS `users`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`display_name` varchar(50) DEFAULT NULL,
`email` varchar(100) NOT NULL,
`password_hash` varchar(255) NOT NULL,
`avatar_url` varchar(255) DEFAULT NULL,
`status` varchar(20) DEFAULT 'offline',
`created_at` timestamp NULL DEFAULT current_timestamp(),
`is_bot` tinyint(1) DEFAULT 0,
`bot_token` varchar(64) DEFAULT NULL,
`dnd_mode` tinyint(1) DEFAULT 0,
`theme` varchar(20) DEFAULT 'dark',
`sound_notifications` tinyint(1) DEFAULT 0,
`is_admin` tinyint(1) DEFAULT 0,
`voice_mode` enum('vox','ptt') DEFAULT 'vox',
`voice_ptt_key` varchar(20) DEFAULT 'v',
`voice_vox_threshold` float DEFAULT 0.1,
`voice_echo_cancellation` tinyint(1) DEFAULT 1,
`voice_noise_suppression` tinyint(1) DEFAULT 1,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`),
UNIQUE KEY `email` (`email`),
UNIQUE KEY `bot_token` (`bot_token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `voice_sessions`
--
DROP TABLE IF EXISTS `voice_sessions`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `voice_sessions` (
`user_id` int(11) NOT NULL,
`channel_id` int(11) NOT NULL,
`joined_at` timestamp NULL DEFAULT current_timestamp(),
`last_seen` bigint(20) DEFAULT 0,
`peer_id` varchar(16) NOT NULL,
`name` varchar(50) DEFAULT NULL,
`is_muted` tinyint(1) DEFAULT 0,
`is_deafened` tinyint(1) DEFAULT 0,
PRIMARY KEY (`user_id`),
KEY `channel_id` (`channel_id`),
CONSTRAINT `voice_sessions_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `voice_sessions_ibfk_2` FOREIGN KEY (`channel_id`) REFERENCES `channels` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `voice_signals`
--
DROP TABLE IF EXISTS `voice_signals`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `voice_signals` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`room_id` varchar(50) NOT NULL,
`from_peer_id` varchar(16) NOT NULL,
`to_peer_id` varchar(16) NOT NULL,
`data` text NOT NULL,
`created_at_ms` bigint(20) NOT NULL,
PRIMARY KEY (`id`),
KEY `to_peer_id` (`to_peer_id`),
KEY `room_id` (`room_id`),
KEY `created_at` (`created_at_ms`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `voice_whispers`
--
DROP TABLE IF EXISTS `voice_whispers`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `voice_whispers` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`target_type` enum('user','channel') NOT NULL,
`target_id` int(11) NOT NULL,
`whisper_key` varchar(50) NOT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `user_id` (`user_id`,`whisper_key`),
CONSTRAINT `voice_whispers_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `webhooks`
--
DROP TABLE IF EXISTS `webhooks`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `webhooks` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`token` varchar(64) NOT NULL,
`channel_id` int(11) NOT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `token` (`token`),
KEY `channel_id` (`channel_id`),
CONSTRAINT `webhooks_ibfk_1` FOREIGN KEY (`channel_id`) REFERENCES `channels` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Seed initial data
INSERT INTO `users` (id, username, display_name, email, password_hash, status, is_admin) VALUES
(1, 'System', 'System Bot', 'system@local', '$2y$10$4DDos6MKhMCbsvw1Yquuj.o0NwWCRlbgy85QCmGIJV6GlL8QC4cWW', 'online', 1);
INSERT INTO `servers` (id, name, owner_id, invite_code) VALUES
(1, 'General Community', 1, 'GEN-123'),
(2, 'Flatlogic Devs', 1, 'DEV-456');
INSERT INTO `server_members` (server_id, user_id) VALUES (1, 1), (2, 1);
INSERT INTO `channels` (id, server_id, name, type) VALUES
(1, 1, 'general', 'chat'),
(2, 1, 'random', 'chat'),
(3, 1, 'Voice General', 'voice'),
(4, 2, 'announcements', 'chat'),
(5, 2, 'coding-help', 'chat');
-- Dump completed on 2026-02-19 16:49:04

View File

@ -5,6 +5,9 @@ define('DB_NAME', 'app_38443');
define('DB_USER', 'app_38443');
define('DB_PASS', '888f6481-a87b-421a-a4bd-c80fa3c5a57b');
// Registration mode: true for private (requires invite code), false for public
define('PRIVATE_REGISTRATION', true);
function db() {
static $pdo;
if (!$pdo) {
@ -15,3 +18,26 @@ function db() {
}
return $pdo;
}
function enforceChannelLimit($channel_id) {
$stmt = db()->prepare("SELECT message_limit FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$channel = $stmt->fetch();
if ($channel && !empty($channel['message_limit'])) {
$limit = (int)$channel['message_limit'];
// Delete oldest messages that exceed the limit
$stmt = db()->prepare("
DELETE FROM messages
WHERE channel_id = ?
AND id NOT IN (
SELECT id FROM (
SELECT id FROM messages
WHERE channel_id = ?
ORDER BY created_at DESC, id DESC
LIMIT " . $limit . "
) as tmp
)
");
$stmt->execute([$channel_id, $channel_id]);
}
}

View File

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS custom_emotes (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
path VARCHAR(255) NOT NULL,
code VARCHAR(60) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

View File

@ -0,0 +1,13 @@
-- Migration to add attachments and reactions
ALTER TABLE messages ADD COLUMN attachment_url VARCHAR(255) AFTER content;
CREATE TABLE IF NOT EXISTS message_reactions (
id INT AUTO_INCREMENT PRIMARY KEY,
message_id INT NOT NULL,
user_id INT NOT NULL,
emoji VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY (message_id, user_id, emoji),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

View File

@ -0,0 +1,2 @@
-- Migration: Add status to channels for voice channels
ALTER TABLE channels ADD COLUMN status VARCHAR(255) DEFAULT NULL;

View File

@ -0,0 +1,26 @@
-- Migration to support DMs and Message Editing
ALTER TABLE messages ADD COLUMN updated_at TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP;
-- Support for DMs in channels table
ALTER TABLE channels MODIFY COLUMN server_id INT NULL;
ALTER TABLE channels MODIFY COLUMN type ENUM('text', 'voice', 'dm') DEFAULT 'text';
-- Track members in channels (especially for DMs)
CREATE TABLE IF NOT EXISTS channel_members (
channel_id INT NOT NULL,
user_id INT NOT NULL,
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (channel_id, user_id),
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Notifications: Track last read message per channel per user
CREATE TABLE IF NOT EXISTS channel_last_read (
channel_id INT NOT NULL,
user_id INT NOT NULL,
last_read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (channel_id, user_id),
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

View File

@ -0,0 +1,3 @@
-- Add solution support to forum threads
ALTER TABLE forum_threads ADD COLUMN solution_message_id INT NULL DEFAULT NULL;
ALTER TABLE forum_threads ADD FOREIGN KEY (solution_message_id) REFERENCES messages(id) ON DELETE SET NULL;

View File

@ -0,0 +1,16 @@
-- Add forum tags support
CREATE TABLE IF NOT EXISTS forum_tags (
id INT AUTO_INCREMENT PRIMARY KEY,
channel_id INT NOT NULL,
name VARCHAR(50) NOT NULL,
color VARCHAR(20) DEFAULT '#7289da',
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS thread_tags (
thread_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (thread_id, tag_id),
FOREIGN KEY (thread_id) REFERENCES forum_threads(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES forum_tags(id) ON DELETE CASCADE
);

View File

@ -0,0 +1,12 @@
-- Migration: Add channel permissions and user theme preference
CREATE TABLE IF NOT EXISTS channel_permissions (
channel_id INT NOT NULL,
role_id INT NOT NULL,
allow_permissions INT DEFAULT 0,
deny_permissions INT DEFAULT 0,
PRIMARY KEY (channel_id, role_id),
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme VARCHAR(20) DEFAULT 'dark';

View File

@ -0,0 +1,12 @@
-- Migration: Add RSS support for announcement channels
CREATE TABLE IF NOT EXISTS channel_rss_feeds (
id INT AUTO_INCREMENT PRIMARY KEY,
channel_id INT NOT NULL,
url VARCHAR(255) NOT NULL,
last_fetched_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
);
ALTER TABLE messages ADD COLUMN IF NOT EXISTS rss_guid VARCHAR(255) NULL;
CREATE INDEX IF NOT EXISTS idx_rss_guid ON messages(rss_guid);

View File

@ -0,0 +1,11 @@
-- Add autorole channels support
CREATE TABLE IF NOT EXISTS channel_autoroles (
id INT AUTO_INCREMENT PRIMARY KEY,
channel_id INT NOT NULL,
icon VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
role_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);

View File

@ -0,0 +1,12 @@
-- Add rules_role_id to channels and create rule_acceptances table
ALTER TABLE channels ADD COLUMN rules_role_id INT NULL;
CREATE TABLE IF NOT EXISTS rule_acceptances (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
channel_id INT NOT NULL,
accepted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY user_channel (user_id, channel_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
);

View File

@ -0,0 +1,9 @@
-- Migration: Track processed RSS items to prevent re-posting after they are deleted by retention policy
CREATE TABLE IF NOT EXISTS rss_processed_items (
id INT AUTO_INCREMENT PRIMARY KEY,
channel_id INT NOT NULL,
rss_guid VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY idx_channel_guid (channel_id, rss_guid),
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
);

View File

@ -0,0 +1,12 @@
-- Add voice settings to users and create voice sessions table
ALTER TABLE users ADD COLUMN voice_mode ENUM('vox', 'ptt') DEFAULT 'vox';
ALTER TABLE users ADD COLUMN voice_ptt_key VARCHAR(20) DEFAULT 'v';
ALTER TABLE users ADD COLUMN voice_vox_threshold FLOAT DEFAULT 0.1;
CREATE TABLE IF NOT EXISTS voice_sessions (
user_id INT PRIMARY KEY,
channel_id INT NOT NULL,
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
);

View File

@ -0,0 +1,2 @@
-- Migration to make invite_code case-sensitive
ALTER TABLE servers MODIFY invite_code VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;

View File

@ -0,0 +1,11 @@
-- Create voice_signals table for reliable signaling
CREATE TABLE IF NOT EXISTS voice_signals (
id INT AUTO_INCREMENT PRIMARY KEY,
room_id VARCHAR(50) NOT NULL,
from_peer_id VARCHAR(16) NOT NULL,
to_peer_id VARCHAR(16) NOT NULL,
data TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
INDEX (to_peer_id),
INDEX (created_at_ms)
);

View File

@ -0,0 +1,3 @@
-- Add mute and deafen status to voice sessions
ALTER TABLE voice_sessions ADD COLUMN is_muted BOOLEAN DEFAULT FALSE;
ALTER TABLE voice_sessions ADD COLUMN is_deafened BOOLEAN DEFAULT FALSE;

View File

@ -0,0 +1,7 @@
-- Add pinned, locked and activity tracking to forum threads
ALTER TABLE forum_threads ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE forum_threads ADD COLUMN is_locked TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE forum_threads ADD COLUMN last_activity_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
-- Initialize last_activity_at with created_at for existing threads
UPDATE forum_threads SET last_activity_at = created_at;

View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS voice_whispers (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
target_type ENUM('user', 'channel') NOT NULL,
target_id INT NOT NULL,
whisper_key VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY (user_id, whisper_key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN display_name VARCHAR(50) AFTER username;
UPDATE users SET display_name = username WHERE display_name IS NULL OR display_name = "";

10
db_init.php Normal file
View File

@ -0,0 +1,10 @@
<?php
require_once 'db/config.php';
try {
$sql = file_get_contents('database/schema.sql');
db()->exec($sql);
echo "Database initialized successfully.\n";
} catch (Exception $e) {
echo "Error initializing database: " . $e->getMessage() . "\n";
}

22
healthz.php Normal file
View File

@ -0,0 +1,22 @@
<?php
require_once 'db/config.php';
header('Content-Type: application/json');
$response = [
'status' => 'ok',
'timestamp' => time(),
'php_version' => PHP_VERSION,
'database' => 'unknown'
];
try {
$db = db();
$db->query('SELECT 1');
$response['database'] = 'connected';
} catch (Exception $e) {
$response['status'] = 'error';
$response['database'] = 'error: ' . $e->getMessage();
}
echo json_encode($response);

29
includes/ai_filtering.php Normal file
View File

@ -0,0 +1,29 @@
<?php
require_once __DIR__ . '/../ai/LocalAIApi.php';
function moderateContent($content) {
if (empty(trim($content))) return ['is_safe' => true];
// Bypass moderation for video platforms as they are handled by their own safety measures
// and often trigger false positives in AI moderation due to "lack of context".
if (preg_match('/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be|dailymotion\.com|dai\.ly|vimeo\.com)\//i', $content)) {
return ['is_safe' => true];
}
$resp = LocalAIApi::createResponse([
'input' => [
['role' => 'system', 'content' => 'You are a content moderator. Analyze the message and return a JSON object with "is_safe" (boolean) and "reason" (string, optional). Safe means no hate speech, extreme violence, or explicit sexual content. Do not flag URLs as unsafe simply because you cannot see the content behind them.'],
['role' => 'user', 'content' => $content],
],
]);
if (!empty($resp['success'])) {
$result = LocalAIApi::decodeJsonFromResponse($resp);
if ($result && isset($result['is_safe'])) {
return $result;
}
}
// Default to safe if AI fails, to avoid blocking users
return ['is_safe' => true];
}

62
includes/opengraph.php Normal file
View File

@ -0,0 +1,62 @@
<?php
function fetchOpenGraphData($url) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language: en-US,en;q=0.9',
'Cache-Control: no-cache',
'Pragma: no-cache',
'Upgrade-Insecure-Requests: 1'
]);
$html = curl_exec($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if (!$html || $info['http_code'] !== 200) return null;
if (!class_exists('DOMDocument')) return null;
$doc = new DOMDocument();
@$doc->loadHTML($html);
$metas = $doc->getElementsByTagName('meta');
$data = [
'title' => '',
'description' => '',
'image' => '',
'url' => $url,
'site_name' => ''
];
// Try title tag if og:title is missing
$titles = $doc->getElementsByTagName('title');
if ($titles->length > 0) {
$data['title'] = $titles->item(0)->nodeValue;
}
foreach ($metas as $meta) {
$property = $meta->getAttribute('property');
$name = $meta->getAttribute('name');
$content = $meta->getAttribute('content');
if ($property === 'og:title' || $name === 'twitter:title') $data['title'] = $content;
if ($property === 'og:description' || $name === 'description' || $name === 'twitter:description') $data['description'] = $content;
if ($property === 'og:image' || $name === 'twitter:image') $data['image'] = $content;
if ($property === 'og:site_name') $data['site_name'] = $content;
}
// Filter out empty results
if (empty($data['title']) && empty($data['description'])) return null;
return $data;
}
function extractUrls($text) {
$pattern = '/https?:\/\/[^\s<]+/';
preg_match_all($pattern, $text, $matches);
return $matches[0];
}

125
includes/permissions.php Normal file
View File

@ -0,0 +1,125 @@
<?php
class Permissions {
const VIEW_CHANNEL = 1;
const SEND_MESSAGES = 2;
const MANAGE_MESSAGES = 4;
const MANAGE_CHANNELS = 8;
const MANAGE_SERVER = 16;
const ADMINISTRATOR = 32;
const CREATE_THREAD = 64;
const MANAGE_TAGS = 128;
const PIN_THREADS = 256;
const LOCK_THREADS = 512;
const SEND_MESSAGES_IN_THREADS = 1024;
const SPEAK = 2048;
public static function hasPermission($user_id, $server_id, $permission) {
$stmt = db()->prepare("SELECT is_admin FROM users WHERE id = ?");
$stmt->execute([$user_id]);
$user = $stmt->fetch();
if ($user && $user['is_admin']) return true;
$stmt = db()->prepare("SELECT owner_id FROM servers WHERE id = ?");
$stmt->execute([$server_id]);
$server = $stmt->fetch();
if ($server && $server['owner_id'] == $user_id) return true;
// Aggregate permissions from user's roles AND the @everyone role
$stmt = db()->prepare("
SELECT BIT_OR(r.permissions) as total_perms
FROM roles r
LEFT JOIN user_roles ur ON r.id = ur.role_id AND ur.user_id = ?
WHERE r.server_id = ? AND (ur.user_id IS NOT NULL OR r.name = '@everyone' OR r.name = 'Everyone')
");
$stmt->execute([$user_id, $server_id]);
$row = $stmt->fetch();
$perms = (int)($row['total_perms'] ?? 0);
if ($perms & self::ADMINISTRATOR) return true;
return ($perms & $permission) === $permission;
}
public static function canViewChannel($user_id, $channel_id) {
return self::canDoInChannel($user_id, $channel_id, self::VIEW_CHANNEL);
}
public static function canSendInChannel($user_id, $channel_id) {
return self::canDoInChannel($user_id, $channel_id, self::SEND_MESSAGES);
}
public static function canDoInChannel($user_id, $channel_id, $permission) {
$stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?");
$stmt->execute([$channel_id]);
$c = $stmt->fetch();
if (!$c) return false;
$server_id = $c['server_id'];
// Check if owner or admin
if (self::hasPermission($user_id, $server_id, self::ADMINISTRATOR)) return true;
// Fetch all relevant overrides
// 1. @everyone role
// 2. User's roles
// 3. User specifically
// Use a single query to get all relevant overrides
$stmt = db()->prepare("
SELECT cp.user_id, cp.role_id, cp.allow_permissions, cp.deny_permissions, r.name as role_name
FROM channel_permissions cp
LEFT JOIN roles r ON cp.role_id = r.id
LEFT JOIN user_roles ur ON cp.role_id = ur.role_id AND ur.user_id = ?
WHERE cp.channel_id = ? AND (
cp.user_id = ? OR
ur.user_id IS NOT NULL OR
r.name = '@everyone' OR
r.name = 'Everyone'
)
");
$stmt->execute([$user_id, $channel_id, $user_id]);
$overrides = $stmt->fetchAll();
if (empty($overrides)) {
// No overrides, fallback to global permissions
return self::hasPermission($user_id, $server_id, $permission);
}
// Resolution order (simplified but effective):
// User overrides > Role overrides > @everyone
$user_override = null;
$role_allow = 0;
$role_deny = 0;
$everyone_override = null;
foreach($overrides as $o) {
if ($o['user_id'] == $user_id) {
$user_override = $o;
} elseif ($o['role_name'] === '@everyone' || $o['role_name'] === 'Everyone') {
$everyone_override = $o;
} else {
$role_allow |= (int)$o['allow_permissions'];
$role_deny |= (int)$o['deny_permissions'];
}
}
// 1. User specifically
if ($user_override) {
if ($user_override['allow_permissions'] & $permission) return true;
if ($user_override['deny_permissions'] & $permission) return false;
}
// 2. Roles
if ($role_allow & $permission) return true;
if ($role_deny & $permission) return false;
// 3. @everyone
if ($everyone_override) {
if ($everyone_override['allow_permissions'] & $permission) return true;
if ($everyone_override['deny_permissions'] & $permission) return false;
}
// Fallback to base permissions
return self::hasPermission($user_id, $server_id, $permission);
}
}

28
includes/pexels.php Normal file
View File

@ -0,0 +1,28 @@
<?php
// includes/pexels.php
function pexels_key() {
$k = getenv('PEXELS_KEY');
return $k && strlen($k) > 0 ? $k : 'Vc99rnmOhHhJAbgGQoKLZtsaIVfkeownoQNbTj78VemUjKh08ZYRbf18';
}
function pexels_get($url) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [ 'Authorization: '. pexels_key() ],
CURLOPT_TIMEOUT => 15,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 200 && $code < 300 && $resp) return json_decode($resp, true);
return null;
}
function download_to($srcUrl, $destPath) {
$data = file_get_contents($srcUrl);
if ($data === false) return false;
if (!is_dir(dirname($destPath))) mkdir(dirname($destPath), 0775, true);
return file_put_contents($destPath, $data) !== false;
}

44
includes/utils.php Normal file
View File

@ -0,0 +1,44 @@
<?php
/**
* Generates a secure random invite code.
* Requirements: lowercase, uppercase, digits, special characters.
* Length: 10 to 12 characters.
*/
function generateInviteCode($length = null) {
if ($length === null) {
$length = rand(10, 12);
}
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+';
$code = '';
$max = strlen($chars) - 1;
// Ensure at least one of each required type if possible,
// but a simple random selection from the full set is usually sufficient
// if the set is diverse enough and the length is 10-12.
// However, to be strictly compliant with "must have...", let's ensure it.
$sets = [
'abcdefghijklmnopqrstuvwxyz',
'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'0123456789',
'!@#$%^&*()-_=+'
];
// Pick one from each set
foreach ($sets as $set) {
$code .= $set[random_int(0, strlen($set) - 1)];
}
// Fill the rest
while (strlen($code) < $length) {
$code .= $chars[random_int(0, $max)];
}
// Shuffle to avoid predictable pattern
$codeArray = str_split($code);
shuffle($codeArray);
return implode('', $codeArray);
}

3068
index.php

File diff suppressed because it is too large Load Diff

116
ws/server.php Normal file
View File

@ -0,0 +1,116 @@
<?php
// Very basic WebSocket server in pure PHP
$host = '0.0.0.0';
$port = 8080;
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_set_option($socket, SOL_SOCKET, SO_REUSEADDR, 1);
socket_bind($socket, $host, $port);
socket_listen($socket);
$clients = [$socket];
echo "Server started on $host:$port\n";
while (true) {
$read = $clients;
$write = $except = null;
socket_select($read, $write, $except, null);
if (in_array($socket, $read)) {
$new_socket = socket_accept($socket);
$clients[] = $new_socket;
$header = socket_read($new_socket, 1024);
perform_handshake($header, $new_socket, $host, $port);
echo "New client connected\n";
$key = array_search($socket, $read);
unset($read[$key]);
}
foreach ($read as $client_socket) {
$data = socket_read($client_socket, 65536);
if ($data === false || strlen($data) === 0) {
$key = array_search($client_socket, $clients);
unset($clients[$key]);
socket_close($client_socket);
echo "Client disconnected\n";
continue;
}
$decoded_data = unmask($data);
if ($decoded_data) {
$payload = json_decode($decoded_data, true);
if ($payload) {
// If it's already a structured message, just broadcast it
$response = mask($decoded_data);
} else {
// Fallback for raw text
$response = mask(json_encode(['type' => 'message', 'data' => $decoded_data]));
}
foreach ($clients as $client) {
if ($client != $socket && $client != $client_socket) {
@socket_write($client, $response, strlen($response));
}
}
}
}
}
function perform_handshake($receved_header, $client_conn, $host, $port) {
$headers = array();
$lines = preg_split("/\r\n/", $receved_header);
foreach ($lines as $line) {
$line = chop($line);
if (preg_match('/\A(\S+): (.*)\z/', $line, $matches)) {
$headers[$matches[1]] = $matches[2];
}
}
if (!isset($headers['Sec-WebSocket-Key'])) return;
$secKey = $headers['Sec-WebSocket-Key'];
$secAccept = base64_encode(pack('H*', sha1($secKey . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')));
$upgrade = "HTTP/1.1 101 Switching Protocols\r\n" .
"Upgrade: websocket\r\n" .
"Connection: Upgrade\r\n" .
"Sec-WebSocket-Accept: $secAccept\r\n\r\n";
socket_write($client_conn, $upgrade, strlen($upgrade));
}
function unmask($text) {
if (strlen($text) < 2) return null;
$length = ord($text[1]) & 127;
if ($length == 126) {
if (strlen($text) < 8) return null;
$masks = substr($text, 4, 4);
$data = substr($text, 8);
} elseif ($length == 127) {
if (strlen($text) < 14) return null;
$masks = substr($text, 10, 4);
$data = substr($text, 14);
} else {
if (strlen($text) < 6) return null;
$masks = substr($text, 2, 4);
$data = substr($text, 6);
}
$decoded = "";
for ($i = 0; $i < strlen($data); ++$i) {
$decoded .= $data[$i] ^ $masks[$i % 4];
}
return $decoded;
}
function mask($text) {
$b1 = 0x81; // FIN + Opcode 1 (text)
$length = strlen($text);
if ($length <= 125)
$header = pack('CC', $b1, $length);
elseif ($length > 125 && $length < 65536)
$header = pack('CCn', $b1, 126, $length);
elseif ($length >= 65536)
$header = pack('CCNN', $b1, 127, $length);
return $header . $text;
}