11package antivirus
22
33import (
4+ "fmt"
45 "os"
56 "runtime"
67 "time"
78
89 "github.com/cozy/cozy-stack/model/instance"
910 "github.com/cozy/cozy-stack/model/job"
11+ "github.com/cozy/cozy-stack/model/notification"
12+ "github.com/cozy/cozy-stack/model/notification/center"
1013 "github.com/cozy/cozy-stack/model/vfs"
1114 "github.com/cozy/cozy-stack/pkg/clamav"
1215 "github.com/cozy/cozy-stack/pkg/config/config"
16+ "github.com/cozy/cozy-stack/pkg/consts"
1317 "github.com/cozy/cozy-stack/pkg/couchdb"
1418)
1519
@@ -66,7 +70,7 @@ func Worker(ctx *job.TaskContext) error {
6670
6771 if avConfig .MaxFileSize > 0 && file .ByteSize > avConfig .MaxFileSize {
6872 log .Infof ("File %s is too large (%d bytes > %d max), skipping scan" , msg .FileID , file .ByteSize , avConfig .MaxFileSize )
69- return updateScanStatus (fs , file , & vfs.AntivirusScan {
73+ return updateScanStatus (inst , file , & vfs.AntivirusScan {
7074 Status : vfs .AVStatusSkipped ,
7175 })
7276 }
@@ -83,7 +87,7 @@ func Worker(ctx *job.TaskContext) error {
8387 content , err := fs .OpenFile (file )
8488 if err != nil {
8589 log .Errorf ("Failed to open file %s: %v" , msg .FileID , err )
86- return updateScanStatus (fs , file , & vfs.AntivirusScan {
90+ return updateScanStatus (inst , file , & vfs.AntivirusScan {
8791 Status : vfs .AVStatusError ,
8892 Error : err .Error (),
8993 })
@@ -95,7 +99,7 @@ func Worker(ctx *job.TaskContext) error {
9599 result , err := client .Scan (ctx , content )
96100 if err != nil {
97101 log .Errorf ("Failed to scan the file %s: %v" , msg .FileID , err )
98- return updateScanStatus (fs , file , & vfs.AntivirusScan {
102+ return updateScanStatus (inst , file , & vfs.AntivirusScan {
99103 Status : vfs .AVStatusError ,
100104 Error : err .Error (),
101105 })
@@ -122,14 +126,16 @@ func Worker(ctx *job.TaskContext) error {
122126 }
123127
124128 log .Debugf ("Updating scan status for file %s: status=%s" , msg .FileID , st .Status )
125- return updateScanStatus (fs , file , st )
129+ return updateScanStatus (inst , file , st )
126130}
127131
128132// updateScanStatus updates the file document with the scan status.
129133// It retries on conflict errors to handle race conditions with the trigger.
130- func updateScanStatus (fs vfs.VFS , file * vfs.FileDoc , scan * vfs.AntivirusScan ) error {
134+ // It also sends a notification when the status is infected, skipped, or error.
135+ func updateScanStatus (inst * instance.Instance , file * vfs.FileDoc , scan * vfs.AntivirusScan ) error {
131136 const maxRetries = 3
132137 var err error
138+ fs := inst .VFS ()
133139
134140 for i := 0 ; i < maxRetries ; i ++ {
135141 var f * vfs.FileDoc
@@ -141,13 +147,19 @@ func updateScanStatus(fs vfs.VFS, file *vfs.FileDoc, scan *vfs.AntivirusScan) er
141147 newdoc .AntivirusScan = scan
142148 err = couchdb .UpdateDoc (fs , newdoc )
143149 if err == nil {
150+ // Send notification for non-clean statuses
151+ if scan .Status != vfs .AVStatusClean && scan .Status != vfs .AVStatusPending {
152+ issueDesc := getIssueDescription (inst , scan )
153+ sendNotification (inst , file , scan .Status , issueDesc )
154+ }
144155 return nil
145156 }
146157 if ! couchdb .IsConflictError (err ) {
147158 return err
148159 }
149160 // Conflict error, retry with fresh document
150161 }
162+
151163 return err
152164}
153165
@@ -156,3 +168,58 @@ func isEnabledForInstance(inst *instance.Instance) bool {
156168 cfg := config .GetAntivirusConfig (inst .ContextName )
157169 return cfg != nil && cfg .Enabled
158170}
171+
172+ // sendNotification sends a notification to the user about an antivirus issue
173+ // using the notification center.
174+ func sendNotification (inst * instance.Instance , file * vfs.FileDoc , status string , issueDescription string ) {
175+ avConfig := config .GetAntivirusConfig (inst .ContextName )
176+ if avConfig == nil || ! avConfig .Notifications .EmailOnInfected {
177+ return
178+ }
179+
180+ log := inst .Logger ().WithNamespace ("antivirus" )
181+
182+ driveURL := inst .SubDomain ("drive" )
183+ driveURL .Fragment = fmt .Sprintf ("/folder/%s/file/%s" , file .DirID , file .DocID )
184+ fileURL := driveURL .String ()
185+
186+ n := & notification.Notification {
187+ Title : inst .Translate ("Mail Antivirus Alert Title" ),
188+ Slug : consts .DriveSlug ,
189+ Data : map [string ]interface {}{
190+ "IssueDescription" : issueDescription ,
191+ "FileName" : file .DocName ,
192+ "FileURL" : fileURL ,
193+ },
194+ PreferredChannels : []string {"mail" },
195+ }
196+
197+ err := center .PushStack (inst .DomainName (), center .NotificationAntivirusAlert , n )
198+ if err != nil {
199+ log .Errorf ("Failed to push antivirus notification: %v" , err )
200+ return
201+ }
202+
203+ log .Infof ("Sent antivirus notification for file %s (status: %s)" , file .DocID , status )
204+ return
205+ }
206+
207+ // getIssueDescription returns a translated issue description for the given status.
208+ func getIssueDescription (inst * instance.Instance , scan * vfs.AntivirusScan ) string {
209+ switch scan .Status {
210+ case vfs .AVStatusInfected :
211+ if scan .VirusName != "" {
212+ return inst .Translate ("Mail Antivirus Issue Infected" ) + " (" + scan .VirusName + ")"
213+ }
214+ return inst .Translate ("Mail Antivirus Issue Infected" )
215+ case vfs .AVStatusSkipped :
216+ return inst .Translate ("Mail Antivirus Issue Skipped" )
217+ case vfs .AVStatusError :
218+ if scan .Error != "" {
219+ return inst .Translate ("Mail Antivirus Issue Error" ) + ": " + scan .Error
220+ }
221+ return inst .Translate ("Mail Antivirus Issue Error" )
222+ default :
223+ return ""
224+ }
225+ }
0 commit comments