Commit 47820191eaa5b2806d86055eb0111b6b26425d20
1 parent
2154352e
refactor: use phpdotenv for wechat config
Showing
3 changed files
with
602 additions
and
592 deletions
| @@ -28,3 +28,7 @@ REDIS_PREFIX=ai_ | @@ -28,3 +28,7 @@ REDIS_PREFIX=ai_ | ||
| 28 | # 心跳配置 | 28 | # 心跳配置 |
| 29 | # HEARTBEAT_INTERVAL=30 | 29 | # HEARTBEAT_INTERVAL=30 |
| 30 | # HEARTBEAT_CHECK_INTERVAL=10 | 30 | # HEARTBEAT_CHECK_INTERVAL=10 |
| 31 | + | ||
| 32 | +# 微信小程序配置 | ||
| 33 | +WECHAT_APP_ID=wx6daaa8a7e2889a50 | ||
| 34 | +WECHAT_APP_SECRET=d394d5f0f82b328a2754d53027370282 |
| @@ -7,8 +7,8 @@ | @@ -7,8 +7,8 @@ | ||
| 7 | return [ | 7 | return [ |
| 8 | // 微信小程序配置 | 8 | // 微信小程序配置 |
| 9 | 'wechat' => [ | 9 | 'wechat' => [ |
| 10 | - 'app_id' => getenv('WECHAT_APP_ID') ?: 'wx6daaa8a7e2889a50', | ||
| 11 | - 'app_secret' => getenv('WECHAT_APP_SECRET') ?: 'd394d5f0f82b328a2754d53027370282', | 10 | + 'app_id' => getenv('WECHAT_APP_ID') ?: '', |
| 11 | + 'app_secret' => getenv('WECHAT_APP_SECRET') ?: '', | ||
| 12 | ], | 12 | ], |
| 13 | 13 | ||
| 14 | // Session 配置 | 14 | // Session 配置 |
| 1 | -<?php | ||
| 2 | -use Workerman\Worker; | ||
| 3 | -use Workerman\Timer; | ||
| 4 | -use Tos\TosClient; | ||
| 5 | -use Tos\Exception\TosClientException; | ||
| 6 | -use Tos\Exception\TosServerException; | ||
| 7 | -use Tos\Model\PutObjectInput; | ||
| 8 | - | ||
| 9 | -require_once __DIR__ . '/vendor/autoload.php'; | ||
| 10 | -require_once __DIR__ . '/auth.php'; | ||
| 11 | -require_once __DIR__ . '/device.php'; | ||
| 12 | - | ||
| 13 | -// 加载配置文件 | ||
| 14 | -$config = require __DIR__ . '/config.php'; | ||
| 15 | - | ||
| 16 | -// Define Heartbeat Interval | ||
| 17 | -define('HEARTBEAT_TIME', $config['heartbeat']['interval']); | ||
| 18 | - | ||
| 19 | -// Database Connection | ||
| 20 | -$pdo = null; | ||
| 21 | -try { | ||
| 22 | - $db_config = $config['database']; | ||
| 23 | - $dsn = "mysql:host={$db_config['host']};port={$db_config['port']};dbname={$db_config['database']};charset=utf8mb4"; | ||
| 24 | - $pdo = new PDO($dsn, $db_config['username'], $db_config['password'], [ | ||
| 25 | - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | ||
| 26 | - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC | ||
| 27 | - ]); | ||
| 28 | - echo "✅ Connected to MySQL at {$db_config['host']}:{$db_config['port']}\n"; | ||
| 29 | -} catch (Exception $e) { | ||
| 30 | - echo "⚠️ MySQL Connection Failed: " . $e->getMessage() . "\n"; | ||
| 31 | -} | ||
| 32 | - | ||
| 33 | -// Redis Connection | ||
| 34 | -$redis = null; | ||
| 35 | -try { | ||
| 36 | - $redis_config = $config['redis']; | ||
| 37 | - | ||
| 38 | - $params = [ | ||
| 39 | - 'scheme' => 'tcp', | ||
| 40 | - 'host' => $redis_config['host'], | ||
| 41 | - 'port' => $redis_config['port'], | ||
| 42 | - ]; | ||
| 43 | - | ||
| 44 | - if (!empty($redis_config['password'])) { | ||
| 45 | - $params['password'] = $redis_config['password']; | ||
| 46 | - } | ||
| 47 | - | ||
| 48 | - // Predis Client | ||
| 49 | - $redis = new Predis\Client($params, ['prefix' => $redis_config['prefix']]); | ||
| 50 | - $redis->connect(); | ||
| 51 | - echo "✅ Connected to Redis at {$redis_config['host']}:{$redis_config['port']} ({$redis_config['prefix']})\n"; | ||
| 52 | -} catch (Exception $e) { | ||
| 53 | - echo "⚠️ Redis Connection Failed: " . $e->getMessage() . "\n"; | ||
| 54 | -} | ||
| 55 | - | ||
| 56 | -// Initialize Services | ||
| 57 | -$authService = $pdo ? new AuthService($pdo, $config, $redis) : null; | ||
| 58 | -$deviceService = $pdo ? new DeviceService($pdo, $config) : null; | ||
| 59 | - | ||
| 60 | -// Create a WebSocket worker | ||
| 61 | -$ws_host = $config['server']['websocket_host']; | ||
| 62 | -$ws_port = $config['server']['websocket_port']; | ||
| 63 | -$ws_worker = new Worker("websocket://{$ws_host}:{$ws_port}"); | ||
| 64 | - | ||
| 65 | -// Store connections (Memory for now, can move to Redis later) | ||
| 66 | -$clients = []; // ClientID -> Connection | ||
| 67 | -$devices = []; // DeviceID -> Connection | ||
| 68 | - | ||
| 69 | -$ws_worker->onWorkerStart = function ($worker) use ($config) { | ||
| 70 | - $ws_host = $config['server']['websocket_host']; | ||
| 71 | - $ws_port = $config['server']['websocket_port']; | ||
| 72 | - echo "Relay Server Started on {$ws_host}:{$ws_port}\n"; | ||
| 73 | - | ||
| 74 | - // Heartbeat check | ||
| 75 | - $check_interval = $config['heartbeat']['check_interval']; | ||
| 76 | - Timer::add($check_interval, function () use ($worker) { | ||
| 77 | - $time_now = time(); | ||
| 78 | - foreach ($worker->connections as $connection) { | ||
| 79 | - // Check if connection is alive possibly? | ||
| 80 | - // Workerman handles basic disconnects, but we can enforce ping logic here if needed | ||
| 81 | - } | ||
| 82 | - }); | ||
| 83 | -}; | ||
| 84 | - | ||
| 85 | -$ws_worker->onConnect = function ($connection) { | ||
| 86 | - echo "New connection: " . $connection->id . "\n"; | ||
| 87 | - $connection->authVerified = false; | ||
| 88 | -}; | ||
| 89 | - | ||
| 90 | -$ws_worker->onMessage = function ($connection, $data) use (&$clients, &$devices, $authService, $deviceService) { | ||
| 91 | - $msg = json_decode($data, true); | ||
| 92 | - if (!$msg || !isset($msg['type'])) { | ||
| 93 | - return; | ||
| 94 | - } | ||
| 95 | - | ||
| 96 | - // 1. Authenticate / Register | ||
| 97 | - if ($msg['type'] === 'register') { | ||
| 98 | - if ($msg['role'] === 'device') { | ||
| 99 | - // Device 注册 - 验证密钥 | ||
| 100 | - $deviceId = $msg['id'] ?? null; | ||
| 101 | - $secret = $msg['secret'] ?? null; | ||
| 102 | - | ||
| 103 | - if (!$deviceId) { | ||
| 104 | - $connection->send(json_encode(['type' => 'error', 'msg' => 'Device ID required'])); | ||
| 105 | - return; | ||
| 106 | - } | ||
| 107 | - | ||
| 108 | - // 验证设备(如果有 deviceService) | ||
| 109 | - if ($deviceService && $secret) { | ||
| 110 | - if (!$deviceService->verifyDevice($deviceId, $secret)) { | ||
| 111 | - $connection->send(json_encode(['type' => 'error', 'msg' => 'Invalid device credentials'])); | ||
| 112 | - $connection->close(); | ||
| 113 | - return; | ||
| 114 | - } | ||
| 115 | - // 更新设备状态为在线 | ||
| 116 | - $deviceService->updateDeviceStatus($deviceId, 'online'); | ||
| 117 | - } | ||
| 118 | - | ||
| 119 | - $devices[$deviceId] = $connection; | ||
| 120 | - $connection->deviceId = $deviceId; | ||
| 121 | - $connection->role = 'device'; | ||
| 122 | - $connection->authVerified = true; | ||
| 123 | - $connection->send(json_encode(['type' => 'ack', 'status' => 'registered'])); | ||
| 124 | - echo "Device Registered: $deviceId\n"; | ||
| 125 | - | ||
| 126 | - } elseif ($msg['role'] === 'client') { | ||
| 127 | - // Mini Program 注册 - 验证 Token | ||
| 128 | - $clientId = $msg['id'] ?? null; | ||
| 129 | - $token = $msg['token'] ?? null; | ||
| 130 | - | ||
| 131 | - if (!$clientId) { | ||
| 132 | - $connection->send(json_encode(['type' => 'error', 'msg' => 'Client ID required'])); | ||
| 133 | - return; | ||
| 134 | - } | ||
| 135 | - | ||
| 136 | - // 验证 Token(如果有 authService 和 token) | ||
| 137 | - $user = null; | ||
| 138 | - if ($authService && $token) { | ||
| 139 | - $user = $authService->verifyToken($token); | ||
| 140 | - if (!$user) { | ||
| 141 | - $connection->send(json_encode(['type' => 'error', 'msg' => 'Invalid or expired token'])); | ||
| 142 | - $connection->close(); | ||
| 143 | - return; | ||
| 144 | - } | ||
| 145 | - $connection->userId = $user['id']; | ||
| 146 | - $connection->userPhone = $user['phone']; | ||
| 147 | - } | ||
| 148 | - | ||
| 149 | - $clients[$clientId] = $connection; | ||
| 150 | - $connection->clientId = $clientId; | ||
| 151 | - $connection->role = 'client'; | ||
| 152 | - $connection->authVerified = true; | ||
| 153 | - $connection->send(json_encode([ | ||
| 154 | - 'type' => 'ack', | ||
| 155 | - 'status' => 'connected', | ||
| 156 | - 'user' => $user | ||
| 157 | - ])); | ||
| 158 | - echo "Client Connected: $clientId" . ($user ? " (User: {$user['phone']})" : "") . "\n"; | ||
| 159 | - } | ||
| 160 | - return; | ||
| 161 | - } | ||
| 162 | - | ||
| 163 | - if (!$connection->authVerified) { | ||
| 164 | - $connection->close(); | ||
| 165 | - return; | ||
| 166 | - } | ||
| 167 | - | ||
| 168 | - // 2. Proxy Logic | ||
| 169 | - | ||
| 170 | - // Client -> Device | ||
| 171 | - if ($msg['type'] === 'proxy' && $connection->role === 'client') { | ||
| 172 | - $targetDeviceId = $msg['targetDeviceId'] ?? null; | ||
| 173 | - | ||
| 174 | - // 验证用户是否有权限访问该设备 | ||
| 175 | - if ($deviceService && isset($connection->userId)) { | ||
| 176 | - if (!$deviceService->canAccessDevice($connection->userId, $targetDeviceId)) { | ||
| 177 | - $connection->send(json_encode(['type' => 'error', 'msg' => 'No permission to access this device'])); | ||
| 178 | - return; | ||
| 179 | - } | ||
| 180 | - } | ||
| 181 | - | ||
| 182 | - if ($targetDeviceId && isset($devices[$targetDeviceId])) { | ||
| 183 | - $payload = $msg['payload']; | ||
| 184 | - // Wrap it so device knows who sent it | ||
| 185 | - $forwardMsg = [ | ||
| 186 | - 'type' => 'cmd:execute', | ||
| 187 | - 'fromClientId' => $connection->clientId, | ||
| 188 | - 'fromUserId' => $connection->userId ?? null, | ||
| 189 | - 'payload' => $payload | ||
| 190 | - ]; | ||
| 191 | - $devices[$targetDeviceId]->send(json_encode($forwardMsg)); | ||
| 192 | - echo "Forwarded msg from Client {$connection->clientId} to Device {$targetDeviceId}\n"; | ||
| 193 | - } else { | ||
| 194 | - $connection->send(json_encode(['type' => 'error', 'msg' => 'Device offline or not found'])); | ||
| 195 | - } | ||
| 196 | - } | ||
| 197 | - | ||
| 198 | - // Device -> Client | ||
| 199 | - if ($msg['type'] === 'proxy_response' && $connection->role === 'device') { | ||
| 200 | - $targetClientId = $msg['targetClientId'] ?? null; | ||
| 201 | - if ($targetClientId && isset($clients[$targetClientId])) { | ||
| 202 | - $payload = $msg['payload']; | ||
| 203 | - $forwardMsg = [ | ||
| 204 | - 'type' => 'response', | ||
| 205 | - 'fromDeviceId' => $connection->deviceId, | ||
| 206 | - 'payload' => $payload | ||
| 207 | - ]; | ||
| 208 | - $clients[$targetClientId]->send(json_encode($forwardMsg)); | ||
| 209 | - echo "Forwarded response from Device {$connection->deviceId} to Client {$targetClientId}\n"; | ||
| 210 | - } | ||
| 211 | - } | ||
| 212 | -}; | ||
| 213 | - | ||
| 214 | -$ws_worker->onClose = function ($connection) use (&$clients, &$devices, $deviceService) { | ||
| 215 | - if (isset($connection->role)) { | ||
| 216 | - if ($connection->role === 'device' && isset($connection->deviceId)) { | ||
| 217 | - unset($devices[$connection->deviceId]); | ||
| 218 | - // 更新设备状态为离线 | ||
| 219 | - if ($deviceService) { | ||
| 220 | - $deviceService->updateDeviceStatus($connection->deviceId, 'offline'); | ||
| 221 | - } | ||
| 222 | - echo "Device disconnected: {$connection->deviceId}\n"; | ||
| 223 | - } elseif ($connection->role === 'client' && isset($connection->clientId)) { | ||
| 224 | - unset($clients[$connection->clientId]); | ||
| 225 | - echo "Client disconnected: {$connection->clientId}\n"; | ||
| 226 | - } | ||
| 227 | - } | ||
| 228 | -}; | ||
| 229 | - | ||
| 230 | -// --------------------------------------------------------- | ||
| 231 | -// [New] HTTP Server for file uploads and static serving | ||
| 232 | -// --------------------------------------------------------- | ||
| 233 | -$http_host = $config['server']['http_host']; | ||
| 234 | -$http_port = $config['server']['http_port']; | ||
| 235 | -$http_worker = new Worker("http://{$http_host}:{$http_port}"); | ||
| 236 | -$http_worker->count = $config['server']['worker_count']; | ||
| 237 | -$http_worker->onMessage = function ($connection, $request) use ($config, $authService, $deviceService) { | ||
| 238 | - $path = $request->path(); | ||
| 239 | - $method = $request->method(); | ||
| 240 | - | ||
| 241 | - // Helper: JSON Response | ||
| 242 | - $jsonResponse = function ($data, $status = 200) use ($connection) { | ||
| 243 | - $connection->send(new \Workerman\Protocols\Http\Response( | ||
| 244 | - $status, | ||
| 245 | - ['Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*'], | ||
| 246 | - json_encode($data) | ||
| 247 | - )); | ||
| 248 | - }; | ||
| 249 | - | ||
| 250 | - // CORS Preflight | ||
| 251 | - if ($method === 'OPTIONS') { | ||
| 252 | - $connection->send(new \Workerman\Protocols\Http\Response(200, [ | ||
| 253 | - 'Access-Control-Allow-Origin' => '*', | ||
| 254 | - 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS', | ||
| 255 | - 'Access-Control-Allow-Headers' => 'Content-Type, Authorization', | ||
| 256 | - ], '')); | ||
| 257 | - return; | ||
| 258 | - } | ||
| 259 | - | ||
| 260 | - // ==================== Auth API ==================== | ||
| 261 | - | ||
| 262 | - // POST /api/auth/login - 微信登录 | ||
| 263 | - if ($path === '/api/auth/login' && $method === 'POST') { | ||
| 264 | - if (!$authService) { | ||
| 265 | - $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500); | ||
| 266 | - return; | ||
| 267 | - } | ||
| 268 | - | ||
| 269 | - $body = json_decode($request->rawBody(), true) ?: []; | ||
| 270 | - $code = $body['code'] ?? null; | ||
| 271 | - $phone = $body['phone'] ?? null; | ||
| 272 | - $phoneCode = $body['phoneCode'] ?? null; | ||
| 273 | - | ||
| 274 | - if (!$code) { | ||
| 275 | - $jsonResponse(['ok' => false, 'error' => 'code is required'], 400); | ||
| 276 | - return; | ||
| 277 | - } | ||
| 278 | - | ||
| 279 | - $result = $authService->wechatLogin($code, $phone, $phoneCode); | ||
| 280 | - $jsonResponse($result, $result['ok'] ? 200 : 400); | ||
| 281 | - return; | ||
| 282 | - } | ||
| 283 | - | ||
| 284 | - // POST /api/auth/verify - 验证 Token | ||
| 285 | - if ($path === '/api/auth/verify' && $method === 'POST') { | ||
| 286 | - if (!$authService) { | ||
| 287 | - $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500); | ||
| 288 | - return; | ||
| 289 | - } | ||
| 290 | - | ||
| 291 | - $body = json_decode($request->rawBody(), true) ?: []; | ||
| 292 | - $token = $body['token'] ?? null; | ||
| 293 | - | ||
| 294 | - if (!$token) { | ||
| 295 | - $jsonResponse(['ok' => false, 'error' => 'token is required'], 400); | ||
| 296 | - return; | ||
| 297 | - } | ||
| 298 | - | ||
| 299 | - $user = $authService->verifyToken($token); | ||
| 300 | - if ($user) { | ||
| 301 | - $jsonResponse(['ok' => true, 'user' => $user]); | ||
| 302 | - } else { | ||
| 303 | - $jsonResponse(['ok' => false, 'error' => 'Invalid or expired token'], 401); | ||
| 304 | - } | ||
| 305 | - return; | ||
| 306 | - } | ||
| 307 | - | ||
| 308 | - // POST /api/auth/logout - 注销 | ||
| 309 | - if ($path === '/api/auth/logout' && $method === 'POST') { | ||
| 310 | - if (!$authService) { | ||
| 311 | - $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500); | ||
| 312 | - return; | ||
| 313 | - } | ||
| 314 | - | ||
| 315 | - $body = json_decode($request->rawBody(), true) ?: []; | ||
| 316 | - $token = $body['token'] ?? null; | ||
| 317 | - | ||
| 318 | - if ($token) { | ||
| 319 | - $authService->logout($token); | ||
| 320 | - } | ||
| 321 | - $jsonResponse(['ok' => true]); | ||
| 322 | - return; | ||
| 323 | - } | ||
| 324 | - | ||
| 325 | - // ==================== Device API ==================== | ||
| 326 | - | ||
| 327 | - // Helper: Get user from token | ||
| 328 | - $getUser = function () use ($request, $authService) { | ||
| 329 | - $authHeader = $request->header('Authorization'); | ||
| 330 | - if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) { | ||
| 331 | - return null; | ||
| 332 | - } | ||
| 333 | - $token = substr($authHeader, 7); | ||
| 334 | - return $authService ? $authService->verifyToken($token) : null; | ||
| 335 | - }; | ||
| 336 | - | ||
| 337 | - // POST /api/device/bind - 绑定设备 | ||
| 338 | - if ($path === '/api/device/bind' && $method === 'POST') { | ||
| 339 | - if (!$deviceService) { | ||
| 340 | - $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500); | ||
| 341 | - return; | ||
| 342 | - } | ||
| 343 | - | ||
| 344 | - $user = $getUser(); | ||
| 345 | - if (!$user) { | ||
| 346 | - $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401); | ||
| 347 | - return; | ||
| 348 | - } | ||
| 349 | - | ||
| 350 | - $body = json_decode($request->rawBody(), true) ?: []; | ||
| 351 | - $deviceId = $body['deviceId'] ?? null; | ||
| 352 | - $deviceSecret = $body['deviceSecret'] ?? null; | ||
| 353 | - | ||
| 354 | - if (!$deviceId) { | ||
| 355 | - $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400); | ||
| 356 | - return; | ||
| 357 | - } | ||
| 358 | - | ||
| 359 | - $result = $deviceService->bindDevice($user['id'], $deviceId, $deviceSecret); | ||
| 360 | - $jsonResponse($result, $result['ok'] ? 200 : 400); | ||
| 361 | - return; | ||
| 362 | - } | ||
| 363 | - | ||
| 364 | - // POST /api/device/unbind - 解绑设备 | ||
| 365 | - if ($path === '/api/device/unbind' && $method === 'POST') { | ||
| 366 | - if (!$deviceService) { | ||
| 367 | - $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500); | ||
| 368 | - return; | ||
| 369 | - } | ||
| 370 | - | ||
| 371 | - $user = $getUser(); | ||
| 372 | - if (!$user) { | ||
| 373 | - $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401); | ||
| 374 | - return; | ||
| 375 | - } | ||
| 376 | - | ||
| 377 | - $body = json_decode($request->rawBody(), true) ?: []; | ||
| 378 | - $deviceId = $body['deviceId'] ?? null; | ||
| 379 | - | ||
| 380 | - if (!$deviceId) { | ||
| 381 | - $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400); | ||
| 382 | - return; | ||
| 383 | - } | ||
| 384 | - | ||
| 385 | - $result = $deviceService->unbindDevice($user['id'], $deviceId); | ||
| 386 | - $jsonResponse($result, $result['ok'] ? 200 : 400); | ||
| 387 | - return; | ||
| 388 | - } | ||
| 389 | - | ||
| 390 | - // GET /api/device/list - 获取绑定的设备列表 | ||
| 391 | - if ($path === '/api/device/list' && $method === 'GET') { | ||
| 392 | - if (!$deviceService) { | ||
| 393 | - $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500); | ||
| 394 | - return; | ||
| 395 | - } | ||
| 396 | - | ||
| 397 | - $user = $getUser(); | ||
| 398 | - if (!$user) { | ||
| 399 | - $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401); | ||
| 400 | - return; | ||
| 401 | - } | ||
| 402 | - | ||
| 403 | - $devices = $deviceService->getUserDevices($user['id']); | ||
| 404 | - $jsonResponse(['ok' => true, 'devices' => $devices]); | ||
| 405 | - return; | ||
| 406 | - } | ||
| 407 | - | ||
| 408 | - // POST /api/device/primary - 设置主设备 | ||
| 409 | - if ($path === '/api/device/primary' && $method === 'POST') { | ||
| 410 | - if (!$deviceService) { | ||
| 411 | - $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500); | ||
| 412 | - return; | ||
| 413 | - } | ||
| 414 | - | ||
| 415 | - $user = $getUser(); | ||
| 416 | - if (!$user) { | ||
| 417 | - $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401); | ||
| 418 | - return; | ||
| 419 | - } | ||
| 420 | - | ||
| 421 | - $body = json_decode($request->rawBody(), true) ?: []; | ||
| 422 | - $deviceId = $body['deviceId'] ?? null; | ||
| 423 | - | ||
| 424 | - if (!$deviceId) { | ||
| 425 | - $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400); | ||
| 426 | - return; | ||
| 427 | - } | ||
| 428 | - | ||
| 429 | - $result = $deviceService->setPrimaryDevice($user['id'], $deviceId); | ||
| 430 | - $jsonResponse($result, $result['ok'] ? 200 : 400); | ||
| 431 | - return; | ||
| 432 | - } | ||
| 433 | - | ||
| 434 | - // ==================== File API ==================== | ||
| 435 | - | ||
| 436 | - // 1. Static File Serving (Simple implementation) | ||
| 437 | - if (strpos($path, '/uploads/') === 0) { | ||
| 438 | - $file = __DIR__ . $path; | ||
| 439 | - if (is_file($file)) { | ||
| 440 | - $connection->send(new \Workerman\Protocols\Http\Response( | ||
| 441 | - 200, | ||
| 442 | - ['Content-Type' => mime_content_type($file)], | ||
| 443 | - file_get_contents($file) | ||
| 444 | - )); | ||
| 445 | - return; | ||
| 446 | - } | ||
| 447 | - } | ||
| 448 | - | ||
| 449 | - // 2. Upload Handler | ||
| 450 | - if ($path === '/upload') { | ||
| 451 | - $files = $request->file(); | ||
| 452 | - | ||
| 453 | - if (empty($files['file'])) { | ||
| 454 | - $connection->send(new \Workerman\Protocols\Http\Response(400, [], json_encode(['ok' => false, 'error' => 'No file']))); | ||
| 455 | - return; | ||
| 456 | - } | ||
| 457 | - | ||
| 458 | - $file = $files['file']; | ||
| 459 | - | ||
| 460 | - // Validate Size | ||
| 461 | - $max_size = $config['upload']['max_size']; | ||
| 462 | - if ($file['size'] > $max_size) { | ||
| 463 | - $max_mb = round($max_size / 1024 / 1024); | ||
| 464 | - $connection->send(new \Workerman\Protocols\Http\Response(400, [], json_encode(['ok' => false, 'error' => "File too large (Max {$max_mb}MB)"]))); | ||
| 465 | - return; | ||
| 466 | - } | ||
| 467 | - | ||
| 468 | - // Validate Extension | ||
| 469 | - $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); | ||
| 470 | - $allowed = $config['upload']['allowed_extensions']; | ||
| 471 | - if (!in_array($ext, $allowed)) { | ||
| 472 | - $connection->send(new \Workerman\Protocols\Http\Response(400, [], json_encode(['ok' => false, 'error' => 'File type not allowed']))); | ||
| 473 | - return; | ||
| 474 | - } | ||
| 475 | - | ||
| 476 | - // TOS Configuration | ||
| 477 | - $tos_config = $config['tos']; | ||
| 478 | - | ||
| 479 | - try { | ||
| 480 | - $client = new TosClient([ | ||
| 481 | - 'region' => $tos_config['region'], | ||
| 482 | - 'endpoint' => $tos_config['endpoint'], | ||
| 483 | - 'ak' => $tos_config['ak'], | ||
| 484 | - 'sk' => $tos_config['sk'], | ||
| 485 | - ]); | ||
| 486 | - | ||
| 487 | - // Generate Key | ||
| 488 | - $uuid = bin2hex(random_bytes(8)); | ||
| 489 | - $userPhone = $config['upload']['default_user']; | ||
| 490 | - $objectKey = "clawdbot/{$userPhone}/{$uuid}.{$ext}"; | ||
| 491 | - $bucket = $tos_config['bucket']; | ||
| 492 | - | ||
| 493 | - // Read file content | ||
| 494 | - $contentFn = fopen($file['tmp_name'], 'r'); | ||
| 495 | - | ||
| 496 | - // Upload using Object Input | ||
| 497 | - $input = new PutObjectInput($bucket, $objectKey, $contentFn); | ||
| 498 | - $input->setACL('public-read'); | ||
| 499 | - | ||
| 500 | - $client->putObject($input); | ||
| 501 | - | ||
| 502 | - if (is_resource($contentFn)) { | ||
| 503 | - fclose($contentFn); | ||
| 504 | - } | ||
| 505 | - | ||
| 506 | - // Generate URL | ||
| 507 | - $url = "https://{$bucket}.{$tos_config['endpoint']}/{$objectKey}"; | ||
| 508 | - | ||
| 509 | - $connection->send(json_encode([ | ||
| 510 | - 'ok' => true, | ||
| 511 | - 'url' => $url | ||
| 512 | - ])); | ||
| 513 | - | ||
| 514 | - } catch (Exception $e) { | ||
| 515 | - $connection->send(new \Workerman\Protocols\Http\Response(500, [], json_encode(['ok' => false, 'error' => 'Upload failed: ' . $e->getMessage()]))); | ||
| 516 | - } | ||
| 517 | - return; | ||
| 518 | - } | ||
| 519 | - | ||
| 520 | - // 3. Generate Pre-Signed URL for Direct Upload | ||
| 521 | - if (strpos($path, '/tos/sign') === 0) { | ||
| 522 | - $query = $request->get(); | ||
| 523 | - $filename = $query['filename'] ?? 'file_' . time(); | ||
| 524 | - $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); | ||
| 525 | - | ||
| 526 | - // Validation | ||
| 527 | - $allowed = $config['upload']['allowed_extensions']; | ||
| 528 | - if (!in_array($ext, $allowed) && $ext !== '') { | ||
| 529 | - // If no extension providing, we might just allow it or fail. | ||
| 530 | - // Ideally client should provide full filename with extension. | ||
| 531 | - } | ||
| 532 | - | ||
| 533 | - // TOS Configuration | ||
| 534 | - $tos_config = $config['tos']; | ||
| 535 | - | ||
| 536 | - try { | ||
| 537 | - $client = new TosClient([ | ||
| 538 | - 'region' => $tos_config['region'], | ||
| 539 | - 'endpoint' => $tos_config['endpoint'], | ||
| 540 | - 'ak' => $tos_config['ak'], | ||
| 541 | - 'sk' => $tos_config['sk'], | ||
| 542 | - ]); | ||
| 543 | - | ||
| 544 | - $uuid = bin2hex(random_bytes(8)); | ||
| 545 | - $userPhone = $config['upload']['default_user']; | ||
| 546 | - // Ensure filename is safe | ||
| 547 | - $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', $filename); | ||
| 548 | - if (!$safeName) | ||
| 549 | - $safeName = 'unnamed'; | ||
| 550 | - | ||
| 551 | - $objectKey = "clawdbot/{$userPhone}/direct_{$uuid}_{$safeName}"; | ||
| 552 | - $bucket = $tos_config['bucket']; | ||
| 553 | - | ||
| 554 | - // Generate Pre-Signed PUT URL (Valid for 15 mins) | ||
| 555 | - // Note: The SDK method name might vary slightly based on version, | ||
| 556 | - // but `preSignedURL` is standard for TOS PHP SDK v2. | ||
| 557 | - $input = new \Tos\Model\PreSignedURLInput( | ||
| 558 | - 'PUT', | ||
| 559 | - $tos_config['bucket'], | ||
| 560 | - $objectKey, | ||
| 561 | - 300 // 5 minutes validity | ||
| 562 | - ); | ||
| 563 | - | ||
| 564 | - // Add content-type if known? client will send it. | ||
| 565 | - // For simple PUT, we just sign the method and resource. | ||
| 566 | - | ||
| 567 | - $output = $client->preSignedURL($input); | ||
| 568 | - $signedUrl = $output->getSignedUrl(); | ||
| 569 | - | ||
| 570 | - // Public Access URL (Assuming bucket is public-read or we use signed Get URL) | ||
| 571 | - // For this project, we used public-read ACL in previous code, so we assume public access. | ||
| 572 | - $publicUrl = "https://{$tos_config['bucket']}.{$tos_config['endpoint']}/{$objectKey}"; | ||
| 573 | - | ||
| 574 | - $connection->send(json_encode([ | ||
| 575 | - 'ok' => true, | ||
| 576 | - 'uploadUrl' => $signedUrl, | ||
| 577 | - 'publicUrl' => $publicUrl, | ||
| 578 | - 'key' => $objectKey | ||
| 579 | - ])); | ||
| 580 | - | ||
| 581 | - } catch (Exception $e) { | ||
| 582 | - $connection->send(new \Workerman\Protocols\Http\Response(500, [], json_encode(['ok' => false, 'error' => $e->getMessage()]))); | ||
| 583 | - } | ||
| 584 | - return; | ||
| 585 | - } | ||
| 586 | - | ||
| 587 | - $connection->send("Moltbot Relay HTTP Server"); | ||
| 588 | -}; | ||
| 589 | - | ||
| 590 | -Worker::runAll(); | 1 | +<?php |
| 2 | +use Workerman\Worker; | ||
| 3 | +use Workerman\Timer; | ||
| 4 | +use Tos\TosClient; | ||
| 5 | +use Tos\Exception\TosClientException; | ||
| 6 | +use Tos\Exception\TosServerException; | ||
| 7 | +use Tos\Model\PutObjectInput; | ||
| 8 | + | ||
| 9 | +require_once __DIR__ . '/vendor/autoload.php'; | ||
| 10 | +require_once __DIR__ . '/auth.php'; | ||
| 11 | +require_once __DIR__ . '/device.php'; | ||
| 12 | + | ||
| 13 | +// Load .env | ||
| 14 | +if (class_exists('Dotenv\Dotenv') && file_exists(__DIR__ . '/.env')) { | ||
| 15 | + $dotenv = Dotenv\Dotenv::createImmutable(__DIR__); | ||
| 16 | + $dotenv->load(); | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +// 加载配置文件 | ||
| 20 | +$config = require __DIR__ . '/config.php'; | ||
| 21 | + | ||
| 22 | +// Define Heartbeat Interval | ||
| 23 | +define('HEARTBEAT_TIME', $config['heartbeat']['interval']); | ||
| 24 | + | ||
| 25 | +// Database Connection | ||
| 26 | +$pdo = null; | ||
| 27 | +try { | ||
| 28 | + $db_config = $config['database']; | ||
| 29 | + $dsn = "mysql:host={$db_config['host']};port={$db_config['port']};dbname={$db_config['database']};charset=utf8mb4"; | ||
| 30 | + $pdo = new PDO($dsn, $db_config['username'], $db_config['password'], [ | ||
| 31 | + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | ||
| 32 | + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC | ||
| 33 | + ]); | ||
| 34 | + echo "✅ Connected to MySQL at {$db_config['host']}:{$db_config['port']}\n"; | ||
| 35 | +} catch (Exception $e) { | ||
| 36 | + echo "⚠️ MySQL Connection Failed: " . $e->getMessage() . "\n"; | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +// Redis Connection | ||
| 40 | +$redis = null; | ||
| 41 | +try { | ||
| 42 | + $redis_config = $config['redis']; | ||
| 43 | + | ||
| 44 | + $params = [ | ||
| 45 | + 'scheme' => 'tcp', | ||
| 46 | + 'host' => $redis_config['host'], | ||
| 47 | + 'port' => $redis_config['port'], | ||
| 48 | + ]; | ||
| 49 | + | ||
| 50 | + if (!empty($redis_config['password'])) { | ||
| 51 | + $params['password'] = $redis_config['password']; | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + // Predis Client | ||
| 55 | + $redis = new Predis\Client($params, ['prefix' => $redis_config['prefix']]); | ||
| 56 | + $redis->connect(); | ||
| 57 | + echo "✅ Connected to Redis at {$redis_config['host']}:{$redis_config['port']} ({$redis_config['prefix']})\n"; | ||
| 58 | +} catch (Exception $e) { | ||
| 59 | + echo "⚠️ Redis Connection Failed: " . $e->getMessage() . "\n"; | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +// Initialize Services | ||
| 63 | +$authService = $pdo ? new AuthService($pdo, $config, $redis) : null; | ||
| 64 | +$deviceService = $pdo ? new DeviceService($pdo, $config) : null; | ||
| 65 | + | ||
| 66 | +// Create a WebSocket worker | ||
| 67 | +$ws_host = $config['server']['websocket_host']; | ||
| 68 | +$ws_port = $config['server']['websocket_port']; | ||
| 69 | +$ws_worker = new Worker("websocket://{$ws_host}:{$ws_port}"); | ||
| 70 | + | ||
| 71 | +// Store connections (Memory for now, can move to Redis later) | ||
| 72 | +$clients = []; // ClientID -> Connection | ||
| 73 | +$devices = []; // DeviceID -> Connection | ||
| 74 | + | ||
| 75 | +$ws_worker->onWorkerStart = function ($worker) use ($config) { | ||
| 76 | + $ws_host = $config['server']['websocket_host']; | ||
| 77 | + $ws_port = $config['server']['websocket_port']; | ||
| 78 | + echo "Relay Server Started on {$ws_host}:{$ws_port}\n"; | ||
| 79 | + | ||
| 80 | + // Heartbeat check | ||
| 81 | + $check_interval = $config['heartbeat']['check_interval']; | ||
| 82 | + Timer::add($check_interval, function () use ($worker) { | ||
| 83 | + $time_now = time(); | ||
| 84 | + foreach ($worker->connections as $connection) { | ||
| 85 | + // Check if connection is alive possibly? | ||
| 86 | + // Workerman handles basic disconnects, but we can enforce ping logic here if needed | ||
| 87 | + } | ||
| 88 | + }); | ||
| 89 | +}; | ||
| 90 | + | ||
| 91 | +$ws_worker->onConnect = function ($connection) { | ||
| 92 | + echo "New connection: " . $connection->id . "\n"; | ||
| 93 | + $connection->authVerified = false; | ||
| 94 | +}; | ||
| 95 | + | ||
| 96 | +$ws_worker->onMessage = function ($connection, $data) use (&$clients, &$devices, $authService, $deviceService) { | ||
| 97 | + $msg = json_decode($data, true); | ||
| 98 | + if (!$msg || !isset($msg['type'])) { | ||
| 99 | + return; | ||
| 100 | + } | ||
| 101 | + | ||
| 102 | + // 1. Authenticate / Register | ||
| 103 | + if ($msg['type'] === 'register') { | ||
| 104 | + if ($msg['role'] === 'device') { | ||
| 105 | + // Device 注册 - 验证密钥 | ||
| 106 | + $deviceId = $msg['id'] ?? null; | ||
| 107 | + $secret = $msg['secret'] ?? null; | ||
| 108 | + | ||
| 109 | + if (!$deviceId) { | ||
| 110 | + $connection->send(json_encode(['type' => 'error', 'msg' => 'Device ID required'])); | ||
| 111 | + return; | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + // 验证设备(如果有 deviceService) | ||
| 115 | + if ($deviceService && $secret) { | ||
| 116 | + if (!$deviceService->verifyDevice($deviceId, $secret)) { | ||
| 117 | + $connection->send(json_encode(['type' => 'error', 'msg' => 'Invalid device credentials'])); | ||
| 118 | + $connection->close(); | ||
| 119 | + return; | ||
| 120 | + } | ||
| 121 | + // 更新设备状态为在线 | ||
| 122 | + $deviceService->updateDeviceStatus($deviceId, 'online'); | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + $devices[$deviceId] = $connection; | ||
| 126 | + $connection->deviceId = $deviceId; | ||
| 127 | + $connection->role = 'device'; | ||
| 128 | + $connection->authVerified = true; | ||
| 129 | + $connection->send(json_encode(['type' => 'ack', 'status' => 'registered'])); | ||
| 130 | + echo "Device Registered: $deviceId\n"; | ||
| 131 | + | ||
| 132 | + } elseif ($msg['role'] === 'client') { | ||
| 133 | + // Mini Program 注册 - 验证 Token | ||
| 134 | + $clientId = $msg['id'] ?? null; | ||
| 135 | + $token = $msg['token'] ?? null; | ||
| 136 | + | ||
| 137 | + if (!$clientId) { | ||
| 138 | + $connection->send(json_encode(['type' => 'error', 'msg' => 'Client ID required'])); | ||
| 139 | + return; | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + // 验证 Token(如果有 authService 和 token) | ||
| 143 | + $user = null; | ||
| 144 | + if ($authService && $token) { | ||
| 145 | + $user = $authService->verifyToken($token); | ||
| 146 | + if (!$user) { | ||
| 147 | + $connection->send(json_encode(['type' => 'error', 'msg' => 'Invalid or expired token'])); | ||
| 148 | + $connection->close(); | ||
| 149 | + return; | ||
| 150 | + } | ||
| 151 | + $connection->userId = $user['id']; | ||
| 152 | + $connection->userPhone = $user['phone']; | ||
| 153 | + } | ||
| 154 | + | ||
| 155 | + $clients[$clientId] = $connection; | ||
| 156 | + $connection->clientId = $clientId; | ||
| 157 | + $connection->role = 'client'; | ||
| 158 | + $connection->authVerified = true; | ||
| 159 | + $connection->send(json_encode([ | ||
| 160 | + 'type' => 'ack', | ||
| 161 | + 'status' => 'connected', | ||
| 162 | + 'user' => $user | ||
| 163 | + ])); | ||
| 164 | + echo "Client Connected: $clientId" . ($user ? " (User: {$user['phone']})" : "") . "\n"; | ||
| 165 | + } | ||
| 166 | + return; | ||
| 167 | + } | ||
| 168 | + | ||
| 169 | + if (!$connection->authVerified) { | ||
| 170 | + $connection->close(); | ||
| 171 | + return; | ||
| 172 | + } | ||
| 173 | + | ||
| 174 | + // 2. Proxy Logic | ||
| 175 | + | ||
| 176 | + // Client -> Device | ||
| 177 | + if ($msg['type'] === 'proxy' && $connection->role === 'client') { | ||
| 178 | + $targetDeviceId = $msg['targetDeviceId'] ?? null; | ||
| 179 | + | ||
| 180 | + // 验证用户是否有权限访问该设备 | ||
| 181 | + if ($deviceService && isset($connection->userId)) { | ||
| 182 | + if (!$deviceService->canAccessDevice($connection->userId, $targetDeviceId)) { | ||
| 183 | + $connection->send(json_encode(['type' => 'error', 'msg' => 'No permission to access this device'])); | ||
| 184 | + return; | ||
| 185 | + } | ||
| 186 | + } | ||
| 187 | + | ||
| 188 | + if ($targetDeviceId && isset($devices[$targetDeviceId])) { | ||
| 189 | + $payload = $msg['payload']; | ||
| 190 | + // Wrap it so device knows who sent it | ||
| 191 | + $forwardMsg = [ | ||
| 192 | + 'type' => 'cmd:execute', | ||
| 193 | + 'fromClientId' => $connection->clientId, | ||
| 194 | + 'fromUserId' => $connection->userId ?? null, | ||
| 195 | + 'payload' => $payload | ||
| 196 | + ]; | ||
| 197 | + $devices[$targetDeviceId]->send(json_encode($forwardMsg)); | ||
| 198 | + echo "Forwarded msg from Client {$connection->clientId} to Device {$targetDeviceId}\n"; | ||
| 199 | + } else { | ||
| 200 | + $connection->send(json_encode(['type' => 'error', 'msg' => 'Device offline or not found'])); | ||
| 201 | + } | ||
| 202 | + } | ||
| 203 | + | ||
| 204 | + // Device -> Client | ||
| 205 | + if ($msg['type'] === 'proxy_response' && $connection->role === 'device') { | ||
| 206 | + $targetClientId = $msg['targetClientId'] ?? null; | ||
| 207 | + if ($targetClientId && isset($clients[$targetClientId])) { | ||
| 208 | + $payload = $msg['payload']; | ||
| 209 | + $forwardMsg = [ | ||
| 210 | + 'type' => 'response', | ||
| 211 | + 'fromDeviceId' => $connection->deviceId, | ||
| 212 | + 'payload' => $payload | ||
| 213 | + ]; | ||
| 214 | + $clients[$targetClientId]->send(json_encode($forwardMsg)); | ||
| 215 | + echo "Forwarded response from Device {$connection->deviceId} to Client {$targetClientId}\n"; | ||
| 216 | + } | ||
| 217 | + } | ||
| 218 | +}; | ||
| 219 | + | ||
| 220 | +$ws_worker->onClose = function ($connection) use (&$clients, &$devices, $deviceService) { | ||
| 221 | + if (isset($connection->role)) { | ||
| 222 | + if ($connection->role === 'device' && isset($connection->deviceId)) { | ||
| 223 | + unset($devices[$connection->deviceId]); | ||
| 224 | + // 更新设备状态为离线 | ||
| 225 | + if ($deviceService) { | ||
| 226 | + $deviceService->updateDeviceStatus($connection->deviceId, 'offline'); | ||
| 227 | + } | ||
| 228 | + echo "Device disconnected: {$connection->deviceId}\n"; | ||
| 229 | + } elseif ($connection->role === 'client' && isset($connection->clientId)) { | ||
| 230 | + unset($clients[$connection->clientId]); | ||
| 231 | + echo "Client disconnected: {$connection->clientId}\n"; | ||
| 232 | + } | ||
| 233 | + } | ||
| 234 | +}; | ||
| 235 | + | ||
| 236 | +// --------------------------------------------------------- | ||
| 237 | +// [New] HTTP Server for file uploads and static serving | ||
| 238 | +// --------------------------------------------------------- | ||
| 239 | +$http_host = $config['server']['http_host']; | ||
| 240 | +$http_port = $config['server']['http_port']; | ||
| 241 | +$http_worker = new Worker("http://{$http_host}:{$http_port}"); | ||
| 242 | +$http_worker->count = $config['server']['worker_count']; | ||
| 243 | +$http_worker->onMessage = function ($connection, $request) use ($config, $authService, $deviceService) { | ||
| 244 | + $path = $request->path(); | ||
| 245 | + $method = $request->method(); | ||
| 246 | + | ||
| 247 | + // Helper: JSON Response | ||
| 248 | + $jsonResponse = function ($data, $status = 200) use ($connection) { | ||
| 249 | + $connection->send(new \Workerman\Protocols\Http\Response( | ||
| 250 | + $status, | ||
| 251 | + ['Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*'], | ||
| 252 | + json_encode($data) | ||
| 253 | + )); | ||
| 254 | + }; | ||
| 255 | + | ||
| 256 | + // CORS Preflight | ||
| 257 | + if ($method === 'OPTIONS') { | ||
| 258 | + $connection->send(new \Workerman\Protocols\Http\Response(200, [ | ||
| 259 | + 'Access-Control-Allow-Origin' => '*', | ||
| 260 | + 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS', | ||
| 261 | + 'Access-Control-Allow-Headers' => 'Content-Type, Authorization', | ||
| 262 | + ], '')); | ||
| 263 | + return; | ||
| 264 | + } | ||
| 265 | + | ||
| 266 | + // ==================== Auth API ==================== | ||
| 267 | + | ||
| 268 | + // POST /api/auth/login - 微信登录 | ||
| 269 | + if ($path === '/api/auth/login' && $method === 'POST') { | ||
| 270 | + if (!$authService) { | ||
| 271 | + $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500); | ||
| 272 | + return; | ||
| 273 | + } | ||
| 274 | + | ||
| 275 | + $body = json_decode($request->rawBody(), true) ?: []; | ||
| 276 | + $code = $body['code'] ?? null; | ||
| 277 | + $phone = $body['phone'] ?? null; | ||
| 278 | + $phoneCode = $body['phoneCode'] ?? null; | ||
| 279 | + | ||
| 280 | + if (!$code) { | ||
| 281 | + $jsonResponse(['ok' => false, 'error' => 'code is required'], 400); | ||
| 282 | + return; | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + $result = $authService->wechatLogin($code, $phone, $phoneCode); | ||
| 286 | + $jsonResponse($result, $result['ok'] ? 200 : 400); | ||
| 287 | + return; | ||
| 288 | + } | ||
| 289 | + | ||
| 290 | + // POST /api/auth/verify - 验证 Token | ||
| 291 | + if ($path === '/api/auth/verify' && $method === 'POST') { | ||
| 292 | + if (!$authService) { | ||
| 293 | + $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500); | ||
| 294 | + return; | ||
| 295 | + } | ||
| 296 | + | ||
| 297 | + $body = json_decode($request->rawBody(), true) ?: []; | ||
| 298 | + $token = $body['token'] ?? null; | ||
| 299 | + | ||
| 300 | + if (!$token) { | ||
| 301 | + $jsonResponse(['ok' => false, 'error' => 'token is required'], 400); | ||
| 302 | + return; | ||
| 303 | + } | ||
| 304 | + | ||
| 305 | + $user = $authService->verifyToken($token); | ||
| 306 | + if ($user) { | ||
| 307 | + $jsonResponse(['ok' => true, 'user' => $user]); | ||
| 308 | + } else { | ||
| 309 | + $jsonResponse(['ok' => false, 'error' => 'Invalid or expired token'], 401); | ||
| 310 | + } | ||
| 311 | + return; | ||
| 312 | + } | ||
| 313 | + | ||
| 314 | + // POST /api/auth/logout - 注销 | ||
| 315 | + if ($path === '/api/auth/logout' && $method === 'POST') { | ||
| 316 | + if (!$authService) { | ||
| 317 | + $jsonResponse(['ok' => false, 'error' => 'Auth service unavailable'], 500); | ||
| 318 | + return; | ||
| 319 | + } | ||
| 320 | + | ||
| 321 | + $body = json_decode($request->rawBody(), true) ?: []; | ||
| 322 | + $token = $body['token'] ?? null; | ||
| 323 | + | ||
| 324 | + if ($token) { | ||
| 325 | + $authService->logout($token); | ||
| 326 | + } | ||
| 327 | + $jsonResponse(['ok' => true]); | ||
| 328 | + return; | ||
| 329 | + } | ||
| 330 | + | ||
| 331 | + // ==================== Device API ==================== | ||
| 332 | + | ||
| 333 | + // Helper: Get user from token | ||
| 334 | + $getUser = function () use ($request, $authService) { | ||
| 335 | + $authHeader = $request->header('Authorization'); | ||
| 336 | + if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) { | ||
| 337 | + return null; | ||
| 338 | + } | ||
| 339 | + $token = substr($authHeader, 7); | ||
| 340 | + return $authService ? $authService->verifyToken($token) : null; | ||
| 341 | + }; | ||
| 342 | + | ||
| 343 | + // POST /api/device/bind - 绑定设备 | ||
| 344 | + if ($path === '/api/device/bind' && $method === 'POST') { | ||
| 345 | + if (!$deviceService) { | ||
| 346 | + $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500); | ||
| 347 | + return; | ||
| 348 | + } | ||
| 349 | + | ||
| 350 | + $user = $getUser(); | ||
| 351 | + if (!$user) { | ||
| 352 | + $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401); | ||
| 353 | + return; | ||
| 354 | + } | ||
| 355 | + | ||
| 356 | + $body = json_decode($request->rawBody(), true) ?: []; | ||
| 357 | + $deviceId = $body['deviceId'] ?? null; | ||
| 358 | + $deviceSecret = $body['deviceSecret'] ?? null; | ||
| 359 | + | ||
| 360 | + if (!$deviceId) { | ||
| 361 | + $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400); | ||
| 362 | + return; | ||
| 363 | + } | ||
| 364 | + | ||
| 365 | + $result = $deviceService->bindDevice($user['id'], $deviceId, $deviceSecret); | ||
| 366 | + $jsonResponse($result, $result['ok'] ? 200 : 400); | ||
| 367 | + return; | ||
| 368 | + } | ||
| 369 | + | ||
| 370 | + // POST /api/device/unbind - 解绑设备 | ||
| 371 | + if ($path === '/api/device/unbind' && $method === 'POST') { | ||
| 372 | + if (!$deviceService) { | ||
| 373 | + $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500); | ||
| 374 | + return; | ||
| 375 | + } | ||
| 376 | + | ||
| 377 | + $user = $getUser(); | ||
| 378 | + if (!$user) { | ||
| 379 | + $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401); | ||
| 380 | + return; | ||
| 381 | + } | ||
| 382 | + | ||
| 383 | + $body = json_decode($request->rawBody(), true) ?: []; | ||
| 384 | + $deviceId = $body['deviceId'] ?? null; | ||
| 385 | + | ||
| 386 | + if (!$deviceId) { | ||
| 387 | + $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400); | ||
| 388 | + return; | ||
| 389 | + } | ||
| 390 | + | ||
| 391 | + $result = $deviceService->unbindDevice($user['id'], $deviceId); | ||
| 392 | + $jsonResponse($result, $result['ok'] ? 200 : 400); | ||
| 393 | + return; | ||
| 394 | + } | ||
| 395 | + | ||
| 396 | + // GET /api/device/list - 获取绑定的设备列表 | ||
| 397 | + if ($path === '/api/device/list' && $method === 'GET') { | ||
| 398 | + if (!$deviceService) { | ||
| 399 | + $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500); | ||
| 400 | + return; | ||
| 401 | + } | ||
| 402 | + | ||
| 403 | + $user = $getUser(); | ||
| 404 | + if (!$user) { | ||
| 405 | + $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401); | ||
| 406 | + return; | ||
| 407 | + } | ||
| 408 | + | ||
| 409 | + $devices = $deviceService->getUserDevices($user['id']); | ||
| 410 | + $jsonResponse(['ok' => true, 'devices' => $devices]); | ||
| 411 | + return; | ||
| 412 | + } | ||
| 413 | + | ||
| 414 | + // POST /api/device/primary - 设置主设备 | ||
| 415 | + if ($path === '/api/device/primary' && $method === 'POST') { | ||
| 416 | + if (!$deviceService) { | ||
| 417 | + $jsonResponse(['ok' => false, 'error' => 'Device service unavailable'], 500); | ||
| 418 | + return; | ||
| 419 | + } | ||
| 420 | + | ||
| 421 | + $user = $getUser(); | ||
| 422 | + if (!$user) { | ||
| 423 | + $jsonResponse(['ok' => false, 'error' => 'Unauthorized'], 401); | ||
| 424 | + return; | ||
| 425 | + } | ||
| 426 | + | ||
| 427 | + $body = json_decode($request->rawBody(), true) ?: []; | ||
| 428 | + $deviceId = $body['deviceId'] ?? null; | ||
| 429 | + | ||
| 430 | + if (!$deviceId) { | ||
| 431 | + $jsonResponse(['ok' => false, 'error' => 'deviceId is required'], 400); | ||
| 432 | + return; | ||
| 433 | + } | ||
| 434 | + | ||
| 435 | + $result = $deviceService->setPrimaryDevice($user['id'], $deviceId); | ||
| 436 | + $jsonResponse($result, $result['ok'] ? 200 : 400); | ||
| 437 | + return; | ||
| 438 | + } | ||
| 439 | + | ||
| 440 | + // ==================== File API ==================== | ||
| 441 | + | ||
| 442 | + // 1. Static File Serving (Simple implementation) | ||
| 443 | + if (strpos($path, '/uploads/') === 0) { | ||
| 444 | + $file = __DIR__ . $path; | ||
| 445 | + if (is_file($file)) { | ||
| 446 | + $connection->send(new \Workerman\Protocols\Http\Response( | ||
| 447 | + 200, | ||
| 448 | + ['Content-Type' => mime_content_type($file)], | ||
| 449 | + file_get_contents($file) | ||
| 450 | + )); | ||
| 451 | + return; | ||
| 452 | + } | ||
| 453 | + } | ||
| 454 | + | ||
| 455 | + // 2. Upload Handler | ||
| 456 | + if ($path === '/upload') { | ||
| 457 | + $files = $request->file(); | ||
| 458 | + | ||
| 459 | + if (empty($files['file'])) { | ||
| 460 | + $connection->send(new \Workerman\Protocols\Http\Response(400, [], json_encode(['ok' => false, 'error' => 'No file']))); | ||
| 461 | + return; | ||
| 462 | + } | ||
| 463 | + | ||
| 464 | + $file = $files['file']; | ||
| 465 | + | ||
| 466 | + // Validate Size | ||
| 467 | + $max_size = $config['upload']['max_size']; | ||
| 468 | + if ($file['size'] > $max_size) { | ||
| 469 | + $max_mb = round($max_size / 1024 / 1024); | ||
| 470 | + $connection->send(new \Workerman\Protocols\Http\Response(400, [], json_encode(['ok' => false, 'error' => "File too large (Max {$max_mb}MB)"]))); | ||
| 471 | + return; | ||
| 472 | + } | ||
| 473 | + | ||
| 474 | + // Validate Extension | ||
| 475 | + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); | ||
| 476 | + $allowed = $config['upload']['allowed_extensions']; | ||
| 477 | + if (!in_array($ext, $allowed)) { | ||
| 478 | + $connection->send(new \Workerman\Protocols\Http\Response(400, [], json_encode(['ok' => false, 'error' => 'File type not allowed']))); | ||
| 479 | + return; | ||
| 480 | + } | ||
| 481 | + | ||
| 482 | + // TOS Configuration | ||
| 483 | + $tos_config = $config['tos']; | ||
| 484 | + | ||
| 485 | + try { | ||
| 486 | + $client = new TosClient([ | ||
| 487 | + 'region' => $tos_config['region'], | ||
| 488 | + 'endpoint' => $tos_config['endpoint'], | ||
| 489 | + 'ak' => $tos_config['ak'], | ||
| 490 | + 'sk' => $tos_config['sk'], | ||
| 491 | + ]); | ||
| 492 | + | ||
| 493 | + // Generate Key | ||
| 494 | + $uuid = bin2hex(random_bytes(8)); | ||
| 495 | + $userPhone = $config['upload']['default_user']; | ||
| 496 | + $objectKey = "clawdbot/{$userPhone}/{$uuid}.{$ext}"; | ||
| 497 | + $bucket = $tos_config['bucket']; | ||
| 498 | + | ||
| 499 | + // Read file content | ||
| 500 | + $contentFn = fopen($file['tmp_name'], 'r'); | ||
| 501 | + | ||
| 502 | + // Upload using Object Input | ||
| 503 | + $input = new PutObjectInput($bucket, $objectKey, $contentFn); | ||
| 504 | + $input->setACL('public-read'); | ||
| 505 | + | ||
| 506 | + $client->putObject($input); | ||
| 507 | + | ||
| 508 | + if (is_resource($contentFn)) { | ||
| 509 | + fclose($contentFn); | ||
| 510 | + } | ||
| 511 | + | ||
| 512 | + // Generate URL | ||
| 513 | + $url = "https://{$bucket}.{$tos_config['endpoint']}/{$objectKey}"; | ||
| 514 | + | ||
| 515 | + $connection->send(json_encode([ | ||
| 516 | + 'ok' => true, | ||
| 517 | + 'url' => $url | ||
| 518 | + ])); | ||
| 519 | + | ||
| 520 | + } catch (Exception $e) { | ||
| 521 | + $connection->send(new \Workerman\Protocols\Http\Response(500, [], json_encode(['ok' => false, 'error' => 'Upload failed: ' . $e->getMessage()]))); | ||
| 522 | + } | ||
| 523 | + return; | ||
| 524 | + } | ||
| 525 | + | ||
| 526 | + // 3. Generate Pre-Signed URL for Direct Upload | ||
| 527 | + if (strpos($path, '/tos/sign') === 0) { | ||
| 528 | + $query = $request->get(); | ||
| 529 | + $filename = $query['filename'] ?? 'file_' . time(); | ||
| 530 | + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); | ||
| 531 | + | ||
| 532 | + // Validation | ||
| 533 | + $allowed = $config['upload']['allowed_extensions']; | ||
| 534 | + if (!in_array($ext, $allowed) && $ext !== '') { | ||
| 535 | + // If no extension providing, we might just allow it or fail. | ||
| 536 | + // Ideally client should provide full filename with extension. | ||
| 537 | + } | ||
| 538 | + | ||
| 539 | + // TOS Configuration | ||
| 540 | + $tos_config = $config['tos']; | ||
| 541 | + | ||
| 542 | + try { | ||
| 543 | + $client = new TosClient([ | ||
| 544 | + 'region' => $tos_config['region'], | ||
| 545 | + 'endpoint' => $tos_config['endpoint'], | ||
| 546 | + 'ak' => $tos_config['ak'], | ||
| 547 | + 'sk' => $tos_config['sk'], | ||
| 548 | + ]); | ||
| 549 | + | ||
| 550 | + $uuid = bin2hex(random_bytes(8)); | ||
| 551 | + $userPhone = $config['upload']['default_user']; | ||
| 552 | + // Ensure filename is safe | ||
| 553 | + $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', $filename); | ||
| 554 | + if (!$safeName) | ||
| 555 | + $safeName = 'unnamed'; | ||
| 556 | + | ||
| 557 | + $objectKey = "clawdbot/{$userPhone}/direct_{$uuid}_{$safeName}"; | ||
| 558 | + $bucket = $tos_config['bucket']; | ||
| 559 | + | ||
| 560 | + // Generate Pre-Signed PUT URL (Valid for 15 mins) | ||
| 561 | + // Note: The SDK method name might vary slightly based on version, | ||
| 562 | + // but `preSignedURL` is standard for TOS PHP SDK v2. | ||
| 563 | + $input = new \Tos\Model\PreSignedURLInput( | ||
| 564 | + 'PUT', | ||
| 565 | + $tos_config['bucket'], | ||
| 566 | + $objectKey, | ||
| 567 | + 300 // 5 minutes validity | ||
| 568 | + ); | ||
| 569 | + | ||
| 570 | + // Add content-type if known? client will send it. | ||
| 571 | + // For simple PUT, we just sign the method and resource. | ||
| 572 | + | ||
| 573 | + $output = $client->preSignedURL($input); | ||
| 574 | + $signedUrl = $output->getSignedUrl(); | ||
| 575 | + | ||
| 576 | + // Public Access URL (Assuming bucket is public-read or we use signed Get URL) | ||
| 577 | + // For this project, we used public-read ACL in previous code, so we assume public access. | ||
| 578 | + $publicUrl = "https://{$tos_config['bucket']}.{$tos_config['endpoint']}/{$objectKey}"; | ||
| 579 | + | ||
| 580 | + $connection->send(json_encode([ | ||
| 581 | + 'ok' => true, | ||
| 582 | + 'uploadUrl' => $signedUrl, | ||
| 583 | + 'publicUrl' => $publicUrl, | ||
| 584 | + 'key' => $objectKey | ||
| 585 | + ])); | ||
| 586 | + | ||
| 587 | + } catch (Exception $e) { | ||
| 588 | + $connection->send(new \Workerman\Protocols\Http\Response(500, [], json_encode(['ok' => false, 'error' => $e->getMessage()]))); | ||
| 589 | + } | ||
| 590 | + return; | ||
| 591 | + } | ||
| 592 | + | ||
| 593 | + $connection->send("Moltbot Relay HTTP Server"); | ||
| 594 | +}; | ||
| 595 | + | ||
| 596 | +Worker::runAll(); |
Please
register
or
login
to post a comment