feat: format user mentions with display names in email notifications (#1930)
Email notifications now display user mentions with inline avatar images for improved visual recognition and easier identification. Mentions gracefully fall back to display names if avatars are unavailable.
This commit is contained in:
parent
d4eccccbfe
commit
fb7764d9f1
|
|
@ -17,11 +17,15 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/modules/avatar"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
|
|
@ -74,3 +78,188 @@ func extractMentionedUsernames(htmlText string) []string {
|
|||
traverse(doc)
|
||||
return usernames
|
||||
}
|
||||
|
||||
// formatMentionsForEmail replaces mention-user tags with user avatars and names for email display.
|
||||
// It converts <mention-user data-id="username" data-label="Display Name"> tags to
|
||||
// <strong><img src="data:..."/> Display Name</strong> with a 20x20 avatar image.
|
||||
// If data-label is missing, it falls back to data-id. Returns the original HTML unchanged on any error.
|
||||
func formatMentionsForEmail(s *xorm.Session, htmlText string) string {
|
||||
if htmlText == "" {
|
||||
return htmlText
|
||||
}
|
||||
|
||||
// Create a synthetic body node for fragment parsing
|
||||
bodyNode := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: "body",
|
||||
DataAtom: atom.Body,
|
||||
}
|
||||
|
||||
fragments, err := html.ParseFragment(strings.NewReader(htmlText), bodyNode)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to parse HTML fragment for mention formatting: %v", err)
|
||||
return htmlText
|
||||
}
|
||||
|
||||
// If no fragments, return original
|
||||
if len(fragments) == 0 {
|
||||
return htmlText
|
||||
}
|
||||
|
||||
// Extract all usernames first to batch fetch users
|
||||
usernames := extractMentionedUsernames(htmlText)
|
||||
var usersMap map[int64]*user.User
|
||||
var usernameToUser map[string]*user.User
|
||||
|
||||
if len(usernames) == 0 {
|
||||
return htmlText
|
||||
}
|
||||
|
||||
// Create maps for user data and avatar data URIs
|
||||
usernameToAvatarURI := make(map[string]string)
|
||||
|
||||
// Only fetch users if we have a valid session
|
||||
usersMap, err = user.GetUsersByUsername(s, usernames, true)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to fetch users for mention formatting: %v", err)
|
||||
// Continue without user data - we'll fall back to display names from attributes
|
||||
} else {
|
||||
// Create username -> user map for easy lookup and fetch avatar data URIs
|
||||
usernameToUser = make(map[string]*user.User)
|
||||
for _, u := range usersMap {
|
||||
usernameToUser[u.Username] = u
|
||||
|
||||
// Fetch avatar data URI for this user
|
||||
provider := avatar.GetProvider(u)
|
||||
avatarDataURI, err := provider.AsDataURI(u, 20)
|
||||
if err == nil && avatarDataURI != "" {
|
||||
usernameToAvatarURI[u.Username] = avatarDataURI
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track nodes to replace (can't modify while traversing)
|
||||
type replacement struct {
|
||||
oldNode *html.Node
|
||||
newNode *html.Node
|
||||
}
|
||||
replacements := []replacement{}
|
||||
|
||||
var traverse func(*html.Node)
|
||||
traverse = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "mention-user" {
|
||||
var dataLabel, dataID string
|
||||
|
||||
// Extract data-label and data-id attributes
|
||||
for _, attr := range n.Attr {
|
||||
switch attr.Key {
|
||||
case "data-label":
|
||||
dataLabel = attr.Val
|
||||
case "data-id":
|
||||
dataID = attr.Val
|
||||
}
|
||||
}
|
||||
|
||||
// Determine what to display
|
||||
displayName := dataLabel
|
||||
if displayName == "" {
|
||||
displayName = dataID
|
||||
}
|
||||
|
||||
// If still empty and has text content (old format), use that
|
||||
if displayName == "" && n.FirstChild != nil && n.FirstChild.Type == html.TextNode {
|
||||
displayName = strings.TrimPrefix(n.FirstChild.Data, "@")
|
||||
}
|
||||
|
||||
if displayName == "" {
|
||||
log.Debugf("Mention node has no data-label, data-id, or text content, skipping")
|
||||
// Continue traversing children in case there are nested elements
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
traverse(child)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Create <strong> wrapper
|
||||
strongNode := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: "strong",
|
||||
}
|
||||
|
||||
// Get pre-fetched avatar data URI for the user
|
||||
var avatarDataURI string
|
||||
if dataID != "" {
|
||||
avatarDataURI = usernameToAvatarURI[dataID]
|
||||
}
|
||||
|
||||
// If we have an avatar, add the img element
|
||||
if avatarDataURI != "" {
|
||||
imgNode := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: "img",
|
||||
Attr: []html.Attribute{
|
||||
{Key: "src", Val: avatarDataURI},
|
||||
{Key: "width", Val: "20"},
|
||||
{Key: "height", Val: "20"},
|
||||
{Key: "style", Val: "border-radius: 50%; vertical-align: middle; margin-right: 4px;"},
|
||||
{Key: "alt", Val: displayName},
|
||||
},
|
||||
}
|
||||
strongNode.AppendChild(imgNode)
|
||||
|
||||
// Add display name without @ since we have the avatar
|
||||
textNode := &html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: displayName,
|
||||
}
|
||||
strongNode.AppendChild(textNode)
|
||||
} else {
|
||||
// Fall back to @DisplayName without avatar
|
||||
textNode := &html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: "@" + displayName,
|
||||
}
|
||||
strongNode.AppendChild(textNode)
|
||||
}
|
||||
|
||||
// Schedule replacement
|
||||
replacements = append(replacements, replacement{
|
||||
oldNode: n,
|
||||
newNode: strongNode,
|
||||
})
|
||||
|
||||
// Don't traverse children of mention-user since we're replacing it
|
||||
return
|
||||
}
|
||||
|
||||
// Traverse child nodes
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
traverse(child)
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse all fragment nodes
|
||||
for _, fragment := range fragments {
|
||||
traverse(fragment)
|
||||
}
|
||||
|
||||
// Apply replacements
|
||||
for _, r := range replacements {
|
||||
if r.oldNode.Parent != nil {
|
||||
r.oldNode.Parent.InsertBefore(r.newNode, r.oldNode)
|
||||
r.oldNode.Parent.RemoveChild(r.oldNode)
|
||||
}
|
||||
}
|
||||
|
||||
// Render each fragment node back to HTML
|
||||
var buf bytes.Buffer
|
||||
for _, fragment := range fragments {
|
||||
err = html.Render(&buf, fragment)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to render HTML fragment after mention formatting: %v", err)
|
||||
return htmlText
|
||||
}
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ package models
|
|||
import (
|
||||
"testing"
|
||||
|
||||
"regexp"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/notifications"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
|
@ -179,3 +181,214 @@ func TestSendingMentionNotification(t *testing.T) {
|
|||
assert.Len(t, dbNotifications, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatMentionsForEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
useRegex bool // If true, expected is treated as a regex pattern
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "no mentions",
|
||||
input: "<p>Lorem Ipsum dolor sit amet</p>",
|
||||
expected: "<p>Lorem Ipsum dolor sit amet</p>",
|
||||
},
|
||||
{
|
||||
name: "single mention with data-label (new format)",
|
||||
input: `<p><mention-user data-id="frederick" data-label="Frederick" data-mention-suggestion-char="@"></mention-user> hello</p>`,
|
||||
expected: `<p><strong>@Frederick</strong> hello</p>`,
|
||||
},
|
||||
{
|
||||
name: "single mention with full name in data-label",
|
||||
input: `<p><mention-user data-id="johndoe" data-label="John Doe" data-mention-suggestion-char="@"></mention-user> please help</p>`,
|
||||
expected: `<p><strong>@John Doe</strong> please help</p>`,
|
||||
},
|
||||
{
|
||||
name: "mention without data-label (fallback to data-id)",
|
||||
input: `<p><mention-user data-id="johndoe"></mention-user> test</p>`,
|
||||
expected: `<p><strong>@johndoe</strong> test</p>`,
|
||||
},
|
||||
{
|
||||
name: "old format with text node inside",
|
||||
input: `<p><mention-user data-id="user1">@user1</mention-user> Lorem Ipsum</p>`,
|
||||
expected: `<p><strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="user1"/>user1</strong> Lorem Ipsum</p>`,
|
||||
useRegex: true,
|
||||
},
|
||||
{
|
||||
name: "old format with text node (data-id takes precedence over text)",
|
||||
input: `<p><mention-user data-id="actualuser">@differentuser</mention-user> text</p>`,
|
||||
expected: `<p><strong>@actualuser</strong> text</p>`,
|
||||
},
|
||||
{
|
||||
name: "multiple mentions in one paragraph",
|
||||
input: `<p>Hey <mention-user data-id="john" data-label="John"></mention-user> and <mention-user data-id="jane" data-label="Jane Doe"></mention-user>, please review</p>`,
|
||||
expected: `<p>Hey <strong>@John</strong> and <strong>@Jane Doe</strong>, please review</p>`,
|
||||
},
|
||||
{
|
||||
name: "mention at beginning",
|
||||
input: `<p><mention-user data-id="user1" data-label="User One"></mention-user> Lorem Ipsum</p>`,
|
||||
expected: `<p><strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User One"/>User One</strong> Lorem Ipsum</p>`,
|
||||
useRegex: true,
|
||||
},
|
||||
{
|
||||
name: "mention at end",
|
||||
input: `<p>Lorem Ipsum <mention-user data-id="user1" data-label="User One"></mention-user></p>`,
|
||||
expected: `<p>Lorem Ipsum <strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User One"/>User One</strong></p>`,
|
||||
useRegex: true,
|
||||
},
|
||||
{
|
||||
name: "mention in middle",
|
||||
input: `<p>Lorem <mention-user data-id="user1" data-label="User One"></mention-user> Ipsum</p>`,
|
||||
expected: `<p>Lorem <strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User One"/>User One</strong> Ipsum</p>`,
|
||||
useRegex: true,
|
||||
},
|
||||
{
|
||||
name: "same user mentioned multiple times",
|
||||
input: `<p><mention-user data-id="user1" data-label="User"></mention-user> and <mention-user data-id="user1" data-label="User"></mention-user> again</p>`,
|
||||
expected: `<p><strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User"/>User</strong> and <strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User"/>User</strong> again</p>`,
|
||||
useRegex: true,
|
||||
},
|
||||
{
|
||||
name: "HTML preservation with links",
|
||||
input: `<p>Check <a href="http://example.com">this link</a> and ask <mention-user data-id="expert" data-label="Expert"></mention-user></p>`,
|
||||
expected: `<p>Check <a href="http://example.com">this link</a> and ask <strong>@Expert</strong></p>`,
|
||||
},
|
||||
{
|
||||
name: "HTML preservation with multiple paragraphs",
|
||||
input: `<p>First paragraph with <mention-user data-id="user1" data-label="User"></mention-user></p><p>Second paragraph</p>`,
|
||||
expected: `<p>First paragraph with <strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User"/>User</strong></p><p>Second paragraph</p>`,
|
||||
useRegex: true,
|
||||
},
|
||||
{
|
||||
name: "HTML preservation with bold and italic",
|
||||
input: `<p><strong>Bold text</strong> and <em>italic</em> with <mention-user data-id="user1" data-label="User"></mention-user></p>`,
|
||||
expected: `<p><strong>Bold text</strong> and <em>italic</em> with <strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User"/>User</strong></p>`,
|
||||
useRegex: true,
|
||||
},
|
||||
{
|
||||
name: "special characters in data-label",
|
||||
input: `<p><mention-user data-id="user1" data-label="O'Brien"></mention-user> test</p>`,
|
||||
expected: `<p><strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="O'Brien"/>O'Brien</strong> test</p>`,
|
||||
useRegex: true,
|
||||
},
|
||||
{
|
||||
name: "special characters - ampersand in data-label",
|
||||
input: `<p><mention-user data-id="user1" data-label="Tom & Jerry"></mention-user> test</p>`,
|
||||
expected: `<p><strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="Tom & Jerry"/>Tom & Jerry</strong> test</p>`,
|
||||
useRegex: true,
|
||||
},
|
||||
{
|
||||
name: "special characters - quotes in data-label",
|
||||
input: `<p><mention-user data-id="user1" data-label=""Nickname""></mention-user> test</p>`,
|
||||
expected: `<p><strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt=""Nickname""/>"Nickname"</strong> test</p>`,
|
||||
useRegex: true,
|
||||
},
|
||||
{
|
||||
name: "mixed old and new format",
|
||||
input: `<p><mention-user data-id="new" data-label="New User"></mention-user> and <mention-user data-id="old">@old</mention-user></p>`,
|
||||
expected: `<p><strong>@New User</strong> and <strong>@old</strong></p>`,
|
||||
},
|
||||
{
|
||||
name: "self-closing tag format (XML-style)",
|
||||
input: `<p><mention-user data-id="user" data-label="User"/> hello</p>`,
|
||||
expected: `<p><strong>@User</strong></p>`,
|
||||
},
|
||||
{
|
||||
name: "mention with only text content (no attributes) - old format edge case",
|
||||
input: `<p><mention-user>@someuser</mention-user> test</p>`,
|
||||
expected: `<p><mention-user>@someuser</mention-user> test</p>`,
|
||||
},
|
||||
{
|
||||
name: "data-label takes precedence over data-id",
|
||||
input: `<p><mention-user data-id="username123" data-label="John Smith"></mention-user> test</p>`,
|
||||
expected: `<p><strong>@John Smith</strong> test</p>`,
|
||||
},
|
||||
{
|
||||
name: "unicode characters in data-label",
|
||||
input: `<p><mention-user data-id="user" data-label="Müller François"></mention-user> test</p>`,
|
||||
expected: `<p><strong>@Müller François</strong> test</p>`,
|
||||
},
|
||||
{
|
||||
name: "emoji in data-label",
|
||||
input: `<p><mention-user data-id="user" data-label="Cool User 😎"></mention-user> test</p>`,
|
||||
expected: `<p><strong>@Cool User 😎</strong> test</p>`,
|
||||
},
|
||||
{
|
||||
name: "nested HTML structure",
|
||||
input: `<div><p>Text with <mention-user data-id="user" data-label="User"></mention-user> in div</p></div>`,
|
||||
expected: `<div><p>Text with <strong>@User</strong> in div</p></div>`,
|
||||
},
|
||||
{
|
||||
name: "mention in list",
|
||||
input: `<ul><li>Item with <mention-user data-id="user" data-label="User"></mention-user></li></ul>`,
|
||||
expected: `<ul><li>Item with <strong>@User</strong></li></ul>`,
|
||||
},
|
||||
{
|
||||
name: "very long name",
|
||||
input: `<p><mention-user data-id="user" data-label="Christopher Montgomery Bartholomew Johnson-Smith III"></mention-user> test</p>`,
|
||||
expected: `<p><strong>@Christopher Montgomery Bartholomew Johnson-Smith III</strong> test</p>`,
|
||||
},
|
||||
{
|
||||
name: "empty data-label and data-id with text content",
|
||||
input: `<p><mention-user>@fallback</mention-user> test</p>`,
|
||||
expected: `<p><mention-user>@fallback</mention-user> test</p>`,
|
||||
},
|
||||
{
|
||||
name: "whitespace in data-label",
|
||||
input: `<p><mention-user data-id="user" data-label=" Spaces "></mention-user> test</p>`,
|
||||
expected: `<p><strong>@ Spaces </strong> test</p>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
result := formatMentionsForEmail(s, tt.input)
|
||||
if tt.useRegex {
|
||||
matched, err := regexp.MatchString(tt.expected, result)
|
||||
require.NoError(t, err, "Invalid regex pattern: %s", tt.expected)
|
||||
assert.True(t, matched, "Result does not match regex pattern.\nExpected pattern: %s\nActual result: %s", tt.expected, result)
|
||||
} else {
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMentionsForEmail_MalformedHTML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
name: "unclosed tag - returns original",
|
||||
input: `<p>Test <mention-user data-id="user" data-label="User">`,
|
||||
},
|
||||
{
|
||||
name: "invalid HTML entities",
|
||||
input: `<p>Test &invalid; entity</p>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
result := formatMentionsForEmail(s, tt.input)
|
||||
// For malformed HTML, we expect it to either be fixed by the parser or returned as-is
|
||||
// The key is that it shouldn't panic or error
|
||||
assert.NotEmpty(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import (
|
|||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/i18n"
|
||||
"code.vikunja.io/api/pkg/notifications"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
|
@ -93,6 +94,9 @@ func (n *TaskCommentNotification) SubjectID() int64 {
|
|||
|
||||
// ToMail returns the mail notification for TaskCommentNotification
|
||||
func (n *TaskCommentNotification) ToMail(lang string) *notifications.Mail {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
formattedComment := formatMentionsForEmail(s, n.Comment.Comment)
|
||||
|
||||
mail := notifications.NewMail().
|
||||
From(n.Doer.GetNameAndFromEmail()).
|
||||
|
|
@ -104,7 +108,7 @@ func (n *TaskCommentNotification) ToMail(lang string) *notifications.Mail {
|
|||
Subject(i18n.T(lang, "notifications.task.comment.mentioned_subject", n.Doer.GetName(), n.Task.Title))
|
||||
}
|
||||
|
||||
mail.HTML(n.Comment.Comment)
|
||||
mail.HTML(formattedComment)
|
||||
|
||||
return mail.
|
||||
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL())
|
||||
|
|
@ -350,6 +354,10 @@ func (n *UserMentionedInTaskNotification) SubjectID() int64 {
|
|||
|
||||
// ToMail returns the mail notification for UserMentionedInTaskNotification
|
||||
func (n *UserMentionedInTaskNotification) ToMail(lang string) *notifications.Mail {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
formattedDescription := formatMentionsForEmail(s, n.Task.Description)
|
||||
|
||||
var subject string
|
||||
if n.IsNew {
|
||||
subject = i18n.T(lang, "notifications.task.mentioned.subject_new", n.Doer.GetName(), n.Task.Title)
|
||||
|
|
@ -361,7 +369,7 @@ func (n *UserMentionedInTaskNotification) ToMail(lang string) *notifications.Mai
|
|||
From(n.Doer.GetNameAndFromEmail()).
|
||||
Subject(subject).
|
||||
Line(i18n.T(lang, "notifications.task.mentioned.message", n.Doer.GetName())).
|
||||
HTML(n.Task.Description)
|
||||
HTML(formattedDescription)
|
||||
|
||||
return mail.
|
||||
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL())
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import (
|
|||
type Provider interface {
|
||||
// GetAvatar is the method used to get an actual avatar for a user
|
||||
GetAvatar(user *user.User, size int64) (avatar []byte, mimeType string, err error)
|
||||
// AsDataURI returns a base64-encoded string representation of the avatar suitable for inline use
|
||||
AsDataURI(user *user.User, size int64) (inlineData string, err error)
|
||||
// FlushCache removes cached avatar data for the user
|
||||
FlushCache(u *user.User) error
|
||||
}
|
||||
|
|
@ -53,3 +55,28 @@ func FlushAllCaches(u *user.User) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider returns the appropriate avatar provider for a user
|
||||
func GetProvider(u *user.User) Provider {
|
||||
provider := u.AvatarProvider
|
||||
if provider == "" {
|
||||
provider = "empty"
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "gravatar":
|
||||
return &gravatar.Provider{}
|
||||
case "initials":
|
||||
return &initials.Provider{}
|
||||
case "upload":
|
||||
return &upload.Provider{}
|
||||
case "marble":
|
||||
return &marble.Provider{}
|
||||
case "ldap":
|
||||
return &ldap.Provider{}
|
||||
case "openid":
|
||||
return &openid.Provider{}
|
||||
default:
|
||||
return &empty.Provider{}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,12 @@
|
|||
|
||||
package empty
|
||||
|
||||
import "code.vikunja.io/api/pkg/user"
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
)
|
||||
|
||||
// Provider represents the empty avatar provider
|
||||
type Provider struct {
|
||||
|
|
@ -46,3 +51,12 @@ const defaultAvatar string = `<?xml version="1.0" encoding="UTF-8"?>
|
|||
func (p *Provider) GetAvatar(_ *user.User, _ int64) (avatar []byte, mimeType string, err error) {
|
||||
return []byte(defaultAvatar), "image/svg+xml", nil
|
||||
}
|
||||
|
||||
// AsDataURI returns a data URI for the default SVG avatar
|
||||
func (p *Provider) AsDataURI(_ *user.User, _ int64) (string, error) {
|
||||
// Encode the SVG as base64 and create a data URI
|
||||
base64Data := base64.StdEncoding.EncodeToString([]byte(defaultAvatar))
|
||||
dataURI := fmt.Sprintf("data:image/svg+xml;base64,%s", base64Data)
|
||||
|
||||
return dataURI, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ package gravatar
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
|
@ -123,6 +125,20 @@ func (g *Provider) GetAvatar(user *user.User, size int64) ([]byte, string, error
|
|||
return av.Content, av.MimeType, nil
|
||||
}
|
||||
|
||||
// AsDataURI returns a base64 encoded data URI for the gravatar
|
||||
func (g *Provider) AsDataURI(user *user.User, size int64) (string, error) {
|
||||
avatarData, mimeType, err := g.GetAvatar(user, size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encode the avatar data as base64 and create a data URI
|
||||
base64Data := base64.StdEncoding.EncodeToString(avatarData)
|
||||
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
||||
|
||||
return dataURI, nil
|
||||
}
|
||||
|
||||
func (g *Provider) avatarExpired(av avatar) bool {
|
||||
return time.Since(av.LoadedAt) > time.Duration(config.AvatarGravaterExpiration.GetInt64())*time.Second
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package initials
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"strconv"
|
||||
|
|
@ -81,3 +82,17 @@ func (p *Provider) GetAvatar(u *user.User, size int64) (avatar []byte, mimeType
|
|||
|
||||
return []byte(svg), "image/svg+xml", nil
|
||||
}
|
||||
|
||||
// AsDataURI returns a data URI for the SVG avatar
|
||||
func (p *Provider) AsDataURI(u *user.User, size int64) (string, error) {
|
||||
avatarData, mimeType, err := p.GetAvatar(u, size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encode the SVG as base64 and create a data URI
|
||||
base64Data := base64.StdEncoding.EncodeToString(avatarData)
|
||||
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
||||
|
||||
return dataURI, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package avatar
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/modules/avatar/empty"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/initials"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/marble"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
)
|
||||
|
||||
func TestAsDataURI(t *testing.T) {
|
||||
testUser := &user.User{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Name: "Test User",
|
||||
Email: "test@example.com",
|
||||
}
|
||||
|
||||
// Table-driven test for SVG providers
|
||||
testCases := []struct {
|
||||
name string
|
||||
provider Provider
|
||||
}{
|
||||
{
|
||||
name: "Initials Provider",
|
||||
provider: &initials.Provider{},
|
||||
},
|
||||
{
|
||||
name: "Marble Provider",
|
||||
provider: &marble.Provider{},
|
||||
},
|
||||
{
|
||||
name: "Empty Provider",
|
||||
provider: &empty.Provider{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := tc.provider.AsDataURI(testUser, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(result, "data:image/svg+xml;base64,") {
|
||||
t.Errorf("Expected data URI with SVG base64, got: %s", result)
|
||||
}
|
||||
// Basic sanity check for reasonable length
|
||||
if len(result) < 50 {
|
||||
t.Errorf("Expected longer data URI, got: %s", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Gravatar Provider - Base64 Format", func(t *testing.T) {
|
||||
// Skip this test as it requires keyvalue store initialization
|
||||
// and network access to gravatar service
|
||||
t.Skip("Gravatar provider test requires full application setup")
|
||||
})
|
||||
}
|
||||
|
|
@ -29,6 +29,11 @@ func (p *Provider) GetAvatar(user *user.User, size int64) (avatar []byte, mimeTy
|
|||
return up.GetAvatar(user, size)
|
||||
}
|
||||
|
||||
func (p *Provider) AsDataURI(user *user.User, size int64) (string, error) {
|
||||
up := upload.Provider{}
|
||||
return up.AsDataURI(user, size)
|
||||
}
|
||||
|
||||
func (p *Provider) FlushCache(u *user.User) error {
|
||||
up := upload.Provider{}
|
||||
return up.FlushCache(u)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@
|
|||
package marble
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
|
|
@ -123,3 +125,17 @@ func (p *Provider) GetAvatar(u *user.User, size int64) (avatar []byte, mimeType
|
|||
</defs>
|
||||
</svg>`), "image/svg+xml", nil
|
||||
}
|
||||
|
||||
// AsDataURI returns a data URI for the SVG avatar
|
||||
func (p *Provider) AsDataURI(u *user.User, size int64) (string, error) {
|
||||
avatarData, mimeType, err := p.GetAvatar(u, size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encode the SVG as base64 and create a data URI
|
||||
base64Data := base64.StdEncoding.EncodeToString(avatarData)
|
||||
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
||||
|
||||
return dataURI, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ func (p *Provider) GetAvatar(user *user.User, size int64) (avatar []byte, mimeTy
|
|||
return up.GetAvatar(user, size)
|
||||
}
|
||||
|
||||
func (p *Provider) AsDataURI(user *user.User, size int64) (string, error) {
|
||||
up := upload.Provider{}
|
||||
return up.AsDataURI(user, size)
|
||||
}
|
||||
|
||||
func (p *Provider) FlushCache(u *user.User) error {
|
||||
up := upload.Provider{}
|
||||
return up.FlushCache(u)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package upload
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
|
|
@ -125,6 +126,20 @@ func (p *Provider) getAvatarWithDepth(u *user.User, size int64, recursionDepth i
|
|||
return cachedAvatar.Content, cachedAvatar.MimeType, nil
|
||||
}
|
||||
|
||||
// AsDataURI returns a base64 encoded data URI for the uploaded avatar
|
||||
func (p *Provider) AsDataURI(u *user.User, size int64) (string, error) {
|
||||
avatarData, mimeType, err := p.GetAvatar(u, size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encode the avatar data as base64 and create a data URI
|
||||
base64Data := base64.StdEncoding.EncodeToString(avatarData)
|
||||
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
||||
|
||||
return dataURI, nil
|
||||
}
|
||||
|
||||
func StoreAvatarFile(s *xorm.Session, u *user.User, src io.Reader) (err error) {
|
||||
|
||||
// Remove the old file if one exists
|
||||
|
|
|
|||
|
|
@ -108,6 +108,12 @@ var logo embed.FS
|
|||
|
||||
func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) {
|
||||
p := bluemonday.UGCPolicy()
|
||||
// Allow data URI images for inline avatars in mentions
|
||||
p.AllowDataURIImages()
|
||||
// Allow style attribute on img elements for avatar styling
|
||||
p.AllowAttrs("style").OnElements("img")
|
||||
// Allow specific CSS properties for avatar styling
|
||||
p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img")
|
||||
|
||||
for _, line := range lines {
|
||||
if line.isHTML {
|
||||
|
|
|
|||
|
|
@ -23,11 +23,6 @@ import (
|
|||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/avatar"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/empty"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/gravatar"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/initials"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/ldap"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/marble"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/openid"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/upload"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
|
@ -68,23 +63,7 @@ func GetAvatar(c echo.Context) error {
|
|||
|
||||
found := err == nil || !user.IsErrUserDoesNotExist(err)
|
||||
|
||||
var avatarProvider avatar.Provider
|
||||
switch u.AvatarProvider {
|
||||
case "gravatar":
|
||||
avatarProvider = &gravatar.Provider{}
|
||||
case "initials":
|
||||
avatarProvider = &initials.Provider{}
|
||||
case "upload":
|
||||
avatarProvider = &upload.Provider{}
|
||||
case "marble":
|
||||
avatarProvider = &marble.Provider{}
|
||||
case "ldap":
|
||||
avatarProvider = &ldap.Provider{}
|
||||
case "openid":
|
||||
avatarProvider = &openid.Provider{}
|
||||
default:
|
||||
avatarProvider = &empty.Provider{}
|
||||
}
|
||||
avatarProvider := avatar.GetProvider(u)
|
||||
|
||||
if !found {
|
||||
avatarProvider = &empty.Provider{}
|
||||
|
|
|
|||
Loading…
Reference in New Issue