diff --git a/avatar_test.go b/avatar_test.go new file mode 100644 index 0000000..768f52c --- /dev/null +++ b/avatar_test.go @@ -0,0 +1,183 @@ +package user + +import ( + "bytes" + "context" + "encoding/json" + "image" + "image/jpeg" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/controllers" + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/lagoon/attach" + "gocloud.dev/blob" + "gocloud.dev/blob/memblob" +) + +func TestUploadAvatar(t *testing.T) { + app, gdb := sessionApp(t, "0s") + publishAvatarBucket(t, app) + token := loginToken(t, app, gdb, "avatar-upload@example.test") + + ok := postAvatar(t, controllers.UploadAvatar(app), token, "avatar", "face.jpg", jpegBytes(t)) + if ok.Code != http.StatusOK { + t.Fatalf("upload %d %s", ok.Code, ok.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(ok.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["message"] != "Avatar updated" { + t.Fatalf("message = %v", body["message"]) + } + user, _ := body["user"].(map[string]any) + if user["has_avatar"] != true { + t.Fatalf("has_avatar = %v", user["has_avatar"]) + } + url, _ := user["avatar_url"].(string) + if url == "" { + t.Fatalf("avatar_url = %#v", user["avatar_url"]) + } + + svg := postAvatar(t, controllers.UploadAvatar(app), token, "avatar", "pic.svg", []byte(``)) + assertAvatarError(t, svg, "The avatar must be a file of type: jpeg, jpg, png, webp, gif.") + + big := make([]byte, 4_096_001) + copy(big, []byte{0xff, 0xd8, 0xff, 0xe0}) + over := postAvatar(t, controllers.UploadAvatar(app), token, "avatar", "big.jpg", big) + assertAvatarError(t, over, "The avatar must not be greater than 4000 kilobytes.") + + missing := postAvatar(t, controllers.UploadAvatar(app), token, "", "", nil) + assertAvatarError(t, missing, "The avatar field is required.") + + var n int64 + if err := gdb.Model(&attach.File{}).Where("field = ?", "avatar").Count(&n).Error; err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("avatar rows = %d, want 1 after rejected replacements", n) + } +} + +func TestRemoveAvatar(t *testing.T) { + app, gdb := sessionApp(t, "0s") + bucket := publishAvatarBucket(t, app) + token := loginToken(t, app, gdb, "avatar-remove@example.test") + up := postAvatar(t, controllers.UploadAvatar(app), token, "avatar", "face.jpg", jpegBytes(t)) + if up.Code != http.StatusOK { + t.Fatalf("upload %d %s", up.Code, up.Body.String()) + } + var row attach.File + if err := gdb.Where("field = ?", "avatar").Last(&row).Error; err != nil { + t.Fatal(err) + } + key := attach.BlobKey(row.DiskName) + exists, err := bucket.Exists(context.Background(), key) + if err != nil || !exists { + t.Fatalf("original blob exists=%v err=%v", exists, err) + } + + gone := bearer(t, controllers.RemoveAvatar(app), http.MethodPost, "/_user/api/v1/avatar/remove", token) + if gone.Code != http.StatusOK { + t.Fatalf("remove %d %s", gone.Code, gone.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(gone.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["message"] != "Avatar removed" { + t.Fatalf("message = %v", body["message"]) + } + user, _ := body["user"].(map[string]any) + if user["has_avatar"] != false { + t.Fatalf("has_avatar = %v", user["has_avatar"]) + } + var n int64 + if err := gdb.Model(&attach.File{}).Where("disk_name = ?", row.DiskName).Count(&n).Error; err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("system_files rows left = %d", n) + } + exists, err = bucket.Exists(context.Background(), key) + if err != nil { + t.Fatal(err) + } + if exists { + t.Fatal("original blob still present") + } + + again := bearer(t, controllers.RemoveAvatar(app), http.MethodPost, "/_user/api/v1/avatar/remove", token) + const want = `{"error":"Your account has no display picture to remove.","errors":{"avatar":["Your account has no display picture to remove."]}}` + if again.Code != http.StatusUnprocessableEntity || again.Body.String() != want { + t.Fatalf("missing avatar %d %s", again.Code, again.Body.String()) + } +} + +func publishAvatarBucket(t *testing.T, app *backpack.App) *blob.Bucket { + t.Helper() + bucket := memblob.OpenBucket(nil) + t.Cleanup(func() { _ = bucket.Close() }) + if err := attach.Publish(app, bucket); err != nil { + t.Fatal(err) + } + return bucket +} + +func jpegBytes(t *testing.T) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 8, 8)) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func postAvatar(t *testing.T, h http.HandlerFunc, token, field, filename string, body []byte) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + if field != "" { + part, err := w.CreateFormFile(field, filename) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(body); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/avatar", &buf) + req.Host = "example.com" + req.Header.Set("Content-Type", w.FormDataContentType()) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + h(rec, req) + return rec +} + +func assertAvatarError(t *testing.T, rec *httptest.ResponseRecorder, message string) { + t.Helper() + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var body struct { + Error string `json:"error"` + Errors map[string][]string `json:"errors"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Error != message || len(body.Errors["avatar"]) != 1 || body.Errors["avatar"][0] != message { + t.Fatalf("body = %s", rec.Body.String()) + } +} diff --git a/console/require_password_change.go b/console/require_password_change.go new file mode 100644 index 0000000..6fc3388 --- /dev/null +++ b/console/require_password_change.go @@ -0,0 +1,56 @@ +package console + +import ( + "context" + "fmt" + "strings" + + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/classes" + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bonfire" + "gorm.io/gorm" +) + +// RequirePasswordChange forces the named account to set a new password on next login. +func RequirePasswordChange(app *backpack.App) bonfire.Command { + return bonfire.Command{ + Name: "user:require-password-change", + Description: "Force a user to change their password on next login (D-21)", + Args: []bonfire.Arg{{ + Name: "email", + Description: "Account email", + Required: true, + }}, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + email, ok := in.Argument("email") + email = strings.TrimSpace(email) + if !ok || email == "" { + return fmt.Errorf("email is required") + } + if app == nil { + return fmt.Errorf("user:require-password-change: app is nil") + } + gdb, ok := app.Lookup[*gorm.DB]() + if !ok || gdb == nil { + return fmt.Errorf("user:require-password-change: database is not configured") + } + user, err := classes.LookupByEmail(ctx, gdb, email) + if err != nil { + return err + } + if user == nil { + return fmt.Errorf("user %s not found", email) + } + res := gdb.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).Update("must_change_password", true) + if res.Error != nil { + return res.Error + } + if res.RowsAffected != 1 { + return fmt.Errorf("user %s was not updated", email) + } + out.Success("Password change required for " + user.Email) + return nil + }, + } +} diff --git a/console_test.go b/console_test.go new file mode 100644 index 0000000..3cfa77d --- /dev/null +++ b/console_test.go @@ -0,0 +1,54 @@ +package user + +import ( + "bytes" + "context" + "strings" + "testing" + + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" + "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/pact" +) + +func TestRequirePasswordChange(t *testing.T) { + app, gdb := sessionApp(t, "0s") + user := insertUser(t, gdb, "alice@example.com", "correct-horse") + p := &Plugin{app: app} + var _ pact.HasCommands = p + cmds := p.Commands() + if len(cmds) != 1 || cmds[0].Name != "user:require-password-change" { + t.Fatalf("commands = %#v", cmds) + } + var buf bytes.Buffer + out := bonfire.NewOutput(nil, &buf, &buf) + if err := cmds[0].Run(context.Background(), emailArg{email: "alice@example.com"}, out); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "alice@example.com") { + t.Fatalf("output = %q", buf.String()) + } + var fresh models.User + if err := gdb.First(&fresh, user.ID).Error; err != nil { + t.Fatal(err) + } + if !fresh.MustChangePassword { + t.Fatal("must_change_password stayed false") + } + if err := cmds[0].Run(context.Background(), emailArg{email: "missing@example.com"}, out); err == nil { + t.Fatal("unknown email returned nil error") + } +} + +type emailArg struct{ email string } + +func (e emailArg) Args() []string { return []string{e.email} } + +func (e emailArg) Argument(name string) (string, bool) { + if name == "email" { + return e.email, true + } + return "", false +} + +func (e emailArg) Flag(string) (string, bool) { return "", false } diff --git a/controllers/api_controller.go b/controllers/api_controller.go index 6c0dc05..6357057 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -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 + } +} diff --git a/mail_test.go b/mail_test.go new file mode 100644 index 0000000..9fef5c1 --- /dev/null +++ b/mail_test.go @@ -0,0 +1,112 @@ +package user + +import ( + "context" + "net/url" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/controllers" + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/postcard" + "gorm.io/gorm" +) + +type recordingMail struct { + inner postcard.Mailer + msgs []postcard.Message +} + +func (r *recordingMail) Send(ctx context.Context, msg postcard.Message) error { + r.msgs = append(r.msgs, msg) + if r.inner == nil { + return nil + } + return r.inner.Send(ctx, msg) +} + +func TestMail(t *testing.T) { + app, gdb := sessionApp(t, "0s") + disableRegisterThrottle(t, app) + if err := app.Config.Set("golem15.user.activation.activate_mode", "user"); err != nil { + t.Fatal(err) + } + rec, mem := publishMemoryMail(t, app) + + postRegister(t, app, "198.51.100.10", `{"email":"mail-pl@example.test","password":"correct-horse","name":"Ada","preferred_locale":"pl"}`) + postRegister(t, app, "198.51.100.11", `{"email":"mail-en@example.test","password":"correct-horse","name":"Grace","preferred_locale":"en"}`) + assertTemplate(t, rec, "golem15.user::mail.activate", "mail-pl@example.test") + assertTemplate(t, rec, "golem15.user::mail.activate-en", "mail-en@example.test") + for _, msg := range rec.msgs { + if !strings.HasPrefix(msg.Template, "golem15.user::mail.activate") { + continue + } + link, _ := msg.Vars["link"].(string) + code, _ := msg.Vars["code"].(string) + decoded, err := url.QueryUnescape(link) + if err != nil { + t.Fatal(err) + } + if link == "" || code == "" || !strings.Contains(decoded, code) { + t.Fatalf("activate vars template=%s link=%q code=%q", msg.Template, link, code) + } + } + if len(mem.Messages()) < 2 { + t.Fatalf("memory driver recorded %d messages", len(mem.Messages())) + } + + before := len(rec.msgs) + postJSON(t, controllers.ForgotPassword(app), "/_user/api/v1/forgot-password", `{"email":"mail-pl@example.test"}`) + postJSON(t, controllers.ForgotPassword(app), "/_user/api/v1/forgot-password", `{"email":"mail-en@example.test"}`) + assertTemplate(t, rec, "golem15.user::mail.restore", "mail-pl@example.test") + assertTemplate(t, rec, "golem15.user::mail.restore-en", "mail-en@example.test") + if len(mem.Messages()) < before+2 { + t.Fatalf("memory driver recorded %d messages after restore", len(mem.Messages())) + } + + softDelete(t, gdb, "react-pl@example.test", "pl") + softDelete(t, gdb, "react-en@example.test", "en") + postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"react-pl@example.test","password":"correct-horse"}`) + postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"react-en@example.test","password":"correct-horse"}`) + assertTemplate(t, rec, "golem15.user::mail.reactivate", "react-pl@example.test") + assertTemplate(t, rec, "golem15.user::mail.reactivate-en", "react-en@example.test") +} + +func publishMemoryMail(t *testing.T, app *backpack.App) (*recordingMail, *postcard.MemoryDriver) { + t.Helper() + cat := postcard.NewCatalog() + p := &Plugin{} + if err := cat.Register(p.ID(), p.MailTemplatesFS(), p.MailTemplates(), p.MailLayouts()); err != nil { + t.Fatal(err) + } + mem := postcard.NewMemoryDriver() + inner := postcard.NewMailer(cat, mem, postcard.Options{From: "noreply@example.test"}) + rec := &recordingMail{inner: inner} + if err := app.Publish[postcard.Mailer](rec); err != nil { + t.Fatal(err) + } + return rec, mem +} + +func assertTemplate(t *testing.T, rec *recordingMail, template, to string) { + t.Helper() + for _, msg := range rec.msgs { + if msg.Template == template && len(msg.To) == 1 && msg.To[0] == to { + return + } + } + t.Fatalf("missing send %s to %s in %d messages", template, to, len(rec.msgs)) +} + +func softDelete(t *testing.T, gdb *gorm.DB, email, locale string) { + t.Helper() + user := insertUser(t, gdb, email, "correct-horse") + if err := gdb.Model(user).Update("preferred_locale", locale).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Model(&models.User{}).Where("id = ?", user.ID).Update("deleted_at", time.Now()).Error; err != nil { + t.Fatal(err) + } +} diff --git a/models/user.go b/models/user.go index 4d62b20..70b816a 100644 --- a/models/user.go +++ b/models/user.go @@ -34,6 +34,9 @@ type User struct { func (User) TableName() string { return "users" } +// MorphName is the PHP class string stored in system_files.attachment_type. +func (User) MorphName() string { return `Golem15\User\Models\User` } + // Fillable is the register/update field list. Username, PIN, and GDPR columns // stay out of Phase 7 because no ported handler reads them. func (User) Fillable() []string { diff --git a/plugin.go b/plugin.go index 71a3c56..58b1809 100644 --- a/plugin.go +++ b/plugin.go @@ -11,9 +11,11 @@ import ( "time" "git.golem15.com/golem15/fonoteka/plugins/golem15/user/classes" + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/console" "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" "git.golem15.com/golem15/fonoteka/plugins/golem15/user/updates" "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bonfire" "git.golem15.com/golem15/summercms/bouncer" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" @@ -23,19 +25,24 @@ import ( ) var ( - _ party.Plugin = (*Plugin)(nil) - _ pact.HasConfig = (*Plugin)(nil) - _ pact.HasMigrations = (*Plugin)(nil) - _ pact.HasMiddleware = (*Plugin)(nil) - _ pact.HasModels = (*Plugin)(nil) - _ pact.HasRoutes = (*Plugin)(nil) - _ surf.BucketProvider = (*Plugin)(nil) - _ bouncer.UserProvider = classes.GormUsers{} + _ party.Plugin = (*Plugin)(nil) + _ pact.HasConfig = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasMiddleware = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) + _ pact.HasRoutes = (*Plugin)(nil) + _ pact.HasMailTemplates = (*Plugin)(nil) + _ pact.HasCommands = (*Plugin)(nil) + _ surf.BucketProvider = (*Plugin)(nil) + _ bouncer.UserProvider = classes.GormUsers{} ) //go:embed config var configFS embed.FS +//go:embed views/mail +var mailFS embed.FS + // Plugin is the golem15.user plugin. type Plugin struct { app *backpack.App @@ -153,6 +160,29 @@ func bearerFrom(r *http.Request) string { func (p *Plugin) ConfigFS() fs.FS { return configFS } +func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS } + +func (p *Plugin) MailTemplates() []string { + return []string{ + "golem15.user::mail.activate", + "golem15.user::mail.activate-en", + "golem15.user::mail.restore", + "golem15.user::mail.restore-en", + "golem15.user::mail.reactivate", + "golem15.user::mail.reactivate-en", + } +} + +func (p *Plugin) MailLayouts() map[string]string { + return map[string]string{ + "user": "golem15.user::mail.layouts.user", + } +} + +func (p *Plugin) Commands() []bonfire.Command { + return []bonfire.Command{console.RequirePasswordChange(p.app)} +} + func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() } func (p *Plugin) Models() []any { return models.All() } diff --git a/routes.go b/routes.go index f049b24..391d698 100644 --- a/routes.go +++ b/routes.go @@ -21,6 +21,8 @@ func (p *Plugin) Routes(r pact.Router) error { g.Post("/update", controllers.Update(p.app)) g.Post("/change-password", controllers.ChangePassword(p.app)) g.Post("/marketing-consent", controllers.MarketingConsent(p.app)) + g.Post("/avatar", controllers.UploadAvatar(p.app)) + g.Post("/avatar/remove", controllers.RemoveAvatar(p.app)) }) return nil } diff --git a/session_test.go b/session_test.go index a700b61..3b9d1a9 100644 --- a/session_test.go +++ b/session_test.go @@ -21,6 +21,7 @@ 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/party" "git.golem15.com/golem15/summercms/postcard" "git.golem15.com/golem15/summercms/surf" @@ -555,6 +556,14 @@ func sessionDB(t *testing.T) (*gorm.DB, *sql.DB) { sessionErr = err return } + files := gormigrate.New(gdb, &gormigrate.Options{ + TableName: "summer_migrations_system_files", + UseTransaction: true, + }, attach.Migrations) + if err := files.Migrate(); err != nil { + sessionErr = err + return + } sessionGDB = gdb sessionSQL = sqlDB }) diff --git a/views/mail/activate-en.htm b/views/mail/activate-en.htm new file mode 100644 index 0000000..ccd0148 --- /dev/null +++ b/views/mail/activate-en.htm @@ -0,0 +1,9 @@ +subject = "Activate your account" +description = "Account activation" +layout = "user" +== +Hello **{{ .Name }}**. + +[Activate your account]({{ .Link }}) + +Code: {{ .Code }} diff --git a/views/mail/activate.htm b/views/mail/activate.htm new file mode 100644 index 0000000..60f399a --- /dev/null +++ b/views/mail/activate.htm @@ -0,0 +1,9 @@ +subject = "Activate your account" +description = "Aktywacja konta" +layout = "user" +== +Cześć **{{ .Name }}**. + +[Aktywuj konto]({{ .Link }}) + +Kod: {{ .Code }} diff --git a/views/mail/layouts/user.htm b/views/mail/layouts/user.htm new file mode 100644 index 0000000..75b8f74 --- /dev/null +++ b/views/mail/layouts/user.htm @@ -0,0 +1,14 @@ +name = "User" +== +-- {{ .Content }} -- +== + + + + + + + +
{{ .Content }}
+ + diff --git a/views/mail/reactivate-en.htm b/views/mail/reactivate-en.htm new file mode 100644 index 0000000..c825bf6 --- /dev/null +++ b/views/mail/reactivate-en.htm @@ -0,0 +1,7 @@ +subject = "Welcome back" +description = "Account reactivated" +layout = "user" +== +Hello **{{ .Name }}**. + +Your account was reactivated when you signed in. diff --git a/views/mail/reactivate.htm b/views/mail/reactivate.htm new file mode 100644 index 0000000..7241977 --- /dev/null +++ b/views/mail/reactivate.htm @@ -0,0 +1,7 @@ +subject = "Welcome back" +description = "Konto zostało ponownie aktywowane" +layout = "user" +== +Cześć **{{ .Name }}**. + +Twoje konto zostało ponownie aktywowane przy logowaniu. diff --git a/views/mail/restore-en.htm b/views/mail/restore-en.htm new file mode 100644 index 0000000..c64db32 --- /dev/null +++ b/views/mail/restore-en.htm @@ -0,0 +1,9 @@ +subject = "Reset your password" +description = "Password reset" +layout = "user" +== +Hello **{{ .Name }}**. + +[Reset your password]({{ .Link }}) + +Code: {{ .Code }} diff --git a/views/mail/restore.htm b/views/mail/restore.htm new file mode 100644 index 0000000..de41a80 --- /dev/null +++ b/views/mail/restore.htm @@ -0,0 +1,9 @@ +subject = "Reset your password" +description = "Reset hasła" +layout = "user" +== +Cześć **{{ .Name }}**. + +[Ustaw nowe hasło]({{ .Link }}) + +Kod: {{ .Code }}