Photo Upload & Profile Pictures

Basic photo upload support. Square cropped images still buggy.
This commit is contained in:
Noah 2022-08-11 23:03:06 -07:00
parent de3d6e9315
commit 60dd396b30
22 changed files with 2103 additions and 4 deletions

3
.gitignore vendored
View File

@ -1,3 +1,4 @@
/gosocial /gosocial
/web/static/photos
database.sqlite database.sqlite
settings.json settings.json

4
go.mod
View File

@ -18,6 +18,8 @@ require (
github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f // indirect
github.com/go-redis/redis v6.15.9+incompatible // indirect github.com/go-redis/redis v6.15.9+incompatible // indirect
github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/css v1.0.0 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect
@ -34,6 +36,7 @@ require (
github.com/microcosm-cc/bluemonday v1.0.19 // indirect github.com/microcosm-cc/bluemonday v1.0.19 // indirect
github.com/russross/blackfriday v1.5.2 // indirect github.com/russross/blackfriday v1.5.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/sergi/go-diff v1.2.0 // indirect github.com/sergi/go-diff v1.2.0 // indirect
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 // indirect github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 // indirect
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 // indirect github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 // indirect
@ -44,6 +47,7 @@ require (
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 // indirect
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect

10
go.sum
View File

@ -16,6 +16,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
@ -113,6 +117,8 @@ github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNue
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
@ -172,6 +178,10 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 h1:/eM0PCrQI2xd471rI+snWuu251/+/jpBpZqir2mPdnU=
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=

View File

@ -17,6 +17,12 @@ const (
TemplatePath = "./web/templates" TemplatePath = "./web/templates"
StaticPath = "./web/static" StaticPath = "./web/static"
SettingsPath = "./settings.json" SettingsPath = "./settings.json"
// Web path where photos are kept. Photos in DB store only their filenames, this
// is the base URL that goes in front. TODO: support setting a CDN URL prefix.
JpegQuality = 90
PhotoWebPath = "/static/photos/"
PhotoDiskPath = "./web/static/photos"
) )
// Security // Security
@ -27,6 +33,7 @@ const (
CSRFInputName = "_csrf" // html input name CSRFInputName = "_csrf" // html input name
SessionCookieMaxAge = 60 * 60 * 24 * 30 SessionCookieMaxAge = 60 * 60 * 24 * 30
SessionRedisKeyFormat = "session/%s" SessionRedisKeyFormat = "session/%s"
MultipartMaxMemory = 1024 * 1024 * 1024 * 20 // 20 MB
) )
// Authentication // Authentication
@ -42,6 +49,12 @@ var (
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`) UsernameRegexp = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
) )
// Photo Galleries
const (
MaxPhotoWidth = 1280
ProfilePhotoWidth = 512
)
// Variables set by main.go to make them readily available. // Variables set by main.go to make them readily available.
var ( var (
RuntimeVersion string RuntimeVersion string

View File

@ -0,0 +1,144 @@
package photo
import (
"bytes"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/photo"
"git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates"
)
// Upload photos controller.
func Upload() http.HandlerFunc {
tmpl := templates.Must("photo/upload.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var vars = map[string]interface{}{
"Intent": r.FormValue("intent"),
"NeedsCrop": false,
}
// Query string parameters: what is the intent of this photo upload?
// - If profile picture, the user will crop their image before posting it.
// - If regular photo, user simply picks a picture and doesn't need to crop it.
if vars["Intent"] == "profile_pic" {
vars["NeedsCrop"] = true
}
user, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
}
// Are they POSTing?
if r.Method == http.MethodPost {
var (
caption = r.PostFormValue("caption")
isExplicit = r.PostFormValue("explicit") == "true"
visibility = r.PostFormValue("visibility")
isGallery = r.PostFormValue("gallery") == "true"
cropCoords = r.PostFormValue("crop")
confirm1 = r.PostFormValue("confirm1") == "true"
confirm2 = r.PostFormValue("confirm2") == "true"
)
// They checked both boxes. The browser shouldn't allow them to
// post but validate it here anyway...
if !confirm1 || !confirm2 {
session.FlashError(w, r, "You must agree to the terms to upload this picture.")
templates.Redirect(w, r.URL.Path)
return
}
// Parse and validate crop coordinates.
var crop []int
if len(cropCoords) > 0 {
aints := strings.Split(cropCoords, ",")
if len(aints) >= 4 {
crop = []int{}
for i, aint := range aints {
if number, err := strconv.Atoi(strings.TrimSpace(aint)); err == nil {
crop = append(crop, number)
} else {
log.Error("Failure to parse crop coordinates ('%s') at number %d: %s", cropCoords, i, err)
}
}
}
}
log.Error("parsed crop coords: %+v", crop)
// Get their file upload.
file, header, err := r.FormFile("file")
if err != nil {
session.FlashError(w, r, "Error receiving your file: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Read the file contents.
log.Debug("Receiving uploaded file (%d bytes): %s", header.Size, header.Filename)
var buf bytes.Buffer
io.Copy(&buf, file)
filename, cropFilename, err := photo.UploadPhoto(photo.UploadConfig{
User: user,
Extension: filepath.Ext(header.Filename),
Data: buf.Bytes(),
Crop: crop,
})
if err != nil {
session.FlashError(w, r, "Error in UploadPhoto: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Configuration for the DB entry.
ptmpl := models.Photo{
UserID: user.ID,
Filename: filename,
CroppedFilename: cropFilename,
Caption: caption,
Visibility: models.PhotoVisibility(visibility),
Gallery: isGallery,
Explicit: isExplicit,
}
// Get the filesize.
if stat, err := os.Stat(photo.DiskPath(filename)); err == nil {
ptmpl.Filesize = stat.Size()
}
// Create it in DB!
p, err := models.CreatePhoto(ptmpl)
if err != nil {
session.FlashError(w, r, "Couldn't create Photo in DB: %s", err)
} else {
log.Info("New photo! %+v", p)
}
// Are we uploading a profile pic? If so, set the user's pic now.
if vars["Intent"] == "profile_pic" {
log.Info("User %s is setting their profile picture", user.Username)
user.ProfilePhotoID = p.ID
user.Save()
}
session.Flash(w, r, "Your photo has been uploaded successfully.")
templates.Redirect(w, r.URL.Path)
return
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -3,6 +3,7 @@ package middleware
import ( import (
"net/http" "net/http"
"git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates" "git.kirsle.net/apps/gosocial/pkg/templates"
) )
@ -13,6 +14,7 @@ func LoginRequired(handler http.Handler) http.Handler {
// User must be logged in. // User must be logged in.
if _, err := session.CurrentUser(r); err != nil { if _, err := session.CurrentUser(r); err != nil {
log.Error("LoginRequired: %s", err)
errhandler := templates.MakeErrorPage("Login Required", "You must be signed in to view this page.", http.StatusForbidden) errhandler := templates.MakeErrorPage("Login Required", "You must be signed in to view this page.", http.StatusForbidden)
errhandler.ServeHTTP(w, r) errhandler.ServeHTTP(w, r)
return return

View File

@ -20,7 +20,7 @@ func CSRF(handler http.Handler) http.Handler {
// If we are running a POST request, validate the CSRF form value. // If we are running a POST request, validate the CSRF form value.
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
r.ParseForm() r.ParseMultipartForm(config.MultipartMaxMemory)
check := r.FormValue(config.CSRFInputName) check := r.FormValue(config.CSRFInputName)
if check != token { if check != token {
log.Error("CSRF mismatch! %s <> %s", check, token) log.Error("CSRF mismatch! %s <> %s", check, token)

View File

@ -10,4 +10,5 @@ var DB *gorm.DB
func AutoMigrate() { func AutoMigrate() {
DB.AutoMigrate(&User{}) DB.AutoMigrate(&User{})
DB.AutoMigrate(&ProfileField{}) DB.AutoMigrate(&ProfileField{})
DB.AutoMigrate(&Photo{})
} }

51
pkg/models/photo.go Normal file
View File

@ -0,0 +1,51 @@
package models
import (
"errors"
"time"
)
// Photo table.
type Photo struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"`
Filename string
CroppedFilename string // if cropped, e.g. for profile photo
Filesize int64
Caption string
Flagged bool // photo has been reported by the community
Visibility PhotoVisibility
Gallery bool // photo appears in the public gallery (if public)
Explicit bool // is an explicit photo
CreatedAt time.Time
UpdatedAt time.Time
}
// PhotoVisibility settings.
type PhotoVisibility string
const (
PhotoPublic PhotoVisibility = "public" // on profile page and/or public gallery
PhotoFriends = "friends" // only friends can see it
PhotoPrivate = "private" // private
)
// CreatePhoto with most of the settings you want (not ID or timestamps) in the database.
func CreatePhoto(tmpl Photo) (*Photo, error) {
if tmpl.UserID == 0 {
return nil, errors.New("UserID required")
}
p := &Photo{
UserID: tmpl.UserID,
Filename: tmpl.Filename,
CroppedFilename: tmpl.CroppedFilename,
Caption: tmpl.Caption,
Visibility: tmpl.Visibility,
Gallery: tmpl.Gallery,
Explicit: tmpl.Explicit,
}
result := DB.Create(p)
return p, result.Error
}

View File

@ -23,12 +23,15 @@ type User struct {
Name *string Name *string
Birthdate time.Time Birthdate time.Time
Certified bool Certified bool
Explicit bool // user has opted-in to see explicit content
CreatedAt time.Time `gorm:"index"` CreatedAt time.Time `gorm:"index"`
UpdatedAt time.Time `gorm:"index"` UpdatedAt time.Time `gorm:"index"`
LastLoginAt time.Time `gorm:"index"` LastLoginAt time.Time `gorm:"index"`
// Relational tables. // Relational tables.
ProfileField []ProfileField ProfileField []ProfileField
ProfilePhotoID uint64
ProfilePhoto Photo
} }
// UserStatus options. // UserStatus options.

