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