Compare commits

..

17 Commits

Author SHA1 Message Date
localhost_frssoft 96a0d7cf05 fix merge conflicts 2023-10-12 16:07:27 +03:00
localhost_frssoft 4379eab5bf merge with upstream 2023-10-12 15:32:55 +03:00
localhost_frssoft 3762ccfb83 true remote timeline 2023-10-12 15:15:07 +03:00
r b83a00aa2c Revoke oauth token on signout 2023-10-02 06:44:26 +00:00
r df031d5edd Cleanup file upload functions 2023-10-01 13:29:04 +00:00
r 81bdc7c705 Add profile edit page 2023-10-01 13:04:07 +00:00
r 8e3999fc3d Fix minimum required Go version in go.mod 2023-09-24 10:41:21 +00:00
r 6707a01a84 Use a custom LimitedReader instead of http.MaxBytesReader
Fixes compatibility with older Go versions.
2023-09-24 10:38:28 +00:00
romin cba88f94a2 Sanitize user field name 2023-09-22 15:56:26 +00:00
r e50f12b615 Restrict instance domain in single_instance mode 2023-09-18 10:07:54 +00:00
r ad38855261 Set timeout and response size limit for the http client 2023-09-18 04:05:20 +00:00
r 60ccc9686a fluoride: Allow submitting the form with Ctrl+Enter 2023-09-09 08:14:16 +00:00
r 60392e61c7 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.
2023-09-09 06:38:52 +00:00
r 8eec93e028 Trim leading and trailing white space from selectable text 2023-09-08 18:11:07 +00:00
r 461908e031 Load CSS on the root page
This applies the background color to the root page and avoids flicker
during the initial page load.
2023-09-08 18:10:29 +00:00
r 426e9ad14f Fix display name and title on mute page 2023-09-08 14:46:40 +00:00
r 8a26dd1908 Fix userlist margin 2023-09-08 14:38:51 +00:00
25 changed files with 521 additions and 238 deletions

View File

@ -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.

View File

@ -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)
}

2
go.mod
View File

@ -6,4 +6,4 @@ require (
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80
)
go 1.13
go 1.11

18
main.go
View File

@ -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)

View File

