Photo Upload & Profile Pictures
Basic photo upload support. Square cropped images still buggy.
This commit is contained in:
parent
c43d052665
commit
b72973e741
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
||||||
/gosocial
|
/gosocial
|
||||||
|
/web/static/photos
|
||||||
database.sqlite
|
database.sqlite
|
||||||
settings.json
|
settings.json
|
||||||
|
|
4
go.mod
4
go.mod
|
@ -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
10
go.sum
|
@ -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=
|
||||||
|
|
|
@ -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
|
||||||
|
|
144
pkg/controller/photo/upload.go
Normal file
144
pkg/controller/photo/upload.go
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
51
pkg/models/photo.go
Normal 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
|
||||||
|
}
|
|
@ -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
62
pkg/photo/filenames.go
Normal 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
173
pkg/photo/upload.go
Normal 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
|
||||||
|
}
|
|
@ -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
21
web/static/css/theme.css
Normal 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;
|
||||||
|
}
|
58
web/static/js/croppr/croppr.css
Normal file
58
web/static/js/croppr/croppr.css
Normal 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;
|
||||||
|
}
|
1189
web/static/js/croppr/croppr.js
Normal file
1189
web/static/js/croppr/croppr.js
Normal file
File diff suppressed because it is too large
Load Diff
1
web/static/js/croppr/croppr.min.css
vendored
Normal file
1
web/static/js/croppr/croppr.min.css
vendored
Normal 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
1
web/static/js/croppr/croppr.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
342
web/templates/photo/upload.html
Normal file
342
web/templates/photo/upload.html
Normal 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}}
|
Reference in New Issue
Block a user