Merge remote-tracking branch 'origin/main' into flutter-upgrade
This commit is contained in:
@@ -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");
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user