From 8a26dd19084e8846a0ca97c328b0a1e7b5cf10a9 Mon Sep 17 00:00:00 2001 From: r Date: Fri, 8 Sep 2023 14:38:51 +0000 Subject: [PATCH 01/14] Fix userlist margin --- static/style.css | 3 ++- templates/user.tmpl | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/static/style.css b/static/style.css index 19cceab..ef27f1f 100644 --- a/static/style.css +++ b/static/style.css @@ -436,7 +436,8 @@ img.emoji { .user-list-item { overflow: auto; - margin: 0 0 12px 0; + margin: 0 0 4px 0; + padding: 4px; display: flex; align-items: center; } diff --git a/templates/user.tmpl b/templates/user.tmpl index 5ea52d9..3146964 100644 --- a/templates/user.tmpl +++ b/templates/user.tmpl @@ -3,7 +3,6 @@
User
-
{{end}}
-
{{if eq .Type ""}}
Statuses
From 426e9ad14f299a674b672d256ca8644281ec267f Mon Sep 17 00:00:00 2001 From: r Date: Fri, 8 Sep 2023 14:46:40 +0000 Subject: [PATCH 02/14] Fix display name and title on mute page --- service/service.go | 2 +- templates/mute.tmpl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/service/service.go b/service/service.go index 0d2e196..bc9e5b8 100644 --- a/service/service.go +++ b/service/service.go @@ -683,7 +683,7 @@ func (s *service) MutePage(c *client, id string) (err error) { if err != nil { return } - cdata := s.cdata(c, "Mute"+user.DisplayName+" @"+user.Acct, 0, 0, "") + cdata := s.cdata(c, "Mute "+user.DisplayName+" @"+user.Acct, 0, 0, "") data := &renderer.UserData{ User: user, CommonData: cdata, diff --git a/templates/mute.tmpl b/templates/mute.tmpl index 47d0533..ee66b91 100644 --- a/templates/mute.tmpl +++ b/templates/mute.tmpl @@ -1,6 +1,6 @@ {{with .Data}} {{template "header.tmpl" (WithContext .CommonData $.Ctx)}} -
Mute {{.User.Acct}}
+
Mute {{EmojiFilter (HTML .User.DisplayName) .User.Emojis | Raw}} @{{.User.Acct}}
From 461908e0319f8fa43b93fbc500babea3ef44722f Mon Sep 17 00:00:00 2001 From: r Date: Fri, 8 Sep 2023 17:15:44 +0000 Subject: [PATCH 03/14] Load CSS on the root page This applies the background color to the root page and avoids flicker during the initial page load. --- static/style.css | 2 +- templates/root.tmpl | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/static/style.css b/static/style.css index ef27f1f..21d8bc0 100644 --- a/static/style.css +++ b/static/style.css @@ -1,4 +1,4 @@ -body { +frame, body { background-color: #d2d2d2; } diff --git a/templates/root.tmpl b/templates/root.tmpl index b1305f5..0fde835 100644 --- a/templates/root.tmpl +++ b/templates/root.tmpl @@ -4,14 +4,15 @@ + {{.Title}} - - + + - + {{end}} From 8eec93e02861ff0d133d8ba5613e2dedbcee40a2 Mon Sep 17 00:00:00 2001 From: r Date: Fri, 8 Sep 2023 17:55:29 +0000 Subject: [PATCH 04/14] Trim leading and trailing white space from selectable text --- templates/nav.tmpl | 4 ++-- templates/notification.tmpl | 14 +++++++------- templates/requestlist.tmpl | 4 ++-- templates/status.tmpl | 12 ++++++------ templates/user.tmpl | 8 ++++---- templates/userlistitem.tmpl | 4 ++-- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/templates/nav.tmpl b/templates/nav.tmpl index db88aa0..4413823 100644 --- a/templates/nav.tmpl +++ b/templates/nav.tmpl @@ -8,9 +8,9 @@ @@ -48,13 +48,13 @@
- {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} + {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} wants to follow you -
- @{{.Account.Acct}} + @{{.Account.Acct}}
@@ -79,7 +79,7 @@ avatar - @{{.Account.Acct}} + @{{.Account.Acct}} retweeted your post - @@ -93,7 +93,7 @@ avatar - @{{.Account.Acct}} + @{{.Account.Acct}} liked your post - @@ -107,7 +107,7 @@ avatar - @{{.Account.Acct}} + @{{.Account.Acct}} {{.Type}} - diff --git a/templates/requestlist.tmpl b/templates/requestlist.tmpl index 1a51e31..f0b2dee 100644 --- a/templates/requestlist.tmpl +++ b/templates/requestlist.tmpl @@ -9,9 +9,9 @@
-
{{EmojiFilter (HTML .DisplayName) .Emojis | Raw}}
+
{{EmojiFilter (HTML .DisplayName) .Emojis | Raw}}
-
@{{.Acct}}
+
{{.Acct}}
diff --git a/templates/status.tmpl b/templates/status.tmpl index 5ada84e..0b09dfb 100644 --- a/templates/status.tmpl +++ b/templates/status.tmpl @@ -5,9 +5,9 @@ avatar - {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} + {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} - @{{.Account.Acct}} + @{{.Account.Acct}} retweeted
@@ -23,9 +23,9 @@
- {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} + {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} - @{{.Account.Acct}} + @{{.Account.Acct}}
{{if (or .Content .SpoilerText)}}
- {{if .SpoilerText}}{{EmojiFilter (HTML .SpoilerText) .Emojis | Raw}}
{{end}} - {{StatusContentFilter .Content .Emojis .Mentions | Raw}} + {{- if .SpoilerText}}{{EmojiFilter (HTML .SpoilerText) .Emojis | Raw}}
{{end -}} + {{- StatusContentFilter .Content .Emojis .Mentions | Raw -}}
{{end}} {{if .MediaAttachments}} diff --git a/templates/user.tmpl b/templates/user.tmpl index 3146964..3adc1a0 100644 --- a/templates/user.tmpl +++ b/templates/user.tmpl @@ -10,8 +10,8 @@
{{if .User.Fields}}
{{range .User.Fields}} -
{{EmojiFilter .Name $.Data.User.Emojis | Raw}} - {{EmojiFilter .Value $.Data.User.Emojis | Raw}}
+
{{- EmojiFilter .Name $.Data.User.Emojis | Raw}} - {{EmojiFilter .Value $.Data.User.Emojis | Raw -}}
{{end}}
{{end}} diff --git a/templates/userlistitem.tmpl b/templates/userlistitem.tmpl index 50b9d0c..ed53b2e 100644 --- a/templates/userlistitem.tmpl +++ b/templates/userlistitem.tmpl @@ -6,9 +6,9 @@
-
{{EmojiFilter (HTML .DisplayName) .Emojis | Raw}}
+
{{EmojiFilter (HTML .DisplayName) .Emojis | Raw}}
-
@{{.Acct}}
+
@{{.Acct}}
From 60392e61c714ad16c9b292180e447dac8f28fd59 Mon Sep 17 00:00:00 2001 From: r Date: Sat, 9 Sep 2023 06:30:38 +0000 Subject: [PATCH 05/14] Disable access log by default Access logs aren't really useful during normal operation. Add a new flag -v to enable the verbose logging mode, which is still useful during the development. Also remove the log_file config because it's no longer useful. --- bloat.conf | 3 --- config/config.go | 3 +-- main.go | 18 +++--------------- service/transport.go | 12 +++++++----- 4 files changed, 11 insertions(+), 25 deletions(-) diff --git a/bloat.conf b/bloat.conf index ddf18fc..f29e553 100644 --- a/bloat.conf +++ b/bloat.conf @@ -31,9 +31,6 @@ static_directory=static # Empty value will disable the format selection in frontend. post_formats=PlainText:text/plain,HTML:text/html,Markdown:text/markdown,BBCode:text/bbcode -# Log file. Will log to stdout if value is empty. -# log_file=log - # In single instance mode, bloat will not ask for instance domain name and # user will be directly redirected to login form. User login from other # instances is not allowed in this mode. diff --git a/config/config.go b/config/config.go index a92d66f..141cb39 100644 --- a/config/config.go +++ b/config/config.go @@ -20,7 +20,6 @@ type config struct { TemplatesPath string CustomCSS string PostFormats []model.PostFormat - LogFile string } func (c *config) IsValid() bool { @@ -97,7 +96,7 @@ func Parse(r io.Reader) (c *config, err error) { } c.PostFormats = formats case "log_file": - c.LogFile = val + // ignore default: return nil, errors.New("invalid config key " + key) } diff --git a/main.go b/main.go index b717b7d..657912d 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ func errExit(err error) { func main() { configFile := flag.String("f", "", "config file") + verbose := flag.Bool("v", false, "verbose mode") flag.Parse() if len(*configFile) > 0 { @@ -52,25 +53,12 @@ func main() { customCSS = "/static/" + customCSS } - var logger *log.Logger - if len(config.LogFile) < 1 { - logger = log.New(os.Stdout, "", log.LstdFlags) - } else { - lf, err := os.OpenFile(config.LogFile, - os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) - if err != nil { - errExit(err) - } - defer lf.Close() - logger = log.New(lf, "", log.LstdFlags) - } - s := service.NewService(config.ClientName, config.ClientScope, config.ClientWebsite, customCSS, config.SingleInstance, config.PostFormats, renderer) - handler := service.NewHandler(s, logger, config.StaticDirectory) + handler := service.NewHandler(s, *verbose, config.StaticDirectory) - logger.Println("listening on", config.ListenAddress) + log.Println("listening on", config.ListenAddress) err = http.ListenAndServe(config.ListenAddress, handler) if err != nil { errExit(err) diff --git a/service/transport.go b/service/transport.go index 5c6472c..dcf2990 100644 --- a/service/transport.go +++ b/service/transport.go @@ -23,7 +23,7 @@ const ( CSRF ) -func NewHandler(s *service, logger *log.Logger, staticDir string) http.Handler { +func NewHandler(s *service, verbose bool, staticDir string) http.Handler { r := mux.NewRouter() writeError := func(c *client, err error, t int, retry bool) { @@ -48,10 +48,12 @@ func NewHandler(s *service, logger *log.Logger, staticDir string) http.Handler { r: req, } - defer func(begin time.Time) { - logger.Printf("path=%s, err=%v, took=%v\n", - req.URL.Path, err, time.Since(begin)) - }(time.Now()) + if verbose { + defer func(begin time.Time) { + log.Printf("path=%s, err=%v, took=%v\n", + req.URL.Path, err, time.Since(begin)) + }(time.Now()) + } var ct string switch rt { From 60ccc9686a39ff67d3cf361c4e6848d79877b18b Mon Sep 17 00:00:00 2001 From: r Date: Sat, 9 Sep 2023 08:12:56 +0000 Subject: [PATCH 06/14] fluoride: Allow submitting the form with Ctrl+Enter --- static/fluoride.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/static/fluoride.js b/static/fluoride.js index e6a63ef..abeed21 100644 --- a/static/fluoride.js +++ b/static/fluoride.js @@ -285,6 +285,12 @@ function onPaste(e) { fp.files = dt.files; } +function onKeydown(e) { + if (e.key == 'Enter' && e.ctrlKey) { + document.querySelector(".post-form").submit(); + } +} + document.addEventListener("DOMContentLoaded", function() { checkCSRFToken(); checkAntiDopamineMode(); @@ -325,8 +331,10 @@ document.addEventListener("DOMContentLoaded", function() { } var pf = document.querySelector(".post-form") - if (pf) + if (pf) { pf.addEventListener("paste", onPaste); + pf.addEventListener("keydown", onKeydown); + } }); // @license-end From ad38855261dca802439922f71408e2b08e7c10ea Mon Sep 17 00:00:00 2001 From: r Date: Sun, 17 Sep 2023 09:44:02 +0000 Subject: [PATCH 07/14] Set timeout and response size limit for the http client --- mastodon/apps.go | 3 +-- mastodon/http.go | 41 +++++++++++++++++++++++++++++++++++++++++ mastodon/mastodon.go | 3 ++- 3 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 mastodon/http.go diff --git a/mastodon/apps.go b/mastodon/apps.go index 5d925c3..12d2e86 100644 --- a/mastodon/apps.go +++ b/mastodon/apps.go @@ -11,7 +11,6 @@ import ( // AppConfig is a setting for registering applications. type AppConfig struct { - http.Client Server string ClientName string @@ -62,7 +61,7 @@ func RegisterApp(ctx context.Context, appConfig *AppConfig) (*Application, error } req = req.WithContext(ctx) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := appConfig.Do(req) + resp, err := httpClient.Do(req) if err != nil { return nil, err } diff --git a/mastodon/http.go b/mastodon/http.go new file mode 100644 index 0000000..ca4c771 --- /dev/null +++ b/mastodon/http.go @@ -0,0 +1,41 @@ +package mastodon + +import ( + "fmt" + "io" + "net/http" + "time" +) + +type lr struct { + io.ReadCloser + r *http.Request +} + +func (r *lr) Read(p []byte) (n int, err error) { + n, err = r.ReadCloser.Read(p) + // override the generic error returned by the MaxBytesReader + if _, ok := err.(*http.MaxBytesError); ok { + err = fmt.Errorf("%s \"%s\": response body too large", r.r.Method, r.r.URL) + } + return +} + +type transport struct { + t http.RoundTripper +} + +func (t *transport) RoundTrip(r *http.Request) (*http.Response, error) { + resp, err := t.t.RoundTrip(r) + if resp != nil && resp.Body != nil { + resp.Body = &lr{http.MaxBytesReader(nil, resp.Body, 8<<20), r} + } + return resp, err +} + +var httpClient = &http.Client{ + Transport: &transport{ + t: http.DefaultTransport, + }, + Timeout: 30 * time.Second, +} diff --git a/mastodon/mastodon.go b/mastodon/mastodon.go index 8678314..f114169 100644 --- a/mastodon/mastodon.go +++ b/mastodon/mastodon.go @@ -168,12 +168,13 @@ func (c *Client) doAPI(ctx context.Context, method string, uri string, params in } } return json.NewDecoder(resp.Body).Decode(&res) + } // NewClient return new mastodon API client. func NewClient(config *Config) *Client { return &Client{ - Client: http.DefaultClient, + Client: httpClient, config: config, } } From e50f12b6158ffae6b0b59f2902798ae86d263b5d Mon Sep 17 00:00:00 2001 From: r Date: Mon, 18 Sep 2023 10:07:54 +0000 Subject: [PATCH 08/14] Restrict instance domain in single_instance mode --- service/client.go | 6 +++++- service/transport.go | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/service/client.go b/service/client.go index 3affd57..e4ab8cb 100644 --- a/service/client.go +++ b/service/client.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "net/http" "strings" "time" @@ -68,7 +69,7 @@ func (c *client) redirect(url string) { c.w.WriteHeader(http.StatusFound) } -func (c *client) authenticate(t int) (err error) { +func (c *client) authenticate(t int, instance string) (err error) { csrf := c.r.FormValue("csrf_token") ref := c.r.URL.RequestURI() defer func() { @@ -98,6 +99,9 @@ func (c *client) authenticate(t int) (err error) { return err } c.s = sess + if len(instance) > 0 && c.s.Instance != instance { + return errors.New("invalid instance") + } c.Client = mastodon.NewClient(&mastodon.Config{ Server: "https://" + c.s.Instance, ClientID: c.s.ClientID, diff --git a/service/transport.go b/service/transport.go index dcf2990..17dfca2 100644 --- a/service/transport.go +++ b/service/transport.go @@ -64,7 +64,7 @@ func NewHandler(s *service, verbose bool, staticDir string) http.Handler { } c.w.Header().Add("Content-Type", ct) - err = c.authenticate(at) + err = c.authenticate(at, s.instance) if err != nil { writeError(c, err, rt, req.Method == http.MethodGet) return @@ -79,7 +79,7 @@ func NewHandler(s *service, verbose bool, staticDir string) http.Handler { } rootPage := handle(func(c *client) error { - err := c.authenticate(SESSION) + err := c.authenticate(SESSION, "") if err != nil { if err == errInvalidSession { c.redirect("/signin") From cba88f94a24ad8b0c04d8fcbf2500d45156ce20f Mon Sep 17 00:00:00 2001 From: romin Date: Fri, 22 Sep 2023 12:02:35 +0200 Subject: [PATCH 09/14] Sanitize user field name --- templates/user.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/user.tmpl b/templates/user.tmpl index 3adc1a0..3b1a5cd 100644 --- a/templates/user.tmpl +++ b/templates/user.tmpl @@ -124,7 +124,7 @@ {{if .User.Fields}}
{{range .User.Fields}} -
{{- EmojiFilter .Name $.Data.User.Emojis | Raw}} - {{EmojiFilter .Value $.Data.User.Emojis | Raw -}}
+
{{- EmojiFilter (HTML .Name) $.Data.User.Emojis | Raw}} - {{EmojiFilter .Value $.Data.User.Emojis | Raw -}}
{{end}}
{{end}} From 6707a01a846c459aa2f4bb51ff725193e4a7a6d3 Mon Sep 17 00:00:00 2001 From: r Date: Sun, 24 Sep 2023 10:38:28 +0000 Subject: [PATCH 10/14] Use a custom LimitedReader instead of http.MaxBytesReader Fixes compatibility with older Go versions. --- mastodon/http.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/mastodon/http.go b/mastodon/http.go index ca4c771..7d1c1c4 100644 --- a/mastodon/http.go +++ b/mastodon/http.go @@ -9,15 +9,19 @@ import ( type lr struct { io.ReadCloser + n int64 r *http.Request } func (r *lr) Read(p []byte) (n int, err error) { - n, err = r.ReadCloser.Read(p) - // override the generic error returned by the MaxBytesReader - if _, ok := err.(*http.MaxBytesError); ok { - err = fmt.Errorf("%s \"%s\": response body too large", r.r.Method, r.r.URL) + if r.n <= 0 { + return 0, fmt.Errorf("%s \"%s\": response body too large", r.r.Method, r.r.URL) } + if int64(len(p)) > r.n { + p = p[0:r.n] + } + n, err = r.ReadCloser.Read(p) + r.n -= int64(n) return } @@ -28,7 +32,7 @@ type transport struct { func (t *transport) RoundTrip(r *http.Request) (*http.Response, error) { resp, err := t.t.RoundTrip(r) if resp != nil && resp.Body != nil { - resp.Body = &lr{http.MaxBytesReader(nil, resp.Body, 8<<20), r} + resp.Body = &lr{resp.Body, 8 << 20, r} } return resp, err } From 8e3999fc3d9761f9ce71c35a7154a77c251caa66 Mon Sep 17 00:00:00 2001 From: r Date: Sun, 24 Sep 2023 10:41:21 +0000 Subject: [PATCH 11/14] Fix minimum required Go version in go.mod --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 508d0be..caba203 100644 --- a/go.mod +++ b/go.mod @@ -5,4 +5,4 @@ require ( github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 ) -go 1.13 +go 1.11 From 81bdc7c705d5d21f62927167d5b2c8e4932c9570 Mon Sep 17 00:00:00 2001 From: r Date: Sun, 1 Oct 2023 13:04:07 +0000 Subject: [PATCH 12/14] Add profile edit page --- mastodon/accounts.go | 108 +++++++++++++++++++++++++++++++++-------- mastodon/mastodon.go | 11 +++++ renderer/model.go | 5 ++ renderer/renderer.go | 1 + service/service.go | 49 +++++++++++++++++++ service/transport.go | 58 ++++++++++++++++++++++ static/style.css | 46 ++++++++++++++++-- templates/nav.tmpl | 3 ++ templates/profile.tmpl | 58 ++++++++++++++++++++++ 9 files changed, 314 insertions(+), 25 deletions(-) create mode 100644 templates/profile.tmpl diff --git a/mastodon/accounts.go b/mastodon/accounts.go index f4e9002..c9e0065 100644 --- a/mastodon/accounts.go +++ b/mastodon/accounts.go @@ -1,10 +1,14 @@ package mastodon import ( + "bytes" "context" "fmt" + "io" + "mime/multipart" "net/http" "net/url" + "path/filepath" "strconv" "time" ) @@ -34,6 +38,7 @@ type Account struct { Moved *Account `json:"moved"` Fields []Field `json:"fields"` Bot bool `json:"bot"` + Source *AccountSource `json:"source"` Pleroma *AccountPleroma `json:"pleroma"` } @@ -95,54 +100,115 @@ type Profile struct { Source *AccountSource // Set the base64 encoded character string of the image. - Avatar string - Header string + Avatar *multipart.FileHeader + Header *multipart.FileHeader } // AccountUpdate updates the information of the current user. func (c *Client) AccountUpdate(ctx context.Context, profile *Profile) (*Account, error) { - params := url.Values{} + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) if profile.DisplayName != nil { - params.Set("display_name", *profile.DisplayName) + err := mw.WriteField("display_name", *profile.DisplayName) + if err != nil { + return nil, err + } } if profile.Note != nil { - params.Set("note", *profile.Note) + err := mw.WriteField("note", *profile.Note) + if err != nil { + return nil, err + } } if profile.Locked != nil { - params.Set("locked", strconv.FormatBool(*profile.Locked)) + err := mw.WriteField("locked", strconv.FormatBool(*profile.Locked)) + if err != nil { + return nil, err + } } if profile.Fields != nil { for idx, field := range *profile.Fields { - params.Set(fmt.Sprintf("fields_attributes[%d][name]", idx), field.Name) - params.Set(fmt.Sprintf("fields_attributes[%d][value]", idx), field.Value) + err := mw.WriteField(fmt.Sprintf("fields_attributes[%d][name]", idx), field.Name) + if err != nil { + return nil, err + } + err = mw.WriteField(fmt.Sprintf("fields_attributes[%d][value]", idx), field.Value) + if err != nil { + return nil, err + } } } - if profile.Source != nil { - if profile.Source.Privacy != nil { - params.Set("source[privacy]", *profile.Source.Privacy) + if profile.Avatar != nil { + f, err := profile.Avatar.Open() + if err != nil { + return nil, err } - if profile.Source.Sensitive != nil { - params.Set("source[sensitive]", strconv.FormatBool(*profile.Source.Sensitive)) + fname := filepath.Base(profile.Avatar.Filename) + part, err := mw.CreateFormFile("avatar", fname) + if err != nil { + return nil, err } - if profile.Source.Language != nil { - params.Set("source[language]", *profile.Source.Language) + _, err = io.Copy(part, f) + if err != nil { + return nil, err } } - if profile.Avatar != "" { - params.Set("avatar", profile.Avatar) + if profile.Header != nil { + f, err := profile.Header.Open() + if err != nil { + return nil, err + } + fname := filepath.Base(profile.Header.Filename) + part, err := mw.CreateFormFile("header", fname) + if err != nil { + return nil, err + } + _, err = io.Copy(part, f) + if err != nil { + return nil, err + } } - if profile.Header != "" { - params.Set("header", profile.Header) + err := mw.Close() + if err != nil { + return nil, err } - + params := &multipartRequest{Data: &buf, ContentType: mw.FormDataContentType()} var account Account - err := c.doAPI(ctx, http.MethodPatch, "/api/v1/accounts/update_credentials", params, &account, nil) + err = c.doAPI(ctx, http.MethodPatch, "/api/v1/accounts/update_credentials", params, &account, nil) if err != nil { return nil, err } return &account, nil } +func (c *Client) accountDeleteField(ctx context.Context, field string) (*Account, error) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _, err := mw.CreateFormField(field) + if err != nil { + return nil, err + } + err = mw.Close() + if err != nil { + return nil, err + } + params := &multipartRequest{Data: &buf, ContentType: mw.FormDataContentType()} + var account Account + err = c.doAPI(ctx, http.MethodPatch, "/api/v1/accounts/update_credentials", params, &account, nil) + if err != nil { + return nil, err + } + return &account, nil +} + +func (c *Client) AccountDeleteAvatar(ctx context.Context) (*Account, error) { + return c.accountDeleteField(ctx, "avatar") +} + +func (c *Client) AccountDeleteHeader(ctx context.Context) (*Account, error) { + return c.accountDeleteField(ctx, "header") +} + // GetAccountStatuses return statuses by specified accuont. func (c *Client) GetAccountStatuses(ctx context.Context, id string, onlyMedia bool, pg *Pagination) ([]*Status, error) { var statuses []*Status diff --git a/mastodon/mastodon.go b/mastodon/mastodon.go index f114169..94e2cf5 100644 --- a/mastodon/mastodon.go +++ b/mastodon/mastodon.go @@ -33,6 +33,11 @@ type Client struct { config *Config } +type multipartRequest struct { + Data io.Reader + ContentType string +} + func (c *Client) doAPI(ctx context.Context, method string, uri string, params interface{}, res interface{}, pg *Pagination) error { u, err := url.Parse(c.config.Server) if err != nil { @@ -133,6 +138,12 @@ func (c *Client) doAPI(ctx context.Context, method string, uri string, params in return err } ct = mw.FormDataContentType() + } else if mr, ok := params.(*multipartRequest); ok { + req, err = http.NewRequest(method, u.String(), mr.Data) + if err != nil { + return err + } + ct = mr.ContentType } else { if method == http.MethodGet && pg != nil { u.RawQuery = pg.toValues().Encode() diff --git a/renderer/model.go b/renderer/model.go index 8311f58..e43279d 100644 --- a/renderer/model.go +++ b/renderer/model.go @@ -156,6 +156,11 @@ type FiltersData struct { Filters []*mastodon.Filter } +type ProfileData struct { + *CommonData + User *mastodon.Account +} + type MuteData struct { *CommonData User *mastodon.Account diff --git a/renderer/renderer.go b/renderer/renderer.go index 7732554..c93a611 100644 --- a/renderer/renderer.go +++ b/renderer/renderer.go @@ -33,6 +33,7 @@ const ( SearchPage = "search.tmpl" SettingsPage = "settings.tmpl" FiltersPage = "filters.tmpl" + ProfilePage = "profile.tmpl" MutePage = "mute.tmpl" ) diff --git a/service/service.go b/service/service.go index bc9e5b8..7043310 100644 --- a/service/service.go +++ b/service/service.go @@ -774,6 +774,55 @@ func (svc *service) FiltersPage(c *client) (err error) { return svc.renderer.Render(c.rctx, c.w, renderer.FiltersPage, data) } +func (svc *service) ProfilePage(c *client) (err error) { + u, err := c.GetAccountCurrentUser(c.ctx) + if err != nil { + return + } + // Some instances allow more than 4 fields, but make sure that there are + // at least 4 fields in the slice because the template depends on it. + if u.Source.Fields == nil { + u.Source.Fields = new([]mastodon.Field) + } + for len(*u.Source.Fields) < 4 { + *u.Source.Fields = append(*u.Source.Fields, mastodon.Field{}) + } + cdata := svc.cdata(c, "edit profile", 0, 0, "") + data := &renderer.ProfileData{ + CommonData: cdata, + User: u, + } + return svc.renderer.Render(c.rctx, c.w, renderer.ProfilePage, data) +} + +func (s *service) ProfileUpdate(c *client, name, bio string, avatar, banner *multipart.FileHeader, + fields []mastodon.Field, locked bool) (err error) { + // Need to pass empty data to clear fields + if len(fields) == 0 { + fields = append(fields, mastodon.Field{}) + } + p := &mastodon.Profile{ + DisplayName: &name, + Note: &bio, + Avatar: avatar, + Header: banner, + Fields: &fields, + Locked: &locked, + } + _, err = c.AccountUpdate(c.ctx, p) + return err +} + +func (s *service) ProfileDelAvatar(c *client) (err error) { + _, err = c.AccountDeleteAvatar(c.ctx) + return +} + +func (s *service) ProfileDelBanner(c *client) (err error) { + _, err = c.AccountDeleteHeader(c.ctx) + return err +} + func (s *service) SingleInstance() (instance string, ok bool) { if len(s.instance) > 0 { instance = s.instance diff --git a/service/transport.go b/service/transport.go index 17dfca2..69d08e2 100644 --- a/service/transport.go +++ b/service/transport.go @@ -2,11 +2,14 @@ package service import ( "encoding/json" + "fmt" "log" + "mime/multipart" "net/http" "strconv" "time" + "bloat/mastodon" "bloat/model" "github.com/gorilla/mux" @@ -202,6 +205,57 @@ func NewHandler(s *service, verbose bool, staticDir string) http.Handler { return s.FiltersPage(c) }, SESSION, HTML) + profilePage := handle(func(c *client) error { + return s.ProfilePage(c) + }, SESSION, HTML) + + profileUpdate := handle(func(c *client) error { + name := c.r.FormValue("name") + bio := c.r.FormValue("bio") + var avatar, banner *multipart.FileHeader + if f := c.r.MultipartForm.File["avatar"]; len(f) > 0 { + avatar = f[0] + } + if f := c.r.MultipartForm.File["banner"]; len(f) > 0 { + banner = f[0] + } + var fields []mastodon.Field + for i := 0; i < 16; i++ { + n := c.r.FormValue(fmt.Sprintf("field-name-%d", i)) + v := c.r.FormValue(fmt.Sprintf("field-value-%d", i)) + if len(n) == 0 { + continue + } + f := mastodon.Field{Name: n, Value: v} + fields = append(fields, f) + } + locked := c.r.FormValue("locked") == "true" + err := s.ProfileUpdate(c, name, bio, avatar, banner, fields, locked) + if err != nil { + return err + } + c.redirect("/") + return nil + }, CSRF, HTML) + + profileDelAvatar := handle(func(c *client) error { + err := s.ProfileDelAvatar(c) + if err != nil { + return err + } + c.redirect(c.r.FormValue("referrer")) + return nil + }, CSRF, HTML) + + profileDelBanner := handle(func(c *client) error { + err := s.ProfileDelBanner(c) + if err != nil { + return err + } + c.redirect(c.r.FormValue("referrer")) + return nil + }, CSRF, HTML) + signin := handle(func(c *client) error { instance := c.r.FormValue("instance") url, sess, err := s.NewSession(c, instance) @@ -682,6 +736,10 @@ func NewHandler(s *service, verbose bool, staticDir string) http.Handler { r.HandleFunc("/search", searchPage).Methods(http.MethodGet) r.HandleFunc("/settings", settingsPage).Methods(http.MethodGet) r.HandleFunc("/filters", filtersPage).Methods(http.MethodGet) + r.HandleFunc("/profile", profilePage).Methods(http.MethodGet) + r.HandleFunc("/profile", profileUpdate).Methods(http.MethodPost) + r.HandleFunc("/profile/delavatar", profileDelAvatar).Methods(http.MethodPost) + r.HandleFunc("/profile/delbanner", profileDelBanner).Methods(http.MethodPost) r.HandleFunc("/signin", signin).Methods(http.MethodPost) r.HandleFunc("/oauth_callback", oauthCallback).Methods(http.MethodGet) r.HandleFunc("/post", post).Methods(http.MethodPost) diff --git a/static/style.css b/static/style.css index 21d8bc0..28683e7 100644 --- a/static/style.css +++ b/static/style.css @@ -163,15 +163,14 @@ textarea { padding: 4px; font-size: 11pt; font-family: initial; + box-sizing: border-box; } .post-content { - box-sizing: border-box; width: 100%; } -#css { - box-sizing: border-box; +#css, #bio { max-width: 100%; } @@ -434,6 +433,10 @@ img.emoji { margin-right: 2px; } +.profile-edit-link { + font-size: 8pt; +} + .user-list-item { overflow: auto; margin: 0 0 4px 0; @@ -589,6 +592,41 @@ kbd { color: #789922; } +.profile-form { + margin: 0 4px; +} + +.profile-form-field { + margin: 8px 0; +} + +.profile-avatar { + height: 96px; + width: 96px; + object-fit: contain; +} + +.profile-banner { + height: 120px; +} + +.block-label, +.profile-delete, +.profile-field, +.profile-field input { + margin: 0 0 4px 0; +} + +.profile-form input[type=text] { + width: 320px; + max-width: 100%; + box-sizing: border-box; +} + +#bio { + width: 644px; +} + .dark { background-color: #222222; background-image: none; @@ -599,7 +637,7 @@ kbd { color: #81a2be; } -.dark textarea { +.dark .post-content { background-color: #333333; border: 1px solid #444444; color: #eaeaea; diff --git a/templates/nav.tmpl b/templates/nav.tmpl index 4413823..bdb72be 100644 --- a/templates/nav.tmpl +++ b/templates/nav.tmpl @@ -12,6 +12,9 @@ @{{.User.Acct}} + + edit +