feat(07-03): add avatars, mail templates, and password-change command
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,8 @@ package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -20,12 +22,18 @@ import (
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
"git.golem15.com/golem15/summercms/lagoon"
|
||||
"git.golem15.com/golem15/summercms/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/postcard"
|
||||
"git.golem15.com/golem15/summercms/surf"
|
||||
"git.golem15.com/golem15/summercms/wire"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const avatarMaxBytes = 4_096_000
|
||||
|
||||
var errNoAvatar = errors.New("golem15.user: no avatar")
|
||||
|
||||
const invalidLoginBody = "Invalid email or password"
|
||||
|
||||
// Login authenticates email and password and returns a JWT plus the user payload.
|
||||
@@ -74,7 +82,7 @@ func Login(app *backpack.App) http.HandlerFunc {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
sendTemplate(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email)
|
||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
||||
}
|
||||
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, true); err != nil {
|
||||
if classes.IsLoginRejected(err) {
|
||||
@@ -231,16 +239,8 @@ func ForgotPassword(app *backpack.App) http.HandlerFunc {
|
||||
if err != nil {
|
||||
slog.Default().Error("golem15.user: issue reset code", "err", err)
|
||||
} else {
|
||||
name := ""
|
||||
if user.Name != nil {
|
||||
name = *user.Name
|
||||
}
|
||||
link := resetLink(app, user.ID, code)
|
||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.restore"), user.Email, map[string]any{
|
||||
"name": name,
|
||||
"link": link,
|
||||
"code": strconv.FormatUint(uint64(user.ID), 10) + "!" + code,
|
||||
})
|
||||
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.restore"), user.Email, mailVars(displayName(user), resetLink(app, user.ID, code), packed))
|
||||
}
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody})
|
||||
@@ -653,7 +653,15 @@ func Register(app *backpack.App) http.HandlerFunc {
|
||||
}
|
||||
ctx := r.Context()
|
||||
if mode == "user" {
|
||||
sendTemplate(ctx, app, mailName(ctx, user, "golem15.user::mail.activate"), user.Email)
|
||||
vars := mailVars(displayName(user), "", "")
|
||||
code, err := classes.IssueActivationCode(ctx, gdb, user)
|
||||
if err != nil {
|
||||
slog.Default().Error("golem15.user: issue activation code", "err", err)
|
||||
} else {
|
||||
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||
vars = mailVars(displayName(user), activationLink(app, user.ID, code), packed)
|
||||
}
|
||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.activate"), user.Email, vars)
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Activation email sent"})
|
||||
return
|
||||
}
|
||||
@@ -809,6 +817,7 @@ func apiArray(ctx context.Context, app *backpack.App, user *models.User) (map[st
|
||||
// feedback_widget_hidden stays false until a feedback plugin listens.
|
||||
"feedback_widget_hidden": false,
|
||||
}
|
||||
applyAvatar(ctx, app, user, payload)
|
||||
if app != nil && app.Events != nil {
|
||||
extra, err := app.Events.Collect(ctx, &classes.GetApiArrayEvent{User: user})
|
||||
if err != nil {
|
||||
@@ -854,6 +863,21 @@ func sendTemplate(ctx context.Context, app *backpack.App, template, to string) {
|
||||
sendTemplateVars(ctx, app, template, to, nil)
|
||||
}
|
||||
|
||||
func displayName(user *models.User) string {
|
||||
if user == nil || user.Name == nil {
|
||||
return ""
|
||||
}
|
||||
return *user.Name
|
||||
}
|
||||
|
||||
func mailVars(name, link, code string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name, "Name": name,
|
||||
"link": link, "Link": link,
|
||||
"code": code, "Code": code,
|
||||
}
|
||||
}
|
||||
|
||||
func sendTemplateVars(ctx context.Context, app *backpack.App, template, to string, vars map[string]any) {
|
||||
if app == nil || to == "" {
|
||||
return
|
||||
@@ -915,6 +939,20 @@ func resetLink(app *backpack.App, id uint, code string) string {
|
||||
return base + "?" + q.Encode()
|
||||
}
|
||||
|
||||
func activationLink(app *backpack.App, id uint, code string) string {
|
||||
base := "/activate"
|
||||
if app != nil && app.Config != nil {
|
||||
if v := strings.TrimSpace(app.Config.String("golem15.user.activation_url_base")); v != "" {
|
||||
base = v
|
||||
} else if appURL := strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/"); appURL != "" {
|
||||
base = appURL + "/activate"
|
||||
}
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("code", strconv.FormatUint(uint64(id), 10)+"!"+code)
|
||||
return base + "?" + q.Encode()
|
||||
}
|
||||
|
||||
func readFields(r *http.Request) (map[string]any, error) {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.Contains(ct, "application/json") {
|
||||
@@ -1004,3 +1042,209 @@ func writeUnauthorized(w http.ResponseWriter) {
|
||||
func writeOpaque500(w http.ResponseWriter) {
|
||||
wire.WriteOpaque500(w)
|
||||
}
|
||||
|
||||
// UploadAvatar stores a sniffed image as the signed-in user's avatar.
|
||||
func UploadAvatar(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, _, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, ok := authenticate(w, r, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
writeValidation(w, map[string][]string{"avatar": {"The avatar field is required."}})
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("avatar")
|
||||
if err != nil {
|
||||
writeValidation(w, map[string][]string{"avatar": {"The avatar field is required."}})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
sniffed := http.DetectContentType(data)
|
||||
if i := strings.IndexByte(sniffed, ';'); i >= 0 {
|
||||
sniffed = strings.TrimSpace(sniffed[:i])
|
||||
}
|
||||
ext, allowed := avatarExt(sniffed)
|
||||
if !allowed {
|
||||
writeValidation(w, map[string][]string{
|
||||
"avatar": {"The avatar must be a file of type: jpeg, jpg, png, webp, gif."},
|
||||
})
|
||||
return
|
||||
}
|
||||
size := int64(len(data))
|
||||
if header != nil && header.Size > size {
|
||||
size = header.Size
|
||||
}
|
||||
if size > avatarMaxBytes {
|
||||
writeValidation(w, map[string][]string{
|
||||
"avatar": {"The avatar must not be greater than 4000 kilobytes."},
|
||||
})
|
||||
return
|
||||
}
|
||||
bucket, ok := app.Lookup[*blob.Bucket]()
|
||||
if !ok || bucket == nil {
|
||||
slog.Default().Error("golem15.user: avatar bucket is not configured")
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if err := deleteAvatar(r.Context(), app, gdb, user); err != nil && !errors.Is(err, errNoAvatar) {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
diskName := hex.EncodeToString(raw) + "." + ext
|
||||
wr, err := bucket.NewWriter(r.Context(), attach.BlobKey(diskName), &blob.WriterOptions{ContentType: sniffed})
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if _, err := wr.Write(data); err != nil {
|
||||
_ = wr.Close()
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if err := wr.Close(); err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
filename := "avatar." + ext
|
||||
if header != nil && strings.TrimSpace(header.Filename) != "" {
|
||||
filename = header.Filename
|
||||
}
|
||||
row := attach.File{
|
||||
DiskName: diskName,
|
||||
FileName: filename,
|
||||
FileSize: int64(len(data)),
|
||||
ContentType: sniffed,
|
||||
Field: "avatar",
|
||||
AttachmentID: strconv.FormatUint(uint64(user.ID), 10),
|
||||
AttachmentType: user.MorphName(),
|
||||
}
|
||||
if err := gdb.WithContext(r.Context()).Create(&row).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
payload, err := apiArray(r.Context(), app, user)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Avatar updated", "user": payload})
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveAvatar deletes the signed-in user's display picture.
|
||||
func RemoveAvatar(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, _, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, ok := authenticate(w, r, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := deleteAvatar(r.Context(), app, gdb, user); err != nil {
|
||||
if errors.Is(err, errNoAvatar) {
|
||||
writeValidation(w, map[string][]string{
|
||||
"avatar": {"Your account has no display picture to remove."},
|
||||
})
|
||||
return
|
||||
}
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
payload, err := apiArray(r.Context(), app, user)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Avatar removed", "user": payload})
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAvatar(ctx context.Context, app *backpack.App, gdb *gorm.DB, user *models.User) error {
|
||||
id := strconv.FormatUint(uint64(user.ID), 10)
|
||||
var n int64
|
||||
if err := gdb.WithContext(ctx).Model(&attach.File{}).
|
||||
Where("attachment_type = ? AND attachment_id = ? AND field = ?", user.MorphName(), id, "avatar").
|
||||
Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return errNoAvatar
|
||||
}
|
||||
var keys []string
|
||||
err := gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return attach.DeleteForOwner(tx, user, id, func(blobKeys []string) error {
|
||||
keys = append(keys, blobKeys...)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bucket, ok := app.Lookup[*blob.Bucket]()
|
||||
if !ok || bucket == nil || len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
return attach.DeleteKeys(ctx, bucket, keys)
|
||||
}
|
||||
|
||||
func applyAvatar(ctx context.Context, app *backpack.App, user *models.User, payload map[string]any) {
|
||||
if app == nil || user == nil || user.ID == 0 {
|
||||
return
|
||||
}
|
||||
gdb, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || gdb == nil {
|
||||
return
|
||||
}
|
||||
var f attach.File
|
||||
err := gdb.WithContext(ctx).
|
||||
Where("attachment_type = ? AND attachment_id = ? AND field = ?", user.MorphName(), strconv.FormatUint(uint64(user.ID), 10), "avatar").
|
||||
Take(&f).Error
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
payload["has_avatar"] = true
|
||||
payload["avatar"] = f.DiskName
|
||||
bucket, ok := app.Lookup[*blob.Bucket]()
|
||||
if !ok || bucket == nil {
|
||||
return
|
||||
}
|
||||
url, err := f.Thumb(ctx, bucket, 128, 128, "auto")
|
||||
if err != nil {
|
||||
slog.Default().Error("golem15.user: avatar thumb", "err", err)
|
||||
payload["avatar_url"] = "/" + attach.BlobKey(f.DiskName)
|
||||
return
|
||||
}
|
||||
payload["avatar_url"] = url
|
||||
}
|
||||
|
||||
func avatarExt(mime string) (string, bool) {
|
||||
switch mime {
|
||||
case "image/jpeg":
|
||||
return "jpg", true
|
||||
case "image/png":
|
||||
return "png", true
|
||||
case "image/gif":
|
||||
return "gif", true
|
||||
case "image/webp":
|
||||
return "webp", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user