Commit 47820191eaa5b2806d86055eb0111b6b26425d20
1 parent
2154352e
refactor: use phpdotenv for wechat config
Showing
3 changed files
with
602 additions
and
592 deletions
| ... | ... | @@ -7,8 +7,8 @@ |
| 7 | 7 | return [ |
| 8 | 8 | // 微信小程序配置 |
| 9 | 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 | 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