62
pkg/photo/filenames.go Normal file
View File

@ -0,0 +1,62 @@
package photo
import (
"fmt"
"os"
"path/filepath"
"git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/log"
"github.com/google/uuid"
)
// Functions that deal with giving photos their:
// - Filename
// - URL prefix (/static/photos or maybe CDN?)
/*
NewFilename generates a Filename with an extension (".jpg").
The filename is a random UUID string, with a couple of directory
paths in front consisting of the first few characters (to keep
directory sizes under control over time). Example:
"91/b9/91b908db-4007-41b2-bbca-71a6526e59aa.jpg"
*/
func NewFilename(ext string) string {
basename := uuid.New().String()
first2 := basename[:2]
next2 := basename[2:4]
log.Debug("photo.NewFilename: UUID %s first2 %d next2 %d", basename, first2, next2)
return fmt.Sprintf(
"%s/%s/%s%s",
first2, next2, basename, ext,
)
}
// DiskPath returns the local disk path to a photo Filename.
func DiskPath(filename string) string {
return config.PhotoDiskPath + "/" + filename
}
/*
EnsurePath makes sure the local './web/static/photos/' path is ready
to write an image to, taking into account path parameters in the
image filename.
The filename is like from NewFilename(), just the photo Filename portion.
It is appended to the PhotoDiskPath.
Returns the full path ("./web/static/photos/...") ready for the caller
to use it for writing.
*/
func EnsurePath(filename string) (string, error) {
fullpath := DiskPath(filename)
dir := filepath.Dir(fullpath)
log.Debug("photo.EnsurePath: check that %s exists", dir)
if err := os.MkdirAll(dir, 0755); err != nil {
return fullpath, fmt.Errorf("EnsurePath: %s", err)
} else {
return fullpath, nil
}
}

