322 lines
11 KiB
PHP
322 lines
11 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 PROFILE_DISPLAY_NAME_FLAG = 0x01;
|
|
const PROFILE_PORTRAIT_FLAG = 0x02;
|
|
const PROFILE_PORTRAIT_MEDIA_TYPE = 5;
|
|
const IM_LOCAL_MEDIA_ROOT = '/opt/im-server/media';
|
|
const IM_LOCAL_MEDIA_PUBLIC_PORT = 18000;
|
|
|
|
function profileRequestValue(string $key, string $default = ''): string
|
|
{
|
|
return trim((string)($_POST[$key] ?? $_GET[$key] ?? $default));
|
|
}
|
|
|
|
function normalizeProfileUser(array $user): array
|
|
{
|
|
$uid = (string)($user['userId'] ?? $user['uid'] ?? $user['_uid'] ?? '');
|
|
return [
|
|
'uid' => $uid,
|
|
'userId' => $uid,
|
|
'name' => (string)($user['name'] ?? $user['_name'] ?? ''),
|
|
'displayName' => (string)($user['displayName'] ?? $user['_display_name'] ?? $user['name'] ?? $user['_name'] ?? $uid),
|
|
'mobile' => (string)($user['mobile'] ?? $user['_mobile'] ?? ''),
|
|
'portrait' => (string)($user['portrait'] ?? $user['_portrait'] ?? ''),
|
|
'address' => (string)($user['address'] ?? $user['_address'] ?? ''),
|
|
];
|
|
}
|
|
|
|
function tryProfileUserInfo(array $payload): ?array
|
|
{
|
|
try {
|
|
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户资料加载失败');
|
|
return $result ?: null;
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function loadProfileUserFromDb(string $identity): ?array
|
|
{
|
|
$statement = imDb()->prepare(
|
|
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address`'
|
|
. ' from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ?'
|
|
. ' order by case when `_uid` = ? then 0 when `_mobile` = ? then 1 when `_name` = ? then 2 else 3 end'
|
|
. ' limit 1'
|
|
);
|
|
$statement->execute([$identity, $identity, $identity, $identity, $identity, $identity]);
|
|
$row = $statement->fetch();
|
|
return $row ? normalizeProfileUser($row) : null;
|
|
}
|
|
|
|
function mergeProfileUser(array $primary, ?array $fallback): array
|
|
{
|
|
if (!$fallback) {
|
|
return $primary;
|
|
}
|
|
|
|
$fallback = normalizeProfileUser($fallback);
|
|
foreach (['name', 'displayName', 'mobile', 'portrait', 'address'] as $key) {
|
|
if ((string)($primary[$key] ?? '') === '' && (string)($fallback[$key] ?? '') !== '') {
|
|
$primary[$key] = (string)$fallback[$key];
|
|
}
|
|
}
|
|
|
|
return $primary;
|
|
}
|
|
|
|
function persistProfileUserToDb(string $userId, string $displayName, string $portrait): void
|
|
{
|
|
if ($portrait !== '') {
|
|
$statement = imDb()->prepare(
|
|
'update `t_user` set `_display_name` = ?, `_portrait` = ?, `_dt` = ? where `_uid` = ? limit 1'
|
|
);
|
|
$statement->execute([$displayName, $portrait, webImNowMs(), $userId]);
|
|
return;
|
|
}
|
|
|
|
$statement = imDb()->prepare(
|
|
'update `t_user` set `_display_name` = ?, `_dt` = ? where `_uid` = ? limit 1'
|
|
);
|
|
$statement->execute([$displayName, webImNowMs(), $userId]);
|
|
}
|
|
|
|
function loadProfileUser(string $identity, bool $preferMobile = true): array
|
|
{
|
|
$identity = trim($identity);
|
|
if ($identity === '') {
|
|
respond(400, '缺少用户ID');
|
|
}
|
|
|
|
$dbUser = loadProfileUserFromDb($identity);
|
|
if ($dbUser) {
|
|
$fallback = null;
|
|
if ((string)($dbUser['portrait'] ?? '') === '') {
|
|
$fallback = tryProfileUserInfo(['userId' => $dbUser['userId']]);
|
|
if (!$fallback && (string)($dbUser['mobile'] ?? '') !== '') {
|
|
$fallback = tryProfileUserInfo(['mobile' => $dbUser['mobile']]);
|
|
}
|
|
if (!$fallback && (string)($dbUser['name'] ?? '') !== '') {
|
|
$fallback = tryProfileUserInfo(['name' => $dbUser['name']]);
|
|
}
|
|
}
|
|
|
|
return mergeProfileUser($dbUser, $fallback);
|
|
}
|
|
|
|
$user = null;
|
|
if ($preferMobile && preg_match('/^\+?\d{5,32}$/', $identity)) {
|
|
$user = tryProfileUserInfo(['mobile' => $identity]);
|
|
}
|
|
if (!$user) {
|
|
$user = tryProfileUserInfo(['userId' => $identity]);
|
|
}
|
|
if (!$user) {
|
|
$user = tryProfileUserInfo(['name' => $identity]);
|
|
}
|
|
if (!$user) {
|
|
respond(404, '用户资料不存在');
|
|
}
|
|
|
|
return normalizeProfileUser($user);
|
|
}
|
|
|
|
function readProfileJavaSerializedString(string $data, int $offset): ?array
|
|
{
|
|
if ($offset + 3 > strlen($data) || ord($data[$offset]) !== 0x74) {
|
|
return null;
|
|
}
|
|
|
|
$length = (ord($data[$offset + 1]) << 8) | ord($data[$offset + 2]);
|
|
$start = $offset + 3;
|
|
if ($start + $length > strlen($data)) {
|
|
return null;
|
|
}
|
|
|
|
return [substr($data, $start, $length), $start + $length];
|
|
}
|
|
|
|
function profileSessionUserId(string $sessionData): string
|
|
{
|
|
$marker = "\x74\x00\x06userId";
|
|
$offset = strpos($sessionData, $marker);
|
|
if ($offset === false) {
|
|
return '';
|
|
}
|
|
|
|
$decoded = readProfileJavaSerializedString($sessionData, $offset + strlen($marker));
|
|
$userId = is_array($decoded) ? (string)$decoded[0] : '';
|
|
if (!preg_match('/^[A-Za-z0-9_:@.+-]{1,128}$/', $userId)) {
|
|
return '';
|
|
}
|
|
|
|
return $userId;
|
|
}
|
|
|
|
function requireProfileUpdateAuth(string $targetUserId): void
|
|
{
|
|
$authToken = trim((string)($_SERVER['HTTP_AUTHTOKEN'] ?? $_SERVER['HTTP_AUTH_TOKEN'] ?? ''));
|
|
if ($authToken === '') {
|
|
respond(401, '请重新登录后再修改资料');
|
|
}
|
|
|
|
$statement = imAppDb()->prepare('select `session_data` from `shiro_session` where `session_id` = ? limit 1');
|
|
$statement->execute([$authToken]);
|
|
$row = $statement->fetch();
|
|
if (!$row) {
|
|
respond(401, '登录状态已失效,请重新登录');
|
|
}
|
|
|
|
$sessionUserId = profileSessionUserId((string)$row['session_data']);
|
|
if ($sessionUserId === '' || $sessionUserId !== $targetUserId) {
|
|
respond(403, '只能修改自己的资料');
|
|
}
|
|
}
|
|
|
|
function profileUploadedFileExtension(string $fileName, string $mime): string
|
|
{
|
|
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
|
|
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
|
|
if ($extension === 'jpeg') {
|
|
$extension = 'jpg';
|
|
}
|
|
|
|
$allowedExtensions = ['jpg', 'png', 'gif', 'webp', 'bmp', 'ico'];
|
|
if (in_array($extension, $allowedExtensions, 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/x-icon', 'image/vnd.microsoft.icon' => 'ico',
|
|
default => 'jpg',
|
|
};
|
|
}
|
|
|
|
function profilePublicMediaBaseUrl(): string
|
|
{
|
|
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
|
|
$scheme = $isHttps ? 'https' : 'http';
|
|
$host = (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1');
|
|
$host = trim(preg_replace('/:\d+$/', '', $host) ?: '127.0.0.1');
|
|
|
|
return $scheme . '://' . $host . ':' . IM_LOCAL_MEDIA_PUBLIC_PORT;
|
|
}
|
|
|
|
function uploadProfilePortrait(array $file): string
|
|
{
|
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
|
throw new RuntimeException('头像上传失败');
|
|
}
|
|
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 5 * 1024 * 1024) {
|
|
throw new RuntimeException('头像不能超过5MB');
|
|
}
|
|
|
|
$tmpName = (string)($file['tmp_name'] ?? '');
|
|
$mime = mime_content_type($tmpName) ?: '';
|
|
if (!str_starts_with($mime, 'image/')) {
|
|
throw new RuntimeException('头像必须是图片文件');
|
|
}
|
|
|
|
if (!is_dir(IM_LOCAL_MEDIA_ROOT) || !is_writable(IM_LOCAL_MEDIA_ROOT)) {
|
|
throw new RuntimeException('头像存储目录不可写');
|
|
}
|
|
|
|
$datePath = 'fs/' . PROFILE_PORTRAIT_MEDIA_TYPE . '/' . date('Y/m/d/H/i/s');
|
|
$targetDir = rtrim(IM_LOCAL_MEDIA_ROOT, '/') . '/' . $datePath;
|
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
|
|
throw new RuntimeException('头像存储目录创建失败');
|
|
}
|
|
|
|
$extension = profileUploadedFileExtension((string)($file['name'] ?? 'avatar.jpg'), $mime);
|
|
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
|
|
$targetPath = $targetDir . '/' . $targetName;
|
|
if (!move_uploaded_file($tmpName, $targetPath)) {
|
|
throw new RuntimeException('头像文件保存失败');
|
|
}
|
|
@chmod($targetPath, 0644);
|
|
|
|
return profilePublicMediaBaseUrl() . '/' . $datePath . '/' . $targetName;
|
|
}
|
|
|
|
try {
|
|
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
|
$isMultipart = stripos($contentType, 'multipart/form-data') !== false;
|
|
$payload = $isMultipart ? [] : readJsonPayload();
|
|
$action = $isMultipart
|
|
? profileRequestValue('action', 'update')
|
|
: trim((string)($payload['action'] ?? 'get'));
|
|
$userId = $isMultipart
|
|
? profileRequestValue('userId')
|
|
: trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
|
|
|
|
$currentProfile = loadProfileUser($userId);
|
|
|
|
if ($action === 'get') {
|
|
respond(0, 'success', ['profile' => $currentProfile]);
|
|
}
|
|
|
|
if ($action !== 'update') {
|
|
respond(400, '不支持的资料操作');
|
|
}
|
|
requireProfileUpdateAuth((string)$currentProfile['userId']);
|
|
|
|
$displayName = $isMultipart
|
|
? profileRequestValue('displayName')
|
|
: trim((string)($payload['displayName'] ?? ''));
|
|
if ($displayName === '') {
|
|
respond(400, '昵称不能为空');
|
|
}
|
|
if (mb_strlen($displayName, 'UTF-8') > 64) {
|
|
respond(400, '昵称不能超过64个字');
|
|
}
|
|
|
|
$portrait = '';
|
|
if (!empty($_FILES['portraitFile']) && ($_FILES['portraitFile']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
|
$portrait = uploadProfilePortrait($_FILES['portraitFile']);
|
|
}
|
|
|
|
$flag = PROFILE_DISPLAY_NAME_FLAG;
|
|
$userInfo = [
|
|
'userId' => $currentProfile['userId'],
|
|
'displayName' => $displayName,
|
|
];
|
|
if ($portrait !== '') {
|
|
$flag |= PROFILE_PORTRAIT_FLAG;
|
|
$userInfo['portrait'] = $portrait;
|
|
}
|
|
|
|
requireImSuccess(
|
|
imAdminPost('/admin/user/update', [
|
|
'flag' => $flag,
|
|
'userInfo' => $userInfo,
|
|
]),
|
|
'用户资料保存失败'
|
|
);
|
|
persistProfileUserToDb($currentProfile['userId'], $displayName, $portrait);
|
|
|
|
$updatedProfile = loadProfileUser($currentProfile['userId'], false);
|
|
if ($portrait === '' && (string)$updatedProfile['portrait'] === '' && (string)($currentProfile['portrait'] ?? '') !== '') {
|
|
$updatedProfile['portrait'] = (string)$currentProfile['portrait'];
|
|
}
|
|
if ($portrait !== '' && (string)$updatedProfile['portrait'] === '') {
|
|
$updatedProfile['portrait'] = $portrait;
|
|
}
|
|
if ((string)$updatedProfile['displayName'] === '') {
|
|
$updatedProfile['displayName'] = $displayName;
|
|
}
|
|
|
|
respond(0, 'success', ['profile' => $updatedProfile]);
|
|
} catch (Throwable $error) {
|
|
respond(500, $error->getMessage() ?: '用户资料服务暂不可用');
|
|
}
|