diff --git a/package-lock.json b/package-lock.json index 0dbdcac..f69825f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "git-file-sync", - "version": "1.0.6", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git-file-sync", - "version": "1.0.6", + "version": "1.1.2", "license": "MIT", "dependencies": { "ignore": "^7.0.5", - "obsidian": "*" + "obsidian": "latest" }, "devDependencies": { "@eslint/js": "9.30.1", diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index 3cd997e..84f4eca 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -75,12 +75,47 @@ export abstract class BaseGitService { protected abstract addAuthHeader(headers: Record): void; + /** + * Safely parses a response body as JSON. + * + * Some servers/proxies return a 2xx (or redirect) response whose body is an + * HTML page β€” a login screen, an SSO redirect, or a proxy/error page β€” rather + * than JSON. Accessing `response.json` directly then throws a cryptic + * "Unexpected token '<', \"(response: RequestUrlResponse): T { + const contentType = (response.headers?.['content-type'] ?? response.headers?.['Content-Type'] ?? '').toLowerCase(); + const bodyText = (response.text ?? '').trimStart(); + const looksLikeHtml = bodyText.startsWith('<') || contentType.includes('html'); + + try { + const data = response.json as T; + if (data === undefined && looksLikeHtml) throw new Error('non-JSON response'); + return data; + } catch (e) { + if (looksLikeHtml) { + throw new Error( + 'Expected a JSON response from the Git server but received an HTML page ' + + '(likely a login, SSO redirect, or proxy/error page). ' + + 'Please check the server URL, your access token, and any network proxy or firewall.' + ); + } + throw new Error(`Failed to parse the Git server response as JSON: ${e instanceof Error ? e.message : String(e)}`); + } + } + protected parseErrorResponse(response: RequestUrlResponse): string { try { const data = response.json as { message?: string; error?: string }; return data.message || data.error || JSON.stringify(data); } catch { - return response.text || 'Unknown error'; + const bodyText = (response.text ?? '').trim(); + const contentType = (response.headers?.['content-type'] ?? response.headers?.['Content-Type'] ?? '').toLowerCase(); + if (bodyText.startsWith('<') || contentType.includes('html')) { + return 'Received an HTML page instead of a JSON error (likely a login, redirect, or proxy/error page). Check the server URL, access token, and network proxy.'; + } + return bodyText || 'Unknown error'; } } diff --git a/src/services/github-service.ts b/src/services/github-service.ts index 2d661b7..dbf888f 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -26,7 +26,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface try { const url = `${this.getApiUrl(path)}?ref=${branch}`; const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitHubContentResponse; + const data = this.parseJson(response); return { content: this.decodeContent(data.content, path), @@ -47,14 +47,14 @@ export class GitHubService extends BaseGitService implements GitServiceInterface }; const response = await this.safeRequest(url, 'PUT', body); - const data = response.json as { content: { path: string, sha: string } }; + const data = this.parseJson<{ content: { path: string, sha: string } }>(response); return { path: data.content.path, sha: data.content.sha }; } async listFiles(branch: string, useFilter = true): Promise { const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitHubTreeResponse; + const data = this.parseJson(response); if (data.truncated) { logger.warn('GitHub tree result is truncated. Some files might not be shown.'); diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index 281e0ef..443056a 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -27,7 +27,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface try { const url = `${this.getApiUrl(path)}?ref=${branch}`; const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitLabFileResponse; + const data = this.parseJson(response); return { content: this.decodeContent(data.content, path), @@ -50,7 +50,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface const method = sha ? 'PUT' : 'POST'; const response = await this.safeRequest(url, method, body); - const data = response.json as GitLabFileResponse; + const data = this.parseJson(response); return { path: data.file_path }; } @@ -63,7 +63,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface while (true) { const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=${perPage}&page=${page}`; const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitLabTreeItem[]; + const data = this.parseJson(response); if (!data || data.length === 0) break; diff --git a/tests/services/git-service-base.test.ts b/tests/services/git-service-base.test.ts index 8cf6a52..ecb5057 100644 --- a/tests/services/git-service-base.test.ts +++ b/tests/services/git-service-base.test.ts @@ -41,6 +41,71 @@ describe('BaseGitService', () => { }); }); + describe('parseJson with non-JSON (HTML) responses', () => { + // Simulates Obsidian's real RequestUrlResponse.json getter, which throws + // SyntaxError when the body is not valid JSON (e.g. an HTML page). + function htmlResponse(status = 200): RequestUrlResponse { + return { + status, + headers: { 'content-type': 'text/html; charset=utf-8' }, + text: 'Login', + get json(): unknown { + throw new SyntaxError('Unexpected token \'<\', " { + vi.mocked(requestUrl).mockResolvedValue(htmlResponse(200)); + await expect(service.getFile('test.md', 'main')).rejects.toThrow(/received an HTML page/); + // The cryptic JSON parse error must not leak through. + await expect(service.getFile('test.md', 'main')).rejects.not.toThrow(/Unexpected token/); + }); + + it('listFiles: throws a clear error when the server returns an HTML page (2xx)', async () => { + vi.mocked(requestUrl).mockResolvedValue(htmlResponse(200)); + await expect(service.listFiles('main')).rejects.toThrow(/received an HTML page/); + }); + + it('detects HTML by leading "<" even without an html content-type', async () => { + vi.mocked(requestUrl).mockResolvedValue({ + status: 200, + headers: {}, + text: ' ...', + get json(): unknown { + throw new SyntaxError("Unexpected token '<'"); + }, + } as unknown as RequestUrlResponse); + await expect(service.listFiles('main')).rejects.toThrow(/received an HTML page/); + }); + + it('reports a generic JSON parse failure for malformed (non-HTML) JSON', async () => { + vi.mocked(requestUrl).mockResolvedValue({ + status: 200, + headers: { 'content-type': 'application/json' }, + text: '{ "tree": [', + get json(): unknown { + throw new SyntaxError('Unexpected end of JSON input'); + }, + } as unknown as RequestUrlResponse); + await expect(service.listFiles('main')).rejects.toThrow(/Failed to parse the Git server response as JSON/); + }); + + it('parseErrorResponse: surfaces a clear message for an HTML error page (>=400)', async () => { + vi.mocked(requestUrl).mockResolvedValue({ + status: 502, + headers: { 'content-type': 'text/html' }, + text: 'Bad Gateway', + get json(): unknown { + throw new SyntaxError("Unexpected token '<'"); + }, + } as unknown as RequestUrlResponse); + await expect(service.listFiles('main')).rejects.toThrow(/Received an HTML page instead of a JSON error/); + // The raw HTML document must not be dumped into the message. + await expect(service.listFiles('main')).rejects.not.toThrow(/DOCTYPE/); + }); + }); + describe('encodeContent / decodeContent round-trip', () => { it('should correctly encode and decode UTF-8 content', async () => { const original = 'Hello, δΈ–η•Œ! 🌍';