This commit is contained in:
Lain Iwakura 2025-07-24 05:58:36 +03:00
parent 0e9d6c681f
commit c5ad7e8cb4
No known key found for this signature in database
GPG Key ID: C7C18257F2ADC6F8
17 changed files with 1041 additions and 88 deletions

24
.gitignore vendored
View File

@ -1 +1,25 @@
config.php config.php
.env
uploads/*
!uploads/.gitkeep
*.log
logs/
*.tmp
*.temp
*.swp
*.swo
*~
.vscode/
.idea/
*.sublime-*
.DS_Store
Thumbs.db
desktop.ini
docker-compose.override.yml
*.sql
*.db
cache/
tmp/
sessions/
*.bak
*.backup

50
AnonymousID.php Normal file
View File

@ -0,0 +1,50 @@
<?php
class AnonymousID {
private $db;
private $ip;
private $boardId;
public function __construct($db, $ip, $boardId) {
$this->db = $db;
$this->ip = $ip;
$this->boardId = $boardId;
}
public function getOrCreateID() {
$stmt = $this->db->prepare('
SELECT anonymous_id FROM posts
WHERE ip_address = ? AND board_id = ?
ORDER BY created_at DESC
LIMIT 1
');
$stmt->execute([$this->ip, $this->boardId]);
$existingId = $stmt->fetchColumn();
if ($existingId) {
return $existingId;
}
return $this->generateNewID();
}
private function generateNewID() {
return 'ID:' . sprintf('%06d', mt_rand(1, 999999));
}
public function getIDForThread() {
$stmt = $this->db->prepare('
SELECT anonymous_id FROM threads
WHERE ip_address = ? AND board_id = ?
ORDER BY created_at DESC
LIMIT 1
');
$stmt->execute([$this->ip, $this->boardId]);
$existingId = $stmt->fetchColumn();
if ($existingId) {
return $existingId;
}
return $this->generateNewID();
}
}

17
MarkdownParser.php Normal file
View File

@ -0,0 +1,17 @@
<?php
class MarkdownParser {
public static function parse($text) {
$text = htmlspecialchars($text);
$text = preg_replace('/>>(\d{6})/', '<a href="#$1" class="reply-link">>>$1</a>', $text);
$text = preg_replace('/\*\*(.*?)\*\*/', '<strong>$1</strong>', $text);
$text = preg_replace('/\*(.*?)\*/', '<em>$1</em>', $text);
$text = preg_replace('/_(.*?)_/', '<u>$1</u>', $text);
$text = preg_replace('/~~(.*?)~~/', '<del>$1</del>', $text);
$text = preg_replace('/`(.*?)`/', '<code>$1</code>', $text);
$text = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '<a href="$2" target="_blank">$1</a>', $text);
$text = preg_replace('/^\* (.+)$/m', '<li>$1</li>', $text);
$text = preg_replace('/(<li>.*<\/li>)/s', '<ul>$1</ul>', $text);
$text = nl2br($text);
return $text;
}
}

42
README
View File

@ -1,10 +1,44 @@
mkach - анонимный имиджборд mkach - анонимный имиджборд
Установка: Установка:
1. Создайте базу данных MySQL
2. Импортируйте sql/create.sql 1. Клонируйте репозиторий:
3. Настройте config.php git clone <url>
4. Создайте папку uploads/ с правами 755 cd mkach
2. Создайте базу данных MySQL:
CREATE DATABASE mkach;
CREATE USER 'mkach'@'localhost' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON mkach.* TO 'mkach'@'localhost';
FLUSH PRIVILEGES;
3. Импортируйте схему:
mysql -u mkach -p mkach < sql/create.sql
4. Настройте config.php:
cp config.php.example config.php
# Отредактируйте параметры БД
5. Создайте папку uploads:
mkdir uploads
chmod 755 uploads
6. Настройте Apache:
# Ubuntu/Debian:
sudo cp configs/apache.conf.debian /etc/apache2/sites-available/mkach
sudo a2ensite mkach
sudo systemctl reload apache2
# CentOS/RHEL:
sudo cp configs/apache.conf.centos /etc/httpd/conf.d/mkach.conf
sudo systemctl reload httpd
7. Настройте PHP:
sudo cp configs/php.ini /etc/php/8.1/apache2/conf.d/mkach.ini
sudo systemctl reload apache2
Docker установка:
docker-compose up -d
Особенности: Особенности:
- Анонимные посты без регистрации - Анонимные посты без регистрации

View File

