repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

pico / ui
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}