nandi/gleanpublic Fork 0
a9e0b8a
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

fix(server): resolve relative URLs in article HTMLUnverified

Julien Robert committed 2026-08-09T16:10:53+02:00 Browse files
a9e0b8a parent: 83ddff1
modified internal/server/articles_handler.go +1 -1
@@ -421,7 +421,7 @@ func (s *Server) handleFetchContent(w http.ResponseWriter, r *http.Request) {
421421 return
422422 }
423423
424- cleaned := sanitizeHTML(content)
424+ cleaned := sanitizeHTMLWithBase(content, article.URL.String)
425425 if cleaned != "" {
426426 if err := s.dbs.Articles.UpdateArticleFullContent(ctx, id, cleaned); err != nil {
427427 s.logger.Error("failed to save full content", "error", err, "id", id)
@@ -421,7 +421,7 @@ func (s *Server) handleFetchContent(w http.ResponseWriter, r *http.Request) {
421 return421 return
422 }422 }
423 423
424- cleaned := sanitizeHTML(content)424+ cleaned := sanitizeHTMLWithBase(content, article.URL.String)
425 if cleaned != "" {425 if cleaned != "" {
426 if err := s.dbs.Articles.UpdateArticleFullContent(ctx, id, cleaned); err != nil {426 if err := s.dbs.Articles.UpdateArticleFullContent(ctx, id, cleaned); err != nil {
427 s.logger.Error("failed to save full content", "error", err, "id", id)427 s.logger.Error("failed to save full content", "error", err, "id", id)
modified internal/server/sanitize.go +88 -0
@@ -1,6 +1,7 @@
11 package server
22
33 import (
4+ "net/url"
45 "regexp"
56 "strings"
67 )
@@ -53,6 +54,14 @@ func isAllowedIframe(tag string) bool {
5354 }
5455
5556 func sanitizeHTML(input string) string {
57+ return sanitizeHTMLWithBase(input, "")
58+}
59+
60+// sanitizeHTMLWithBase runs sanitizeHTML and additionally resolves relative
61+// URLs in url-bearing attributes (src, href, data, poster, srcset) against
62+// baseURL. An empty baseURL leaves relative URLs untouched. Non-http(s)
63+// schemes (data:, javascript:, mailto:, anchors, etc.) are preserved as-is.
64+func sanitizeHTMLWithBase(input, baseURL string) string {
5665 s := input
5766 s = scriptRe.ReplaceAllString(s, "")
5867
@@ -71,9 +80,88 @@ func sanitizeHTML(input string) string {
7180 s = jsHrefSRe.ReplaceAllString(s, `href="#"`)
7281 s = styleExprRe.ReplaceAllString(s, "")
7382 s = styleUrlRe.ReplaceAllString(s, "")
83+
84+ if baseURL != "" {
85+ if base, err := url.Parse(baseURL); err == nil && base.IsAbs() {
86+ s = resolveURLAttrs(s, base)
87+ }
88+ }
89+
7490 return strings.TrimSpace(s)
7591 }
7692
93+// urlAttrRe matches a url-bearing attribute (src, href, data, poster) with
94+// its quoted value. Capture group 1 is the opening quote, group 2 the URL.
95+var urlAttrRe = regexp.MustCompile(`(?i)\b(src|href|data|poster)\s*=\s*("([^"]*)"|'([^']*)')`)
96+
97+// srcsetAttrRe matches a srcset attribute value (double-quoted only for now).
98+var srcsetAttrRe = regexp.MustCompile(`(?i)\bsrcset\s*=\s*"([^"]*)"`)
99+
100+// resolveURLAttrs rewrites relative URLs in url-bearing attributes to be
101+// absolute, resolved against base. Already-absolute URLs and non-http(s)
102+// schemes (data:, mailto:, #anchors) are left untouched.
103+func resolveURLAttrs(s string, base *url.URL) string {
104+ s = urlAttrRe.ReplaceAllStringFunc(s, func(match string) string {
105+ m := urlAttrRe.FindStringSubmatch(match)
106+ if m == nil {
107+ return match
108+ }
109+ raw := m[3]
110+ if raw == "" {
111+ raw = m[4]
112+ }
113+ resolved, ok := resolveURL(raw, base)
114+ if !ok {
115+ return match
116+ }
117+ // Preserve the original quote style.
118+ if m[3] != "" {
119+ return m[1] + `="` + resolved + `"`
120+ }
121+ return m[1] + `='` + resolved + `'`
122+ })
123+
124+ s = srcsetAttrRe.ReplaceAllStringFunc(s, func(match string) string {
125+ m := srcsetAttrRe.FindStringSubmatch(match)
126+ if m == nil {
127+ return match
128+ }
129+ value := m[1]
130+ parts := strings.Split(value, ",")
131+ for i, p := range parts {
132+ p = strings.TrimSpace(p)
133+ if p == "" {
134+ continue
135+ }
136+ fields := strings.Fields(p)
137+ if resolved, ok := resolveURL(fields[0], base); ok {
138+ fields[0] = resolved
139+ parts[i] = strings.Join(fields, " ")
140+ }
141+ }
142+ return `srcset="` + strings.Join(parts, ", ") + `"`
143+ })
144+
145+ return s
146+}
147+
148+// resolveURL resolves raw against base. Returns false when raw is already
149+// absolute with a non-http(s) scheme, or is empty, or is a fragment-only URL.
150+func resolveURL(raw string, base *url.URL) (string, bool) {
151+ if raw == "" || strings.HasPrefix(raw, "#") {
152+ return raw, false
153+ }
154+ ref, err := url.Parse(raw)
155+ if err != nil {
156+ return raw, false
157+ }
158+ if ref.IsAbs() {
159+ // Keep http(s) as-is; preserve other schemes (data:, mailto:, etc.).
160+ return raw, ref.Scheme == "http" || ref.Scheme == "https"
161+ }
162+ return base.ResolveReference(ref).String(), true
163+}
164+
77165 func convertMediaLinks(s string) string {
78166 s = youtubeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`)
79167 s = youtuBeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`)
@@ -1,6 +1,7 @@
1 package server1 package server
2 2
3 import (3 import (
4+ "net/url"
4 "regexp"5 "regexp"
5 "strings"6 "strings"
6 )7 )
@@ -53,6 +54,14 @@ func isAllowedIframe(tag string) bool {
53 }54 }
54 55
55 func sanitizeHTML(input string) string {56 func sanitizeHTML(input string) string {
57+ return sanitizeHTMLWithBase(input, "")
58+}
59+
60+// sanitizeHTMLWithBase runs sanitizeHTML and additionally resolves relative
61+// URLs in url-bearing attributes (src, href, data, poster, srcset) against
62+// baseURL. An empty baseURL leaves relative URLs untouched. Non-http(s)
63+// schemes (data:, javascript:, mailto:, anchors, etc.) are preserved as-is.
64+func sanitizeHTMLWithBase(input, baseURL string) string {
56 s := input65 s := input
57 s = scriptRe.ReplaceAllString(s, "")66 s = scriptRe.ReplaceAllString(s, "")
58 67
@@ -71,9 +80,88 @@ func sanitizeHTML(input string) string {
71 s = jsHrefSRe.ReplaceAllString(s, `href="#"`)80 s = jsHrefSRe.ReplaceAllString(s, `href="#"`)
72 s = styleExprRe.ReplaceAllString(s, "")81 s = styleExprRe.ReplaceAllString(s, "")
73 s = styleUrlRe.ReplaceAllString(s, "")82 s = styleUrlRe.ReplaceAllString(s, "")
83+
84+ if baseURL != "" {
85+ if base, err := url.Parse(baseURL); err == nil && base.IsAbs() {
86+ s = resolveURLAttrs(s, base)
87+ }
88+ }
89+
74 return strings.TrimSpace(s)90 return strings.TrimSpace(s)
75 }91 }
76 92
93+// urlAttrRe matches a url-bearing attribute (src, href, data, poster) with
94+// its quoted value. Capture group 1 is the opening quote, group 2 the URL.
95+var urlAttrRe = regexp.MustCompile(`(?i)\b(src|href|data|poster)\s*=\s*("([^"]*)"|'([^']*)')`)
96+
97+// srcsetAttrRe matches a srcset attribute value (double-quoted only for now).
98+var srcsetAttrRe = regexp.MustCompile(`(?i)\bsrcset\s*=\s*"([^"]*)"`)
99+
100+// resolveURLAttrs rewrites relative URLs in url-bearing attributes to be
101+// absolute, resolved against base. Already-absolute URLs and non-http(s)
102+// schemes (data:, mailto:, #anchors) are left untouched.
103+func resolveURLAttrs(s string, base *url.URL) string {
104+ s = urlAttrRe.ReplaceAllStringFunc(s, func(match string) string {
105+ m := urlAttrRe.FindStringSubmatch(match)
106+ if m == nil {
107+ return match
108+ }
109+ raw := m[3]
110+ if raw == "" {
111+ raw = m[4]
112+ }
113+ resolved, ok := resolveURL(raw, base)
114+ if !ok {
115+ return match
116+ }
117+ // Preserve the original quote style.
118+ if m[3] != "" {
119+ return m[1] + `="` + resolved + `"`
120+ }
121+ return m[1] + `='` + resolved + `'`
122+ })
123+
124+ s = srcsetAttrRe.ReplaceAllStringFunc(s, func(match string) string {
125+ m := srcsetAttrRe.FindStringSubmatch(match)
126+ if m == nil {
127+ return match
128+ }
129+ value := m[1]
130+ parts := strings.Split(value, ",")
131+ for i, p := range parts {
132+ p = strings.TrimSpace(p)
133+ if p == "" {
134+ continue
135+ }
136+ fields := strings.Fields(p)
137+ if resolved, ok := resolveURL(fields[0], base); ok {
138+ fields[0] = resolved
139+ parts[i] = strings.Join(fields, " ")
140+ }
141+ }
142+ return `srcset="` + strings.Join(parts, ", ") + `"`
143+ })
144+
145+ return s
146+}
147+
148+// resolveURL resolves raw against base. Returns false when raw is already
149+// absolute with a non-http(s) scheme, or is empty, or is a fragment-only URL.
150+func resolveURL(raw string, base *url.URL) (string, bool) {
151+ if raw == "" || strings.HasPrefix(raw, "#") {
152+ return raw, false
153+ }
154+ ref, err := url.Parse(raw)
155+ if err != nil {
156+ return raw, false
157+ }
158+ if ref.IsAbs() {
159+ // Keep http(s) as-is; preserve other schemes (data:, mailto:, etc.).
160+ return raw, ref.Scheme == "http" || ref.Scheme == "https"
161+ }
162+ return base.ResolveReference(ref).String(), true
163+}
164+
77 func convertMediaLinks(s string) string {165 func convertMediaLinks(s string) string {
78 s = youtubeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`)166 s = youtubeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`)
79 s = youtuBeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`)167 s = youtuBeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`)
modified internal/server/sanitize_test.go +60 -0
@@ -206,3 +206,63 @@ func TestSanitizeHTML_PreservesNonMediaLinks(t *testing.T) {
206206 t.Fatalf("non-media link was modified: %s", got)
207207 }
208208 }
209+
210+func TestSanitizeHTMLWithBase_ResolvesRelativeImgSrc(t *testing.T) {
211+ input := `<img src="photo.jpg" alt="photo">`
212+ got := sanitizeHTMLWithBase(input, "https://example.com/posts/123")
213+ want := `<img src="https://example.com/posts/photo.jpg" alt="photo">`
214+ if got != want {
215+ t.Fatalf("relative img src not resolved:\ngot: %s\nwant: %s", got, want)
216+ }
217+}
218+
219+func TestSanitizeHTMLWithBase_ResolvesRelativeVideoSource(t *testing.T) {
220+ input := `<video><source src="clip.mp4" type="video/mp4"></video>`
221+ got := sanitizeHTMLWithBase(input, "https://example.com/a/b")
222+ want := `<video><source src="https://example.com/a/clip.mp4" type="video/mp4"></video>`
223+ if got != want {
224+ t.Fatalf("relative source src not resolved:\ngot: %s\nwant: %s", got, want)
225+ }
226+}
227+
228+func TestSanitizeHTMLWithBase_PreservesAbsoluteHttpURL(t *testing.T) {
229+ input := `<img src="https://cdn.example.com/photo.jpg" alt="photo">`
230+ got := sanitizeHTMLWithBase(input, "https://example.com/post")
231+ if got != input {
232+ t.Fatalf("absolute http url modified:\ngot: %s\nwant: %s", got, input)
233+ }
234+}
235+
236+func TestSanitizeHTMLWithBase_PreservesDataURI(t *testing.T) {
237+ input := `<img src="data:image/png;base64,iVBORw0KGgo=">`
238+ got := sanitizeHTMLWithBase(input, "https://example.com/post")
239+ if got != input {
240+ t.Fatalf("data uri modified:\ngot: %s\nwant: %s", got, input)
241+ }
242+}
243+
244+func TestSanitizeHTMLWithBase_PreservesAnchor(t *testing.T) {
245+ input := `<a href="#section">jump</a>`
246+ got := sanitizeHTMLWithBase(input, "https://example.com/post")
247+ if got != input {
248+ t.Fatalf("anchor modified:\ngot: %s\nwant: %s", got, input)
249+ }
250+}
251+
252+func TestSanitizeHTMLWithBase_ResolvesRootRelative(t *testing.T) {
253+ input := `<img src="/assets/photo.jpg">`
254+ got := sanitizeHTMLWithBase(input, "https://example.com/posts/123")
255+ want := `<img src="https://example.com/assets/photo.jpg">`
256+ if got != want {
257+ t.Fatalf("root-relative src not resolved:\ngot: %s\nwant: %s", got, want)
258+ }
259+}
260+
261+func TestSanitizeHTMLWithBase_NoBasePreservesRelative(t *testing.T) {
262+ // Without a base URL, relative URLs are left untouched (legacy behavior).
263+ input := `<img src="photo.jpg" alt="photo">`
264+ got := sanitizeHTML(input)
265+ if got != input {
266+ t.Fatalf("relative img src modified without base:\ngot: %s\nwant: %s", got, input)
267+ }
268+}
@@ -206,3 +206,63 @@ func TestSanitizeHTML_PreservesNonMediaLinks(t *testing.T) {
206 t.Fatalf("non-media link was modified: %s", got)206 t.Fatalf("non-media link was modified: %s", got)
207 }207 }
208 }208 }
209+
210+func TestSanitizeHTMLWithBase_ResolvesRelativeImgSrc(t *testing.T) {
211+ input := `<img src="photo.jpg" alt="photo">`
212+ got := sanitizeHTMLWithBase(input, "https://example.com/posts/123")
213+ want := `<img src="https://example.com/posts/photo.jpg" alt="photo">`
214+ if got != want {
215+ t.Fatalf("relative img src not resolved:\ngot: %s\nwant: %s", got, want)
216+ }
217+}
218+
219+func TestSanitizeHTMLWithBase_ResolvesRelativeVideoSource(t *testing.T) {
220+ input := `<video><source src="clip.mp4" type="video/mp4"></video>`
221+ got := sanitizeHTMLWithBase(input, "https://example.com/a/b")
222+ want := `<video><source src="https://example.com/a/clip.mp4" type="video/mp4"></video>`
223+ if got != want {
224+ t.Fatalf("relative source src not resolved:\ngot: %s\nwant: %s", got, want)
225+ }
226+}
227+
228+func TestSanitizeHTMLWithBase_PreservesAbsoluteHttpURL(t *testing.T) {
229+ input := `<img src="https://cdn.example.com/photo.jpg" alt="photo">`
230+ got := sanitizeHTMLWithBase(input, "https://example.com/post")
231+ if got != input {
232+ t.Fatalf("absolute http url modified:\ngot: %s\nwant: %s", got, input)
233+ }
234+}
235+
236+func TestSanitizeHTMLWithBase_PreservesDataURI(t *testing.T) {
237+ input := `<img src="data:image/png;base64,iVBORw0KGgo=">`
238+ got := sanitizeHTMLWithBase(input, "https://example.com/post")
239+ if got != input {
240+ t.Fatalf("data uri modified:\ngot: %s\nwant: %s", got, input)
241+ }
242+}
243+
244+func TestSanitizeHTMLWithBase_PreservesAnchor(t *testing.T) {
245+ input := `<a href="#section">jump</a>`
246+ got := sanitizeHTMLWithBase(input, "https://example.com/post")
247+ if got != input {
248+ t.Fatalf("anchor modified:\ngot: %s\nwant: %s", got, input)
249+ }
250+}
251+
252+func TestSanitizeHTMLWithBase_ResolvesRootRelative(t *testing.T) {
253+ input := `<img src="/assets/photo.jpg">`
254+ got := sanitizeHTMLWithBase(input, "https://example.com/posts/123")
255+ want := `<img src="https://example.com/assets/photo.jpg">`
256+ if got != want {
257+ t.Fatalf("root-relative src not resolved:\ngot: %s\nwant: %s", got, want)
258+ }
259+}
260+
261+func TestSanitizeHTMLWithBase_NoBasePreservesRelative(t *testing.T) {
262+ // Without a base URL, relative URLs are left untouched (legacy behavior).
263+ input := `<img src="photo.jpg" alt="photo">`
264+ got := sanitizeHTML(input)
265+ if got != input {
266+ t.Fatalf("relative img src modified without base:\ngot: %s\nwant: %s", got, input)
267+ }
268+}
modified web/src/lib/api.ts +75 -129
@@ -29,7 +29,7 @@ export function setCsrfToken(token: string) {
2929 cachedCsrf = token;
3030 }
3131
32-function readCsrfCookie(): string {
32+function readCsrfToken(): string {
3333 if (cachedCsrf) return cachedCsrf;
3434 if (typeof document !== "undefined") {
3535 const match = document.cookie.match(/(?:^|;\s*)glean_csrf=([^;]+)/);
@@ -40,67 +40,46 @@ function readCsrfCookie(): string {
4040
4141 type FetchFn = typeof fetch;
4242
43-export const api = {
44- get: <T>(
45- path: string,
46- query?: Record<string, string>,
47- fetchFn: FetchFn = fetch,
48- ) => request<T>("GET", path, { query }, fetchFn),
49- post: <T>(
50- path: string,
51- body?: Record<string, unknown>,
52- fetchFn: FetchFn = fetch,
53- ) => request<T>("POST", path, { body }, fetchFn),
54- postForm: <T>(path: string, form: FormData, fetchFn: FetchFn = fetch) =>
55- request<T>("POST", path, { body: form }, fetchFn),
56- del: <T>(
57- path: string,
58- body?: Record<string, unknown>,
59- fetchFn: FetchFn = fetch,
60- ) => request<T>("DELETE", path, { body }, fetchFn),
61-};
43+// Encode a body for the Go handlers, which read inputs via r.FormValue.
44+function encodeBody(
45+ body: Record<string, unknown> | FormData | undefined,
46+): { body: BodyInit | undefined; contentType?: string } {
47+ if (!body) return { body: undefined };
48+ if (body instanceof FormData) return { body };
49+ const params = new URLSearchParams();
50+ for (const [k, v] of Object.entries(body)) {
51+ if (Array.isArray(v)) {
52+ for (const item of v) params.append(k, String(item));
53+ } else if (v !== undefined && v !== null) {
54+ params.set(k, String(v));
55+ }
56+ }
57+ return { body: params, contentType: "application/x-www-form-urlencoded" };
58+}
6259
6360 async function request<T>(
6461 method: string,
6562 path: string,
66- opts: {
67- body?: Record<string, unknown> | FormData;
68- query?: Record<string, string>;
69- } = {},
70- fetchFn: FetchFn = fetch,
63+ fetchFn: FetchFn,
64+ opts: { body?: Record<string, unknown> | FormData; query?: Record<string, string> } = {},
7165 ): Promise<T> {
72- const url = new URL(path, "http://placeholder");
66+ const search = new URLSearchParams();
7367 if (opts.query) {
7468 for (const [k, v] of Object.entries(opts.query)) {
75- if (v !== "" && v != null) url.searchParams.set(k, v);
69+ if (v !== "" && v != null) search.set(k, v);
7670 }
7771 }
78- const search = url.search;
72+ const qs = search.toString();
73+ const { body, contentType } = encodeBody(opts.body);
7974
80- let body: BodyInit | undefined;
8175 const headers: Record<string, string> = {};
82- if (opts.body && !(opts.body instanceof FormData)) {
83- // The Go handlers read inputs via r.FormValue, so send form-encoded bodies.
84- headers["Content-Type"] = "application/x-www-form-urlencoded";
85- const params = new URLSearchParams();
86- for (const [k, v] of Object.entries(opts.body)) {
87- if (Array.isArray(v)) {
88- for (const item of v) params.append(k, String(item));
89- } else if (v !== undefined && v !== null) {
90- params.set(k, String(v));
91- }
92- }
93- body = params;
94- } else if (opts.body instanceof FormData) {
95- body = opts.body;
96- }
97-
76+ if (contentType) headers["Content-Type"] = contentType;
9877 if (method !== "GET" && method !== "HEAD") {
99- const token = readCsrfCookie();
78+ const token = readCsrfToken();
10079 if (token) headers["X-CSRF-Token"] = token;
10180 }
10281
103- const res = await fetchFn(`/api${path}${search}`, {
82+ const res = await fetchFn(`/api${path}${qs ? `?${qs}` : ""}`, {
10483 method,
10584 headers,
10685 body,
@@ -121,9 +100,7 @@ async function request<T>(
121100 }
122101
123102 const ct = res.headers.get("content-type") ?? "";
124- if (ct.includes("application/json")) {
125- return (await res.json()) as T;
126- }
103+ if (ct.includes("application/json")) return (await res.json()) as T;
127104 return (await res.text()) as unknown as T;
128105 }
129106
@@ -223,113 +200,82 @@ export interface FeedRecs {
223200 type Endpoints = ReturnType<typeof createEndpoints>;
224201
225202 function createEndpoints(fetchFn: FetchFn) {
226- const a = {
227- get: <T>(path: string, query?: Record<string, string>) =>
228- request<T>("GET", path, { query }, fetchFn),
229- post: <T>(path: string, body?: Record<string, unknown>) =>
230- request<T>("POST", path, { body }, fetchFn),
231- postForm: <T>(path: string, form: FormData) =>
232- request<T>("POST", path, { body: form }, fetchFn),
233- del: <T>(path: string, body?: Record<string, unknown>) =>
234- request<T>("DELETE", path, { body }, fetchFn),
235- };
203+ const get = <T>(path: string, query?: Record<string, string>) =>
204+ request<T>("GET", path, fetchFn, { query });
205+ const post = <T>(path: string, body?: Record<string, unknown> | FormData) =>
206+ request<T>("POST", path, fetchFn, { body });
207+ const del = <T>(path: string, body?: Record<string, unknown>) =>
208+ request<T>("DELETE", path, fetchFn, { body });
209+
236210 return {
237- me: () => a.get<MeResponse>("/me"),
238- dashboard: () => a.get<DashboardData>("/dashboard"),
239- articles: (params: Record<string, string>) =>
240- a.get<ArticlesData>("/articles", params),
211+ me: () => get<MeResponse>("/me"),
212+ dashboard: () => get<DashboardData>("/dashboard"),
213+ articles: (params: Record<string, string>) => get<ArticlesData>("/articles", params),
241214 article: (id: number, params: Record<string, string>) =>
242- a.get<ArticleDetailData>(`/articles/${id}`, params),
215+ get<ArticleDetailData>(`/articles/${id}`, params),
243216 newArticleCount: (since: number) =>
244- a.get<{ count: number }>("/articles/new-count", { since: String(since) }),
245- markRead: (id: number) =>
246- a.post<{ id: number; is_read: boolean }>(`/articles/${id}/read`),
217+ get<{ count: number }>("/articles/new-count", { since: String(since) }),
218+ markRead: (id: number) => post<{ id: number; is_read: boolean }>(`/articles/${id}/read`),
247219 markUnread: (id: number) =>
248- a.post<{ id: number; is_read: boolean }>(`/articles/${id}/unread`),
220+ post<{ id: number; is_read: boolean }>(`/articles/${id}/unread`),
249221 toggleLike: (id: number) =>
250- a.post<{ id: number; liked: boolean; like_count: number }>(
251- `/articles/${id}/like`,
252- ),
222+ post<{ id: number; liked: boolean; like_count: number }>(`/articles/${id}/like`),
253223 fetchContent: (id: number) =>
254- a.post<{ id: number; full_content: string }>(
255- `/articles/${id}/fetch-content`,
256- ),
224+ post<{ id: number; full_content: string }>(`/articles/${id}/fetch-content`),
257225 markAllRead: (feed?: string) =>
258- a.post<void>("/articles/mark-all-read", feed ? { feed } : {}),
226+ post<void>("/articles/mark-all-read", feed ? { feed } : {}),
259227
260- feeds: (category?: string) =>
261- a.get<FeedsData>("/feeds", category ? { category } : {}),
228+ feeds: (category?: string) => get<FeedsData>("/feeds", category ? { category } : {}),
262229 addFeed: (feed_url: string, category?: string) =>
263- a.post<{ subscription: Subscription }>("/feeds/add", {
264- feed_url,
265- category,
266- }),
230+ post<{ subscription: Subscription }>("/feeds/add", { feed_url, category }),
267231 editFeed: (feed_url: string, category: string) =>
268- a.post<{ subscription: Subscription }>("/feeds/edit", {
269- feed_url,
270- category,
271- }),
272- removeFeed: (url: string) => a.del<void>("/feeds/remove", { url }),
273- feedList: (category?: string) =>
274- a.get<{ subscriptions: Subscription[] }>(
275- "/feeds/list",
276- category ? { category } : {},
277- ),
232+ post<{ subscription: Subscription }>("/feeds/edit", { feed_url, category }),
233+ removeFeed: (url: string) => del<void>("/feeds/remove", { url }),
278234 refreshFeeds: (category?: string) =>
279- a.post<{ subscriptions: Subscription[] }>(
280- "/feeds/refresh",
281- category ? { category } : {},
282- ),
283- retryFeed: (url: string) =>
284- a.post<{ dead_feeds: Feed[] }>("/feeds/retry", { url }),
285- clearFeeds: () => a.post<void>("/feeds/clear", {}),
286- uploadOpml: (form: FormData) =>
287- a.postForm<{ added: number }>("/feeds/opml/upload", form),
288-
289- trending: (params: Record<string, string>) =>
290- a.get<TrendingData>("/trending", params),
291-
292- library: (params: Record<string, string>) =>
293- a.get<LibraryData>("/library", params),
235+ post<{ subscriptions: Subscription[] }>("/feeds/refresh", category ? { category } : {}),
236+ retryFeed: (url: string) => post<{ dead_feeds: Feed[] }>("/feeds/retry", { url }),
237+ clearFeeds: () => post<void>("/feeds/clear", {}),
238+ uploadOpml: (form: FormData) => post<{ added: number }>("/feeds/opml/upload", form),
239+
240+ trending: (params: Record<string, string>) => get<TrendingData>("/trending", params),
241+
242+ library: (params: Record<string, string>) => get<LibraryData>("/library", params),
294243 createAnnotation: (body: Record<string, unknown>) =>
295- a.post<{ annotation: Annotation }>("/library/create", body),
296- deleteAnnotation: (id: number) => a.post<void>(`/library/${id}/delete`),
244+ post<{ annotation: Annotation }>("/library/create", body),
245+ deleteAnnotation: (id: number) => post<void>(`/library/${id}/delete`),
297246
298- profile: (did: string) => a.get<ProfileData>(`/profile/${did}`),
247+ profile: (did: string) => get<ProfileData>(`/profile/${did}`),
299248
300- articleRecs: () => a.get<{ articles: Article[] }>("/recs/articles"),
301- feedRecs: () => a.get<FeedRecs>("/recs/feeds"),
302- peopleRecs: () => a.get<PeopleRecs>("/recs/people"),
303- dismissFeed: (feed_url: string) =>
304- a.post<void>("/recs/dismiss-feed", { feed_url }),
249+ articleRecs: () => get<{ articles: Article[] }>("/recs/articles"),
250+ feedRecs: () => get<FeedRecs>("/recs/feeds"),
251+ peopleRecs: () => get<PeopleRecs>("/recs/people"),
252+ dismissFeed: (feed_url: string) => post<void>("/recs/dismiss-feed", { feed_url }),
305253 dismissArticle: (article_url: string) =>
306- a.post<void>("/recs/dismiss-article", { article_url }),
254+ post<void>("/recs/dismiss-article", { article_url }),
307255 dismissPerson: (target_did: string) =>
308- a.post<void>("/recs/dismiss-person", { target_did }),
256+ post<void>("/recs/dismiss-person", { target_did }),
309257
310258 toggleLanguage: (code: string) =>
311- a.post<{ languages: string[] }>(`/settings/languages/${code}`),
259+ post<{ languages: string[] }>(`/settings/languages/${code}`),
312260 toggleExpandedView: (expanded_view: boolean) =>
313- a.post<{ expanded_view: boolean }>("/settings/expanded-view", {
261+ post<{ expanded_view: boolean }>("/settings/expanded-view", {
314262 expanded_view: expanded_view ? "1" : "0",
315263 }),
316264 toggleDigest: (digest_enabled: boolean) =>
317- a.post<{ digest_enabled: boolean }>("/settings/digest-enabled", {
265+ post<{ digest_enabled: boolean }>("/settings/digest-enabled", {
318266 digest_enabled: digest_enabled ? "1" : "0",
319267 }),
320268
321- digest: () => a.get<Digest | null>("/digest"),
269+ digest: () => get<Digest | null>("/digest"),
322270 markDigestRead: (ids: number[]) =>
323- a.post<Digest>("/digest/mark-read", { ids: ids.map(String) }),
271+ post<Digest>("/digest/mark-read", { ids: ids.map(String) }),
324272
325- authActors: (q: string) =>
326- a.get<{ actors: Actor[] }>("/auth/actors", { q }),
327- authStart: (handle: string) =>
328- a.post<{ redirect: string }>("/auth/start", { handle }),
329- authRegister: () => a.get<{ redirect: string }>("/auth/register"),
330- authLogout: () => a.post<{ redirect: string }>("/auth/logout"),
273+ authActors: (q: string) => get<{ actors: Actor[] }>("/auth/actors", { q }),
274+ authStart: (handle: string) => post<{ redirect: string }>("/auth/start", { handle }),
275+ authRegister: () => get<{ redirect: string }>("/auth/register"),
276+ authLogout: () => post<{ redirect: string }>("/auth/logout"),
331277
332- stats: () => a.get<StatsData>("/stats"),
278+ stats: () => get<StatsData>("/stats"),
333279 };
334280 }
335281
@@ -29,7 +29,7 @@ export function setCsrfToken(token: string) {
29 cachedCsrf = token;29 cachedCsrf = token;
30 }30 }
31 31
32-function readCsrfCookie(): string {32+function readCsrfToken(): string {
33 if (cachedCsrf) return cachedCsrf;33 if (cachedCsrf) return cachedCsrf;
34 if (typeof document !== "undefined") {34 if (typeof document !== "undefined") {
35 const match = document.cookie.match(/(?:^|;\s*)glean_csrf=([^;]+)/);35 const match = document.cookie.match(/(?:^|;\s*)glean_csrf=([^;]+)/);
@@ -40,67 +40,46 @@ function readCsrfCookie(): string {
40 40
41 type FetchFn = typeof fetch;41 type FetchFn = typeof fetch;
42 42
43-export const api = {43+// Encode a body for the Go handlers, which read inputs via r.FormValue.
44- get: <T>(44+function encodeBody(
45- path: string,45+ body: Record<string, unknown> | FormData | undefined,
46- query?: Record<string, string>,46+): { body: BodyInit | undefined; contentType?: string } {
47- fetchFn: FetchFn = fetch,47+ if (!body) return { body: undefined };
48- ) => request<T>("GET", path, { query }, fetchFn),48+ if (body instanceof FormData) return { body };
49- post: <T>(49+ const params = new URLSearchParams();
50- path: string,50+ for (const [k, v] of Object.entries(body)) {
51- body?: Record<string, unknown>,51+ if (Array.isArray(v)) {
52- fetchFn: FetchFn = fetch,52+ for (const item of v) params.append(k, String(item));
53- ) => request<T>("POST", path, { body }, fetchFn),53+ } else if (v !== undefined && v !== null) {
54- postForm: <T>(path: string, form: FormData, fetchFn: FetchFn = fetch) =>54+ params.set(k, String(v));
55- request<T>("POST", path, { body: form }, fetchFn),55+ }
56- del: <T>(56+ }
57- path: string,57+ return { body: params, contentType: "application/x-www-form-urlencoded" };
58- body?: Record<string, unknown>,58+}
59- fetchFn: FetchFn = fetch,
60- ) => request<T>("DELETE", path, { body }, fetchFn),
61-};
62 59
63 async function request<T>(60 async function request<T>(
64 method: string,61 method: string,
65 path: string,62 path: string,
66- opts: {63+ fetchFn: FetchFn,
67- body?: Record<string, unknown> | FormData;64+ opts: { body?: Record<string, unknown> | FormData; query?: Record<string, string> } = {},
68- query?: Record<string, string>;
69- } = {},
70- fetchFn: FetchFn = fetch,
71 ): Promise<T> {65 ): Promise<T> {
72- const url = new URL(path, "http://placeholder");66+ const search = new URLSearchParams();
73 if (opts.query) {67 if (opts.query) {
74 for (const [k, v] of Object.entries(opts.query)) {68 for (const [k, v] of Object.entries(opts.query)) {
75- if (v !== "" && v != null) url.searchParams.set(k, v);69+ if (v !== "" && v != null) search.set(k, v);
76 }70 }
77 }71 }
78- const search = url.search;72+ const qs = search.toString();
73+ const { body, contentType } = encodeBody(opts.body);
79 74
80- let body: BodyInit | undefined;
81 const headers: Record<string, string> = {};75 const headers: Record<string, string> = {};
82- if (opts.body && !(opts.body instanceof FormData)) {76+ if (contentType) headers["Content-Type"] = contentType;
83- // The Go handlers read inputs via r.FormValue, so send form-encoded bodies.
84- headers["Content-Type"] = "application/x-www-form-urlencoded";
85- const params = new URLSearchParams();
86- for (const [k, v] of Object.entries(opts.body)) {
87- if (Array.isArray(v)) {
88- for (const item of v) params.append(k, String(item));
89- } else if (v !== undefined && v !== null) {
90- params.set(k, String(v));
91- }
92- }
93- body = params;
94- } else if (opts.body instanceof FormData) {
95- body = opts.body;
96- }
97-
98 if (method !== "GET" && method !== "HEAD") {77 if (method !== "GET" && method !== "HEAD") {
99- const token = readCsrfCookie();78+ const token = readCsrfToken();
100 if (token) headers["X-CSRF-Token"] = token;79 if (token) headers["X-CSRF-Token"] = token;
101 }80 }
102 81
103- const res = await fetchFn(`/api${path}${search}`, {82+ const res = await fetchFn(`/api${path}${qs ? `?${qs}` : ""}`, {
104 method,83 method,
105 headers,84 headers,
106 body,85 body,
@@ -121,9 +100,7 @@ async function request<T>(
121 }100 }
122 101
123 const ct = res.headers.get("content-type") ?? "";102 const ct = res.headers.get("content-type") ?? "";
124- if (ct.includes("application/json")) {103+ if (ct.includes("application/json")) return (await res.json()) as T;
125- return (await res.json()) as T;
126- }
127 return (await res.text()) as unknown as T;104 return (await res.text()) as unknown as T;
128 }105 }
129 106
@@ -223,113 +200,82 @@ export interface FeedRecs {
223 type Endpoints = ReturnType<typeof createEndpoints>;200 type Endpoints = ReturnType<typeof createEndpoints>;
224 201
225 function createEndpoints(fetchFn: FetchFn) {202 function createEndpoints(fetchFn: FetchFn) {
226- const a = {203+ const get = <T>(path: string, query?: Record<string, string>) =>
227- get: <T>(path: string, query?: Record<string, string>) =>204+ request<T>("GET", path, fetchFn, { query });
228- request<T>("GET", path, { query }, fetchFn),205+ const post = <T>(path: string, body?: Record<string, unknown> | FormData) =>
229- post: <T>(path: string, body?: Record<string, unknown>) =>206+ request<T>("POST", path, fetchFn, { body });
230- request<T>("POST", path, { body }, fetchFn),207+ const del = <T>(path: string, body?: Record<string, unknown>) =>
231- postForm: <T>(path: string, form: FormData) =>208+ request<T>("DELETE", path, fetchFn, { body });
232- request<T>("POST", path, { body: form }, fetchFn),209+
233- del: <T>(path: string, body?: Record<string, unknown>) =>
234- request<T>("DELETE", path, { body }, fetchFn),
235- };
236 return {210 return {
237- me: () => a.get<MeResponse>("/me"),211+ me: () => get<MeResponse>("/me"),
238- dashboard: () => a.get<DashboardData>("/dashboard"),212+ dashboard: () => get<DashboardData>("/dashboard"),
239- articles: (params: Record<string, string>) =>213+ articles: (params: Record<string, string>) => get<ArticlesData>("/articles", params),
240- a.get<ArticlesData>("/articles", params),
241 article: (id: number, params: Record<string, string>) =>214 article: (id: number, params: Record<string, string>) =>
242- a.get<ArticleDetailData>(`/articles/${id}`, params),215+ get<ArticleDetailData>(`/articles/${id}`, params),
243 newArticleCount: (since: number) =>216 newArticleCount: (since: number) =>
244- a.get<{ count: number }>("/articles/new-count", { since: String(since) }),217+ get<{ count: number }>("/articles/new-count", { since: String(since) }),
245- markRead: (id: number) =>218+ markRead: (id: number) => post<{ id: number; is_read: boolean }>(`/articles/${id}/read`),
246- a.post<{ id: number; is_read: boolean }>(`/articles/${id}/read`),
247 markUnread: (id: number) =>219 markUnread: (id: number) =>
248- a.post<{ id: number; is_read: boolean }>(`/articles/${id}/unread`),220+ post<{ id: number; is_read: boolean }>(`/articles/${id}/unread`),
249 toggleLike: (id: number) =>221 toggleLike: (id: number) =>
250- a.post<{ id: number; liked: boolean; like_count: number }>(222+ post<{ id: number; liked: boolean; like_count: number }>(`/articles/${id}/like`),
251- `/articles/${id}/like`,
252- ),
253 fetchContent: (id: number) =>223 fetchContent: (id: number) =>
254- a.post<{ id: number; full_content: string }>(224+ post<{ id: number; full_content: string }>(`/articles/${id}/fetch-content`),
255- `/articles/${id}/fetch-content`,
256- ),
257 markAllRead: (feed?: string) =>225 markAllRead: (feed?: string) =>
258- a.post<void>("/articles/mark-all-read", feed ? { feed } : {}),226+ post<void>("/articles/mark-all-read", feed ? { feed } : {}),
259 227
260- feeds: (category?: string) =>228+ feeds: (category?: string) => get<FeedsData>("/feeds", category ? { category } : {}),
261- a.get<FeedsData>("/feeds", category ? { category } : {}),
262 addFeed: (feed_url: string, category?: string) =>229 addFeed: (feed_url: string, category?: string) =>
263- a.post<{ subscription: Subscription }>("/feeds/add", {230+ post<{ subscription: Subscription }>("/feeds/add", { feed_url, category }),
264- feed_url,
265- category,
266- }),
267 editFeed: (feed_url: string, category: string) =>231 editFeed: (feed_url: string, category: string) =>
268- a.post<{ subscription: Subscription }>("/feeds/edit", {232+ post<{ subscription: Subscription }>("/feeds/edit", { feed_url, category }),
269- feed_url,233+ removeFeed: (url: string) => del<void>("/feeds/remove", { url }),
270- category,
271- }),
272- removeFeed: (url: string) => a.del<void>("/feeds/remove", { url }),
273- feedList: (category?: string) =>
274- a.get<{ subscriptions: Subscription[] }>(
275- "/feeds/list",
276- category ? { category } : {},
277- ),
278 refreshFeeds: (category?: string) =>234 refreshFeeds: (category?: string) =>
279- a.post<{ subscriptions: Subscription[] }>(235+ post<{ subscriptions: Subscription[] }>("/feeds/refresh", category ? { category } : {}),
280- "/feeds/refresh",236+ retryFeed: (url: string) => post<{ dead_feeds: Feed[] }>("/feeds/retry", { url }),
281- category ? { category } : {},237+ clearFeeds: () => post<void>("/feeds/clear", {}),
282- ),238+ uploadOpml: (form: FormData) => post<{ added: number }>("/feeds/opml/upload", form),
283- retryFeed: (url: string) =>239+
284- a.post<{ dead_feeds: Feed[] }>("/feeds/retry", { url }),240+ trending: (params: Record<string, string>) => get<TrendingData>("/trending", params),
285- clearFeeds: () => a.post<void>("/feeds/clear", {}),241+
286- uploadOpml: (form: FormData) =>242+ library: (params: Record<string, string>) => get<LibraryData>("/library", params),
287- a.postForm<{ added: number }>("/feeds/opml/upload", form),
288-
289- trending: (params: Record<string, string>) =>
290- a.get<TrendingData>("/trending", params),
291-
292- library: (params: Record<string, string>) =>
293- a.get<LibraryData>("/library", params),
294 createAnnotation: (body: Record<string, unknown>) =>243 createAnnotation: (body: Record<string, unknown>) =>
295- a.post<{ annotation: Annotation }>("/library/create", body),244+ post<{ annotation: Annotation }>("/library/create", body),
296- deleteAnnotation: (id: number) => a.post<void>(`/library/${id}/delete`),245+ deleteAnnotation: (id: number) => post<void>(`/library/${id}/delete`),
297 246
298- profile: (did: string) => a.get<ProfileData>(`/profile/${did}`),247+ profile: (did: string) => get<ProfileData>(`/profile/${did}`),
299 248
300- articleRecs: () => a.get<{ articles: Article[] }>("/recs/articles"),249+ articleRecs: () => get<{ articles: Article[] }>("/recs/articles"),
301- feedRecs: () => a.get<FeedRecs>("/recs/feeds"),250+ feedRecs: () => get<FeedRecs>("/recs/feeds"),
302- peopleRecs: () => a.get<PeopleRecs>("/recs/people"),251+ peopleRecs: () => get<PeopleRecs>("/recs/people"),
303- dismissFeed: (feed_url: string) =>252+ dismissFeed: (feed_url: string) => post<void>("/recs/dismiss-feed", { feed_url }),
304- a.post<void>("/recs/dismiss-feed", { feed_url }),
305 dismissArticle: (article_url: string) =>253 dismissArticle: (article_url: string) =>
306- a.post<void>("/recs/dismiss-article", { article_url }),254+ post<void>("/recs/dismiss-article", { article_url }),
307 dismissPerson: (target_did: string) =>255 dismissPerson: (target_did: string) =>
308- a.post<void>("/recs/dismiss-person", { target_did }),256+ post<void>("/recs/dismiss-person", { target_did }),
309 257
310 toggleLanguage: (code: string) =>258 toggleLanguage: (code: string) =>
311- a.post<{ languages: string[] }>(`/settings/languages/${code}`),259+ post<{ languages: string[] }>(`/settings/languages/${code}`),
312 toggleExpandedView: (expanded_view: boolean) =>260 toggleExpandedView: (expanded_view: boolean) =>
313- a.post<{ expanded_view: boolean }>("/settings/expanded-view", {261+ post<{ expanded_view: boolean }>("/settings/expanded-view", {
314 expanded_view: expanded_view ? "1" : "0",262 expanded_view: expanded_view ? "1" : "0",
315 }),263 }),
316 toggleDigest: (digest_enabled: boolean) =>264 toggleDigest: (digest_enabled: boolean) =>
317- a.post<{ digest_enabled: boolean }>("/settings/digest-enabled", {265+ post<{ digest_enabled: boolean }>("/settings/digest-enabled", {
318 digest_enabled: digest_enabled ? "1" : "0",266 digest_enabled: digest_enabled ? "1" : "0",
319 }),267 }),
320 268
321- digest: () => a.get<Digest | null>("/digest"),269+ digest: () => get<Digest | null>("/digest"),
322 markDigestRead: (ids: number[]) =>270 markDigestRead: (ids: number[]) =>
323- a.post<Digest>("/digest/mark-read", { ids: ids.map(String) }),271+ post<Digest>("/digest/mark-read", { ids: ids.map(String) }),
324 272
325- authActors: (q: string) =>273+ authActors: (q: string) => get<{ actors: Actor[] }>("/auth/actors", { q }),
326- a.get<{ actors: Actor[] }>("/auth/actors", { q }),274+ authStart: (handle: string) => post<{ redirect: string }>("/auth/start", { handle }),
327- authStart: (handle: string) =>275+ authRegister: () => get<{ redirect: string }>("/auth/register"),
328- a.post<{ redirect: string }>("/auth/start", { handle }),276+ authLogout: () => post<{ redirect: string }>("/auth/logout"),
329- authRegister: () => a.get<{ redirect: string }>("/auth/register"),
330- authLogout: () => a.post<{ redirect: string }>("/auth/logout"),
331 277
332- stats: () => a.get<StatsData>("/stats"),278+ stats: () => get<StatsData>("/stats"),
333 };279 };
334 }280 }
335 281