feat(07-03): add avatars, mail templates, and password-change command
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
183
avatar_test.go
Normal file
183
avatar_test.go
Normal file
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`))
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
56
console/require_password_change.go
Normal file
56
console/require_password_change.go
Normal file
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
54
console_test.go
Normal file
54
console_test.go
Normal file
@@ -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 }
|
||||||
@@ -2,6 +2,8 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
@@ -20,12 +22,18 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/bouncer"
|
"git.golem15.com/golem15/summercms/bouncer"
|
||||||
"git.golem15.com/golem15/summercms/compass"
|
"git.golem15.com/golem15/summercms/compass"
|
||||||
"git.golem15.com/golem15/summercms/lagoon"
|
"git.golem15.com/golem15/summercms/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/lagoon/attach"
|
||||||
"git.golem15.com/golem15/summercms/postcard"
|
"git.golem15.com/golem15/summercms/postcard"
|
||||||
"git.golem15.com/golem15/summercms/surf"
|
"git.golem15.com/golem15/summercms/surf"
|
||||||
"git.golem15.com/golem15/summercms/wire"
|
"git.golem15.com/golem15/summercms/wire"
|
||||||
|
"gocloud.dev/blob"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const avatarMaxBytes = 4_096_000
|
||||||
|
|
||||||
|
var errNoAvatar = errors.New("golem15.user: no avatar")
|
||||||
|
|
||||||
const invalidLoginBody = "Invalid email or password"
|
const invalidLoginBody = "Invalid email or password"
|
||||||
|
|
||||||
// Login authenticates email and password and returns a JWT plus the user payload.
|
// 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)
|
writeOpaque500(w)
|
||||||
return
|
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 err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, true); err != nil {
|
||||||
if classes.IsLoginRejected(err) {
|
if classes.IsLoginRejected(err) {
|
||||||
@@ -231,16 +239,8 @@ func ForgotPassword(app *backpack.App) http.HandlerFunc {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Default().Error("golem15.user: issue reset code", "err", err)
|
slog.Default().Error("golem15.user: issue reset code", "err", err)
|
||||||
} else {
|
} else {
|
||||||
name := ""
|
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||||
if user.Name != nil {
|
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.restore"), user.Email, mailVars(displayName(user), resetLink(app, user.ID, code), packed))
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody})
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody})
|
||||||
@@ -653,7 +653,15 @@ func Register(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
if mode == "user" {
|
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"})
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Activation email sent"})
|
||||||
return
|
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 stays false until a feedback plugin listens.
|
||||||
"feedback_widget_hidden": false,
|
"feedback_widget_hidden": false,
|
||||||
}
|
}
|
||||||
|
applyAvatar(ctx, app, user, payload)
|
||||||
if app != nil && app.Events != nil {
|
if app != nil && app.Events != nil {
|
||||||
extra, err := app.Events.Collect(ctx, &classes.GetApiArrayEvent{User: user})
|
extra, err := app.Events.Collect(ctx, &classes.GetApiArrayEvent{User: user})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -854,6 +863,21 @@ func sendTemplate(ctx context.Context, app *backpack.App, template, to string) {
|
|||||||
sendTemplateVars(ctx, app, template, to, nil)
|
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) {
|
func sendTemplateVars(ctx context.Context, app *backpack.App, template, to string, vars map[string]any) {
|
||||||
if app == nil || to == "" {
|
if app == nil || to == "" {
|
||||||
return
|
return
|
||||||
@@ -915,6 +939,20 @@ func resetLink(app *backpack.App, id uint, code string) string {
|
|||||||
return base + "?" + q.Encode()
|
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) {
|
func readFields(r *http.Request) (map[string]any, error) {
|
||||||
ct := r.Header.Get("Content-Type")
|
ct := r.Header.Get("Content-Type")
|
||||||
if strings.Contains(ct, "application/json") {
|
if strings.Contains(ct, "application/json") {
|
||||||
@@ -1004,3 +1042,209 @@ func writeUnauthorized(w http.ResponseWriter) {
|
|||||||
func writeOpaque500(w http.ResponseWriter) {
|
func writeOpaque500(w http.ResponseWriter) {
|
||||||
wire.WriteOpaque500(w)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
112
mail_test.go
Normal file
112
mail_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,9 @@ type User struct {
|
|||||||
|
|
||||||
func (User) TableName() string { return "users" }
|
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
|
// Fillable is the register/update field list. Username, PIN, and GDPR columns
|
||||||
// stay out of Phase 7 because no ported handler reads them.
|
// stay out of Phase 7 because no ported handler reads them.
|
||||||
func (User) Fillable() []string {
|
func (User) Fillable() []string {
|
||||||
|
|||||||
46
plugin.go
46
plugin.go
@@ -11,9 +11,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/classes"
|
"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/models"
|
||||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/updates"
|
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/updates"
|
||||||
"git.golem15.com/golem15/summercms/backpack"
|
"git.golem15.com/golem15/summercms/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/bonfire"
|
||||||
"git.golem15.com/golem15/summercms/bouncer"
|
"git.golem15.com/golem15/summercms/bouncer"
|
||||||
"git.golem15.com/golem15/summercms/pact"
|
"git.golem15.com/golem15/summercms/pact"
|
||||||
"git.golem15.com/golem15/summercms/party"
|
"git.golem15.com/golem15/summercms/party"
|
||||||
@@ -23,19 +25,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
_ party.Plugin = (*Plugin)(nil)
|
_ party.Plugin = (*Plugin)(nil)
|
||||||
_ pact.HasConfig = (*Plugin)(nil)
|
_ pact.HasConfig = (*Plugin)(nil)
|
||||||
_ pact.HasMigrations = (*Plugin)(nil)
|
_ pact.HasMigrations = (*Plugin)(nil)
|
||||||
_ pact.HasMiddleware = (*Plugin)(nil)
|
_ pact.HasMiddleware = (*Plugin)(nil)
|
||||||
_ pact.HasModels = (*Plugin)(nil)
|
_ pact.HasModels = (*Plugin)(nil)
|
||||||
_ pact.HasRoutes = (*Plugin)(nil)
|
_ pact.HasRoutes = (*Plugin)(nil)
|
||||||
_ surf.BucketProvider = (*Plugin)(nil)
|
_ pact.HasMailTemplates = (*Plugin)(nil)
|
||||||
_ bouncer.UserProvider = classes.GormUsers{}
|
_ pact.HasCommands = (*Plugin)(nil)
|
||||||
|
_ surf.BucketProvider = (*Plugin)(nil)
|
||||||
|
_ bouncer.UserProvider = classes.GormUsers{}
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed config
|
//go:embed config
|
||||||
var configFS embed.FS
|
var configFS embed.FS
|
||||||
|
|
||||||
|
//go:embed views/mail
|
||||||
|
var mailFS embed.FS
|
||||||
|
|
||||||
// Plugin is the golem15.user plugin.
|
// Plugin is the golem15.user plugin.
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
app *backpack.App
|
app *backpack.App
|
||||||
@@ -153,6 +160,29 @@ func bearerFrom(r *http.Request) string {
|
|||||||
|
|
||||||
func (p *Plugin) ConfigFS() fs.FS { return configFS }
|
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) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||||
|
|
||||||
func (p *Plugin) Models() []any { return models.All() }
|
func (p *Plugin) Models() []any { return models.All() }
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ func (p *Plugin) Routes(r pact.Router) error {
|
|||||||
g.Post("/update", controllers.Update(p.app))
|
g.Post("/update", controllers.Update(p.app))
|
||||||
g.Post("/change-password", controllers.ChangePassword(p.app))
|
g.Post("/change-password", controllers.ChangePassword(p.app))
|
||||||
g.Post("/marketing-consent", controllers.MarketingConsent(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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/bouncer"
|
"git.golem15.com/golem15/summercms/bouncer"
|
||||||
"git.golem15.com/golem15/summercms/compass"
|
"git.golem15.com/golem15/summercms/compass"
|
||||||
"git.golem15.com/golem15/summercms/lagoon"
|
"git.golem15.com/golem15/summercms/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/lagoon/attach"
|
||||||
"git.golem15.com/golem15/summercms/party"
|
"git.golem15.com/golem15/summercms/party"
|
||||||
"git.golem15.com/golem15/summercms/postcard"
|
"git.golem15.com/golem15/summercms/postcard"
|
||||||
"git.golem15.com/golem15/summercms/surf"
|
"git.golem15.com/golem15/summercms/surf"
|
||||||
@@ -555,6 +556,14 @@ func sessionDB(t *testing.T) (*gorm.DB, *sql.DB) {
|
|||||||
sessionErr = err
|
sessionErr = err
|
||||||
return
|
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
|
sessionGDB = gdb
|
||||||
sessionSQL = sqlDB
|
sessionSQL = sqlDB
|
||||||
})
|
})
|
||||||
|
|||||||
9
views/mail/activate-en.htm
Normal file
9
views/mail/activate-en.htm
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
subject = "Activate your account"
|
||||||
|
description = "Account activation"
|
||||||
|
layout = "user"
|
||||||
|
==
|
||||||
|
Hello **{{ .Name }}**.
|
||||||
|
|
||||||
|
[Activate your account]({{ .Link }})
|
||||||
|
|
||||||
|
Code: {{ .Code }}
|
||||||
9
views/mail/activate.htm
Normal file
9
views/mail/activate.htm
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
subject = "Activate your account"
|
||||||
|
description = "Aktywacja konta"
|
||||||
|
layout = "user"
|
||||||
|
==
|
||||||
|
Cześć **{{ .Name }}**.
|
||||||
|
|
||||||
|
[Aktywuj konto]({{ .Link }})
|
||||||
|
|
||||||
|
Kod: {{ .Code }}
|
||||||
14
views/mail/layouts/user.htm
Normal file
14
views/mail/layouts/user.htm
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name = "User"
|
||||||
|
==
|
||||||
|
-- {{ .Content }} --
|
||||||
|
==
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<style type="text/css">{{ .brandCss }}{{ .css }}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout-user">{{ .Content }}</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
views/mail/reactivate-en.htm
Normal file
7
views/mail/reactivate-en.htm
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
subject = "Welcome back"
|
||||||
|
description = "Account reactivated"
|
||||||
|
layout = "user"
|
||||||
|
==
|
||||||
|
Hello **{{ .Name }}**.
|
||||||
|
|
||||||
|
Your account was reactivated when you signed in.
|
||||||
7
views/mail/reactivate.htm
Normal file
7
views/mail/reactivate.htm
Normal file
@@ -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.
|
||||||
9
views/mail/restore-en.htm
Normal file
9
views/mail/restore-en.htm
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
subject = "Reset your password"
|
||||||
|
description = "Password reset"
|
||||||
|
layout = "user"
|
||||||
|
==
|
||||||
|
Hello **{{ .Name }}**.
|
||||||
|
|
||||||
|
[Reset your password]({{ .Link }})
|
||||||
|
|
||||||
|
Code: {{ .Code }}
|
||||||
9
views/mail/restore.htm
Normal file
9
views/mail/restore.htm
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
subject = "Reset your password"
|
||||||
|
description = "Reset hasła"
|
||||||
|
layout = "user"
|
||||||
|
==
|
||||||
|
Cześć **{{ .Name }}**.
|
||||||
|
|
||||||
|
[Ustaw nowe hasło]({{ .Link }})
|
||||||
|
|
||||||
|
Kod: {{ .Code }}
|
||||||
Reference in New Issue
Block a user