User Photo Gallery & Management

* Add the user photo gallery for profile pages. Paginated, grid or full (blog
  style) view options. In grid view clicking a photo opens a large modal to
  see it; full view already shows large photos.
* Edit page: can also re-crop and set an existing pic to be your profile pic.
* Delete page: remove photos from the DB and hard drive.
* Photos are cleaned up from disk when not needed, e.g. during a re-crop the
  old cropped photo is removed before the new one replaces it.
* Fixed bug with cropping pictures.
pull/12/head
Noah 2022-08-12 23:11:36 -07:00
parent 60dd396b30
commit cd1b349fcc
22 changed files with 1029 additions and 83 deletions

View File

@ -8,7 +8,7 @@ import (
// Branding // Branding
const ( const (
Title = "gosocial" Title = "nonshy"
Subtitle = "A purpose built social networking app." Subtitle = "A purpose built social networking app."
) )
@ -21,7 +21,7 @@ const (
// Web path where photos are kept. Photos in DB store only their filenames, this // 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. // is the base URL that goes in front. TODO: support setting a CDN URL prefix.
JpegQuality = 90 JpegQuality = 90
PhotoWebPath = "/static/photos/" PhotoWebPath = "/static/photos"
PhotoDiskPath = "./web/static/photos" PhotoDiskPath = "./web/static/photos"
) )

View File

