github-commit-image.mjs
223 lines 7.6 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * GitHub Contents API: commit an image file to a user's backup repo (Phase 18D).
3 * Returns the raw.githubusercontent.com URL for embedding in notes.
4 */
5
6 const GITHUB_API = 'https://api.github.com';
7
8 const ALLOWED_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp']);
9
10 const MAGIC_BYTES = {
11 jpeg: [0xFF, 0xD8, 0xFF],
12 png: [0x89, 0x50, 0x4E, 0x47],
13 gif_87a: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61],
14 gif_89a: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61],
15 webp_riff: [0x52, 0x49, 0x46, 0x46],
16 };
17
18 /**
19 * Parse owner/repo from a GitHub remote URL or short slug.
20 * Supports:
21 * https://github.com/user/repo
22 * https://github.com/user/repo.git
23 * [email protected]:user/repo.git
24 * user/repo (short "owner/repo" format stored by the bridge)
25 * @param {string} repoUrl
26 * @returns {{ owner: string, repo: string }}
27 */
28 export function parseGitHubRepoUrl(repoUrl) {
29 if (!repoUrl || typeof repoUrl !== 'string') {
30 throw new Error('repoUrl is required');
31 }
32 const cleaned = repoUrl.trim().replace(/\/+$/, '');
33
34 // Full URL: https://github.com/user/repo[.git]
35 // SSH: [email protected]:user/repo[.git]
36 const fullMatch = cleaned.match(/github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?$/i);
37 if (fullMatch) {
38 return { owner: fullMatch[1], repo: fullMatch[2] };
39 }
40
41 // Short slug: owner/repo[.git]
42 const shortMatch = cleaned.match(/^([^/]+)\/([^/]+?)(?:\.git)?$/);
43 if (shortMatch) {
44 return { owner: shortMatch[1], repo: shortMatch[2] };
45 }
46
47 throw new Error(`Cannot parse GitHub owner/repo from URL: ${repoUrl}`);
48 }
49
50 /**
51 * Validate image file extension.
52 * @param {string} filename
53 * @returns {string} normalized extension (lowercase, without dot)
54 */
55 export function validateImageExtension(filename) {
56 if (!filename || typeof filename !== 'string') {
57 throw new Error('Filename is required');
58 }
59 const ext = filename.split('.').pop().toLowerCase();
60 if (!ALLOWED_EXTENSIONS.has(ext)) {
61 throw new Error(`File extension .${ext} is not allowed. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`);
62 }
63 return ext;
64 }
65
66 /**
67 * Validate file content matches its declared extension by checking magic bytes.
68 * @param {Buffer} buffer
69 * @param {string} ext - expected extension (jpg, png, gif, webp)
70 * @returns {boolean}
71 */
72 export function validateMagicBytes(buffer, ext) {
73 if (!buffer || buffer.length < 4) return false;
74
75 const matches = (signature) =>
76 signature.every((byte, i) => buffer[i] === byte);
77
78 switch (ext) {
79 case 'jpg':
80 case 'jpeg':
81 return matches(MAGIC_BYTES.jpeg);
82 case 'png':
83 return matches(MAGIC_BYTES.png);
84 case 'gif':
85 return matches(MAGIC_BYTES.gif_87a) || matches(MAGIC_BYTES.gif_89a);
86 case 'webp':
87 if (!matches(MAGIC_BYTES.webp_riff)) return false;
88 return buffer.length >= 12 &&
89 buffer[8] === 0x57 && buffer[9] === 0x45 &&
90 buffer[10] === 0x42 && buffer[11] === 0x50;
91 default:
92 return false;
93 }
94 }
95
96 /**
97 * Fetch the default branch and privacy status of the repo.
98 * @param {string} accessToken
99 * @param {string} owner
100 * @param {string} repo
101 * @returns {Promise<{ branch: string, isPrivate: boolean }>}
102 */
103 async function getDefaultBranch(accessToken, owner, repo) {
104 const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`, {
105 headers: {
106 Authorization: `token ${accessToken}`,
107 Accept: 'application/vnd.github.v3+json',
108 'User-Agent': 'Knowtation-Hub/1.0',
109 },
110 });
111 if (res.status === 404) {
112 throw new Error(`Repository ${owner}/${repo} not found. Check the Git remote URL in Settings → Backup.`);
113 }
114 if (res.status === 403 || res.status === 401) {
115 throw new Error('GitHub token lacks repository access. Reconnect GitHub in Settings → Backup.');
116 }
117 if (!res.ok) {
118 throw new Error(`GitHub API error: HTTP ${res.status}`);
119 }
120 const data = await res.json();
121 return { branch: data.default_branch || 'main', isPrivate: data.private === true };
122 }
123
124 /**
125 * Get the SHA of an existing file (needed for updates).
126 * @returns {Promise<string|null>} SHA or null if file doesn't exist
127 */
128 async function getExistingFileSha(accessToken, owner, repo, filePath, branch) {
129 const res = await fetch(
130 `${GITHUB_API}/repos/${owner}/${repo}/contents/${encodeURIComponent(filePath)}?ref=${encodeURIComponent(branch)}`,
131 {
132 headers: {
133 Authorization: `token ${accessToken}`,
134 Accept: 'application/vnd.github.v3+json',
135 'User-Agent': 'Knowtation-Hub/1.0',
136 },
137 },
138 );
139 if (res.status === 404) return null;
140 if (!res.ok) return null;
141 const data = await res.json();
142 return data.sha || null;
143 }
144
145 /**
146 * Commit an image file to a GitHub repository via the Contents API.
147 * @param {{ accessToken: string, repoUrl: string, filePath: string, fileBuffer: Buffer, commitMessage?: string }} opts
148 * @returns {Promise<{ url: string, sha: string, htmlUrl: string, isPrivate: boolean }>}
149 */
150 export async function commitImageToRepo({ accessToken, repoUrl, filePath, fileBuffer, commitMessage }) {
151 if (!accessToken) throw new Error('GitHub access token is required');
152 if (!repoUrl) throw new Error('GitHub repo URL is required');
153 if (!filePath) throw new Error('filePath is required');
154 if (!fileBuffer || !Buffer.isBuffer(fileBuffer)) throw new Error('fileBuffer (Buffer) is required');
155
156 const { owner, repo } = parseGitHubRepoUrl(repoUrl);
157 const { branch, isPrivate } = await getDefaultBranch(accessToken, owner, repo);
158
159 const content = fileBuffer.toString('base64');
160 const message = commitMessage || `Add image: ${filePath.split('/').pop()}`;
161
162 const body = { message, content, branch };
163
164 const existingSha = await getExistingFileSha(accessToken, owner, repo, filePath, branch);
165 if (existingSha) {
166 body.sha = existingSha;
167 }
168
169 const res = await fetch(
170 `${GITHUB_API}/repos/${owner}/${repo}/contents/${encodeURIComponent(filePath)}`,
171 {
172 method: 'PUT',
173 headers: {
174 Authorization: `token ${accessToken}`,
175 Accept: 'application/vnd.github.v3+json',
176 'Content-Type': 'application/json',
177 'User-Agent': 'Knowtation-Hub/1.0',
178 },
179 body: JSON.stringify(body),
180 },
181 );
182
183 if (res.status === 422 && !existingSha) {
184 const sha = await getExistingFileSha(accessToken, owner, repo, filePath, branch);
185 if (sha) {
186 body.sha = sha;
187 const retry = await fetch(
188 `${GITHUB_API}/repos/${owner}/${repo}/contents/${encodeURIComponent(filePath)}`,
189 {
190 method: 'PUT',
191 headers: {
192 Authorization: `token ${accessToken}`,
193 Accept: 'application/vnd.github.v3+json',
194 'Content-Type': 'application/json',
195 'User-Agent': 'Knowtation-Hub/1.0',
196 },
197 body: JSON.stringify(body),
198 },
199 );
200 if (!retry.ok) {
201 const errBody = await retry.text().catch(() => '');
202 throw new Error(`GitHub API error on retry: HTTP ${retry.status} ${errBody}`);
203 }
204 const retryData = await retry.json();
205 const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`;
206 return { url: rawUrl, sha: retryData.content?.sha || '', htmlUrl: retryData.content?.html_url || '', isPrivate };
207 }
208 }
209
210 if (res.status === 403 || res.status === 401) {
211 throw new Error('GitHub token lacks permission to write to this repository. Reconnect GitHub with repo scope.');
212 }
213 if (!res.ok) {
214 const errBody = await res.text().catch(() => '');
215 throw new Error(`GitHub API error: HTTP ${res.status} ${errBody}`);
216 }
217
218 const data = await res.json();
219 const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`;
220 return { url: rawUrl, sha: data.content?.sha || '', htmlUrl: data.content?.html_url || '', isPrivate };
221 }
222
223 export { ALLOWED_EXTENSIONS };
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago