412 lines
15 KiB
PHP
412 lines
15 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('Cache-Control: no-store');
|
|
|
|
require __DIR__ . '/im_web_bootstrap.php';
|
|
|
|
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
|
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
|
const WEB_MEDIA_TYPE_IMAGE = 1;
|
|
const WEB_MEDIA_TYPE_FILE = 4;
|
|
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
|
|
|
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
|
|
{
|
|
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
|
|
return false;
|
|
}
|
|
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
|
|
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
|
$currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i');
|
|
[$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart));
|
|
[$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd));
|
|
$startMinutes = $startHour * 60 + $startMinute;
|
|
$endMinutes = $endHour * 60 + $endMinute;
|
|
if ($startMinutes === $endMinutes) {
|
|
return true;
|
|
}
|
|
if ($startMinutes < $endMinutes) {
|
|
return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes;
|
|
}
|
|
|
|
return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes;
|
|
}
|
|
|
|
function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool
|
|
{
|
|
if ($role === 'owner') {
|
|
return false;
|
|
}
|
|
|
|
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1');
|
|
$statement->execute([$groupId]);
|
|
$rule = $statement->fetch();
|
|
if (!is_array($rule)) {
|
|
return false;
|
|
}
|
|
|
|
$mode = (string)$rule['mode'];
|
|
if ($mode === 'off') {
|
|
return false;
|
|
}
|
|
if ($mode === 'all_except_admins' && $role === 'admin') {
|
|
return false;
|
|
}
|
|
if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) {
|
|
return false;
|
|
}
|
|
|
|
$scheduleType = (string)$rule['schedule_type'];
|
|
if ($scheduleType === 'manual') {
|
|
return true;
|
|
}
|
|
if ($scheduleType === 'daily') {
|
|
return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs);
|
|
}
|
|
|
|
$startsAtMs = (int)$rule['starts_at_ms'];
|
|
$endsAtMs = (int)$rule['ends_at_ms'];
|
|
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
|
|
}
|
|
|
|
function messageRequestValue(array $payload, string $key, string $default = ''): string
|
|
{
|
|
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
|
|
}
|
|
|
|
function safeUploadName(string $fileName): string
|
|
{
|
|
$name = basename(str_replace('\\', '/', $fileName));
|
|
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file';
|
|
$name = trim($name, " .\t\n\r\0\x0B");
|
|
|
|
return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file';
|
|
}
|
|
|
|
function uploadExtension(string $fileName, string $mime, string $attachmentType): string
|
|
{
|
|
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
|
|
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
|
|
if ($extension === 'jpeg') {
|
|
$extension = 'jpg';
|
|
}
|
|
|
|
if ($attachmentType === 'image') {
|
|
if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) {
|
|
return $extension;
|
|
}
|
|
return match ($mime) {
|
|
'image/jpeg' => 'jpg',
|
|
'image/png' => 'png',
|
|
'image/gif' => 'gif',
|
|
'image/webp' => 'webp',
|
|
'image/bmp', 'image/x-ms-bmp' => 'bmp',
|
|
'image/svg+xml' => 'svg',
|
|
'image/avif' => 'avif',
|
|
'image/heic' => 'heic',
|
|
'image/heif' => 'heif',
|
|
default => 'jpg',
|
|
};
|
|
}
|
|
|
|
if ($extension !== '') {
|
|
return $extension;
|
|
}
|
|
|
|
return 'bin';
|
|
}
|
|
|
|
function detectedUploadMime(string $tmpName): string
|
|
{
|
|
$mime = mime_content_type($tmpName) ?: '';
|
|
if ($mime !== '' && $mime !== 'application/octet-stream') {
|
|
return $mime;
|
|
}
|
|
|
|
$imageInfo = @getimagesize($tmpName);
|
|
if (is_array($imageInfo) && !empty($imageInfo['mime'])) {
|
|
return (string)$imageInfo['mime'];
|
|
}
|
|
|
|
return $mime !== '' ? $mime : 'application/octet-stream';
|
|
}
|
|
|
|
function normalizedImageUploadMime(string $tmpName, string $mime): string
|
|
{
|
|
if (str_starts_with($mime, 'image/')) {
|
|
return $mime;
|
|
}
|
|
|
|
$imageInfo = @getimagesize($tmpName);
|
|
$imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : '';
|
|
if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) {
|
|
return $imageMime;
|
|
}
|
|
|
|
throw new RuntimeException('请选择图片文件');
|
|
}
|
|
|
|
function mediaPublicBaseUrl(): string
|
|
{
|
|
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
|
$scheme = $isHttps ? 'https' : 'http';
|
|
$host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1');
|
|
|
|
return $scheme . '://' . $host . ':' . WEB_MEDIA_PUBLIC_PORT;
|
|
}
|
|
|
|
function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
|
{
|
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
|
throw new RuntimeException('文件上传失败');
|
|
}
|
|
$size = (int)($file['size'] ?? 0);
|
|
$maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES;
|
|
if ($size <= 0 || $size > $maxSize) {
|
|
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
|
|
}
|
|
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
|
|
throw new RuntimeException('媒体存储目录不可写');
|
|
}
|
|
|
|
$tmpName = (string)($file['tmp_name'] ?? '');
|
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
|
throw new RuntimeException('文件上传失败');
|
|
}
|
|
$mime = detectedUploadMime($tmpName);
|
|
if ($attachmentType === 'image') {
|
|
$mime = normalizedImageUploadMime($tmpName, $mime);
|
|
}
|
|
|
|
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
|
|
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
|
|
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
|
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
|
|
throw new RuntimeException('媒体存储目录创建失败');
|
|
}
|
|
|
|
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
|
|
$extension = uploadExtension($originalName, $mime, $attachmentType);
|
|
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
|
|
$targetPath = $targetDir . '/' . $targetName;
|
|
if (!move_uploaded_file($tmpName, $targetPath)) {
|
|
throw new RuntimeException('文件保存失败');
|
|
}
|
|
@chmod($targetPath, 0644);
|
|
|
|
return [
|
|
'type' => $attachmentType,
|
|
'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName,
|
|
'name' => $originalName,
|
|
'mime' => $mime,
|
|
'size' => $size,
|
|
'mediaType' => $mediaType,
|
|
];
|
|
}
|
|
|
|
function storeMessageAttachment(
|
|
PDO $appDb,
|
|
string $messageUid,
|
|
string $conversationType,
|
|
string $target,
|
|
string $sender,
|
|
array $attachment,
|
|
int $timestamp
|
|
): void {
|
|
if ($messageUid === '' || empty($attachment['url'])) {
|
|
return;
|
|
}
|
|
|
|
$statement = $appDb->prepare(
|
|
'insert into `t_web_message_attachments`'
|
|
. ' (`message_uid`, `conversation_type`, `target_id`, `sender_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`, `created_at_ms`)'
|
|
. ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
|
. ' on duplicate key update'
|
|
. ' `attachment_type` = values(`attachment_type`),'
|
|
. ' `url` = values(`url`),'
|
|
. ' `name` = values(`name`),'
|
|
. ' `mime` = values(`mime`),'
|
|
. ' `size_bytes` = values(`size_bytes`)'
|
|
);
|
|
$statement->execute([
|
|
$messageUid,
|
|
$conversationType,
|
|
$target,
|
|
$sender,
|
|
(string)$attachment['type'],
|
|
(string)$attachment['url'],
|
|
(string)$attachment['name'],
|
|
(string)$attachment['mime'],
|
|
(int)$attachment['size'],
|
|
$timestamp,
|
|
]);
|
|
}
|
|
|
|
function attachmentFromRequest(string $messageType): ?array
|
|
{
|
|
if (!in_array($messageType, ['image', 'file'], true)) {
|
|
return null;
|
|
}
|
|
|
|
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
|
if (!is_array($attachmentFile)) {
|
|
respond(400, '缺少上传文件');
|
|
}
|
|
|
|
return saveMessageAttachmentFile($attachmentFile, $messageType);
|
|
}
|
|
|
|
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
|
$isMultipart = stripos($contentTypeHeader, 'multipart/form-data') !== false;
|
|
$payload = $isMultipart ? [] : readJsonPayload();
|
|
$sender = messageRequestValue($payload, 'sender');
|
|
$target = messageRequestValue($payload, 'target');
|
|
$text = messageRequestValue($payload, 'text');
|
|
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
|
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
|
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
|
$attachment = null;
|
|
|
|
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
|
|
respond(400, '缺少发送者、接收者或消息内容');
|
|
}
|
|
|
|
try {
|
|
if ($isGroupConversation) {
|
|
ensureWebImAppTables();
|
|
$appDb = imAppDb();
|
|
$memberStatement = $appDb->prepare(
|
|
'select g.`group_id`, g.`category`, g.`owner_uid`, m.`role`'
|
|
. ' from `t_web_groups` g'
|
|
. ' join `t_web_group_members` m on m.`group_id` = g.`group_id` and m.`status` = 0'
|
|
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
|
|
. ' limit 1'
|
|
);
|
|
$memberStatement->execute([$target, $sender]);
|
|
$group = $memberStatement->fetch();
|
|
if (!$group) {
|
|
respond(403, '你不在该群组中');
|
|
}
|
|
$category = (string)($group['category'] ?? 'group');
|
|
$role = (string)($group['role'] ?? 'member');
|
|
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
|
respond(403, '公告群仅管理员可发言');
|
|
}
|
|
|
|
$now = webImNowMs();
|
|
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
|
|
respond(403, '当前群组处于禁言时段');
|
|
}
|
|
$attachment = attachmentFromRequest($messageType);
|
|
$messageUid = webImMessageUid();
|
|
$insert = $appDb->prepare(
|
|
'insert into `t_web_group_messages`'
|
|
. ' (`message_uid`, `group_id`, `sender_uid`, `text`, `created_at_ms`, `edited_at_ms`, `recalled`)'
|
|
. ' values (?, ?, ?, ?, ?, 0, 0)'
|
|
);
|
|
$insert->execute([$messageUid, $target, $sender, $text, $now]);
|
|
if ($attachment) {
|
|
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $now);
|
|
}
|
|
$updateGroup = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
|
|
$updateGroup->execute([$now, $target]);
|
|
|
|
$nativeError = '';
|
|
try {
|
|
$pushContent = $text !== ''
|
|
? $text
|
|
: ($attachment
|
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
|
: $text);
|
|
$payloadBody = [
|
|
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
|
'searchableContent' => $pushContent,
|
|
'pushContent' => $pushContent,
|
|
'content' => $text,
|
|
'persistFlag' => 3,
|
|
];
|
|
if ($attachment) {
|
|
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
|
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
|
$payloadBody['extra'] = json_encode([
|
|
'name' => (string)$attachment['name'],
|
|
'mime' => (string)$attachment['mime'],
|
|
'size' => (int)$attachment['size'],
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
}
|
|
requireImSuccess(imAdminPost('/admin/message/send', [
|
|
'sender' => $sender,
|
|
'conv' => [
|
|
'type' => 1,
|
|
'target' => $target,
|
|
'line' => 0,
|
|
],
|
|
'payload' => $payloadBody,
|
|
'userMessage' => true,
|
|
]), '原生群消息发送失败');
|
|
} catch (Throwable $error) {
|
|
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
|
}
|
|
|
|
respond(0, 'success', [
|
|
'messageUid' => $messageUid,
|
|
'timestamp' => $now,
|
|
'senderRole' => $role,
|
|
'attachment' => $attachment,
|
|
'nativeError' => $nativeError,
|
|
]);
|
|
}
|
|
|
|
$attachment = attachmentFromRequest($messageType);
|
|
$pushContent = $text !== ''
|
|
? $text
|
|
: ($attachment
|
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
|
: $text);
|
|
$payloadBody = [
|
|
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
|
'searchableContent' => $pushContent,
|
|
'pushContent' => $pushContent,
|
|
'content' => $text,
|
|
'persistFlag' => 3,
|
|
];
|
|
if ($attachment) {
|
|
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
|
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
|
$payloadBody['extra'] = json_encode([
|
|
'name' => (string)$attachment['name'],
|
|
'mime' => (string)$attachment['mime'],
|
|
'size' => (int)$attachment['size'],
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
}
|
|
|
|
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
|
'sender' => $sender,
|
|
'conv' => [
|
|
'type' => 0,
|
|
'target' => $target,
|
|
'line' => 0,
|
|
],
|
|
'payload' => $payloadBody,
|
|
'userMessage' => true,
|
|
]), '发送失败');
|
|
|
|
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
|
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
|
|
if ($attachment && $messageUid !== '') {
|
|
ensureWebImAppTables();
|
|
storeMessageAttachment(imAppDb(), $messageUid, 'single', $target, $sender, $attachment, $timestamp);
|
|
}
|
|
|
|
respond(0, 'success', [
|
|
'messageUid' => $messageUid !== '' ? $messageUid : null,
|
|
'timestamp' => $timestamp,
|
|
'attachment' => $attachment,
|
|
]);
|
|
} catch (Throwable $error) {
|
|
respond(500, $error->getMessage() ?: '发送失败');
|
|
}
|