173
pkg/photo/upload.go Normal file
View File

@ -0,0 +1,173 @@
package photo
import (
"bytes"
"errors"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"os"
"git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/models"
"github.com/edwvee/exiffix"
"golang.org/x/image/draw"
)
type UploadConfig struct {
User *models.User
Extension string // 'jpg' or 'png' only.
Data []byte
Crop []int // x, y, w, h
}
// UploadPhoto handles an incoming photo to add to a user's account.
//
// Returns:
// - NewFilename() of the created photo file on disk.
// - NewFilename() of the cropped version, or "" if not cropping.
// - error on errors
func UploadPhoto(cfg UploadConfig) (string, string, error) {
// Validate and normalize the extension.
var extension = cfg.Extension
switch cfg.Extension {
case ".jpg":
fallthrough
case ".jpe":
fallthrough
case ".jpeg":
extension = ".jpg"
case ".png":
extension = ".png"
default:
return "", "", errors.New("unsupported image extension, must be jpg or png")
}
// Decide on a filename for this photo.
var (
filename = NewFilename(extension)
cropFilename = NewFilename(extension)
)
// Decode the image using exiffix, which will auto-rotate jpeg images
// based on their EXIF tags.
reader := bytes.NewReader(cfg.Data)
origImage, _, err := exiffix.Decode(reader)
if err != nil {
return "", "", err
}
// Read the config to get the image width.
reader.Seek(0, io.SeekStart)
var width, height int
if decoded, _, err := image.DecodeConfig(reader); err == nil {
width, height = decoded.Width, decoded.Height
} else {
return "", "", err
}
// Find the longest edge, if it's too large (over 1280px)
// cap it to the max and scale the other dimension proportionally.
log.Debug("UploadPhoto: taking a %dx%d image to name it %s", width, height, filename)
if width >= height {
if width > config.MaxPhotoWidth {
newWidth := config.MaxPhotoWidth
log.Debug("(%d / %d) * %d", width, height, newWidth)
height = int((float64(height) / float64(width)) * float64(newWidth))
width = newWidth
log.Debug("Its longest is width, scale to %sx%s", width, height)
}
} else {
if height > config.MaxPhotoWidth {
newHeight := config.MaxPhotoWidth
width = int((float64(width) / float64(height)) * float64(newHeight))
height = newHeight
log.Debug("Its longest is height, scale to %sx%s", width, height)
}
}
// Scale the image.
scaledImg := Scale(origImage, image.Rect(0, 0, width, height), draw.ApproxBiLinear)
// Write the image to disk.
if err := ToDisk(filename, extension, scaledImg); err != nil {
return "", "", err
}
// Are we producing a cropped image, too?
log.Error("Are we to crop? %+v", cfg.Crop)
if cfg.Crop != nil && len(cfg.Crop) >= 4 {
log.Debug("Also cropping this image to %+v", cfg.Crop)
var (
x = cfg.Crop[0]
y = cfg.Crop[1]
w = cfg.Crop[2]
h = cfg.Crop[3]
)
croppedImg := Crop(origImage, image.Rect(
x,
y,
w,
h,
))
// Write that to disk, too.
log.Debug("Writing cropped image to disk: %s", cropFilename)
if err := ToDisk(cropFilename, extension, croppedImg); err != nil {
return filename, "", err
}
// Return both filenames!
return filename, cropFilename, nil
}
// Not cropping, return only the first filename.
return filename, "", nil
}
// Scale down an image. Example:
//
// scaled := Scale(src, image.Rect(0, 0, 200, 200), draw.ApproxBiLinear)
func Scale(src image.Image, rect image.Rectangle, scale draw.Scaler) image.Image {
dst := image.NewRGBA(rect)
scale.Scale(dst, rect, src, src.Bounds(), draw.Over, nil)
return dst
}
// Crop an image, returning the new image. Example:
//
// cropped := Crop()
func Crop(src image.Image, rect image.Rectangle) image.Image {
dst := image.NewRGBA(rect)
draw.Copy(dst, image.Point{}, src, rect, draw.Over, nil)
return dst
}
// ToDisk commits a photo image to disk in the right file format.
//
// Filename is like NewFilename() and it goes to e.g. "./web/static/photos/"
func ToDisk(filename string, extension string, img image.Image) error {
if path, err := EnsurePath(filename); err == nil {
fh, err := os.Create(path)
if err != nil {
return err
}
defer fh.Close()
switch extension {
case ".jpg":
jpeg.Encode(fh, img, &jpeg.Options{
Quality: config.JpegQuality,
})
case ".png":
png.Encode(fh, img)
}
} else {
return fmt.Errorf("couldn't EnsurePath: %s", err)
}
return nil
}

