add a couple post endpoints + /timelines/home
This commit is contained in:
parent
dd72a1f4c1
commit
9f052dc9ef
24 changed files with 462 additions and 32 deletions
|
@ -122,14 +122,18 @@ const (
|
|||
ErrInternalServerError = 500 // catch-all code for unknown errors
|
||||
|
||||
// Auth related
|
||||
ErrInvalidToken = 1001
|
||||
ErrMissingScope = 1002
|
||||
ErrInvalidToken = 1001
|
||||
ErrMissingScope = 1002
|
||||
ErrNotYourObject = 1003
|
||||
|
||||
// Account related
|
||||
ErrAccountNotFound = 2001
|
||||
|
||||
// Blog related
|
||||
ErrBlogNotFound = 3001
|
||||
|
||||
// Post related
|
||||
ErrPostNotFound = 4001
|
||||
)
|
||||
|
||||
func ErrCodeMessage(code int) string {
|
||||
|
@ -144,12 +148,15 @@ var errCodeMessages = map[int]string{
|
|||
ErrTooManyRequests: "Rate limit reached",
|
||||
ErrMethodNotAllowed: "Method not allowed",
|
||||
|
||||
ErrInvalidToken: "No token supplied, or token is invalid",
|
||||
ErrMissingScope: "Token is missing required scope for this endpoint",
|
||||
ErrInvalidToken: "No token supplied, or token is invalid",
|
||||
ErrMissingScope: "Token is missing required scope for this endpoint",
|
||||
ErrNotYourObject: "Object you are trying to perform action on is not owned by you",
|
||||
|
||||
ErrAccountNotFound: "Account not found",
|
||||
|
||||
ErrBlogNotFound: "Blog not found",
|
||||
|
||||
ErrPostNotFound: "Post not found",
|
||||
}
|
||||
|
||||
func ErrCodeStatus(code int) int {
|
||||
|
@ -164,10 +171,13 @@ var errCodeStatuses = map[int]int{
|
|||
ErrTooManyRequests: http.StatusTooManyRequests,
|
||||
ErrMethodNotAllowed: http.StatusMethodNotAllowed,
|
||||
|
||||
ErrInvalidToken: http.StatusUnauthorized,
|
||||
ErrMissingScope: http.StatusForbidden,
|
||||
ErrInvalidToken: http.StatusUnauthorized,
|
||||
ErrMissingScope: http.StatusForbidden,
|
||||
ErrNotYourObject: http.StatusForbidden,
|
||||
|
||||
ErrAccountNotFound: http.StatusNotFound,
|
||||
|
||||
ErrBlogNotFound: http.StatusNotFound,
|
||||
|
||||
ErrPostNotFound: http.StatusNotFound,
|
||||
}
|
||||
|
|
35
web/api/post.go
Normal file
35
web/api/post.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"git.sleepycat.moe/sam/mercury/internal/database"
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
ID ulid.ULID `json:"id"`
|
||||
Content *string `json:"content"`
|
||||
Source *string `json:"source"`
|
||||
Visibility database.PostVisibility `json:"visibility"`
|
||||
|
||||
Blog postPartialBlog `json:"blog"`
|
||||
}
|
||||
|
||||
type postPartialBlog struct {
|
||||
ID ulid.ULID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Domain *string `json:"domain"`
|
||||
}
|
||||
|
||||
func DBPostToPost(p database.Post, b database.Blog) Post {
|
||||
return Post{
|
||||
ID: p.ID,
|
||||
Content: p.Content,
|
||||
Source: p.Source,
|
||||
Visibility: p.Visibility,
|
||||
Blog: postPartialBlog{
|
||||
ID: p.BlogID,
|
||||
Name: b.Name,
|
||||
Domain: b.Domain,
|
||||
},
|
||||
}
|
||||
}
|
91
web/api/posts/create_post.go
Normal file
91
web/api/posts/create_post.go
Normal file
|
@ -0,0 +1,91 @@
|
|||
package posts
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sleepycat.moe/sam/mercury/internal/database"
|
||||
"git.sleepycat.moe/sam/mercury/internal/database/sql"
|
||||
"git.sleepycat.moe/sam/mercury/internal/utils"
|
||||
"git.sleepycat.moe/sam/mercury/web/api"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type createPostParams struct {
|
||||
Content string `json:"content"`
|
||||
Visibility database.PostVisibility `json:"visibility"`
|
||||
}
|
||||
|
||||
func (p createPostParams) Validate(cfg database.Config) bool {
|
||||
if p.Content == "" { // TODO: allow empty content if the post has attachments
|
||||
return false
|
||||
}
|
||||
if p.Visibility == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if utils.StringLength(p.Content) > cfg.PostCharacterLimit {
|
||||
return false
|
||||
}
|
||||
|
||||
switch p.Visibility {
|
||||
case database.PublicVisibility, database.UnlistedVisibility, database.FollowersVisibility, database.DirectVisibility:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
// everything checks out
|
||||
return true
|
||||
}
|
||||
|
||||
func (app *App) Create(w http.ResponseWriter, r *http.Request) (api.Post, error) {
|
||||
ctx := r.Context()
|
||||
token, _ := app.TokenFromContext(ctx)
|
||||
|
||||
var req createPostParams
|
||||
err := render.Decode(r, &req)
|
||||
if err != nil {
|
||||
return api.Post{}, api.Error{Code: api.ErrBadRequest}
|
||||
}
|
||||
if !req.Validate(app.DBConfig.Get()) {
|
||||
return api.Post{}, api.Error{Code: api.ErrBadRequest}
|
||||
}
|
||||
|
||||
blogID, err := ulid.Parse(chi.URLParam(r, "blogID"))
|
||||
if err != nil {
|
||||
return api.Post{}, api.Error{Code: api.ErrBlogNotFound}
|
||||
}
|
||||
|
||||
conn, err := app.Database.Acquire(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("acquiring connection")
|
||||
return api.Post{}, err
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
blog, err := app.Blog(conn).ByID(ctx, blogID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNotFound {
|
||||
return api.Post{}, api.Error{Code: api.ErrBlogNotFound}
|
||||
}
|
||||
|
||||
log.Err(err).Msg("fetching blog")
|
||||
return api.Post{}, err
|
||||
}
|
||||
|
||||
if blog.AccountID != token.UserID {
|
||||
return api.Post{}, api.Error{Code: api.ErrNotYourObject}
|
||||
}
|
||||
|
||||
// create post
|
||||
post, err := app.Post(conn).Create(ctx, blog, req.Content, req.Visibility)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("creating post")
|
||||
return api.Post{}, err
|
||||
}
|
||||
|
||||
// TODO: federate post + push to websockets
|
||||
return api.DBPostToPost(post, blog), nil
|
||||
}
|
44
web/api/posts/get_post.go
Normal file
44
web/api/posts/get_post.go
Normal file
|
@ -0,0 +1,44 @@
|
|||
package posts
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sleepycat.moe/sam/mercury/internal/database/sql"
|
||||
"git.sleepycat.moe/sam/mercury/web/api"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (app *App) GetID(w http.ResponseWriter, r *http.Request) (api.Post, error) {
|
||||
ctx := r.Context()
|
||||
id, err := ulid.Parse(chi.URLParamFromCtx(ctx, "postID"))
|
||||
if err != nil {
|
||||
return api.Post{}, api.Error{Code: api.ErrBlogNotFound}
|
||||
}
|
||||
|
||||
conn, err := app.Database.Acquire(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("acquiring connection")
|
||||
return api.Post{}, err
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
post, err := app.Post(conn).ByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNotFound {
|
||||
return api.Post{}, api.Error{Code: api.ErrBlogNotFound}
|
||||
}
|
||||
|
||||
log.Err(err).Str("id", id.String()).Msg("fetching post from database")
|
||||
return api.Post{}, err
|
||||
}
|
||||
|
||||
blog, err := app.Blog(conn).ByID(ctx, post.BlogID)
|
||||
if err != nil {
|
||||
log.Err(err).Str("id", post.BlogID.String()).Msg("fetching blog from database")
|
||||
return api.Post{}, err
|
||||
}
|
||||
|
||||
return api.DBPostToPost(post, blog), nil
|
||||
}
|
13
web/api/posts/module.go
Normal file
13
web/api/posts/module.go
Normal file
|
@ -0,0 +1,13 @@
|
|||
package posts
|
||||
|
||||
import "git.sleepycat.moe/sam/mercury/web/app"
|
||||
|
||||
type App struct {
|
||||
*app.App
|
||||
}
|
||||
|
||||
func New(app *app.App) *App {
|
||||
return &App{
|
||||
App: app,
|
||||
}
|
||||
}
|
47
web/api/timelines/home_timeline.go
Normal file
47
web/api/timelines/home_timeline.go
Normal file
|
@ -0,0 +1,47 @@
|
|||
package timelines
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.sleepycat.moe/sam/mercury/web/api"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type timelineResponse struct {
|
||||
Posts []api.Post `json:"posts"`
|
||||
}
|
||||
|
||||
func (app *App) Home(w http.ResponseWriter, r *http.Request) (timelineResponse, error) {
|
||||
ctx := r.Context()
|
||||
token, _ := app.TokenFromContext(ctx)
|
||||
|
||||
var before, after *ulid.ULID
|
||||
if id, err := ulid.Parse(r.FormValue("before")); err == nil {
|
||||
before = &id
|
||||
}
|
||||
if id, err := ulid.Parse(r.FormValue("after")); err == nil {
|
||||
after = &id
|
||||
}
|
||||
|
||||
if before != nil && after != nil {
|
||||
return timelineResponse{}, api.Error{Code: api.ErrBadRequest, Details: "`before` and `after` are mutually exclusive"}
|
||||
}
|
||||
limit, err := strconv.Atoi(r.FormValue("limit"))
|
||||
if err != nil || limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
posts, err := app.Timeline().Home(ctx, token.UserID, limit, before, after)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("getting posts from database")
|
||||
}
|
||||
|
||||
resp := timelineResponse{Posts: make([]api.Post, len(posts))}
|
||||
for i := range posts {
|
||||
resp.Posts[i] = api.DBPostToPost(posts[i].Post, posts[i].Blog)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
13
web/api/timelines/module.go
Normal file
13
web/api/timelines/module.go
Normal file
|
@ -0,0 +1,13 @@
|
|||
package timelines
|
||||
|
||||
import "git.sleepycat.moe/sam/mercury/web/app"
|
||||
|
||||
type App struct {
|
||||
*app.App
|
||||
}
|
||||
|
||||
func New(app *app.App) *App {
|
||||
return &App{
|
||||
App: app,
|
||||
}
|
||||
}
|
|
@ -94,6 +94,13 @@ func (a *App) Post(q ...sql.Querier) *sql.PostStore {
|
|||
return sql.NewPostStore(q[0])
|
||||
}
|
||||
|
||||
func (a *App) Timeline(q ...sql.Querier) *sql.TimelineStore {
|
||||
if len(q) == 0 || q[0] == nil {
|
||||
return sql.NewTimelineStore(a.Database.PoolQuerier())
|
||||
}
|
||||
return sql.NewTimelineStore(q[0])
|
||||
}
|
||||
|
||||
func (a *App) Token(q ...sql.Querier) *sql.TokenStore {
|
||||
if len(q) == 0 || q[0] == nil {
|
||||
return sql.NewTokenStore(a.Database.PoolQuerier())
|
||||
|
|
|
@ -5,6 +5,8 @@ import (
|
|||
"git.sleepycat.moe/sam/mercury/web/api"
|
||||
"git.sleepycat.moe/sam/mercury/web/api/accounts"
|
||||
"git.sleepycat.moe/sam/mercury/web/api/blogs"
|
||||
"git.sleepycat.moe/sam/mercury/web/api/posts"
|
||||
"git.sleepycat.moe/sam/mercury/web/api/timelines"
|
||||
"git.sleepycat.moe/sam/mercury/web/app"
|
||||
"git.sleepycat.moe/sam/mercury/web/auth"
|
||||
"git.sleepycat.moe/sam/mercury/web/frontend"
|
||||
|
@ -22,6 +24,37 @@ func Routes(app *app.App) {
|
|||
r.Post("/sign_up", auth.PostSignup)
|
||||
})
|
||||
|
||||
// APIv1 handlers
|
||||
app.Router.Route("/api/v1", func(r chi.Router) {
|
||||
unauthedAccess := !app.AppConfig.Security.RestrictAPI
|
||||
unauthedTimelineAccess := app.AppConfig.Security.PublicTimelines && !app.AppConfig.Security.RestrictAPI
|
||||
_ = unauthedTimelineAccess
|
||||
|
||||
// account handlers
|
||||
accounts := accounts.New(app)
|
||||
r.With(app.APIAuth(database.TokenScopeAccountsRead, unauthedAccess)).
|
||||
Get("/accounts/{accountID}", api.WrapHandlerT(accounts.GetID))
|
||||
r.With(app.APIAuth(database.TokenScopeAccountsMe, false)).
|
||||
Get("/accounts/@me", api.WrapHandlerT(accounts.GetMe))
|
||||
|
||||
blogs := blogs.New(app)
|
||||
r.With(app.APIAuth(database.TokenScopeBlogsRead, unauthedAccess)).
|
||||
Get("/blogs/{blogID}", api.WrapHandlerT(blogs.GetID))
|
||||
r.With(app.APIAuth(database.TokenScopeBlogsRead, unauthedAccess)).
|
||||
Get("/blogs/lookup/{blogName}", api.WrapHandlerT(blogs.LookupName))
|
||||
|
||||
posts := posts.New(app)
|
||||
r.With(app.APIAuth(database.TokenScopePostsRead, unauthedAccess)).
|
||||
Get("/posts/{postID}", api.WrapHandlerT(posts.GetID))
|
||||
r.With(app.APIAuth(database.TokenScopePostsWrite, false)).
|
||||
Post("/blogs/{blogID}/posts", api.WrapHandlerT(posts.Create))
|
||||
|
||||
timelines := timelines.New(app)
|
||||
r.With(app.APIAuth(database.TokenScopeTimeline, false)).
|
||||
Get("/timelines/home", api.WrapHandlerT(timelines.Home))
|
||||
|
||||
})
|
||||
|
||||
// web app handlers
|
||||
// also assets
|
||||
app.Router.Group(func(r chi.Router) {
|
||||
|
@ -35,20 +68,4 @@ func Routes(app *app.App) {
|
|||
r.HandleFunc("/web/@{username}", frontend.ServeUser)
|
||||
r.HandleFunc("/web/@{username}/posts/{postID}", frontend.ServeStatus)
|
||||
})
|
||||
|
||||
// APIv1 handlers
|
||||
app.Router.Route("/api/v1", func(r chi.Router) {
|
||||
// account handlers
|
||||
accounts := accounts.New(app)
|
||||
r.With(app.APIAuth(database.TokenScopeAccountsRead, true)).
|
||||
Get("/accounts/{accountID}", api.WrapHandlerT(accounts.GetID))
|
||||
r.With(app.APIAuth(database.TokenScopeAccountsMe, false)).
|
||||
Get("/accounts/@me", api.WrapHandlerT(accounts.GetMe))
|
||||
|
||||
blogs := blogs.New(app)
|
||||
r.With(app.APIAuth(database.TokenScopeBlogsRead, true)).
|
||||
Get("/blogs/{blogID}", api.WrapHandlerT(blogs.GetID))
|
||||
r.With(app.APIAuth(database.TokenScopeBlogsRead, true)).
|
||||
Get("/blogs/lookup/{blogName}", api.WrapHandlerT(blogs.LookupName))
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue