repos / pico

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

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