Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions server/cmd/museum/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ import (
"database/sql"
b64 "encoding/base64"
"fmt"
"github.com/ente-io/museum/pkg/controller/collections"
publicCtrl "github.com/ente-io/museum/pkg/controller/public"
"github.com/ente-io/museum/pkg/repo/public"
"net/http"
"os"
"os/signal"
Expand All @@ -17,6 +14,10 @@ import (
"syscall"
"time"

"github.com/ente-io/museum/pkg/controller/collections"
publicCtrl "github.com/ente-io/museum/pkg/controller/public"
"github.com/ente-io/museum/pkg/repo/public"

"github.com/ente-io/museum/ente/base"
"github.com/ente-io/museum/pkg/controller/emergency"
"github.com/ente-io/museum/pkg/controller/file_copy"
Expand Down Expand Up @@ -47,6 +48,7 @@ import (
embeddingCtrl "github.com/ente-io/museum/pkg/controller/embedding"
"github.com/ente-io/museum/pkg/controller/family"
"github.com/ente-io/museum/pkg/controller/lock"
memoryShareCtrl "github.com/ente-io/museum/pkg/controller/memory_share"
remoteStoreCtrl "github.com/ente-io/museum/pkg/controller/remotestore"
socialcontroller "github.com/ente-io/museum/pkg/controller/social"
"github.com/ente-io/museum/pkg/controller/storagebonus"
Expand Down Expand Up @@ -185,6 +187,7 @@ func main() {
familyRepo := &repo.FamilyRepository{DB: db}
trashRepo := &repo.TrashRepository{DB: db, ObjectRepo: objectRepo, FileRepo: fileRepo, QueueRepo: queueRepo, FileLinkRepo: fileLinkRepo}
collectionLinkRepo := public.NewCollectionLinkRepository(db, viper.GetString("apps.public-albums"))
memoryShareRepo := repo.NewMemoryShareRepository(db)

collectionRepo := &repo.CollectionRepository{DB: db, FileRepo: fileRepo, CollectionLinkRepo: collectionLinkRepo,
TrashRepo: trashRepo, SecretEncryptionKey: secretEncryptionKeyBytes, QueueRepo: queueRepo, LatencyLogger: latencyLogger}
Expand Down Expand Up @@ -417,6 +420,9 @@ func main() {
JwtSecret: jwtSecretBytes,
}

memoryShareController := memoryShareCtrl.NewController(memoryShareRepo, fileRepo, accessCtrl)
memorySharePublicController := publicCtrl.NewMemoryShareController(memoryShareRepo, fileRepo, fileController)

passkeyCtrl := &controller.PasskeyController{
Repo: passkeysRepo,
UserRepo: userRepo,
Expand All @@ -441,6 +447,9 @@ func main() {
BillingCtrl: billingController,
DiscordController: discordController,
}
memoryShareMiddleware := &middleware.MemoryShareMiddleware{
Repo: memoryShareRepo,
}

if environment != "local" {
gin.SetMode(gin.ReleaseMode)
Expand Down Expand Up @@ -492,6 +501,13 @@ func main() {
fileLinkApi := server.Group("/file-link")
fileLinkApi.Use(rateLimiter.GlobalRateLimiter(), fileLinkMiddleware.Authenticate(urlSanitizer))

publicMemoryAPI := server.Group("/public-memory")
publicMemoryAPI.Use(
rateLimiter.GlobalRateLimiter(),
memoryShareMiddleware.Authenticate(urlSanitizer),
rateLimiter.APIRateLimitMiddleware(urlSanitizer),
)

healthCheckHandler := &api.HealthCheckHandler{
DB: db,
}
Expand Down Expand Up @@ -716,6 +732,22 @@ func main() {
publicCollectionAPI.GET("/anon-profiles", publicSocialHandler.AnonProfiles)
publicCollectionAPI.POST("/anon-identity", publicSocialHandler.CreateAnonIdentity)

memoryShareHandler := &api.MemoryShareHandler{
Controller: memoryShareController,
}
publicMemoryShareHandler := &api.PublicMemoryShareHandler{
PublicCtrl: memorySharePublicController,
}

privateAPI.POST("/memory-share", memoryShareHandler.Create)
privateAPI.GET("/memory-share", memoryShareHandler.List)
privateAPI.GET("/memory-share/:shareID", memoryShareHandler.GetByID)
privateAPI.DELETE("/memory-share/:shareID", memoryShareHandler.Delete)

publicMemoryAPI.GET("/info", publicMemoryShareHandler.GetInfo)
publicMemoryAPI.GET("/files", publicMemoryShareHandler.GetFiles)
publicMemoryAPI.GET("/files/preview/:fileID", publicMemoryShareHandler.GetThumbnail)

castAPI := server.Group("/cast")

castCtrl := cast.NewController(&castDb, accessCtrl)
Expand Down Expand Up @@ -1203,6 +1235,7 @@ func cacheHeaders() gin.HandlerFunc {
strings.HasPrefix(reqPath, "/files/download/") ||
strings.HasPrefix(reqPath, "/public-collection/files/preview/") ||
strings.HasPrefix(reqPath, "/public-collection/files/download/") ||
strings.HasPrefix(reqPath, "/public-memory/files/preview/") ||
strings.HasPrefix(reqPath, "/cast/files/preview/") ||
strings.HasPrefix(reqPath, "/cast/files/download/") {
// Exclude those that redirect to S3 for file downloads.
Expand Down
90 changes: 90 additions & 0 deletions server/ente/memory_share.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package ente

// MemoryShareType represents the type of memory share
type MemoryShareType string

const (
// MemoryShareTypeShare is a user-curated memory share with selected files
MemoryShareTypeShare MemoryShareType = "share"
// MemoryShareTypeLane is an auto-generated memory lane
MemoryShareTypeLane MemoryShareType = "lane"
)

// MemoryShare represents a shared memory with its encrypted metadata
type MemoryShare struct {
ID int64 `json:"id"`
UserID int64 `json:"-"`
Type MemoryShareType `json:"type"`
MetadataCipher string `json:"metadataCipher,omitempty"`
MetadataNonce string `json:"metadataNonce,omitempty"`
EncryptedKey string `json:"encryptedKey,omitempty"`
KeyDecryptionNonce string `json:"keyDecryptionNonce,omitempty"`
AccessToken string `json:"accessToken,omitempty"`
IsDeleted bool `json:"isDeleted,omitempty"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt,omitempty"`
URL string `json:"url,omitempty"`
}

// MemoryShareFile represents a file within a memory share
type MemoryShareFile struct {
ID int64 `json:"id"`
MemoryShareID int64 `json:"-"`
FileID int64 `json:"fileID"`
FileOwnerID int64 `json:"-"`
EncryptedKey string `json:"encryptedKey"`
KeyDecryptionNonce string `json:"keyDecryptionNonce"`
CreatedAt int64 `json:"createdAt"`
}

// CreateMemoryShareRequest is the request body for creating a memory share
type CreateMemoryShareRequest struct {
MetadataCipher string `json:"metadataCipher"`
MetadataNonce string `json:"metadataNonce"`
EncryptedKey string `json:"encryptedKey" binding:"required"`
KeyDecryptionNonce string `json:"keyDecryptionNonce" binding:"required"`
Files []MemoryShareFileItem `json:"files" binding:"required,min=1"`
}

// MemoryShareFileItem represents a file in the create request
type MemoryShareFileItem struct {
FileID int64 `json:"fileID" binding:"required"`
EncryptedKey string `json:"encryptedKey" binding:"required"`
KeyDecryptionNonce string `json:"keyDecryptionNonce" binding:"required"`
}

// CreateMemoryShareResponse is the response for creating a memory share
type CreateMemoryShareResponse struct {
MemoryShare MemoryShare `json:"memoryShare"`
}

// ListMemorySharesResponse is the response for listing memory shares
type ListMemorySharesResponse struct {
MemoryShares []MemoryShare `json:"memoryShares"`
}

// PublicMemoryShareResponse is the response for public memory share access
type PublicMemoryShareResponse struct {
MemoryShare MemoryShare `json:"memoryShare"`
}

// PublicMemoryShareFile combines file data with its re-encrypted key for public access
type PublicMemoryShareFile struct {
File File `json:"file"`
EncryptedKey string `json:"encryptedKey"`
KeyDecryptionNonce string `json:"keyDecryptionNonce"`
}

// PublicMemoryShareFilesResponse is the response for listing files in a public memory share
type PublicMemoryShareFilesResponse struct {
Files []PublicMemoryShareFile `json:"files"`
}

// MemoryShareAccessContext represents the context for public memory share access
type MemoryShareAccessContext struct {
ID int64
ShareID int64
AccessToken string
IP string
UserAgent string
}
4 changes: 3 additions & 1 deletion server/ente/userentity/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ const (
CGroup EntityType = "cgroup"
// SmartAlbum is a new entity type for storing smart album config data
SmartAlbum EntityType = "smart_album"
// Memory is the entity type for memory share encryption keys
Memory EntityType = "memory"
)

func (et EntityType) IsValid() error {
switch et {
case Location, Person, CGroup, SmartAlbum:
case Location, Person, CGroup, SmartAlbum, Memory:
return nil
}
return ente.NewBadRequestWithMessage(fmt.Sprintf("Invalid EntityType: %s", et))
Expand Down
2 changes: 2 additions & 0 deletions server/migrations/115_create_memory_shares.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS memory_share_files;
DROP TABLE IF EXISTS memory_shares;
32 changes: 32 additions & 0 deletions server/migrations/115_create_memory_shares.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
-- Memory share table - stores shared memory metadata
CREATE TABLE IF NOT EXISTS memory_shares (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(user_id),
type TEXT NOT NULL CHECK (type IN ('share', 'lane')),
metadata_cipher TEXT,
metadata_nonce TEXT,
mem_enc_key TEXT NOT NULL,
mem_key_decryption_nonce TEXT NOT NULL,
access_token TEXT NOT NULL UNIQUE,
is_deleted BOOLEAN DEFAULT false,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_memory_shares_user_id ON memory_shares(user_id);
CREATE INDEX IF NOT EXISTS idx_memory_shares_access_token ON memory_shares(access_token);

-- Memory share files - tracks files in each memory share with their owners
CREATE TABLE IF NOT EXISTS memory_share_files (
id BIGSERIAL PRIMARY KEY,
memory_share_id BIGINT NOT NULL REFERENCES memory_shares(id) ON DELETE CASCADE,
file_id BIGINT NOT NULL,
file_owner_id BIGINT NOT NULL,
file_enc_key TEXT NOT NULL,
file_key_decryption_nonce TEXT NOT NULL,
created_at BIGINT NOT NULL,
UNIQUE(memory_share_id, file_id)
);

CREATE INDEX IF NOT EXISTS idx_memory_share_files_share_id ON memory_share_files(memory_share_id);
CREATE INDEX IF NOT EXISTS idx_memory_share_files_file_id ON memory_share_files(file_id);
86 changes: 86 additions & 0 deletions server/pkg/api/memory_share.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package api

import (
"net/http"
"strconv"

"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/controller/memory_share"
"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"
)

// MemoryShareHandler exposes request handlers for memory share operations
type MemoryShareHandler struct {
Controller *memory_share.Controller
}

// Create creates a new memory share
func (h *MemoryShareHandler) Create(c *gin.Context) {
var req ente.CreateMemoryShareRequest
if err := c.ShouldBindJSON(&req); err != nil {
handler.Error(c, stacktrace.Propagate(ente.ErrBadRequest, "invalid request body"))
return
}

userID := auth.GetUserID(c.Request.Header)
resp, err := h.Controller.Create(c, userID, req)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, "failed to create memory share"))
return
}

c.JSON(http.StatusOK, resp)
}

// List returns all memory shares for the authenticated user
func (h *MemoryShareHandler) List(c *gin.Context) {
userID := auth.GetUserID(c.Request.Header)
resp, err := h.Controller.List(c, userID)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, "failed to list memory shares"))
return
}

c.JSON(http.StatusOK, resp)
}

// Delete soft-deletes a memory share
func (h *MemoryShareHandler) Delete(c *gin.Context) {
shareID, err := strconv.ParseInt(c.Param("shareID"), 10, 64)
if err != nil {
handler.Error(c, stacktrace.Propagate(ente.ErrBadRequest, "invalid share ID"))
return
}

userID := auth.GetUserID(c.Request.Header)
err = h.Controller.Delete(c, userID, shareID)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, "failed to delete memory share"))
return
}

c.JSON(http.StatusOK, gin.H{})
}

// GetByID returns a memory share by ID (for owner only)
func (h *MemoryShareHandler) GetByID(c *gin.Context) {
shareID, err := strconv.ParseInt(c.Param("shareID"), 10, 64)
if err != nil {
handler.Error(c, stacktrace.Propagate(ente.ErrBadRequest, "invalid share ID"))
return
}

userID := auth.GetUserID(c.Request.Header)
share, err := h.Controller.GetByID(c, userID, shareID)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, "failed to get memory share"))
return
}

c.JSON(http.StatusOK, gin.H{
"memoryShare": share,
})
}
Loading