@ -1,11 +1,15 @@
package mastodon
import (
"bytes"
"context"
"fmt"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/url"
"path/filepath"
"strconv"
"time"
"strings"
@ -42,6 +46,7 @@ type Account struct {
Moved *Account `json:"moved"`
Fields []Field `json:"fields"`
Bot bool `json:"bot"`
Source *AccountSource `json:"source"`
Pleroma *AccountPleroma `json:"pleroma"`
MastodonAccount bool
}
@ -180,13 +185,12 @@ type Profile struct {
Source *AccountSource
// Set the base64 encoded character string of the image.
Avatar string
Header string
Avatar *multipart.FileHeader
Header *multipart.FileHeader
//Other settings
Bot *bool
Pleroma *ProfilePleroma
}
type ProfilePleroma struct {
@ -199,61 +203,116 @@ type ProfilePleroma struct {
HideFollows *bool
HideFollowersCount *bool
HideFollowsCount *bool
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)
}
if profile.Bot != nil {
params.Set("bot", strconv.FormatBool(*profile.Bot))
}
if profile.Pleroma != nil {
if profile.Pleroma.AcceptsChatMessages != nil {
params.Set("accepts_chat_messages", strconv.FormatBool(*profile.Pleroma.AcceptsChatMessages))
}
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, onlyPinned bool, pg *Pagination) ([]*Status, error) {
var statuses []*Status

View File

@ -11,7 +11,6 @@ import (
// AppConfig is a setting for registering applications.
type AppConfig struct {
http.Client
Server string
ClientName string
@ -61,7 +60,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
}

45
mastodon/http.go Normal file
View File

@ -0,0 +1,45 @@
package mastodon
import (
"fmt"
"io"
"net/http"
"time"
)
type lr struct {
io.ReadCloser
n int64
r *http.Request
}
func (r *lr) Read(p []byte) (n int, err error) {
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
}
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{resp.Body, 8 << 20, r}
}
return resp, err
}
var httpClient = &http.Client{
Transport: &transport{
t: http.DefaultTransport,
},
Timeout: 30 * time.Second,
}

View File

@ -2,19 +2,15 @@
package mastodon
import (
"bytes"
"context"
"encoding/json"
"compress/gzip"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"github.com/tomnomnom/linkheader"
@ -34,6 +30,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 {
@ -57,83 +58,12 @@ func (c *Client) doAPI(ctx context.Context, method string, uri string, params in
if err != nil {
return err
}
} else if file, ok := params.(string); ok {
f, err := os.Open(file)
} else if mr, ok := params.(*multipartRequest); ok {
req, err = http.NewRequest(method, u.String(), mr.Data)
if err != nil {
return err
}
defer f.Close()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file", filepath.Base(file))
if err != nil {
return err
}
_, err = io.Copy(part, f)
if err != nil {
return err
}
err = mw.Close()
if err != nil {
return err
}
req, err = http.NewRequest(method, u.String(), &buf)
if err != nil {
return err
}
ct = mw.FormDataContentType()
} else if file, ok := params.(*multipart.FileHeader); ok {
f, err := file.Open()
if err != nil {
return err
}
defer f.Close()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fname := filepath.Base(file.Filename)
err = mw.WriteField("description", fname)
if err != nil {
return err
}
part, err := mw.CreateFormFile("file", fname)
if err != nil {
return err
}
_, err = io.Copy(part, f)
if err != nil {
return err
}
err = mw.Close()
if err != nil {
return err
}
req, err = http.NewRequest(method, u.String(), &buf)
if err != nil {
return err
}
ct = mw.FormDataContentType()
} else if reader, ok := params.(io.Reader); ok {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file", "upload")
if err != nil {
return err
}
_, err = io.Copy(part, reader)
if err != nil {
return err
}
err = mw.Close()
if err != nil {
return err
}
req, err = http.NewRequest(method, u.String(), &buf)
if err != nil {
return err
}
ct = mw.FormDataContentType()
ct = mr.ContentType
} else {
if method == http.MethodGet && pg != nil {
u.RawQuery = pg.toValues().Encode()
@ -183,7 +113,7 @@ func (c *Client) doAPI(ctx context.Context, method string, uri string, params in
// NewClient return new mastodon API client.
func NewClient(config *Config) *Client {
return &Client{
Client: http.DefaultClient,
Client: httpClient,
config: config,
}
}
@ -219,6 +149,16 @@ func (c *Client) AuthenticateToken(ctx context.Context, authCode, redirectURI st
return c.authenticate(ctx, params)
}
func (c *Client) RevokeToken(ctx context.Context) error {
params := url.Values{
"client_id": {c.config.ClientID},
"client_secret": {c.config.ClientSecret},
"token": {c.GetAccessToken(ctx)},
}
return c.doAPI(ctx, http.MethodPost, "/oauth/revoke", params, nil, nil)
}
func (c *Client) authenticate(ctx context.Context, params url.Values) error {
u, err := url.Parse(c.config.Server)
if err != nil {

View File

@ -1,13 +1,18 @@
package mastodon
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"path/filepath"
"time"
"encoding/json"
"path"
"strings"
)
type StatusPleroma struct {
@ -224,6 +229,51 @@ func (c *Client) GetTimelineHome(ctx context.Context, pg *Pagination) ([]*Status
return statuses, nil
}
type RemoteTimelineInstance struct {
http.Client
}
// TrueRemoteTimeline get public timeline from remote Mastodon API compatible instance directly
func (c *Client) TrueRemoteTimeline(ctx context.Context, instance string, pg *Pagination) ([]*Status, error) {
var httpclient RemoteTimelineInstance
var publicstatuses []*Status
params := url.Values{}
params.Set("local", "true")
if pg != nil {
params = pg.setValues(params)
}
u, err := url.Parse("https://" + instance)
if err != nil {
return nil, err
}
u.Path = path.Join(u.Path, "/api/v1/timelines/public")
req, err := http.NewRequest(http.MethodGet, u.String(), strings.NewReader(params.Encode()))
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpclient.Do(req)
fmt.Println(req)
fmt.Println(resp)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, parseAPIError("bad request", resp)
}
err = json.NewDecoder(resp.Body).Decode(&publicstatuses)
fmt.Println(resp.Body)
if err != nil {
return nil, err
}
return publicstatuses, nil
}
// GetTimelinePublic return statuses from public timeline.
func (c *Client) GetTimelinePublic(ctx context.Context, isLocal bool, instance string, pg *Pagination) ([]*Status, error) {
params := url.Values{}
@ -357,30 +407,35 @@ func (c *Client) Search(ctx context.Context, q string, qType string, limit int,
return &results, nil
}
// UploadMedia upload a media attachment from a file.
func (c *Client) UploadMedia(ctx context.Context, file string) (*Attachment, error) {
var attachment Attachment
err := c.doAPI(ctx, http.MethodPost, "/api/v1/media", file, &attachment, nil)
if err != nil {
return nil, err
}
return &attachment, nil
}
// UploadMediaFromReader uploads a media attachment from a io.Reader.
func (c *Client) UploadMediaFromReader(ctx context.Context, reader io.Reader) (*Attachment, error) {
var attachment Attachment
err := c.doAPI(ctx, http.MethodPost, "/api/v1/media", reader, &attachment, nil)
if err != nil {
return nil, err
}
return &attachment, nil
}
// UploadMediaFromReader uploads a media attachment from a io.Reader.
func (c *Client) UploadMediaFromMultipartFileHeader(ctx context.Context, fh *multipart.FileHeader) (*Attachment, error) {
f, err := fh.Open()
if err != nil {
return nil, err
}
defer f.Close()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fname := filepath.Base(fh.Filename)
err = mw.WriteField("description", fname)
if err != nil {
return nil, err
}
part, err := mw.CreateFormFile("file", fname)
if err != nil {
return nil, err
}
_, err = io.Copy(part, f)
if err != nil {
return nil, err
}
err = mw.Close()
if err != nil {
return nil, err
}
params := &multipartRequest{Data: &buf, ContentType: mw.FormDataContentType()}
var attachment Attachment
err := c.doAPI(ctx, http.MethodPost, "/api/v1/media", fh, &attachment, nil)
err = c.doAPI(ctx, http.MethodPost, "/api/v1/media", params, &attachment, nil)
if err != nil {
return nil, err
}

View File

@ -177,6 +177,11 @@ type FiltersData struct {
Filters []*mastodon.Filter
}
type ProfileData struct {
*CommonData
User *mastodon.Account
}
type MuteData struct {
*CommonData
User *mastodon.Account

View File

@ -36,6 +36,7 @@ const (
SettingsPage = "settings.tmpl"
UserEditPage = "useredit.tmpl"
FiltersPage = "filters.tmpl"
ProfilePage = "profile.tmpl"
MutePage = "mute.tmpl"
)

View File

@ -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() {
@ -100,6 +101,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,

View File

@ -149,12 +149,20 @@ func (s *service) TimelinePage(c *client, tType, instance, listId, maxID,
title = "Local Timeline"
case "remote":
if len(instance) > 0 {
statuses, err = c.GetTimelinePublic(c.ctx, false, instance, &pg)
statuses, err = c.GetTimelinePublic(c.ctx, true, instance, &pg)
if err != nil {
return err
}
}
title = "Remote Timeline"
case "tremote":
if len(instance) > 0 {
statuses, err = c.TrueRemoteTimeline(c.ctx, instance, &pg)
if err != nil {
return err
}
}
title = "True Remote Timeline"
case "twkn":
statuses, err = c.GetTimelinePublic(c.ctx, false, "", &pg)
if err != nil {
@ -739,7 +747,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,
@ -862,6 +870,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
@ -1006,6 +1063,9 @@ func (s *service) NewSessionRegister(c *client, instance string, reason string,
return
}
func (s *service) Signout(c *client) (err error) {
return c.RevokeToken(c.ctx)
}
func (s *service) Post(c *client, content string, replyToID string,
format string, visibility string, isNSFW bool, spoilerText string,

View File

@ -2,7 +2,9 @@ package service
import (
"encoding/json"
"fmt"
"log"
"mime/multipart"
"net/http"
"strconv"
"time"
@ -24,7 +26,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) {
@ -49,10 +51,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 {
@ -63,7 +67,7 @@ func NewHandler(s *service, logger *log.Logger, 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
@ -78,7 +82,7 @@ func NewHandler(s *service, logger *log.Logger, 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")
@ -203,43 +207,6 @@ func NewHandler(s *service, logger *log.Logger, staticDir string) http.Handler {
return s.SearchPage(c, sq, qType, offset)
}, SESSION, HTML)
userEditPage := handle(func(c *client) error {
return s.UserEditPage(c)
}, SESSION, HTML)
userEdit := handle(func(c *client) error {
displayName := c.r.FormValue("display-name")
note := c.r.FormValue("note")
locked := c.r.FormValue("locked") == "true"
bot := c.r.FormValue("bot") == "true"
acceptsChatMessages := c.r.FormValue("accepts-chat-messages") == "true"
hideFavourites := c.r.FormValue("hide-favourites") == "true"
pleromaProfile := mastodon.ProfilePleroma{
AcceptsChatMessages: &acceptsChatMessages,
HideFavourites: &hideFavourites,
}
usersettings := mastodon.Profile{
DisplayName: &displayName,
Note: &note,
Locked: &locked,
Fields: nil,
Source: nil,
Avatar: "",
Header: "",
Bot: &bot,
Pleroma: &pleromaProfile,
}
err := s.UserSave(c, usersettings)
if err != nil {
return err
}
c.redirect("/user/"+c.r.FormValue("id"))
return nil
}, SESSION, HTML)
settingsPage := handle(func(c *client) error {
return s.SettingsPage(c)
}, SESSION, HTML)
@ -248,6 +215,57 @@ func NewHandler(s *service, logger *log.Logger, 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)
@ -750,6 +768,10 @@ func NewHandler(s *service, logger *log.Logger, staticDir string) http.Handler {
}, CSRF, HTML)
signout := handle(func(c *client) error {
err := s.Signout(c)
if err != nil {
return err
}
c.unsetSession()
c.redirect("/")
return nil
@ -811,10 +833,12 @@ func NewHandler(s *service, logger *log.Logger, staticDir string) http.Handler {
r.HandleFunc("/aboutinstance", aboutInstance).Methods(http.MethodGet)
r.HandleFunc("/emojis", emojisPage).Methods(http.MethodGet)
r.HandleFunc("/search", searchPage).Methods(http.MethodGet)
r.HandleFunc("/useredit", userEditPage).Methods(http.MethodGet)
r.HandleFunc("/useredit", userEdit).Methods(http.MethodPost)
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("/signup", signup).Methods(http.MethodPost)
r.HandleFunc("/oauth_callback", oauthCallback).Methods(http.MethodGet)

View File

@ -286,6 +286,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();
@ -326,8 +332,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

View File

@ -1,4 +1,4 @@
body {
frame, body {
background-color: #d2d2d2;
}
@ -167,15 +167,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%;
}
@ -442,9 +441,14 @@ img.emoji {
margin-right: 2px;
}
.profile-edit-link {
font-size: 8pt;
}
.user-list-item {
overflow: auto;
margin: 0 0 12px 0;
margin: 0 0 4px 0;
padding: 4px;
display: flex;
align-items: center;
}
@ -596,6 +600,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;
@ -606,7 +645,7 @@ kbd {
color: #81a2be;
}
.dark textarea {
.dark .post-content {
background-color: #333333;
border: 1px solid #444444;
color: #eaeaea;

View File

@ -1,6 +1,6 @@
{{with .Data}}
{{template "header.tmpl" (WithContext .CommonData $.Ctx)}}
<div class="page-title"> Mute {{.User.Acct}} </div>
<div class="page-title"> Mute {{EmojiFilter (HTML .User.DisplayName) .User.Emojis | Raw}} @{{.User.Acct}} </div>
<form action="/mute/{{.User.ID}}" method="POST">
<input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}">

View File

@ -8,9 +8,12 @@
</div>
<div class="user-info-details-container">
<div class="user-info-details-name">
<bdi class="status-dname"> {{EmojiFilter (HTML .User.DisplayName) .User.Emojis | Raw}} </bdi>
<bdi class="status-dname">{{EmojiFilter (HTML .User.DisplayName) .User.Emojis | Raw}}</bdi>
<a class="nav-link" href="/user/{{.User.ID}}" accesskey="0" title="User profile (0)">
<span class="status-uname"> @{{.User.Acct}} </span>
<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">

View File

@ -28,13 +28,13 @@
</div>
<div class="notification-follow">
<div class="notification-info-text">
<bdi class="status-dname"> {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} </bdi>
<bdi class="status-dname">{{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}}</bdi>
<span class="notification-text"> followed you -
<time datetime="{{FormatTimeRFC3339 .CreatedAt}}" title="{{FormatTimeRFC822 .CreatedAt}}">{{TimeSince .CreatedAt}}</time>
</span>
</div>
<div>
<a href="/user/{{.Account.ID}}"> <span class="status-uname"> @{{.Account.Acct}} </span> </a>
<a href="/user/{{.Account.ID}}"> <span class="status-uname">@{{.Account.Acct}}</span> </a>
</div>
</div>
</div>
@ -48,13 +48,13 @@
</div>
<div class="notification-follow">
<div class="notification-info-text">
<bdi class="status-dname"> {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} </bdi>
<bdi class="status-dname">{{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}}</bdi>
<span class="notification-text"> wants to follow you -
<time datetime="{{FormatTimeRFC3339 .CreatedAt}}" title="{{FormatTimeRFC822 .CreatedAt}}">{{TimeSince .CreatedAt}}</time>
</span>
</div>
<div>
<a href="/user/{{.Account.ID}}"> <span class="status-uname"> @{{.Account.Acct}} </span> </a>
<a href="/user/{{.Account.ID}}"><span class="status-uname">@{{.Account.Acct}}</span></a>
</div>
<form class="d-inline" action="/accept/{{.Account.ID}}" method="post" target="_self">
<input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}">
@ -79,7 +79,7 @@
<img class="status-profile-img" src="{{.Account.Avatar}}" title="@{{.Account.Acct}}" alt="avatar" height="48" />
</a>
<a href="/user/{{.Account.ID}}">
<span class="status-uname"> @{{.Account.Acct}} </span>
<span class="status-uname">@{{.Account.Acct}}</span>
</a>
<span class="notification-text"> retweeted your post -
<time datetime="{{FormatTimeRFC3339 .CreatedAt}}" title="{{FormatTimeRFC822 .CreatedAt}}">{{TimeSince .CreatedAt}}</time>
@ -93,7 +93,7 @@
<img class="status-profile-img" src="{{.Account.Avatar}}" title="@{{.Account.Acct}}" alt="avatar" height="48" />
</a>
<a href="/user/{{.Account.ID}}">
<span class="status-uname"> @{{.Account.Acct}} </span>
<span class="status-uname">@{{.Account.Acct}}</span>
</a>
<span class="notification-text"> liked your post -
<time datetime="{{FormatTimeRFC3339 .CreatedAt}}" title="{{FormatTimeRFC822 .CreatedAt}}">{{TimeSince .CreatedAt}}</time>
@ -134,7 +134,7 @@
<img class="status-profile-img" src="{{.Account.Avatar}}" title="@{{.Account.Acct}}" alt="avatar" height="48" />
</a>
<a href="/user/{{.Account.ID}}">
<span class="status-uname"> @{{.Account.Acct}} </span>
<span class="status-uname">@{{.Account.Acct}}</span>
</a>
<span class="notification-text"> {{.Type}} -
<time datetime="{{FormatTimeRFC3339 .CreatedAt}}" title="{{FormatTimeRFC822 .CreatedAt}}">{{TimeSince .CreatedAt}}</time>

58
templates/profile.tmpl Normal file
View File

@ -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}}

View File

@ -9,9 +9,9 @@
</div>
<div class="user-list-name">
<div>
<div class="status-dname"> {{EmojiFilter (HTML .DisplayName) .Emojis | Raw}} </div>
<div class="status-dname">{{EmojiFilter (HTML .DisplayName) .Emojis | Raw}}</div>
<a class="img-link" href="/user/{{.ID}}">
<div class="status-uname"> @{{.Acct}} </div>
<div class="status-uname">{{.Acct}}</div>
</a>
</div>
<form class="d-inline" action="/accept/{{.ID}}" method="post" target="_self">

View File

@ -4,14 +4,15 @@
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="stylesheet" href="/static/style.css">
<title>{{.Title}}</title>
</head>
<frameset cols="424px,*">
<frameset rows="316px,*">
<frame name="nav" src="/nav">
<frame name="notification" src="/notifications">
<frame name="nav" src="/nav" {{if $.Ctx.DarkMode}}class="dark"{{end}}>
<frame name="notification" src="/notifications" {{if $.Ctx.DarkMode}}class="dark"{{end}}>
</frameset>
<frame name="main" src="/timeline/home">
<frame name="main" src="/timeline/home" {{if $.Ctx.DarkMode}}class="dark"{{end}}>
</frameset>
</html>
{{end}}

View File

@ -5,9 +5,9 @@
<a class="img-link" href="/user/{{.Account.ID}}">
<img class="status-profile-img" src="{{.Account.Avatar}}" title="@{{.Account.Acct}}" alt="avatar" height="24" />
</a>
<bdi class="status-dname"> {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} </bdi>
<bdi class="status-dname">{{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}}</bdi>
<a href="/user/{{.Account.ID}}">
<span class="status-uname"> @{{.Account.Acct}} </span>
<span class="status-uname">@{{.Account.Acct}}</span>
</a>
retweeted
</div>
@ -25,7 +25,7 @@
<div class="status-name">
<bdi class="status-dname"> {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} {{if .Account.Bot}}🤖{{end}}</bdi>
<a href="/user/{{.Account.ID}}">
<span class="status-uname"> @{{.Account.Acct}} </span>
<span class="status-uname">@{{.Account.Acct}}</span>
</a>
<div class="more-container">
<div class="remote-link">
@ -99,8 +99,8 @@
{{if (or .Content .SpoilerText)}}
<div class="status-content">
{{if .Sensitive}}[NSFW]<br/>{{end}}
{{if .SpoilerText}}[{{EmojiFilter (HTML .SpoilerText) .Emojis | Raw}}]<br/>{{end}}
{{StatusContentFilter .Content .Emojis .Mentions | Raw}}
{{- if .SpoilerText}}{{EmojiFilter (HTML .SpoilerText) .Emojis | Raw}}<br/>{{end -}}
{{- StatusContentFilter .Content .Emojis .Mentions | Raw -}}
</div>
{{end}}
{{$st_id := .ID}}

View File

@ -3,7 +3,6 @@
<div class="page-title"> User </div>
<div class="user-info-container">
<div>
<div class="user-profile-img-container">
<a class="img-link" href="{{.User.Avatar}}" target="_blank">
<img class="user-profile-img" src="{{.User.Avatar}}" alt="profile-avatar" height="96" />
@ -11,8 +10,8 @@
</div>
<div class="user-profile-details-container">
<div>
<bdi class="status-dname"> {{EmojiFilter (HTML .User.DisplayName) .User.Emojis | Raw}} </bdi>
<span class="status-uname"> @{{.User.Acct}} </span>
<bdi class="status-dname">{{EmojiFilter (HTML .User.DisplayName) .User.Emojis | Raw}}</bdi>
<span class="status-uname">@{{.User.Acct}}</span>
<a class="remote-link" href="{{.User.URL}}" target="_blank" title="remote profile">
source
</a>
@ -128,18 +127,17 @@
<summary>About user</summary>
<div class="user-profile-decription">
[User created: {{.User.CreatedAt}}]<br>
{{EmojiFilter .User.Note .User.Emojis | Raw}}
{{- EmojiFilter .User.Note .User.Emojis | Raw -}}
</div>
</details>
{{if .User.Fields}}
<div class="user-fields">
{{range .User.Fields}}
<div>{{EmojiFilter .Name $.Data.User.Emojis | Raw}} - {{EmojiFilter .Value $.Data.User.Emojis | Raw}}</div>
<div>{{- EmojiFilter (HTML .Name) $.Data.User.Emojis | Raw}} - {{EmojiFilter .Value $.Data.User.Emojis | Raw -}}</div>
{{end}}
</div>
{{end}}
</div>
</div>
{{if eq .Type ""}}
<div class="page-title"> Statuses </div>

View File

@ -6,9 +6,9 @@
</a>
</div>
<div class="user-list-name">
<div class="status-dname"> {{EmojiFilter (HTML .DisplayName) .Emojis | Raw}} </div>
<div class="status-dname">{{EmojiFilter (HTML .DisplayName) .Emojis | Raw}}</div>
<a class="img-link" href="/user/{{.ID}}">
<div class="status-uname"> @{{.Acct}} </div>
<div class="status-uname">@{{.Acct}}</div>
</a>
</div>
</div>