@ -0,0 +1,209 @@
package photo
import (
"net/http"
"strconv"
"git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/models"
pphoto "git.kirsle.net/apps/gosocial/pkg/photo"
"git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates"
)
// Edit controller (/photo/edit?id=N) to change properties about your picture.
func Edit() http.HandlerFunc {
// Reuse the upload page but with an EditPhoto variable.
tmpl := templates.Must("photo/upload.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
photoID, err := strconv.Atoi(r.FormValue("id"))
if err != nil {
session.FlashError(w, r, "Photo 'id' parameter required.")
templates.Redirect(w, "/")
return
}
// Find this photo by ID.
photo, err := models.GetPhoto(uint64(photoID))
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Load the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
templates.Redirect(w, "/")
return
}
// Do we have permission for this photo?
if photo.UserID != currentUser.ID && !currentUser.IsAdmin {
templates.ForbiddenPage(w, r)
return
}
// Are we saving the changes?
if r.Method == http.MethodPost {
var (
caption = r.FormValue("caption")
isExplicit = r.FormValue("explicit") == "true"
isGallery = r.FormValue("gallery") == "true"
visibility = r.FormValue("visibility")
// Profile pic fields
setProfilePic = r.FormValue("intent") == "profile-pic"
crop = pphoto.ParseCropCoords(r.FormValue("crop"))
)
photo.Caption = caption
photo.Explicit = isExplicit
photo.Gallery = isGallery
photo.Visibility = models.PhotoVisibility(visibility)
// Are we cropping ourselves a new profile pic?
log.Error("Profile pic? %+v and crop is: %+v", setProfilePic, crop)
if setProfilePic && crop != nil && len(crop) >= 4 {
cropFilename, err := pphoto.ReCrop(photo.Filename, crop[0], crop[1], crop[2], crop[3])
log.Error("ReCrop got: %s, %s", cropFilename, err)
if err != nil {
session.FlashError(w, r, "Couldn't re-crop for profile picture: %s", err)
} else {
// If there was an old profile pic, remove it from disk.
if photo.CroppedFilename != "" {
pphoto.Delete(photo.CroppedFilename)
}
photo.CroppedFilename = cropFilename
log.Warn("HERE WE SET (%s) ON PHOTO (%+v)", cropFilename, photo)
}
}
log.Error("SAVING PHOTO: %+v", photo)
if err := photo.Save(); err != nil {
session.FlashError(w, r, "Couldn't save photo: %s", err)
}
// Set their profile pic to this one.
currentUser.ProfilePhoto = *photo
log.Error("Set user ProfilePhotoID=%d", photo.ID)
if err := currentUser.Save(); err != nil {
session.FlashError(w, r, "Couldn't save user: %s", err)
}
// Flash success.
session.Flash(w, r, "Photo settings updated!")
// Whose photo gallery to redirect to? if admin editing a user's photo,
// go back to the owner's gallery instead of our own.
if photo.UserID != currentUser.ID {
if owner, err := models.GetUser(photo.UserID); err == nil {
templates.Redirect(w, "/photo/u/"+owner.Username)
return
}
}
// Return the user to their gallery.
templates.Redirect(w, "/photo/u/"+currentUser.Username)
return
}
var vars = map[string]interface{}{
"EditPhoto": photo,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// Delete controller (/photo/Delete?id=N) to change properties about your picture.
func Delete() http.HandlerFunc {
// Reuse the upload page but with an EditPhoto variable.
tmpl := templates.Must("photo/delete.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
photoID, err := strconv.Atoi(r.FormValue("id"))
if err != nil {
session.FlashError(w, r, "Photo 'id' parameter required.")
templates.Redirect(w, "/")
return
}
// Find this photo by ID.
photo, err := models.GetPhoto(uint64(photoID))
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Load the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
templates.Redirect(w, "/")
return
}
// Do we have permission for this photo?
if photo.UserID != currentUser.ID && !currentUser.IsAdmin {
templates.ForbiddenPage(w, r)
return
}
// Confirm deletion?
if r.Method == http.MethodPost {
confirm := r.PostFormValue("confirm") == "true"
if !confirm {
session.FlashError(w, r, "Confirm you want to delete this photo.")
templates.Redirect(w, r.URL.Path)
return
}
// Remove the images from disk.
for _, filename := range []string{
photo.Filename,
photo.CroppedFilename,
} {
if len(filename) > 0 {
if err := pphoto.Delete(filename); err != nil {
log.Error("Delete Photo: couldn't remove file from disk: %s: %s", filename, err)
}
}
}
if err := photo.Delete(); err != nil {
session.FlashError(w, r, "Couldn't delete photo: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
session.Flash(w, r, "Photo deleted!")
// Whose photo gallery to redirect to? if admin editing a user's photo,
// go back to the owner's gallery instead of our own.
if photo.UserID != currentUser.ID {
if owner, err := models.GetUser(photo.UserID); err == nil {
templates.Redirect(w, "/photo/u/"+owner.Username)
return
}
}
// Return the user to their gallery.
templates.Redirect(w, "/photo/u/"+currentUser.Username)
}
var vars = map[string]interface{}{
"Photo": photo,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -6,8 +6,6 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings"
"git.kirsle.net/apps/gosocial/pkg/log" "git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/models" "git.kirsle.net/apps/gosocial/pkg/models"
@ -59,18 +57,8 @@ func Upload() http.HandlerFunc {
// Parse and validate crop coordinates. // Parse and validate crop coordinates.
var crop []int var crop []int
if len(cropCoords) > 0 { if vars["NeedsCrop"] == true {
aints := strings.Split(cropCoords, ",") crop = photo.ParseCropCoords(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) log.Error("parsed crop coords: %+v", crop)
@ -127,12 +115,12 @@ func Upload() http.HandlerFunc {
// Are we uploading a profile pic? If so, set the user's pic now. // Are we uploading a profile pic? If so, set the user's pic now.
if vars["Intent"] == "profile_pic" { if vars["Intent"] == "profile_pic" {
log.Info("User %s is setting their profile picture", user.Username) log.Info("User %s is setting their profile picture", user.Username)
user.ProfilePhotoID = p.ID user.ProfilePhoto = *p
user.Save() user.Save()
} }
session.Flash(w, r, "Your photo has been uploaded successfully.") session.Flash(w, r, "Your photo has been uploaded successfully.")
templates.Redirect(w, r.URL.Path) templates.Redirect(w, "/photo/u/"+user.Username)
return return
} }

View File

@ -0,0 +1,77 @@
package photo
import (
"net/http"
"regexp"
"git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates"
)
var UserPhotosRegexp = regexp.MustCompile(`^/photo/u/([^@]+?)$`)
// UserPhotos controller (/photo/u/:username) to view a user's gallery or manage if it's yourself.
func UserPhotos() http.HandlerFunc {
tmpl := templates.Must("photo/user_photos.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
var (
viewStyle = r.FormValue("view") // cards (default), full
)
if viewStyle != "full" {
viewStyle = "cards"
}
// Parse the username out of the URL parameters.
var username string
m := UserPhotosRegexp.FindStringSubmatch(r.URL.Path)
if m != nil {
username = m[1]
}
// Find this user.
user, err := models.FindUser(username)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Load the current user in case they are viewing their own page.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
}
var isOwnPhotos = currentUser.ID == user.ID
// What set of visibilities to query?
visibility := []models.PhotoVisibility{models.PhotoPublic}
if isOwnPhotos || currentUser.IsAdmin {
visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate)
}
// Get the page of photos.
pager := &models.Pagination{
Page: 1,
PerPage: 8,
Sort: "created_at desc",
}
pager.ParsePage(r)
log.Error("Pager: %+v", pager)
photos, err := models.PaginateUserPhotos(user.ID, visibility, pager)
var vars = map[string]interface{}{
"IsOwnPhotos": currentUser.ID == user.ID,
"User": user,
"Photos": photos,
"Pager": pager,
"ViewStyle": viewStyle,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

76
pkg/models/pagination.go Normal file
View File

@ -0,0 +1,76 @@
package models
import (
"net/http"
"strconv"
"git.kirsle.net/apps/gosocial/pkg/log"
)
// Pagination result object.
type Pagination struct {
Page int
PerPage int
Pages int
Total int64
Sort string
}
// Page for Iter.
type Page struct {
Page int
IsCurrent bool
}
// Load the page from form or query parameters.
func (p *Pagination) ParsePage(r *http.Request) {
raw := r.FormValue("page")
a, err := strconv.Atoi(raw)
log.Debug("ParsePage: %s %d err=%s", raw, a, err)
if err == nil {
if a < 0 {
a = 1
}
p.Page = a
log.Warn("set page1=%+v =XXXXX%d", p, a)
}
log.Warn("set page=%+v", p)
}
// Iter the pages, for templates.
func (p *Pagination) Iter() []Page {
var pages = []Page{}
for i := 1; i <= p.Pages; i++ {
pages = append(pages, Page{
Page: i,
IsCurrent: i == p.Page,
})
}
return pages
}
func (p *Pagination) GetOffset() int {
return (p.Page - 1) * p.PerPage
}
func (p *Pagination) HasNext() bool {
return p.Page < p.Pages
}
func (p *Pagination) HasPrevious() bool {
return p.Page > 1
}
func (p *Pagination) Next() int {
if p.Page >= p.Pages {
return p.Pages
}
return p.Page + 1
}
func (p *Pagination) Previous() int {
if p.Page > 1 {
return p.Page - 1
}
return 1
}

View File

@ -2,6 +2,7 @@ package models
import ( import (
"errors" "errors"
"math"
"time" "time"
) )
@ -49,3 +50,47 @@ func CreatePhoto(tmpl Photo) (*Photo, error) {
result := DB.Create(p) result := DB.Create(p)
return p, result.Error return p, result.Error
} }
// GetPhoto by ID.
func GetPhoto(id uint64) (*Photo, error) {
p := &Photo{}
result := DB.First(&p, id)
return p, result.Error
}
/*
PaginateUserPhotos gets a page of photos belonging to a user ID.
*/
func PaginateUserPhotos(userID uint64, visibility []PhotoVisibility, pager *Pagination) ([]*Photo, error) {
var p = []*Photo{}
query := DB.Where(
"user_id = ? AND visibility IN ?",
userID,
visibility,
).Order(
pager.Sort,
)
// Get the total count.
query.Model(&Photo{}).Count(&pager.Total)
pager.Pages = int(math.Ceil(float64(pager.Total) / float64(pager.PerPage)))
result := query.Offset(
pager.GetOffset(),
).Limit(pager.PerPage).Find(&p)
return p, result.Error
}
// Save photo.
func (p *Photo) Save() error {
result := DB.Save(p)
return result.Error
}
// Delete photo.
func (p *Photo) Delete() error {
result := DB.Delete(p)
return result.Error
}

View File

@ -6,7 +6,6 @@ import "time"
type ProfileField struct { type ProfileField struct {
ID uint64 `gorm:"primaryKey"` ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"` UserID uint64 `gorm:"index"`
User User
Name string `gorm:"index"` Name string `gorm:"index"`
Value string `gorm:"index"` Value string `gorm:"index"`
CreatedAt time.Time CreatedAt time.Time

View File

@ -1,6 +1,8 @@
package models package models
import ( import (
"bytes"
"encoding/json"
"errors" "errors"
"strings" "strings"
"time" "time"
@ -8,7 +10,7 @@ import (
"git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/log" "git.kirsle.net/apps/gosocial/pkg/log"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm/clause" "gorm.io/gorm"
) )
// User account table. // User account table.
@ -31,7 +33,12 @@ type User struct {
// Relational tables. // Relational tables.
ProfileField []ProfileField ProfileField []ProfileField
ProfilePhotoID uint64 ProfilePhotoID uint64
ProfilePhoto Photo ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"`
}
// Preload related tables for the user (classmethod).
func (u *User) Preload() *gorm.DB {
return DB.Preload("ProfileField").Preload("ProfilePhoto")
} }
// UserStatus options. // UserStatus options.
@ -68,7 +75,7 @@ func CreateUser(username, email, password string) (*User, error) {
// GetUser by ID. // GetUser by ID.
func GetUser(userId uint64) (*User, error) { func GetUser(userId uint64) (*User, error) {
user := &User{} user := &User{}
result := DB.Preload(clause.Associations).First(&user, userId) result := user.Preload().First(&user, userId)
return user, result.Error return user, result.Error
} }
@ -80,10 +87,10 @@ func FindUser(username string) (*User, error) {
u := &User{} u := &User{}
if strings.ContainsRune(username, '@') { if strings.ContainsRune(username, '@') {
result := DB.Preload(clause.Associations).Where("email = ?", username).Limit(1).First(u) result := u.Preload().Where("email = ?", username).Limit(1).First(u)
return u, result.Error return u, result.Error
} }
result := DB.Preload(clause.Associations).Where("username = ?", username).Limit(1).First(u) result := u.Preload().Where("username = ?", username).Limit(1).First(u)
return u, result.Error return u, result.Error
} }
@ -157,3 +164,14 @@ func (u *User) Save() error {
result := DB.Save(u) result := DB.Save(u)
return result.Error return result.Error
} }
// Print user object as pretty JSON.
func (u *User) Print() string {
var (
buf bytes.Buffer
enc = json.NewEncoder(&buf)
)
enc.SetIndent("", " ")
enc.Encode(u)
return buf.String()
}

View File

@ -39,6 +39,11 @@ func DiskPath(filename string) string {
return config.PhotoDiskPath + "/" + filename return config.PhotoDiskPath + "/" + filename
} }
// URLPath returns the public HTTP path to a photo. May be relative like "/static/photos" or could be a full CDN.
func URLPath(filename string) string {
return config.PhotoWebPath + "/" + filename
}
/* /*
EnsurePath makes sure the local './web/static/photos/' path is ready EnsurePath makes sure the local './web/static/photos/' path is ready
to write an image to, taking into account path parameters in the to write an image to, taking into account path parameters in the

View File

@ -9,6 +9,9 @@ import (
"image/png" "image/png"
"io" "io"
"os" "os"
"path/filepath"
"strconv"
"strings"
"git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/log" "git.kirsle.net/apps/gosocial/pkg/log"
@ -62,23 +65,20 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) {
// Read the config to get the image width. // Read the config to get the image width.
reader.Seek(0, io.SeekStart) reader.Seek(0, io.SeekStart)
var width, height int var width, height = origImage.Bounds().Max.X, origImage.Bounds().Max.Y
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) // Find the longest edge, if it's too large (over 1280px)
// cap it to the max and scale the other dimension proportionally. // 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) log.Debug("UploadPhoto: taking a w=%d by h=%d image to name it %s", width, height, filename)
if width >= height { if width >= height {
log.Debug("Its width(%d) is >= its height (%d)", width, height)
if width > config.MaxPhotoWidth { if width > config.MaxPhotoWidth {
newWidth := config.MaxPhotoWidth newWidth := config.MaxPhotoWidth
log.Debug("(%d / %d) * %d", width, height, newWidth) log.Debug("\tnewWidth=%d", newWidth)
log.Debug("\tnewHeight=(%d / %d) * %d", width, height, newWidth)
height = int((float64(height) / float64(width)) * float64(newWidth)) height = int((float64(height) / float64(width)) * float64(newWidth))
width = newWidth width = newWidth
log.Debug("Its longest is width, scale to %sx%s", width, height) log.Debug("Its longest is width, scale to %dx%d", width, height)
} }
} else { } else {
if height > config.MaxPhotoWidth { if height > config.MaxPhotoWidth {
@ -107,12 +107,7 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) {
w = cfg.Crop[2] w = cfg.Crop[2]
h = cfg.Crop[3] h = cfg.Crop[3]
) )
croppedImg := Crop(origImage, image.Rect( croppedImg := Crop(origImage, x, y, w, h)
x,
y,
w,
h,
))
// Write that to disk, too. // Write that to disk, too.
log.Debug("Writing cropped image to disk: %s", cropFilename) log.Debug("Writing cropped image to disk: %s", cropFilename)
@ -133,19 +128,83 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) {
// scaled := Scale(src, image.Rect(0, 0, 200, 200), draw.ApproxBiLinear) // scaled := Scale(src, image.Rect(0, 0, 200, 200), draw.ApproxBiLinear)
func Scale(src image.Image, rect image.Rectangle, scale draw.Scaler) image.Image { func Scale(src image.Image, rect image.Rectangle, scale draw.Scaler) image.Image {
dst := image.NewRGBA(rect) dst := image.NewRGBA(rect)
scale.Scale(dst, rect, src, src.Bounds(), draw.Over, nil) copyRect := image.Rect(
rect.Min.X,
rect.Min.Y,
rect.Min.X+rect.Max.X,
rect.Min.Y+rect.Max.Y,
)
scale.Scale(dst, copyRect, src, src.Bounds(), draw.Over, nil)
return dst return dst
} }
// Crop an image, returning the new image. Example: // Crop an image, returning the new image. Example:
// //
// cropped := Crop() // cropped := Crop()
func Crop(src image.Image, rect image.Rectangle) image.Image { func Crop(src image.Image, x, y, w, h int) image.Image {
dst := image.NewRGBA(rect) dst := image.NewRGBA(image.Rect(0, 0, w, h))
draw.Copy(dst, image.Point{}, src, rect, draw.Over, nil) srcrect := image.Rect(x, y, x+w, y+h)
draw.Copy(dst, image.ZP, src, srcrect, draw.Over, nil)
return dst return dst
} }
// ReCrop an image, loading the original image from disk. Returns the newly created filename.
func ReCrop(filename string, x, y, w, h int) (string, error) {
var (
ext = filepath.Ext(filename)
cropFilename = NewFilename(ext)
)
fh, err := os.Open(DiskPath(filename))
if err != nil {
return "", err
}
// Decode the image.
var img image.Image
switch ext {
case ".jpg":
img, err = jpeg.Decode(fh)
if err != nil {
return "", err
}
case ".png":
img, err = png.Decode(fh)
if err != nil {
return "", err
}
default:
return "", errors.New("unsupported file type")
}
// Crop it.
croppedImg := Crop(img, x, y, w, h)
// Write it.
err = ToDisk(cropFilename, ext, croppedImg)
return cropFilename, err
}
// ParseCropCoords splits a string of x,y,w,h values into proper crop coordinates, or nil.
func ParseCropCoords(coords string) []int {
// Parse and validate crop coordinates.
var crop []int
if len(coords) > 0 {
aints := strings.Split(coords, ",")
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", coords, i, err)
}
}
}
}
return crop
}
// ToDisk commits a photo image to disk in the right file format. // 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/" // Filename is like NewFilename() and it goes to e.g. "./web/static/photos/"
@ -171,3 +230,8 @@ func ToDisk(filename string, extension string, img image.Image) error {
return nil return nil
} }
// Delete a photo from disk.
func Delete(filename string) error {
return os.Remove(DiskPath(filename))
}

View File

@ -26,6 +26,9 @@ func New() http.Handler {
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())) mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload()))
mux.Handle("/photo/u/", middleware.LoginRequired(photo.UserPhotos()))
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete()))
// JSON API endpoints. // JSON API endpoints.
mux.HandleFunc("/v1/version", api.Version()) mux.HandleFunc("/v1/version", api.Version())

View File

@ -9,6 +9,11 @@ var NotFoundPage = func() http.HandlerFunc {
return MakeErrorPage("Not Found", "The page you requested was not here.", http.StatusNotFound) return MakeErrorPage("Not Found", "The page you requested was not here.", http.StatusNotFound)
}() }()
// ForbiddenPage is an HTTP handler for 404 pages.
var ForbiddenPage = func() http.HandlerFunc {
return MakeErrorPage("Forbidden", "You do not have permission for this page.", http.StatusForbidden)
}()
func MakeErrorPage(header string, message string, statusCode int) http.HandlerFunc { func MakeErrorPage(header string, message string, statusCode int) http.HandlerFunc {
tmpl := Must("errors/error.html") tmpl := Must("errors/error.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@ -9,6 +9,7 @@ import (
"git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/markdown" "git.kirsle.net/apps/gosocial/pkg/markdown"
"git.kirsle.net/apps/gosocial/pkg/photo"
"git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/utility" "git.kirsle.net/apps/gosocial/pkg/utility"
) )
@ -21,6 +22,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
"ComputeAge": utility.Age, "ComputeAge": utility.Age,
"Split": strings.Split, "Split": strings.Split,
"ToMarkdown": ToMarkdown, "ToMarkdown": ToMarkdown,
"PhotoURL": photo.URLPath,
} }
} }

View File

@ -17,6 +17,8 @@ func MergeVars(r *http.Request, m map[string]interface{}) {
if r == nil { if r == nil {
return return
} }
m["Request"] = r
} }
// MergeUserVars mixes in global template variables: LoggedIn and CurrentUser. The http.Request is optional. // MergeUserVars mixes in global template variables: LoggedIn and CurrentUser. The http.Request is optional.

View File

@ -18,4 +18,11 @@
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
}
/* Photo modals in addition to Bulma .modal */
.photo-modal {
width: auto !important;
max-width: fit-content;
max-height: fit-content;
} }

View File

@ -1,22 +1,29 @@
// Hamburger menu script for mobile. // Hamburger menu script for mobile.
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Get all "navbar-burger" elements // Get all "navbar-burger" elements
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0); const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
// Add a click event on each of them // Add a click event on each of them
$navbarBurgers.forEach( el => { $navbarBurgers.forEach( el => {
el.addEventListener('click', () => { el.addEventListener('click', () => {
// Get the target from the "data-target" attribute // Get the target from the "data-target" attribute
const target = el.dataset.target; const target = el.dataset.target;
const $target = document.getElementById(target); const $target = document.getElementById(target);
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu" // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
el.classList.toggle('is-active'); el.classList.toggle('is-active');
$target.classList.toggle('is-active'); $target.classList.toggle('is-active');
});
}); });
});
});
// Common event handlers for bulma modals.
(document.querySelectorAll(".modal-background, .modal-close, .photo-modal") || []).forEach(node => {
const target = node.closest(".modal");
node.addEventListener("click", () => {
target.classList.remove("is-active");
})
})
});

View File

@ -19,11 +19,42 @@
<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>
<li><a href="/photo/upload">Upload Photos</a></li> <a href="/u/{{.CurrentUser.Username}}">
<li><a href="/settings">Settings</a></li> <span class="icon"><i class="fa fa-user"></i></span>
<li><a href="/logout">Log out</a></li> My Profile
<li><a href="/account/delete">Delete account</a></li> </a>
</li>
<li>
<a href="/photo/u/{{.CurrentUser.Username}}">
<span class="icon"><i class="fa fa-image"></i></span>
My Photos
</a>
</li>
<li>
<a href="/photo/upload">
<span class="icon"><i class="fa fa-upload"></i></span>
Upload Photos
</a>
</li>
<li>
<a href="/settings">
<span class="icon"><i class="fa fa-edit"></i></span>
Edit Profile &amp; Settings
</a>
</li>
<li>
<a href="/logout">
<span class="icon"><i class="fa fa-arrow-right-from-bracket"></i></span>
Log out
</a>
</li>
<li>
<a href="/account/delete">
<span class="icon"><i class="fa fa-trash"></i></span>
Delete account
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
{{define "title"}}User Settings{{end}} {{define "title"}}{{.User.Username}}{{end}}
{{define "content"}} {{define "content"}}
<div class="container"> <div class="container">
<section class="hero is-info is-bold"> <section class="hero is-info is-bold">
@ -7,7 +7,7 @@
<div class="columns"> <div class="columns">
<div class="column is-narrow"> <div class="column is-narrow">
<figure class="profile-photo"> <figure class="profile-photo">
{{if .User.ProfilePhoto}} {{if .User.ProfilePhoto.ID}}
<img src="/static/photos/{{.User.ProfilePhoto.CroppedFilename}}"> <img src="/static/photos/{{.User.ProfilePhoto.CroppedFilename}}">
{{else}} {{else}}
<img class="is-rounded" src="/static/img/shy.png"> <img class="is-rounded" src="/static/img/shy.png">
@ -138,6 +138,27 @@
</section> </section>
<div class="block p-4"> <div class="block p-4">
<div class="tabs is-boxed">
<ul>
<li class="is-active">
<a>
<span class="icon is-small">
<i class="fa fa-user"></i>
</span>
<span>Profile</span>
</a>
</li>
<li>
<a href="/photo/u/{{.User.Username}}">
<span class="icon is-small">
<i class="fa fa-image"></i>
</span>
<span>Photos</span>
</a>
</li>
</ul>
</div>
<div class="columns"> <div class="columns">
<div class="column is-two-thirds"> <div class="column is-two-thirds">

View File

@ -75,7 +75,7 @@
<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}} {{if gt .CurrentUser.ProfilePhoto.ID 0}}
<img src="/static/photos/{{.User.ProfilePhoto.CroppedFilename}}" class="is-rounded"> <img src="{{PhotoURL .CurrentUser.ProfilePhoto.CroppedFilename}}" class="is-rounded">
{{else}} {{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}} {{end}}

