Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
975f481fa0 | ||
|
|
eacaa0e950 | ||
|
|
a08b70fdf1 | ||
|
|
224fc0227c | ||
|
|
e988030fc8 | ||
|
|
29d6cdef20 | ||
|
|
9dfebe6d21 | ||
|
|
7dfe53647e | ||
|
|
68972bbe6c | ||
|
|
f56ab7ba2b | ||
|
|
430d57545f | ||
|
|
19b2a3e8d6 | ||
|
|
f1694cdee6 | ||
|
|
e923af9f34 | ||
|
|
04e745874e | ||
|
|
e6a755b1d6 | ||
|
|
4eaf7679f1 | ||
|
|
b6b25ed90d | ||
|
|
a763e6e5b1 | ||
|
|
5329017efa | ||
|
|
09fa2a7096 | ||
|
|
c11359b2d2 | ||
|
|
24671bdbc7 | ||
|
|
04cad1c49b | ||
|
|
08664dda0d | ||
|
|
d8c5bbb218 | ||
|
|
920e26ada3 | ||
|
|
95cfa227e9 | ||
|
|
75e3425c41 | ||
|
|
35c2bad3b7 | ||
|
|
afb642ce41 | ||
|
|
eb7cbe5ace | ||
|
|
e678bcf5aa | ||
|
|
f46a1c7e4b | ||
|
|
794f971b73 | ||
|
|
e3e1dc3456 | ||
|
|
5083b2794c | ||
|
|
f55113bf56 | ||
|
|
e387e07cc6 | ||
|
|
c0b4015a24 | ||
|
|
5b5ac99cae | ||
|
|
f26d0b6abc | ||
|
|
f9c70d9be2 | ||
|
|
2bda3a08f3 | ||
|
|
7241b4052b | ||
|
|
171804c16a | ||
|
|
cd2b57b27d | ||
|
|
e6233598d6 | ||
|
|
77269fa65c | ||
|
|
f20e908050 | ||
|
|
41fa76eec3 | ||
|
|
1a0b8da2ba | ||
|
|
7aa3b7d910 | ||
|
|
79d65ef265 | ||
|
|
f41686b17d | ||
|
|
a35fd4aafb | ||
|
|
c987b0caba | ||
|
|
b5ae307f55 | ||
|
|
f604713529 | ||
|
|
a757fa13ed | ||
|
|
c08cfebf52 | ||
|
|
5494f1e4ee | ||
|
|
e3984686cb | ||
|
|
98888d0370 | ||
|
|
652014e524 | ||
|
|
dfd640b430 | ||
|
|
1440c83ccf | ||
|
|
001690b707 | ||
|
|
7a52251131 | ||
|
|
5d6fd46690 | ||
|
|
40f605d106 | ||
|
|
9c07e1ee23 | ||
|
|
1e73419ffb | ||
|
|
0911f86785 | ||
|
|
c49abcc049 | ||
|
|
ef520f4259 | ||
|
|
4883125cda | ||
|
|
2642f97c8b |
1
VERSIONS/Release_v0.2
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit eacaa0e950815780c3aab4232cc6b8cb49c7105d
|
||||
139
api/emotes.php
Normal 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
@ -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;
|
||||
}
|
||||
27
api/refresh_invite_code.php
Normal 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
@ -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
@ -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;
|
||||
}
|
||||
}
|
||||
158
api_v1_channel_permissions.php
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
BIN
assets/images/custom_emotes/1771261756_shield.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
2951
assets/js/main.js
Normal file
997
assets/js/voice.js
Normal 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);
|
||||
}
|
||||
}
|
||||
BIN
assets/pasted-20260215-151928-c94822be.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
assets/pasted-20260215-153522-763a8478.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
assets/pasted-20260215-162151-f0d79b58.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
assets/pasted-20260215-164921-daccb69a.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
assets/pasted-20260215-214239-79c3300e.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
assets/pasted-20260216-132213-80b79cbe.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
assets/pasted-20260216-162915-c3590120.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
assets/pasted-20260216-163622-0013f90f.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
assets/pasted-20260216-170004-77ff069d.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
assets/pasted-20260216-225623-7f182d79.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
assets/pasted-20260217-121543-09802912.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/pasted-20260217-141526-2008a77e.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
assets/pasted-20260217-143739-c7f88b4b.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
assets/pasted-20260218-160633-6ce717d1.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
assets/pasted-20260218-191112-132e95c8.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
assets/pasted-20260219-121958-92d2aa61.png
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
assets/pasted-20260219-144524-4a4fe8eb.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
assets/pasted-20260219-145037-ae47b380.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
assets/pasted-20260219-164830-4e1d84f3.png
Normal file
|
After Width: | Height: | Size: 449 KiB |
69
auth/login.php
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -0,0 +1 @@
|
||||
hello
|
||||
1
data/test_www.txt
Normal file
@ -0,0 +1 @@
|
||||
hello
|
||||
556
database/schema.sql
Normal 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
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
7
db/migrations/001_create_custom_emotes.sql
Normal 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
|
||||
);
|
||||
13
db/migrations/20260215_add_attachments_and_reactions.sql
Normal 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
|
||||
);
|
||||
2
db/migrations/20260215_channel_status.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- Migration: Add status to channels for voice channels
|
||||
ALTER TABLE channels ADD COLUMN status VARCHAR(255) DEFAULT NULL;
|
||||
26
db/migrations/20260215_dms_and_edits.sql
Normal 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
|
||||
);
|
||||
3
db/migrations/20260215_forum_solution.sql
Normal 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;
|
||||
16
db/migrations/20260215_forum_tags.sql
Normal 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
|
||||
);
|
||||
12
db/migrations/20260215_granular_roles_and_themes.sql
Normal 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';
|
||||
12
db/migrations/20260215_rss_feeds.sql
Normal 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);
|
||||
11
db/migrations/20260216_autorole_channels.sql
Normal 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
|
||||
);
|
||||
12
db/migrations/20260216_rules_acceptance.sql
Normal 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
|
||||
);
|
||||
9
db/migrations/20260217_rss_processed_items.sql
Normal 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
|
||||
);
|
||||
12
db/migrations/20260217_voice_system.sql
Normal 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
|
||||
);
|
||||
2
db/migrations/20260218_make_invite_code_binary.sql
Normal 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;
|
||||
11
db/migrations/20260218_voice_signals.sql
Normal 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)
|
||||
);
|
||||
3
db/migrations/20260218_voice_status.sql
Normal 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;
|
||||
7
db/migrations/20260219_forum_enhancements.sql
Normal 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;
|
||||
10
db/migrations/20260219_voice_whispers.sql
Normal 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;
|
||||
2
db/migrations/add_display_name.sql
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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);
|
||||
}
|
||||
116
ws/server.php
Normal 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;
|
||||
}
|
||||