This commit is contained in:
Manav Rathi
2025-06-13 08:45:09 +05:30
parent 9fde80593f
commit 4d9b7fa905
4 changed files with 182 additions and 27 deletions

View File

@@ -61,11 +61,7 @@ const REQUEST_BATCH_SIZE = 1000;
export const createAlbum = (albumName: string) => {
// TODO(C2):
if (isDevBuild && process.env.NEXT_PUBLIC_ENTE_WIP_NEWIMPL) {
// TODO(C2):
// In general this cast would not necessarily be correct, but we still
// need to add it as temporary scaffolding as part of the migration to
// the new type.
return createCollection2(albumName, "album") as Promise<Collection>;
return createCollection2(albumName, "album");
}
return createCollection(albumName, "album");
};

View File

@@ -3,8 +3,11 @@ import {
type EncryptedMagicMetadata,
type MagicMetadataCore,
} from "ente-media/file";
import { ItemVisibility } from "ente-media/file-metadata";
import { nullishToEmpty, nullToUndefined } from "ente-utils/transform";
import {
nullishToEmpty,
nullishToFalse,
nullToUndefined,
} from "ente-utils/transform";
import { z } from "zod/v4";
import {
decryptMagicMetadata,
@@ -16,7 +19,7 @@ import {
* A collection, as used and persisted locally by the client.
*
* A collection is, well, a collection of files. It is roughly equivalent to an
* "album" (which is also the term we use in the UI), bute there can also be
* "album" (which is also the term we use in the UI), but there can also be
* special type of collections like "favorites" which have special behaviour.
*
* A collection contains zero or more files ({@link EnteFile}).
@@ -79,7 +82,7 @@ export interface Collection2 {
/**
* Public links that can be used to access and update the collection.
*/
publicURLs?: unknown; // PublicURL[];
publicURLs?: PublicURL2[];
/**
* The last time the collection was updated (epoch microseconds).
*
@@ -116,10 +119,6 @@ export interface Collection2 {
* See: [Note: Metadatum]
*/
sharedMagicMetadata?: MagicMetadata<CollectionShareeMagicMetadataData>;
// TODO(C2): Temporarily forwarded for compatilibity with the existing
// collection type.
isDeleted: boolean;
attributes: unknown;
}
/**
@@ -251,18 +250,144 @@ export interface CollectionUser {
* 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.
*/
export const RemoteCollectionUser = z.object({
export const LocalCollectionUser = z.looseObject({
id: z.number(),
email: z.string().nullish().transform(nullToUndefined),
role: z.string().nullish().transform(nullToUndefined),
});
export const RemoteCollectionUser = z.looseObject({
...LocalCollectionUser.shape,
name: z.unknown(),
});
type RemoteCollectionUser = z.infer<typeof RemoteCollectionUser>;
/**
* Zod schema for {@link Collection}.
* A public link for a shared collection.
*
* This structure contains a (partial^) URL that can be used to access the
* shared collection, along with other attributes of the link.
*
* ^ The URL is partial because it doesn't have the URL fragment, which is
* client side only as it contains the decryption key
*/
export const RemoteCollection = z.object({
export interface PublicURL2 {
/**
* A URL that can be used access the shared collection.
*
* This will be of the form "https://<public-albums-app>/?t=<token>", e.g.,
* "https://albums.ente.io/?t=xxxxxx".
*
* In particular, this URL does not contain the URL fragment (the part after
* the "#"). URL fragments are client side only, and not sent to remote.
* They contain the decryption key.
*
* The client can use this field to form the fully usable URL (e.g.
* "https://albums.ente.io/?t=xxxxxx#yyy...yyy") and provide it to the user
* for sharing.
*/
url: string;
/**
* The number of unique devices which can access the collection using the
* public URL.
*
* Set to 0 to indicate no device limit.
*/
deviceLimit: number;
/**
* The epoch microseconds until which the link is valid.
*
* Set to 0 to indicate no expiry.
*/
validTill: number;
/**
* `true` if downloads are enabled from this link.
*
* When creating a new link this is `true` by default, and can optionally be
* disabled in the public link settings.
*/
enableDownload: boolean;
enableJoin: boolean;
/**
* `true` if people can use the public link to upload new files to the
* shared collection.
*/
enableCollect: boolean;
/**
* `true` if the link is password protected.
*
* When this is `true`, {@link nonce}, {@link memLimit} and {@link opsLimit}
* will also be set.
*/
passwordEnabled: boolean;
/**
* The nonce to use when hashing the password.
*
* Only present when {@link passwordEnabled} is `true`.
*/
nonce?: string;
/**
* The ops limit to use when hashing the password.
*
* Only present when {@link passwordEnabled} is `true`.
*/
opsLimit?: number;
/**
* The mem limit to use when hashing the password.
*
* Only present when {@link passwordEnabled} is `true`.
*/
memLimit?: number;
}
/**
* Zod schema for the {@link PublicURL2} we use in our interactions with remote.
*
* We also use the same schema when persisting the collection locally.
*/
export const RemotePublicURL = z.looseObject({
url: z.string(),
deviceLimit: z.number(),
validTill: z.number(),
enableDownload: z.boolean().nullish().transform(nullishToFalse),
enableJoin: z.boolean().nullish().transform(nullishToFalse),
enableCollect: z.boolean().nullish().transform(nullishToFalse),
passwordEnabled: z.boolean().nullish().transform(nullishToFalse),
nonce: z.string().nullish().transform(nullToUndefined),
memLimit: z.number().nullish().transform(nullToUndefined),
opsLimit: z.number().nullish().transform(nullToUndefined),
});
/**
* Zod schema for {@link Collection}.
*
* [Note: Use looseObject when parsing JSON that will get persisted]
*
* While not always necessary, for a few cases (files and collections being the
* most prominent and important) we try to retain any unknown fields in the JSON
* we get from remote, so that future versions of the client with support for
* fields unbeknownst to the current one can read and use them.
*
* In such cases, the nested objects should also (recursively) use the
* looseObject schema. But not always - in some cases where the structures we
* use are well established, e.g. {@link RemoteMagicMetadata} - we use the
* default Zod behaviour of discarding unknown fields.
*
* > Note that even in the case of {@link RemoteMagicMetadata}, we still apply
* > looseObject to the payload itself.
* >
* > See: [Note: Use looseObject for metadata Zod schemas]
*
* Unlike metadata, where we do strictly want to retain unknown or unacted on
* fields, in the more general case we are okay with being a bit loose with
* looseObject, and even intentionally dropping fields that we know we're not
* going to use on the current client. Such looseness is okay because even if we
* need to use them in the future, we can always refetch the objects again
* (while in the case of metadata, we need to also push our changes to remote,
* so it is functionally important for us to retain the source verbatim).
*/
export const RemoteCollection = z.looseObject({
id: z.number(),
owner: RemoteCollectionUser,
encryptedKey: z.string(),
@@ -291,7 +416,7 @@ export const RemoteCollection = z.object({
*/
type: z.string(),
sharees: z.array(RemoteCollectionUser).nullish().transform(nullishToEmpty),
publicURLs: z.array(z.looseObject({})).nullish().transform(nullishToEmpty),
publicURLs: z.array(RemotePublicURL).nullish().transform(nullishToEmpty),
updationTime: z.number(),
/**
* Tombstone marker.
@@ -343,6 +468,8 @@ export interface Collection
magicMetadata: CollectionMagicMetadata;
pubMagicMetadata: CollectionPublicMagicMetadata;
sharedMagicMetadata: CollectionShareeMagicMetadata;
// TODO(C2): Gradual conversion to new structure.
c2?: Collection2;
}
export interface PublicURL {
@@ -366,11 +493,13 @@ export interface PublicURL {
* encrypted fields in {@link collection}.
*
* @returns A decrypted collection.
*
* TODO(C2): For legacy compat, it returns the older structure.
*/
export const decryptRemoteCollection = async (
collection: RemoteCollection,
collectionKey: string,
): Promise<Collection2> => {
): Promise<Collection> => {
const { id, owner, type, sharees, publicURLs, updationTime } = collection;
const name =
@@ -415,7 +544,8 @@ export const decryptRemoteCollection = async (
sharedMagicMetadata = { ...genericMM, data };
}
return {
// return {
const c2 = {
id,
owner,
key: collectionKey,
@@ -427,10 +557,35 @@ export const decryptRemoteCollection = async (
magicMetadata,
pubMagicMetadata,
sharedMagicMetadata,
// TODO(C2):
isDeleted: !!collection.isDeleted,
attributes: {},
};
// Temporary scaffolding for the migration.
const c = {
...collection,
c2,
key: collectionKey,
name,
type: collection.type as CollectionType,
attributes: {},
isDeleted: !!collection.isDeleted,
magicMetadata: (magicMetadata
? { ...magicMetadata, header: collection.magicMetadata!.header }
: undefined)!,
pubMagicMetadata: (pubMagicMetadata
? {
...pubMagicMetadata,
header: collection.pubMagicMetadata!.header,
}
: undefined)!,
sharedMagicMetadata: (sharedMagicMetadata
? {
...sharedMagicMetadata,
header: collection.sharedMagicMetadata!.header,
}
: undefined)!,
};
return c;
};
/**
@@ -603,11 +758,10 @@ export interface CollectionMagicMetadataProps {
}
export type CollectionMagicMetadata =
// TODO(C2):
Omit<MagicMetadataCore<CollectionMagicMetadataProps>, "header">;
MagicMetadataCore<CollectionMagicMetadataProps>;
export interface CollectionShareeMetadataProps {
visibility?: ItemVisibility;
visibility?: number; // ItemVisibility;
}
export type CollectionShareeMagicMetadata =
MagicMetadataCore<CollectionShareeMetadataProps>;

View File

@@ -14,7 +14,6 @@ import {
decryptRemoteCollection,
RemoteCollection,
type Collection,
type Collection2,
type CollectionNewParticipantRole,
type CollectionPrivateMagicMetadataData,
type CollectionType,
@@ -114,7 +113,7 @@ export const createCollection2 = async (
name: string,
type: CollectionType,
magicMetadataData?: CollectionPrivateMagicMetadataData,
): Promise<Collection2> => {
): Promise<Collection> => {
const masterKey = await ensureMasterKeyFromSession();
const collectionKey = await generateKey();
const { encryptedData: encryptedKey, nonce: keyDecryptionNonce } =

View File

@@ -4,6 +4,12 @@
export const nullToUndefined = <T>(v: T | null | undefined): T | undefined =>
v === null ? undefined : v;
/**
* Convert `null` and `undefined` to `false`, passthrough everything else unchanged.
*/
export const nullishToFalse = (v: boolean | null | undefined): boolean =>
v ?? false;
/**
* Convert `null` and `undefined` to `0`, passthrough everything else unchanged.
*/