diff --git a/web/apps/photos/src/services/collectionService.ts b/web/apps/photos/src/services/collectionService.ts index e0e568434f..a2ef44a624 100644 --- a/web/apps/photos/src/services/collectionService.ts +++ b/web/apps/photos/src/services/collectionService.ts @@ -1,6 +1,7 @@ import type { User } from "ente-accounts/services/user"; import { ensureLocalUser } from "ente-accounts/services/user"; import { encryptMetadataJSON, sharedCryptoWorker } from "ente-base/crypto"; +import { isDevBuild } from "ente-base/env"; import log from "ente-base/log"; import { apiURL } from "ente-base/origins"; import { ensureMasterKeyFromSession } from "ente-base/session"; @@ -23,6 +24,7 @@ import { EncryptedMagicMetadata, EnteFile } from "ente-media/file"; import { ItemVisibility } from "ente-media/file-metadata"; import { addToCollection, + createCollection2, isDefaultHiddenCollection, moveToCollection, } from "ente-new/photos/services/collection"; @@ -57,6 +59,11 @@ const FAVORITE_COLLECTION_NAME = "Favorites"; const REQUEST_BATCH_SIZE = 1000; export const createAlbum = (albumName: string) => { + if (isDevBuild && process.env.NEXT_PUBLIC_ENTE_WIP_NEWIMPL) { + // TODO: WIP + console.log(createCollection2); + // return createCollection2(albumName, "album"); + } return createCollection(albumName, "album"); }; diff --git a/web/packages/media/collection.ts b/web/packages/media/collection.ts index f466e65f3b..bf0f84fdab 100644 --- a/web/packages/media/collection.ts +++ b/web/packages/media/collection.ts @@ -1,13 +1,16 @@ +import { decryptBoxBytes } from "ente-base/crypto"; import { type EncryptedMagicMetadata, type MagicMetadataCore, } from "ente-media/file"; import { ItemVisibility } from "ente-media/file-metadata"; -import { nullToUndefined } from "ente-utils/transform"; +import { nullishToEmpty, nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; -import { RemoteMagicMetadataSchema } from "./magic-metadata"; - -// TODO: Audit this file +import { + decryptMagicMetadata, + RemoteMagicMetadataSchema, + type RemoteMagicMetadata, +} from "./magic-metadata"; /** * The type of a collection. @@ -43,7 +46,7 @@ import { RemoteMagicMetadataSchema } from "./magic-metadata"; * * In the remote schema, each item ({@link EnteFile}) is always associated * with a collection. The same item may belong to multiple collections (See: - * [Note: Collection File]), but it must belong to at least one collection. + * [Note: Collection file]), but it must belong to at least one collection. * * In some scenarios, e.g. when deleting the last collection to which a file * belongs, the file would thus get orphaned and violate the schema @@ -117,12 +120,31 @@ export interface CollectionUser { /** * Zod schema for {@link CollectionUser}. + * + * [Note: Enums in remote objects] + * + * In some cases remote returns a value which is part of a known set. The + * underlying type might be an integer or a string. + * + * While Zod allows us to validate enum types during a parse, we refrain from + * doing this as this would cause existing clients to break if remote were to in + * the future add new enum cases. + * + * This is especially pertinent for objects which we might persist locally, + * since the client code which persists the object might not know about a + * particular enum case but a future client which reads the saved value might. + * + * So we keep the underlying data type (string or number) as it is instead of + * converting / validating to an enum or discarding unknown values. + * + * As an example consider the {@link role} item in {@link RemoteCollectionUser}. + * There is a known set of values ({@link CollectionParticipantRole}) this can + * be, but in the Zod schema we keep the data type as a string. */ -// TODO: Use me. export const RemoteCollectionUser = z.object({ id: z.number(), - email: z.string().nullish(), - role: z.string().nullish(), + email: z.string().nullish().transform(nullToUndefined), + role: z.string().nullish().transform(nullToUndefined), }); type RemoteCollectionUser = z.infer; @@ -130,74 +152,23 @@ type RemoteCollectionUser = z.infer; /** * Zod schema for {@link Collection}. * - * TODO(RE): The following reasoning is not fully correct, since we anyways need - * to do a conversion when decrypting the collection's fields. The intent is to - * update this once we've figured out something that also works with the current - * persistence mechanism (trying to avoid a migration). - * - * [Note: Schema validation for bulk persisted remote objects] - * - * Objects like files and collections that we get from remote are treated - * specially when it comes to schema validation. - * - * 1. Enum conversion. - * 2. Loose objects. - * 3. Blank handling. - * 4. Casting instead of validating when reading local values. - * - * Let us take a concrete example of the {@link Collection} TypeScript type, - * whose zod schema is defined by {@link RemoteCollection}. - * - * The collection that we get from remote contains (nested) enum types - a - * {@link CollectionParticipantRole}. While zod allows us to validate enum types - * during a parse, this would cause existing clients to break if remote were to - * in the future add new enum cases. So when parsing we'd like to keep the role - * value as a string. - * - * This is especially important for a object like {@link Collection} which is - * also persisted locally, because a current client code might persist a object - * which might be read by future client code that understands more fields. So we - * use zod's {@link looseObject} directive on the {@link RemoteCollection} to - * ensure we don't discard fields we don't recognize, in a manner similar to - * [Note: Use looseObject for metadata Zod schemas]. - * - * In keeping with this general principle of retaining the object we get from - * remote as vintage as possible, we also don't do transformations to deal with - * various remote idiosyncracies. For example, for the role field remote (in - * some cases) uses blanks to indicate missing values. While zod would allow to - * transform these, we just let it be as remote had sent it. - * - * Finally, while we always use the {@link RemoteCollection} schema validator - * when parsing remote network responses, we don't do the same when reading the - * persisted values. This is to retain the performance characteristics of the - * existing code. This might seem miniscule for the {@link Collection} example, - * but users can easily have hundreds of thousands of {@link EnteFile}s - * persisted locally, and while the overhead of validation when reading from DB - * might not matter, but it needs to be profiled first before adding it to the - * existing code paths. - * - * So when reading arrays of these objects from local DB, we do a cast instead - * of do a runtime zod validation. - * - * To summarize, for certain remote objects which are also persisted to disk in - * potentially large numbers, we (a) try to retain the remote semantics as much - * as possible to avoid the need for a parsing / transform step, and (b) we - * don't even do a parsing / transform step when reading them from local DB. - * - * This means that the "types might lie". On the other hand, we need to special - * case only very specific objects this way: - * - * 1. {@link EnteFile} - * 2. {@link Collection} - * 3. {@link Trash} - * + * See: [Note: Schema suffix for exported Zod schemas]. */ -// TODO: Use me -export const RemoteCollection = z.object({ +export const RemoteCollectionSchema = z.object({ id: z.number(), owner: RemoteCollectionUser, encryptedKey: z.string(), + /** + * Remote will set this to a blank string for albums which have been shared + * with the user (the decryption pipeline for those doesn't use the nonce). + */ keyDecryptionNonce: z.string(), + /** + * Expected to be present (along with {@link nameDecryptionNonce}), but it + * is still optional since it might not be present if {@link name} is present. + */ + encryptedName: z.string().nullish().transform(nullToUndefined), + nameDecryptionNonce: z.string().nullish().transform(nullToUndefined), /** * Not used anymore, but still might be present for very old collections. * @@ -208,17 +179,11 @@ export const RemoteCollection = z.object({ */ name: z.string().nullish().transform(nullToUndefined), /** - * Expected to be present (along with {@link nameDecryptionNonce}), but it - * is still optional since it might not be present if {@link name} is present. + * Expected to be one of {@link CollectionType} */ - encryptedName: z.string().nullish().transform(nullToUndefined), - nameDecryptionNonce: z.string().nullish().transform(nullToUndefined), - /* Expected to be one of {@link CollectionType} */ type: z.string(), - // TODO(RE): Use nullishToEmpty? - sharees: z.array(RemoteCollectionUser).nullish().transform(nullToUndefined), // ? - // TODO(RE): Use nullishToEmpty? - publicURLs: z.array(z.looseObject({})).nullish().transform(nullToUndefined), // ? + sharees: z.array(RemoteCollectionUser).nullish().transform(nullishToEmpty), + publicURLs: z.array(z.looseObject({})).nullish().transform(nullishToEmpty), updationTime: z.number(), /** * Tombstone marker. @@ -235,7 +200,61 @@ export const RemoteCollection = z.object({ RemoteMagicMetadataSchema.nullish().transform(nullToUndefined), }); +export type RemoteCollection = z.infer; + export interface EncryptedCollection { + id: number; + owner: CollectionUser; + encryptedKey: string; + keyDecryptionNonce: string; + name?: string; + encryptedName: string; + nameDecryptionNonce: string; + type: CollectionType; + attributes: unknown; + sharees: CollectionUser[]; + publicURLs?: PublicURL[]; + updationTime: number; + isDeleted: boolean; + magicMetadata: EncryptedMagicMetadata; + pubMagicMetadata: EncryptedMagicMetadata; + sharedMagicMetadata: EncryptedMagicMetadata; +} + +export interface Collection + extends Omit< + EncryptedCollection, + | "encryptedKey" + | "keyDecryptionNonce" + | "encryptedName" + | "nameDecryptionNonce" + | "magicMetadata" + | "pubMagicMetadata" + | "sharedMagicMetadata" + > { + key: string; + name: string; + magicMetadata: CollectionMagicMetadata; + pubMagicMetadata: CollectionPublicMagicMetadata; + sharedMagicMetadata: CollectionShareeMagicMetadata; +} + +/** + * A collection, as used and persisted locally by the client. + * + * A collection is roughly equivalent to an "album", though there can be special + * type of collections (like "favorites") which have special behaviours attached + * to them. + * + * A collection contains zero or more files ({@link EnteFile}). + * + * A collection can be owned by the user (in whose context this code is + * running), or might be a collection that is shared with them. + * + * TODO: This type supercedes {@link Collection}. Once migration is done, rename + * this to drop the "2" suffix. + */ +export interface Collection2 { /** * The collection's globally unique ID. * @@ -258,34 +277,36 @@ export interface EncryptedCollection { * - {@link role} will be blank. */ owner: CollectionUser; - encryptedKey: string; - keyDecryptionNonce: string; - name?: string; - encryptedName: string; - nameDecryptionNonce: string; + /** + * The "collection key" (base64 encoded). + * + * The collection key is used to encrypt and decrypt that files that are + * associated with the collection. See: [Note: Collection file]. + */ + key: string; + /** + * The name of the collection. + */ + name: string; /** * The type of the collection. * - * See the documentation of {@link CollectionType} for more details. + * Expected to be one of {@link CollectionType}. */ - type: CollectionType; - /** - * TODO(RE): Remove me? - */ - attributes: collectionAttributes; + type: string; /** * The other Ente users with whom the collection has been shared with. * * Within the {@link CollectionUser} instances of the {@link sharee} field: * * - {@link email} will be set. - * - {@link role} will be one of "VIEWER" or "COLLABORATOR". + * - {@link role} is expected to be one of "VIEWER" or "COLLABORATOR". */ sharees: CollectionUser[]; /** - * Public links for the collection. + * Public links that can be used to access and update the collection. */ - publicURLs?: PublicURL[]; + publicURLs?: unknown; // PublicURL[]; /** * The last time the collection was updated (epoch microseconds). * @@ -295,45 +316,20 @@ export interface EncryptedCollection { * - When the collection's own fields are modified. */ updationTime: number; - isDeleted: boolean; - magicMetadata: EncryptedMagicMetadata; - pubMagicMetadata: EncryptedMagicMetadata; - sharedMagicMetadata: EncryptedMagicMetadata; -} - -export interface Collection - extends Omit< - EncryptedCollection, - | "encryptedKey" - | "keyDecryptionNonce" - | "encryptedName" - | "nameDecryptionNonce" - | "magicMetadata" - | "pubMagicMetadata" - | "sharedMagicMetadata" - > { - /** - * The "collection key" (base64 encoded). - */ - key: string; - /** - * The name of the collection. - */ - name: string; /** * Mutable metadata associated with the collection that is only visible to * the owner of the collection. * * See: [Note: Metadatum] */ - magicMetadata: CollectionMagicMetadata; + magicMetadata?: unknown; //CollectionMagicMetadata; /** * Public mutable metadata associated with the collection that is visible to * all users with whom the collection has been shared. * * See: [Note: Metadatum] */ - pubMagicMetadata: CollectionPublicMagicMetadata; + pubMagicMetadata?: unknown; //CollectionPublicMagicMetadata; /** * Private mutable metadata associated with the collection that is only * visible to the current user, if they're not the owner. @@ -346,7 +342,7 @@ export interface Collection * * See: [Note: Metadatum] */ - sharedMagicMetadata: CollectionShareeMagicMetadata; + sharedMagicMetadata?: unknown; // CollectionShareeMagicMetadata; } export interface PublicURL { @@ -361,6 +357,97 @@ export interface PublicURL { memLimit?: number; } +/** + * Decrypt a remote collection using the provided {@link collectionKey}. + * + * @param collection The remote collection to decrypt. + * + * @param collectionKey The base64 encoded key to use for decrypting the various + * encrypted fields in {@link collection}. + * + * @returns A decrypted collection. + */ +export const decryptRemoteCollection = async ( + collection: RemoteCollection, + collectionKey: string, +): Promise => { + const { id, owner, type, sharees, publicURLs, updationTime } = collection; + + const name = + collection.name ?? + new TextDecoder().decode( + await decryptBoxBytes( + { + encryptedData: collection.encryptedName!, + nonce: collection.nameDecryptionNonce!, + }, + collectionKey, + ), + ); + + const decryptMM = async (mm: RemoteMagicMetadata | undefined) => + mm ? await decryptMagicMetadata(mm, collectionKey) : undefined; + + return { + id, + owner, + key: collectionKey, + name, + type, + sharees, + publicURLs, + updationTime, + magicMetadata: await decryptMM(collection.magicMetadata), + pubMagicMetadata: await decryptMM(collection.pubMagicMetadata), + sharedMagicMetadata: await decryptMM(collection.sharedMagicMetadata), + }; +}; + +/** + * Additional context stored as part of the collection's magic metadata to + * augment the {@link type} associated with a {@link Collection}. + */ +export const CollectionSubType = { + default: 0, + defaultHidden: 1, + quicklink: 2, +} as const; + +export type CollectionSubType = + (typeof CollectionSubType)[keyof typeof CollectionSubType]; + +/** + * Mutable private metadata associated with an {@link Collection}. + * + * - Unlike {@link CollectionPublicMagicMetadataData} this is only available to + * the owner of the file. + * + * See: [Note: Private magic metadata is called magic metadata on remote] + */ +export interface CollectionPrivateMagicMetadataData { + /** + * The (owner specific) visibility of the collection. + * + * and independently edit its visibility without revealing their visibility + * preference to the other people with whom they have shared the file. + */ + visibility?: ItemVisibility; + /** + * The {@link CollectionSubType}, if applicable. + */ + subType?: CollectionSubType; + /** + * An overrride to the sort ordering used for the collection. + * + * - For pinned collections, this will be set to `1`. Pinned collections + * will be moved to the beginning of the sort order. + * + * - Otherwise, the collection is a normal (unpinned) collection, and will + * retain its natural sort position. + */ + order?: number; +} + export interface UpdatePublicURL { collectionID: number; disablePassword?: boolean; @@ -380,25 +467,11 @@ export interface CreatePublicAccessTokenRequest { deviceLimit?: number; } -export interface collectionAttributes { - encryptedPath?: string; - pathDecryptionNonce?: string; -} - export interface RemoveFromCollectionRequest { collectionID: number; fileIDs: number[]; } -export const CollectionSubType = { - default: 0, - defaultHidden: 1, - quicklink: 2, -} as const; - -export type CollectionSubType = - (typeof CollectionSubType)[keyof typeof CollectionSubType]; - export interface CollectionMagicMetadataProps { visibility?: ItemVisibility; subType?: CollectionSubType; diff --git a/web/packages/media/file-metadata.ts b/web/packages/media/file-metadata.ts index 4c2e25bf12..9c1c3f5f82 100644 --- a/web/packages/media/file-metadata.ts +++ b/web/packages/media/file-metadata.ts @@ -200,11 +200,17 @@ export interface PrivateMagicMetadata { * The visibility of an Ente file or collection. */ export const ItemVisibility = { - /** The normal state - The item is visible. */ + /** + * The normal state - The item is visible. + */ visible: 0, - /** The item has been archived. */ + /** + * The item has been archived. + */ archived: 1, - /** The item has been hidden. */ + /** + * The item has been hidden. + */ hidden: 2, } as const; diff --git a/web/packages/media/file.ts b/web/packages/media/file.ts index c5d8937e58..9315ce233d 100644 --- a/web/packages/media/file.ts +++ b/web/packages/media/file.ts @@ -79,7 +79,7 @@ export interface EncryptedEnteFile { * * The same file (ID) may be associated with multiple collectionID, in which * case there will be multiple {@link EnteFile} entries for each - * ({@link id}, {@link collectionID}) pair. See: [Note: Collection File]. + * ({@link id}, {@link collectionID}) pair. See: [Note: Collection file]. */ collectionID: number; /** @@ -152,7 +152,7 @@ export interface EncryptedEnteFile { * While the file ID is unique, we'd can still have multiple entries for each * file ID in our local state, one per collection IDs to which the file belongs. * That is, the uniqueness is across the (fileID, collectionID) pairs. See - * [Note: Collection File]. + * [Note: Collection file]. */ export interface EnteFile extends Omit< diff --git a/web/packages/media/magic-metadata.ts b/web/packages/media/magic-metadata.ts index 801ff914e2..57af979a29 100644 --- a/web/packages/media/magic-metadata.ts +++ b/web/packages/media/magic-metadata.ts @@ -1,14 +1,23 @@ -import { encryptMetadataJSON } from "ente-base/crypto"; +import { decryptMetadataJSON, encryptMetadataJSON } from "ente-base/crypto"; import { z } from "zod/v4"; /** * Zod schema of the mutable metadatum objects that we send to or receive from - * remote. It is effectively an envelope of the encrypted metadata contents with - * some bookkeeping information attached. + * remote. It contains an encrypted metadata JSON object with some bookkeeping + * information attached. * - * See {@link RemoteMagicMetadata} for the corresponding type. Since this module - * exports both the Zod schema and the TypeScript type, to avoid confusion the - * schema is suffixed by "Schema". + * See {@link RemoteMagicMetadata} for the corresponding type. + * + * [Note: Schema suffix for exported Zod schemas] + * + * Since this module exports both the Zod schema and the TypeScript type, to + * avoid confusion the schema is suffixed by "Schema". + * + * This is a general pattern we follow in all cases where we need to export the + * Zod schema so other modules can compose schema definitions. Such exports are + * meant to be exceptions though; usually the schema should be internal to the + * module (and so can have the same name as the TypeScript type without the need + * for a disambiguating suffix). */ export const RemoteMagicMetadataSchema = z.object({ version: z.number(), @@ -18,16 +27,16 @@ export const RemoteMagicMetadataSchema = z.object({ }); /** - * Any of the mutable metadata fields, as represented by remote. + * The type of the mutable metadata fields, as represented by remote. * * See: [Note: Metadatum] * - * This is the encrypted magic metadata that we send to and receive from remote. - * It contains the encrypted contents, and a version + count of fields that help - * ensure that clients do not overwrite updates with stale objects. + * This is the magic metadata that we send to and receive from remote. It + * contains an encrypted JSON object, and a version + count of entries to ensure + * that clients do not overwrite already applied updates with stale objects. * - * When decrypt these into specific {@link MagicMetadata} instantiations before - * using and storing them on the client. + * When decrypt these into specific local {@link MagicMetadata} instantiations + * before using and storing them on the client. * * See {@link RemoteMagicMetadataSchema} for the corresponding Zod schema. The * same structure is used to store the metadata for various kinds of objects @@ -61,10 +70,10 @@ export interface RemoteMagicMetadata { * This are the base64 encoded bytes of the encrypted magic metadata JSON * object. * - * The encryption will happen using the "key" of the object whose magic - * metadata field this is. For example, if this is the + * The encryption will happen using the "key" associated with the object + * whose magic metadata field this is. For example, if this is the * {@link pubMagicMetadata} of an {@link EnteFile}, then the file's key will - * be used for encryption. + * be used for encryption and decryption. */ data: string; /** @@ -79,11 +88,13 @@ export interface RemoteMagicMetadata { * by the client. * * The bookkeeping fields ({@link version} and {@link count}) are copied over - * from the envelope ({@link RemoteMagicMetadata}), while the encrypted contents - * ({@link data} and {@link header}) are decrypted into a JSON object. + * from the remote object ({@link RemoteMagicMetadata}), while the encrypted + * contents ({@link data} and {@link header} of {@link RemoteMagicMetadata}) are + * decrypted into a JSON object that is stored as {@link data}. * - * Since different types of magic metadata fields have different JSON contents, - * so this decrypted JSON object is parameterized as `T` in this generic type. + * Since different types of magic metadata fields are expected to contain + * different JSON objects as {@link data}, the type is generic, with the generic + * parameter `T` standing for the type of the JSON object. * * > Since TypeScript does not currently have a native JSON type, this T doesn't * > have other constraints enforced by the type system, but it is meant to be a @@ -96,62 +107,117 @@ export interface RemoteMagicMetadata { * sense - to describe the shape of any of the magic metadata like fields used * in various types. See: [Note: Metadatum]. */ -export interface MagicMetadata { +export interface MagicMetadata { version: number; count: number; + /** + * The "metadata" itself. + * + * This is expected to be a JSON object, whose exact schema depends on the + * magic metadata field in the parent object. + */ data: T; } /** * Encrypt the provided magic metadata into a form that can be used in - * communication with remote, updating the count if needed. + * communication with remote, trimming the JSON object and updating the count. * * @param magicMetadata The decrypted {@link MagicMetadata} that is being used * by the client. * - * As a usability convenience, this function allows passing `undefined` as the - * {@link magicMetadata}. In such a case, it will return `undefined` too. + * Any entries in {@link magicMetadata}'s {@link data} that are `null` or + * `undefined` are discarded before obtaining the final object that will be + * encrypted (and whose count will be used). * - * It is okay for the count in the envelope to be out of sync with the count in - * the actual JSON object contents, this function will update the envelope count - * with the number of entries in the JSON object. + * So in particular, it is okay for the count in the {@link magicMetadata} to be + * out of sync with the count in the `data` JSON object because this function + * will recompute the count using the trimmed JSON object. * - * Any entries in {@link magicMetadata} that are `null` or `undefined` are - * discarded before obtaining the final object that will be encrypted (and whose - * count will be used). + * @param key The base64 encoded key to use for encrypting the {@link data} + * contents of {@link magicMetadata}. * - * @param key The key (as a base64 string) to use for encrypting the - * {@link data} contents of {@link magicMetadata}. - * - * The specific key used depends on the object whose metadata this is. For - * example, if this were the {@link pubMagicMetadata} associated with an - * {@link EnteFile}, then this would be the file's key. + * The specific key used depends on the object whose magic metadata field this + * is meant for. For example, if this were the {@link pubMagicMetadata} + * associated with an {@link EnteFile}, then this would be the file's key. * * @returns a {@link RemoteMagicMetadata} object that contains the encrypted * contents of the {@link data} present in the provided {@link magicMetadata}. * The {@link version} is copied over from the provided {@link magicMetadata}, - * while the {@link count} is obtained from the number of entries in the JSON - * object (the {@link data} property of {@link magicMetadata}). - * + * while the {@link count} is obtained from the number of entries in the trimmed + * JSON object that was encrypted. */ -export const encryptMagicMetadata = async ( - magicMetadata: MagicMetadata | undefined, +export const encryptMagicMetadata = async ( + magicMetadata: MagicMetadata, key: string, ): Promise => { - if (!magicMetadata) return undefined; + const { version } = magicMetadata; - // Discard any entries that are `null` or `undefined`. - const jsonObject = Object.fromEntries( - Object.entries(magicMetadata.data ?? {}).filter( - ([, v]) => v !== null && v !== undefined, - ), - ); - - const version = magicMetadata.version; - const count = Object.keys(jsonObject).length; + const newMM = createMagicMetadata(magicMetadata.data); + const { count } = newMM; const { encryptedData: data, decryptionHeader: header } = - await encryptMetadataJSON(jsonObject, key); + await encryptMetadataJSON(newMM.data, key); return { version, count, data, header }; }; + +/** + * A function to wrap an arbitrary JSON object in the {@link MagicMetadata} + * envelope used for the various magic metadata fields. + * + * A trimmed JSON object is obtained from the provided JSON object by removing + * any entries whose values is `undefined` or `null`. + * + * Then, + * + * - The `version` is set to 0. + * - The `count` is set to the number of entries in the trimmed JSON object. + * - The `data` is set to the trimmed JSON object. + * + * {@link encryptMagicMetadata} internally uses this function to obtain an + * trimmed `data` and its corresponding `count`. This function is also exported + * for use in places where we just have a JSON object and would like to a new + * {@link MagicMetadata} object to house it from scratch. + * + * @param data A JSON object. Since TypeScript does not have a JSON type, this + * uses the `unknown` type. + */ +export const createMagicMetadata = (data: unknown) => { + // Discard any entries that are `undefined` or `null`. + const jsonObject = Object.fromEntries( + Object.entries(data ?? {}).filter( + ([, v]) => v !== undefined && v !== null, + ), + ); + + const count = Object.keys(jsonObject).length; + + return { version: 0, count, data: jsonObject }; +}; + +/** + * Decrypt the magic metadata received from remote into an object suitable for + * use and persistence by the client. + * + * This is meant as the inverse of {@link encryptMagicMetadata}, see that + * function's documentation for details. + */ +export const decryptMagicMetadata = async ( + remoteMagicMetadata: RemoteMagicMetadata, + key: string, +): Promise => { + const { + version, + count, + data: encryptedData, + header: decryptionHeader, + } = remoteMagicMetadata; + + const data = await decryptMetadataJSON( + { encryptedData, decryptionHeader }, + key, + ); + + return { version, count, data }; +}; diff --git a/web/packages/new/photos/services/collection.ts b/web/packages/new/photos/services/collection.ts index dcbe72b9b0..11633e2010 100644 --- a/web/packages/new/photos/services/collection.ts +++ b/web/packages/new/photos/services/collection.ts @@ -1,16 +1,34 @@ -import type { User } from "ente-accounts/services/user"; -import { boxSeal, encryptBox } from "ente-base/crypto"; +import { ensureLocalUser, type User } from "ente-accounts/services/user"; +import { + boxSeal, + boxSealOpen, + decryptBox, + encryptBox, + generateKey, +} from "ente-base/crypto"; import { authenticatedRequestHeaders, ensureOk } from "ente-base/http"; import { apiURL } from "ente-base/origins"; +import { ensureMasterKeyFromSession } from "ente-base/session"; import { CollectionSubType, + decryptRemoteCollection, + RemoteCollectionSchema, type Collection, + type Collection2, type CollectionNewParticipantRole, + type CollectionPrivateMagicMetadataData, + type CollectionType, + type RemoteCollection, } from "ente-media/collection"; import { type EnteFile } from "ente-media/file"; import { ItemVisibility } from "ente-media/file-metadata"; +import { + createMagicMetadata, + encryptMagicMetadata, +} from "ente-media/magic-metadata"; import { batch } from "ente-utils/array"; -import { getPublicKey } from "./user"; +import { z } from "zod/v4"; +import { ensureUserKeyPair, getPublicKey } from "./user"; /** * An reasonable but otherwise arbitrary number of items (e.g. files) to include @@ -81,6 +99,91 @@ export const getCollectionUserFacingName = (collection: Collection) => { return collection.name; }; +/** + * Create a new collection on remote, and return its local representation. + * + * Remote only, does not modify local state. + * + * @param name The name of the new collection. + * + * @param type The type of the new collection. + * + * @param magicMetadataData Optional metadata to use as the collection's private + * mutable metadata when creating the new collection. + */ +export const createCollection2 = async ( + name: string, + type: CollectionType, + magicMetadataData?: CollectionPrivateMagicMetadataData, +): Promise => { + const masterKey = await ensureMasterKeyFromSession(); + const collectionKey = await generateKey(); + const { encryptedData: encryptedKey, nonce: keyDecryptionNonce } = + await encryptBox(collectionKey, masterKey); + const { encryptedData: encryptedName, nonce: nameDecryptionNonce } = + await encryptBox(new TextEncoder().encode(name), collectionKey); + const magicMetadata = magicMetadataData + ? await encryptMagicMetadata( + createMagicMetadata(magicMetadataData), + collectionKey, + ) + : undefined; + + const collection = await postCollections({ + encryptedKey, + keyDecryptionNonce, + encryptedName, + nameDecryptionNonce, + type, + ...(magicMetadata ? { magicMetadata } : {}), + }); + + return decryptRemoteCollection( + collection, + await decryptCollectionKey(collection), + ); +}; + +/** + * Return the decrypted collection key (as a base64 string) for the given + * {@link RemoteCollection}. + */ +export const decryptCollectionKey = async ( + collection: RemoteCollection, +): Promise => { + const { owner, encryptedKey, keyDecryptionNonce } = collection; + if (owner.id == ensureLocalUser().id) { + // The collection key of collections owned by the user is encrypted with + // the user's master key. + return decryptBox( + { encryptedData: encryptedKey, nonce: keyDecryptionNonce }, + await ensureMasterKeyFromSession(), + ); + } else { + // The collection key of collections shared with the user is encrypted + // with the user's public key. + return boxSealOpen(encryptedKey, await ensureUserKeyPair()); + } +}; + +/** + * Create a collection on remote with the provided data, and return the new + * remote collection object returned by remote on success. + */ +const postCollections = async ( + collectionData: Partial, +): Promise => { + const res = await fetch(await apiURL("/collections"), { + method: "POST", + headers: await authenticatedRequestHeaders(), + body: JSON.stringify(collectionData), + }); + ensureOk(res); + return z + .object({ collection: RemoteCollectionSchema }) + .parse(await res.json()).collection; +}; + /** * Return a map of the (user-facing) collection name, indexed by collection ID. */ diff --git a/web/packages/new/photos/services/files.ts b/web/packages/new/photos/services/files.ts index 56752ee480..fadbb67c9a 100644 --- a/web/packages/new/photos/services/files.ts +++ b/web/packages/new/photos/services/files.ts @@ -188,13 +188,14 @@ export const sortFiles = (files: EnteFile[], sortAsc = false) => { }; /** - * [Note: Collection File] + * [Note: Collection file] * * File IDs themselves are unique across all the files for the user (in fact, - * they're unique across all the files in an Ente instance). However, we still - * can have multiple entries for the same file ID in our local database because - * the unit of account is not actually a file, but a "Collection File": a - * collection and file pair. + * they're unique across all the files in an Ente instance). + * + * However, we can have multiple entries for the same file ID in our local + * database and/or remote responses because the unit of account is not file, but + * a "Collection File" – a collection and file pair. * * For example, if the same file is symlinked into two collections, then we will * have two "Collection File" entries for it, both with the same file ID, but diff --git a/web/packages/utils/transform.ts b/web/packages/utils/transform.ts index 11c16b5472..905977c6d7 100644 --- a/web/packages/utils/transform.ts +++ b/web/packages/utils/transform.ts @@ -4,13 +4,6 @@ export const nullToUndefined = (v: T | null | undefined): T | undefined => v === null ? undefined : v; -/** - * Convert any falsey value (including blank strings) to `undefined`; - * passthrough everything else unchanged. - */ -export const falseyToUndefined = (v: T | null | undefined): T | undefined => - v || undefined; - /** * Convert `null` and `undefined` to `0`, passthrough everything else unchanged. */