Merge pull request #3668 from aduffeck/send-guestlink-emails

Send guestlink emails
This commit is contained in:
Andre Duffeck authored and GitHub committed 2026-10-06 15:43:15 +02:00
commit 1df2eb21d4
14 files changed
+267 -28

No files matched your search

+2 -2
View File
@@ -35,6 +35,7 @@ require (
github.com/go-playground/validator/v10 v10.30.3
github.com/go-resty/resty/v2 v2.17.2
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/gofrs/flock v0.13.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang/protobuf v1.5.4
github.com/google/go-cmp v0.7.0
@@ -65,7 +66,7 @@ require (
github.com/open-policy-agent/opa v1.19.1
github.com/opencloud-eu/icap-client v0.0.0-20250930132611-28a2afe62d89
github.com/opencloud-eu/libre-graph-api-go v1.0.8
github.com/opencloud-eu/reva/v2 v2.51.0
github.com/opencloud-eu/reva/v2 v2.51.1-0.20261006112042-7d9dd4c07424
github.com/opensearch-project/opensearch-go/v4 v4.7.3
github.com/orcaman/concurrent-map v1.0.0
github.com/pkg/errors v0.9.1
@@ -229,7 +230,6 @@ require (
github.com/gobwas/ws v1.4.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofrs/flock v0.13.1 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/snappy v1.0.0 // indirect
+2 -2
View File
@@ -761,8 +761,8 @@ github.com/opencloud-eu/icap-client v0.0.0-20250930132611-28a2afe62d89 h1:W1ms+l
github.com/opencloud-eu/icap-client v0.0.0-20250930132611-28a2afe62d89/go.mod h1:vigJkNss1N2QEceCuNw/ullDehncuJNFB6mEnzfq9UI=
github.com/opencloud-eu/libre-graph-api-go v1.0.8 h1:VTkFLkLNna+T4RN6JUZnVQ1bqVAfPtMeORXbFzEYGKs=
github.com/opencloud-eu/libre-graph-api-go v1.0.8/go.mod h1:lTM8JeGblNpoMySTW7Lui2+c5TTLI95mwxtdUIHHrhU=
github.com/opencloud-eu/reva/v2 v2.51.0 h1:PbvUtLlbCpS5em48cSGk2sHRdjVA7htRQVKjpckI2w4=
github.com/opencloud-eu/reva/v2 v2.51.0/go.mod h1:ngLsDakKnt8zCFCeULhuSZFESGy5NIuSq5/JGeOhPdU=
github.com/opencloud-eu/reva/v2 v2.51.1-0.20261006112042-7d9dd4c07424 h1:rvopdGQTsI0PT7P3MRamrDsO1yYIKJBOxJAbbYmc4P8=
github.com/opencloud-eu/reva/v2 v2.51.1-0.20261006112042-7d9dd4c07424/go.mod h1:ngLsDakKnt8zCFCeULhuSZFESGy5NIuSq5/JGeOhPdU=
github.com/opencloud-eu/secure v0.0.0-20260312082735-b6f5cb2244e4 h1:l2oB/RctH+t8r7QBj5p8thfEHCM/jF35aAY3WQ3hADI=
github.com/opencloud-eu/secure v0.0.0-20260312082735-b6f5cb2244e4/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -290,6 +290,9 @@ func FrontendConfigFromStruct(cfg *config.Config, logger log.Logger) (map[string
"outgoing": cfg.EnableFederatedSharingOutgoing,
"incoming": cfg.EnableFederatedSharingIncoming,
},
"guest_links": map[string]any{
"enabled": cfg.Commons.EnableGuestLinks,
},
},
"spaces": map[string]any{
"version": "1.0.0",
@@ -91,6 +91,7 @@ func Server(cfg *config.Config) *cobra.Command {
events.ScienceMeshInviteTokenGenerated{},
events.SendEmailsEvent{},
ocEvents.ResourceMention{},
ocEvents.GuestTokenCreated{},
}
registeredEvents := make(map[string]events.Unmarshaller)
for _, e := range evs {
@@ -49,6 +49,24 @@ Even though this share has been revoked you still might have access through othe
Even though this share has been revoked you still might have access through other shares and/or space memberships.`),
}
// Guest link templates
GuestLinkShareCreated = MessageTemplate{
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// GuestLinkShareCreated email template, Subject field (resolves directly)
Subject: l10n.Template(`{ShareSharer} shared '{ShareFolder}' with you`),
// GuestLinkShareCreated email template, resolves via {{ .Greeting }}
Greeting: l10n.Template(`Hello,`),
// GuestLinkShareCreated email template, resolves via {{ .MessageBody }}
MessageBody: l10n.Template(`{ShareSharer} has shared "{ShareFolder}" with you.
The link below is personal and can only be used once. Please do not forward it.
The link is only valid for 30 minutes.`),
// GuestLinkShareCreated email template, resolves via {{ .CallToAction }}
CallToAction: l10n.Template(`Click here to view it: {ShareLink}`),
}
// Spaces templates
SharedSpace = MessageTemplate{
textTemplate: _textTemplate,
@@ -0,0 +1,84 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0
package service
import (
"context"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"google.golang.org/protobuf/types/known/fieldmaskpb"
ocevents "github.com/opencloud-eu/opencloud/pkg/events"
"github.com/opencloud-eu/opencloud/services/notifications/pkg/channels"
"github.com/opencloud-eu/opencloud/services/notifications/pkg/email"
)
// handleGuestTokenCreated sends the guest link to the guest's email address.
// Guests have no account, settings or locale, so the mail is always sent
// instantly (the token is short-lived) in the default language.
func (s eventsNotifier) handleGuestTokenCreated(e ocevents.GuestTokenCreated) {
logger := s.logger.With().
Str("event", "GuestTokenCreated").
Str("itemid", e.ItemID.GetOpaqueId()).
Logger()
if err := validate.Var(e.GranteeEmail, "required,email"); err != nil {
logger.Error().Err(err).Msg("invalid guest email address")
return
}
if e.Token == "" {
logger.Error().Msg("guest token is empty")
return
}
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return
}
ctx, err := utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret)
if err != nil {
logger.Error().Err(err).Msg("could not get service user context")
return
}
owner, err := utils.GetUserNoGroups(ctx, e.Sharer, gatewayClient)
if err != nil {
logger.Error().Err(err).Msg("could not get user")
return
}
shareFolder := e.ResourceName
if shareFolder == "" {
resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
if err != nil {
logger.Error().Err(err).Msg("could not stat resource")
return
}
shareFolder = resourceInfo.GetName()
}
shareLink, err := urlJoinPath(s.openCloudURL, "g", e.Token)
if err != nil {
logger.Error().Err(err).Msg("could not create guest link")
return
}
msg, err := email.RenderEmailTemplate(email.GuestLinkShareCreated, s.defaultLanguage, s.defaultLanguage,
s.emailTemplatePath, s.translationPath,
map[string]string{
"ShareSharer": owner.GetDisplayName(),
"ShareFolder": shareFolder,
"ShareLink": shareLink,
})
if err != nil {
logger.Error().Err(err).Msg("could not render the email")
return
}
msg.Sender = owner.GetDisplayName()
msg.Recipient = []string{e.GranteeEmail}
s.send(ctx, []*channels.Message{msg})
}
@@ -131,6 +131,8 @@ EventLoop:
s.handleShareExpired(e, evt.ID)
case events.ShareRemoved:
s.handleShareRemoved(e, evt.ID)
case ocEvents.GuestTokenCreated:
s.handleGuestTokenCreated(e)
case events.ScienceMeshInviteTokenGenerated:
s.handleScienceMeshInviteTokenGenerated(e)
case events.SendEmailsEvent:
@@ -202,6 +204,10 @@ func (s eventsNotifier) ensureGranteeList(ctx context.Context, executant, u *use
func (s eventsNotifier) getGranteeList(ctx context.Context, executant, u *user.UserId, g *group.GroupId) ([]*user.User, error) {
switch {
case u != nil:
// guests aren't users, they are notified via GuestTokenCreated
if u.GetType() == user.UserType_USER_TYPE_GUEST {
return nil, nil
}
if s.disableEmails(ctx, u) {
return nil, nil
}
@@ -13,6 +13,7 @@ import (
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
ocEvents "github.com/opencloud-eu/opencloud/pkg/events"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/shared"
settingssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/settings/v0"
@@ -98,7 +99,7 @@ var _ = Describe("Notifications", func() {
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
ch := make(chan events.Event)
evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gatewaySelector, vs, "",
"", "", "", "", "", "",
"", "", "", testOpenCloudURL, "", "",
store.Create(), nil, nil)
go evts.Run()
@@ -118,7 +119,7 @@ var _ = Describe("Notifications", func() {
Dr. S. Harer has shared "secrets of the board" with you.
Click here to view it: files/shares/with-me
Click here to view it: https://cloud.example.com/files/shares/with-me
---
@@ -126,6 +127,7 @@ OpenCloud - a safe home for all your data
https://opencloud.eu
`,
expectedSender: sharer.GetDisplayName(),
expectedLink: testOpenCloudURL + "/files/shares/with-me",
done: make(chan struct{}),
}, events.Event{
Event: events.ShareCreated{
@@ -192,7 +194,7 @@ https://opencloud.eu
Dr. S. Harer has invited you to join "secret space".
Click here to view it: f/spaceid
Click here to view it: https://cloud.example.com/f/spaceid
---
@@ -200,6 +202,7 @@ OpenCloud - a safe home for all your data
https://opencloud.eu
`,
expectedSender: sharer.GetDisplayName(),
expectedLink: testOpenCloudURL + "/f/spaceid",
done: make(chan struct{}),
}, events.Event{
Event: events.SpaceShared{
@@ -219,7 +222,7 @@ Dr. S. Harer has removed you from "secret space".
You might still have access through your other groups or direct membership.
Click here to check it: f/spaceid
Click here to check it: https://cloud.example.com/f/spaceid
---
@@ -227,6 +230,7 @@ OpenCloud - a safe home for all your data
https://opencloud.eu
`,
expectedSender: sharer.GetDisplayName(),
expectedLink: testOpenCloudURL + "/f/spaceid",
done: make(chan struct{}),
}, events.Event{
Event: events.SpaceUnshared{
@@ -261,6 +265,79 @@ https://opencloud.eu
ExpiredAt: time.Date(2023, 4, 17, 16, 42, 0, 0, time.UTC),
},
}),
Entry("Guest Token Created", testChannel{
expectedReceipients: []string{"guest@example.com"},
expectedSubject: "Dr. S. Harer shared 'guest folder' with you",
expectedTextBody: `Hello,
Dr. S. Harer has shared "guest folder" with you.
The link below is personal and can only be used once. Please do not forward it.
The link is only valid for 30 minutes.
Click here to view it: https://cloud.example.com/g/v1.sharehash.secret
---
OpenCloud - a safe home for all your data
https://opencloud.eu
`,
expectedHTMLBody: `<!DOCTYPE html>
<html>
<body>
<table cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td>
<table cellspacing="0" cellpadding="0" border="0" width="600px">
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
Hello,
<br><br>
Dr. S. Harer has shared "guest folder" with you.<br><br>The link below is personal and can only be used once. Please do not forward it.<br><br>The link is only valid for 30 minutes.
<br><br>
Click here to view it: <a href="https://cloud.example.com/g/v1.sharehash.secret">https://cloud.example.com/g/v1.sharehash.secret</a>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
<footer>
<br>
<br>
--- <br>
OpenCloud - a safe home for all your data<br>
<a href="https://opencloud.eu">https://opencloud.eu</a>
</footer>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
expectedSender: sharer.GetDisplayName(),
expectedLink: testOpenCloudURL + "/g/v1.sharehash.secret",
done: make(chan struct{}),
}, events.Event{
Event: ocEvents.GuestTokenCreated{
Sharer: sharer.GetId(),
GranteeEmail: "guest@example.com",
ItemID: resourceid,
ResourceName: "guest folder",
Token: "v1.sharehash.secret",
},
}),
)
})
@@ -339,7 +416,7 @@ var _ = Describe("Notifications X-Site Scripting", func() {
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
ch := make(chan events.Event)
evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gatewaySelector, vs, "",
"", "", "", "", "", "",
"", "", "", testOpenCloudURL, "", "",
store.Create(), nil, nil)
go evts.Run()
@@ -359,7 +436,7 @@ var _ = Describe("Notifications X-Site Scripting", func() {
Dr. O'reilly has shared "<script>alert('secrets of the board');</script>" with you.
Click here to view it: files/shares/with-me
Click here to view it: https://cloud.example.com/files/shares/with-me
---
@@ -380,7 +457,7 @@ https://opencloud.eu
<br><br>
Dr. O&#39;reilly has shared "&lt;script&gt;alert(&#39;secrets of the board&#39;);&lt;/script&gt;" with you.
<br><br>
Click here to view it: <a href="files/shares/with-me">files/shares/with-me</a>
Click here to view it: <a href="https://cloud.example.com/files/shares/with-me">https://cloud.example.com/files/shares/with-me</a>
</td>
</tr>
<tr>
@@ -410,7 +487,8 @@ https://opencloud.eu
`,
expectedSender: sharer.GetDisplayName(),
done: make(chan struct{}),
expectedLink: testOpenCloudURL + "/files/shares/with-me",
done: make(chan struct{}),
}, events.Event{
Event: events.ShareCreated{
Sharer: sharer.GetId(),
@@ -427,7 +505,7 @@ https://opencloud.eu
Dr. O'reilly has invited you to join "<script>alert('secret space');</script>".
Click here to view it: f/spaceid
Click here to view it: https://cloud.example.com/f/spaceid
---
@@ -449,7 +527,7 @@ https://opencloud.eu
<br><br>
Dr. O&#39;reilly has invited you to join "&lt;script&gt;alert(&#39;secret space&#39;);&lt;/script&gt;".
<br><br>
Click here to view it: <a href="f/spaceid">f/spaceid</a>
Click here to view it: <a href="https://cloud.example.com/f/spaceid">https://cloud.example.com/f/spaceid</a>
</td>
</tr>
<tr>
@@ -477,7 +555,8 @@ https://opencloud.eu
</body>
</html>
`,
done: make(chan struct{}),
expectedLink: testOpenCloudURL + "/f/spaceid",
done: make(chan struct{}),
}, events.Event{
Event: events.SpaceShared{
Executant: sharer.GetId(),
@@ -489,14 +568,18 @@ https://opencloud.eu
)
})
// NOTE: This is explictitly not testing the message itself. Should we?
const testOpenCloudURL = "https://cloud.example.com"
type testChannel struct {
expectedReceipients []string
expectedSubject string
expectedTextBody string
expectedHTMLBody string
expectedSender string
done chan struct{}
// expectedLink, if set, must be an absolute URL on the OpenCloud instance
// and must be linked in the HTML body.
expectedLink string
done chan struct{}
}
func (tc testChannel) SendMessage(ctx context.Context, m *channels.Message) error {
@@ -506,9 +589,15 @@ func (tc testChannel) SendMessage(ctx context.Context, m *channels.Message) erro
Expect(tc.expectedSubject).To(Equal(m.Subject))
Expect(tc.expectedTextBody).To(Equal(m.TextBody))
Expect(tc.expectedSender).To(Equal(m.Sender))
Expect(m.HTMLBody).ToNot(BeEmpty())
if tc.expectedHTMLBody != "" {
Expect(tc.expectedHTMLBody).To(Equal(m.HTMLBody))
}
if tc.expectedLink != "" {
Expect(tc.expectedLink).To(HavePrefix(testOpenCloudURL + "/"))
Expect(m.TextBody).To(ContainSubstring(tc.expectedLink))
Expect(m.HTMLBody).To(ContainSubstring(`<a href="` + tc.expectedLink + `">`))
}
tc.done <- struct{}{}
return nil
}
@@ -198,6 +198,12 @@ func (h *Handler) Init(c *config.Config) {
// h.c.Capabilities.FilesSharing.Federation.Outgoing is boolean
// h.c.Capabilities.FilesSharing.Federation.Incoming is boolean
if h.c.Capabilities.FilesSharing.GuestLinks == nil {
h.c.Capabilities.FilesSharing.GuestLinks = &ocs.CapabilitiesFilesSharingGuestLinks{}
}
// h.c.Capabilities.FilesSharing.GuestLinks.Enabled is boolean
if h.c.Capabilities.FilesSharing.SearchMinLength == 0 {
h.c.Capabilities.FilesSharing.SearchMinLength = 2
}
@@ -225,6 +225,7 @@ type CapabilitiesFilesSharing struct {
DefaultPermissions int `json:"default_permissions" xml:"default_permissions" mapstructure:"default_permissions"`
UserEnumeration *CapabilitiesFilesSharingUserEnumeration `json:"user_enumeration" xml:"user_enumeration" mapstructure:"user_enumeration"`
Federation *CapabilitiesFilesSharingFederation `json:"federation" xml:"federation"`
GuestLinks *CapabilitiesFilesSharingGuestLinks `json:"guest_links" xml:"guest_links" mapstructure:"guest_links"`
Public *CapabilitiesFilesSharingPublic `json:"public" xml:"public"`
User *CapabilitiesFilesSharingUser `json:"user" xml:"user"`
// TODO: Remove next line once web defaults to resharing=false
@@ -291,6 +292,11 @@ type CapabilitiesFilesSharingFederation struct {
Incoming ocsBool `json:"incoming" xml:"incoming"`
}
// CapabilitiesFilesSharingGuestLinks holds the guest links capabilities
type CapabilitiesFilesSharingGuestLinks struct {
Enabled ocsBool `json:"enabled" xml:"enabled" mapstructure:"enabled"`
}
// CapabilitiesNotifications holds a list of notification endpoints
type CapabilitiesNotifications struct {
Endpoints []string `json:"ocs-endpoints,omitempty" xml:"ocs-endpoints>element,omitempty" mapstructure:"endpoints"`
@@ -53,7 +53,7 @@ func New(root string) (*Blobstore, error) {
}
// Upload stores some data in the blobstore under the given key
func (bs *Blobstore) Upload(node *node.Node, source, _copyTarget string) error {
func (bs *Blobstore) Upload(node *node.Node, source, _copyTarget string) (err error) {
if node.BlobID == "" {
return ErrBlobIDEmpty
}
@@ -80,14 +80,27 @@ func (bs *Blobstore) Upload(node *node.Node, source, _copyTarget string) error {
if err != nil {
return errors.Wrapf(err, "could not open blob '%s' for writing", dest)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = errors.Wrapf(cerr, "could not close blob '%s'", dest)
}
}()
w := bufio.NewWriter(f)
_, err = w.ReadFrom(file)
if err != nil {
if _, err = w.ReadFrom(file); err != nil {
return errors.Wrapf(err, "could not write blob '%s'", dest)
}
return w.Flush()
if err = w.Flush(); err != nil {
return errors.Wrapf(err, "could not flush blob '%s'", dest)
}
// Ensure data is synced to disk before returning
if err = f.Sync(); err != nil {
return errors.Wrapf(err, "could not sync blob '%s' to disk", dest)
}
return nil
}
// Download retrieves a blob from the blobstore for reading
@@ -53,7 +53,7 @@ func New(root string) (*Blobstore, error) {
}
// Upload stores some data in the blobstore under the given key
func (bs *Blobstore) Upload(node *node.Node, source string) error {
func (bs *Blobstore) Upload(node *node.Node, source string) (err error) {
if node.BlobID == "" {
return ErrBlobIDEmpty
}
@@ -80,14 +80,27 @@ func (bs *Blobstore) Upload(node *node.Node, source string) error {
if err != nil {
return errors.Wrapf(err, "could not open blob '%s' for writing", dest)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = errors.Wrapf(cerr, "could not close blob '%s'", dest)
}
}()
w := bufio.NewWriter(f)
_, err = w.ReadFrom(file)
if err != nil {
if _, err = w.ReadFrom(file); err != nil {
return errors.Wrapf(err, "could not write blob '%s'", dest)
}
return w.Flush()
if err = w.Flush(); err != nil {
return errors.Wrapf(err, "could not flush blob '%s'", dest)
}
// Ensure data is synced to disk before returning
if err = f.Sync(); err != nil {
return errors.Wrapf(err, "could not sync blob '%s' to disk", dest)
}
return nil
}
// Download retrieves a blob from the blobstore for reading
@@ -95,6 +95,7 @@ func (bs *Blobstore) Upload(n *node.Node, source, copyTarget string) error {
}
if err := copyWithPeriodicSync(tempFile, sourceFile); err != nil {
_ = tempFile.Close()
return fmt.Errorf("failed to copy source file '%s' to temp file '%s' - %v", source, tempName, err)
}
@@ -204,8 +205,7 @@ func copyWithPeriodicSync(dst, src *os.File) error {
}
}
if err == io.EOF {
_ = dst.Sync()
return nil
return dst.Sync()
}
if err != nil {
return err
+1 -1
View File
@@ -1362,7 +1362,7 @@ github.com/opencloud-eu/icap-client
# github.com/opencloud-eu/libre-graph-api-go v1.0.8
## explicit; go 1.23
github.com/opencloud-eu/libre-graph-api-go
# github.com/opencloud-eu/reva/v2 v2.51.0
# github.com/opencloud-eu/reva/v2 v2.51.1-0.20261006112042-7d9dd4c07424
## explicit; go 1.26.0
github.com/opencloud-eu/reva/v2/cmd/revad/internal/grace
github.com/opencloud-eu/reva/v2/cmd/revad/runtime