View File

@ -8,6 +8,7 @@ import (
"git.kirsle.net/apps/gosocial/pkg/controller/account" "git.kirsle.net/apps/gosocial/pkg/controller/account"
"git.kirsle.net/apps/gosocial/pkg/controller/api" "git.kirsle.net/apps/gosocial/pkg/controller/api"
"git.kirsle.net/apps/gosocial/pkg/controller/index" "git.kirsle.net/apps/gosocial/pkg/controller/index"
"git.kirsle.net/apps/gosocial/pkg/controller/photo"
"git.kirsle.net/apps/gosocial/pkg/middleware" "git.kirsle.net/apps/gosocial/pkg/middleware"
) )
@ -24,6 +25,7 @@ func New() http.Handler {
mux.Handle("/me", middleware.LoginRequired(account.Dashboard())) mux.Handle("/me", middleware.LoginRequired(account.Dashboard()))
mux.Handle("/settings", middleware.LoginRequired(account.Settings())) mux.Handle("/settings", middleware.LoginRequired(account.Settings()))
mux.Handle("/u/", middleware.LoginRequired(account.Profile())) mux.Handle("/u/", middleware.LoginRequired(account.Profile()))
mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload()))
// JSON API endpoints. // JSON API endpoints.
mux.HandleFunc("/v1/version", api.Version()) mux.HandleFunc("/v1/version", api.Version())

