This commit is contained in:
Neeraj Gupta
2025-05-28 10:31:49 +05:30
parent cf5aabbde1
commit bbf4462c6c
10 changed files with 445 additions and 8 deletions

View File

@@ -440,6 +440,8 @@ func main() {
privateAPI.GET("/files/preview/:fileID", fileHandler.GetThumbnail)
privateAPI.GET("/files/preview/v2/:fileID", fileHandler.GetThumbnail)
privateAPI.POST("files/share-url", fileHandler.ShareUrl)
privateAPI.PUT("/files/data", fileHandler.PutFileData)
privateAPI.PUT("/files/video-data", fileHandler.PutVideoData)
privateAPI.POST("/files/data/status-diff", fileHandler.FileDataStatusDiff)

View File

@@ -97,8 +97,8 @@ var ErrUserDeleted = errors.New("user account has been deleted")
// ErrLockUnavailable is thrown when a lock could not be acquired
var ErrLockUnavailable = errors.New("could not acquire lock")
// ErrActiveLinkAlreadyExists is thrown when the collection already has active public link
var ErrActiveLinkAlreadyExists = errors.New("Collection already has active public link")
// ErrActiveLinkAlreadyExists is thrown when an active link already exists for entity
var ErrActiveLinkAlreadyExists = errors.New("link already exists for this entity")
// ErrNotImplemented indicates that the action that we tried to perform is not
// available at this museum instance. e.g. this could be something that is not

View File

@@ -5,7 +5,7 @@ type CreateFileUrl struct {
FileID int64 `json:"fileID" binding:"required"`
}
// UpdateFileResponse represents a response to the UpdateFileRequest
// UpdateFileUrl ..
type UpdateFileUrl struct {
LinkID string `json:"linkID" binding:"required"`
FileID int64 `json:"fileID" binding:"required"`
@@ -34,6 +34,7 @@ type PublicFileUrlRow struct {
PasswordInfo *PassWordInfo
EnableDownload bool
CreatedAt int64
UpdatedAt int64
}
type FileUrl struct {

View File

@@ -0,0 +1,28 @@
package api
import (
"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/utils/auth"
"github.com/ente-io/museum/pkg/utils/handler"
"github.com/ente-io/stacktrace"
"github.com/gin-gonic/gin"
"net/http"
)
// Update updates already existing file
func (h *FileHandler) ShareUrl(c *gin.Context) {
enteApp := auth.GetApp(c)
userID := auth.GetUserID(c.Request.Header)
var file ente.CreateFileUrl
if err := c.ShouldBindJSON(&file); err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return
}
response, err := h.Controller.Update(c, userID, file, enteApp)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return
}
c.JSON(http.StatusOK, response)
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"github.com/ente-io/museum/pkg/controller/access"
"github.com/ente-io/museum/pkg/repo/public"
"github.com/ente-io/museum/ente"
@@ -55,20 +56,28 @@ type PublicCollectionController struct {
FileController *FileController
EmailNotificationCtrl *emailCtrl.EmailNotificationController
PublicCollectionRepo *public.PublicCollectionRepository
PublicFileRepo *public.PublicFileRepository
CollectionRepo *repo.CollectionRepository
UserRepo *repo.UserRepository
JwtSecret []byte
}
func (c *PublicCollectionController) CreateAccessToken(ctx context.Context, req ente.CreatePublicAccessTokenRequest) (ente.PublicURL, error) {
func (c *PublicCollectionController) CreateFileUrl(ctx *gin.Context, req ente.CreateFileUrl) (*ente.FileUrl, error) {
userID := auth.GetUserID(ctx.Request.Header)
if err := c.FileController.AccessCtrl.VerifyFileOwnership(ctx, &access.VerifyFileOwnershipParams{
ActorUserId: userID,
FileIDs: []int64{req.FileID},
}); err != nil {
return nil, stacktrace.Propagate(err, "failed to verify file ownership")
}
accessToken := shortuuid.New()[0:AccessTokenLength]
err := c.PublicCollectionRepo.
Insert(ctx, req.CollectionID, accessToken, req.ValidTill, req.DeviceLimit, req.EnableCollect, req.EnableJoin)
err := c.PublicFileRepo.Insert(ctx, req.FileID, userID, accessToken)
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, "")
return nil, stacktrace.Propagate(err2, "")
}
if publicUrls, ok := collectionToPubUrlMap[req.CollectionID]; ok {
if len(publicUrls) > 0 {

View File

@@ -0,0 +1,236 @@
package controller
import (
"context"
"errors"
"fmt"
"github.com/ente-io/museum/ente"
enteJWT "github.com/ente-io/museum/ente/jwt"
"github.com/ente-io/museum/pkg/utils/auth"
"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"
)
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, req.EnableJoin)
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) GetActivePublicCollectionToken(ctx context.Context, collectionID int64) (ente.PublicCollectionToken, error) {
return c.PublicCollectionRepo.GetActivePublicCollectionToken(ctx, collectionID)
}
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
}
if req.EnableJoin != nil {
publicCollectionToken.EnableJoin = *req.EnableJoin
}
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,
EnableJoin: publicCollectionToken.EnableJoin,
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
}
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,
EnableJoin: publicUrl.EnableJoin,
})
}
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
}

