Skip to content

Commit

Permalink
add support for avatar upload
Browse files Browse the repository at this point in the history
  • Loading branch information
dimkr committed Apr 11, 2024
1 parent 9699a97 commit e2077fc
Show file tree
Hide file tree
Showing 20 changed files with 471 additions and 15 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ tootik is lightweight, private and accessible social network:
* With support for [Mastodon's follower synchronization mechanism](https://docs.joinmastodon.org/spec/activitypub/#follower-synchronization-mechanism), aka [FEP-8fcf](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
* Multi-choice polls
* Full-text search within posts
* Upload of user avatars, over [Titan](gemini://transjovian.org/titan)
* Account migration, in both directions

## Using tootik
Expand Down
25 changes: 25 additions & 0 deletions cfg/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ type Config struct {

MaxDisplayNameLength int
MaxBioLength int
MaxAvatarSize int64
MaxAvatarWidth int
MaxAvatarHeight int
AvatarWidth int
AvatarHeight int
MinActorEditInterval time.Duration

MaxFollowsPerUser int
Expand Down Expand Up @@ -167,6 +172,26 @@ func (c *Config) FillDefaults() {
c.MaxBioLength = 500
}

if c.MaxAvatarSize <= 0 {
c.MaxAvatarSize = 2 * 1024 * 1024
}

if c.MaxAvatarWidth <= 0 {
c.MaxAvatarWidth = 1024
}

if c.MaxAvatarHeight <= 0 {
c.MaxAvatarHeight = 1024
}

if c.AvatarWidth <= 0 {
c.AvatarWidth = 400
}

if c.AvatarHeight <= 0 {
c.AvatarHeight = 400
}

if c.MinActorEditInterval <= 0 {
c.MinActorEditInterval = time.Minute * 30
}
Expand Down
2 changes: 1 addition & 1 deletion fed/icon.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ package fed
import (
"database/sql"
"errors"
"github.com/dimkr/tootik/fed/icon"
"github.com/dimkr/tootik/icon"
"net/http"
"strings"
)
Expand Down
153 changes: 153 additions & 0 deletions front/avatar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
Copyright 2024 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package front

import (
"fmt"
"github.com/dimkr/tootik/front/text"
"github.com/dimkr/tootik/icon"
"github.com/dimkr/tootik/outbox"
"io"
"strconv"
"strings"
"time"
)

var supportedImageTypes = map[string]struct{}{
"image/png": {},
"image/jpeg": {},
"image/gif": {},
}

func (h *Handler) uploadAvatar(w text.Writer, r *request, args ...string) {
if r.User == nil {
w.Redirect("/users")
return
}

if r.Body == nil {
w.Redirect("/users/oops")
return
}

var sizeStr, mimeType string
if args[1] == "size" && args[3] == "mime" {
sizeStr = args[2]
mimeType = args[4]
} else if args[1] == "mime" && args[3] == "size" {
sizeStr = args[4]
mimeType = args[2]
} else {
r.Log.Warn("Invalid parameters")
w.Error()
return
}

size, err := strconv.ParseInt(sizeStr, 10, 64)
if err != nil {
r.Log.Warn("Failed to parse avatar size", "error", err)
w.Status(40, "Invalid size")
return
}

if size > r.Handler.Config.MaxAvatarSize {
r.Log.Warn("Image is too big", "size", size)
w.Status(40, "Image is too big")
return
}

if _, ok := supportedImageTypes[mimeType]; !ok {
r.Log.Warn("Image type is unsupported", "type", mimeType)
w.Status(40, "Unsupported image type")
return
}

now := time.Now()

if (r.User.Updated != nil && now.Sub(r.User.Updated.Time) < h.Config.MinActorEditInterval) || (r.User.Updated == nil && now.Sub(r.User.Published.Time) < h.Config.MinActorEditInterval) {
r.Log.Warn("Throttled request to set avatar")
w.Status(40, "Please try again later")
return
}

buf := make([]byte, size)
n, err := io.ReadFull(r.Body, buf)
if err != nil {
r.Log.Warn("Failed to read avatar", "error", err)
w.Error()
return
}

if int64(n) != size {
r.Log.Warn("Avatar is truncated")
w.Error()
return
}

resized, err := icon.Scale(r.Handler.Config, buf)
if err != nil {
r.Log.Warn("Failed to read avatar", "error", err)
w.Error()
return
}

tx, err := r.DB.BeginTx(r.Context, nil)
if err != nil {
r.Log.Warn("Failed to set avatar", "error", err)
w.Error()
return
}
defer tx.Rollback()

if _, err := tx.ExecContext(
r.Context,
"update persons set actor = json_set(actor, '$.icon.url', $1, '$.updated', $2) where id = $3",
// we add fragment because some servers cache the image until the URL changes
fmt.Sprintf("https://%s/icon/%s%s#%d", r.Handler.Domain, r.User.PreferredUsername, icon.FileNameExtension, now.UnixNano()),
now.Format(time.RFC3339Nano),
r.User.ID,
); err != nil {
r.Log.Error("Failed to set avatar", "error", err)
w.Error()
return
}

if _, err := tx.ExecContext(
r.Context,
"insert into icons(name, buf) values($1, $2) on conflict(name) do update set buf = $2",
r.User.PreferredUsername,
string(resized),
); err != nil {
r.Log.Error("Failed to set avatar", "error", err)
w.Error()
return
}

if err := outbox.UpdateActor(r.Context, h.Domain, tx, r.User.ID); err != nil {
r.Log.Error("Failed to set avatar", "error", err)
w.Error()
return
}

if err := tx.Commit(); err != nil {
r.Log.Error("Failed to set avatar", "error", err)
w.Error()
return
}

w.Redirectf("gemini://%s/users/outbox/%s", r.Handler.Domain, strings.TrimPrefix(r.User.ID, "https://"))
}
12 changes: 9 additions & 3 deletions front/gemini/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func (gl *Listener) Handle(ctx context.Context, conn net.Conn, wg *sync.WaitGrou
req := make([]byte, 1024+2)
total := 0
for {
n, err := conn.Read(req[total:])
n, err := conn.Read(req[total : total+1])
if err != nil && total == 0 && errors.Is(err, io.EOF) {
gl.Log.Debug("Failed to receive request", "error", err)
return
Expand Down Expand Up @@ -142,7 +142,7 @@ func (gl *Listener) Handle(ctx context.Context, conn net.Conn, wg *sync.WaitGrou
return
}

gl.Handler.Handle(ctx, gl.Log, w, reqUrl, user, privKey, gl.DB, gl.Resolver, wg)
gl.Handler.Handle(ctx, gl.Log, conn, w, reqUrl, user, privKey, gl.DB, gl.Resolver, wg)
}

// ListenAndServe handles Gemini requests.
Expand Down Expand Up @@ -197,8 +197,14 @@ func (gl *Listener) ListenAndServe(ctx context.Context) error {

wg.Add(1)
go func() {
gl.Handle(requestCtx, conn, &wg)
<-requestCtx.Done()
conn.Close()
wg.Done()
}()

wg.Add(1)
go func() {
gl.Handle(requestCtx, conn, &wg)
timer.Stop()
cancelRequest()
wg.Done()
Expand Down
2 changes: 1 addition & 1 deletion front/gopher/gopher.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func (gl *Listener) handle(ctx context.Context, conn net.Conn, wg *sync.WaitGrou

w := gmap.Wrap(conn, gl.Domain, gl.Config)

gl.Handler.Handle(ctx, gl.Log.With(slog.Group("request", "path", reqUrl.Path)), w, reqUrl, nil, httpsig.Key{}, gl.DB, gl.Resolver, wg)
gl.Handler.Handle(ctx, gl.Log.With(slog.Group("request", "path", reqUrl.Path)), nil, w, reqUrl, nil, httpsig.Key{}, gl.DB, gl.Resolver, wg)
}

// ListenAndServe handles Gopher requests.
Expand Down
2 changes: 1 addition & 1 deletion front/guppy/guppy.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func (gl *Listener) handle(ctx context.Context, wg *sync.WaitGroup, from net.Add
w.Status(4, "Wrong host")
} else {
gl.Log.Info("Handling request", "path", reqUrl.Path, "url", reqUrl.String(), "from", from)
gl.Handler.Handle(ctx, gl.Log, w, reqUrl, nil, httpsig.Key{}, gl.DB, gl.Resolver, wg)
gl.Handler.Handle(ctx, gl.Log, nil, w, reqUrl, nil, httpsig.Key{}, gl.DB, gl.Resolver, wg)
}

if ctx.Err() != nil {
Expand Down
5 changes: 4 additions & 1 deletion front/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/dimkr/tootik/front/static"
"github.com/dimkr/tootik/front/text"
"github.com/dimkr/tootik/httpsig"
"io"
"log/slog"
"net/url"
"regexp"
Expand Down Expand Up @@ -82,6 +83,7 @@ func NewHandler(domain string, closed bool, cfg *cfg.Config) (Handler, error) {
h.handlers[regexp.MustCompile(`^/users/outbox/(\S+)$`)] = withUserMenu(h.userOutbox)
h.handlers[regexp.MustCompile(`^/users/me$`)] = withUserMenu(me)

h.handlers[regexp.MustCompile(`^/users/upload/avatar;([a-z]+)=([^;]+);([a-z]+)=([^;]+)`)] = h.uploadAvatar
h.handlers[regexp.MustCompile(`^/users/bio$`)] = h.bio
h.handlers[regexp.MustCompile(`^/users/name$`)] = h.name
h.handlers[regexp.MustCompile(`^/users/alias$`)] = h.alias
Expand Down Expand Up @@ -147,7 +149,7 @@ func NewHandler(domain string, closed bool, cfg *cfg.Config) (Handler, error) {
}

// Handle handles a request and writes a response.
func (h *Handler) Handle(ctx context.Context, log *slog.Logger, w text.Writer, reqUrl *url.URL, user *ap.Actor, key httpsig.Key, db *sql.DB, resolver ap.Resolver, wg *sync.WaitGroup) {
func (h *Handler) Handle(ctx context.Context, log *slog.Logger, r io.Reader, w text.Writer, reqUrl *url.URL, user *ap.Actor, key httpsig.Key, db *sql.DB, resolver ap.Resolver, wg *sync.WaitGroup) {
for re, handler := range h.handlers {
m := re.FindStringSubmatch(reqUrl.Path)
if m != nil {
Expand All @@ -164,6 +166,7 @@ func (h *Handler) Handle(ctx context.Context, log *slog.Logger, w text.Writer, r
Context: ctx,
Handler: h,
URL: reqUrl,
Body: r,
User: user,
Key: key,
DB: db,
Expand Down
2 changes: 2 additions & 0 deletions front/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"database/sql"
"github.com/dimkr/tootik/ap"
"github.com/dimkr/tootik/httpsig"
"io"
"log/slog"
"net/url"
"sync"
Expand All @@ -30,6 +31,7 @@ type request struct {
Context context.Context
Handler *Handler
URL *url.URL
Body io.Reader
User *ap.Actor
Key httpsig.Key
DB *sql.DB
Expand Down
1 change: 1 addition & 0 deletions front/static/users/help.gmi
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ This page allows you to:
* Set the short (up to {{.Config.MaxBioLength}} characters long) description that appears at the top of your profile
* Set an account alias, to allow account migration to this instance
* Notify followers about account migration from this instance
* Upload a .png, .jpg or .gif image to serve as your avatar (use your client certificate for authentication): up to {{.Config.MaxAvatarWidth}}x{{.Config.MaxAvatarHeight}} and {{.Config.MaxAvatarSize}} bytes, downscaled to {{.Config.AvatarWidth}}x{{.Config.AvatarHeight}}

> 📊 Statistics

Expand Down
1 change: 1 addition & 0 deletions front/static/users/settings.gmi
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

=> /users/name 👺 Set display name
=> /users/bio 📜 Set bio
=> titan://{{.Domain}}/users/upload/avatar Upload avatar

## Migration

Expand Down
2 changes: 1 addition & 1 deletion front/user/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ import (
"encoding/pem"
"fmt"
"github.com/dimkr/tootik/ap"
"github.com/dimkr/tootik/fed/icon"
"github.com/dimkr/tootik/httpsig"
"github.com/dimkr/tootik/icon"
"time"
)

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/fsnotify/fsnotify v1.7.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/stretchr/testify v1.9.0
golang.org/x/image v0.15.0
golang.org/x/sync v0.7.0
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
Expand Down
6 changes: 0 additions & 6 deletions fed/icon/icon.go → icon/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

// Package icon generates tiny, pseudo-random user avatars.
package icon

import (
Expand All @@ -26,11 +25,6 @@ import (
"image/gif"
)

const (
MediaType = "image/gif"
FileNameExtension = ".gif"
)

// Generate generates a tiny pseudo-random image by user name
func Generate(s string) ([]byte, error) {
hash := sha256.Sum256([]byte(s))
Expand Down
23 changes: 23 additions & 0 deletions icon/icon.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
Copyright 2023, 2024 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

// Package icon generates tiny, pseudo-random user avatars.
package icon

const (
MediaType = "image/gif"
FileNameExtension = ".gif"
)
Loading

0 comments on commit e2077fc

Please sign in to comment.