Skip to content

Commit

Permalink
Extract MailExportTokenFacade.ts
Browse files Browse the repository at this point in the history
  • Loading branch information
charlag committed Dec 9, 2024
1 parent fe08009 commit 67d10b5
Show file tree
Hide file tree
Showing 16 changed files with 295 additions and 324 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,
}
}
18 changes: 1 addition & 17 deletions src/common/api/worker/facades/BlobAccessTokenFacade.ts
Original file line number Diff line number Diff line change
@@ -1,34 +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
3 changes: 2 additions & 1 deletion src/common/api/worker/facades/lazy/BlobFacade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ 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()}`
Expand Down
131 changes: 21 additions & 110 deletions src/common/api/worker/facades/lazy/MailExportFacade.ts
Original file line number Diff line number Diff line change
@@ -1,144 +1,47 @@
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 { CacheMode, EntityRestClientLoadOptions } from "../../rest/EntityRestClient"
import { isNotNull, promiseMap } from "@tutao/tutanota-utils"
import { AccessExpiredError, NotFoundError } from "../../../common/error/RestError"
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 { createReferencingInstance } from "../../../../file/FileController.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 mailExportTokenFacade: MailExportTokenFacade,
private readonly bulkMailLoader: BulkMailLoader,
private readonly blobFacade: BlobFacade,
private readonly cryptoFacade: CryptoFacade,
) {}

/**
* 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()
} else {
// Already a request going on... wait for that to finish
newToken = this.currentExportToken ?? (await this.requestNewToken())
}

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

private applyExportOptions(token: MailExportToken): EntityRestClientLoadOptions {
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
}

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

async loadMailDetails(mails: readonly Mail[]): Promise<MailWithMailDetails[]> {
return this.handleRequest((options) => this.bulkMailLoader.loadMailDetails(mails, options))
return this.mailExportTokenFacade.loadWithToken((token) => this.bulkMailLoader.loadMailDetails(mails, this.options(token)))
}

async loadAttachments(mails: readonly Mail[]): Promise<TutanotaFile[]> {
return this.handleRequest((options) => this.bulkMailLoader.loadAttachments(mails, options))
return this.mailExportTokenFacade.loadWithToken((token) => this.bulkMailLoader.loadAttachments(mails, this.options(token)))
}

async loadAttachmentData(mail: Mail, attachments: readonly TutanotaFile[]): Promise<DataFile[]> {
Expand All @@ -148,8 +51,8 @@ export class MailExportFacade {
// - 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.handleRequest((options) =>
this.blobFacade.downloadAndDecrypt(ArchiveDataType.Attachments, createReferencingInstance(attachment), options),
const bytes = await this.mailExportTokenFacade.loadWithToken((token) =>
this.blobFacade.downloadAndDecrypt(ArchiveDataType.Attachments, createReferencingInstance(attachment), this.options(token)),
)
return convertToDataFile(attachment, bytes)
} catch (e) {
Expand All @@ -162,4 +65,12 @@ export class MailExportFacade {
})
return attachmentData.filter(isNotNull)
}

private options(token: string): { extraHeaders: Dict } {
return {
extraHeaders: {
[MAIL_EXPORT_TOKEN_HEADER]: token,
},
}
}
}
93 changes: 93 additions & 0 deletions src/common/api/worker/facades/lazy/MailExportTokenFacade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { AccessExpiredError } from "../../../common/error/RestError.js"
import { MailExportTokenService } from "../../../entities/tutanota/Services.js"
import { IServiceExecutor } from "../../../common/ServiceRequest.js"

const TAG = "[MailExportTokenFacade]"

/**
* 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 }

/**
* Takes care of requested and invalidating export tokens as needed.
*
* Export token should be passed with network requests to avoid server penalties.
*/
export class MailExportTokenFacade {
// 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) {}

/**
* Runs {@param request}.
*
* If {@link AccessExpiredError} is thrown, deletes the cached token and re-runs it again.
*/
async loadWithToken<T>(request: (token: string) => Promise<T>): Promise<T> {
const token = this.currentExportToken ?? (await this.requestNewToken())
try {
return await request(token)
} 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()
} else {
// Already a request going on... wait for that to finish
newToken = this.currentExportToken ?? (await this.requestNewToken())
}

return await request(newToken)
} else {
throw e
}
}
}

/**
* Request a new token and write it to {@link currentExportToken}.
*
* This token will be valid for the mail group and current user for a short amount of time, after which you will get
* an {@link AccessExpiredError} when using the token (or {@link NotAuthorizedError} if the user lost access to the group in the
* meantime).
* @throws TooManyRequestsError the user cannot request any more tokens right now
*/
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
}
}
12 changes: 1 addition & 11 deletions src/common/file/FileController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,12 @@ import Stream from "mithril/stream"
import { showProgressDialog } from "../gui/dialogs/ProgressDialog.js"
import { CancelledError } from "../api/common/error/CancelledError.js"
import { ConnectionError } from "../api/common/error/RestError.js"
import { elementIdPart, listIdPart } from "../api/common/utils/EntityUtils.js"
import { BlobReferencingInstance } from "../api/worker/facades/BlobAccessTokenFacade.js"
import { CryptoError } from "@tutao/tutanota-crypto/error.js"
import { isOfflineError } from "../api/common/utils/ErrorUtils.js"
import { locator } from "../api/main/CommonLocator.js"
import { PermissionError } from "../api/common/error/PermissionError.js"
import { FileNotFoundError } from "../api/common/error/FileNotFoundError.js"
import { createReferencingInstance } from "../api/common/utils/BlobUtils.js"

assertMainOrNode()
export const CALENDAR_MIME_TYPE = "text/calendar"
Expand Down Expand Up @@ -308,15 +307,6 @@ export async function downloadAndDecryptDataFile(file: TutanotaFile, blobFacade:
return convertToDataFile(file, bytes)
}

export function createReferencingInstance(tutanotaFile: TutanotaFile): BlobReferencingInstance {
return {
blobs: tutanotaFile.blobs,
elementId: elementIdPart(tutanotaFile._id),
listId: listIdPart(tutanotaFile._id),
entity: tutanotaFile,
}
}

export async function guiDownload(downloadPromise: Promise<void>, progress?: stream<number>): Promise<void> {
try {
await showProgressDialog("pleaseWait_msg", downloadPromise, progress)
Expand Down
3 changes: 2 additions & 1 deletion src/common/file/FileControllerNative.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { CancelledError } from "../api/common/error/CancelledError"
import type { NativeFileApp } from "../native/common/FileApp.js"
import { ArchiveDataType } from "../api/common/TutanotaConstants"
import { BlobFacade } from "../api/worker/facades/lazy/BlobFacade.js"
import { createReferencingInstance, FileController, ProgressObserver, zipDataFiles } from "./FileController.js"
import { FileController, ProgressObserver, zipDataFiles } from "./FileController.js"
import { ProgrammingError } from "../api/common/error/ProgrammingError.js"
import { createReferencingInstance } from "../api/common/utils/BlobUtils.js"

assertMainOrNode()

Expand Down
Loading

0 comments on commit 67d10b5

Please sign in to comment.