fix(server): resolve relative URLs in article HTMLUnverified
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) { | ||
| 421 | 421 | return |
| 422 | 422 | } |
| 423 | 423 | |
| 424 | - cleaned := sanitizeHTML(content) | |
| 424 | + cleaned := sanitizeHTMLWithBase(content, article.URL.String) | |
| 425 | 425 | if cleaned != "" { |
| 426 | 426 | if err := s.dbs.Articles.UpdateArticleFullContent(ctx, id, cleaned); err != nil { |
| 427 | 427 | 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 | return | 421 | 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 @@ | ||
| 1 | 1 | package server |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "net/url" | |
| 4 | 5 | "regexp" |
| 5 | 6 | "strings" |
| 6 | 7 | ) |
| @@ -53,6 +54,14 @@ func isAllowedIframe(tag string) bool { | ||
| 53 | 54 | } |
| 54 | 55 | |
| 55 | 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 | 65 | s := input |
| 57 | 66 | s = scriptRe.ReplaceAllString(s, "") |
| 58 | 67 | |
| @@ -71,9 +80,88 @@ func sanitizeHTML(input string) string { | ||
| 71 | 80 | s = jsHrefSRe.ReplaceAllString(s, `href="#"`) |
| 72 | 81 | s = styleExprRe.ReplaceAllString(s, "") |
| 73 | 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 | 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 | 165 | func convertMediaLinks(s string) string { |
| 78 | 166 | s = youtubeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`) |
| 79 | 167 | s = youtuBeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`) |
| @@ -1,6 +1,7 @@ | |||
| 1 | package server | 1 | 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 := input | 65 | 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) { | ||
| 206 | 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 | +} | |
| @@ -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) { | ||
| 29 | 29 | cachedCsrf = token; |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | -function readCsrfCookie(): string { | |
| 32 | +function readCsrfToken(): string { | |
| 33 | 33 | if (cachedCsrf) return cachedCsrf; |
| 34 | 34 | if (typeof document !== "undefined") { |
| 35 | 35 | const match = document.cookie.match(/(?:^|;\s*)glean_csrf=([^;]+)/); |
| @@ -40,67 +40,46 @@ function readCsrfCookie(): string { | ||
| 40 | 40 | |
| 41 | 41 | type FetchFn = typeof fetch; |
| 42 | 42 | |
| 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 | +} | |
| 62 | 59 | |
| 63 | 60 | async function request<T>( |
| 64 | 61 | method: string, |
| 65 | 62 | 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> } = {}, | |
| 71 | 65 | ): Promise<T> { |
| 72 | - const url = new URL(path, "http://placeholder"); | |
| 66 | + const search = new URLSearchParams(); | |
| 73 | 67 | if (opts.query) { |
| 74 | 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 | 75 | 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; | |
| 98 | 77 | if (method !== "GET" && method !== "HEAD") { |
| 99 | - const token = readCsrfCookie(); | |
| 78 | + const token = readCsrfToken(); | |
| 100 | 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 | 83 | method, |
| 105 | 84 | headers, |
| 106 | 85 | body, |
| @@ -121,9 +100,7 @@ async function request<T>( | ||
| 121 | 100 | } |
| 122 | 101 | |
| 123 | 102 | 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; | |
| 127 | 104 | return (await res.text()) as unknown as T; |
| 128 | 105 | } |
| 129 | 106 | |
| @@ -223,113 +200,82 @@ export interface FeedRecs { | ||
| 223 | 200 | type Endpoints = ReturnType<typeof createEndpoints>; |
| 224 | 201 | |
| 225 | 202 | 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 | + | |
| 236 | 210 | 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), | |
| 241 | 214 | article: (id: number, params: Record<string, string>) => |
| 242 | - a.get<ArticleDetailData>(`/articles/${id}`, params), | |
| 215 | + get<ArticleDetailData>(`/articles/${id}`, params), | |
| 243 | 216 | 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`), | |
| 247 | 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 | 221 | 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`), | |
| 253 | 223 | 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`), | |
| 257 | 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) => | |
| 261 | - a.get<FeedsData>("/feeds", category ? { category } : {}), | |
| 228 | + feeds: (category?: string) => get<FeedsData>("/feeds", category ? { category } : {}), | |
| 262 | 229 | 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 }), | |
| 267 | 231 | 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 }), | |
| 278 | 234 | 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), | |
| 294 | 243 | 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`), | |
| 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"), | |
| 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 }), | |
| 305 | 253 | dismissArticle: (article_url: string) => |
| 306 | - a.post<void>("/recs/dismiss-article", { article_url }), | |
| 254 | + post<void>("/recs/dismiss-article", { article_url }), | |
| 307 | 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 | 258 | toggleLanguage: (code: string) => |
| 311 | - a.post<{ languages: string[] }>(`/settings/languages/${code}`), | |
| 259 | + post<{ languages: string[] }>(`/settings/languages/${code}`), | |
| 312 | 260 | toggleExpandedView: (expanded_view: boolean) => |
| 313 | - a.post<{ expanded_view: boolean }>("/settings/expanded-view", { | |
| 261 | + post<{ expanded_view: boolean }>("/settings/expanded-view", { | |
| 314 | 262 | expanded_view: expanded_view ? "1" : "0", |
| 315 | 263 | }), |
| 316 | 264 | toggleDigest: (digest_enabled: boolean) => |
| 317 | - a.post<{ digest_enabled: boolean }>("/settings/digest-enabled", { | |
| 265 | + post<{ digest_enabled: boolean }>("/settings/digest-enabled", { | |
| 318 | 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 | 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) => | |
| 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"), | |
| 331 | 277 | |
| 332 | - stats: () => a.get<StatsData>("/stats"), | |
| 278 | + stats: () => get<StatsData>("/stats"), | |
| 333 | 279 | }; |
| 334 | 280 | } |
| 335 | 281 | |
| @@ -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 | ||