aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--mastodon/accounts.go106
-rw-r--r--mastodon/mastodon.go11
-rw-r--r--renderer/model.go5
-rw-r--r--renderer/renderer.go1
-rw-r--r--service/service.go49
-rw-r--r--service/transport.go58
-rw-r--r--static/style.css46
-rw-r--r--templates/nav.tmpl3
-rw-r--r--templates/profile.tmpl58
9 files changed, 313 insertions, 24 deletions
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)
+ 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)
+ 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 @@
<a class="nav-link" href="/user/{{.User.ID}}" accesskey="0" title="User profile (0)">
<span class="status-uname">@{{.User.Acct}}</span>
</a>
+ <a class="profile-edit-link" href="/profile" title="edit profile" target="_top">
+ edit
+ </a>
</div>
<div class="user-info-details-nav">
<a class="nav-link" href="/timeline/home" accesskey="1" title="Home timeline (1)">home</a>
diff --git a/templates/profile.tmpl b/templates/profile.tmpl
new file mode 100644
index 0000000..4bf1937
--- /dev/null
+++ b/templates/profile.tmpl
@@ -0,0 +1,58 @@
+{{with .Data}}
+{{template "header.tmpl" (WithContext .CommonData $.Ctx)}}
+<div class="page-title"> Edit Profile </div>
+
+<form class="profile-form" action="/profile" method="POST" enctype="multipart/form-data">
+ <input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}">
+ <input type="hidden" name="referrer" value="{{$.Ctx.Referrer}}">
+ <div class="profile-form-field">
+ <div class="block-label">
+ <label for="avatar">Avatar</label> -
+ <input class="btn-link" type="submit" formaction="/profile/delavatar" formmethod="POST" value="delete">
+ </div>
+ <div>
+ <a href="{{.User.Avatar}}" target="_blank">
+ <img class="profile-avatar" src="{{.User.Avatar}}" alt="profile-avatar" height="96">
+ </a>
+ </div>
+ <div><input id="avatar" name="avatar" type="file"></div>
+ </div>
+ <div class="profile-form-field">
+ <div class="block-label">
+ <label for="banner">Banner</label> -
+ <input class="btn-link" type="submit" formaction="/profile/delbanner" formmethod="POST" value="delete">
+ </div>
+ <div>
+ <a href="{{.User.Header}}" target="_blank">
+ <img class="profile-banner" src="{{.User.Header}}" alt="profile-banner" height="120">
+ </a>
+ </div>
+ <input id="banner" name="banner" type="file">
+ </div>
+ <div class="profile-form-field">
+ <div class="block-label"><label for="name">Name</label></div>
+ <div><input id="name" name="name" type="text" value="{{.User.DisplayName}}"></div>
+ </div>
+ <div class="profile-form-field">
+ <div class="block-label"><label for="bio">Bio</label></div>
+ <textarea id="bio" name="bio" cols="80" rows="8">{{.User.Source.Note}}</textarea>
+ </div>
+ <div class="profile-form-field">
+ <div class="block-label"><label>Metadata</label></div>
+ {{range $i, $f := .User.Source.Fields}}
+ <div class="profile-field">
+ <input id="field-name-{{$i}}" name="field-name-{{$i}}" type="text" value="{{$f.Name}}" placeholder="name">
+ <input id="field-value-{{$i}}" name="field-value-{{$i}}" type="text" value="{{$f.Value}}" placeholder="value">
+ </div>
+ {{end}}
+ </div>
+ <div class="profile-form-field">
+ <input id="locked" name="locked" type="checkbox" value="true" {{if .User.Locked}}checked{{end}}>
+ <label for="locked">Require manual approval of follow requests</label>
+ </div>
+ <button type="submit"> Save </button>
+ <button type="reset"> Reset </button>
+</form>
+
+{{template "footer.tmpl"}}
+{{end}}