// A stub of the parts of the Gitea API that publish.ps1 and install.ps1 use. // // The point is to prove the real scripts work: that the token header is sent, that the // multipart upload delivers the .vsix byte-for-byte (a zip, so any encoding step would // corrupt it), that re-publishing replaces assets instead of duplicating them, and that // the download survives a redirect with the auth header intact. const http = require('http'); const crypto = require('crypto'); const PORT = Number(process.env.PORT || 5602); const TOKEN = 'TESTTOKEN'; let nextId = 100; const releases = []; // { id, tag_name, name, body, assets: [] } const blobs = new Map(); // uuid -> Buffer const log = []; function sha256(buffer) { return crypto.createHash('sha256').update(buffer).digest('hex').toUpperCase(); } function authorized(request) { return request.headers.authorization === `token ${TOKEN}`; } function send(response, status, payload) { const body = payload === undefined ? '' : JSON.stringify(payload); response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(body); } function readBody(request) { return new Promise((resolve, reject) => { const chunks = []; request.on('data', chunk => chunks.push(chunk)); request.on('end', () => resolve(Buffer.concat(chunks))); request.on('error', reject); }); } /** The one file out of a multipart/form-data body, as raw bytes. */ function parseMultipart(buffer, contentType) { const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || ''); if (!match) { return null; } const boundary = Buffer.from(`--${match[1] || match[2]}`); const parts = []; let at = buffer.indexOf(boundary); while (at >= 0) { const from = at + boundary.length; const next = buffer.indexOf(boundary, from); if (next < 0) { break; } parts.push(buffer.slice(from, next)); at = next; } for (const part of parts) { const split = part.indexOf('\r\n\r\n'); if (split < 0) { continue; } const headers = part.slice(0, split).toString('latin1'); // Trailing CRLF belongs to the delimiter, not the content. let content = part.slice(split + 4); if (content.slice(-2).toString('latin1') === '\r\n') { content = content.slice(0, -2); } // Values may be unquoted: .NET Framework's MultipartFormDataContent writes // name=attachment, and Go's mime/multipart (what Gitea uses) accepts both. const name = /\bname=(?:"([^"]*)"|([^;\s]+))/i.exec(headers); const filename = /\bfilename=(?:"([^"]*)"|([^;\s]+))/i.exec(headers); const fieldName = name ? (name[1] !== undefined ? name[1] : name[2]) : null; if (fieldName === 'attachment') { return { content, filename: filename ? (filename[1] !== undefined ? filename[1] : filename[2]) : null, headers: headers.trim(), }; } } return null; } const server = http.createServer(async (request, response) => { const url = new URL(request.url, 'http://localhost'); const path = url.pathname; log.push(`${request.method} ${request.url}`); // Serving an attachment: what browser_download_url points at. let hit = /^\/attachments\/([^/]+)$/.exec(path); if (hit && request.method === 'GET') { // Redirect once, so the client has to carry the auth header across it. response.writeHead(302, { Location: `/files/${hit[1]}` }); return response.end(); } hit = /^\/files\/([^/]+)$/.exec(path); if (hit && request.method === 'GET') { if (!authorized(request)) { log.push(' !! download had no token'); return send(response, 401, { message: 'token required on download' }); } const blob = blobs.get(hit[1]); if (!blob) { return send(response, 404, { message: 'no such attachment' }); } response.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': blob.length, }); return response.end(blob); } // Test-only: lets the harness assert on what the server actually received. if (path === '/__state') { return send(response, 200, { log, releases: releases.map(release => ({ id: release.id, tag_name: release.tag_name, name: release.name, assets: release.assets.map(asset => ({ id: asset.id, name: asset.name, size: asset.size, sha256: asset.sha256, uploadFilename: asset.uploadFilename, })), })), }); } if (!authorized(request)) { log.push(' !! missing or wrong token'); return send(response, 401, { message: 'token required' }); } const api = /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/releases(.*)$/.exec(path); if (!api) { return send(response, 404, { message: `unhandled ${path}` }); } const rest = api[3]; // GET /releases/tags/:tag hit = /^\/tags\/(.+)$/.exec(rest); if (hit && request.method === 'GET') { const tag = decodeURIComponent(hit[1]); const release = releases.find(candidate => candidate.tag_name === tag); if (!release) { return send(response, 404, { message: 'release not found' }); } return send(response, 200, release); } // POST /releases if (rest === '' && request.method === 'POST') { const body = JSON.parse((await readBody(request)).toString('utf8') || '{}'); if (!body.tag_name) { return send(response, 422, { message: 'tag_name required' }); } const release = { id: nextId++, tag_name: body.tag_name, name: body.name || body.tag_name, body: body.body || '', draft: !!body.draft, prerelease: !!body.prerelease, target_commitish: body.target_commitish || 'main', assets: [], }; releases.push(release); return send(response, 201, release); } // POST /releases/:id/assets?name=... hit = /^\/(\d+)\/assets$/.exec(rest); if (hit && request.method === 'POST') { const release = releases.find(candidate => candidate.id === Number(hit[1])); if (!release) { return send(response, 404, { message: 'no such release' }); } const raw = await readBody(request); const part = parseMultipart(raw, request.headers['content-type']); if (!part) { const preview = raw.slice(0, 400).toString('latin1').replace(/\r\n/g, '\\r\\n'); log.push(` !! no attachment field. content-type=${request.headers['content-type']}`); log.push(` !! body starts: ${preview}`); return send(response, 400, { message: 'expected multipart with an attachment field' }); } log.push(` disposition: ${part.headers.replace(/\r\n/g, ' | ')}`); const name = url.searchParams.get('name') || part.filename || 'unnamed'; const uuid = crypto.randomBytes(8).toString('hex'); blobs.set(uuid, part.content); const asset = { id: nextId++, name, size: part.content.length, sha256: sha256(part.content), uploadFilename: part.filename, browser_download_url: `http://127.0.0.1:${PORT}/attachments/${uuid}`, }; release.assets.push(asset); return send(response, 201, asset); } // DELETE /releases/:id/assets/:assetId hit = /^\/(\d+)\/assets\/(\d+)$/.exec(rest); if (hit && request.method === 'DELETE') { const release = releases.find(candidate => candidate.id === Number(hit[1])); if (!release) { return send(response, 404, { message: 'no such release' }); } const before = release.assets.length; release.assets = release.assets.filter(asset => asset.id !== Number(hit[2])); if (release.assets.length === before) { return send(response, 404, { message: 'no such asset' }); } response.writeHead(204); return response.end(); } return send(response, 404, { message: `unhandled ${request.method} ${path}` }); }); server.listen(PORT, '127.0.0.1', () => { console.log(`fake gitea on http://127.0.0.1:${PORT} (token ${TOKEN})`); });