21
web/static/css/theme.css Normal file
View File

@ -0,0 +1,21 @@
/* Custom CSS styles */
/* Container for large profile pic on user pages */
.profile-photo {
width: 150px;
height: 150px;
display: block;
border: 1px solid #000;
background-color: #fff;
padding: 4px;
position: relative;
}
.profile-photo img {
max-width: 100%;
height: auto;
}
.profile-photo .corner {
position: absolute;
top: 0;
right: 0;
}

View File

@ -0,0 +1,58 @@
.croppr-container * {
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
box-sizing: border-box;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
}
.croppr-container img {
vertical-align: middle;
max-width: 100%;
}
.croppr {
position: relative;
display: inline-block;
}
.croppr-overlay {
background: rgba(0,0,0,0.5);
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
cursor: crosshair;
}
.croppr-region {
border: 1px dashed rgba(0, 0, 0, 0.5);
position: absolute;
z-index: 3;
cursor: move;
top: 0;
}
.croppr-imageClipped {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
pointer-events: none;
}
.croppr-handle {
border: 1px solid black;
background-color: white;
width: 10px;
height: 10px;
position: absolute;
z-index: 4;
top: 0;
}

File diff suppressed because it is too large Load Diff

1
web/static/js/croppr/croppr.min.css vendored Normal file
View File

