bsky-app/bskyweb/cmd/bskyweb/server.go
Paul Frazee f57a8cf8ba
Lists updates: curate lists and blocklists (#1689)
* Add lists screen

* Update Lists screen and List create/edit modal to support curate lists

* Rework the ProfileList screen and add curatelist support

* More ProfileList progress

* Update list modals

* Rename mutelists to modlists

* Layout updates/fixes

* More layout fixes

* Modal fixes

* List list screen updates

* Update feed page to give more info

* Layout fixes to ListAddUser modal

* Layout fixes to FlatList and Feed on desktop

* Layout fix to LoadLatestBtn on Web

* Handle did resolution before showing the ProfileList screen

* Rename the CustomFeed routes to ProfileFeed for consistency

* Fix layout issues with the pager and feeds

* Factor out some common code

* Fix UIs for mobile

* Fix user list rendering

* Fix: dont bubble custom feed errors in the merge feed

* Refactor feed models to reduce usage of the SavedFeeds model

* Replace CustomFeedModel with FeedSourceModel which abstracts feed-generators and lists

* Add the ability to pin lists

* Add pinned lists to mobile

* Remove dead code

* Rework the ProfileScreenHeader to create more real-estate for action buttons

* Improve layout behavior on web mobile breakpoints

* Refactor feed & list pages to use new Tabs layout component

* Refactor to ProfileSubpageHeader

* Implement modlist block and mute

* Switch to new api and just modify state on modlist actions

* Fix some UI overflows

* Fix: dont show edit buttons on lists you dont own

* Fix alignment issue on long titles

* Improve loading and error states for feeds & lists

* Update list dropdown icons for ios

* Fetch feed display names in the mergefeed

* Improve rendering off offline feeds in the feed-listing page

* Update Feeds listing UI to react to changes in saved/pinned state

* Refresh list and feed on posts tab press

* Fix pinned feed ordering UI

* Fixes to list pinning

* Remove view=simple qp

* Add list to feed tuners

* Render richtext

* Add list href

* Add 'view avatar'

* Remove unused import

* Fix missing import

* Correctly reflect block by list state

* Replace the <Tabs> component with the more effective <PagerWithHeader> component

* Improve the responsiveness of the PagerWithHeader

* Fix visual jank in the feed loading state

* Improve performance of the PagerWithHeader

* Fix a case that would cause the header to animate too aggressively

* Add the ability to scroll to top by tapping the selected tab

* Fix unit test runner

* Update modlists test

* Add curatelist tests

* Fix: remove link behavior in ListAddUser modal

* Fix some layout jank in the PagerWithHeader on iOS

* Simplify ListItems header rendering

* Wait for the appview to recognize the list before proceeding with list creation

* Fix glitch in the onPageSelecting index of the Pager

* Fix until()

* Copy fix

Co-authored-by: Eric Bailey <git@esb.lol>

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2023-11-01 16:15:40 -07:00

372 lines
11 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"io/ioutil"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
appbsky "github.com/bluesky-social/indigo/api/bsky"
cliutil "github.com/bluesky-social/indigo/cmd/gosky/util"
"github.com/bluesky-social/indigo/xrpc"
"github.com/bluesky-social/social-app/bskyweb"
"github.com/flosch/pongo2/v6"
"github.com/klauspost/compress/gzhttp"
"github.com/klauspost/compress/gzip"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/urfave/cli/v2"
)
type Server struct {
echo *echo.Echo
httpd *http.Server
mailmodo *Mailmodo
xrpcc *xrpc.Client
}
func serve(cctx *cli.Context) error {
debug := cctx.Bool("debug")
httpAddress := cctx.String("http-address")
appviewHost := cctx.String("appview-host")
mailmodoAPIKey := cctx.String("mailmodo-api-key")
mailmodoListName := cctx.String("mailmodo-list-name")
// Echo
e := echo.New()
// Mailmodo client.
mailmodo := NewMailmodo(mailmodoAPIKey, mailmodoListName)
// create a new session (no auth)
xrpcc := &xrpc.Client{
Client: cliutil.NewHttpClient(),
Host: appviewHost,
}
// httpd
var (
httpTimeout = 2 * time.Minute
httpMaxHeaderBytes = 2 * (1024 * 1024)
gzipMinSizeBytes = 1024 * 2
gzipCompressionLevel = gzip.BestSpeed
gzipExceptMIMETypes = []string{"image/png"}
)
// Wrap the server handler in a gzip handler to compress larger responses.
gzipHandler, err := gzhttp.NewWrapper(
gzhttp.MinSize(gzipMinSizeBytes),
gzhttp.CompressionLevel(gzipCompressionLevel),
gzhttp.ExceptContentTypes(gzipExceptMIMETypes),
)
if err != nil {
return err
}
//
// server
//
server := &Server{
echo: e,
mailmodo: mailmodo,
xrpcc: xrpcc,
}
// Create the HTTP server.
server.httpd = &http.Server{
Handler: gzipHandler(server),
Addr: httpAddress,
WriteTimeout: httpTimeout,
ReadTimeout: httpTimeout,
MaxHeaderBytes: httpMaxHeaderBytes,
}
e.HideBanner = true
e.Renderer = NewRenderer("templates/", &bskyweb.TemplateFS, debug)
e.HTTPErrorHandler = server.errorHandler
e.IPExtractor = echo.ExtractIPFromXFFHeader()
// SECURITY: Do not modify without due consideration.
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
ContentTypeNosniff: "nosniff",
XFrameOptions: "SAMEORIGIN",
HSTSMaxAge: 31536000, // 365 days
// TODO:
// ContentSecurityPolicy
// XSSProtection
}))
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
// Don't log requests for static content.
Skipper: func(c echo.Context) bool {
return strings.HasPrefix(c.Request().URL.Path, "/static")
},
}))
e.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
Skipper: middleware.DefaultSkipper,
Store: middleware.NewRateLimiterMemoryStoreWithConfig(
middleware.RateLimiterMemoryStoreConfig{
Rate: 10, // requests per second
Burst: 30, // allow bursts
ExpiresIn: 3 * time.Minute, // garbage collect entries older than 3 minutes
},
),
IdentifierExtractor: func(ctx echo.Context) (string, error) {
id := ctx.RealIP()
return id, nil
},
DenyHandler: func(c echo.Context, identifier string, err error) error {
return c.String(http.StatusTooManyRequests, "Your request has been rate limited. Please try again later. Contact security@bsky.app if you believe this was a mistake.\n")
},
}))
// redirect trailing slash to non-trailing slash.
// all of our current endpoints have no trailing slash.
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
RedirectCode: http.StatusFound,
}))
//
// configure routes
//
// static files
staticHandler := http.FileServer(func() http.FileSystem {
if debug {
log.Debugf("serving static file from the local file system")
return http.FS(os.DirFS("static"))
}
fsys, err := fs.Sub(bskyweb.StaticFS, "static")
if err != nil {
log.Fatal(err)
}
return http.FS(fsys)
}())
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
e.GET("/security.txt", func(c echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
})
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
path := c.Request().URL.Path
maxAge := 1 * (60 * 60) // default is 1 hour
// Cache javascript and images files for 1 week, which works because
// they're always versioned (e.g. /static/js/main.64c14927.js)
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") {
maxAge = 7 * (60 * 60 * 24) // 1 week
}
c.Response().Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
return next(c)
}
})
// home
e.GET("/", server.WebHome)
// generic routes
e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
e.GET("/lists", server.WebGeneric)
e.GET("/moderation", server.WebGeneric)
e.GET("/moderation/modlists", server.WebGeneric)
e.GET("/moderation/muted-accounts", server.WebGeneric)
e.GET("/moderation/blocked-accounts", server.WebGeneric)
e.GET("/settings", server.WebGeneric)
e.GET("/settings/language", server.WebGeneric)
e.GET("/settings/app-passwords", server.WebGeneric)
e.GET("/settings/home-feed", server.WebGeneric)
e.GET("/settings/saved-feeds", server.WebGeneric)
e.GET("/settings/threads", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
e.GET("/support", server.WebGeneric)
e.GET("/support/privacy", server.WebGeneric)
e.GET("/support/tos", server.WebGeneric)
e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric)
// profile endpoints; only first populates info
e.GET("/profile/:handle", server.WebProfile)
e.GET("/profile/:handle/follows", server.WebGeneric)
e.GET("/profile/:handle/followers", server.WebGeneric)
e.GET("/profile/:handle/lists/:rkey", server.WebGeneric)
e.GET("/profile/:handle/feed/:rkey", server.WebGeneric)
e.GET("/profile/:handle/feed/:rkey/liked-by", server.WebGeneric)
// post endpoints; only first populates info
e.GET("/profile/:handle/post/:rkey", server.WebPost)
e.GET("/profile/:handle/post/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handle/post/:rkey/reposted-by", server.WebGeneric)
// Mailmodo
e.POST("/api/waitlist", server.apiWaitlist)
// Start the server.
log.Infof("starting server address=%s", httpAddress)
go func() {
if err := server.httpd.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
log.Errorf("HTTP server shutting down unexpectedly: %s", err)
}
}
}()
// Wait for a signal to exit.
log.Info("registering OS exit signal handler")
quit := make(chan struct{})
exitSignals := make(chan os.Signal, 1)
signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-exitSignals
log.Infof("received OS exit signal: %s", sig)
// Shut down the HTTP server.
if err := server.Shutdown(); err != nil {
log.Errorf("HTTP server shutdown error: %s", err)
}
// Trigger the return that causes an exit.
close(quit)
}()
<-quit
log.Infof("graceful shutdown complete")
return nil
}
func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
srv.echo.ServeHTTP(rw, req)
}
func (srv *Server) Shutdown() error {
log.Info("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.httpd.Shutdown(ctx)
}
func (srv *Server) errorHandler(err error, c echo.Context) {
code := http.StatusInternalServerError
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
}
c.Logger().Error(err)
data := pongo2.Context{
"statusCode": code,
}
c.Render(code, "error.html", data)
}
// handler for endpoint that have no specific server-side handling
func (srv *Server) WebGeneric(c echo.Context) error {
data := pongo2.Context{}
return c.Render(http.StatusOK, "base.html", data)
}
func (srv *Server) WebHome(c echo.Context) error {
data := pongo2.Context{}
return c.Render(http.StatusOK, "home.html", data)
}
func (srv *Server) WebPost(c echo.Context) error {
data := pongo2.Context{}
handle := c.Param("handle")
rkey := c.Param("rkey")
// sanity check argument
if len(handle) > 4 && len(handle) < 128 && len(rkey) > 0 {
ctx := c.Request().Context()
// requires two fetches: first fetch profile (!)
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle)
if err != nil {
log.Warnf("failed to fetch handle: %s\t%v", handle, err)
} else {
did := pv.Did
data["did"] = did
// then fetch the post thread (with extra context)
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey)
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, uri)
if err != nil {
log.Warnf("failed to fetch post: %s\t%v", uri, err)
} else {
req := c.Request()
postView := tpv.Thread.FeedDefs_ThreadViewPost.Post
data["postView"] = postView
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
if postView.Embed != nil && postView.Embed.EmbedImages_View != nil {
data["imgThumbUrl"] = postView.Embed.EmbedImages_View.Images[0].Thumb
}
}
}
}
return c.Render(http.StatusOK, "post.html", data)
}
func (srv *Server) WebProfile(c echo.Context) error {
data := pongo2.Context{}
handle := c.Param("handle")
// sanity check argument
if len(handle) > 4 && len(handle) < 128 {
ctx := c.Request().Context()
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle)
if err != nil {
log.Warnf("failed to fetch handle: %s\t%v", handle, err)
} else {
req := c.Request()
data["profileView"] = pv
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
}
}
return c.Render(http.StatusOK, "profile.html", data)
}
func (srv *Server) apiWaitlist(c echo.Context) error {
type jsonError struct {
Error string `json:"error"`
}
// Read the API request.
type apiRequest struct {
Email string `json:"email"`
}
bodyReader := http.MaxBytesReader(c.Response(), c.Request().Body, 16*1024)
payload, err := ioutil.ReadAll(bodyReader)
if err != nil {
return err
}
var req apiRequest
if err := json.Unmarshal(payload, &req); err != nil {
return c.JSON(http.StatusBadRequest, jsonError{Error: "Invalid API request"})
}
if req.Email == "" {
return c.JSON(http.StatusBadRequest, jsonError{Error: "Please enter a valid email address."})
}
if err := srv.mailmodo.AddToList(c.Request().Context(), req.Email); err != nil {
log.Errorf("adding email to waitlist failed: %s", err)
return c.JSON(http.StatusBadRequest, jsonError{
Error: "Storing email in waitlist failed. Please enter a valid email address.",
})
}
return c.JSON(http.StatusOK, map[string]bool{"success": true})
}