Skip to content

Commit 0e769ad

Browse files
authored
feat: Added notification about antivirus alerts (#4629)
2 parents a93ccdb + 033776e commit 0e769ad

File tree

9 files changed

+49819
-30459
lines changed

9 files changed

+49819
-30459
lines changed

assets/locales/en.po

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1300,4 +1300,31 @@ msgid "Mail Sharing File Changed Link Text"
13001300
msgstr "See on Twake Drive"
13011301

13021302
msgid "Mail Sharing Folder Changed Intro"
1303-
msgstr "Someone shared a folder with you:"
1303+
msgstr "Someone shared a folder with you:"
1304+
1305+
msgid "Mail Antivirus Alert Subject"
1306+
msgstr "Antivirus Alert on Your Twake Drive"
1307+
1308+
msgid "Mail Antivirus Alert Title"
1309+
msgstr "Antivirus Alert on Your Twake Drive"
1310+
1311+
msgid "Mail Antivirus Alert Description"
1312+
msgstr "Antivirus scan for a file on your Twake Drive has flagged an issue."
1313+
1314+
msgid "Mail Antivirus Issue Label"
1315+
msgstr "Issue:"
1316+
1317+
msgid "Mail Antivirus Issue Infected"
1318+
msgstr "Virus detected"
1319+
1320+
msgid "Mail Antivirus Issue Skipped"
1321+
msgstr "File too large to be scanned"
1322+
1323+
msgid "Mail Antivirus Issue Error"
1324+
msgstr "Scan error"
1325+
1326+
msgid "Mail Antivirus File Label"
1327+
msgstr "File:"
1328+
1329+
msgid "Mail Antivirus View Details Button"
1330+
msgstr "View File Details"

assets/locales/fr.po

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,3 +1415,30 @@ msgstr "Voir sur Twake Drive"
14151415

14161416
msgid "Mail Sharing Folder Changed Intro"
14171417
msgstr "Quelqu'un a partagé un dossier avec vous :"
1418+
1419+
msgid "Mail Antivirus Alert Subject"
1420+
msgstr "Alerte antivirus sur votre Twake Drive"
1421+
1422+
msgid "Mail Antivirus Alert Title"
1423+
msgstr "Alerte antivirus sur votre Twake Drive"
1424+
1425+
msgid "Mail Antivirus Alert Description"
1426+
msgstr "L'analyse antivirus d'un fichier sur votre Twake Drive a signalé un problème."
1427+
1428+
msgid "Mail Antivirus Issue Label"
1429+
msgstr "Problème :"
1430+
1431+
msgid "Mail Antivirus Issue Infected"
1432+
msgstr "Virus détecté"
1433+
1434+
msgid "Mail Antivirus Issue Skipped"
1435+
msgstr "Fichier trop volumineux pour être analysé"
1436+
1437+
msgid "Mail Antivirus Issue Error"
1438+
msgstr "Erreur d'analyse"
1439+
1440+
msgid "Mail Antivirus File Label"
1441+
msgstr "Fichier :"
1442+
1443+
msgid "Mail Antivirus View Details Button"
1444+
msgstr "Voir les détails du fichier"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{{define "content"}}
2+
<mj-text mj-class="content-medium" color="#999999">
3+
<span style="color: #e74c3c; font-size: 14px;">&#128683;</span>&nbsp;
4+
{{t "Mail Antivirus Issue Label"}} {{.IssueDescription}}
5+
</mj-text>
6+
<mj-text mj-class="title content-medium">
7+
{{t "Mail Antivirus Alert Title"}}
8+
</mj-text>
9+
<mj-text mj-class="content-medium">
10+
{{t "Mail Antivirus Alert Description"}}
11+
</mj-text>
12+
<mj-text mj-class="content-medium">
13+
<strong>{{t "Mail Antivirus File Label"}}</strong> {{.FileName}}
14+
</mj-text>
15+
<mj-button href="{{.FileURL}}" align="left" mj-class="primary-button content-xlarge">
16+
{{t "Mail Antivirus View Details Button"}}
17+
</mj-button>
18+
{{end}}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{{t "Mail Antivirus Issue Label"}} {{.IssueDescription}}
2+
3+
{{t "Mail Antivirus Alert Title"}}
4+
5+
{{t "Mail Antivirus Alert Description"}}
6+
7+
{{t "Mail Antivirus File Label"}} {{.FileName}}
8+
9+
{{t "Mail Antivirus View Details Button"}}
10+
11+
[{{.FileURL}}]

model/notification/center/notification_center.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ const (
3131
// NotificationSharingFileChanged category for sending alert when a file or
3232
// folder is created in a sharing.
3333
NotificationSharingFileChanged = "sharing-file-changed"
34+
// NotificationAntivirusAlert category for sending alert when antivirus
35+
// scanning detects an issue with a file (infected, too large, or error).
36+
NotificationAntivirusAlert = "antivirus-alert"
3437
)
3538

3639
var (
@@ -54,6 +57,12 @@ var (
5457
Stateful: false,
5558
MailTemplate: "sharing_file_changed",
5659
},
60+
NotificationAntivirusAlert: {
61+
Description: "Alert when antivirus scanning detects an issue with a file",
62+
Collapsible: false,
63+
Stateful: false,
64+
MailTemplate: "notifications_antivirus",
65+
},
5766
}
5867
)
5968

