Eric Bower
·
07 May 24
api.go
1package ui
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "net/http"
8 "slices"
9 "strings"
10 "time"
11
12 "github.com/charmbracelet/ssh"
13 "github.com/picosh/pico/db"
14 "github.com/picosh/pico/shared"
15)
16
17type registerPayload struct {
18 Name string `json:"name"`
19}
20
21func ensureUser(w http.ResponseWriter, user *db.User) bool {
22 if user == nil {
23 shared.JSONError(w, "User not found", http.StatusNotFound)
24 return false
25 }
26 return true
27}
28
29func registerUser(apiConfig *shared.ApiConfig, ctx ssh.Context, pubkey ssh.PublicKey) http.HandlerFunc {
30 logger := apiConfig.Cfg.Logger
31 return func(w http.ResponseWriter, r *http.Request) {
32 w.Header().Set("Content-Type", "application/json")
33 dbpool := shared.GetDB(r)
34 var payload registerPayload
35 body, _ := io.ReadAll(r.Body)
36 _ = json.Unmarshal(body, &payload)
37
38 pubkeyStr, err := shared.KeyForKeyText(pubkey)
39 if err != nil {
40 errMsg := fmt.Sprintf("could not get pubkey text: %s", err.Error())
41 logger.Error("could not get pubkey text", "err", err.Error())
42 shared.JSONError(w, errMsg, http.StatusUnprocessableEntity)
43 return
44 }
45
46 user, err := dbpool.RegisterUser(payload.Name, pubkeyStr, "")
47 if err != nil {
48 errMsg := fmt.Sprintf("error registering user: %s", err.Error())
49 logger.Error("error registering user", "err", err.Error())
50 shared.JSONError(w, errMsg, http.StatusUnprocessableEntity)
51 return
52 }
53
54 picoApi := shared.NewUserApi(user, pubkey)
55 shared.SetUserCtx(ctx, user)
56 err = json.NewEncoder(w).Encode(picoApi)
57 if err != nil {
58 logger.Error("json encoding error", "err", err.Error())
59 }
60 }
61}
62
63type featuresPayload struct {
64 Features []*db.FeatureFlag `json:"features"`
65}
66
67func getFeatures(apiConfig *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
68 logger := apiConfig.Cfg.Logger
69 return func(w http.ResponseWriter, r *http.Request) {
70 w.Header().Set("Content-Type", "application/json")
71 user, _ := shared.GetUserCtx(ctx)
72 if !ensureUser(w, user) {
73 return
74 }
75
76 dbpool := shared.GetDB(r)
77 features, err := dbpool.FindFeaturesForUser(user.ID)
78 if err != nil {
79 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
80 return
81 }
82
83 if features == nil {
84 features = []*db.FeatureFlag{}
85 }
86 err = json.NewEncoder(w).Encode(&featuresPayload{Features: features})
87 if err != nil {
88 logger.Error(err.Error())
89 }
90 }
91}
92
93type tokenSecretPayload struct {
94 Secret string `json:"secret"`
95}
96
97func findOrCreateRssToken(apiConfig *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
98 logger := apiConfig.Cfg.Logger
99 return func(w http.ResponseWriter, r *http.Request) {
100 w.Header().Set("Content-Type", "application/json")
101 user, _ := shared.GetUserCtx(ctx)
102 if !ensureUser(w, user) {
103 return
104 }
105
106 dbpool := shared.GetDB(r)
107 var err error
108 rssToken, _ := dbpool.FindTokenByName(user.ID, "pico-rss")
109 if rssToken == "" {
110 rssToken, err = dbpool.InsertToken(user.ID, "pico-rss")
111 if err != nil {
112 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
113 return
114 }
115 }
116
117 err = json.NewEncoder(w).Encode(&tokenSecretPayload{Secret: rssToken})
118 if err != nil {
119 logger.Error(err.Error())
120 }
121 }
122}
123
124type pubkeysPayload struct {
125 Pubkeys []*db.PublicKey `json:"pubkeys"`
126}
127
128func toFingerprint(pubkey string) (string, error) {
129 kk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkey))
130 if err != nil {
131 return "", err
132 }
133 return shared.KeyForSha256(kk), nil
134}
135
136func getPublicKeys(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
137 logger := httpCtx.Cfg.Logger
138 return func(w http.ResponseWriter, r *http.Request) {
139 w.Header().Set("Content-Type", "application/json")
140 user, _ := shared.GetUserCtx(ctx)
141 if !ensureUser(w, user) {
142 return
143 }
144
145 dbpool := shared.GetDB(r)
146 pubkeys, err := dbpool.FindKeysForUser(user)
147 if err != nil {
148 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
149 return
150 }
151
152 for _, pk := range pubkeys {
153 fingerprint, err := toFingerprint(pk.Key)
154 if err != nil {
155 logger.Error("could not parse public key", "err", err.Error())
156 continue
157 }
158 pk.Key = fingerprint
159 }
160
161 err = json.NewEncoder(w).Encode(&pubkeysPayload{Pubkeys: pubkeys})
162 if err != nil {
163 logger.Error("json encode", "err", err.Error())
164 }
165 }
166}
167
168type createPubkeyPayload struct {
169 Pubkey string `json:"pubkey"`
170 Name string `json:"name"`
171}
172
173func createPubkey(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
174 logger := httpCtx.Cfg.Logger
175 return func(w http.ResponseWriter, r *http.Request) {
176 w.Header().Set("Content-Type", "application/json")
177 user, _ := shared.GetUserCtx(ctx)
178 if !ensureUser(w, user) {
179 return
180 }
181
182 dbpool := shared.GetDB(r)
183 var payload createPubkeyPayload
184 body, _ := io.ReadAll(r.Body)
185 _ = json.Unmarshal(body, &payload)
186 err := dbpool.InsertPublicKey(user.ID, payload.Pubkey, payload.Name, nil)
187 if err != nil {
188 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
189 return
190 }
191
192 pubkey, err := dbpool.FindPublicKeyForKey(payload.Pubkey)
193 if err != nil {
194 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
195 return
196 }
197
198 fingerprint, err := toFingerprint(pubkey.Key)
199 if err != nil {
200 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
201 return
202 }
203 pubkey.Key = fingerprint
204 err = json.NewEncoder(w).Encode(pubkey)
205 if err != nil {
206 logger.Error("json encode", "err", err.Error())
207 }
208 }
209}
210
211func deletePubkey(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
212 logger := httpCtx.Cfg.Logger
213 return func(w http.ResponseWriter, r *http.Request) {
214 w.Header().Set("Content-Type", "application/json")
215 user, _ := shared.GetUserCtx(ctx)
216 if !ensureUser(w, user) {
217 return
218 }
219 dbpool := shared.GetDB(r)
220 pubkeyID := shared.GetField(r, 0)
221
222 ownedKeys, err := dbpool.FindKeysForUser(user)
223 if err != nil {
224 logger.Error("could not query for pubkeys", "err", err.Error())
225 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
226 return
227 }
228
229 found := false
230 for _, key := range ownedKeys {
231 if key.ID == pubkeyID {
232 found = true
233 break
234 }
235 }
236
237 if !found {
238 logger.Error("user trying to delete key they do not own")
239 shared.JSONError(w, "user trying to delete key they do not own", http.StatusUnauthorized)
240 return
241 }
242
243 err = dbpool.RemoveKeys([]string{pubkeyID})
244 if err != nil {
245 logger.Error("could not remove pubkey", "err", err.Error())
246 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
247 return
248 }
249 w.WriteHeader(http.StatusNoContent)
250 }
251}
252
253func patchPubkey(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
254 logger := httpCtx.Cfg.Logger
255 return func(w http.ResponseWriter, r *http.Request) {
256 w.Header().Set("Content-Type", "application/json")
257 user, _ := shared.GetUserCtx(ctx)
258 if !ensureUser(w, user) {
259 return
260 }
261
262 dbpool := shared.GetDB(r)
263 pubkeyID := shared.GetField(r, 0)
264
265 var payload createPubkeyPayload
266 body, _ := io.ReadAll(r.Body)
267 _ = json.Unmarshal(body, &payload)
268
269 auth, err := dbpool.FindPublicKey(pubkeyID)
270 if err != nil {
271 logger.Error("could not find user with pubkey provided", "err", err.Error())
272 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
273 return
274 }
275
276 if auth.UserID != user.ID {
277 logger.Error("user trying to update pubkey they do not own")
278 shared.JSONError(w, "user trying to update pubkey they do not own", http.StatusUnauthorized)
279 return
280 }
281
282 pubkey, err := dbpool.UpdatePublicKey(pubkeyID, payload.Name)
283 if err != nil {
284 logger.Error("could not update pubkey", "err", err.Error())
285 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
286 return
287 }
288
289 fingerprint, err := toFingerprint(pubkey.Key)
290 if err != nil {
291 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
292 return
293 }
294 pubkey.Key = fingerprint
295
296 err = json.NewEncoder(w).Encode(pubkey)
297 if err != nil {
298 logger.Error("json encode", "err", err.Error())
299 }
300 }
301}
302
303type tokensPayload struct {
304 Tokens []*db.Token `json:"tokens"`
305}
306
307func getTokens(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
308 logger := httpCtx.Cfg.Logger
309 return func(w http.ResponseWriter, r *http.Request) {
310 w.Header().Set("Content-Type", "application/json")
311 user, _ := shared.GetUserCtx(ctx)
312 if !ensureUser(w, user) {
313 return
314 }
315
316 dbpool := shared.GetDB(r)
317 tokens, err := dbpool.FindTokensForUser(user.ID)
318 if err != nil {
319 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
320 return
321 }
322
323 if tokens == nil {
324 tokens = []*db.Token{}
325 }
326
327 err = json.NewEncoder(w).Encode(&tokensPayload{Tokens: tokens})
328 if err != nil {
329 logger.Error(err.Error())
330 }
331 }
332}
333
334type createTokenPayload struct {
335 Name string `json:"name"`
336}
337
338type createTokenResponsePayload struct {
339 Secret string `json:"secret"`
340 Token *db.Token `json:"token"`
341}
342
343func createToken(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
344 logger := httpCtx.Cfg.Logger
345 return func(w http.ResponseWriter, r *http.Request) {
346 w.Header().Set("Content-Type", "application/json")
347 user, _ := shared.GetUserCtx(ctx)
348 if !ensureUser(w, user) {
349 return
350 }
351
352 dbpool := shared.GetDB(r)
353 var payload createTokenPayload
354 body, _ := io.ReadAll(r.Body)
355 _ = json.Unmarshal(body, &payload)
356 secret, err := dbpool.InsertToken(user.ID, payload.Name)
357 if err != nil {
358 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
359 return
360 }
361
362 // TODO: find token by name
363 tokens, err := dbpool.FindTokensForUser(user.ID)
364 if err != nil {
365 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
366 }
367
368 var token *db.Token
369 for _, tok := range tokens {
370 if tok.Name == payload.Name {
371 token = tok
372 break
373 }
374 }
375
376 err = json.NewEncoder(w).Encode(&createTokenResponsePayload{Secret: secret, Token: token})
377 if err != nil {
378 logger.Error("json encode", "err", err.Error())
379 }
380 }
381}
382
383func deleteToken(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
384 logger := httpCtx.Cfg.Logger
385 return func(w http.ResponseWriter, r *http.Request) {
386 w.Header().Set("Content-Type", "application/json")
387 user, _ := shared.GetUserCtx(ctx)
388 if !ensureUser(w, user) {
389 return
390 }
391 dbpool := shared.GetDB(r)
392 tokenID := shared.GetField(r, 0)
393
394 toks, err := dbpool.FindTokensForUser(user.ID)
395 if err != nil {
396 logger.Error("could not query for user tokens", "err", err.Error())
397 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
398 return
399 }
400
401 found := false
402 for _, tok := range toks {
403 if tok.ID == tokenID {
404 found = true
405 break
406 }
407 }
408
409 if !found {
410 logger.Error("user trying to delete token they do not own")
411 shared.JSONError(w, "user trying to delete token they do not own", http.StatusUnauthorized)
412 return
413 }
414
415 err = dbpool.RemoveToken(tokenID)
416 if err != nil {
417 logger.Error("could not remove token", "err", err.Error())
418 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
419 return
420 }
421 w.WriteHeader(http.StatusNoContent)
422 }
423}
424
425type projectsPayload struct {
426 Projects []*db.Project `json:"projects"`
427}
428
429func getProjects(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
430 logger := httpCtx.Cfg.Logger
431 return func(w http.ResponseWriter, r *http.Request) {
432 w.Header().Set("Content-Type", "application/json")
433 user, _ := shared.GetUserCtx(ctx)
434 if !ensureUser(w, user) {
435 return
436 }
437
438 dbpool := shared.GetDB(r)
439 projects, err := dbpool.FindProjectsByUser(user.ID)
440 if err != nil {
441 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
442 return
443 }
444
445 if projects == nil {
446 projects = []*db.Project{}
447 }
448
449 err = json.NewEncoder(w).Encode(&projectsPayload{Projects: projects})
450 if err != nil {
451 logger.Error(err.Error())
452 }
453 }
454}
455
456type postsPayload struct {
457 Posts []*db.Post `json:"posts"`
458}
459
460func getPosts(httpCtx *shared.ApiConfig, ctx ssh.Context, space string) http.HandlerFunc {
461 logger := httpCtx.Cfg.Logger
462 return func(w http.ResponseWriter, r *http.Request) {
463 w.Header().Set("Content-Type", "application/json")
464 user, _ := shared.GetUserCtx(ctx)
465 if !ensureUser(w, user) {
466 return
467 }
468
469 dbpool := shared.GetDB(r)
470 posts, err := dbpool.FindAllPostsForUser(user.ID, space)
471 if err != nil {
472 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
473 return
474 }
475
476 if posts == nil {
477 posts = []*db.Post{}
478 }
479
480 err = json.NewEncoder(w).Encode(&postsPayload{Posts: posts})
481 if err != nil {
482 logger.Error(err.Error())
483 }
484 }
485}
486
487type objectsPayload struct {
488 Objects []*ProjectObject `json:"objects"`
489}
490
491type ProjectObject struct {
492 ID string `json:"id"`
493 Name string `json:"name"`
494 Size int64 `json:"size"`
495 ModTime time.Time `json:"mod_time"`
496}
497
498type createFeaturePayload struct {
499 Name string `json:"name"`
500}
501
502var featureAllowList = []string{
503 "analytics",
504}
505
506func createFeature(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
507 logger := httpCtx.Cfg.Logger
508 return func(w http.ResponseWriter, r *http.Request) {
509 w.Header().Set("Content-Type", "application/json")
510 user, _ := shared.GetUserCtx(ctx)
511 if !ensureUser(w, user) {
512 return
513 }
514
515 dbpool := shared.GetDB(r)
516 var payload createFeaturePayload
517 body, _ := io.ReadAll(r.Body)
518 _ = json.Unmarshal(body, &payload)
519
520 // only allow the user to add certain features to their account
521 if !slices.Contains(featureAllowList, payload.Name) {
522 err := fmt.Errorf(
523 "(%s) is not in feature allowlist (%s)",
524 payload.Name,
525 strings.Join(featureAllowList, ", "),
526 )
527 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
528 return
529 }
530
531 now := time.Now()
532 expiresAt := now.AddDate(100, 0, 0)
533 feature, err := dbpool.InsertFeature(user.ID, payload.Name, expiresAt)
534 if err != nil {
535 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
536 return
537 }
538
539 err = json.NewEncoder(w).Encode(feature)
540 if err != nil {
541 logger.Error("json encode", "err", err.Error())
542 }
543 }
544}
545
546func deleteFeature(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
547 logger := httpCtx.Cfg.Logger
548 return func(w http.ResponseWriter, r *http.Request) {
549 w.Header().Set("Content-Type", "application/json")
550 user, _ := shared.GetUserCtx(ctx)
551 if !ensureUser(w, user) {
552 return
553 }
554 dbpool := shared.GetDB(r)
555 featureName := shared.GetField(r, 0)
556
557 if !slices.Contains(featureAllowList, featureName) {
558 err := fmt.Errorf(
559 "(%s) is not in feature allowlist (%s)",
560 featureName,
561 strings.Join(featureAllowList, ", "),
562 )
563 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
564 return
565 }
566
567 err := dbpool.RemoveFeature(user.ID, featureName)
568 if err != nil {
569 logger.Error("could not remove features", "err", err.Error())
570 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
571 return
572 }
573 w.WriteHeader(http.StatusNoContent)
574 }
575}
576
577func getProjectObjects(apiConfig *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
578 logger := apiConfig.Cfg.Logger
579 storage := apiConfig.Storage
580 return func(w http.ResponseWriter, r *http.Request) {
581 w.Header().Set("Content-Type", "application/json")
582 user, _ := shared.GetUserCtx(ctx)
583 if !ensureUser(w, user) {
584 return
585 }
586
587 projectName := shared.GetField(r, 0) + "/"
588 bucketName := shared.GetAssetBucketName(user.ID)
589 bucket, err := storage.GetBucket(bucketName)
590 if err != nil {
591 logger.Info("bucket not found", "err", err.Error())
592 shared.JSONError(w, err.Error(), http.StatusNotFound)
593 return
594 }
595 objects, err := storage.ListObjects(bucket, projectName, true)
596 if err != nil {
597 logger.Info("cannot fetch objects", "err", err.Error())
598 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
599 return
600 }
601
602 pobjs := []*ProjectObject{}
603 for _, obj := range objects {
604 pobjs = append(pobjs, &ProjectObject{
605 ID: fmt.Sprintf("%s%s", projectName, obj.Name()),
606 Name: obj.Name(),
607 Size: obj.Size(),
608 ModTime: obj.ModTime(),
609 })
610 }
611
612 err = json.NewEncoder(w).Encode(&objectsPayload{Objects: pobjs})
613 if err != nil {
614 logger.Error(err.Error())
615 }
616 }
617}
618
619func getAnalytics(apiConfig *shared.ApiConfig, ctx ssh.Context, sumtype, bytype, where string) http.HandlerFunc {
620 logger := apiConfig.Cfg.Logger
621 dbpool := apiConfig.Dbpool
622 return func(w http.ResponseWriter, r *http.Request) {
623 w.Header().Set("Content-Type", "application/json")
624 user, _ := shared.GetUserCtx(ctx)
625 if !ensureUser(w, user) {
626 return
627 }
628
629 fkID := user.ID
630 by := "user_id"
631 if bytype == "project" {
632 fkID = shared.GetField(r, 0)
633 by = "project_id"
634 } else if bytype == "post" {
635 fkID = shared.GetField(r, 0)
636 by = "post_id"
637 }
638
639 year := &db.SummaryOpts{FkID: fkID, By: by, Interval: "month", Origin: shared.StartOfYear(), Where: where}
640 month := &db.SummaryOpts{FkID: fkID, By: by, Interval: "day", Origin: shared.StartOfMonth(), Where: where}
641
642 opts := year
643 if sumtype == "month" {
644 opts = month
645 }
646
647 summary, err := dbpool.VisitSummary(opts)
648 if err != nil {
649 logger.Info("cannot fetch analytics", "err", err.Error())
650 shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
651 return
652 }
653
654 err = json.NewEncoder(w).Encode(&summary)
655 if err != nil {
656 logger.Error(err.Error())
657 }
658 }
659}
660
661func CreateRoutes(apiConfig *shared.ApiConfig, ctx ssh.Context) []shared.Route {
662 logger := apiConfig.Cfg.Logger
663 pubkey, err := shared.GetPublicKeyCtx(ctx)
664 if err != nil {
665 logger.Error("could not get pubkey from ctx", "err", err.Error())
666 return []shared.Route{}
667 }
668
669 return []shared.Route{
670 shared.NewCorsRoute("POST", "/api/users", registerUser(apiConfig, ctx, pubkey)),
671 shared.NewCorsRoute("GET", "/api/features", getFeatures(apiConfig, ctx)),
672 shared.NewCorsRoute("PUT", "/api/rss-token", findOrCreateRssToken(apiConfig, ctx)),
673 shared.NewCorsRoute("GET", "/api/pubkeys", getPublicKeys(apiConfig, ctx)),
674 shared.NewCorsRoute("POST", "/api/pubkeys", createPubkey(apiConfig, ctx)),
675 shared.NewCorsRoute("DELETE", "/api/pubkeys/(.+)", deletePubkey(apiConfig, ctx)),
676 shared.NewCorsRoute("POST", "/api/features", createFeature(apiConfig, ctx)),
677 shared.NewCorsRoute("DELETE", "/api/features/(.+)", deleteFeature(apiConfig, ctx)),
678 shared.NewCorsRoute("PATCH", "/api/pubkeys/(.+)", patchPubkey(apiConfig, ctx)),
679 shared.NewCorsRoute("GET", "/api/tokens", getTokens(apiConfig, ctx)),
680 shared.NewCorsRoute("POST", "/api/tokens", createToken(apiConfig, ctx)),
681 shared.NewCorsRoute("DELETE", "/api/tokens/(.+)", deleteToken(apiConfig, ctx)),
682 shared.NewCorsRoute("GET", "/api/projects/(.+)/analytics", getAnalytics(apiConfig, ctx, "month", "project", "")),
683 shared.NewCorsRoute("GET", "/api/projects/(.+)/analytics/year", getAnalytics(apiConfig, ctx, "year", "project", "")),
684 shared.NewCorsRoute("GET", "/api/projects/(.+)", getProjectObjects(apiConfig, ctx)),
685 shared.NewCorsRoute("GET", "/api/projects", getProjects(apiConfig, ctx)),
686 shared.NewCorsRoute("GET", "/api/posts/analytics/year", getAnalytics(apiConfig, ctx, "year", "user", "AND (post_id IS NOT NULL OR (post_id IS NULL AND project_id IS NULL))")),
687 shared.NewCorsRoute("GET", "/api/posts/analytics", getAnalytics(apiConfig, ctx, "month", "user", "AND (post_id IS NOT NULL OR (post_id IS NULL AND project_id IS NULL))")),
688 shared.NewCorsRoute("GET", "/api/posts/(.+)/analytics", getAnalytics(apiConfig, ctx, "month", "post", "")),
689 shared.NewCorsRoute("GET", "/api/posts/(.+)/analytics/year", getAnalytics(apiConfig, ctx, "year", "post", "")),
690 shared.NewCorsRoute("GET", "/api/posts/prose", getPosts(apiConfig, ctx, "prose")),
691 shared.NewCorsRoute("GET", "/api/posts/pastes", getPosts(apiConfig, ctx, "pastes")),
692 shared.NewCorsRoute("GET", "/api/posts/feeds", getPosts(apiConfig, ctx, "feeds")),
693 shared.NewCorsRoute("GET", "/api/analytics/year", getAnalytics(apiConfig, ctx, "year", "user", "")),
694 shared.NewCorsRoute("GET", "/api/analytics", getAnalytics(apiConfig, ctx, "month", "user", "")),
695 }
696}