exec(" CREATE TABLE IF NOT EXISTS gitea_accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, base_url TEXT NOT NULL, token TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) "); })(); // ── Encryption helpers (mirrors email plugin / CalDavSync) ──────── function giteaEncKey(): string { $db = Database::connect(); $vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn(); if ($vk) return substr(hash('sha256', $vk), 0, 32); $hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn(); return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32); } function giteaEncrypt(string $plain): string { $key = giteaEncKey(); $iv = random_bytes(16); $cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); return base64_encode($iv . $cipher); } function giteaDecrypt(string $encoded): string { $key = giteaEncKey(); $raw = base64_decode($encoded); if (!$raw || strlen($raw) < 17) return ''; $iv = substr($raw, 0, 16); $cipher = substr($raw, 16); return openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv) ?: ''; } function giteaJson($data, int $code = 200): void { http_response_code($code); header('Content-Type: application/json'); echo json_encode($data); exit; } function giteaGetAccount(int $id): ?array { $db = Database::connect(); $stmt = $db->prepare('SELECT * FROM gitea_accounts WHERE id = :id'); $stmt->execute(['id' => $id]); return $stmt->fetch() ?: null; } function giteaOpenClient(array $row): GiteaClient { return new GiteaClient($row['base_url'], giteaDecrypt($row['token'])); } // ── Route registration ──────────────────────────────────────────── PluginManager::instance()->onApiRoute(function (string $route, string $method) { // ── Accounts CRUD ── if ($route === '/api/gitea/accounts' && $method === 'GET') { $db = Database::connect(); $rows = $db->query('SELECT id, name, base_url, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll(); giteaJson($rows); } if ($route === '/api/gitea/accounts' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $name = trim((string)($input['name'] ?? '')); $baseUrl = trim((string)($input['base_url'] ?? '')); $token = (string)($input['token'] ?? ''); if ($name === '' || $baseUrl === '' || $token === '') { giteaJson(['error' => 'name, base_url, and token are required'], 400); } // Normalize base URL: strip trailing slash, accept with/without scheme if (!preg_match('#^https?://#i', $baseUrl)) { $baseUrl = 'https://' . $baseUrl; } $baseUrl = rtrim($baseUrl, '/'); $db = Database::connect(); $stmt = $db->prepare(' INSERT INTO gitea_accounts (name, base_url, token) VALUES (:n, :u, :t) '); $stmt->execute([ 'n' => $name, 'u' => $baseUrl, 't' => giteaEncrypt($token), ]); giteaJson(['ok' => true, 'id' => (int)$db->lastInsertId()]); } if (preg_match('#^/api/gitea/accounts/(\d+)$#', $route, $m) && $method === 'PATCH') { $id = (int)$m[1]; $row = giteaGetAccount($id); if (!$row) giteaJson(['error' => 'account not found'], 404); $input = json_decode(file_get_contents('php://input'), true) ?: []; $fields = []; $params = ['id' => $id]; if (array_key_exists('name', $input)) { $fields[] = 'name = :name'; $params['name'] = trim((string)$input['name']); } if (array_key_exists('base_url', $input)) { $u = trim((string)$input['base_url']); if (!preg_match('#^https?://#i', $u)) $u = 'https://' . $u; $fields[] = 'base_url = :base_url'; $params['base_url'] = rtrim($u, '/'); } if (!empty($input['token'])) { $fields[] = 'token = :token'; $params['token'] = giteaEncrypt($input['token']); } if (!$fields) giteaJson(['ok' => true]); $db = Database::connect(); $db->prepare('UPDATE gitea_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); giteaJson(['ok' => true]); } if (preg_match('#^/api/gitea/accounts/(\d+)$#', $route, $m) && $method === 'DELETE') { $id = (int)$m[1]; $db = Database::connect(); $db->prepare('DELETE FROM gitea_accounts WHERE id = :id')->execute(['id' => $id]); giteaJson(['ok' => true]); } // ── Issues: list ── if ($route === '/api/gitea/issues' && $method === 'GET') { $accId = (int)($_GET['account'] ?? 0); $row = giteaGetAccount($accId); if (!$row) giteaJson(['error' => 'account not found'], 404); $state = in_array($_GET['state'] ?? 'open', ['open', 'closed', 'all'], true) ? $_GET['state'] : 'open'; $q = trim((string)($_GET['q'] ?? '')); $owner = trim((string)($_GET['owner'] ?? '')); $scope = $_GET['scope'] ?? 'all'; // all | assigned | created | mentioned $page = max(1, (int)($_GET['page'] ?? 1)); $limit = max(5, min(100, (int)($_GET['limit'] ?? 30))); try { $client = giteaOpenClient($row); $issues = $client->searchIssues([ 'state' => $state, 'q' => $q !== '' ? $q : null, 'owner' => $owner !== '' ? $owner : null, 'type' => 'issues', // exclude PRs 'assigned' => $scope === 'assigned' ? true : null, 'created' => $scope === 'created' ? true : null, 'mentioned' => $scope === 'mentioned' ? true : null, 'page' => $page, 'limit' => $limit, ]); // Map to a compact, UI-friendly shape $mapped = array_map(function ($i) { $repoFull = $i['repository']['full_name'] ?? null; if (!$repoFull && isset($i['repository']['owner'], $i['repository']['name'])) { $repoFull = $i['repository']['owner'] . '/' . $i['repository']['name']; } return [ 'id' => $i['id'] ?? null, 'number' => $i['number'] ?? null, 'title' => $i['title'] ?? '', 'state' => $i['state'] ?? 'open', 'created_at' => $i['created_at'] ?? null, 'updated_at' => $i['updated_at'] ?? null, 'comments_count' => $i['comments'] ?? 0, 'html_url' => $i['html_url'] ?? null, 'repo' => $repoFull, 'author' => $i['user']['login'] ?? null, 'labels' => array_map(fn($l) => [ 'name' => $l['name'] ?? '', 'color' => $l['color'] ?? '888888', ], $i['labels'] ?? []), 'assignees' => array_map(fn($a) => $a['login'] ?? '', $i['assignees'] ?? []), ]; }, $issues); giteaJson([ 'ok' => true, 'issues' => $mapped, 'page' => $page, 'limit' => $limit, 'hasMore' => count($mapped) >= $limit, // heuristic — Gitea doesn't always return total ]); } catch (GiteaApiException $e) { giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); } catch (\Throwable $e) { giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); } } // ── Issue detail (with comments) ── if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)$#', $route, $m) && $method === 'GET') { $accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4]; $row = giteaGetAccount($accId); if (!$row) giteaJson(['error' => 'account not found'], 404); try { $client = giteaOpenClient($row); $issue = $client->getIssue($owner, $repo, $index); $comments = $client->listIssueComments($owner, $repo, $index); giteaJson([ 'ok' => true, 'issue' => [ 'id' => $issue['id'] ?? null, 'number' => $issue['number'] ?? $index, 'title' => $issue['title'] ?? '', 'body' => $issue['body'] ?? '', 'state' => $issue['state'] ?? 'open', 'created_at' => $issue['created_at'] ?? null, 'updated_at' => $issue['updated_at'] ?? null, 'closed_at' => $issue['closed_at'] ?? null, 'due_date' => $issue['due_date'] ?? null, 'html_url' => $issue['html_url'] ?? null, 'author' => $issue['user']['login'] ?? null, 'author_avatar' => $issue['user']['avatar_url'] ?? null, 'repo' => "$owner/$repo", 'owner' => $owner, 'repo_name' => $repo, 'labels' => array_map(fn($l) => [ 'name' => $l['name'] ?? '', 'color' => $l['color'] ?? '888888', ], $issue['labels'] ?? []), 'assignees' => array_map(fn($a) => $a['login'] ?? '', $issue['assignees'] ?? []), 'milestone' => isset($issue['milestone']) && $issue['milestone'] ? [ 'id' => $issue['milestone']['id'] ?? null, 'title' => $issue['milestone']['title'] ?? '', ] : null, ], 'comments' => array_map(fn($c) => [ 'id' => $c['id'] ?? null, 'author' => $c['user']['login'] ?? null, 'body' => $c['body'] ?? '', 'created_at' => $c['created_at'] ?? null, 'updated_at' => $c['updated_at'] ?? null, ], $comments), ]); } catch (GiteaApiException $e) { giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); } catch (\Throwable $e) { giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); } } // ── Issue write operations ── if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)/comment$#', $route, $m) && $method === 'POST') { $accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4]; $row = giteaGetAccount($accId); if (!$row) giteaJson(['error' => 'account not found'], 404); $input = json_decode(file_get_contents('php://input'), true) ?: []; $body = trim((string)($input['body'] ?? '')); if ($body === '') giteaJson(['error' => 'comment body required'], 400); try { $client = giteaOpenClient($row); $created = $client->createIssueComment($owner, $repo, $index, $body); giteaJson([ 'ok' => true, 'comment' => [ 'id' => $created['id'] ?? null, 'author' => $created['user']['login'] ?? null, 'body' => $created['body'] ?? '', 'created_at' => $created['created_at'] ?? null, ], ]); } catch (GiteaApiException $e) { giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); } catch (\Throwable $e) { giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); } } if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)$#', $route, $m) && $method === 'PATCH') { $accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4]; $row = giteaGetAccount($accId); if (!$row) giteaJson(['error' => 'account not found'], 404); $input = json_decode(file_get_contents('php://input'), true) ?: []; $changes = []; foreach (['title', 'body', 'state', 'assignees', 'milestone', 'due_date', 'unset_due_date'] as $k) { if (array_key_exists($k, $input)) $changes[$k] = $input[$k]; } if (!$changes) giteaJson(['error' => 'no changes'], 400); if (isset($changes['state']) && !in_array($changes['state'], ['open', 'closed'], true)) { giteaJson(['error' => 'invalid state'], 400); } try { $client = giteaOpenClient($row); $updated = $client->editIssue($owner, $repo, $index, $changes); giteaJson([ 'ok' => true, 'issue' => [ 'title' => $updated['title'] ?? '', 'body' => $updated['body'] ?? '', 'state' => $updated['state'] ?? 'open', 'updated_at' => $updated['updated_at'] ?? null, 'closed_at' => $updated['closed_at'] ?? null, ], ]); } catch (GiteaApiException $e) { giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); } catch (\Throwable $e) { giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); } } if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)/labels$#', $route, $m) && $method === 'PUT') { $accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4]; $row = giteaGetAccount($accId); if (!$row) giteaJson(['error' => 'account not found'], 404); $input = json_decode(file_get_contents('php://input'), true) ?: []; $labels = $input['labels'] ?? []; if (!is_array($labels)) giteaJson(['error' => 'labels array required'], 400); try { $client = giteaOpenClient($row); $resulting = $client->replaceIssueLabels($owner, $repo, $index, $labels); giteaJson([ 'ok' => true, 'labels' => array_map(fn($l) => [ 'id' => $l['id'] ?? null, 'name' => $l['name'] ?? '', 'color' => $l['color'] ?? '888888', ], $resulting), ]); } catch (GiteaApiException $e) { giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); } catch (\Throwable $e) { giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); } } // ── Attachment proxy ── // Gitea attachments live under /attachments/ on the gitea instance // and require the auth token (or session cookie). The browser can't fetch // them directly (CORS + no token), so hyprcrd proxies them. if (preg_match('#^/api/gitea/attachment/(\d+)/(.+)$#', $route, $m) && $method === 'GET') { $accId = (int)$m[1]; $path = $m[2]; // e.g. "attachments/abc-123-uuid" or "user/repo/raw/branch/file.png" $row = giteaGetAccount($accId); if (!$row) { http_response_code(404); exit; } // Build target URL on the gitea instance $url = rtrim($row['base_url'], '/') . '/' . ltrim($path, '/'); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => [ 'Authorization: token ' . giteaDecrypt($row['token']), 'User-Agent: hyprcrd-gitea-plugin/1.0', ], ]); $body = curl_exec($ch); if ($body === false) { $err = curl_error($ch); curl_close($ch); http_response_code(502); header('Content-Type: text/plain'); echo "Could not reach gitea: $err"; exit; } $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'application/octet-stream'; curl_close($ch); if ($code >= 400) { http_response_code($code); header('Content-Type: text/plain'); echo "Gitea returned HTTP $code"; exit; } // Strip any default headers PHP might emit so we can replace them header_remove('Pragma'); header_remove('Expires'); header('Content-Type: ' . $contentType); header('Cache-Control: private, max-age=3600'); header('Content-Length: ' . strlen($body)); echo $body; exit; } // ── Create issue ── if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/issues$#', $route, $m) && $method === 'POST') { $accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $row = giteaGetAccount($accId); if (!$row) giteaJson(['error' => 'account not found'], 404); $input = json_decode(file_get_contents('php://input'), true) ?: []; $title = trim((string)($input['title'] ?? '')); if ($title === '') giteaJson(['error' => 'title is required'], 400); $opts = ['title' => $title]; if (array_key_exists('body', $input)) $opts['body'] = (string)$input['body']; if (array_key_exists('labels', $input) && is_array($input['labels'])) { $opts['labels'] = array_map('intval', $input['labels']); } if (array_key_exists('assignees', $input) && is_array($input['assignees'])) { $opts['assignees'] = array_values(array_filter(array_map('strval', $input['assignees']))); } if (!empty($input['milestone'])) $opts['milestone'] = (int)$input['milestone']; if (!empty($input['due_date'])) $opts['due_date'] = (string)$input['due_date']; try { $client = giteaOpenClient($row); $created = $client->createIssue($owner, $repo, $opts); giteaJson([ 'ok' => true, 'issue' => [ 'id' => $created['id'] ?? null, 'number' => $created['number'] ?? null, 'title' => $created['title'] ?? '', 'state' => $created['state'] ?? 'open', 'html_url' => $created['html_url'] ?? null, 'owner' => $owner, 'repo_name' => $repo, ], ]); } catch (GiteaApiException $e) { giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); } catch (\Throwable $e) { giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); } } // ── User repos (for the repo picker) ── if ($route === '/api/gitea/repos' && $method === 'GET') { $accId = (int)($_GET['account'] ?? 0); $row = giteaGetAccount($accId); if (!$row) giteaJson(['error' => 'account not found'], 404); try { $client = giteaOpenClient($row); $repos = $client->listMyRepos(1, 100); $mapped = array_map(fn($r) => [ 'id' => $r['id'] ?? null, 'full_name' => $r['full_name'] ?? null, 'name' => $r['name'] ?? null, 'owner' => $r['owner']['login'] ?? null, 'description' => $r['description'] ?? '', 'private' => $r['private'] ?? false, 'archived' => $r['archived'] ?? false, 'permissions' => $r['permissions'] ?? null, ], $repos); // Filter out archived repos and ones without push permission $mapped = array_values(array_filter($mapped, function ($r) { if ($r['archived']) return false; $p = $r['permissions'] ?? null; if ($p && isset($p['push'])) return $p['push'] || $p['admin'] ?? false; return true; // unknown permissions = include })); giteaJson(['ok' => true, 'repos' => $mapped]); } catch (GiteaApiException $e) { giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); } catch (\Throwable $e) { giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); } } // ── Repo metadata ── if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/labels$#', $route, $m) && $method === 'GET') { $accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $row = giteaGetAccount($accId); if (!$row) giteaJson(['error' => 'account not found'], 404); try { $client = giteaOpenClient($row); // Merge repo-level + org-level labels (org labels are inherited by // repos in an org but don't appear in /repos/.../labels) $repoLabels = $client->listRepoLabels($owner, $repo); $orgLabels = []; $warning = null; try { $orgLabels = $client->listOrgLabels($owner); } catch (GiteaApiException $e) { if ($e->statusCode === 404) { // Owner is a user, not an org — no org labels exist. silent. } elseif ($e->statusCode === 403) { // Token lacks read:organization scope — surface a warning // but don't fail the whole request; repo labels still work. $warning = 'Token lacks read:organization scope — org-level labels are not visible. Update your gitea token to include read:organization to see them.'; } else { throw $e; } } // Dedupe by id (in case the same label appears in both) $seen = []; $merged = []; foreach (array_merge($repoLabels, $orgLabels) as $l) { $id = $l['id'] ?? null; if ($id !== null && !isset($seen[$id])) { $seen[$id] = true; $merged[] = $l; } } $payload = [ 'ok' => true, 'labels' => array_map(fn($l) => [ 'id' => $l['id'] ?? null, 'name' => $l['name'] ?? '', 'color' => $l['color'] ?? '888888', 'description' => $l['description'] ?? '', ], $merged), ]; if ($warning) $payload['warning'] = $warning; giteaJson($payload); } catch (GiteaApiException $e) { giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); } catch (\Throwable $e) { giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); } } if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/milestones$#', $route, $m) && $method === 'GET') { $accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $row = giteaGetAccount($accId); if (!$row) giteaJson(['error' => 'account not found'], 404); try { $client = giteaOpenClient($row); $milestones = $client->listRepoMilestones($owner, $repo, 'open'); giteaJson([ 'ok' => true, 'milestones' => array_map(fn($ms) => [ 'id' => $ms['id'] ?? null, 'title' => $ms['title'] ?? '', 'description' => $ms['description'] ?? '', 'due_on' => $ms['due_on'] ?? null, 'state' => $ms['state'] ?? 'open', ], $milestones), ]); } catch (GiteaApiException $e) { giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); } catch (\Throwable $e) { giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); } } if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/collaborators$#', $route, $m) && $method === 'GET') { $accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $row = giteaGetAccount($accId); if (!$row) giteaJson(['error' => 'account not found'], 404); try { $client = giteaOpenClient($row); // Use /assignees instead of /collaborators — it returns the full // set of users that can be assigned (owner + org members + // explicit collaborators), which is what we actually want. $assignees = $client->listRepoAssignees($owner, $repo); giteaJson([ 'ok' => true, 'collaborators' => array_map(fn($u) => [ 'login' => $u['login'] ?? '', 'full_name' => $u['full_name'] ?? '', 'avatar_url' => $u['avatar_url'] ?? '', ], $assignees), ]); } catch (GiteaApiException $e) { giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); } catch (\Throwable $e) { giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); } } if (preg_match('#^/api/gitea/accounts/(\d+)/test$#', $route, $m) && $method === 'POST') { $id = (int)$m[1]; $row = giteaGetAccount($id); if (!$row) giteaJson(['error' => 'account not found'], 404); try { $client = giteaOpenClient($row); $user = $client->testConnection(); giteaJson([ 'ok' => true, 'user' => [ 'login' => $user['login'] ?? null, 'full_name' => $user['full_name'] ?? null, 'email' => $user['email'] ?? null, 'avatar_url' => $user['avatar_url'] ?? null, ], 'base_url' => $row['base_url'], ]); } catch (GiteaApiException $e) { giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); } catch (\Throwable $e) { giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); } } });