123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338 |
- package controller
- import (
- "context"
- "errors"
- "fmt"
- "github.com/ente-io/museum/ente"
- enteJWT "github.com/ente-io/museum/ente/jwt"
- emailCtrl "github.com/ente-io/museum/pkg/controller/email"
- "github.com/ente-io/museum/pkg/repo"
- "github.com/ente-io/museum/pkg/utils/auth"
- "github.com/ente-io/museum/pkg/utils/email"
- "github.com/ente-io/museum/pkg/utils/time"
- "github.com/ente-io/stacktrace"
- "github.com/gin-gonic/gin"
- "github.com/golang-jwt/jwt"
- "github.com/lithammer/shortuuid/v3"
- "github.com/sirupsen/logrus"
- )
- var AllowedReasons = map[string]string{
- "COPYRIGHT": "Copyright Infringement",
- "MALICIOUS_CONTENT": "Malicious Content",
- }
- const (
- AccessTokenLength = 8
- // AutoDisableAbuseThreshold indicates minimum number of abuse reports post which the access token is
- // automatically disabled
- AutoDisableAbuseThreshold = 3
- // DeviceLimitThreshold represents number of unique devices which can access a shared collection. (ip + user agent)
- // is treated as unique device
- DeviceLimitThreshold = 50
- DeviceLimitThresholdMultiplier = 10
- DeviceLimitWarningThreshold = 2000
- AbuseAlertSubject = "[Alert] Abuse report received against your album on ente"
- AbuseAlertTeamSubject = "Abuse report received"
- AbuseLimitExceededSubject = "[Alert] Too many abuse reports received against your album on ente"
- AbuseAlertTemplate = "report_alert.html"
- AbuseLimitExceededTemplate = "report_limit_exceeded_alert.html"
- )
- // PublicCollectionController controls share collection operations
- type PublicCollectionController struct {
- FileController *FileController
- EmailNotificationCtrl *emailCtrl.EmailNotificationController
- PublicCollectionRepo *repo.PublicCollectionRepository
- CollectionRepo *repo.CollectionRepository
- UserRepo *repo.UserRepository
- JwtSecret []byte
- }
- func (c *PublicCollectionController) CreateAccessToken(ctx context.Context, req ente.CreatePublicAccessTokenRequest) (ente.PublicURL, error) {
- accessToken := shortuuid.New()[0:AccessTokenLength]
- err := c.PublicCollectionRepo.Insert(ctx, req.CollectionID, accessToken, req.ValidTill, req.DeviceLimit, req.EnableCollect)
- if err != nil {
- if errors.Is(err, ente.ErrActiveLinkAlreadyExists) {
- collectionToPubUrlMap, err2 := c.PublicCollectionRepo.GetCollectionToActivePublicURLMap(ctx, []int64{req.CollectionID})
- if err2 != nil {
- return ente.PublicURL{}, stacktrace.Propagate(err2, "")
- }
- if publicUrls, ok := collectionToPubUrlMap[req.CollectionID]; ok {
- if len(publicUrls) > 0 {
- return publicUrls[0], nil
- }
- }
- // ideally we should never reach here
- return ente.PublicURL{}, stacktrace.NewError("Unexpected state")
- } else {
- return ente.PublicURL{}, stacktrace.Propagate(err, "")
- }
- }
- response := ente.PublicURL{
- URL: c.PublicCollectionRepo.GetAlbumUrl(accessToken),
- ValidTill: req.ValidTill,
- DeviceLimit: req.DeviceLimit,
- EnableDownload: true,
- EnableCollect: req.EnableCollect,
- PasswordEnabled: false,
- }
- return response, nil
- }
- func (c *PublicCollectionController) CreateFile(ctx *gin.Context, file ente.File, app ente.App) (ente.File, error) {
- collection, err := c.GetPublicCollection(ctx, true)
- if err != nil {
- return ente.File{}, stacktrace.Propagate(err, "")
- }
- collectionOwnerID := collection.Owner.ID
- // Do not let any update happen via public Url
- file.ID = 0
- file.OwnerID = collectionOwnerID
- file.UpdationTime = time.Microseconds()
- file.IsDeleted = false
- createdFile, err := c.FileController.Create(ctx, collectionOwnerID, file, ctx.Request.UserAgent(), app)
- if err != nil {
- return ente.File{}, stacktrace.Propagate(err, "")
- }
- // Note: Stop sending email notification for public collection till
- // we add in-app setting to enable/disable email notifications
- //go c.EmailNotificationCtrl.OnFilesCollected(file.OwnerID)
- return createdFile, nil
- }
- // Disable all public accessTokens generated for the given cID till date.
- func (c *PublicCollectionController) Disable(ctx context.Context, cID int64) error {
- err := c.PublicCollectionRepo.DisableSharing(ctx, cID)
- return stacktrace.Propagate(err, "")
- }
- func (c *PublicCollectionController) UpdateSharedUrl(ctx context.Context, req ente.UpdatePublicAccessTokenRequest) (ente.PublicURL, error) {
- publicCollectionToken, err := c.PublicCollectionRepo.GetActivePublicCollectionToken(ctx, req.CollectionID)
- if err != nil {
- return ente.PublicURL{}, err
- }
- if req.ValidTill != nil {
- publicCollectionToken.ValidTill = *req.ValidTill
- }
- if req.DeviceLimit != nil {
- publicCollectionToken.DeviceLimit = *req.DeviceLimit
- }
- if req.PassHash != nil && req.Nonce != nil && req.OpsLimit != nil && req.MemLimit != nil {
- publicCollectionToken.PassHash = req.PassHash
- publicCollectionToken.Nonce = req.Nonce
- publicCollectionToken.OpsLimit = req.OpsLimit
- publicCollectionToken.MemLimit = req.MemLimit
- } else if req.DisablePassword != nil && *req.DisablePassword {
- publicCollectionToken.PassHash = nil
- publicCollectionToken.Nonce = nil
- publicCollectionToken.OpsLimit = nil
- publicCollectionToken.MemLimit = nil
- }
- if req.EnableDownload != nil {
- publicCollectionToken.EnableDownload = *req.EnableDownload
- }
- if req.EnableCollect != nil {
- publicCollectionToken.EnableCollect = *req.EnableCollect
- }
- err = c.PublicCollectionRepo.UpdatePublicCollectionToken(ctx, publicCollectionToken)
- if err != nil {
- return ente.PublicURL{}, stacktrace.Propagate(err, "")
- }
- return ente.PublicURL{
- URL: c.PublicCollectionRepo.GetAlbumUrl(publicCollectionToken.Token),
- DeviceLimit: publicCollectionToken.DeviceLimit,
- ValidTill: publicCollectionToken.ValidTill,
- EnableDownload: publicCollectionToken.EnableDownload,
- EnableCollect: publicCollectionToken.EnableCollect,
- PasswordEnabled: publicCollectionToken.PassHash != nil && *publicCollectionToken.PassHash != "",
- Nonce: publicCollectionToken.Nonce,
- MemLimit: publicCollectionToken.MemLimit,
- OpsLimit: publicCollectionToken.OpsLimit,
- }, nil
- }
- // VerifyPassword verifies if the user has provided correct pw hash. If yes, it returns a signed jwt token which can be
- // used by the client to pass in other requests for public collection.
- // Having a separate endpoint for password validation allows us to easily rate-limit the attempts for brute-force
- // attack for guessing password.
- func (c *PublicCollectionController) VerifyPassword(ctx *gin.Context, req ente.VerifyPasswordRequest) (*ente.VerifyPasswordResponse, error) {
- accessContext := auth.MustGetPublicAccessContext(ctx)
- publicCollectionToken, err := c.PublicCollectionRepo.GetActivePublicCollectionToken(ctx, accessContext.CollectionID)
- if err != nil {
- return nil, stacktrace.Propagate(err, "failed to get public collection info")
- }
- if publicCollectionToken.PassHash == nil || *publicCollectionToken.PassHash == "" {
- return nil, stacktrace.Propagate(ente.ErrBadRequest, "password is not configured for the link")
- }
- if req.PassHash != *publicCollectionToken.PassHash {
- return nil, stacktrace.Propagate(ente.ErrInvalidPassword, "incorrect password for link")
- }
- token := jwt.NewWithClaims(jwt.SigningMethodHS256, &enteJWT.PublicAlbumPasswordClaim{
- PassHash: req.PassHash,
- ExpiryTime: time.NDaysFromNow(365),
- })
- // Sign and get the complete encoded token as a string using the secret
- tokenString, err := token.SignedString(c.JwtSecret)
- if err != nil {
- return nil, stacktrace.Propagate(err, "")
- }
- return &ente.VerifyPasswordResponse{
- JWTToken: tokenString,
- }, nil
- }
- func (c *PublicCollectionController) ValidateJWTToken(ctx *gin.Context, jwtToken string, passwordHash string) error {
- token, err := jwt.ParseWithClaims(jwtToken, &enteJWT.PublicAlbumPasswordClaim{}, func(token *jwt.Token) (interface{}, error) {
- if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
- return stacktrace.Propagate(fmt.Errorf("unexpected signing method: %v", token.Header["alg"]), ""), nil
- }
- return c.JwtSecret, nil
- })
- if err != nil {
- return stacktrace.Propagate(err, "JWT parsed failed")
- }
- claims, ok := token.Claims.(*enteJWT.PublicAlbumPasswordClaim)
- if !ok {
- return stacktrace.Propagate(errors.New("no claim in jwt token"), "")
- }
- if token.Valid && claims.PassHash == passwordHash {
- return nil
- }
- return ente.ErrInvalidPassword
- }
- // ReportAbuse captures abuse report for a publicly shared collection.
- // It will also disable the accessToken for the collection if total abuse reports for the said collection
- // reaches AutoDisableAbuseThreshold
- func (c *PublicCollectionController) ReportAbuse(ctx *gin.Context, req ente.AbuseReportRequest) error {
- accessContext := auth.MustGetPublicAccessContext(ctx)
- readableReason, found := AllowedReasons[req.Reason]
- if !found {
- return stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("unexpected reason %s", req.Reason))
- }
- logrus.WithField("collectionID", accessContext.CollectionID).Error("CRITICAL: received abuse report")
- err := c.PublicCollectionRepo.RecordAbuseReport(ctx, accessContext, req.URL, req.Reason, req.Details)
- if err != nil {
- return stacktrace.Propagate(err, "")
- }
- count, err := c.PublicCollectionRepo.GetAbuseReportCount(ctx, accessContext)
- if err != nil {
- return stacktrace.Propagate(err, "")
- }
- c.onAbuseReportReceived(accessContext.CollectionID, req, readableReason, count)
- if count >= AutoDisableAbuseThreshold {
- logrus.WithFields(logrus.Fields{
- "collectionID": accessContext.CollectionID,
- }).Warn("disabling accessTokens for shared collection due to multiple abuse reports")
- return stacktrace.Propagate(c.Disable(ctx, accessContext.CollectionID), "")
- }
- return nil
- }
- func (c *PublicCollectionController) onAbuseReportReceived(collectionID int64, report ente.AbuseReportRequest, readableReason string, abuseCount int64) {
- collection, err := c.CollectionRepo.Get(collectionID)
- if err != nil {
- logrus.Error("Could not get collection for abuse report")
- return
- }
- user, err := c.UserRepo.Get(collection.Owner.ID)
- if err != nil {
- logrus.Error("Could not get owner for abuse report")
- return
- }
- comment := report.Details.Comment
- if comment == "" {
- comment = "None"
- }
- err = email.SendTemplatedEmail([]string{user.Email}, "abuse@ente.io", "abuse@ente.io", AbuseAlertSubject, AbuseAlertTemplate, map[string]interface{}{
- "AlbumLink": report.URL,
- "Reason": readableReason,
- "Comments": comment,
- }, nil)
- if err != nil {
- logrus.Error("Error sending abuse notification ", err)
- }
- if abuseCount >= AutoDisableAbuseThreshold {
- err = email.SendTemplatedEmail([]string{user.Email}, "abuse@ente.io", "abuse@ente.io", AbuseLimitExceededSubject, AbuseLimitExceededTemplate, nil, nil)
- if err != nil {
- logrus.Error("Error sending abuse limit exceeded notification ", err)
- }
- }
- err = email.SendTemplatedEmail([]string{"team@ente.io"}, "abuse@ente.io", "abuse@ente.io", AbuseAlertTeamSubject, AbuseAlertTemplate, map[string]interface{}{
- "AlbumLink": report.URL,
- "Reason": readableReason,
- "Comments": comment,
- }, nil)
- if err != nil {
- logrus.Error("Error notifying team about abuse ", err)
- }
- }
- func (c *PublicCollectionController) HandleAccountDeletion(ctx context.Context, userID int64, logger *logrus.Entry) error {
- logger.Info("updating public collection on account deletion")
- collectionIDs, err := c.PublicCollectionRepo.GetActivePublicTokenForUser(ctx, userID)
- if err != nil {
- return stacktrace.Propagate(err, "")
- }
- logger.WithField("cIDs", collectionIDs).Info("disable public tokens due to account deletion")
- for _, collectionID := range collectionIDs {
- err = c.Disable(ctx, collectionID)
- if err != nil {
- return stacktrace.Propagate(err, "")
- }
- }
- return nil
- }
- // GetPublicCollection will return collection info for a public url.
- // is mustAllowCollect is set to true but the underlying collection doesn't allow uploading
- func (c *PublicCollectionController) GetPublicCollection(ctx *gin.Context, mustAllowCollect bool) (ente.Collection, error) {
- accessContext := auth.MustGetPublicAccessContext(ctx)
- collection, err := c.CollectionRepo.Get(accessContext.CollectionID)
- if err != nil {
- return ente.Collection{}, stacktrace.Propagate(err, "")
- }
- if collection.IsDeleted {
- return ente.Collection{}, stacktrace.Propagate(ente.ErrNotFound, "collection is deleted")
- }
- // hide redundant/private information
- collection.Sharees = nil
- collection.MagicMetadata = nil
- publicURLsWithLimitedInfo := make([]ente.PublicURL, 0)
- for _, publicUrl := range collection.PublicURLs {
- publicURLsWithLimitedInfo = append(publicURLsWithLimitedInfo, ente.PublicURL{
- EnableDownload: publicUrl.EnableDownload,
- EnableCollect: publicUrl.EnableCollect,
- PasswordEnabled: publicUrl.PasswordEnabled,
- Nonce: publicUrl.Nonce,
- MemLimit: publicUrl.MemLimit,
- OpsLimit: publicUrl.OpsLimit,
- })
- }
- collection.PublicURLs = publicURLsWithLimitedInfo
- if mustAllowCollect {
- if len(publicURLsWithLimitedInfo) != 1 {
- errorMsg := fmt.Sprintf("Unexpected number of public urls: %d", len(publicURLsWithLimitedInfo))
- return ente.Collection{}, stacktrace.Propagate(ente.NewInternalError(errorMsg), "")
- }
- if !publicURLsWithLimitedInfo[0].EnableCollect {
- return ente.Collection{}, stacktrace.Propagate(&ente.ErrPublicCollectDisabled, "")
- }
- }
- return collection, nil
- }
|