Merge remote-tracking branch 'origin/main' into flutter-upgrade

This commit is contained in:
Prateek Sunal
2025-06-12 18:44:10 +05:30
8 changed files with 456 additions and 207 deletions

View File

@@ -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");
};

View File

@@ -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<typeof RemoteCollectionUser>;
@@ -130,74 +152,23 @@ type RemoteCollectionUser = z.infer<typeof RemoteCollectionUser>;
/**
* 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<typeof RemoteCollectionSchema>;
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<Collection2> => {
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;

View File

@@ -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;

View File

@@ -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<

View File

@@ -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<T> {
export interface MagicMetadata<T = unknown> {
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 <T>(
magicMetadata: MagicMetadata<T> | undefined,
export const encryptMagicMetadata = async (
magicMetadata: MagicMetadata,
key: string,
): Promise<RemoteMagicMetadata | undefined> => {
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<MagicMetadata | undefined> => {
const {
version,
count,
data: encryptedData,
header: decryptionHeader,
} = remoteMagicMetadata;
const data = await decryptMetadataJSON(
{ encryptedData, decryptionHeader },
key,
);
return { version, count, data };
};

View File

@@ -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<Collection2> => {
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<string> => {
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<RemoteCollection>,
): Promise<RemoteCollection> => {
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.
*/

View File

@@ -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

View File

@@ -4,13 +4,6 @@
export const nullToUndefined = <T>(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 = <T>(v: T | null | undefined): T | undefined =>
v || undefined;
/**
* Convert `null` and `undefined` to `0`, passthrough everything else unchanged.
*/