Skip to content

Commit

Permalink
Use mailbox export tokens, Extract MailExportTokenFacade
Browse files Browse the repository at this point in the history
#8098

Make `createTestEntity` deterministic and fix tests related to it.

Co-authored-by: bir <bir@tutao.de>
  • Loading branch information
2 people authored and hrb-hub committed Dec 19, 2024
1 parent dbfed0b commit e12dcc5
Show file tree
Hide file tree
Showing 22 changed files with 483 additions and 401 deletions.
28 changes: 28 additions & 0 deletions src/common/api/common/utils/BlobUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { File as TutanotaFile } from "../../entities/tutanota/TypeRefs.js"
import { elementIdPart, listIdPart } from "./EntityUtils.js"
import { Blob } from "../../entities/sys/TypeRefs.js"
import { SomeEntity } from "../EntityTypes.js"

/**
* Common interface for instances that are referencing blobs. Main purpose is to have a proper way to access the attribute for the Blob aggregated type
* because the name of the attribute can be different for each instance.
*
*/
export type BlobReferencingInstance = {
elementId: Id

listId: Id | null

blobs: Blob[]

entity: SomeEntity
}

export function createReferencingInstance(tutanotaFile: TutanotaFile): BlobReferencingInstance {
return {
blobs: tutanotaFile.blobs,
elementId: elementIdPart(tutanotaFile._id),
listId: listIdPart(tutanotaFile._id),
entity: tutanotaFile,
}
}
4 changes: 2 additions & 2 deletions src/common/api/common/utils/EntityUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,14 +255,14 @@ export function customIdToString(customId: string): string {
return utf8Uint8ArrayToString(base64ToUint8Array(base64UrlToBase64(customId)))
}