@ -0,0 +1 @@
.croppr-container *{user-select:none;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;box-sizing:border-box;-webkit-box-sizing:border-box;-moz-box-sizing:border-box}.croppr-container img{vertical-align:middle;max-width:100%}.croppr{position:relative;display:inline-block}.croppr-handle,.croppr-imageClipped,.croppr-overlay,.croppr-region{position:absolute;top:0}.croppr-overlay{background:rgba(0,0,0,.5);right:0;bottom:0;left:0;z-index:1;cursor:crosshair}.croppr-region{border:1px dashed rgba(0,0,0,.5);z-index:3;cursor:move}.croppr-imageClipped{right:0;bottom:0;left:0;z-index:2;pointer-events:none}.croppr-handle{border:1px solid #000;background-color:#fff;width:10px;height:10px;z-index:4}

1
web/static/js/croppr/croppr.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -20,6 +20,7 @@
<div class="card-content"> <div class="card-content">
<ul class="menu-list"> <ul class="menu-list">
<li><a href="/u/{{.CurrentUser.Username}}">My Profile</a></li> <li><a href="/u/{{.CurrentUser.Username}}">My Profile</a></li>
<li><a href="/photo/upload">Upload Photos</a></li>
<li><a href="/settings">Settings</a></li> <li><a href="/settings">Settings</a></li>
<li><a href="/logout">Log out</a></li> <li><a href="/logout">Log out</a></li>
<li><a href="/account/delete">Delete account</a></li> <li><a href="/account/delete">Delete account</a></li>

View File

@ -6,8 +6,23 @@
<div class="container"> <div class="container">
<div class="columns"> <div class="columns">
<div class="column is-narrow"> <div class="column is-narrow">
<figure class="image is-128x128"> <figure class="profile-photo">
{{if .User.ProfilePhoto}}
<img src="/static/photos/{{.User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img class="is-rounded" src="/static/img/shy.png"> <img class="is-rounded" src="/static/img/shy.png">
{{end}}
<!-- CurrentUser can upload a new profile pic -->
{{if eq .CurrentUser.ID .User.ID}}
<span class="corner">
<button class="button is-small p-1 is-success">
<a href="/photo/upload?intent=profile_pic"
class="fa fa-camera has-text-link"
title="Upload a new Profile Picture"></a>
</button>
</span>
{{end}}
</figure> </figure>
</div> </div>

View File

@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css"> <link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css">
<link rel="stylesheet" href="/static/css/theme.css">
<title>{{template "title" .}} - {{ .Title }}</title> <title>{{template "title" .}} - {{ .Title }}</title>
</head> </head>
<body> <body>
@ -73,7 +74,11 @@
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="/me"> <a class="navbar-link" href="/me">
<figure class="image is-24x24 mr-2"> <figure class="image is-24x24 mr-2">
{{if gt .CurrentUser.ProfilePhoto.ID 0}}
<img src="/static/photos/{{.User.ProfilePhoto.CroppedFilename}}" class="is-rounded">
{{else}}
<img src="/static/img/shy.png" class="is-rounded has-background-warning"> <img src="/static/img/shy.png" class="is-rounded has-background-warning">
{{end}}
</figure> </figure>
{{.CurrentUser.Username}} {{.CurrentUser.Username}}
</a> </a>

View File

@ -0,0 +1,342 @@
{{define "title"}}Upload a Photo{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
{{if eq .Intent "profile_pic"}}
Upload a Profile Picture
{{else}}
Upload a Photo
{{end}}
</h1>
</div>
</div>
</section>
{{ $User := .CurrentUser }}
<form action="/photo/upload" method="POST" enctype="multipart/form-data">
{{InputCSRF}}
<input type="hidden" name="intent" value="{{.Intent}}">
<div class="block p-4">
<div class="content block">
<p>
You can use this page to upload a new photo to your profile. Please remember
the rules below:
</p>
<ul>
<li>
🤳 <strong>Self pictures only:</strong> you may only upload pictures which depict
<em>you</em> in them. If the picture also contains other people, be sure you
have their consent to post it here!
</li>
<li>
🔞 <strong>Mark whether your picture is explicit:</strong> not all nudists want to
see sexual content or close-up shots of genitalia. If your picture is not a
"normal nude" please check the Explicit box to help the rest of us out!
</li>
<li>
🧑 <strong>Your main profile picture must show your face:</strong> it doesn't have
to be a nude pic but your face needs to be in it. Additional photos uploaded to
your page do not need to require your face in them.
</li>
</ul>
</div>
<div class="columns">
<div class="column">
<div class="card">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
<i class="fa fa-camera pr-2"></i>
Select a Photo
</p>
</header>
<div class="card-content">
<p class="block">
Browse or drag a photo onto this page:
</p>
<div class="field block">
<div class="file has-name is-fullwidth">
<label class="file-label">
<input class="file-input" type="file"
name="file"
id="file"
accept=".jpg,.jpeg,.jpe,.png"
required>
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label">
Choose a file…
</span>
</span>
<span class="file-name" id="fileName">
Select a file
</span>
</label>
</div>
</div>
<div class="box" id="imagePreview" style="display: none">
<h3 class="subtitle">
{{if .NeedsCrop}}Crop image:{{else}}Selected image:{{end}}
</h3>
{{if .NeedsCrop}}
<p class="block">
Select a square crop of this image for your profile picture. The full
image will go among the rest of your photos, and the square version
will be used as your profile pic and avatar.
</p>
{{end}}
<!-- Container of img tags for the selected photo preview. -->
<div id="previewBox" class="block"></div>
<button type="button" class="button block is-info" onclick="resetCrop()">Reset</button>
</div>
<!-- Holder of image crop coordinates in x,y,w,h format. -->
<input type="text" name="crop" id="cropCoords">
</div>
</div>
</div>
<div class="column">
<div class="card">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
<i class="fa fa-pencil pr-2"></i>
Photo Settings
</p>
</header>
<div class="card-content">
<div class="field">
<label class="label" for="caption">Caption</label>
<input type="text" class="input"
name="caption"
id="caption"
placeholder="Caption">
</div>
<div class="field">
<label class="label">Explicit Content</label>
{{if eq .Intent "profile_pic"}}
<span class="has-text-danger">
Your default profile picture should
<strong class="has-text-danger">NOT</strong>
contain explicit content.
</span>
<p class="help">
Your default profile picture is about your face. You can have nudity
in it, too, but not a close-up shot of your genitals or sporting an
erection or engaging in sexual conduct. You can upload pictures like
that to your page, just not as your default profile picture!
</p>
{{else}}
<label class="checkbox">
<input type="checkbox"
name="explicit"
value="true">
This photo contains explicit content
</label>
<p class="help">
Mark this box if this photo contains any explicit content, including an
erect penis, close-up of genitalia, or any depiction of sexual activity.
Use your best judgment. "Normal nudes" such as full body nudes in a
non-sexual context do not need to check this box.
</p>
{{end}}
</div>
<div class="field">
<label class="label">Photo Visibility</label>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="public"
checked>
<strong>Public:</strong> this photo will appear on your profile page
and can be seen by any logged-in user account. It may also appear
on the site-wide Photo Gallery if that option is enabled, below.
</label>
</div>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="friends">
<strong>Friends only:</strong> only users you have added as a friend
can see this photo on your profile page.
</label>
</div>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="private">
<strong>Private:</strong> this photo is not visible to anybody except
for people whom you allow to see your private pictures (not implemented yet!)
</label>
</div>
</div>
<div class="field">
<label class="label">Site Photo Gallery</label>
<label class="checkbox">
<input type="checkbox"
name="gallery"
value="true"
checked>
Show this photo in the site-wide Photo Gallery (public photos only)
</label>
<p class="help">
Leave this box checked and your (public only) photo can appear in the site's
Photo Gallery page. If you uncheck this box, your (public) photo will still
appear on your profile page but not on the site photo gallery. Friends-only
or private photos never appear in the gallery even if this box is checked.
</p>
</div>
<div class="field">
<label class="label">Confirm Upload</label>
<label class="checkbox">
<input type="checkbox"
name="confirm1"
value="true"
required>
I assert that this is a photo <strong>of myself</strong> and that I have
permission to upload this picture.
</label>
{{if eq .Intent "profile_pic"}}
<label class="checkbox">
<input type="checkbox"
name="confirm2"
value="true"
required>
I assert that this picture shows my face and is not explicit
</label>
{{else}}
<input type="hidden" name="confirm2" value="true">
{{end}}
</div>
<div class="field">
<button type="submit" class="button is-primary">Upload Photo</button>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
<!-- image cropper -->
<!-- <script src="/static/js/jquery-3.6.0.min.js"></script> -->
<link rel="stylesheet" href="/static/js/croppr/croppr.min.css">
<script src="/static/js/croppr/croppr.js"></script>
<script type="text/javascript">
var croppr = null;
const usingCroppr = true;
function resetCrop() {
if (croppr !== null) {
croppr.reset();
}
}
window.addEventListener("DOMContentLoaded", (event) => {
let $file = document.querySelector("#file"),
$fileName = document.querySelector("#fileName"),
$hiddenPreview = document.querySelector("#imagePreview"),
$previewBox = document.querySelector("#previewBox"),
$cropField = document.querySelector("#cropCoords");
// Clear the answer in case of page reload.
$cropField.value = "";
$file.addEventListener("change", function() {
let file = this.files[0];
$fileName.innerHTML = file.name;
// Read the image to show the preview on-page.
const reader = new FileReader();
reader.addEventListener("load", () => {
const uploadedImg = reader.result;
$hiddenPreview.style.display = "block";
// Create a new <img> tag the first time.
if (croppr !== null) {
croppr.setImage(uploadedImg);
croppr.reset();
return;
}
// If not using croppr, flush the old img preview out.
if (!usingCroppr) {
$previewBox.innerHTML = "";
}
let img = document.createElement("img");
img.src = uploadedImg;
img.style.display = "block";
img.style.maxWidth = "100%";
img.style.height = "auto";
// Add it to the wrapper div.
$previewBox.appendChild(img);
if (usingCroppr) {
croppr = new Croppr(img, {
aspectRatio: 1,
minSize: [ 32, 32, 'px' ],
returnMode: 'real',
onCropStart: (data) => {
// console.log(data);
},
onCropMove: (data) => {
// console.log(data);
},
onCropEnd: (data) => {
// console.log(data);
$cropField.value = [
data.x, data.y, data.width, data.height
].join(",");
},
onInitialize: (inst) => {
// Populate the default crop value into the form field.
let data = inst.getValue();
$cropField.value = [
data.x, data.y, data.width, data.height
].join(",");
}
});
}
});
reader.readAsDataURL(file);
});
})
</script>
</div>
{{end}}