bibis/data-post.php

527 行
14 KiB
PHP

<?php
// Data controller (post section)
// [投稿ファイルについて]
// 投稿ファイルは POST_DIR (data/post) フォルダーに保存される。
// 投稿ファイルの名前にメタ情報が含まれる。
// 形式:
// "{$datetime}.id{$id}.us{$userid}.to{未使用}.th{$thread_id}.txt"
// 内訳:
// $datetime: 投稿日時(ISO形式、タイムゾーンは「Z」固定)
// $id: 投稿ID(md5ハッシュ)
// $userid: 投稿者$id (ゲストの場合「-」が入る)
// {未使用}: 現在は常に「-」が入る
// $thread_id: 返信先スレッドの投稿ID (返信以外なら「-」が入る)
// ファイル名先頭の日附は、1日あたりの投稿数の判定や、
// 新着順のソートなどに使用できる。
// 投稿ファイルの内容はヘッダー情報、本文の2つのパートに分れ、
// 両者の間には「-」だけの行がある。
// ヘッダー情報のパートは「項目=値」の形式で屬性を持つ。
// 内訳:
// title=件名
// attachment_id=添付ファイルのID
// [添附ファイルについて]
// 添附ファイルは ATTACHMENT_DIR (data/attachment) フォルダーに gzip 形式で保存される。
// ファイル名の形式: {id}.gz
// idにはランダムな英數字が入る。
// [聯投防止について]
// 投稿されてから一定時間は同じ内容の投稿ができなくなる。
// 制限の狀態は LIMIT_TSV (data/limit.tsv) に保存される。
// 制限は「投稿から記號を削除した後の文字列」で判定される。
// 制限される時間などは make_repeating_info を參照。
$post_title_cache = [];
function load_postfile($filepath) {
$text = file_get_contents($filepath);
$split = preg_split('/^-$/m', $text, 2);
$text = null;
$head = mbtrim($split[0]);
$body = mbtrim($split[1] ?? '');
$split = null;
$parsed = parse_key_value($head, ['title', 'attachment_id']);
$head = null;
$title = $parsed['title'] ?? '';
$attachment_id = $parsed['attachment_id'] ?? '';
$parsed = null;
$deleted = $body === '';
if ($deleted) {
$body = DELETEDTEXT;
$title = '';
$attachment_id = '';
}
return compact('deleted', 'title', 'body', 'attachment_id');
}
function get_post_metadata($file, $deleted = false) {
// Filename's head is datetime
$datetime = substr($file, 0, 20);
$is_future = $datetime > date('Y-m-d\\TH:i:s\\Z');
$date = new Datetime($datetime);
if (TIMEZONE !== null) {
$date->setTimezone(new DateTimeZone(TIMEZONE));
}
$format = 'Y-m-d';
if (!HIDE_TIME || $is_future) { $format .= ' H:i'; }
$time = $date->format($format);
$format = null;
$date = null;
// TODO:これだとファイル名がマッチしない時に予期せぬ結果になる
// 投稿ID: ".id{$id}."
$id = preg_replace('/.+\.id([^.]+)\..+/', '\1', $file);
// 返信先スレッドID: ".th{$id}."
$thread_id = preg_replace('/.+\.th([^.]+)\..+/', '\1', $file);
if ($thread_id === NOREPLY_ID) { $thread_id = ''; }
// 投稿ユーザーID: ".us{$id}"
$userid = '';
if (!$deleted) {
$userid = preg_replace('/.+\.us([^.]+)\..+/', '\1', $file);
}
if ($userid === GUEST_ID) {
$userid = '';
}
$is_guest = $userid === '';
$thread_title = '';
if ($thread_id != '') {
$thread_title = load_post_title_by_id($thread_id);
}
if ($thread_id != '' && $thread_title != '') {
$thread_title = '無題#' . mb_substr($thread_id, 0, 7);
}
$detail_url = sitebase('post/?id=' . $id);
$delete_url = sitebase('post-delete/?id=' . $id);
$user_url = $is_guest ? '' : sitebase('user/?id=' . $userid);
$thread_url = $thread_id != '' ? sitebase('post/?id=' . $thread_id) : '';
$is_mine = !$is_guest && $userid === ($_SESSION['user']['id'] ?? '');
// too long, sorry.
return compact('id', 'is_guest', 'userid', 'detail_url', 'delete_url', 'user_url', 'datetime', 'time', 'thread_id', 'thread_title', 'thread_url', 'is_mine', 'is_future');
}
function load_post($filepath) {
$files = null;
$detail = load_postfile($filepath);
$deleted = $detail['deleted'];
$title = $detail['title'];
$body_raw = $detail['body'];
$body = url_to_link(nl2br(htmlspecialchars($body_raw), false));
$attachment_id = $detail['attachment_id'];
$detail = null;
$metadata = get_post_metadata(basename($filepath), $deleted);
$userid = $metadata['userid'];
$users = load_users();
$username = GUESTNAME;
if ($deleted) {
$username = DELETEDNAME;
}
elseif ($userid != '') {
$username = $users[$userid]['username'] ?? 'UNKNOWN';
}
$attachments = [];
if ($attachment_id != '') {
$attachments[] = [
'url' => sitebase('attachment/?id=' . $attachment_id),
'name' => '添付画像(Image)',
];
}
return array_merge(
$metadata,
compact('username', 'deleted', 'title', 'body', 'body_raw', 'attachments'),
);
}
function load_post_by_id($id) {
if (!file_exists(POST_DIR)) {
die(POST_DIR.': ディレクトリは存在しません。');
}
$files = glob(POST_DIR . '2*Z*.id' . $id . '.*.txt');
if (sizeof($files) !== 1) {
$files = null;
return null;
}
$filepath = $files[0];
return load_post($filepath);
}
function search_post($options = []) {
if (!file_exists(POST_DIR)) {
die(POST_DIR.': ディレクトリは存在しません。');
}
$pagesize = $options['pagesize'] ?? 0;
$has_paging = $pagesize > 0;
$thread = false;
$pager_prefix = $options['pager_prefix'] ?? '?page=';
$key = $options['key'] ?? '';
$value = $options['value'] ?? '';
$page = $options['page'] ?? 1;
$options = null;
$pattern = '2*Z*.txt';
if ($key === 'userid' && $value != '') {
$pattern = "2*Z*.us{$value}.*.txt";
}
elseif ($key == 'thread' && $value >= 0) {
// 'th' is last
$pattern = "2*Z*.th{$value}.txt";
$thread = true;
}
$key = null;
$value = null;
$now = date('Y-m-d\\TH:i:s\\Z');
$files = glob(POST_DIR . $pattern);
$files = array_filter(
$files,
function ($v) use($now) {
$datetime = substr(basename($v), 0, 20);
return $datetime <= $now;
},
ARRAY_FILTER_USE_BOTH
);
if (!$thread) {
$files = array_reverse($files);
}
$total = sizeof($files);
if ($has_paging) {
$last_page = (int)ceil($total / $pagesize);
}
else {
$last_page = 0;
}
if ($page < 1) { $page = 1; }
if ($page > $last_page) { $page = $last_page; }
if ($has_paging) {
$tail = array_slice($files, ($page - 1) * $pagesize, $pagesize);
}
else {
$tail = $files;
}
$files = null;
$post_list = [];
foreach ($tail as $filepath) {
$post_list[] = load_post($filepath);
}
$filepath = null;
$tail = null;
$has_pager = $last_page > 1;
$pager = [];
if ($has_paging && $has_pager) {
if ($page > 1) {
$pager['after'] = $pager_prefix . ($page - 1);
}
if ($page < $last_page) {
$pager['before'] = $pager_prefix . ($page + 1);
}
$pager['pagesize'] = POSTS_PER_PAGE;
}
return compact('post_list', 'total', 'page', 'pager', 'last_page');
}
function add_post($userid, $title, $body, $attachment_id, $file_hash, &$post_id_ref, $thread_id, $spooftime) {
// TODO:引数は配列の方が良い?
// TODO:$post_id_refの参照渡しはイマイチ
if ($thread_id !== NOREPLY_ID) {
// 返信の場合件名を捨てる
$title = '';
}
$text = '';
if ($title) {
$text .= 'title=' . $title . PHP_EOL;
}
if (isset($attachment_id)) {
$text .= 'attachment_id=' . $attachment_id . PHP_EOL;
}
$text .= '-' . PHP_EOL . $body . PHP_EOL;
$id = md5(bin2hex(random_bytes(256)) . $userid . $title . $body);
$dto = new DateTime($spooftime ? '+' . (mt_rand(60 * 60 * 3, 60 * 60 * 27 - 1)) . 'seconds' : '');
$datetime = $dto->format('Y-m-d\\TH:i:s\\Z');
$timestamp = $dto->getTimestamp();
unset($dto);
$file = "{$datetime}.id{$id}.us{$userid}.to-.th{$thread_id}.txt";
if ($body !== '.' || $file_hash === '') {
$error = check_repeating_info($body, $datetime);
if ($error) { return ['すでに同様の書き込み有り(削除済みを含む)。連投防止。']; }
}
if ($file_hash != '') {
$error = check_repeating_info($body, $datetime, $file_hash);
if ($error) { return ['すでに同じ画像有り(削除済みを含む)。連投防止。']; }
}
if (!mkdir_p(POST_DIR)) {
die("mkdir_pを実行に失敗しました。");
}
$result = file_put_contents(POST_DIR . $file, $text, LOCK_EX);
if ($result === false) { return ['書き込みの保存に失敗。']; }
touch(POST_DIR . $file, $timestamp, $timestamp);
chmod(POST_DIR . $file, 0644);
if ($body !== '.' || $file_hash === '') {
add_repeating_info(make_repeating_info($body, $datetime));
}
if ($file_hash != '') {
add_repeating_info(make_repeating_info($body, $datetime, $file_hash));
}
$post_id_ref = $id;
return [];
}
function check_uploaded_image($key) {
$size = $_FILES[$key]['size'] ?? 0;
if ($size <= 0) { return ['画像ファイルが不正。']; }
else if ($size > 1024 * 500) { ['画像ファイルは 500 kb 以内。']; }
$buffer = file_get_contents($_FILES[$key]['tmp_name']);
$type = get_image_type($buffer);
$buffer = null;
if (!isset($type)) { return ['画像ファイルが不正。']; }
return [];
}
function save_uploaded_image($key, $attachment_id) {
$buffer = file_get_contents($_FILES[$key]['tmp_name']);
$type = get_image_type($buffer);
if (!isset($type)) { on_error(400, ['画像ファイルが不正。']); }
if (!$result = mkdir_p(ATTACHMENT_DIR, 0700)) {
return ['フォルダの作成に失敗。'];
}
$target = ATTACHMENT_DIR . $attachment_id . '.gz';
if (!$gz = gzopen($target, 'w1')) { return ['画像の書き込みに失敗。']; }
$result = gzwrite($gz, $buffer);
if ($result === false) { return ['画像の書き込みに失敗。']; }
gzclose($gz);
chmod($target, 0777);
return [];
}
function delete_post($id) {
$files = glob(POST_DIR . '2*Z*.id' . $id . '.*.txt');
if (sizeof($files) !== 1) { return ['書き込みのファイルが存在しない。']; }
$filepath = $files[0];
$files = null;
$attachment_id = load_postfile($filepath)['attachment_id'];
// 存在確認済みだからmkdir_pは不要
if ($attachment_id) {
$attachment_path = ATTACHMENT_DIR . $attachment_id . '.gz';
if (file_exists($attachment_path)) {
$result = unlink($attachment_path);
if (!$result) { return ['ファイルの削除に失敗。']; }
}
}
$result = file_put_contents($filepath, '', LOCK_EX);
if ($result === false) { return ['書き込みの削除に失敗。']; }
return [];
}
function load_threads() {
$pattern = '2*Z.*.th*.txt';
$files = glob(POST_DIR . $pattern);
$files = array_reverse($files);
$threads = [];
foreach ($files as $filepath) {
$metadata = get_post_metadata(basename($filepath));
$is_future = $metadata['is_future'];
$thread_id = $metadata['thread_id'];
$metadata = null;
if ($is_future) { continue; }
if ($thread_id === '') { continue; }
if (isset($threads[$thread_id])) {
$threads[$thread_id]['count'] += 1;
continue;
}
$parent = load_post_by_id($thread_id);
if ($parent['deleted']) { continue; }
$thread = ['count' => 2];
if (($parent['title'] ?? '') != '') {
$thread['title'] = $parent['title'];
}
else {
$thread['title'] = '無題#' . mb_substr($thread_id, 0, 7);
}
$thread['detail_url'] = $parent['detail_url'];
$text = preg_replace("/[\n\r]+/", ' ', $parent['body_raw']);
$thread['sammary'] = htmlspecialchars(mb_substr($text, 0, 30));
if (mb_strlen($text) > 30) {
$thread['sammary'] .= '(...)';
}
$text = null;
$threads[$thread_id] = $thread;
$thread = null;
}
$files = null;
$filepath = null;
return $threads;
}
function make_repeating_info($body, $datetime, $file_hash = '') {
if ($file_hash != '') {
return ['hash' => $file_fash, 'limit' => mb_substr($datetime, 0, 10)];
}
$letter_only = preg_replace('/[^\p{Ll}\p{Lt}\p{Lo}\p{Lu}\p{N}]/u', '', $body);
if (mb_strlen($letter_only) > 0) { $body = $letter_only; }
$hash = md5($body);
$len = mb_strlen($body);
if ($len <= 2) { $size = 16; }
elseif ($len <= 30) { $size = 13; }
elseif ($len <= 40) { $size = 10; }
else { $size = 7; }
return [
'hash' => $hash,
'limit' => mb_substr($datetime, 0, $size),
];
}
function add_repeating_info($repeating_info) {
// NOTE:大して重要ではないためロックしない
$hash = $repeating_info['hash'] ?? '';
$limit = $repeating_info['limit'] ?? '';
$text = "$hash\t$limit" . PHP_EOL;
$result = file_put_contents(LIMIT_TSV, $text, FILE_APPEND | LOCK_EX);
}
function check_repeating_info($body, $datetime, $file_hash = '') {
if (!file_exists(LIMIT_TSV)) {
die(LIMIT_TSV.": ファイルを見つけられません。");
}
$hash = make_repeating_info($body, $datetime, $file_hash)['hash'];
$text = file_get_contents(LIMIT_TSV) ?? '';
$lines = explode(PHP_EOL, $text);
$text = null;
$out = '';
$count = 0;
foreach ($lines as $line) {
if (mbtrim($line) === '') { continue; }
$array = explode("\t", $line);
if (strpos($datetime, $array[1] ?? '') === 0) {
$out .= $line . PHP_EOL;
if ($array[0] === $hash) {
$count += 1;
}
}
unset($array);
unset($line);
}
unset($lines);
$result = file_put_contents(LIMIT_TSV, $out, LOCK_EX);
return $count >= 1;
}
// Util
function count_post() {
if (!file_exists(POST_DIR)) {
die(POST_DIR.': ディレクトリは存在しません。');
}
$pattern = '2*Z*.txt';
$files = glob(POST_DIR . '2*Z*.txt');
$size = sizeof($files);
$files = null;
return $size;
}
function count_post_today() {
if (!file_exists(POST_DIR)) {
die(POST_DIR.': ディレクトリは存在しません。');
}
$date = date('Y-m-d');
$pattern = "{$date}T*Z.*.txt";
$files = glob(POST_DIR . $pattern);
$size = sizeof($files);
$files = null;
return $size;
}
function load_post_title_by_id($id) {
if (!file_exists(POST_DIR)) {
die(POST_DIR.': ディレクトリは存在しません。');
}
global $post_title_cache;
if (isset($post_title_cache[$id])) { return $post_title_cache[$id]; }
$files = glob(POST_DIR . '2*Z*.id' . $id . '.*.txt');
if (sizeof($files) !== 1) {
$files = null;
$post_title_cache[$id] = '';
return '';
}
$filepath = $files[0];
$files = null;
$post_title_cache[$id] = load_postfile($filepath)['title'] ?? '';
return $post_title_cache[$id];
}