@@ -7,9 +7,12 @@ package mailer
77import (
88 "bytes"
99 "context"
10+ "encoding/base64"
1011 "fmt"
1112 "html/template"
13+ "io"
1214 "mime"
15+ "net/http"
1316 "regexp"
1417 "strconv"
1518 "strings"
@@ -18,19 +21,23 @@ import (
1821
1922 activities_model "code.gitea.io/gitea/models/activities"
2023 issues_model "code.gitea.io/gitea/models/issues"
24+ access_model "code.gitea.io/gitea/models/perm/access"
2125 repo_model "code.gitea.io/gitea/models/repo"
26+ "code.gitea.io/gitea/models/unit"
2227 user_model "code.gitea.io/gitea/models/user"
2328 "code.gitea.io/gitea/modules/base"
2429 "code.gitea.io/gitea/modules/emoji"
2530 "code.gitea.io/gitea/modules/log"
2631 "code.gitea.io/gitea/modules/markup"
2732 "code.gitea.io/gitea/modules/markup/markdown"
2833 "code.gitea.io/gitea/modules/setting"
34+ "code.gitea.io/gitea/modules/storage"
2935 "code.gitea.io/gitea/modules/timeutil"
3036 "code.gitea.io/gitea/modules/translation"
3137 incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
3238 "code.gitea.io/gitea/services/mailer/token"
3339
40+ "golang.org/x/net/html"
3441 "gopkg.in/gomail.v2"
3542)
3643
@@ -232,6 +239,15 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
232239 return nil , err
233240 }
234241
242+ if setting .MailService .Base64EmbedImages {
243+ bodyStr := string (body )
244+ bodyStr , err = Base64InlineImages (bodyStr , ctx )
245+ if err != nil {
246+ return nil , err
247+ }
248+ body = template .HTML (bodyStr )
249+ }
250+
235251 actType , actName , tplName := actionToTemplate (ctx .Issue , ctx .ActionType , commentType , reviewType )
236252
237253 if actName != "new" {
@@ -363,6 +379,110 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
363379 return msgs , nil
364380}
365381
382+ func Base64InlineImages (body string , ctx * mailCommentContext ) (string , error ) {
383+ doc , err := html .Parse (strings .NewReader (body ))
384+ if err != nil {
385+ log .Error ("Failed to parse HTML body: %v" , err )
386+ return "" , err
387+ }
388+
389+ var totalEmbeddedImagesSize int64
390+
391+ var processNode func (* html.Node )
392+ processNode = func (n * html.Node ) {
393+ if n .Type == html .ElementNode {
394+ if n .Data == "img" {
395+ for i , attr := range n .Attr {
396+ if attr .Key == "src" {
397+ attachmentPath := attr .Val
398+ dataURI , err := AttachmentSrcToBase64DataURI (attachmentPath , ctx , & totalEmbeddedImagesSize )
399+ if err != nil {
400+ log .Trace ("attachmentSrcToDataURI not possible: %v" , err ) // Not an error, just skip. This is probably an image from outside the gitea instance.
401+ continue
402+ }
403+ log .Trace ("Old value of src attribute: %s, new value (first 100 characters): %s" , attr .Val , dataURI [:100 ])
404+ n .Attr [i ].Val = dataURI
405+ break
406+ }
407+ }
408+ }
409+ }
410+
411+ for c := n .FirstChild ; c != nil ; c = c .NextSibling {
412+ processNode (c )
413+ }
414+ }
415+
416+ processNode (doc )
417+
418+ var buf bytes.Buffer
419+ err = html .Render (& buf , doc )
420+ if err != nil {
421+ log .Error ("Failed to render modified HTML: %v" , err )
422+ return "" , err
423+ }
424+ return buf .String (), nil
425+ }
426+
427+ func AttachmentSrcToBase64DataURI (attachmentPath string , ctx * mailCommentContext , totalEmbeddedImagesSize * int64 ) (string , error ) {
428+ if ! strings .HasPrefix (attachmentPath , setting .AppURL ) { // external image
429+ return "" , fmt .Errorf ("external image" )
430+ }
431+ parts := strings .Split (attachmentPath , "/attachments/" )
432+ if len (parts ) <= 1 {
433+ return "" , fmt .Errorf ("invalid attachment path: %s" , attachmentPath )
434+ }
435+
436+ attachmentUUID := parts [len (parts )- 1 ]
437+ attachment , err := repo_model .GetAttachmentByUUID (ctx , attachmentUUID )
438+ if err != nil {
439+ return "" , err
440+ }
441+
442+ // "Doer" is theoretically not the correct permission check (as Doer created the action on which to send), but as this is batch processed the receipants can't be accessed.
443+ // Therefore we check the Doer, with which we counter leaking information as a Doer brute force attack on attachments would be possible.
444+ perm , err := access_model .GetUserRepoPermission (ctx , ctx .Issue .Repo , ctx .Doer )
445+ if err != nil {
446+ return "" , err
447+ }
448+ if ! perm .CanRead (unit .TypeIssues ) {
449+ return "" , fmt .Errorf ("no permission" )
450+ }
451+
452+ fr , err := storage .Attachments .Open (attachment .RelativePath ())
453+ if err != nil {
454+ return "" , err
455+ }
456+ defer fr .Close ()
457+
458+ maxSize := setting .MailService .Base64EmbedImagesMaxSizePerEmail // at maximum read the whole available combined email size, to prevent maliciously large file reads
459+
460+ lr := & io.LimitedReader {R : fr , N : maxSize + 1 }
461+ content , err := io .ReadAll (lr )
462+ if err != nil {
463+ return "" , err
464+ }
465+ if len (content ) > int (maxSize ) {
466+ return "" , fmt .Errorf ("file size exceeds the embedded image max limit \\ (%d bytes\\ )" , maxSize )
467+ }
468+
469+ if * totalEmbeddedImagesSize + int64 (len (content )) > setting .MailService .Base64EmbedImagesMaxSizePerEmail {
470+ return "" , fmt .Errorf ("total embedded images exceed max limit: %d > %d" , * totalEmbeddedImagesSize + int64 (len (content )), setting .MailService .Base64EmbedImagesMaxSizePerEmail )
471+ }
472+ * totalEmbeddedImagesSize += int64 (len (content ))
473+
474+ mimeType := http .DetectContentType (content )
475+
476+ if ! strings .HasPrefix (mimeType , "image/" ) {
477+ return "" , fmt .Errorf ("not an image" )
478+ }
479+
480+ encoded := base64 .StdEncoding .EncodeToString (content )
481+ dataURI := fmt .Sprintf ("data:%s;base64,%s" , mimeType , encoded )
482+
483+ return dataURI , nil
484+ }
485+
366486func generateMessageIDForIssue (issue * issues_model.Issue , comment * issues_model.Comment , actionType activities_model.ActionType ) string {
367487 var path string
368488 if issue .IsPull {
0 commit comments