View File

@ -0,0 +1,48 @@
{{define "title"}}Delete Photo{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
Delete Photo
</h1>
</div>
</div>
</section>
<div class="block p-4">
<div class="level">
<div class="level-item">
<div class="card" style="max-width: 512px">
<header class="card-header has-background-danger">
<p class="card-header-title has-text-light">
<span class="icon"><i class="fa fa-trash"></i></span>
Delete Photo
</p>
</header>
<div class="card-content">
<form method="POST" action="/photo/delete">
{{InputCSRF}}
<input type="hidden" name="id" value="{{.Photo.ID}}">
<input type="hidden" name="confirm" value="true">
<div class="image block">
<img src="{{PhotoURL .Photo.Filename}}">
</div>
<div class="block">
Are you sure you want to delete this photo?
</div>
<div class="block has-text-center">
<button type="submit" class="button is-danger">Delete Photo</button>
<button type="button" class="button" onclick="history.back()">Cancel</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@ -5,7 +5,9 @@
<div class="hero-body"> <div class="hero-body">
<div class="container"> <div class="container">
<h1 class="title"> <h1 class="title">
{{if eq .Intent "profile_pic"}} {{if .EditPhoto}}
Edit Photo
{{else if eq .Intent "profile_pic"}}
Upload a Profile Picture Upload a Profile Picture
{{else}} {{else}}
Upload a Photo Upload a Photo
@ -17,11 +19,18 @@
{{ $User := .CurrentUser }} {{ $User := .CurrentUser }}
{{if .EditPhoto}}
<form action="/photo/edit" method="POST">
<input type="hidden" name="id" value="{{.EditPhoto.ID}}">
{{else}}
<form action="/photo/upload" method="POST" enctype="multipart/form-data"> <form action="/photo/upload" method="POST" enctype="multipart/form-data">
{{end}}
{{InputCSRF}} {{InputCSRF}}
<input type="hidden" name="intent" value="{{.Intent}}"> <input type="hidden" id="intent" name="intent" value="{{.Intent}}">
<div class="block p-4"> <div class="block p-4">
<!-- Upload disclaimers, but not if editing a photo -->
{{if not .EditPhoto}}
<div class="content block"> <div class="content block">
<p> <p>
You can use this page to upload a new photo to your profile. Please remember You can use this page to upload a new photo to your profile. Please remember
@ -46,6 +55,7 @@
</li> </li>
</ul> </ul>
</div> </div>
{{end}}
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
@ -54,10 +64,16 @@
<header class="card-header has-background-link"> <header class="card-header has-background-link">
<p class="card-header-title has-text-light"> <p class="card-header-title has-text-light">
<i class="fa fa-camera pr-2"></i> <i class="fa fa-camera pr-2"></i>
{{if .EditPhoto}}
Your Photo
{{else}}
Select a Photo Select a Photo
{{end}}
</p> </p>
</header> </header>
<!-- Upload field, not when editing -->
{{if not .EditPhoto}}
<div class="card-content"> <div class="card-content">
<p class="block"> <p class="block">
Browse or drag a photo onto this page: Browse or drag a photo onto this page:
@ -102,15 +118,40 @@
<!-- Container of img tags for the selected photo preview. --> <!-- Container of img tags for the selected photo preview. -->
<div id="previewBox" class="block"></div> <div id="previewBox" class="block"></div>
{{if .NeedsCrop}}
<div class="block has-text-centered">
<button type="button" class="button block is-info" onclick="resetCrop()">Reset</button>
</div>
{{end}}
</div>
<!-- Holder of image crop coordinates in x,y,w,h format. -->
<input type="hidden" name="crop" id="cropCoords">
</div>
{{else}}<!-- when .EditPhoto -->
<div class="card-content">
<figure id="editphoto-preview" class="image block">
<img src="{{PhotoURL .EditPhoto.Filename}}" id="editphoto-img">
</figure>
<div class="block has-text-centered" id="editphoto-begin-crop">
<button type="button" class="button" onclick="setProfilePhoto()">
<span class="icon"><i class="fa fa-crop-simple"></i></span>
<span>Set this as my profile photo (crop image)</span>
</button>
</div>
<div class="block has-text-centered" id="editphoto-cropping" style="display: none">
<button type="button" class="button block is-info" onclick="resetCrop()">Reset</button> <button type="button" class="button block is-info" onclick="resetCrop()">Reset</button>
</div> </div>
<!-- Holder of image crop coordinates in x,y,w,h format. --> <!-- Holder of image crop coordinates in x,y,w,h format. -->
<input type="text" name="crop" id="cropCoords"> <input type="hidden" name="crop" id="cropCoords">
</div> </div>
</div> {{end}}
</div> </div><!-- /card -->
</div><!-- /column -->
<div class="column"> <div class="column">
@ -129,7 +170,8 @@
<input type="text" class="input" <input type="text" class="input"
name="caption" name="caption"
id="caption" id="caption"
placeholder="Caption"> placeholder="Caption"
value="{{.EditPhoto.Caption}}">
</div> </div>
<div class="field"> <div class="field">
@ -151,7 +193,8 @@
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" <input type="checkbox"
name="explicit" name="explicit"
value="true"> value="true"
{{if .EditPhoto.Explicit}}checked{{end}}>
This photo contains explicit content This photo contains explicit content
</label> </label>
<p class="help"> <p class="help">
@ -170,7 +213,7 @@
<input type="radio" <input type="radio"
name="visibility" name="visibility"
value="public" value="public"
checked> {{if or (not .EditPhoto) (eq .EditPhoto.Visibility "public")}}checked{{end}}>
<strong>Public:</strong> this photo will appear on your profile page <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 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. on the site-wide Photo Gallery if that option is enabled, below.
@ -180,7 +223,8 @@
<label class="radio"> <label class="radio">
<input type="radio" <input type="radio"
name="visibility" name="visibility"
value="friends"> value="friends"
{{if eq .EditPhoto.Visibility "friends"}}checked{{end}}>
<strong>Friends only:</strong> only users you have added as a friend <strong>Friends only:</strong> only users you have added as a friend
can see this photo on your profile page. can see this photo on your profile page.
</label> </label>
@ -189,7 +233,8 @@
<label class="radio"> <label class="radio">
<input type="radio" <input type="radio"
name="visibility" name="visibility"
value="private"> value="private"
{{if eq .EditPhoto.Visibility "private"}}checked{{end}}>
<strong>Private:</strong> this photo is not visible to anybody except <strong>Private:</strong> this photo is not visible to anybody except
for people whom you allow to see your private pictures (not implemented yet!) for people whom you allow to see your private pictures (not implemented yet!)
</label> </label>
@ -202,7 +247,8 @@
<input type="checkbox" <input type="checkbox"
name="gallery" name="gallery"
value="true" value="true"
checked> checked
{{if .EditPhoto.Gallery}}checked{{end}}>
Show this photo in the site-wide Photo Gallery (public photos only) Show this photo in the site-wide Photo Gallery (public photos only)
</label> </label>
<p class="help"> <p class="help">
@ -213,6 +259,7 @@
</p> </p>
</div> </div>
{{if not .EditPhoto}}
<div class="field"> <div class="field">
<label class="label">Confirm Upload</label> <label class="label">Confirm Upload</label>
<label class="checkbox"> <label class="checkbox">
@ -236,9 +283,16 @@
<input type="hidden" name="confirm2" value="true"> <input type="hidden" name="confirm2" value="true">
{{end}} {{end}}
</div> </div>
{{end}}
<div class="field"> <div class="field">
<button type="submit" class="button is-primary">Upload Photo</button> <button type="submit" class="button is-primary">
{{if .EditPhoto}}
Save Changes
{{else}}
Upload Photo
{{end}}
</button>
</div> </div>
</div> </div>
@ -256,7 +310,7 @@
<script type="text/javascript"> <script type="text/javascript">
var croppr = null; var croppr = null;
const usingCroppr = true; const usingCroppr = {{if .NeedsCrop}}true{{else}}false{{end}};
function resetCrop() { function resetCrop() {
if (croppr !== null) { if (croppr !== null) {
@ -264,6 +318,7 @@
} }
} }
{{if not .EditPhoto}}
window.addEventListener("DOMContentLoaded", (event) => { window.addEventListener("DOMContentLoaded", (event) => {
let $file = document.querySelector("#file"), let $file = document.querySelector("#file"),
$fileName = document.querySelector("#fileName"), $fileName = document.querySelector("#fileName"),
@ -335,7 +390,51 @@
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
}) });
{{end}}
// EditPhoto only: a button to crop their photo to set as a profile pic.
function setProfilePhoto() {
let $begin = document.querySelector("#editphoto-begin-crop"),
$cropRow = document.querySelector("#editphoto-cropping"),
$preview = document.querySelector("#editphoto-preview")
$cropField = document.querySelector("#cropCoords"),
$intent = document.querySelector("#intent");
img = document.querySelector("#editphoto-img");
// Toggle the button display, from begin crop to the crop reset button.
$begin.style.display = 'none';
$cropRow.style.display = 'block';
// Set intent to profile-pic so when the form posts the crop coords will
// create a new profile pic for this user.
$intent.value = "profile-pic";
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(",");
}
});
}
</script> </script>
</div> </div>