@ -1,8 +1,8 @@
<?php <?php
class RateLimiter { class RateLimiter {
private $db; private $db;
private $maxRequests = 3; private $maxRequests = 30;
private $timeWindow = 30; private $timeWindow = 6;
public function __construct($db) { public function __construct($db) {
$this->db = $db; $this->db = $db;

View File

@ -1,6 +1,9 @@
Listen 65511
<VirtualHost *:65511> <VirtualHost *:65511>
DocumentRoot /var/www/html DocumentRoot /var/www/html
ServerName localhost ServerName localhost
AddDefaultCharset UTF-8
<Directory /var/www/html> <Directory /var/www/html>
AllowOverride All AllowOverride All

257
board.php
View File

@ -1,5 +1,6 @@
<?php <?php
session_start(); session_start();
header('Content-Type: text/html; charset=utf-8');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY'); header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block'); header('X-XSS-Protection: 1; mode=block');
@ -10,6 +11,8 @@ if (!isset($_SESSION['authenticated'])) {
} }
$config = require 'config.php'; $config = require 'config.php';
$boardId = $_GET['board'] ?? 'b';
$threadId = $_GET['thread'] ?? null;
try { try {
$db = new PDO( $db = new PDO(
@ -23,10 +26,13 @@ try {
} }
require_once 'RateLimiter.php'; require_once 'RateLimiter.php';
require_once 'AnonymousID.php';
require_once 'MarkdownParser.php';
$rateLimiter = new RateLimiter($db); $rateLimiter = new RateLimiter($db);
$rateLimiter->cleanup(); $rateLimiter->cleanup();
$ip = $_SERVER['REMOTE_ADDR']; $ip = $_SERVER['REMOTE_ADDR'];
$anonymousID = new AnonymousID($db, $ip, $boardId);
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$rateLimiter->isAllowed($ip)) { if (!$rateLimiter->isAllowed($ip)) {
@ -34,8 +40,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} else { } else {
$message = trim($_POST['message'] ?? ''); $message = trim($_POST['message'] ?? '');
$file = $_FILES['file'] ?? null; $file = $_FILES['file'] ?? null;
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
if ($message || ($file && $file['error'] === UPLOAD_ERR_OK)) { if ($message || $title || ($file && $file['error'] === UPLOAD_ERR_OK)) {
$postId = sprintf('%06d', mt_rand(1, 999999)); $postId = sprintf('%06d', mt_rand(1, 999999));
$fileName = null; $fileName = null;
@ -68,13 +76,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
if (empty($error)) { if (empty($error)) {
$stmt = $db->prepare(' if ($threadId) {
INSERT INTO posts (post_id, message, file_name, file_size, file_type, ip_address) $anonymousId = $anonymousID->getOrCreateID();
VALUES (?, ?, ?, ?, ?, ?) $stmt = $db->prepare('
'); INSERT INTO posts (post_id, thread_id, board_id, message, file_name, file_size, file_type, ip_address, anonymous_id)
$stmt->execute([$postId, $message, $fileName, $fileSize, $fileType, $ip]); VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
');
$stmt->execute([$postId, $threadId, $boardId, $message, $fileName, $fileSize, $fileType, $ip, $anonymousId]);
} else {
header('Location: newthread.php?board=' . $boardId);
exit;
}
header('Location: board.php'); header('Location: ' . $_SERVER['REQUEST_URI']);
exit; exit;
} }
} else { } else {
@ -83,32 +97,77 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
} }
if (isset($_GET['logout'])) {
session_destroy();
header('Location: index.php');
exit;
}
try { try {
$stmt = $db->query('SELECT * FROM posts ORDER BY created_at DESC LIMIT 100'); $db->exec('SET NAMES utf8');
$posts = $stmt->fetchAll(PDO::FETCH_ASSOC); $stmt = $db->prepare('SELECT * FROM boards WHERE board_id = ?');
$stmt->execute([$boardId]);
$board = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$board) {
header('Location: index.php');
exit;
}
} catch (PDOException $e) { } catch (PDOException $e) {
die('Database error'); die('Database error');
} }
if ($threadId) {
try {
$stmt = $db->prepare('
SELECT p.*, t.title as thread_title
FROM posts p
JOIN threads t ON p.thread_id = t.thread_id
WHERE p.thread_id = ? AND p.board_id = ?
ORDER BY p.created_at ASC
');
$stmt->execute([$threadId, $boardId]);
$posts = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($posts)) {
header('Location: board.php?board=' . $boardId);
exit;
}
} catch (PDOException $e) {
die('Database error');
}
} else {
try {
$stmt = $db->prepare('
SELECT t.*,
COUNT(p.id) as post_count,
MAX(p.created_at) as last_post_time
FROM threads t
LEFT JOIN posts p ON t.thread_id = p.thread_id
WHERE t.board_id = ?
GROUP BY t.id
ORDER BY t.updated_at DESC
LIMIT 20
');
$stmt->execute([$boardId]);
$threads = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
die('Database error');
}
}
function formatMessage($message) {
return MarkdownParser::parse($message);
}
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mkach</title> <title>mkach - /<?= htmlspecialchars($boardId) ?>/</title>
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<h1>mkach</h1> <h1><a href="index.php" class="home-link">mkach</a> - /<?= htmlspecialchars($boardId) ?>/ - <?= htmlspecialchars($board['name']) ?></h1>
<div class="header-buttons"> <div class="header-buttons">
<a href="index.php" class="boards-btn">Доски</a>
<a href="?logout=1" class="logout-btn">Выход</a> <a href="?logout=1" class="logout-btn">Выход</a>
</div> </div>
</div> </div>
@ -118,70 +177,126 @@ try {
<div class="error"><?= htmlspecialchars($error) ?></div> <div class="error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<div class="posts-container" id="posts"> <?php if ($threadId): ?>
<?php foreach ($posts as $post): ?> <div class="thread-container">
<div class="post"> <div class="thread-header">
<div class="post-header"> <h2><?= htmlspecialchars($posts[0]['thread_title'] ?? 'Без названия') ?></h2>
<span class="post-id"><?= htmlspecialchars($post['post_id']) ?></span> <a href="board.php?board=<?= htmlspecialchars($boardId) ?>" class="back-link"> Назад к списку</a>
<span class="post-time"><?= date('d.m.Y H:i:s', strtotime($post['created_at'])) ?></span>
</div>
<?php if ($post['file_name']): ?>
<div class="post-file">
<a href="uploads/<?= htmlspecialchars($post['file_name']) ?>" target="_blank">
<img src="uploads/<?= htmlspecialchars($post['file_name']) ?>" alt="File" class="post-image">
</a>
<div class="file-info">
<?= htmlspecialchars($post['file_name']) ?>
(<?= number_format($post['file_size'] / 1024, 1) ?> KB)
</div>
</div>
<?php endif; ?>
<?php if ($post['message']): ?>
<div class="post-message"><?= nl2br(htmlspecialchars($post['message'])) ?></div>
<?php endif; ?>
</div> </div>
<?php endforeach; ?>
</div> <div class="posts-container" id="posts">
<?php foreach ($posts as $post): ?>
<div class="post" id="<?= htmlspecialchars($post['post_id']) ?>">
<div class="post-header">
<span class="post-id"><?= htmlspecialchars($post['post_id']) ?></span>
<?php if ($post['anonymous_id']): ?>
<span class="anonymous-id"><?= htmlspecialchars($post['anonymous_id']) ?></span>
<?php endif; ?>
<span class="post-time"><?= date('d.m.Y H:i:s', strtotime($post['created_at'])) ?></span>
</div>
<?php if ($post['file_name']): ?>
<div class="post-file">
<a href="uploads/<?= htmlspecialchars($post['file_name']) ?>" target="_blank">
<img src="uploads/<?= htmlspecialchars($post['file_name']) ?>" alt="File" class="post-image">
</a>
<div class="file-info">
<?= htmlspecialchars($post['file_name']) ?>
(<?= number_format($post['file_size'] / 1024, 1) ?> KB)
</div>
</div>
<?php endif; ?>
<?php if ($post['message']): ?>
<div class="post-message"><?= formatMessage($post['message']) ?></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<div class="threads-container">
<div class="threads-list" id="threads">
<?php foreach ($threads as $thread): ?>
<div class="thread-item">
<div class="thread-header">
<a href="board.php?board=<?= htmlspecialchars($boardId) ?>&thread=<?= htmlspecialchars($thread['thread_id']) ?>" class="thread-link">
<span class="thread-title"><?= htmlspecialchars($thread['title'] ?? 'Без названия') ?></span>
</a>
<span class="thread-info">
Постов: <?= $thread['post_count'] ?> |
Обновлено: <?= date('d.m.Y H:i', strtotime($thread['last_post_time'] ?? $thread['created_at'])) ?>
<?php if ($thread['anonymous_id']): ?>
| <?= htmlspecialchars($thread['anonymous_id']) ?>
<?php endif; ?>
</span>
</div>
<?php if ($thread['file_name']): ?>
<div class="thread-file">
<a href="uploads/<?= htmlspecialchars($thread['file_name']) ?>" target="_blank">
<img src="uploads/<?= htmlspecialchars($thread['file_name']) ?>" alt="File" class="thread-image">
</a>
</div>
<?php endif; ?>
<?php if ($thread['description']): ?>
<div class="thread-description"><?= formatMessage($thread['description']) ?></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div> </div>
<div class="post-form"> <?php if (!$threadId): ?>
<form method="post" enctype="multipart/form-data"> <div class="new-thread-button">
<div class="form-row"> <a href="newthread.php?board=<?= htmlspecialchars($boardId) ?>" class="new-thread-btn">Создать новый тред</a>
<textarea name="message" placeholder="Сообщение (необязательно)" class="message-input"></textarea> </div>
</div> <?php else: ?>
<div class="form-row"> <div class="post-form">
<input type="file" name="file" accept=".jpg,.jpeg,.png,.gif,.webp" class="file-input"> <form method="post" enctype="multipart/form-data">
<button type="submit" class="send-btn">Отправить</button> <div class="form-row">
</div> <textarea name="message" placeholder="Сообщение (поддерживает Markdown)" class="message-input"></textarea>
</form> </div>
</div> <div class="form-row">
<input type="file" name="file" accept=".jpg,.jpeg,.png,.gif,.webp" class="file-input">
<button type="submit" class="send-btn">Отправить</button>
</div>
</form>
</div>
<?php endif; ?>
</div> </div>
<script> <script>
let isScrolledToBottom = true; let isScrolledToBottom = true;
const postsContainer = document.getElementById('posts'); const container = document.getElementById('posts') || document.getElementById('threads');
postsContainer.addEventListener('scroll', () => { if (container) {
const { scrollTop, scrollHeight, clientHeight } = postsContainer; container.addEventListener('scroll', () => {
isScrolledToBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 1; const { scrollTop, scrollHeight, clientHeight } = container;
}); isScrolledToBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 1;
});
setInterval(() => { setInterval(() => {
fetch(window.location.href) fetch(window.location.href)
.then(response => response.text()) .then(response => response.text())
.then(html => { .then(html => {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html'); const doc = parser.parseFromString(html, 'text/html');
const oldHeight = postsContainer.scrollHeight; const newContainer = doc.getElementById('posts') || doc.getElementById('threads');
postsContainer.innerHTML = doc.getElementById('posts').innerHTML; if (newContainer) {
const oldHeight = container.scrollHeight;
container.innerHTML = newContainer.innerHTML;
if (isScrolledToBottom) { if (isScrolledToBottom) {
postsContainer.scrollTop = postsContainer.scrollHeight; container.scrollTop = container.scrollHeight;
} }
}); }
}, 10000); });
}, 10000);
}
</script> </script>
</body> </body>
</html> </html>

View File

@ -8,6 +8,9 @@ return [
], ],
'access_key' => 'mkalwaysthebest1337', 'access_key' => 'mkalwaysthebest1337',
'upload_path' => 'uploads/', 'upload_path' => 'uploads/',
'max_file_size' => 5242880, 'max_file_size' => 26214400,
'allowed_types' => ['jpg', 'jpeg', 'png', 'gif', 'webp'] 'allowed_types' => ['jpg', 'jpeg', 'png', 'gif', 'webp','JPG','JPEG','PNG','GIF','WEBP'],
'motd' => 'Добро пожаловать на mkach - анонимный имиджборд от МК',
'logo_enabled' => true,
'logo_text' => 'mkach'
]; ];

16
config.php.example Normal file
View File

@ -0,0 +1,16 @@
<?php
return [
'db' => [
'host' => $_ENV['DB_HOST'] ?? 'mysql',
'name' => $_ENV['DB_NAME'] ?? 'mkach',
'user' => $_ENV['DB_USER'] ?? 'mkach',
'pass' => $_ENV['DB_PASS'] ?? 'mkach'
],
'access_key' => '129381923812093780198273098172',
'upload_path' => 'uploads/',
'max_file_size' => 26214400,
'allowed_types' => ['jpg', 'jpeg', 'png', 'gif', 'webp','JPG','JPEG','PNG','GIF','WEBP'],
'motd' => 'Добро пожаловать на mkach - анонимный имиджборд от МК',
'logo_enabled' => true,
'logo_text' => 'mkach'
];

17
configs/apache.conf Normal file
View File

@ -0,0 +1,17 @@
<VirtualHost *:80>
DocumentRoot /var/www/html/mkach
ServerName localhost
AddDefaultCharset UTF-8
<Directory /var/www/html/mkach>
AllowOverride All
Require all granted
</Directory>
<Directory /var/www/html/mkach/uploads>
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/mkach_error.log
CustomLog ${APACHE_LOG_DIR}/mkach_access.log combined
</VirtualHost>

View File

@ -0,0 +1,17 @@
<VirtualHost *:80>
DocumentRoot /var/www/html/mkach
ServerName localhost
AddDefaultCharset UTF-8
<Directory /var/www/html/mkach>
AllowOverride All
Require all granted
</Directory>
<Directory /var/www/html/mkach/uploads>
Require all granted
</Directory>
ErrorLog /var/log/httpd/mkach_error.log
CustomLog /var/log/httpd/mkach_access.log combined
</VirtualHost>

View File

@ -0,0 +1,17 @@
<VirtualHost *:80>
DocumentRoot /var/www/html/mkach
ServerName localhost
AddDefaultCharset UTF-8
<Directory /var/www/html/mkach>
AllowOverride All
Require all granted
</Directory>
<Directory /var/www/html/mkach/uploads>
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/mkach_error.log
CustomLog ${APACHE_LOG_DIR}/mkach_access.log combined
</VirtualHost>

7
configs/php.ini Normal file
View File

@ -0,0 +1,7 @@
upload_max_filesize = 25M
post_max_size = 26M
max_execution_time = 30
memory_limit = 128M
file_uploads = On
max_file_uploads = 1
default_charset = UTF-8

View File

@ -1,8 +1,10 @@
<?php <?php
session_start(); session_start();
header('Content-Type: text/html; charset=utf-8');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY'); header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block'); header('X-XSS-Protection: 1; mode=block');
header('Content-Type: text/html; charset=utf-8');
$config = require 'config.php'; $config = require 'config.php';
@ -11,7 +13,7 @@ if (!isset($_SESSION['authenticated'])) {
$key = $_POST['access_key'] ?? ''; $key = $_POST['access_key'] ?? '';
if (hash('sha256', $key) === hash('sha256', $config['access_key'])) { if (hash('sha256', $key) === hash('sha256', $config['access_key'])) {
$_SESSION['authenticated'] = true; $_SESSION['authenticated'] = true;
header('Location: board.php'); header('Location: index.php');
exit; exit;
} else { } else {
$error = 'Неверный ключ'; $error = 'Неверный ключ';
@ -100,4 +102,68 @@ if (!isset($_SESSION['authenticated'])) {
exit; exit;
} }
header('Location: board.php'); try {
$db = new PDO(
"mysql:host={$config['db']['host']};dbname={$config['db']['name']}",
$config['db']['user'],
$config['db']['pass']
);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
die('Connection failed');
}
try {
$db->exec('SET NAMES utf8');
$stmt = $db->query('SELECT * FROM boards ORDER BY board_id');
$boards = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
die('Database error');
}
if (isset($_GET['logout'])) {
session_destroy();
header('Location: index.php');
exit;
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mkach - Доски</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<div class="header">
<h1><?= $config['logo_enabled'] ? htmlspecialchars($config['logo_text']) : 'mkach' ?></h1>
<div class="header-buttons">
<a href="?logout=1" class="logout-btn">Выход</a>
</div>
</div>
<?php if ($config['motd']): ?>
<div class="motd-container">
<div class="motd"><?= htmlspecialchars($config['motd']) ?></div>
</div>
<?php endif; ?>
<div class="boards-container">
<h2>Доски</h2>
<div class="boards-list">
<?php foreach ($boards as $board): ?>
<div class="board-item">
<a href="board.php?board=<?= htmlspecialchars($board['board_id']) ?>" class="board-link">
<span class="board-id">/<?= htmlspecialchars($board['board_id']) ?>/</span>
<span class="board-name"><?= htmlspecialchars($board['name']) ?></span>
<span class="board-desc"><?= htmlspecialchars($board['description']) ?></span>
</a>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</body>
</html>

186
newthread.php Normal file
View File

@ -0,0 +1,186 @@
<?php
session_start();
header('Content-Type: text/html; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
if (!isset($_SESSION['authenticated'])) {
header('Location: index.php');
exit;
}
$config = require 'config.php';
$boardId = $_GET['board'] ?? 'b';
try {
$db = new PDO(
"mysql:host={$config['db']['host']};dbname={$config['db']['name']}",
$config['db']['user'],
$config['db']['pass']
);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
die('Connection failed');
}
require_once 'RateLimiter.php';
require_once 'AnonymousID.php';
$rateLimiter = new RateLimiter($db);
$rateLimiter->cleanup();
$ip = $_SERVER['REMOTE_ADDR'];
$anonymousID = new AnonymousID($db, $ip, $boardId);
try {
$db->exec('SET NAMES utf8');
$stmt = $db->prepare('SELECT * FROM boards WHERE board_id = ?');
$stmt->execute([$boardId]);
$board = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$board) {
header('Location: index.php');
exit;
}
} catch (PDOException $e) {
die('Database error');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$rateLimiter->isAllowed($ip)) {
$error = 'Слишком много запросов';
} else {
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
$message = trim($_POST['message'] ?? '');
$file = $_FILES['file'] ?? null;
if ($title && ($description || $message || ($file && $file['error'] === UPLOAD_ERR_OK))) {
$threadId = sprintf('%06d', mt_rand(1, 999999));
$postId = sprintf('%06d', mt_rand(1, 999999));
$anonymousId = $anonymousID->getIDForThread();
$fileName = null;
$fileSize = null;
$fileType = null;
if ($file && $file['error'] === UPLOAD_ERR_OK) {
$fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($fileExt, $config['allowed_types'])) {
$error = 'Неподдерживаемый тип файла';
} elseif ($file['size'] > $config['max_file_size']) {
$error = 'Файл слишком большой';
} else {
$uploadDir = $config['upload_path'];
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$fileName = $threadId . '.' . $fileExt;
$filePath = $uploadDir . $fileName;
if (move_uploaded_file($file['tmp_name'], $filePath)) {
$fileSize = $file['size'];
$fileType = $fileExt;
} else {
$error = 'Ошибка загрузки файла';
}
}
}
if (empty($error)) {
$stmt = $db->prepare('
INSERT INTO threads (thread_id, board_id, title, description, file_name, file_size, file_type, ip_address, anonymous_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
');
$stmt->execute([$threadId, $boardId, $title, $description, $fileName, $fileSize, $fileType, $ip, $anonymousId]);
if ($message) {
$stmt = $db->prepare('
INSERT INTO posts (post_id, thread_id, board_id, message, ip_address, anonymous_id)
VALUES (?, ?, ?, ?, ?, ?)
');
$stmt->execute([$postId, $threadId, $boardId, $message, $ip, $anonymousId]);
}
header('Location: board.php?board=' . $boardId . '&thread=' . $threadId);
exit;
}
} else {
$error = 'Введите название треда и хотя бы описание, сообщение или файл';
}
}
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mkach - Создать тред в /<?= htmlspecialchars($boardId) ?>/</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<div class="header">
<h1><a href="index.php" class="home-link">mkach</a> - Создать тред в /<?= htmlspecialchars($boardId) ?>/</h1>
<div class="header-buttons">
<a href="board.php?board=<?= htmlspecialchars($boardId) ?>" class="boards-btn"> Назад</a>
<a href="index.php" class="boards-btn">Доски</a>
<a href="?logout=1" class="logout-btn">Выход</a>
</div>
</div>
<div class="new-thread-container">
<?php if (!empty($error)): ?>
<div class="error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<div class="thread-form">
<h2>Создать новый тред</h2>
<form method="post" enctype="multipart/form-data">
<div class="form-row">
<label>Название треда:</label>
<input type="text" name="title" placeholder="Введите название треда" class="title-input" required>
</div>
<div class="form-row">
<label>Описание:</label>
<textarea name="description" placeholder="Описание треда (поддерживает Markdown)" class="description-input"></textarea>
</div>
<div class="form-row">
<label>Первое сообщение (необязательно):</label>
<textarea name="message" placeholder="Первое сообщение в треде (поддерживает Markdown)" class="message-input"></textarea>
</div>
<div class="form-row">
<label>Файл (необязательно):</label>
<input type="file" name="file" accept=".jpg,.jpeg,.png,.gif,.webp" class="file-input">
</div>
<div class="form-row">
<button type="submit" class="send-btn">Создать тред</button>
<a href="board.php?board=<?= htmlspecialchars($boardId) ?>" class="cancel-btn">Отмена</a>
</div>
</form>
</div>
<div class="markdown-help">
<h3>Поддерживаемые теги Markdown:</h3>
<ul>
<li><code>**жирный**</code> - жирный текст</li>
<li><code>*курсив*</code> - курсив</li>
<li><code>_подчеркнутый_</code> - подчеркнутый</li>
<li><code>~~зачеркнутый~~</code> - зачеркнутый</li>
<li><code>`код`</code> - код</li>
<li><code>>>123456</code> - ссылка на пост</li>
<li><code>[текст](url)</code> - ссылка</li>
<li><code>* элемент</code> - список</li>
</ul>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,14 +1,51 @@
CREATE TABLE boards (
id INT AUTO_INCREMENT PRIMARY KEY,
board_id VARCHAR(10) NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_board_id (board_id)
);
CREATE TABLE threads (
id INT AUTO_INCREMENT PRIMARY KEY,
thread_id VARCHAR(6) NOT NULL UNIQUE,
board_id VARCHAR(10) NOT NULL,
title VARCHAR(255),
description TEXT,
file_name VARCHAR(255),
file_size INT,
file_type VARCHAR(10),
ip_address VARCHAR(45),
anonymous_id VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_thread_id (thread_id),
INDEX idx_board_id (board_id),
INDEX idx_updated_at (updated_at),
INDEX idx_anonymous_id (anonymous_id),
FOREIGN KEY (board_id) REFERENCES boards(board_id)
);
CREATE TABLE posts ( CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
post_id VARCHAR(6) NOT NULL UNIQUE, post_id VARCHAR(6) NOT NULL UNIQUE,
thread_id VARCHAR(6) NOT NULL,
board_id VARCHAR(10) NOT NULL,
message TEXT, message TEXT,
file_name VARCHAR(255), file_name VARCHAR(255),
file_size INT, file_size INT,
file_type VARCHAR(10), file_type VARCHAR(10),
ip_address VARCHAR(45), ip_address VARCHAR(45),
anonymous_id VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_post_id (post_id), INDEX idx_post_id (post_id),
INDEX idx_created_at (created_at) INDEX idx_thread_id (thread_id),
INDEX idx_board_id (board_id),
INDEX idx_created_at (created_at),
INDEX idx_anonymous_id (anonymous_id),
FOREIGN KEY (thread_id) REFERENCES threads(thread_id),
FOREIGN KEY (board_id) REFERENCES boards(board_id)
); );
CREATE TABLE rate_limits ( CREATE TABLE rate_limits (
@ -19,3 +56,7 @@ CREATE TABLE rate_limits (
INDEX idx_ip_action (ip_address, action_type), INDEX idx_ip_action (ip_address, action_type),
INDEX idx_created_at (created_at) INDEX idx_created_at (created_at)
); );
INSERT INTO boards (board_id, name, description) VALUES
('b', 'Random', 'Случайные темы'),
('mk', 'MK', 'MK темы');

View File

@ -34,29 +34,313 @@ body {
color: #800000; color: #800000;
} }
.logout-btn { .home-link {
color: #800000;
text-decoration: none;
}
.home-link:hover {
color: #ff6b6b;
}
.boards-btn, .logout-btn {
background: #d6daf0; background: #d6daf0;
border: 1px solid #b7c5d9; border: 1px solid #b7c5d9;
padding: 5px 10px; padding: 5px 10px;
text-decoration: none; text-decoration: none;
color: #800000; color: #800000;
font-size: 12px; font-size: 12px;
margin-left: 5px;
} }
.logout-btn:hover { .boards-btn:hover, .logout-btn:hover {
background: #e5e9f0; background: #e5e9f0;
} }
.boards-container {
background: #efefef;
border: 1px solid #b7c5d9;
margin-bottom: 10px;
padding: 15px;
}
.boards-container h2 {
color: #800000;
margin-bottom: 15px;
font-size: 16px;
}
.boards-list {
display: grid;
gap: 10px;
}
.board-item {
background: #f0e0d6;
border: 1px solid #d9bfb7;
padding: 10px;
transition: background 0.2s;
}
.board-item:hover {
background: #e5d4c0;
}
.board-link {
text-decoration: none;
color: #800000;
display: block;
}
.board-id {
font-weight: bold;
color: #ff6b6b;
margin-right: 10px;
}
.board-name {
font-weight: bold;
margin-right: 10px;
}
.board-desc {
color: #707070;
font-size: 12px;
}
.motd-container {
background: #efefef;
border: 1px solid #b7c5d9;
margin-bottom: 10px;
padding: 15px;
text-align: center;
}
.motd {
color: #800000;
font-size: 16px;
font-weight: bold;
}
.board-container { .board-container {
background: #efefef; background: #efefef;
border: 1px solid #b7c5d9; border: 1px solid #b7c5d9;
margin-bottom: 10px; margin-bottom: 10px;
} }
.thread-container {
padding: 10px;
}
.thread-header {
background: #f0e0d6;
border: 1px solid #d9bfb7;
padding: 10px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.thread-header h2 {
color: #800000;
font-size: 16px;
}
.back-link {
color: #ff6b6b;
text-decoration: none;
font-size: 12px;
}
.back-link:hover {
text-decoration: underline;
}
.threads-container {
padding: 10px;
}
.threads-list {
max-height: 600px;
overflow-y: auto;
}
.thread-item {
background: #f0e0d6;
border: 1px solid #d9bfb7;
margin-bottom: 10px;
padding: 10px;
transition: background 0.2s;
}
.thread-item:hover {
background: #e5d4c0;
}
.thread-link {
text-decoration: none;
color: #800000;
}
.thread-title {
font-weight: bold;
color: #ff6b6b;
}
.thread-info {
color: #707070;
font-size: 12px;
margin-top: 5px;
display: block;
}
.thread-file {
margin: 10px 0;
}
.thread-image {
max-width: 200px;
max-height: 200px;
border: 1px solid #d9bfb7;
}
.thread-description {
color: #800000;
margin-top: 10px;
padding: 10px;
background: #e5d4c0;
border-left: 3px solid #ff6b6b;
font-size: 13px;
}
.anonymous-id {
color: #ff6b6b;
font-weight: bold;
margin-left: 10px;
font-size: 12px;
}
.new-thread-button {
text-align: center;
margin: 20px 0;
}
.new-thread-btn {
background: #ff6b6b;
border: 1px solid #ff6b6b;
padding: 15px 30px;
color: #fff;
text-decoration: none;
font-size: 16px;
font-weight: bold;
border-radius: 5px;
transition: background 0.2s;
}
.new-thread-btn:hover {
background: #ff5252;
}
.new-thread-container {
background: #efefef;
border: 1px solid #b7c5d9;
margin-bottom: 10px;
padding: 20px;
}
.thread-form {
background: #f0e0d6;
border: 1px solid #d9bfb7;
padding: 20px;
margin-bottom: 20px;
}
.thread-form h2 {
color: #800000;
margin-bottom: 20px;
font-size: 18px;
}
.markdown-help {
background: #f0e0d6;
border: 1px solid #d9bfb7;
padding: 15px;
}
.markdown-help h3 {
color: #800000;
margin-bottom: 10px;
font-size: 14px;
}
.markdown-help ul {
list-style: none;
padding: 0;
}
.markdown-help li {
margin-bottom: 5px;
font-size: 12px;
color: #800000;
}
.markdown-help code {
background: #e5d4c0;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.cancel-btn {
background: #d6daf0;
border: 1px solid #b7c5d9;
padding: 8px 15px;
color: #800000;
text-decoration: none;
font-size: 14px;
margin-left: 10px;
}
.cancel-btn:hover {
background: #e5e9f0;
}
.post-message strong {
font-weight: bold;
}
.post-message em {
font-style: italic;
}
.post-message u {
text-decoration: underline;
}
.post-message del {
text-decoration: line-through;
}
.post-message code {
background: #e5d4c0;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.post-message ul {
margin: 10px 0;
padding-left: 20px;
}
.post-message li {
margin-bottom: 5px;
}
.posts-container { .posts-container {
max-height: 600px; max-height: 600px;
overflow-y: auto; overflow-y: auto;
padding: 10px;
} }
.post { .post {
@ -64,16 +348,19 @@ body {
border: 1px solid #d9bfb7; border: 1px solid #d9bfb7;
margin-bottom: 10px; margin-bottom: 10px;
padding: 10px; padding: 10px;
border-left: 3px solid #ff6b6b;
} }
.post-header { .post-header {
margin-bottom: 8px; margin-bottom: 8px;
font-size: 12px; font-size: 12px;
border-bottom: 1px solid #d9bfb7;
padding-bottom: 5px;
} }
.post-id { .post-id {
font-weight: bold; font-weight: bold;
color: #800000; color: #ff6b6b;
} }
.post-time { .post-time {
@ -100,6 +387,17 @@ body {
.post-message { .post-message {
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
color: #800000;
}
.reply-link {
color: #ffa500;
text-decoration: none;
font-weight: bold;
}
.reply-link:hover {
text-decoration: underline;
} }
.post-form { .post-form {
@ -112,6 +410,38 @@ body {
margin-bottom: 10px; margin-bottom: 10px;
} }
.title-input {
width: 100%;
border: 1px solid #b7c5d9;
padding: 8px;
font-family: inherit;
font-size: 14px;
background: #f0e0d6;
color: #800000;
}
.title-input:focus {
outline: none;
border-color: #ff6b6b;
}
.description-input {
width: 100%;
height: 60px;
border: 1px solid #b7c5d9;
padding: 8px;
font-family: inherit;
font-size: 14px;
resize: vertical;
background: #f0e0d6;
color: #800000;
}
.description-input:focus {
outline: none;
border-color: #ff6b6b;
}
.message-input { .message-input {
width: 100%; width: 100%;
height: 80px; height: 80px;
@ -120,6 +450,13 @@ body {
font-family: inherit; font-family: inherit;
font-size: 14px; font-size: 14px;
resize: vertical; resize: vertical;
background: #f0e0d6;
color: #800000;
}
.message-input:focus {
outline: none;
border-color: #ff6b6b;
} }
.file-input { .file-input {
@ -127,6 +464,8 @@ body {
padding: 5px; padding: 5px;
font-size: 12px; font-size: 12px;
margin-right: 10px; margin-right: 10px;
background: #f0e0d6;
color: #800000;
} }
.send-btn { .send-btn {
@ -136,6 +475,7 @@ body {
color: #800000; color: #800000;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
transition: background 0.2s;
} }
.send-btn:hover { .send-btn:hover {