View File

@@ -123,7 +123,7 @@ func (pcr *PublicCollectionRepository) RecordAbuseReport(ctx context.Context, ac
url string, reason string, details ente.AbuseReportDetails) error {
_, err := pcr.DB.ExecContext(ctx, `INSERT INTO public_abuse_report
(share_id, ip, user_agent, url, reason, details) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT ON CONSTRAINT unique_report_sid_ip_ua DO UPDATE SET (reason, details) = ($5, $6)`,
ON CONFLICT ON CONSTRAINT unique_report_public_collection_id_ip_ua DO UPDATE SET (reason, details) = ($5, $6)`,
accessCtx.ID, accessCtx.IP, accessCtx.UserAgent, url, reason, details)
return stacktrace.Propagate(err, "failed to record abuse report")
}

View File

@@ -0,0 +1,161 @@
package public
import (
"context"
"database/sql"
"fmt"
"github.com/ente-io/museum/ente/base"
"github.com/ente-io/museum/ente"
"github.com/ente-io/stacktrace"
"github.com/lib/pq"
)
// PublicFileRepository defines the methods for inserting, updating and
// retrieving entities related to public file
type PublicFileRepository struct {
DB *sql.DB
albumHost string
}
// NewPublicFileRepository ..
func NewPublicFileRepository(db *sql.DB, albumHost string) *PublicFileRepository {
if albumHost == "" {
albumHost = "https://albums.ente.io"
}
return &PublicFileRepository{
DB: db,
albumHost: albumHost,
}
}
func (pcr *PublicFileRepository) GetAlbumUrl(token string) string {
return fmt.Sprintf("%s/?t=%s", pcr.albumHost, token)
}
func (pcr *PublicFileRepository) Insert(
ctx context.Context,
fileID int64,
ownerID int64,
token string,
) error {
id, err := base.NewID("pft")
if err != nil {
return stacktrace.Propagate(err, "failed to generate new ID for public file token")
}
_, err = pcr.DB.ExecContext(ctx, `INSERT INTO public_file_tokens
(id, file_id, owner_id, access_token) VALUES ($1, $2, $3, $4)`,
id, fileID, ownerID, token)
if err != nil && err.Error() == "pq: duplicate key value violates unique constraint \"public_access_token_unique_idx\"" {
return ente.ErrActiveLinkAlreadyExists
}
return stacktrace.Propagate(err, "failed to insert")
}
func (pcr *PublicFileRepository) DisableSharing(ctx context.Context, fileID int64) error {
_, err := pcr.DB.ExecContext(ctx, `UPDATE public_file_tokens SET is_disabled = true where
file_id = $1 and is_disabled = false`, fileID)
return stacktrace.Propagate(err, "failed to disable sharing")
}
// GetCollectionToActivePublicURLMap will return map of collectionID to PublicURLs which are not disabled yet.
// Note: The url could be expired or deviceLimit is already reached
func (pcr *PublicFileRepository) GetCollectionToActivePublicURLMap(ctx context.Context, collectionIDs []int64) (map[int64][]ente.PublicURL, error) {
rows, err := pcr.DB.QueryContext(ctx, `SELECT collection_id, access_token, valid_till, device_limit, enable_download, enable_collect, enable_join, pw_nonce, mem_limit, ops_limit FROM
public_collection_tokens WHERE collection_id = ANY($1) and is_disabled = FALSE`,
pq.Array(collectionIDs))
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
defer rows.Close()
result := make(map[int64][]ente.PublicURL, 0)
for _, cID := range collectionIDs {
result[cID] = make([]ente.PublicURL, 0)
}
for rows.Next() {
publicUrl := ente.PublicURL{}
var collectionID int64
var accessToken string
var nonce *string
var opsLimit, memLimit *int64
if err = rows.Scan(&collectionID, &accessToken, &publicUrl.ValidTill, &publicUrl.DeviceLimit, &publicUrl.EnableDownload, &publicUrl.EnableCollect, &publicUrl.EnableJoin, &nonce, &memLimit, &opsLimit); err != nil {
return nil, stacktrace.Propagate(err, "")
}
publicUrl.URL = pcr.GetAlbumUrl(accessToken)
if nonce != nil {
publicUrl.Nonce = nonce
publicUrl.MemLimit = memLimit
publicUrl.OpsLimit = opsLimit
publicUrl.PasswordEnabled = true
}
result[collectionID] = append(result[collectionID], publicUrl)
}
return result, nil
}
// GetActivePublicCollectionToken will return ente.PublicCollectionToken for given collection ID
// Note: The token could be expired or deviceLimit is already reached
func (pcr *PublicFileRepository) GetActivePublicCollectionToken(ctx context.Context, collectionID int64) (ente.PublicCollectionToken, error) {
row := pcr.DB.QueryRowContext(ctx, `SELECT id, collection_id, access_token, valid_till, device_limit,
is_disabled, pw_hash, pw_nonce, mem_limit, ops_limit, enable_download, enable_collect, enable_join FROM
public_collection_tokens WHERE collection_id = $1 and is_disabled = FALSE`,
collectionID)
//defer rows.Close()
ret := ente.PublicCollectionToken{}
err := row.Scan(&ret.ID, &ret.CollectionID, &ret.Token, &ret.ValidTill, &ret.DeviceLimit,
&ret.IsDisabled, &ret.PassHash, &ret.Nonce, &ret.MemLimit, &ret.OpsLimit, &ret.EnableDownload, &ret.EnableCollect, &ret.EnableJoin)
if err != nil {
return ente.PublicCollectionToken{}, stacktrace.Propagate(err, "")
}
return ret, nil
}
// UpdatePublicCollectionToken will update the row for corresponding public collection token
func (pcr *PublicFileRepository) UpdatePublicCollectionToken(ctx context.Context, pct ente.PublicCollectionToken) error {
_, err := pcr.DB.ExecContext(ctx, `UPDATE public_collection_tokens SET valid_till = $1, device_limit = $2,
pw_hash = $3, pw_nonce = $4, mem_limit = $5, ops_limit = $6, enable_download = $7, enable_collect = $8, enable_join = $9
where id = $10`,
pct.ValidTill, pct.DeviceLimit, pct.PassHash, pct.Nonce, pct.MemLimit, pct.OpsLimit, pct.EnableDownload, pct.EnableCollect, pct.EnableJoin, pct.ID)
return stacktrace.Propagate(err, "failed to update public collection token")
}
func (pcr *PublicFileRepository) GetUniqueAccessCount(ctx context.Context, shareId int64) (int64, error) {
panic("not implemented, refactor & public collection")
}
func (pcr *PublicFileRepository) RecordAccessHistory(ctx context.Context, shareID int64, ip string, ua string) error {
panic("not implemented, refactor & public collection")
}
// AccessedInPast returns true if the given ip, ua agent combination has accessed the url in the past
func (pcr *PublicFileRepository) AccessedInPast(ctx context.Context, shareID int64, ip string, ua string) (bool, error) {
panic("not implemented, refactor & public collection")
}
func (pcr *PublicFileRepository) GetFileUrlRowByToken(ctx context.Context, accessToken string) (*ente.PublicFileUrlRow, error) {
row := pcr.DB.QueryRowContext(ctx,
`SELECT id, file_id, is_disabled, valid_till, device_limit, password_info,
created_at, updated_at
from public_file_tokens
where access_token = $1
`, accessToken)
var result = ente.PublicFileUrlRow{}
err := row.Scan(&result.LinkID, &result.FileID, &result.IsDisabled, &result.ValidTill, &result.DeviceLimit, result.PasswordInfo, &result.CreatedAt, &result.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ente.ErrNotFound
}
return nil, stacktrace.Propagate(err, "failed to get public file url summary by token")
}
return &result, nil
}
// CleanupAccessHistory public_collection_access_history where public_collection_tokens is disabled and the last updated time is older than 30 days
func (pcr *PublicFileRepository) CleanupAccessHistory(ctx context.Context) error {
_, err := pcr.DB.ExecContext(ctx, `DELETE FROM public_collection_access_history WHERE share_id IN (SELECT id FROM public_collection_tokens WHERE is_disabled = TRUE AND updated_at < (now_utc_micro_seconds() - (24::BIGINT * 30 * 60 * 60 * 1000 * 1000)))`)
if err != nil {
return stacktrace.Propagate(err, "failed to clean up public collection access history")
}
return nil
}