web/statik/statik.go

Lines changed: 49624 additions & 30453 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

worker/antivirus/antivirus.go

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
package antivirus
22

33
import (
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+
}

worker/antivirus/antivirus_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import (
99
"github.com/cozy/cozy-stack/model/instance"
1010
"github.com/cozy/cozy-stack/model/instance/lifecycle"
1111
"github.com/cozy/cozy-stack/model/job"
12+
"github.com/cozy/cozy-stack/model/notification"
1213
"github.com/cozy/cozy-stack/model/vfs"
1314
"github.com/cozy/cozy-stack/pkg/clamav"
1415
"github.com/cozy/cozy-stack/pkg/config/config"
16+
"github.com/cozy/cozy-stack/pkg/consts"
1517
"github.com/cozy/cozy-stack/pkg/couchdb"
1618
"github.com/cozy/cozy-stack/pkg/logger"
1719
"github.com/cozy/cozy-stack/tests/testutils"
@@ -79,6 +81,9 @@ func TestAntivirus(t *testing.T) {
7981
"address": clamavAddress,
8082
"timeout": 30 * time.Second,
8183
"max_file_size": int64(0),
84+
"notifications": map[string]interface{}{
85+
"email_on_infected": true,
86+
},
8287
},
8388
}
8489
t.Cleanup(func() {
@@ -179,6 +184,9 @@ func testWorkerInfectedFile(t *testing.T, inst *instance.Instance) {
179184
require.Equal(t, vfs.AVStatusInfected, updatedDoc.AntivirusScan.Status)
180185
require.NotNil(t, updatedDoc.AntivirusScan.ScannedAt)
181186
require.Contains(t, updatedDoc.AntivirusScan.VirusName, "EICAR")
187+
188+
// Verify notification was sent
189+
verifyNotificationCreated(t, inst, "infected-test.txt")
182190
}
183191

184192
func testWorkerSkipsLargeFiles(t *testing.T, inst *instance.Instance) {
@@ -218,6 +226,9 @@ func testWorkerSkipsLargeFiles(t *testing.T, inst *instance.Instance) {
218226
require.NoError(t, err)
219227
require.NotNil(t, updatedDoc.AntivirusScan)
220228
require.Equal(t, vfs.AVStatusSkipped, updatedDoc.AntivirusScan.Status)
229+
230+
// Verify notification was sent
231+
verifyNotificationCreated(t, inst, "large-test.txt")
221232
}
222233

223234
func testWorkerDeletedFile(t *testing.T, inst *instance.Instance) {
@@ -296,3 +307,21 @@ func deleteTestFile(t *testing.T, fs vfs.VFS, doc *vfs.FileDoc) {
296307
t.Helper()
297308
_ = fs.DestroyFile(doc)
298309
}
310+
311+
func verifyNotificationCreated(t *testing.T, inst *instance.Instance, fileName string) {
312+
t.Helper()
313+
var notifs []*notification.Notification
314+
err := couchdb.GetAllDocs(inst, consts.Notifications, nil, &notifs)
315+
require.NoError(t, err)
316+
317+
found := false
318+
for _, n := range notifs {
319+
if n.Category == "antivirus-alert" {
320+
if data, ok := n.Data["FileName"].(string); ok && data == fileName {
321+
found = true
322+
break
323+
}
324+
}
325+
}
326+
require.True(t, found, "Expected notification for file %s not found", fileName)
327+
}

worker/mails/mail_templates.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func initMailTemplates() {
3838
"notifications_sharing": subjectEntry{"Notification Sharing Subject", []string{"SharerPublicName", "TitleType"}},
3939
"notifications_diskquota": subjectEntry{"Notifications Disk Quota Subject", nil},
4040
"notifications_oauthclients": subjectEntry{"Notifications OAuth Clients Subject", nil},
41+
"notifications_antivirus": subjectEntry{"Mail Antivirus Alert Subject", nil},
4142
"update_email": subjectEntry{"Mail Update Email Subject", nil},
4243
}
4344
}

0 commit comments

Comments
 (0)