@@ -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,24 @@ 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 "code.gitea.io/gitea/models/renderhelper"
2226 repo_model "code.gitea.io/gitea/models/repo"
27+ "code.gitea.io/gitea/models/unit"
2328 user_model "code.gitea.io/gitea/models/user"
2429 "code.gitea.io/gitea/modules/emoji"
2530 "code.gitea.io/gitea/modules/log"
2631 "code.gitea.io/gitea/modules/markup/markdown"
2732 "code.gitea.io/gitea/modules/setting"
33+ "code.gitea.io/gitea/modules/storage"
2834 "code.gitea.io/gitea/modules/templates"
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 sender_service "code.gitea.io/gitea/services/mailer/sender"
3339 "code.gitea.io/gitea/services/mailer/token"
40+
41+ "golang.org/x/net/html"
3442)
3543
3644const (
@@ -228,6 +236,15 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
228236 return nil , err
229237 }
230238
239+ if setting .MailService .Base64EmbedImages {
240+ bodyStr := string (body )
241+ bodyStr , err = Base64InlineImages (bodyStr , ctx )
242+ if err != nil {
243+ return nil , err
244+ }
245+ body = template .HTML (bodyStr )
246+ }
247+
231248 actType , actName , tplName := actionToTemplate (ctx .Issue , ctx .ActionType , commentType , reviewType )
232249
233250 if actName != "new" {
@@ -359,6 +376,110 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
359376 return msgs , nil
360377}
361378
379+ func Base64InlineImages (body string , ctx * mailCommentContext ) (string , error ) {
380+ doc , err := html .Parse (strings .NewReader (body ))
381+ if err != nil {
382+ log .Error ("Failed to parse HTML body: %v" , err )
383+ return "" , err
384+ }
385+
386+ var totalEmbeddedImagesSize int64
387+
388+ var processNode func (* html.Node )
389+ processNode = func (n * html.Node ) {
390+ if n .Type == html .ElementNode {
391+ if n .Data == "img" {
392+ for i , attr := range n .Attr {
393+ if attr .Key == "src" {
394+ attachmentPath := attr .Val
395+ dataURI , err := AttachmentSrcToBase64DataURI (attachmentPath , ctx , & totalEmbeddedImagesSize )
396+ if err != nil {
397+ log .Trace ("attachmentSrcToDataURI not possible: %v" , err ) // Not an error, just skip. This is probably an image from outside the gitea instance.
398+ continue
399+ }
400+ log .Trace ("Old value of src attribute: %s, new value (first 100 characters): %s" , attr .Val , dataURI [:100 ])
401+ n .Attr [i ].Val = dataURI
402+ break
403+ }
404+ }
405+ }
406+ }
407+
408+ for c := n .FirstChild ; c != nil ; c = c .NextSibling {
409+ processNode (c )
410+ }
411+ }
412+
413+ processNode (doc )
414+
415+ var buf bytes.Buffer
416+ err = html .Render (& buf , doc )
417+ if err != nil {
418+ log .Error ("Failed to render modified HTML: %v" , err )
419+ return "" , err
420+ }
421+ return buf .String (), nil
422+ }
423+
424+ func AttachmentSrcToBase64DataURI (attachmentPath string , ctx * mailCommentContext , totalEmbeddedImagesSize * int64 ) (string , error ) {
425+ if ! strings .HasPrefix (attachmentPath , setting .AppURL ) { // external image
426+ return "" , fmt .Errorf ("external image" )
427+ }
428+ parts := strings .Split (attachmentPath , "/attachments/" )
429+ if len (parts ) <= 1 {
430+ return "" , fmt .Errorf ("invalid attachment path: %s" , attachmentPath )
431+ }
432+
433+ attachmentUUID := parts [len (parts )- 1 ]
434+ attachment , err := repo_model .GetAttachmentByUUID (ctx , attachmentUUID )
435+ if err != nil {
436+ return "" , err
437+ }
438+
439+ // "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.
440+ // Therefore we check the Doer, with which we counter leaking information as a Doer brute force attack on attachments would be possible.
441+ perm , err := access_model .GetUserRepoPermission (ctx , ctx .Issue .Repo , ctx .Doer )
442+ if err != nil {
443+ return "" , err
444+ }
445+ if ! perm .CanRead (unit .TypeIssues ) {
446+ return "" , fmt .Errorf ("no permission" )
447+ }
448+
449+ fr , err := storage .Attachments .Open (attachment .RelativePath ())
450+ if err != nil {
451+ return "" , err
452+ }
453+ defer fr .Close ()
454+
455+ maxSize := setting .MailService .Base64EmbedImagesMaxSizePerEmail // at maximum read the whole available combined email size, to prevent maliciously large file reads
456+
457+ lr := & io.LimitedReader {R : fr , N : maxSize + 1 }
458+ content , err := io .ReadAll (lr )
459+ if err != nil {
460+ return "" , err
461+ }
462+ if len (content ) > int (maxSize ) {
463+ return "" , fmt .Errorf ("file size exceeds the embedded image max limit \\ (%d bytes\\ )" , maxSize )
464+ }
465+
466+ if * totalEmbeddedImagesSize + int64 (len (content )) > setting .MailService .Base64EmbedImagesMaxSizePerEmail {
467+ return "" , fmt .Errorf ("total embedded images exceed max limit: %d > %d" , * totalEmbeddedImagesSize + int64 (len (content )), setting .MailService .Base64EmbedImagesMaxSizePerEmail )
468+ }
469+ * totalEmbeddedImagesSize += int64 (len (content ))
470+
471+ mimeType := http .DetectContentType (content )
472+
473+ if ! strings .HasPrefix (mimeType , "image/" ) {
474+ return "" , fmt .Errorf ("not an image" )
475+ }
476+
477+ encoded := base64 .StdEncoding .EncodeToString (content )
478+ dataURI := fmt .Sprintf ("data:%s;base64,%s" , mimeType , encoded )
479+
480+ return dataURI , nil
481+ }
482+
362483func generateMessageIDForIssue (issue * issues_model.Issue , comment * issues_model.Comment , actionType activities_model.ActionType ) string {
363484 var path string
364485 if issue .IsPull {
0 commit comments