View File

@ -0,0 +1,240 @@
{{define "title"}}Photos of {{.User.Username}}{{end}}
<!-- Reusable card body -->
{{define "card-body"}}
<div>
<small class="has-text-grey">Uploaded {{.CreatedAt.Format "Jan _2 2006 15:04:05"}}</small>
</div>
<div class="mt-2">
{{if .Explicit}}
<span class="tag is-danger is-light">
<span class="icon"><i class="fa fa-fire"></i></span>
<span>Explicit</span>
</span>
{{end}}
<span class="tag is-info is-light">
<span class="icon"><i class="fa fa-eye"></i></span>
<span>
{{if eq .Visibility "public"}}Public{{end}}
{{if eq .Visibility "private"}}Private{{end}}
{{if eq .Visibility "friends"}}Friends{{end}}
</span>
</span>
{{if .Gallery}}
<span class="tag is-success is-light">
<span class="icon"><i class="fa fa-image"></i></span>
<span>Gallery</span>
</span>
{{end}}
</div>
{{end}}
<!-- Reusable card footer -->
{{define "card-footer"}}
<footer class="card-footer">
<a class="card-footer-item" href="/photo/edit?id={{.ID}}">
<span class="icon"><i class="fa fa-edit"></i></span>
Edit
</a>
<a class="card-footer-item has-text-danger" href="/photo/delete?id={{.ID}}">
<span class="icon"><i class="fa fa-trash"></i></span>
Delete
</a>
</footer>
{{end}}
<!-- Reusable pager -->
{{define "pager"}}
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?view={{.ViewStyle}}&page={{.Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?view={{.ViewStyle}}&page={{.Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{$Root := .}}
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?view={{$Root.ViewStyle}}&page={{.Page}}">
{{.Page}}
</a>
</li>
{{end}}
</ul>
</nav>
{{end}}
<!-- Main content template -->
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="level">
<div class="level-left">
<h1 class="title">
Photos of {{.User.Username}}
</h1>
</div>
{{if .IsOwnPhotos}}
<div class="level-right">
<div>
<a href="/photo/upload" class="button">
<span class="icon"><i class="fa fa-upload"></i></span>
<span>Upload Photos</span>
</a>
</div>
</div>
{{end}}
</div>
</div>
</section>
<!-- ugly hack.. needed by the card-footers later below. -->
{{$Root := .}}
<div class="block p-4">
<div class="tabs is-boxed">
<ul>
<li>
<a href="/u/{{.User.Username}}">
<span class="icon is-small">
<i class="fa fa-user"></i>
</span>
<span>Profile</span>
</a>
</li>
<li class="is-active">
<a>
<span class="icon is-small">
<i class="fa fa-image"></i>
</span>
<span>Photos</span>
</a>
</li>
</ul>
</div>
<!-- Photo Detail Modal -->
<div class="modal" id="detail-modal">
<div class="modal-background"></div>
<div class="modal-content photo-modal">
<div class="image is-fullwidth">
<img id="detailImg">
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</div>
<div class="block">
<div class="level">
<div class="level-left">
<div class="level-item">
<span>
Found <strong>{{.Pager.Total}}</strong> photos (page {{.Pager.Page}} of {{.Pager.Pages}}).
</span>
</div>
</div>
<div class="level-right">
<div class="level-item">
<div class="tabs is-toggle is-small">
<ul>
<li{{if eq .ViewStyle "cards"}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?view=cards">Cards</a>
</li>
<li{{if eq .ViewStyle "full"}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?view=full">Full</a>
</li>
</ul>
</div>
</div>
</div>
</div>
{{template "pager" .}}
<!-- "Full" view style? (blog style) -->
{{if eq .ViewStyle "full"}}
{{range .Photos}}
<div class="card block">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
<span class="icon">
<i class="fa fa-image"></i>
</span>
{{or .Caption "Photo"}}
</p>
</header>
<div class="card-content">
<img src="{{PhotoURL .Filename}}">
{{template "card-body" .}}
</div>
{{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}}
{{template "card-footer" .}}
{{end}}
</div>
{{end}}
<!-- "Cards" style (default) -->
{{else}}
<div class="columns is-multiline">
{{range .Photos}}
<div class="column is-one-quarter-desktop is-half-tablet">
<div class="card">
<div class="card-image">
<figure class="image">
<a href="{{PhotoURL .Filename}}" target="_blank"
class="js-modal-trigger" data-target="detail-modal"
onclick="setModalImage(this.href)">
<img src="{{PhotoURL .Filename}}">
</a>
</figure>
</div>
<div class="card-content">
{{if .Caption}}
{{.Caption}}
{{else}}<em>No caption</em>{{end}}
{{template "card-body" .}}
</div>
{{if or $Root.IsOwnPhotos $Root.CurrentUser.IsAdmin}}
{{template "card-footer" .}}
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}<!-- ViewStyle -->
{{template "pager" .}}
</div>
</div>
</div>
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => {
// Get our modal to trigger it on click of a detail img.
let $modal = document.querySelector("#detail-modal");
document.querySelectorAll(".js-modal-trigger").forEach(node => {
node.addEventListener("click", (e) => {
e.preventDefault();
setModalImage(node.href);
$modal.classList.add("is-active");
})
});
});
function setModalImage(url) {
let $modalImg = document.querySelector("#detailImg");
$modalImg.src = url;
return false;
}
</script>
{{end}}