export function create<T>(typeModel: TypeModel, typeRef: TypeRef<T>): T {
export function create<T>(typeModel: TypeModel, typeRef: TypeRef<T>, createDefaultValue: (name: string, value: ModelValue) => any = _getDefaultValue): T {
let i: Record<string, any> = {
_type: typeRef,
}

for (let valueName of Object.keys(typeModel.values)) {
let value = typeModel.values[valueName]
i[valueName] = _getDefaultValue(valueName, value)
i[valueName] = createDefaultValue(valueName, value)
}

for (let associationName of Object.keys(typeModel.associations)) {
Expand Down
4 changes: 2 additions & 2 deletions src/common/api/worker/crypto/CryptoFacade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,9 +780,9 @@ export class CryptoFacade {
* @param mainInstance the instance that has the bucketKey
* @param childInstances the files that belong to the mainInstance
*/
async enforceSessionKeyUpdateIfNeeded(mainInstance: Record<string, any>, childInstances: File[]): Promise<File[]> {
async enforceSessionKeyUpdateIfNeeded(mainInstance: Record<string, any>, childInstances: readonly File[]): Promise<File[]> {
if (!childInstances.some((f) => f._ownerEncSessionKey == null)) {
return childInstances
return childInstances.slice()
}
const typeModel = await resolveTypeReference(mainInstance._type)
const outOfSyncInstances = childInstances.filter((f) => f._ownerEncSessionKey == null)
Expand Down
27 changes: 8 additions & 19 deletions src/common/api/worker/facades/BlobAccessTokenFacade.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,18 @@
import { ArchiveDataType } from "../../common/TutanotaConstants"
import { assertWorkerOrNode } from "../../common/Env"
import { BlobAccessTokenService } from "../../entities/storage/Services"
import { Blob } from "../../entities/sys/TypeRefs.js"
import { IServiceExecutor } from "../../common/ServiceRequest"
import { BlobServerAccessInfo, createBlobAccessTokenPostIn, createBlobReadData, createBlobWriteData, createInstanceId } from "../../entities/storage/TypeRefs"
import { DateProvider } from "../../common/DateProvider.js"
import { resolveTypeReference } from "../../common/EntityFunctions.js"
import { AuthDataProvider } from "./UserFacade.js"
import { SomeEntity } from "../../common/EntityTypes.js"
import { isEmpty, TypeRef } from "@tutao/tutanota-utils"
import { ProgrammingError } from "../../common/error/ProgrammingError.js"
import { BlobLoadOptions } from "./lazy/BlobFacade.js"
import { BlobReferencingInstance } from "../../common/utils/BlobUtils.js"

assertWorkerOrNode()

/**
* Common interface for instances that are referencing blobs. Main purpose is to have a proper way to access the attribute for the Blob aggregated type
* because the name of the attribute can be different for each instance.
*
*/
export type BlobReferencingInstance = {
elementId: Id

listId: Id | null

blobs: Blob[]

entity: SomeEntity
}

/**
* The BlobAccessTokenFacade requests blobAccessTokens from the BlobAccessTokenService to get or post to the BlobService (binary blobs)
* or DefaultBlobElementResource (instances).
Expand Down Expand Up @@ -126,7 +111,11 @@ export class BlobAccessTokenFacade {
* @param archiveDataType specify the data type
* @param referencingInstance the instance that references the blobs
*/
async requestReadTokenBlobs(archiveDataType: ArchiveDataType, referencingInstance: BlobReferencingInstance): Promise<BlobServerAccessInfo> {
async requestReadTokenBlobs(
archiveDataType: ArchiveDataType,
referencingInstance: BlobReferencingInstance,
blobLoadOptions: BlobLoadOptions,
): Promise<BlobServerAccessInfo> {
const requestNewToken = async () => {
const archiveId = this.getArchiveId([referencingInstance])
const instanceListId = referencingInstance.listId
Expand All @@ -141,7 +130,7 @@ export class BlobAccessTokenFacade {
}),
write: null,
})
const { blobAccessInfo } = await this.serviceExecutor.post(BlobAccessTokenService, tokenRequest)
const { blobAccessInfo } = await this.serviceExecutor.post(BlobAccessTokenService, tokenRequest, blobLoadOptions)
return blobAccessInfo
}
return this.readBlobCache.getToken(referencingInstance.elementId, requestNewToken)
Expand Down
33 changes: 26 additions & 7 deletions src/common/api/worker/facades/lazy/BlobFacade.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { addParamsToUrl, isSuspensionResponse, RestClient } from "../../rest/RestClient.js"
import { addParamsToUrl, isSuspensionResponse, RestClient, SuspensionBehavior } from "../../rest/RestClient.js"
import { CryptoFacade } from "../../crypto/CryptoFacade.js"
import { clear, concat, neverNull, promiseMap, splitUint8ArrayInChunks, uint8ArrayToBase64, uint8ArrayToString } from "@tutao/tutanota-utils"
import { ArchiveDataType, MAX_BLOB_SIZE_BYTES } from "../../../common/TutanotaConstants.js"
Expand All @@ -19,14 +19,22 @@ import { IServiceExecutor } from "../../../common/ServiceRequest.js"
import { BlobGetInTypeRef, BlobPostOut, BlobPostOutTypeRef, BlobServerAccessInfo, createBlobGetIn } from "../../../entities/storage/TypeRefs.js"
import { AuthDataProvider } from "../UserFacade.js"
import { doBlobRequestWithRetry, tryServers } from "../../rest/EntityRestClient.js"
import { BlobAccessTokenFacade, BlobReferencingInstance } from "../BlobAccessTokenFacade.js"
import { BlobAccessTokenFacade } from "../BlobAccessTokenFacade.js"
import { DefaultEntityRestCache } from "../../rest/DefaultEntityRestCache.js"
import { SomeEntity } from "../../../common/EntityTypes.js"
import { encryptBytes } from "../../crypto/CryptoWrapper.js"
import { BlobReferencingInstance } from "../../../common/utils/BlobUtils.js"

assertWorkerOrNode()
export const BLOB_SERVICE_REST_PATH = `/rest/${BlobService.app}/${BlobService.name.toLowerCase()}`

export interface BlobLoadOptions {
extraHeaders?: Dict
suspensionBehavior?: SuspensionBehavior
/** override origin for the request */
baseUrl?: string
}

/**
* The BlobFacade uploads and downloads blobs to/from the blob store.
*
Expand Down Expand Up @@ -100,11 +108,15 @@ export class BlobFacade {
* @param referencingInstance that directly references the blobs
* @returns Uint8Array unencrypted binary data
*/
async downloadAndDecrypt(archiveDataType: ArchiveDataType, referencingInstance: BlobReferencingInstance): Promise<Uint8Array> {
async downloadAndDecrypt(
archiveDataType: ArchiveDataType,
referencingInstance: BlobReferencingInstance,
blobLoadOptions: BlobLoadOptions = {},
): Promise<Uint8Array> {
const sessionKey = await this.resolveSessionKey(referencingInstance.entity)
const doBlobRequest = async () => {
const blobServerAccessInfo = await this.blobAccessTokenFacade.requestReadTokenBlobs(archiveDataType, referencingInstance)
return promiseMap(referencingInstance.blobs, (blob) => this.downloadAndDecryptChunk(blob, blobServerAccessInfo, sessionKey))
const blobServerAccessInfo = await this.blobAccessTokenFacade.requestReadTokenBlobs(archiveDataType, referencingInstance, blobLoadOptions)
return promiseMap(referencingInstance.blobs, (blob) => this.downloadAndDecryptChunk(blob, blobServerAccessInfo, sessionKey, blobLoadOptions))
}
const doEvictToken = () => this.blobAccessTokenFacade.evictReadBlobsToken(referencingInstance)

Expand Down Expand Up @@ -135,7 +147,7 @@ export class BlobFacade {
const decryptedChunkFileUris: FileUri[] = []
const doBlobRequest = async () => {
clear(decryptedChunkFileUris) // ensure that the decrypted file uris are emtpy in case we retry because of NotAuthorized error
const blobServerAccessInfo = await this.blobAccessTokenFacade.requestReadTokenBlobs(archiveDataType, referencingInstance)
const blobServerAccessInfo = await this.blobAccessTokenFacade.requestReadTokenBlobs(archiveDataType, referencingInstance, {})
return promiseMap(referencingInstance.blobs, async (blob) => {
decryptedChunkFileUris.push(await this.downloadAndDecryptChunkNative(blob, blobServerAccessInfo, sessionKey))
}).catch(async (e: Error) => {
Expand Down Expand Up @@ -249,7 +261,12 @@ export class BlobFacade {
return createBlobReferenceTokenWrapper({ blobReferenceToken })
}

private async downloadAndDecryptChunk(blob: Blob, blobServerAccessInfo: BlobServerAccessInfo, sessionKey: AesKey): Promise<Uint8Array> {
private async downloadAndDecryptChunk(
blob: Blob,
blobServerAccessInfo: BlobServerAccessInfo,
sessionKey: AesKey,
blobLoadOptions: BlobLoadOptions,
): Promise<Uint8Array> {
const { archiveId, blobId } = blob
const getData = createBlobGetIn({
archiveId,
Expand All @@ -269,6 +286,8 @@ export class BlobFacade {
responseType: MediaType.Binary,
baseUrl: serverUrl,
noCORS: true,
headers: blobLoadOptions.extraHeaders,
suspensionBehavior: blobLoadOptions.suspensionBehavior,
})
return aesDecrypt(sessionKey, data)
},
Expand Down
156 changes: 45 additions & 111 deletions src/common/api/worker/facades/lazy/MailExportFacade.ts
Original file line number Diff line number Diff line change
@@ -1,142 +1,76 @@
import { MailExportTokenService } from "../../../entities/tutanota/Services"
import { File as TutanotaFile, Mail } from "../../../entities/tutanota/TypeRefs"
import { assertWorkerOrNode } from "../../../common/Env"
import { IServiceExecutor } from "../../../common/ServiceRequest"
import { EntityClient } from "../../../common/EntityClient"
import { CacheMode, EntityRestClientLoadOptions } from "../../rest/EntityRestClient"
import type { ListElementEntity, SomeEntity } from "../../../common/EntityTypes"
import { assertNotNull, TypeRef } from "@tutao/tutanota-utils"
import { AccessExpiredError } from "../../../common/error/RestError"
import { isNotNull, promiseMap } from "@tutao/tutanota-utils"
import { NotFoundError } from "../../../common/error/RestError"
import { BulkMailLoader, MailWithMailDetails } from "../../../../../mail-app/workerUtils/index/BulkMailLoader.js"
import { convertToDataFile, DataFile } from "../../../common/DataFile.js"
import { ArchiveDataType } from "../../../common/TutanotaConstants.js"
import { BlobFacade } from "./BlobFacade.js"
import { CryptoFacade } from "../../crypto/CryptoFacade.js"
import { createReferencingInstance } from "../../../common/utils/BlobUtils.js"
import { MailExportTokenFacade } from "./MailExportTokenFacade.js"

assertWorkerOrNode()

const TAG = "[MailExportFacade]"

/**
* Denotes the header that will have the mail export token.
*/
export const MAIL_EXPORT_TOKEN_HEADER = "mailExportToken"

/**
* Denotes an export token. This is internally just a string, but we want the TypeScript compiler to enforce strong
* typing.
*/
type MailExportToken = string & { _exportToken: undefined }

/**
* Mail exporter functions
* Wraps bulk loading of mails for mail export.
*
* This implements loadForMailGroup and loadRangeForMailGroup which uses mail export tokens retrieved from the server
* and does not write to cache. Note that no loadAll method is implemented since tokens expire after a short period of
* time, and it is better to process in batches.
* Takes care of using mail export tokens.
*/
export class MailExportFacade {
// This will only be set if a request is in progress
private currentExportTokenRequest: Promise<MailExportToken> | null = null
// Set when we have a known valid token
private currentExportToken: MailExportToken | null = null

constructor(private readonly serviceExecutor: IServiceExecutor, private readonly entityClient: EntityClient) {}

/**
* Load a single element for export, (re-)generating a mail export token if needed
*/
async load<T extends SomeEntity>(typeRef: TypeRef<T>, id: PropertyType<T, "_id">): Promise<T> {
return this.handleRequest((options) => this.entityClient.load(typeRef, id, options))
constructor(
private readonly mailExportTokenFacade: MailExportTokenFacade,
private readonly bulkMailLoader: BulkMailLoader,
private readonly blobFacade: BlobFacade,
private readonly cryptoFacade: CryptoFacade,
) {}

async loadFixedNumberOfMailsWithCache(mailListId: Id, startId: Id): Promise<Mail[]> {
return this.mailExportTokenFacade.loadWithToken((token) =>
this.bulkMailLoader.loadFixedNumberOfMailsWithCache(mailListId, startId, this.options(token)),
)
}

/**
* Load a multiple elements for export, (re-)generating a mail export token if needed
*/
async loadMultiple<T extends SomeEntity>(typeRef: TypeRef<T>, listId: Id | null, elementIds: Id[]): Promise<T[]> {
return this.handleRequest((options) => this.entityClient.loadMultiple(typeRef, listId, elementIds, undefined, options))
async loadMailDetails(mails: readonly Mail[]): Promise<MailWithMailDetails[]> {
return this.mailExportTokenFacade.loadWithToken((token) => this.bulkMailLoader.loadMailDetails(mails, this.options(token)))
}

/**
* Load a range of elements for export, (re-)generating a mail export token if needed
*/
async loadRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, firstId: Id, count: number, reverse: boolean): Promise<T[]> {
return this.handleRequest((options) => this.entityClient.loadRange<T>(typeRef, listId, firstId, count, reverse, options))
async loadAttachments(mails: readonly Mail[]): Promise<TutanotaFile[]> {
return this.mailExportTokenFacade.loadWithToken((token) => this.bulkMailLoader.loadAttachments(mails, this.options(token)))
}

/**
* Runs `request`.
*
* If `AccessExpiredError` is thrown, delete the cached token and re-run it again.
* @param request function to run
* @private
*/
private async handleRequest<T>(request: (options: EntityRestClientLoadOptions) => Promise<T>): Promise<T> {
const token = this.currentExportToken ?? (await this.requestNewToken())
try {
const options = this.applyExportOptions(token)
return await request(options)
} catch (e) {
// We only allow one retry
if (e instanceof AccessExpiredError) {
let newToken
if (this.currentExportToken === token) {
console.log(TAG, `token expired for exporting and will be renewed`)
newToken = await this.requestNewToken()
async loadAttachmentData(mail: Mail, attachments: readonly TutanotaFile[]): Promise<DataFile[]> {
const attachmentsWithKeys = await this.cryptoFacade.enforceSessionKeyUpdateIfNeeded(mail, attachments)
// TODO: download attachments efficiently.
// - download multiple blobs at once if possible
// - use file references instead of data files (introduce a similar type to MailBundle or change MailBundle)
const attachmentData = await promiseMap(attachmentsWithKeys, async (attachment) => {
try {
const bytes = await this.mailExportTokenFacade.loadWithToken((token) =>
this.blobFacade.downloadAndDecrypt(ArchiveDataType.Attachments, createReferencingInstance(attachment), this.options(token)),
)
return convertToDataFile(attachment, bytes)
} catch (e) {
if (e instanceof NotFoundError) {
return null
} else {
// Already a request going on... wait for that to finish
newToken = this.currentExportToken ?? (await this.requestNewToken())
throw e
}

const options = this.applyExportOptions(newToken)
return await request(options)
} else {
throw e
}
}
})
return attachmentData.filter(isNotNull)
}

private applyExportOptions(token: MailExportToken): EntityRestClientLoadOptions {
private options(token: string): { extraHeaders: Dict } {
return {
cacheMode: CacheMode.ReadOnly,
extraHeaders: {
[MAIL_EXPORT_TOKEN_HEADER]: token,
},
}
}

/**
* Request a new token and write it to the tokenCache.
*
* This token will be valid for the mail group and current user for a short amount of time, after which you will get
* an `AccessExpiredError` when using the token (or `NotAuthorizedError` if the user lost access to the group in the
* meantime).
* @throws TooManyRequestsError the user cannot request any more tokens right now
* @return the token
*/
private requestNewToken(): Promise<MailExportToken> {
if (this.currentExportTokenRequest) {
return this.currentExportTokenRequest
}

this.currentExportToken = null
this.currentExportTokenRequest = this.serviceExecutor.post(MailExportTokenService, null).then(
(result) => {
this.currentExportToken = result.mailExportToken as MailExportToken
this.currentExportTokenRequest = null
return this.currentExportToken
},
(error) => {
// Re-initialize in case MailExportTokenService won't fail on a future request
this.currentExportTokenRequest = null
throw error
},
)
return this.currentExportTokenRequest
}

// @VisibleForTesting
_setCurrentExportToken(token: string) {
this.currentExportToken = token as MailExportToken
this.currentExportTokenRequest = null
}

// @VisibleForTesting
_getCurrentExportToken(): string | null {
return this.currentExportToken
}
}
Loading

0 comments on commit e12dcc5

Please sign in to comment.