diff --git a/extensions/fsv2/src/controllers/FSController.ts b/extensions/fsv2/src/controllers/FSController.ts index 77b265e179..39ca51ca07 100644 --- a/extensions/fsv2/src/controllers/FSController.ts +++ b/extensions/fsv2/src/controllers/FSController.ts @@ -35,11 +35,13 @@ import type { ThumbnailUploadPreparePayload, } from './types.js'; -const { Controller, ExtensionController, HttpError, Post } = extension.import('extensionController'); +const { Controller, ExtensionController, HttpError, Post } = extension.import( + 'extensionController', +); const { Context } = extension.import('core'); -const getApp = extension.import('core').util.helpers.get_app as ( - query: { uid: string }, -) => Promise<{ id?: unknown } | null>; +const getApp = extension.import('core').util.helpers.get_app as (query: { + uid: string; +}) => Promise<{ id?: unknown } | null>; class UploadProgressTracker implements UploadProgressTrackerLike { total = 0; progress = 0; @@ -74,7 +76,6 @@ const DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY = 8; @Controller('/fs') export class FSController extends ExtensionController { - constructor ( private fsEntryService: FSEntryService, private eventService: EventService, @@ -82,20 +83,32 @@ export class FSController extends ExtensionController { super(); } @Post('/startWrite', { subdomain: 'api' }) - async startWrite (req: Request, res: Response) { + async startWrite ( + req: Request, + res: Response, + ) { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const requestBody = this.#withGuiMetadata(req.body, req.body); - requestBody.fileMetadata = this.#normalizeFileMetadataPath(req, requestBody.fileMetadata, requestBody); - requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(requestBody.fileMetadata, requestBody); + requestBody.fileMetadata = this.#normalizeFileMetadataPath( + req, + requestBody.fileMetadata, + requestBody, + ); + requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata( + requestBody.fileMetadata, + requestBody, + ); await this.#assertWriteAccess(req, requestBody.fileMetadata, { pathAlreadyNormalized: true, }); - const { - response, - createdDirectoryEntries, - } = await this.fsEntryService.startUrlWriteWithCreatedDirectories(userId, requestBody, storageAllowanceMax); + const { response, createdDirectoryEntries } = + await this.fsEntryService.startUrlWriteWithCreatedDirectories( + userId, + requestBody, + storageAllowanceMax, + ); await this.#attachSignedThumbnailUploadTargets([requestBody], [response]); if ( ! requestBody.directory ) { await this.#runNonCritical(async () => { @@ -117,25 +130,35 @@ export class FSController extends ExtensionController { } @Post('/startBatchWrite', { subdomain: 'api' }) - async startBatchWrites (req: Request, res: Response) { + async startBatchWrites ( + req: Request, + res: Response, + ) { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const appUidLookupCache = new Map>(); const requests = Array.isArray(req.body) - ? await Promise.all(req.body.map(async (requestBody) => { - const normalizedRequestBody = this.#withGuiMetadata(requestBody, req.body); - normalizedRequestBody.fileMetadata = this.#normalizeFileMetadataPath( - req, - normalizedRequestBody.fileMetadata, - normalizedRequestBody, - ); - normalizedRequestBody.fileMetadata = await this.#resolveAssociatedAppMetadata( - normalizedRequestBody.fileMetadata, - normalizedRequestBody, - appUidLookupCache, - ); - return normalizedRequestBody; - })) + ? await Promise.all( + req.body.map(async (requestBody) => { + const normalizedRequestBody = this.#withGuiMetadata( + requestBody, + req.body, + ); + normalizedRequestBody.fileMetadata = + this.#normalizeFileMetadataPath( + req, + normalizedRequestBody.fileMetadata, + normalizedRequestBody, + ); + normalizedRequestBody.fileMetadata = + await this.#resolveAssociatedAppMetadata( + normalizedRequestBody.fileMetadata, + normalizedRequestBody, + appUidLookupCache, + ); + return normalizedRequestBody; + }), + ) : []; await this.#assertBatchWriteAccess( req, @@ -143,11 +166,16 @@ export class FSController extends ExtensionController { { pathAlreadyNormalized: true }, ); - const { - responses, - createdDirectoryEntries, - } = await this.fsEntryService.batchStartUrlWritesWithCreatedDirectories(userId, requests, storageAllowanceMax); - const directoryGuiMetadataByPath = new Map( + const { responses, createdDirectoryEntries } = + await this.fsEntryService.batchStartUrlWritesWithCreatedDirectories( + userId, + requests, + storageAllowanceMax, + ); + const directoryGuiMetadataByPath = new Map< + string, + WriteGuiMetadata | undefined + >( requests .filter((request) => request.directory) .map((request) => [request.fileMetadata.path, request.guiMetadata]), @@ -163,7 +191,11 @@ export class FSController extends ExtensionController { const requestBody = requests[index]; if ( requestBody && writeResponse ) { if ( ! requestBody.directory ) { - await this.#emitGuiPendingWriteEvent(userId, requestBody, writeResponse); + await this.#emitGuiPendingWriteEvent( + userId, + requestBody, + writeResponse, + ); } } }, @@ -188,12 +220,18 @@ export class FSController extends ExtensionController { } @Post('/completeWrite', { subdomain: 'api' }) - async completeWrite (req: Request, res: Response) { + async completeWrite ( + req: Request, + res: Response, + ) { const userId = this.#getActorUserId(req); const requestBody = this.#withGuiMetadata(req.body, req.body); this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData); - const response = await this.fsEntryService.completeUrlWrite(userId, requestBody); + const response = await this.fsEntryService.completeUrlWrite( + userId, + requestBody, + ); const writeResponse = await this.#applyWriteResponseSideEffects( userId, { @@ -221,7 +259,10 @@ export class FSController extends ExtensionController { for ( const requestBody of requests ) { this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData); } - const response = await this.fsEntryService.batchCompleteUrlWrite(userId, requests); + const response = await this.fsEntryService.batchCompleteUrlWrite( + userId, + requests, + ); const updatedResponse = await runWithConcurrencyLimit( response, DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY, @@ -244,7 +285,10 @@ export class FSController extends ExtensionController { } @Post('/abortWrite', { subdomain: 'api' }) - async abortWrite (req: Request, res: Response<{ ok: true }>) { + async abortWrite ( + req: Request, + res: Response<{ ok: true }>, + ) { const userId = this.#getActorUserId(req); if ( ! req.body?.uploadId ) { throw new HttpError(400, 'Missing uploadId'); @@ -260,17 +304,30 @@ export class FSController extends ExtensionController { res: Response, ) { const userId = this.#getActorUserId(req); - const response = await this.fsEntryService.signMultipartParts(userId, req.body); + const response = await this.fsEntryService.signMultipartParts( + userId, + req.body, + ); res.json(response); } @Post('/write', { subdomain: 'api' }) - async write (req: Request, res: Response) { + async write ( + req: Request, + res: Response, + ) { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const requestBody = this.#withGuiMetadata(req.body, req.body); - requestBody.fileMetadata = this.#normalizeFileMetadataPath(req, requestBody.fileMetadata, requestBody); - requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(requestBody.fileMetadata, requestBody); + requestBody.fileMetadata = this.#normalizeFileMetadataPath( + req, + requestBody.fileMetadata, + requestBody, + ); + requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata( + requestBody.fileMetadata, + requestBody, + ); await this.#assertWriteAccess(req, requestBody.fileMetadata, { pathAlreadyNormalized: true, }); @@ -282,7 +339,12 @@ export class FSController extends ExtensionController { Number(requestBody.fileMetadata.size ?? 0), requestBody.guiMetadata, ); - const response = await this.fsEntryService.write(userId, requestBody, uploadTracker, storageAllowanceMax); + const response = await this.fsEntryService.write( + userId, + requestBody, + uploadTracker, + storageAllowanceMax, + ); const updatedResponse = await this.#applyWriteResponseSideEffects( userId, response, @@ -292,7 +354,10 @@ export class FSController extends ExtensionController { } @Post('/batchWrite', { subdomain: 'api' }) - async batchWrites (req: Request, res: Response) { + async batchWrites ( + req: Request, + res: Response, + ) { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const requestMode = this.#resolveBatchWriteRequestMode(req); @@ -321,14 +386,21 @@ export class FSController extends ExtensionController { busboy.on('field', (fieldName, value, info) => { if ( info.fieldnameTruncated || info.valueTruncated ) { - failParse(new HttpError(400, 'Batch write manifest field is truncated')); + failParse( + new HttpError(400, 'Batch write manifest field is truncated'), + ); return; } if ( fieldName !== 'manifest' ) { return; } if ( manifestPreparationPromise ) { - failParse(new HttpError(409, 'Batch write manifest was provided more than once')); + failParse( + new HttpError( + 409, + 'Batch write manifest was provided more than once', + ), + ); return; } @@ -339,7 +411,11 @@ export class FSController extends ExtensionController { ...parsedManifest, items: parsedManifest.items.map((item) => ({ ...item, - fileMetadata: this.#normalizeFileMetadataPath(req, item.fileMetadata, item), + fileMetadata: this.#normalizeFileMetadataPath( + req, + item.fileMetadata, + item, + ), })), ignoredItemIndexes, }; @@ -355,17 +431,20 @@ export class FSController extends ExtensionController { } parsedManifest = { ...parsedManifest, - items: await Promise.all(parsedManifest.items.map(async (item) => ({ - ...item, - fileMetadata: await this.#resolveAssociatedAppMetadata( - item.fileMetadata, - item, - appUidLookupCache, - ), - }))), + items: await Promise.all( + parsedManifest.items.map(async (item) => ({ + ...item, + fileMetadata: await this.#resolveAssociatedAppMetadata( + item.fileMetadata, + item, + appUidLookupCache, + ), + })), + ), }; - const activeManifestItems = parsedManifest.items - .filter((item) => !parsedManifest?.ignoredItemIndexes?.has(item.index)); + const activeManifestItems = parsedManifest.items.filter( + (item) => !parsedManifest?.ignoredItemIndexes?.has(item.index), + ); await this.#assertBatchWriteAccess( req, @@ -405,7 +484,10 @@ export class FSController extends ExtensionController { throw parseFailure; } if ( ! manifestPreparationPromise ) { - throw new HttpError(400, 'Batch write manifest must come before file content'); + throw new HttpError( + 400, + 'Batch write manifest must come before file content', + ); } await manifestPreparationPromise; @@ -428,13 +510,19 @@ export class FSController extends ExtensionController { return null; } if ( uploadedIndexes.has(itemIndex) ) { - throw new HttpError(409, `Duplicate file content for batch index ${itemIndex}`); + throw new HttpError( + 409, + `Duplicate file content for batch index ${itemIndex}`, + ); } uploadedIndexes.add(itemIndex); const preparedItem = preparedBatch.itemsByIndex.get(itemIndex); if ( ! preparedItem ) { - throw new HttpError(400, `Batch write metadata was not found for index ${itemIndex}`); + throw new HttpError( + 400, + `Batch write metadata was not found for index ${itemIndex}`, + ); } const uploadTracker = await this.#createUploadTracker( @@ -475,27 +563,47 @@ export class FSController extends ExtensionController { await manifestPreparationPromise; const uploadResults = await Promise.allSettled(uploadPromises); const uploadedItems = uploadResults - .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .filter( + ( + result, + ): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) .map((result) => result.value) - .filter((uploadedItem): uploadedItem is UploadedBatchWriteItem => uploadedItem !== null); + .filter( + (uploadedItem): uploadedItem is UploadedBatchWriteItem => + uploadedItem !== null, + ); if ( parseFailure ) { if ( preparedBatch ) { - await this.fsEntryService.cleanupPreparedBatchUploads(preparedBatch, uploadedItems); + await this.fsEntryService.cleanupPreparedBatchUploads( + preparedBatch, + uploadedItems, + ); } throw parseFailure; } if ( ! preparedBatch ) { throw new HttpError(500, 'Failed to prepare batch write operation'); } - const failedUpload = uploadResults.find((result) => result.status === 'rejected'); + const failedUpload = uploadResults.find( + (result) => result.status === 'rejected', + ); if ( failedUpload?.status === 'rejected' ) { - await this.fsEntryService.cleanupPreparedBatchUploads(preparedBatch, uploadedItems); - throw (failedUpload.reason instanceof Error + await this.fsEntryService.cleanupPreparedBatchUploads( + preparedBatch, + uploadedItems, + ); + throw failedUpload.reason instanceof Error ? failedUpload.reason - : new Error('Failed to upload multipart batch item')); + : new Error('Failed to upload multipart batch item'); } - const writeResponses = await this.fsEntryService.finalizePreparedBatchWrites(preparedBatch, uploadedItems); + const writeResponses = + await this.fsEntryService.finalizePreparedBatchWrites( + preparedBatch, + uploadedItems, + ); const updatedResponses = await runWithConcurrencyLimit( writeResponses, 32, @@ -513,20 +621,27 @@ export class FSController extends ExtensionController { } const requests = Array.isArray(req.body) - ? await Promise.all(req.body.map(async (requestBody) => { - const normalizedRequestBody = this.#withGuiMetadata(requestBody, req.body); - normalizedRequestBody.fileMetadata = this.#normalizeFileMetadataPath( - req, - normalizedRequestBody.fileMetadata, - normalizedRequestBody, - ); - normalizedRequestBody.fileMetadata = await this.#resolveAssociatedAppMetadata( - normalizedRequestBody.fileMetadata, - normalizedRequestBody, - appUidLookupCache, - ); - return normalizedRequestBody; - })) + ? await Promise.all( + req.body.map(async (requestBody) => { + const normalizedRequestBody = this.#withGuiMetadata( + requestBody, + req.body, + ); + normalizedRequestBody.fileMetadata = + this.#normalizeFileMetadataPath( + req, + normalizedRequestBody.fileMetadata, + normalizedRequestBody, + ); + normalizedRequestBody.fileMetadata = + await this.#resolveAssociatedAppMetadata( + normalizedRequestBody.fileMetadata, + normalizedRequestBody, + appUidLookupCache, + ); + return normalizedRequestBody; + }), + ) : []; const filteredRequests = requests.filter((requestBody) => { return !this.#shouldIgnoreUploadPath(requestBody.fileMetadata.path); @@ -562,7 +677,9 @@ export class FSController extends ExtensionController { async (requestBody, index) => { const preparedItem = preparedBatch.items[index]; if ( ! preparedItem ) { - throw new Error(`Failed to resolve prepared batch item for index ${index}`); + throw new Error( + `Failed to resolve prepared batch item for index ${index}`, + ); } const uploadTracker = await this.#createUploadTracker( userId, @@ -581,17 +698,29 @@ export class FSController extends ExtensionController { }, ); const uploadedItems = uploadResults - .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) .map((result) => result.value); - const failedUpload = uploadResults.find((result) => result.status === 'rejected'); + const failedUpload = uploadResults.find( + (result) => result.status === 'rejected', + ); if ( failedUpload?.status === 'rejected' ) { - await this.fsEntryService.cleanupPreparedBatchUploads(preparedBatch, uploadedItems); - throw (failedUpload.reason instanceof Error + await this.fsEntryService.cleanupPreparedBatchUploads( + preparedBatch, + uploadedItems, + ); + throw failedUpload.reason instanceof Error ? failedUpload.reason - : new Error('Failed to upload batch write item')); + : new Error('Failed to upload batch write item'); } - const writeResponses = await this.fsEntryService.finalizePreparedBatchWrites(preparedBatch, uploadedItems); + const writeResponses = + await this.fsEntryService.finalizePreparedBatchWrites( + preparedBatch, + uploadedItems, + ); const updatedResponses = await runWithConcurrencyLimit( writeResponses, 32, @@ -607,14 +736,14 @@ export class FSController extends ExtensionController { res.json(updatedResponses); } - #getActorUserId ( - req: Request, - ): number { - const requestUser = (req as Request & { - user?: { - id?: unknown; - }; - }).user; + #getActorUserId (req: Request): number { + const requestUser = ( + req as Request & { + user?: { + id?: unknown; + }; + } + ).user; const actorUser = req.actor?.type?.user; const candidateUserId = requestUser?.id ?? actorUser?.id; if ( candidateUserId === undefined || candidateUserId === null ) { @@ -629,17 +758,20 @@ export class FSController extends ExtensionController { return userId; } - #getActorUsername ( - req: Request, - ): string { - const requestUser = (req as Request & { - user?: { - username?: unknown; - }; - }).user; + #getActorUsername (req: Request): string { + const requestUser = ( + req as Request & { + user?: { + username?: unknown; + }; + } + ).user; const actorUser = req.actor?.type?.user; const actorUsername = requestUser?.username ?? actorUser?.username; - if ( typeof actorUsername !== 'string' || actorUsername.trim().length === 0 ) { + if ( + typeof actorUsername !== 'string' || + actorUsername.trim().length === 0 + ) { throw new HttpError(401, 'Unauthorized'); } return actorUsername.trim(); @@ -721,7 +853,9 @@ export class FSController extends ExtensionController { normalizedFileMetadata.path = path; } - const size = this.#toNumber(this.#firstDefined(metadataRecord.size, fallbackRecord.size)); + const size = this.#toNumber( + this.#firstDefined(metadataRecord.size, fallbackRecord.size), + ); if ( size !== undefined ) { normalizedFileMetadata.size = size; } @@ -746,70 +880,79 @@ export class FSController extends ExtensionController { normalizedFileMetadata.checksumSha256 = checksumSha256; } - const overwrite = this.#toBoolean(this.#firstDefined( - metadataRecord.overwrite, - fallbackRecord.overwrite, - )); + const overwrite = this.#toBoolean( + this.#firstDefined(metadataRecord.overwrite, fallbackRecord.overwrite), + ); if ( overwrite !== undefined ) { normalizedFileMetadata.overwrite = overwrite; } - const dedupeName = this.#toBoolean(this.#firstDefined( - metadataRecord.dedupeName, - metadataRecord.dedupe_name, - fallbackRecord.dedupeName, - fallbackRecord.dedupe_name, - fallbackRecord.rename, - fallbackRecord.change_name, - )); + const dedupeName = this.#toBoolean( + this.#firstDefined( + metadataRecord.dedupeName, + metadataRecord.dedupe_name, + fallbackRecord.dedupeName, + fallbackRecord.dedupe_name, + fallbackRecord.rename, + fallbackRecord.change_name, + ), + ); if ( dedupeName !== undefined ) { normalizedFileMetadata.dedupeName = dedupeName; } - const createMissingParents = this.#toBoolean(this.#firstDefined( - metadataRecord.createMissingParents, - metadataRecord.create_missing_parents, - metadataRecord.create_missing_ancestors, - fallbackRecord.createMissingParents, - fallbackRecord.create_missing_parents, - fallbackRecord.createMissingAncestors, - fallbackRecord.create_missing_ancestors, - fallbackRecord.createFileParent, - fallbackRecord.create_file_parent, - )); + const createMissingParents = this.#toBoolean( + this.#firstDefined( + metadataRecord.createMissingParents, + metadataRecord.create_missing_parents, + metadataRecord.create_missing_ancestors, + fallbackRecord.createMissingParents, + fallbackRecord.create_missing_parents, + fallbackRecord.createMissingAncestors, + fallbackRecord.create_missing_ancestors, + fallbackRecord.createFileParent, + fallbackRecord.create_file_parent, + ), + ); if ( createMissingParents !== undefined ) { normalizedFileMetadata.createMissingParents = createMissingParents; } - const immutable = this.#toBoolean(this.#firstDefined( - metadataRecord.immutable, - fallbackRecord.immutable, - )); + const immutable = this.#toBoolean( + this.#firstDefined(metadataRecord.immutable, fallbackRecord.immutable), + ); if ( immutable !== undefined ) { normalizedFileMetadata.immutable = immutable; } - const isPublic = this.#toBoolean(this.#firstDefined( - metadataRecord.isPublic, - metadataRecord.is_public, - fallbackRecord.isPublic, - fallbackRecord.is_public, - )); + const isPublic = this.#toBoolean( + this.#firstDefined( + metadataRecord.isPublic, + metadataRecord.is_public, + fallbackRecord.isPublic, + fallbackRecord.is_public, + ), + ); if ( isPublic !== undefined ) { normalizedFileMetadata.isPublic = isPublic; } - const multipartPartSize = this.#toNumber(this.#firstDefined( - metadataRecord.multipartPartSize, - metadataRecord.multipart_part_size, - fallbackRecord.multipartPartSize, - fallbackRecord.multipart_part_size, - )); + const multipartPartSize = this.#toNumber( + this.#firstDefined( + metadataRecord.multipartPartSize, + metadataRecord.multipart_part_size, + fallbackRecord.multipartPartSize, + fallbackRecord.multipart_part_size, + ), + ); if ( multipartPartSize !== undefined && multipartPartSize > 0 ) { normalizedFileMetadata.multipartPartSize = multipartPartSize; } - const bucket = this.#firstDefined(metadataRecord.bucket, fallbackRecord.bucket); + const bucket = this.#firstDefined( + metadataRecord.bucket, + fallbackRecord.bucket, + ); if ( typeof bucket === 'string' && bucket.length > 0 ) { normalizedFileMetadata.bucket = bucket; } @@ -824,12 +967,14 @@ export class FSController extends ExtensionController { normalizedFileMetadata.bucketRegion = bucketRegion; } - const associatedAppId = this.#toNumber(this.#firstDefined( - metadataRecord.associatedAppId, - metadataRecord.associated_app_id, - fallbackRecord.associatedAppId, - fallbackRecord.associated_app_id, - )); + const associatedAppId = this.#toNumber( + this.#firstDefined( + metadataRecord.associatedAppId, + metadataRecord.associated_app_id, + fallbackRecord.associatedAppId, + fallbackRecord.associated_app_id, + ), + ); if ( associatedAppId !== undefined ) { normalizedFileMetadata.associatedAppId = associatedAppId; } @@ -845,12 +990,14 @@ export class FSController extends ExtensionController { const metadataRecord = this.#toObjectRecord(fileMetadata); const fallbackRecord = this.#toObjectRecord(fallbackSource); - const associatedAppId = this.#toNumber(this.#firstDefined( - metadataRecord.associatedAppId, - metadataRecord.associated_app_id, - fallbackRecord.associatedAppId, - fallbackRecord.associated_app_id, - )); + const associatedAppId = this.#toNumber( + this.#firstDefined( + metadataRecord.associatedAppId, + metadataRecord.associated_app_id, + fallbackRecord.associatedAppId, + fallbackRecord.associated_app_id, + ), + ); if ( associatedAppId !== undefined ) { return { ...fileMetadata, @@ -947,7 +1094,10 @@ export class FSController extends ExtensionController { fileMetadata: FSEntryWriteInput | undefined, fallbackSource?: unknown, ): FSEntryWriteInput { - const resolvedFileMetadata = this.#resolveWriteFileMetadata(fileMetadata, fallbackSource); + const resolvedFileMetadata = this.#resolveWriteFileMetadata( + fileMetadata, + fallbackSource, + ); if ( typeof resolvedFileMetadata.path !== 'string' ) { throw new HttpError(400, 'Missing path'); } @@ -959,46 +1109,60 @@ export class FSController extends ExtensionController { }; } - #extractGuiMetadata (input: unknown, fallback: WriteGuiMetadata | undefined): WriteGuiMetadata | undefined { - const source = input && typeof input === 'object' - ? input as Record - : {}; + #extractGuiMetadata ( + input: unknown, + fallback: WriteGuiMetadata | undefined, + ): WriteGuiMetadata | undefined { + const source = + input && typeof input === 'object' + ? (input as Record) + : {}; const guiMetadata: WriteGuiMetadata = { - originalClientSocketId: typeof source.originalClientSocketId === 'string' - ? source.originalClientSocketId - : typeof source.original_client_socket_id === 'string' - ? source.original_client_socket_id - : fallback?.originalClientSocketId, - socketId: typeof source.socketId === 'string' - ? source.socketId - : typeof source.socket_id === 'string' - ? source.socket_id - : fallback?.socketId, - operationId: typeof source.operationId === 'string' - ? source.operationId - : typeof source.operation_id === 'string' - ? source.operation_id - : fallback?.operationId, - itemUploadId: typeof source.itemUploadId === 'string' - ? source.itemUploadId - : typeof source.item_upload_id === 'string' - ? source.item_upload_id - : fallback?.itemUploadId, + originalClientSocketId: + typeof source.originalClientSocketId === 'string' + ? source.originalClientSocketId + : typeof source.original_client_socket_id === 'string' + ? source.original_client_socket_id + : fallback?.originalClientSocketId, + socketId: + typeof source.socketId === 'string' + ? source.socketId + : typeof source.socket_id === 'string' + ? source.socket_id + : fallback?.socketId, + operationId: + typeof source.operationId === 'string' + ? source.operationId + : typeof source.operation_id === 'string' + ? source.operation_id + : fallback?.operationId, + itemUploadId: + typeof source.itemUploadId === 'string' + ? source.itemUploadId + : typeof source.item_upload_id === 'string' + ? source.item_upload_id + : fallback?.itemUploadId, }; if ( - !guiMetadata.originalClientSocketId - && !guiMetadata.socketId - && !guiMetadata.operationId - && !guiMetadata.itemUploadId + !guiMetadata.originalClientSocketId && + !guiMetadata.socketId && + !guiMetadata.operationId && + !guiMetadata.itemUploadId ) { return undefined; } return guiMetadata; } - #withGuiMetadata (value: T, fallbackSource: unknown): T { - const guiMetadata = this.#extractGuiMetadata(value, this.#extractGuiMetadata(fallbackSource, undefined)); + #withGuiMetadata( + value: T, + fallbackSource: unknown, + ): T { + const guiMetadata = this.#extractGuiMetadata( + value, + this.#extractGuiMetadata(fallbackSource, undefined), + ); if ( ! guiMetadata ) { return value; } @@ -1038,14 +1202,16 @@ export class FSController extends ExtensionController { const dedupeEnabled = this.#isDedupeEnabled(normalizedFileMetadata); let pathToCheck = parentPath; if ( Boolean(normalizedFileMetadata.overwrite) && !dedupeEnabled ) { - const destinationExists = await this.fsEntryService.entryExistsByPath(targetPath); + const destinationExists = + await this.fsEntryService.entryExistsByPath(targetPath); if ( destinationExists ) { pathToCheck = targetPath; } } const fsEntryService = this.fsEntryService; - let ancestorsCache: Promise> | null = null; + let ancestorsCache: Promise> | null = + null; const resourceDescriptor = { path: pathToCheck, resolveAncestors () { @@ -1061,7 +1227,11 @@ export class FSController extends ExtensionController { return; } - const safeAclError = await aclService.get_safe_acl_error(actor, resourceDescriptor, 'write') as { + const safeAclError = (await aclService.get_safe_acl_error( + actor, + resourceDescriptor, + 'write', + )) as { status?: unknown; message?: unknown; fields?: { @@ -1069,15 +1239,17 @@ export class FSController extends ExtensionController { }; }; const safeAclStatus = Number(safeAclError?.status); - const safeAclMessage = typeof safeAclError?.message === 'string' && safeAclError.message.length > 0 - ? safeAclError.message - : 'Write access denied for destination'; - const safeAclCode = typeof safeAclError?.fields?.code === 'string' - ? safeAclError.fields.code - : undefined; - const legacyCode = safeAclCode === 'forbidden' - ? 'access_denied' - : safeAclCode; + const safeAclMessage = + typeof safeAclError?.message === 'string' && + safeAclError.message.length > 0 + ? safeAclError.message + : 'Write access denied for destination'; + const safeAclCode = + typeof safeAclError?.fields?.code === 'string' + ? safeAclError.fields.code + : undefined; + const legacyCode = + safeAclCode === 'forbidden' ? 'access_denied' : safeAclCode; if ( safeAclStatus === 404 ) { throw new HttpError(404, safeAclMessage, { @@ -1122,8 +1294,12 @@ export class FSController extends ExtensionController { ? { original_client_socket_id: guiMetadata.originalClientSocketId } : {}), ...(guiMetadata.socketId ? { socket_id: guiMetadata.socketId } : {}), - ...(guiMetadata.operationId ? { operation_id: guiMetadata.operationId } : {}), - ...(guiMetadata.itemUploadId ? { item_upload_id: guiMetadata.itemUploadId } : {}), + ...(guiMetadata.operationId + ? { operation_id: guiMetadata.operationId } + : {}), + ...(guiMetadata.itemUploadId + ? { item_upload_id: guiMetadata.itemUploadId } + : {}), }; } @@ -1157,15 +1333,20 @@ export class FSController extends ExtensionController { associated_app_id: entry.associatedAppId, }; - if ( typeof response.thumbnail === 'string' && response.thumbnail.length > 0 ) { + if ( + typeof response.thumbnail === 'string' && + response.thumbnail.length > 0 + ) { const thumbnailEntry = { uuid: entry.uuid, thumbnail: response.thumbnail, }; await this.eventService.emit('thumbnail.read', thumbnailEntry); - response.thumbnail = typeof thumbnailEntry.thumbnail === 'string' && thumbnailEntry.thumbnail.length > 0 - ? thumbnailEntry.thumbnail - : null; + response.thumbnail = + typeof thumbnailEntry.thumbnail === 'string' && + thumbnailEntry.thumbnail.length > 0 + ? thumbnailEntry.thumbnail + : null; } return response; @@ -1177,7 +1358,7 @@ export class FSController extends ExtensionController { guiMetadata: WriteGuiMetadata | undefined, ): Promise { const response = { - ...await this.#toGuiFsEntry(fsEntry), + ...(await this.#toGuiFsEntry(fsEntry)), ...this.#toEventGuiMetadata(guiMetadata, false), from_new_service: true, }; @@ -1187,6 +1368,16 @@ export class FSController extends ExtensionController { }); } + async #emitFsLifecycleEvent ( + eventName: 'fs.write.file' | 'fs.create.file', + fsEntry: FSEntry, + ): Promise { + await this.eventService.emit(eventName, { + node: fsEntry, + context: Context.get(), + }); + } + async #emitGuiPendingWriteEvent ( userId: number, requestBody: SignedWriteRequest, @@ -1223,7 +1414,7 @@ export class FSController extends ExtensionController { #estimateDataUrlSize (dataUrl: string): number { const commaIndex = dataUrl.indexOf(','); const base64 = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1); - return Math.ceil(base64.length * 3 / 4); + return Math.ceil((base64.length * 3) / 4); } #isOversizedThumbnailDataUrl (thumbnail: string): boolean { @@ -1247,15 +1438,21 @@ export class FSController extends ExtensionController { const thumbnailPayload = { url: requestedThumbnail }; await this.eventService.emit('thumbnail.created', thumbnailPayload); - const finalThumbnail = typeof thumbnailPayload.url === 'string' && thumbnailPayload.url.length > 0 - ? thumbnailPayload.url - : null; + const finalThumbnail = + typeof thumbnailPayload.url === 'string' && + thumbnailPayload.url.length > 0 + ? thumbnailPayload.url + : null; if ( finalThumbnail === fsEntry.thumbnail || finalThumbnail === null ) { return fsEntry; } - return this.fsEntryService.updateEntryThumbnail(userId, fsEntry.uuid, finalThumbnail); + return this.fsEntryService.updateEntryThumbnail( + userId, + fsEntry.uuid, + finalThumbnail, + ); } #toThumbnailPrepareItem ( @@ -1271,11 +1468,15 @@ export class FSController extends ExtensionController { return null; } - const contentType = typeof thumbnailMetadata.contentType === 'string' - ? thumbnailMetadata.contentType.trim() - : ''; + const contentType = + typeof thumbnailMetadata.contentType === 'string' + ? thumbnailMetadata.contentType.trim() + : ''; if ( ! contentType ) { - throw new HttpError(400, 'thumbnailMetadata.contentType is required for signed thumbnail upload'); + throw new HttpError( + 400, + 'thumbnailMetadata.contentType is required for signed thumbnail upload', + ); } if ( thumbnailMetadata.size === undefined ) { @@ -1284,7 +1485,10 @@ export class FSController extends ExtensionController { const size = Number(thumbnailMetadata.size); if ( !Number.isFinite(size) || size < 0 ) { - throw new HttpError(400, 'thumbnailMetadata.size must be a non-negative number'); + throw new HttpError( + 400, + 'thumbnailMetadata.size must be a non-negative number', + ); } if ( size > MAX_THUMBNAIL_BYTES ) { return null; @@ -1298,30 +1502,39 @@ export class FSController extends ExtensionController { responses: SignedWriteResponse[], ): Promise { const prepareItems = requests - .map((requestBody, index) => this.#toThumbnailPrepareItem(requestBody, index)) + .map((requestBody, index) => + this.#toThumbnailPrepareItem(requestBody, index)) .filter((item): item is ThumbnailUploadPrepareItem => Boolean(item)); if ( prepareItems.length === 0 ) { return; } const payload: ThumbnailUploadPreparePayload = { - items: prepareItems.map((item): ThumbnailUploadPrepareItem => ({ - index: item.index, - contentType: item.contentType, - ...(item.size !== undefined ? { size: item.size } : {}), - })), + items: prepareItems.map( + (item): ThumbnailUploadPrepareItem => ({ + index: item.index, + contentType: item.contentType, + ...(item.size !== undefined ? { size: item.size } : {}), + }), + ), }; await this.eventService.emit('thumbnail.upload.prepare', payload); for ( const item of payload.items ) { const response = responses[item.index]; if ( ! response ) { - throw new HttpError(500, 'Failed to resolve signed thumbnail response target'); + throw new HttpError( + 500, + 'Failed to resolve signed thumbnail response target', + ); } if ( typeof item.uploadUrl !== 'string' || item.uploadUrl.length === 0 ) { continue; } - if ( typeof item.thumbnailUrl !== 'string' || item.thumbnailUrl.length === 0 ) { + if ( + typeof item.thumbnailUrl !== 'string' || + item.thumbnailUrl.length === 0 + ) { continue; } @@ -1356,13 +1569,14 @@ export class FSController extends ExtensionController { } const contentTypeHeader = req.headers['content-type']; - const contentType = typeof contentTypeHeader === 'string' - ? contentTypeHeader.toLowerCase() - : ''; + const contentType = + typeof contentTypeHeader === 'string' + ? contentTypeHeader.toLowerCase() + : ''; if ( - contentType.includes('application/json') - || contentType.startsWith('text/plain;actually=json') + contentType.includes('application/json') || + contentType.startsWith('text/plain;actually=json') ) { return 'json'; } @@ -1373,11 +1587,17 @@ export class FSController extends ExtensionController { ); } - async #runNonCritical (work: () => Promise, operationName: string): Promise { + async #runNonCritical ( + work: () => Promise, + operationName: string, + ): Promise { try { await work(); } catch ( error ) { - console.error(`prodfsv2 non-critical operation failed: ${operationName}`, error); + console.error( + `prodfsv2 non-critical operation failed: ${operationName}`, + error, + ); } } @@ -1410,7 +1630,10 @@ export class FSController extends ExtensionController { return uploadTracker; } - async #emitWriteHashEvent (contentHashSha256: string | null | undefined, entryUuid: string): Promise { + async #emitWriteHashEvent ( + contentHashSha256: string | null | undefined, + entryUuid: string, + ): Promise { if ( ! contentHashSha256 ) { return; } @@ -1441,12 +1664,21 @@ export class FSController extends ExtensionController { await this.#runNonCritical(async () => { await this.#emitGuiWriteEvent( - response.wasOverwrite ? 'outer.gui.item.updated' : 'outer.gui.item.added', + response.wasOverwrite + ? 'outer.gui.item.updated' + : 'outer.gui.item.added', fsEntry, guiMetadata, ); }, 'emitGuiWriteEvent'); + await this.#runNonCritical(async () => { + await this.#emitFsLifecycleEvent( + response.wasOverwrite ? 'fs.write.file' : 'fs.create.file', + fsEntry, + ); + }, 'emitFsLifecycleEvent'); + await hashEventPromise; return { ...response, fsEntry }; @@ -1469,32 +1701,55 @@ export class FSController extends ExtensionController { const manifest: BatchWriteManifest = Array.isArray(parsedManifest) ? { items: parsedManifest as BatchWriteManifestItem[] } - : parsedManifest as BatchWriteManifest; + : (parsedManifest as BatchWriteManifest); - if ( !manifest || !Array.isArray(manifest.items) || manifest.items.length === 0 ) { - throw new HttpError(400, 'Batch write manifest must include a non-empty items array'); + if ( + !manifest || + !Array.isArray(manifest.items) || + manifest.items.length === 0 + ) { + throw new HttpError( + 400, + 'Batch write manifest must include a non-empty items array', + ); } - const manifestGuiMetadata = this.#extractGuiMetadata(manifest, fallbackGuiMetadata); + const manifestGuiMetadata = this.#extractGuiMetadata( + manifest, + fallbackGuiMetadata, + ); const normalizedItems = manifest.items.map((item, orderIndex) => { if ( !item || typeof item !== 'object' ) { - throw new HttpError(400, `Batch write manifest item at position ${orderIndex} is invalid`); + throw new HttpError( + 400, + `Batch write manifest item at position ${orderIndex} is invalid`, + ); } - const candidateIndex = (item as { index?: number | string }).index ?? orderIndex; + const candidateIndex = + (item as { index?: number | string }).index ?? orderIndex; const index = Number(candidateIndex); if ( !Number.isInteger(index) || index < 0 ) { - throw new HttpError(400, `Batch write manifest item index is invalid at position ${orderIndex}`); + throw new HttpError( + 400, + `Batch write manifest item index is invalid at position ${orderIndex}`, + ); } if ( !item.fileMetadata || typeof item.fileMetadata !== 'object' ) { - throw new HttpError(400, `Batch write manifest item ${index} is missing fileMetadata`); + throw new HttpError( + 400, + `Batch write manifest item ${index} is missing fileMetadata`, + ); } return { index, fileMetadata: item.fileMetadata, - thumbnailData: typeof item.thumbnailData === 'string' ? item.thumbnailData : undefined, + thumbnailData: + typeof item.thumbnailData === 'string' + ? item.thumbnailData + : undefined, guiMetadata: this.#extractGuiMetadata(item, manifestGuiMetadata), }; }); @@ -1503,7 +1758,10 @@ export class FSController extends ExtensionController { const fieldIndexMap = new Map(); for ( const item of normalizedItems ) { if ( seenIndexes.has(item.index) ) { - throw new HttpError(409, `Batch write manifest has duplicate index ${item.index}`); + throw new HttpError( + 409, + `Batch write manifest has duplicate index ${item.index}`, + ); } seenIndexes.add(item.index); fieldIndexMap.set(String(item.index), item.index); @@ -1548,7 +1806,9 @@ export class FSController extends ExtensionController { return fallbackItem.index; } - throw new HttpError(400, `Batch write file part "${fieldName}" does not map to manifest metadata`); + throw new HttpError( + 400, + `Batch write file part "${fieldName}" does not map to manifest metadata`, + ); } - } diff --git a/src/backend/src/clients/redis/redisSingleton.ts b/src/backend/src/clients/redis/redisSingleton.ts index 8691fba6e0..683dd00b6b 100644 --- a/src/backend/src/clients/redis/redisSingleton.ts +++ b/src/backend/src/clients/redis/redisSingleton.ts @@ -4,7 +4,7 @@ import MockRedis from 'ioredis-mock'; const redisStartupRetryMaxDelayMs = 2000; const redisSlotsRefreshTimeoutMs = 5000; const redisConnectTimeoutMs = 10000; -const redisMaxRetriesPerRequest = 2; +const redisMaxRetriesPerRequest = 1; const redisBootRetryRegex = /Cluster(All)?FailedError|None of startup nodes is available/i; const formatRedisError = (error: unknown): string => { diff --git a/src/backend/src/helpers.js b/src/backend/src/helpers.js index 2ef22e7280..bc0e212952 100644 --- a/src/backend/src/helpers.js +++ b/src/backend/src/helpers.js @@ -364,7 +364,7 @@ export async function get_app (options) { const cacheApp = async (app) => { if ( ! app ) return; AppRedisCacheSpace.setCachedApp(app, { - ttlSeconds: 300, + ttlSeconds: 24 * 60 * 60, }); }; const isDecoratedAppCacheEntry = (app) => ( @@ -550,7 +550,7 @@ export const get_apps = spanify('get_apps', async (specifiers, options = {}) => const cacheApp = async (app) => { if ( ! app ) return; AppRedisCacheSpace.setCachedApp(app, { - ttlSeconds: 300, + ttlSeconds: 24 * 60 * 60, }); }; diff --git a/src/backend/src/modules/apps/AppInformationService.js b/src/backend/src/modules/apps/AppInformationService.js index 05a8fc8ccc..7a18dcc77d 100644 --- a/src/backend/src/modules/apps/AppInformationService.js +++ b/src/backend/src/modules/apps/AppInformationService.js @@ -123,6 +123,10 @@ class AppInformationService extends BaseService { value: appUid, rawIcon: false, }), + AppRedisCacheSpace.objectKey({ + lookup: 'uid', + value: appUid, + }), ]), AppRedisCacheSpace.invalidateAppStats(appUid), ]); diff --git a/src/backend/src/modules/apps/AppRedisCacheSpace.js b/src/backend/src/modules/apps/AppRedisCacheSpace.js index c948d4f9ce..01802304e7 100644 --- a/src/backend/src/modules/apps/AppRedisCacheSpace.js +++ b/src/backend/src/modules/apps/AppRedisCacheSpace.js @@ -21,6 +21,7 @@ import { deleteRedisKeys } from '../../clients/redis/deleteRedisKeys.js'; const appFullNamespace = 'apps'; const appLookupKeys = ['uid', 'name', 'id']; +const appObjectSuffix = 'object'; const safeParseJson = (value, fallback = null) => { if ( value === null || value === undefined ) return fallback; @@ -45,15 +46,29 @@ const appCacheKey = ({ lookup, value }) => ( `${appNamespace()}:${lookup}:${value}` ); +const appObjectNamespace = () => `${appNamespace()}:${appObjectSuffix}`; + +const appObjectCacheKey = ({ lookup, value }) => ( + `${appObjectNamespace()}:${lookup}:${value}` +); + export const AppRedisCacheSpace = { key: appCacheKey, namespace: appNamespace, + objectNamespace: appObjectNamespace, + objectKey: appObjectCacheKey, keysForApp: (app) => { if ( ! app ) return []; return appLookupKeys .filter(lookup => app[lookup] !== undefined && app[lookup] !== null && app[lookup] !== '') .map(lookup => appCacheKey({ lookup, value: app[lookup] })); }, + objectKeysForApp: (app) => { + if ( ! app ) return []; + return appLookupKeys + .filter(lookup => app[lookup] !== undefined && app[lookup] !== null && app[lookup] !== '') + .map(lookup => appObjectCacheKey({ lookup, value: app[lookup] })); + }, uidScanPattern: () => `${appNamespace()}:uid:*`, pendingNamespace: () => 'pending_app', pendingKey: ({ lookup, value }) => ( @@ -77,6 +92,9 @@ export const AppRedisCacheSpace = { getCachedApp: async ({ lookup, value }) => ( safeParseJson(await redisClient.get(appCacheKey({ lookup, value }))) ), + getCachedAppObject: async ({ lookup, value }) => ( + safeParseJson(await redisClient.get(appObjectCacheKey({ lookup, value }))) + ), setCachedApp: async (app, { ttlSeconds } = {}) => { if ( ! app ) return; const serialized = JSON.stringify(app); @@ -86,9 +104,21 @@ export const AppRedisCacheSpace = { await Promise.all(writes); } }, + setCachedAppObject: async (app, { ttlSeconds } = {}) => { + if ( ! app ) return; + const serialized = JSON.stringify(app); + const writes = AppRedisCacheSpace.objectKeysForApp(app) + .map(key => setKey(key, serialized, { ttlSeconds: ttlSeconds || 60 })); + if ( writes.length ) { + await Promise.all(writes); + } + }, invalidateCachedApp: (app, { includeStats = false } = {}) => { if ( ! app ) return; - const keys = [...AppRedisCacheSpace.keysForApp(app)]; + const keys = [ + ...AppRedisCacheSpace.keysForApp(app), + ...AppRedisCacheSpace.objectKeysForApp(app), + ]; if ( includeStats && app.uid ) { keys.push(...AppRedisCacheSpace.statsKeys(app.uid)); } @@ -98,10 +128,16 @@ export const AppRedisCacheSpace = { }, invalidateCachedAppName: async (name) => { if ( ! name ) return; - const keys = [appCacheKey({ - lookup: 'name', - value: name, - })]; + const keys = [ + appCacheKey({ + lookup: 'name', + value: name, + }), + appObjectCacheKey({ + lookup: 'name', + value: name, + }), + ]; return deleteRedisKeys(keys); }, invalidateAppStats: async (uid) => { diff --git a/src/backend/src/om/entitystorage/AppES.js b/src/backend/src/om/entitystorage/AppES.js index 4fa125bb1b..4287d32af9 100644 --- a/src/backend/src/om/entitystorage/AppES.js +++ b/src/backend/src/om/entitystorage/AppES.js @@ -33,6 +33,7 @@ const uuidv4 = require('uuid').v4; const APP_UID_ALIAS_KEY_PREFIX = 'app:canonicalUidAlias'; const APP_UID_ALIAS_REVERSE_KEY_PREFIX = 'app:canonicalUidAliasReverse'; const APP_UID_ALIAS_TTL_SECONDS = 60 * 60 * 24 * 90; +const APP_OBJECT_CACHE_TTL_SECONDS = 24 * 60 * 60; const indexUrlUniquenessExemptionCandidates = [ 'https://dev-center.puter.com/coming-soon', ]; @@ -299,7 +300,7 @@ class AppES extends BaseES { }; await svc_event.emit('app.new-icon', event); if ( typeof event.url === 'string' && event.url ) { - this.db.write( + await this.db.write( 'UPDATE apps SET icon = ? WHERE id = ? LIMIT 1', [event.url, insert_id], ); @@ -446,6 +447,26 @@ class AppES extends BaseES { }); }, + async get_cached_app_object_ (appUid) { + if ( typeof appUid !== 'string' || !appUid ) return null; + return await AppRedisCacheSpace.getCachedAppObject({ + lookup: 'uid', + value: appUid, + }); + }, + + async set_cached_app_object_ (entity) { + if ( ! entity ) return; + + const cacheable = await entity.get_client_safe(); + delete cacheable.stats; + delete cacheable.privateAccess; + + await AppRedisCacheSpace.setCachedAppObject(cacheable, { + ttlSeconds: APP_OBJECT_CACHE_TTL_SECONDS, + }); + }, + /** * Transforms app data before reading by adding associations and handling permissions * @param {Object} entity - App entity to transform @@ -463,6 +484,7 @@ class AppES extends BaseES { const appIndexUrl = await entity.get('index_url'); const appCreatedAt = await entity.get('created_at'); const appIsPrivate = await entity.get('is_private'); + const cachedAppObject = await this.get_cached_app_object_(appUid); const appInformationService = services.get('app-information'); const authService = services.get('auth'); @@ -473,21 +495,36 @@ class AppES extends BaseES { created_at: appCreatedAt, }) : Promise.resolve(undefined); - const fileAssociationsPromise = this.db.read( - 'SELECT type FROM app_filetype_association WHERE app_id = ?', - [entity.private_meta.mysql_id], + const cachedFiletypeAssociations = Array.isArray(cachedAppObject?.filetype_associations) + ? cachedAppObject.filetype_associations + : null; + const hasCachedCreatedFromOrigin = !!( + cachedAppObject && + Object.prototype.hasOwnProperty.call(cachedAppObject, 'created_from_origin') ); - const createdFromOriginPromise = (async () => { - if ( ! authService ) return null; - try { - const origin = origin_from_url(appIndexUrl); - const expectedUid = await authService.app_uid_from_origin(origin); - return expectedUid === appUid ? origin : null; - } catch { - // This happens when index_url is not a valid URL. - return null; - } - })(); + const shouldRefreshCachedAppObject = + !cachedAppObject || + !cachedFiletypeAssociations || + !hasCachedCreatedFromOrigin; + const fileAssociationsPromise = cachedFiletypeAssociations + ? Promise.resolve(cachedFiletypeAssociations) + : this.db.read( + 'SELECT type FROM app_filetype_association WHERE app_id = ?', + [entity.private_meta.mysql_id], + ).then(rows => rows.map(row => row.type)); + const createdFromOriginPromise = hasCachedCreatedFromOrigin + ? Promise.resolve(cachedAppObject.created_from_origin ?? null) + : (async () => { + if ( ! authService ) return null; + try { + const origin = origin_from_url(appIndexUrl); + const expectedUid = await authService.app_uid_from_origin(origin); + return expectedUid === appUid ? origin : null; + } catch { + // This happens when index_url is not a valid URL. + return null; + } + })(); const privateAccessPromise = resolvePrivateLaunchAccess({ app: { uid: appUid, @@ -501,7 +538,7 @@ class AppES extends BaseES { }); const [ - fileAssociationRows, + filetypeAssociations, stats, createdFromOrigin, privateAccess, @@ -511,13 +548,13 @@ class AppES extends BaseES { createdFromOriginPromise, privateAccessPromise, ]); - await entity.set( - 'filetype_associations', - fileAssociationRows.map(row => row.type), - ); + await entity.set('filetype_associations', filetypeAssociations); await entity.set('stats', stats); await entity.set('created_from_origin', createdFromOrigin); await entity.set('privateAccess', privateAccess); + if ( shouldRefreshCachedAppObject ) { + await this.set_cached_app_object_(entity); + } // Migrate b64 icons to the filesystem-backed icon flow without blocking reads. this.queueIconMigration(entity); diff --git a/src/backend/src/services/MeteringService/costMaps/togetherCostMap.ts b/src/backend/src/services/MeteringService/costMaps/togetherCostMap.ts index e6f8445dc0..c6884c57ba 100644 --- a/src/backend/src/services/MeteringService/costMaps/togetherCostMap.ts +++ b/src/backend/src/services/MeteringService/costMaps/togetherCostMap.ts @@ -16,28 +16,28 @@ export const TOGETHER_COST_MAP = { 'together-image:Qwen/Qwen-Image': 0.0058 * 100_000_000, 'together-image:RunDiffusion/Juggernaut-pro-flux': 0.0049 * 100_000_000, 'together-image:Rundiffusion/Juggernaut-Lightning-Flux': 0.0017 * 100_000_000, - 'together-image:black-forest-labs/FLUX.1-Canny-pro': 0.05 * 100_000_000, - 'together-image:black-forest-labs/FLUX.1-dev': 0.025 * 100_000_000, - 'together-image:black-forest-labs/FLUX.1-dev-lora': 0.025 * 100_000_000, - 'together-image:black-forest-labs/FLUX.1-kontext-dev': 0.025 * 100_000_000, 'together-image:black-forest-labs/FLUX.1-kontext-max': 0.08 * 100_000_000, 'together-image:black-forest-labs/FLUX.1-kontext-pro': 0.04 * 100_000_000, 'together-image:black-forest-labs/FLUX.1-krea-dev': 0.025 * 100_000_000, - 'together-image:black-forest-labs/FLUX.1-pro': 0.05 * 100_000_000, 'together-image:black-forest-labs/FLUX.1-schnell': 0.0027 * 100_000_000, - 'together-image:black-forest-labs/FLUX.1.1-pro': 0.05 * 100_000_000, + 'together-image:black-forest-labs/FLUX.1.1-pro': 0.04 * 100_000_000, 'together-image:black-forest-labs/FLUX.2-pro': 0.03 * 100_000_000, 'together-image:black-forest-labs/FLUX.2-flex': 0.03 * 100_000_000, 'together-image:black-forest-labs/FLUX.2-dev': 0.0154 * 100_000_000, 'together-image:black-forest-labs/FLUX.2-max': 0.07 * 100_000_000, 'together-image:google/flash-image-2.5': 0.039 * 100_000_000, + 'together-image:google/flash-image-3.1': 0.067 * 100_000_000, 'together-image:google/gemini-3-pro-image': 0.134 * 100_000_000, 'together-image:google/imagen-4.0-fast': 0.02 * 100_000_000, 'together-image:google/imagen-4.0-preview': 0.04 * 100_000_000, 'together-image:google/imagen-4.0-ultra': 0.06 * 100_000_000, 'together-image:ideogram/ideogram-3.0': 0.06 * 100_000_000, + 'together-image:openai/gpt-image-1.5': 0.034 * 100_000_000, + 'together-image:Qwen/Qwen-Image-2.0': 0.04 * 100_000_000, + 'together-image:Qwen/Qwen-Image-2.0-Pro': 0.08 * 100_000_000, + 'together-image:Wan-AI/Wan2.6-image': 0.03 * 100_000_000, 'together-image:stabilityai/stable-diffusion-3-medium': 0.0019 * 100_000_000, - 'together-image:stabilityai/stable-diffusion-xl-base-1.0': 0.0045 * 100_000_000, + 'together-image:stabilityai/stable-diffusion-xl-base-1.0': 0.0019 * 100_000_000, // Video generation placeholder (per-video pricing). Update with real pricing when available. 'together-video:default': 0, @@ -45,6 +45,7 @@ export const TOGETHER_COST_MAP = { 'together-video:ByteDance/Seedance-1.0-pro': 0.57 * 100_000_000, 'together-video:Wan-AI/Wan2.2-I2V-A14B': 0.31 * 100_000_000, 'together-video:Wan-AI/Wan2.2-T2V-A14B': 0.66 * 100_000_000, + 'together-video:Wan-AI/wan2.7-t2v': 0.10 * 100_000_000, 'together-video:google/veo-2.0': 2.50 * 100_000_000, 'together-video:google/veo-3.0': 1.60 * 100_000_000, 'together-video:google/veo-3.0-audio': 3.20 * 100_000_000, @@ -56,10 +57,10 @@ export const TOGETHER_COST_MAP = { 'together-video:kwaivgI/kling-2.1-master': 0.92 * 100_000_000, 'together-video:kwaivgI/kling-2.1-pro': 0.32 * 100_000_000, 'together-video:kwaivgI/kling-2.1-standard': 0.18 * 100_000_000, - 'together-video:minimax/hailuo-02': 0.56 * 100_000_000, + 'together-video:minimax/hailuo-02': 0.49 * 100_000_000, 'together-video:minimax/video-01-director': 0.28 * 100_000_000, 'together-video:openai/sora-2': 0.80 * 100_000_000, - 'together-video:openai/sora-2-pro': 4.00 * 100_000_000, + 'together-video:openai/sora-2-pro': 3.00 * 100_000_000, 'together-video:pixverse/pixverse-v5': 0.30 * 100_000_000, 'together-video:vidu/vidu-2.0': 0.28 * 100_000_000, 'together-video:vidu/vidu-q1': 0.22 * 100_000_000, diff --git a/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/OpenAiImageGenerationProvider.ts b/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/OpenAiImageGenerationProvider.ts index 53fe18418a..3147c076fc 100644 --- a/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/OpenAiImageGenerationProvider.ts +++ b/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/OpenAiImageGenerationProvider.ts @@ -85,9 +85,15 @@ export class OpenAiImageGenerationProvider implements IImageProvider { throw new Error('`prompt` must be a string'); } - const validRations = selectedModel?.allowedRatios; - if ( validRations && (!ratio || !validRations.some(r => r.w === ratio.w && r.h === ratio.h)) ) { - ratio = validRations[0]; // Default to the first allowed ratio + const validRatios = selectedModel?.allowedRatios; + if ( validRatios ) { + if ( !ratio || !validRatios.some(r => r.w === ratio.w && r.h === ratio.h) ) { + ratio = validRatios[0]; // Default to the first allowed ratio + } + } else { + // Open-ended size models (gpt-image-2): conform to OpenAI's size + // rules (16px multiples, 3840 cap, 3:1 ratio, pixel budget). + ratio = this.#normalizeGptImage2Ratio(ratio); } if ( ! ratio ) { @@ -101,7 +107,10 @@ export class OpenAiImageGenerationProvider implements IImageProvider { const size = `${ratio.w}x${ratio.h}`; const price_key = this.#buildPriceKey(selectedModel.id, quality!, size); - const outputPriceInCents = selectedModel?.costs[price_key]; + let outputPriceInCents: number | undefined = selectedModel?.costs[price_key]; + if ( outputPriceInCents === undefined ) { + outputPriceInCents = this.#estimateOutputCostFromTokens(selectedModel, ratio, quality); + } if ( outputPriceInCents === undefined ) { const availableSizes = Object.keys(selectedModel?.costs) .filter(key => !OpenAiImageGenerationProvider.#NON_SIZE_COST_KEYS.includes(key)); @@ -412,8 +421,96 @@ export class OpenAiImageGenerationProvider implements IImageProvider { } #isGptImageModel (model: string) { - // Covers gpt-image-1, gpt-image-1-mini, gpt-image-1.5 and future variants. - return model.startsWith('gpt-image-1'); + // Covers gpt-image-1, gpt-image-1-mini, gpt-image-1.5, gpt-image-2 and future variants. + return model.startsWith('gpt-image-'); + } + + // gpt-image-2 size rules: each edge in [16, 3840] and a multiple of 16, + // long:short ratio ≤ 3:1, pixel count in [655360, 8294400]. Silently + // clamps/snaps rather than throwing so arbitrary user input is accepted. + // https://developers.openai.com/api/docs/guides/image-generation + #normalizeGptImage2Ratio (ratio?: { w: number; h: number }) { + const MIN_EDGE = 16; + const MAX_EDGE = 3840; + const STEP = 16; + const MAX_RATIO = 3; + const MIN_PIXELS = 655_360; + const MAX_PIXELS = 8_294_400; + + let w = Number(ratio?.w); + let h = Number(ratio?.h); + if ( !Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0 ) { + return { w: 1024, h: 1024 }; + } + + // 1. Clamp long:short ratio to MAX_RATIO by shrinking the longer edge. + if ( w / h > MAX_RATIO ) w = h * MAX_RATIO; + else if ( h / w > MAX_RATIO ) h = w * MAX_RATIO; + + // 2. Cap each edge at MAX_EDGE, preserving aspect ratio. + if ( w > MAX_EDGE ) { + const s = MAX_EDGE / w; w = MAX_EDGE; h *= s; + } + if ( h > MAX_EDGE ) { + const s = MAX_EDGE / h; h = MAX_EDGE; w *= s; + } + + // 3. Scale uniformly into the pixel budget. + const prescaledPixels = w * h; + if ( prescaledPixels < MIN_PIXELS ) { + const s = Math.sqrt(MIN_PIXELS / prescaledPixels); + w *= s; h *= s; + } else if ( prescaledPixels > MAX_PIXELS ) { + const s = Math.sqrt(MAX_PIXELS / prescaledPixels); + w *= s; h *= s; + } + + // 4. Snap to STEP. Bias rounding direction so snap doesn't push pixels + // back out of the budget. + const dir = prescaledPixels < MIN_PIXELS ? 1 + : prescaledPixels > MAX_PIXELS ? -1 + : 0; + const snap = (v: number) => { + const snapped = dir > 0 ? Math.ceil(v / STEP) * STEP + : dir < 0 ? Math.floor(v / STEP) * STEP + : Math.round(v / STEP) * STEP; + return Math.max(MIN_EDGE, Math.min(MAX_EDGE, snapped)); + }; + w = snap(w); h = snap(h); + + // 5. If snap rounding pushed ratio above MAX_RATIO, trim the longer + // edge by one STEP. Pixel budget had headroom from step 3 so this + // won't drop below MIN_PIXELS. + if ( Math.max(w, h) / Math.min(w, h) > MAX_RATIO ) { + if ( w >= h ) w = Math.max(MIN_EDGE, w - STEP); + else h = Math.max(MIN_EDGE, h - STEP); + } + return { w, h }; + } + + // extracted from calculator at https://developers.openai.com/api/docs/guides/image-generation#cost-and-latency + #estimateGptImage2OutputTokens (width: number, height: number, quality?: string): number { + const FACTORS: Record = { low: 16, medium: 48, high: 96 }; + const factor = FACTORS[quality ?? ''] ?? FACTORS.medium; + const longEdge = Math.max(width, height); + const shortEdge = Math.min(width, height); + const shortLatent = Math.round(factor * shortEdge / longEdge); + const latentW = width >= height ? factor : shortLatent; + const latentH = width >= height ? shortLatent : factor; + const baseArea = latentW * latentH; + return Math.ceil(baseArea * (2_000_000 + width * height) / 4_000_000); + } + + #estimateOutputCostFromTokens ( + selectedModel: IImageModel, + ratio: { w: number; h: number }, + quality?: string, + ): number | undefined { + if ( ! selectedModel.id.startsWith('gpt-image-2') ) return undefined; + const rate = this.#getCostRate(selectedModel, 'image_output'); + if ( rate === undefined ) return undefined; + const tokens = this.#estimateGptImage2OutputTokens(ratio.w, ratio.h, quality); + return this.#costForTokens(tokens, rate); } #buildPriceKey (model: string, quality: string, size: string) { diff --git a/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/models.ts b/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/models.ts index 3b86dd05a6..de62f7584f 100644 --- a/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/models.ts +++ b/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/models.ts @@ -1,6 +1,27 @@ import { IImageModel } from '../types'; export const OPEN_AI_IMAGE_GENERATION_MODELS: IImageModel[] = [ + { + puterId: 'openai:openai/gpt-image-2', + id: 'gpt-image-2', + aliases: ['openai/gpt-image-2', 'gpt-image-2-2026-04-21'], + name: 'GPT Image 2', + version: '2.0', + costs_currency: 'usd-cents', + index_cost_key: 'low:1024x1024', + costs: { + // Text tokens (per 1M tokens) + text_input: 500, // $5.00 + text_cached_input: 125, // $1.25 + text_output: 1000, // $10.00 + // Image tokens (per 1M tokens) + image_input: 800, // $8.00 + image_cached_input: 200, // $2.00 + image_output: 3000, // $30.00 + 'low:1024x1024': 0.588, + }, + allowedQualityLevels: ['low', 'medium', 'high', 'auto'], + }, { puterId: 'openai:openai/gpt-image-1.5', id: 'gpt-image-1.5', diff --git a/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/TogetherImageGenerationProvider.ts b/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/TogetherImageGenerationProvider.ts index 8cb9387ae1..9c35e9e44d 100644 --- a/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/TogetherImageGenerationProvider.ts +++ b/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/TogetherImageGenerationProvider.ts @@ -24,14 +24,13 @@ import { Context } from '../../../../../util/context.js'; import { EventService } from '../../../../EventService.js'; import { MeteringService } from '../../../../MeteringService/MeteringService.js'; import { IGenerateParams, IImageModel, IImageProvider } from '../types.js'; -import { TOGETHER_IMAGE_GENERATION_MODELS, GEMINI_3_IMAGE_RESOLUTION_MAP } from './models.js'; +import { TOGETHER_IMAGE_GENERATION_MODELS } from './models.js'; const TOGETHER_DEFAULT_RATIO = { w: 1024, h: 1024 }; type TogetherGenerateParams = IGenerateParams & { steps?: number; seed?: number; negative_prompt?: string; - n?: number; image_url?: string; image_base64?: string; mask_image_url?: string; @@ -101,28 +100,43 @@ export class TogetherImageGenerationProvider implements IImageProvider { throw new Error('actor not found in context'); } - const isGemini3 = selectedModel.id === 'togetherai:google/gemini-3-pro-image'; + const pricingUnit = selectedModel.pricing_unit ?? 'per-MP'; let costInMicroCents: number; let usageAmount: number; - const qualityCostKey = isGemini3 && quality && selectedModel.costs[quality] !== undefined ? quality : undefined; + let usageKey: string; - if ( qualityCostKey ) { - const centsPerImage = selectedModel.costs[qualityCostKey]; + if ( pricingUnit === 'per-image' ) { + const centsPerImage = selectedModel.costs['per-image']; + if ( centsPerImage === undefined ) { + throw new Error(`Model ${selectedModel.id} missing 'per-image' cost`); + } costInMicroCents = centsPerImage * 1_000_000; usageAmount = 1; + usageKey = 'per-image'; + } else if ( pricingUnit === 'per-tier' ) { + const tierKey = quality && selectedModel.costs[quality] !== undefined + ? quality + : Object.keys(selectedModel.costs)[0]; + const centsPerImage = selectedModel.costs[tierKey]; + if ( centsPerImage === undefined ) { + throw new Error(`Model ${selectedModel.id} missing tier cost`); + } + costInMicroCents = centsPerImage * 1_000_000; + usageAmount = 1; + usageKey = tierKey; } else { - const priceKey = '1MP'; - const centsPerMP = selectedModel.costs[priceKey]; + const centsPerMP = selectedModel.costs['1MP']; if ( centsPerMP === undefined ) { - throw new Error(`No pricing configured for model ${selectedModel.id}`); + throw new Error(`Model ${selectedModel.id} missing '1MP' cost`); } const MP = (ratio.h * ratio.w) / 1_000_000; costInMicroCents = centsPerMP * MP * 1_000_000; usageAmount = MP; + usageKey = '1MP'; } - const usageType = `${selectedModel.id}:${quality || '1MP'}`; + const usageType = `${selectedModel.id}:${usageKey}`; const usageAllowed = await this.#meteringService.hasEnoughCredits(actor, costInMicroCents); @@ -130,11 +144,12 @@ export class TogetherImageGenerationProvider implements IImageProvider { throw APIError.create('insufficient_funds'); } - // Resolve abstract aspect ratios to actual pixel dimensions for Gemini 3 Pro + // Resolve abstract aspect ratios (e.g. 1:1, 16:9) to concrete pixel + // dimensions via the model's own resolution_map. let resolvedRatio = ratio; - if ( isGemini3 && quality ) { + if ( pricingUnit === 'per-tier' && quality && selectedModel.resolution_map ) { const ratioKey = `${ratio.w}:${ratio.h}`; - const resolutionEntry = GEMINI_3_IMAGE_RESOLUTION_MAP[ratioKey]?.[quality]; + const resolutionEntry = selectedModel.resolution_map[ratioKey]?.[quality]; if ( resolutionEntry ) { resolvedRatio = resolutionEntry; } @@ -174,7 +189,6 @@ export class TogetherImageGenerationProvider implements IImageProvider { steps, seed, negative_prompt, - n, image_url, image_base64, mask_image_url, @@ -188,6 +202,7 @@ export class TogetherImageGenerationProvider implements IImageProvider { const request: Record = { prompt, model: model ?? DEFAULT_MODEL, + n: 1, }; const requiresConditionImage = this.#modelRequiresConditionImage(request.model as string); @@ -206,9 +221,6 @@ export class TogetherImageGenerationProvider implements IImageProvider { } if ( typeof seed === 'number' && Number.isFinite(seed) ) request.seed = Math.round(seed); if ( typeof negative_prompt === 'string' ) request.negative_prompt = negative_prompt; - if ( typeof n === 'number' && Number.isFinite(n) ) { - request.n = Math.max(1, Math.min(4, Math.round(n))); - } if ( disable_safety_checker ) { request.disable_safety_checker = true; } diff --git a/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/models.ts b/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/models.ts index 5eb43ace39..1b3e2abd18 100644 --- a/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/models.ts +++ b/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/models.ts @@ -19,201 +19,254 @@ import { IImageModel } from '../types'; +type ResolutionMap = Record>; + +export const GEMINI_3_IMAGE_RESOLUTION_MAP: ResolutionMap = { + '1:1': { '1K': { w: 1024, h: 1024 }, '2K': { w: 2048, h: 2048 }, '4K': { w: 4096, h: 4096 } }, + '2:3': { '1K': { w: 848, h: 1264 }, '2K': { w: 1696, h: 2528 }, '4K': { w: 3392, h: 5096 } }, + '3:2': { '1K': { w: 1264, h: 848 }, '2K': { w: 2528, h: 1696 }, '4K': { w: 5096, h: 3392 } }, + '3:4': { '1K': { w: 896, h: 1200 }, '2K': { w: 1792, h: 2400 }, '4K': { w: 3584, h: 4800 } }, + '4:3': { '1K': { w: 1200, h: 896 }, '2K': { w: 2400, h: 1792 }, '4K': { w: 4800, h: 3584 } }, + '4:5': { '1K': { w: 928, h: 1152 }, '2K': { w: 1856, h: 2304 }, '4K': { w: 3712, h: 4608 } }, + '5:4': { '1K': { w: 1152, h: 928 }, '2K': { w: 2304, h: 1856 }, '4K': { w: 4608, h: 3712 } }, + '9:16': { '1K': { w: 768, h: 1376 }, '2K': { w: 1536, h: 2752 }, '4K': { w: 3072, h: 5504 } }, + '16:9': { '1K': { w: 1376, h: 768 }, '2K': { w: 2752, h: 1536 }, '4K': { w: 5504, h: 3072 } }, + '21:9': { '1K': { w: 1584, h: 672 }, '2K': { w: 3168, h: 1344 }, '4K': { w: 6336, h: 2688 } }, +}; + +export const FLASH_IMAGE_3_1_RESOLUTION_MAP: ResolutionMap = { + '1:1': { '0.5K': { w: 512, h: 512 }, '1K': { w: 1024, h: 1024 }, '2K': { w: 2048, h: 2048 }, '4K': { w: 4096, h: 4096 } }, + '1:4': { '0.5K': { w: 256, h: 1024 }, '1K': { w: 512, h: 2048 }, '2K': { w: 1024, h: 4096 }, '4K': { w: 2048, h: 8192 } }, + '1:8': { '0.5K': { w: 192, h: 1536 }, '1K': { w: 384, h: 3072 }, '2K': { w: 768, h: 6144 }, '4K': { w: 1536, h: 12288 } }, + '2:3': { '0.5K': { w: 424, h: 632 }, '1K': { w: 848, h: 1264 }, '2K': { w: 1696, h: 2528 }, '4K': { w: 3392, h: 5056 } }, + '3:2': { '0.5K': { w: 632, h: 424 }, '1K': { w: 1264, h: 848 }, '2K': { w: 2528, h: 1696 }, '4K': { w: 5056, h: 3392 } }, + '3:4': { '0.5K': { w: 448, h: 600 }, '1K': { w: 896, h: 1200 }, '2K': { w: 1792, h: 2400 }, '4K': { w: 3584, h: 4800 } }, + '4:1': { '0.5K': { w: 1024, h: 256 }, '1K': { w: 2048, h: 512 }, '2K': { w: 4096, h: 1024 }, '4K': { w: 8192, h: 2048 } }, + '4:3': { '0.5K': { w: 600, h: 448 }, '1K': { w: 1200, h: 896 }, '2K': { w: 2400, h: 1792 }, '4K': { w: 4800, h: 3584 } }, + '4:5': { '0.5K': { w: 464, h: 576 }, '1K': { w: 928, h: 1152 }, '2K': { w: 1856, h: 2304 }, '4K': { w: 3712, h: 4608 } }, + '5:4': { '0.5K': { w: 576, h: 464 }, '1K': { w: 1152, h: 928 }, '2K': { w: 2304, h: 1856 }, '4K': { w: 4608, h: 3712 } }, + '8:1': { '0.5K': { w: 1536, h: 192 }, '1K': { w: 3072, h: 384 }, '2K': { w: 6144, h: 768 }, '4K': { w: 12288, h: 1536 } }, + '9:16': { '0.5K': { w: 384, h: 688 }, '1K': { w: 768, h: 1376 }, '2K': { w: 1536, h: 2752 }, '4K': { w: 3072, h: 5504 } }, + '16:9': { '0.5K': { w: 688, h: 384 }, '1K': { w: 1376, h: 768 }, '2K': { w: 2752, h: 1536 }, '4K': { w: 5504, h: 3072 } }, + '21:9': { '0.5K': { w: 792, h: 168 }, '1K': { w: 1584, h: 672 }, '2K': { w: 3168, h: 1344 }, '4K': { w: 6336, h: 2688 } }, +}; + export const TOGETHER_IMAGE_GENERATION_MODELS: IImageModel[] = [ { id: 'togetherai:ByteDance-Seed/Seedream-3.0', - aliases: ['ByteDance-Seed/Seedream-3.0'], + aliases: ['ByteDance-Seed/Seedream-3.0', 'Seedream-3.0'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'ByteDance-Seed/Seedream-3.0', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 1.8 }, }, { id: 'togetherai:ByteDance-Seed/Seedream-4.0', - aliases: ['ByteDance-Seed/Seedream-4.0'], + aliases: ['ByteDance-Seed/Seedream-4.0', 'Seedream-4.0'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'ByteDance-Seed/Seedream-4.0', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 3 }, }, { id: 'togetherai:HiDream-ai/HiDream-I1-Dev', - aliases: ['HiDream-ai/HiDream-I1-Dev'], + aliases: ['HiDream-ai/HiDream-I1-Dev', 'HiDream-I1-Dev'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'HiDream-ai/HiDream-I1-Dev', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.45 }, }, { id: 'togetherai:HiDream-ai/HiDream-I1-Fast', - aliases: ['HiDream-ai/HiDream-I1-Fast'], + aliases: ['HiDream-ai/HiDream-I1-Fast', 'HiDream-I1-Fast'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'HiDream-ai/HiDream-I1-Fast', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.32 }, }, { id: 'togetherai:HiDream-ai/HiDream-I1-Full', - aliases: ['HiDream-ai/HiDream-I1-Full'], + aliases: ['HiDream-ai/HiDream-I1-Full', 'HiDream-I1-Full'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'HiDream-ai/HiDream-I1-Full', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.9 }, }, { id: 'togetherai:Lykon/DreamShaper', - aliases: ['Lykon/DreamShaper'], + aliases: ['Lykon/DreamShaper', 'DreamShaper'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'Lykon/DreamShaper', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.06 }, }, { id: 'togetherai:Qwen/Qwen-Image', - aliases: ['Qwen/Qwen-Image'], + aliases: ['Qwen/Qwen-Image', 'Qwen-Image'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'Qwen/Qwen-Image', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.58 }, }, { - id: 'togetherai:RunDiffusion/Juggernaut-pro-flux', - aliases: ['RunDiffusion/Juggernaut-pro-flux'], + id: 'togetherai:Qwen/Qwen-Image-2.0', + aliases: ['Qwen/Qwen-Image-2.0', 'Qwen-Image-2.0'], costs_currency: 'usd-cents', - index_cost_key: '1MP', - name: 'RunDiffusion/Juggernaut-pro-flux', + index_cost_key: 'per-image', + name: 'Qwen/Qwen-Image-2.0', allowedQualityLevels: [''], - costs: { '1MP': 0.49 }, + pricing_unit: 'per-image', + costs: { 'per-image': 4 }, }, { - id: 'togetherai:Rundiffusion/Juggernaut-Lightning-Flux', - aliases: ['Rundiffusion/Juggernaut-Lightning-Flux'], + id: 'togetherai:Qwen/Qwen-Image-2.0-Pro', + aliases: ['Qwen/Qwen-Image-2.0-Pro', 'Qwen-Image-2.0-Pro'], costs_currency: 'usd-cents', - index_cost_key: '1MP', - name: 'Rundiffusion/Juggernaut-Lightning-Flux', + index_cost_key: 'per-image', + name: 'Qwen/Qwen-Image-2.0-Pro', allowedQualityLevels: [''], - costs: { '1MP': 0.17 }, + pricing_unit: 'per-image', + costs: { 'per-image': 8 }, }, { - id: 'togetherai:black-forest-labs/FLUX.1-dev', - aliases: ['black-forest-labs/FLUX.1-dev'], + id: 'togetherai:RunDiffusion/Juggernaut-pro-flux', + aliases: ['RunDiffusion/Juggernaut-pro-flux', 'Juggernaut-pro-flux'], costs_currency: 'usd-cents', index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.1-dev', + name: 'RunDiffusion/Juggernaut-pro-flux', allowedQualityLevels: [''], - costs: { '1MP': 2.5 }, + pricing_unit: 'per-MP', + costs: { '1MP': 0.49 }, }, { - id: 'togetherai:black-forest-labs/FLUX.1-dev-lora', - aliases: ['black-forest-labs/FLUX.1-dev-lora'], + id: 'togetherai:Rundiffusion/Juggernaut-Lightning-Flux', + aliases: ['Rundiffusion/Juggernaut-Lightning-Flux', 'Juggernaut-Lightning-Flux'], costs_currency: 'usd-cents', index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.1-dev-lora', + name: 'Rundiffusion/Juggernaut-Lightning-Flux', allowedQualityLevels: [''], - costs: { '1MP': 2.5 }, + pricing_unit: 'per-MP', + costs: { '1MP': 0.17 }, }, { - id: 'togetherai:black-forest-labs/FLUX.1-kontext-dev', - aliases: ['black-forest-labs/FLUX.1-kontext-dev'], + id: 'togetherai:Wan-AI/Wan2.6-image', + aliases: ['Wan-AI/Wan2.6-image', 'Wan2.6-image'], costs_currency: 'usd-cents', - index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.1-kontext-dev', + index_cost_key: 'per-image', + name: 'Wan-AI/Wan2.6-image', allowedQualityLevels: [''], - costs: { '1MP': 2.5 }, + pricing_unit: 'per-image', + costs: { 'per-image': 3 }, }, { id: 'togetherai:black-forest-labs/FLUX.1-kontext-max', - aliases: ['black-forest-labs/FLUX.1-kontext-max'], + aliases: ['black-forest-labs/FLUX.1-kontext-max', 'FLUX.1-kontext-max'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'black-forest-labs/FLUX.1-kontext-max', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 8 }, }, { id: 'togetherai:black-forest-labs/FLUX.1-kontext-pro', - aliases: ['black-forest-labs/FLUX.1-kontext-pro'], + aliases: ['black-forest-labs/FLUX.1-kontext-pro', 'FLUX.1-kontext-pro'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'black-forest-labs/FLUX.1-kontext-pro', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 4 }, }, { id: 'togetherai:black-forest-labs/FLUX.1-krea-dev', - aliases: ['black-forest-labs/FLUX.1-krea-dev'], + aliases: ['black-forest-labs/FLUX.1-krea-dev', 'FLUX.1-krea-dev'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'black-forest-labs/FLUX.1-krea-dev', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 2.5 }, }, - { - id: 'togetherai:black-forest-labs/FLUX.1-pro', - aliases: ['black-forest-labs/FLUX.1-pro'], - costs_currency: 'usd-cents', - index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.1-pro', - allowedQualityLevels: [''], - costs: { '1MP': 5 }, - }, { id: 'togetherai:black-forest-labs/FLUX.1-schnell', - aliases: ['black-forest-labs/FLUX.1-schnell'], + aliases: ['black-forest-labs/FLUX.1-schnell', 'FLUX.1-schnell'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'black-forest-labs/FLUX.1-schnell', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.27 }, }, { id: 'togetherai:black-forest-labs/FLUX.1.1-pro', - aliases: ['black-forest-labs/FLUX.1.1-pro'], + aliases: ['black-forest-labs/FLUX.1.1-pro', 'FLUX.1.1-pro'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'black-forest-labs/FLUX.1.1-pro', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 4 }, }, { - id: 'togetherai:black-forest-labs/FLUX.2-pro', - aliases: ['black-forest-labs/FLUX.2-pro'], + id: 'togetherai:black-forest-labs/FLUX.2-dev', + aliases: ['black-forest-labs/FLUX.2-dev', 'FLUX.2-dev'], costs_currency: 'usd-cents', - index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.2-pro', + index_cost_key: 'per-image', + name: 'black-forest-labs/FLUX.2-dev', allowedQualityLevels: [''], - costs: { '1MP': 3 }, + pricing_unit: 'per-image', + costs: { 'per-image': 1.54 }, }, { id: 'togetherai:black-forest-labs/FLUX.2-flex', - aliases: ['black-forest-labs/FLUX.2-flex'], + aliases: ['black-forest-labs/FLUX.2-flex', 'FLUX.2-flex'], costs_currency: 'usd-cents', - index_cost_key: '1MP', + index_cost_key: 'per-image', name: 'black-forest-labs/FLUX.2-flex', allowedQualityLevels: [''], - costs: { '1MP': 3 }, + pricing_unit: 'per-image', + costs: { 'per-image': 3 }, }, { - id: 'togetherai:black-forest-labs/FLUX.2-dev', - aliases: ['black-forest-labs/FLUX.2-dev'], + id: 'togetherai:black-forest-labs/FLUX.2-max', + aliases: ['black-forest-labs/FLUX.2-max', 'FLUX.2-max'], costs_currency: 'usd-cents', index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.2-dev', + name: 'black-forest-labs/FLUX.2-max', allowedQualityLevels: [''], - costs: { '1MP': 3 }, + pricing_unit: 'per-MP', + costs: { '1MP': 7 }, + }, + { + id: 'togetherai:black-forest-labs/FLUX.2-pro', + aliases: ['black-forest-labs/FLUX.2-pro', 'FLUX.2-pro'], + costs_currency: 'usd-cents', + index_cost_key: 'per-image', + name: 'black-forest-labs/FLUX.2-pro', + allowedQualityLevels: [''], + pricing_unit: 'per-image', + costs: { 'per-image': 3 }, }, { id: 'togetherai:google/flash-image-2.5', - aliases: ['google/flash-image-2.5'], + aliases: ['google/flash-image-2.5', 'flash-image-2.5'], costs_currency: 'usd-cents', - index_cost_key: '1MP', + index_cost_key: 'per-image', name: 'google/flash-image-2.5', allowedQualityLevels: ['1K'], allowedRatios: [ @@ -228,9 +281,36 @@ export const TOGETHER_IMAGE_GENERATION_MODELS: IImageModel[] = [ { w: 1344, h: 768 }, { w: 1536, h: 672 }, { w: 672, h: 1536 }, - ], - costs: { '1MP': 3.91 }, + pricing_unit: 'per-image', + costs: { 'per-image': 3.9 }, + }, + { + id: 'togetherai:google/flash-image-3.1', + aliases: ['google/flash-image-3.1', 'flash-image-3.1', 'nano-banana-2'], + name: 'google/flash-image-3.1', + costs_currency: 'usd-cents', + index_cost_key: '1K', + allowedQualityLevels: ['0.5K', '1K', '2K', '4K'], + allowedRatios: [ + { w: 1, h: 1 }, + { w: 2, h: 3 }, + { w: 3, h: 2 }, + { w: 3, h: 4 }, + { w: 4, h: 3 }, + { w: 4, h: 5 }, + { w: 5, h: 4 }, + { w: 9, h: 16 }, + { w: 16, h: 9 }, + { w: 21, h: 9 }, + { w: 1, h: 4 }, + { w: 4, h: 1 }, + { w: 1, h: 8 }, + { w: 8, h: 1 }, + ], + pricing_unit: 'per-tier', + costs: { '0.5K': 4.5, '1K': 6.7, '2K': 10.1, '4K': 15.1 }, + resolution_map: FLASH_IMAGE_3_1_RESOLUTION_MAP, }, { id: 'togetherai:google/gemini-3-pro-image', @@ -251,82 +331,78 @@ export const TOGETHER_IMAGE_GENERATION_MODELS: IImageModel[] = [ { w: 16, h: 9 }, { w: 21, h: 9 }, ], + pricing_unit: 'per-tier', costs: { '1K': 13.4, '2K': 13.4, '4K': 24 }, + resolution_map: GEMINI_3_IMAGE_RESOLUTION_MAP, }, { id: 'togetherai:google/imagen-4.0-fast', - aliases: ['google/imagen-4.0-fast'], + aliases: ['google/imagen-4.0-fast', 'imagen-4.0-fast'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'google/imagen-4.0-fast', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 2 }, }, { id: 'togetherai:google/imagen-4.0-preview', - aliases: ['google/imagen-4.0-preview'], + aliases: ['google/imagen-4.0-preview', 'imagen-4.0-preview'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'google/imagen-4.0-preview', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 4 }, }, { id: 'togetherai:google/imagen-4.0-ultra', - aliases: ['google/imagen-4.0-ultra'], + aliases: ['google/imagen-4.0-ultra', 'imagen-4.0-ultra'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'google/imagen-4.0-ultra', allowedQualityLevels: [''], - costs: { '1MP': 6.02 }, + pricing_unit: 'per-MP', + costs: { '1MP': 6 }, }, { id: 'togetherai:ideogram/ideogram-3.0', - aliases: ['ideogram/ideogram-3.0'], + aliases: ['ideogram/ideogram-3.0', 'ideogram-3.0'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'ideogram/ideogram-3.0', allowedQualityLevels: [''], - costs: { '1MP': 6.02 }, + pricing_unit: 'per-MP', + costs: { '1MP': 6 }, + }, + { + id: 'togetherai:openai/gpt-image-1.5', + aliases: ['openai/gpt-image-1.5', 'gpt-image-1.5'], + costs_currency: 'usd-cents', + index_cost_key: 'per-image', + name: 'openai/gpt-image-1.5', + allowedQualityLevels: [''], + pricing_unit: 'per-image', + costs: { 'per-image': 3.4 }, }, { id: 'togetherai:stabilityai/stable-diffusion-3-medium', - aliases: ['stabilityai/stable-diffusion-3-medium'], + aliases: ['stabilityai/stable-diffusion-3-medium', 'stable-diffusion-3-medium'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'stabilityai/stable-diffusion-3-medium', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.19 }, }, { id: 'togetherai:stabilityai/stable-diffusion-xl-base-1.0', - aliases: ['stabilityai/stable-diffusion-xl-base-1.0'], + aliases: ['stabilityai/stable-diffusion-xl-base-1.0', 'stable-diffusion-xl-base-1.0'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'stabilityai/stable-diffusion-xl-base-1.0', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.19 }, }, - { - id: 'togetherai:black-forest-labs/FLUX.2-max', - aliases: ['black-forest-labs/FLUX.2-max', 'FLUX.2-max'], - costs_currency: 'usd-cents', - index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.2-max', - allowedQualityLevels: [''], - costs: { '1MP': 7 }, - }, ]; - -export const GEMINI_3_IMAGE_RESOLUTION_MAP: Record> = { - '1:1': { '1K': { w: 1024, h: 1024 }, '2K': { w: 2048, h: 2048 }, '4K': { w: 4096, h: 4096 } }, - '2:3': { '1K': { w: 848, h: 1264 }, '2K': { w: 1696, h: 2528 }, '4K': { w: 3392, h: 5096 } }, - '3:2': { '1K': { w: 1264, h: 848 }, '2K': { w: 2528, h: 1696 }, '4K': { w: 5096, h: 3392 } }, - '3:4': { '1K': { w: 896, h: 1200 }, '2K': { w: 1792, h: 2400 }, '4K': { w: 3584, h: 4800 } }, - '4:3': { '1K': { w: 1200, h: 896 }, '2K': { w: 2400, h: 1792 }, '4K': { w: 4800, h: 3584 } }, - '4:5': { '1K': { w: 928, h: 1152 }, '2K': { w: 1856, h: 2304 }, '4K': { w: 3712, h: 4608 } }, - '5:4': { '1K': { w: 1152, h: 928 }, '2K': { w: 2304, h: 1856 }, '4K': { w: 4608, h: 3712 } }, - '9:16': { '1K': { w: 768, h: 1376 }, '2K': { w: 1536, h: 2752 }, '4K': { w: 3072, h: 5504 } }, - '16:9': { '1K': { w: 1376, h: 768 }, '2K': { w: 2752, h: 1536 }, '4K': { w: 5504, h: 3072 } }, - '21:9': { '1K': { w: 1584, h: 672 }, '2K': { w: 3168, h: 1344 }, '4K': { w: 6336, h: 2688 } }, -}; diff --git a/src/backend/src/services/ai/image/providers/types.ts b/src/backend/src/services/ai/image/providers/types.ts index eac489101c..ffe3ca67d9 100644 --- a/src/backend/src/services/ai/image/providers/types.ts +++ b/src/backend/src/services/ai/image/providers/types.ts @@ -1,3 +1,5 @@ +export type ImagePricingUnit = 'per-image' | 'per-MP' | 'per-tier'; + export interface IImageModel { id: string; name: string; @@ -10,6 +12,20 @@ export interface IImageModel { index_cost_key?: string; index_input_cost_key?: string; costs: Record; + /** + * How `costs` should be interpreted: + * - 'per-image': flat cost per generated image (key: 'per-image') + * - 'per-MP': cost scales with width*height/1e6 (key: '1MP') + * - 'per-tier': cost is picked by `quality` (keys: e.g. '1K','2K','4K') + * Defaults to 'per-MP' when unset (legacy behavior). + */ + pricing_unit?: ImagePricingUnit; + /** + * For per-tier models: resolves an abstract aspect ratio (keyed `{w}:{h}`) + * + quality tier (e.g. '1K'/'2K'/'4K') to concrete pixel dimensions sent + * to the provider. Only consulted when `pricing_unit === 'per-tier'`. + */ + resolution_map?: Record>; allowedQualityLevels?: string[]; allowedRatios?: { w: number, h: number }[]; } diff --git a/src/backend/src/services/ai/video/providers/TogetherVideoGenerationProvider/models.ts b/src/backend/src/services/ai/video/providers/TogetherVideoGenerationProvider/models.ts index 3563621c70..913bd1791d 100644 --- a/src/backend/src/services/ai/video/providers/TogetherVideoGenerationProvider/models.ts +++ b/src/backend/src/services/ai/video/providers/TogetherVideoGenerationProvider/models.ts @@ -54,7 +54,7 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [ name: 'MiniMax Hailuo 02', model: 'minimax/hailuo-02', costs_currency: 'usd-cents', - costs: { 'per-video': 56 }, + costs: { 'per-video': 49 }, output_cost_key: 'per-video', durationSeconds: [10], dimensions: ['1366x768', '1920x1080'], @@ -362,6 +362,22 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [ promptLength: null, promptSupported: null, }, + { + id: 'togetherai:Wan-AI/wan2.7-t2v', + puterId: 'togetherai:wan-ai/wan2.7-t2v', + organization: 'Wan-AI', + name: 'Wan 2.7 T2V', + model: 'Wan-AI/wan2.7-t2v', + costs_currency: 'usd-cents', + costs: { 'per-video': 10 }, + output_cost_key: 'per-video', + durationSeconds: null, + dimensions: null, + fps: null, + keyframes: null, + promptLength: null, + promptSupported: null, + }, { id: 'togetherai:vidu/vidu-2.0', puterId: 'togetherai:vidu/vidu-2.0', @@ -427,7 +443,7 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [ name: 'Sora 2 Pro', model: 'openai/sora-2-pro', costs_currency: 'usd-cents', - costs: { 'per-video': 400 }, + costs: { 'per-video': 300 }, output_cost_key: 'per-video', durationSeconds: [8], dimensions: ['1280x720', '720x1280'], diff --git a/src/docs/src/AI/txt2img.md b/src/docs/src/AI/txt2img.md index 8441d85a9a..adb7b4e107 100755 --- a/src/docs/src/AI/txt2img.md +++ b/src/docs/src/AI/txt2img.md @@ -37,13 +37,13 @@ Additional settings for the generation request. Available options depend on the #### OpenAI Options -Available when `provider: 'openai-image-generation'` or inferred from model (`gpt-image-1.5`, `gpt-image-1`, `gpt-image-1-mini`, `dall-e-3`): +Available when `provider: 'openai-image-generation'` or inferred from model (`gpt-image-2`, `gpt-image-1.5`, `gpt-image-1`, `gpt-image-1-mini`, `dall-e-3`): | Option | Type | Description | |--------|------|-------------| -| `model` | `String` | Image model to use. Available: `'gpt-image-1.5'`, `'gpt-image-1'`, `'gpt-image-1-mini'`, `'dall-e-3'` | -| `quality` | `String` | Image quality. For GPT models: `'high'`, `'medium'`, `'low'` (default: `'low'`). For DALL-E 3: `'hd'`, `'standard'` (default: `'standard'`) | -| `ratio` | `Object` | Aspect ratio with `w` and `h` properties | +| `model` | `String` | Image model to use. Available: `'gpt-image-2'`, `'gpt-image-1.5'`, `'gpt-image-1'`, `'gpt-image-1-mini'`, `'dall-e-3'` | +| `quality` | `String` | Image quality. For GPT models: `'high'`, `'medium'`, `'low'` (default: `'low'`); `gpt-image-2` also accepts `'auto'`. For DALL-E 3: `'hd'`, `'standard'` (default: `'standard'`) | +| `ratio` | `Object` | Aspect ratio with `w` and `h` properties. `gpt-image-2` accepts arbitrary sizes; other GPT models and DALL-E are restricted to fixed sizes | For more details, see the [OpenAI API reference](https://platform.openai.com/docs/api-reference/images/create). diff --git a/src/docs/src/Drivers.md b/src/docs/src/Drivers.md deleted file mode 100644 index c23b63e8d0..0000000000 --- a/src/docs/src/Drivers.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Drivers -description: Interact and access various system resources with Puter drivers. ---- - -The Drivers API allows you to interact with puter drivers. It provides a way to access and control various system resources and peripherals. - -## Available Functions - -- **[`puter.drivers.call()`](/Drivers/call/)** - Call driver functions \ No newline at end of file diff --git a/src/docs/src/Drivers/call.md b/src/docs/src/Drivers/call.md deleted file mode 100755 index e95b204ff9..0000000000 --- a/src/docs/src/Drivers/call.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: puter.drivers.call() -description: Call drivers that are not directly exposed by Puter.js high level API. -platforms: [websites, apps, nodejs, workers] ---- - - -A low-level function that allows you to call any driver on any interface. This function is useful when you want to call a driver that is not directly exposed by Puter.js's high-level API or for when you need more control over the driver call. - -## Syntax -```js -puter.drivers.call(interface, driver, method) -puter.drivers.call(interface, driver, method, args = {}) -``` - -## Parameters -#### `interface` (String) (Required) -The name of the interface you want to call. - -#### `driver` (String) (Required) -The name of the driver you want to call. - -#### `method` (String) (Required) -The name of the method you want to call on the driver. - -#### `args` (Array) (Optional) -An object containing the arguments you want to pass to the driver. - -## Return value - -A `Promise` that will resolve to the result of the driver call. The result can be of any type, depending on the driver you are calling. - -In case of an error, the `Promise` will reject with an error message. diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js index 6f98d79b14..0d63798076 100755 --- a/src/docs/src/sidebar.js +++ b/src/docs/src/sidebar.js @@ -1074,22 +1074,6 @@ let sidebar = [ }, ], }, - { - title: 'Drivers', - title_tag: 'Drivers', - source: '/Drivers.md', - path: '/Drivers', - children: [ - { - title: 'call', - page_title: 'puter.drivers.call()', - title_tag: 'puter.drivers.call()', - icon: '/assets/img/function.svg', - source: '/Drivers/call.md', - path: '/Drivers/call', - }, - ], - }, { title: 'Utilities', title_tag: 'Utilities', diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index ec4c68fc02..8570a4390b 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -250,6 +250,7 @@ const TabApps = { e.stopPropagation(); UIContextMenu({ + parent_element: $(this), position: { top: e.clientY, left: e.clientX }, items, }); @@ -270,7 +271,7 @@ const TabApps = { }, ), fetch( - `${window.api_origin}/get-launch-apps?icon_size=64`, + `${window.api_origin}/get-launch-apps?icon_size=128`, { headers: { 'Authorization': `Bearer ${window.auth_token}` }, method: 'GET', @@ -289,7 +290,7 @@ const TabApps = { name: app.name, title: app.title, uid: app.uuid || app.uid || null, - iconUrl: app.icon || null, + iconUrl: app.iconUrl || app.icon || null, })); // Build seen set from launch apps diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index 7b912e9b7d..2f36ea9634 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -99,8 +99,7 @@ async function UIDashboard (options) { continue; } const isActive = i === 0 ? ' active' : ''; - const isBeta = tab.label === 'Apps'; - h += `
`; + h += `
`; h += tab.icon; h += tab.label; h += '
'; diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index 13c17dba0c..1bdfad4968 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -18,7 +18,7 @@ */ import path from '../lib/path.js'; -import UIWindowClaimReferral from './UIWindowClaimReferral.js'; + import UIContextMenu from './UIContextMenu.js'; import UIItem from './UIItem.js'; import UIAlert from './UIAlert.js'; @@ -29,7 +29,7 @@ import UIWindowMyWebsites from './UIWindowMyWebsites.js'; import UIWindowFeedback from './UIWindowFeedback.js'; import UIWindowLogin from './UIWindowLogin.js'; import UIWindowQR from './UIWindowQR.js'; -import UIWindowRefer from './UIWindowRefer.js'; + import UIWindowProgress from './UIWindowProgress.js'; import UITaskbar from './UITaskbar.js'; import new_context_menu_item from '../helpers/new_context_menu_item.js'; @@ -1244,11 +1244,6 @@ async function UIDesktop (options) { // 'Show Desktop' ht += ``; - // refer - if ( window.user.referral_code ) { - ht += `
`; - } - // github ht += ``; @@ -1458,21 +1453,6 @@ async function UIDesktop (options) { display_ct(); setInterval(display_ct, 1000); - // show referral notice window - if ( window.show_referral_notice && !window.user.email_confirmed ) { - puter.kv.get('shown_referral_notice').then(async (val) => { - if ( !val || val === 'false' || val === false ) { - setTimeout(() => { - UIWindowClaimReferral(); - }, 1000); - puter.kv.set({ - key: 'shown_referral_notice', - value: true, - }); - } - }); - } - window.hide_toolbar = (animate = true) => { // Always show toolbar on mobile and tablet devices if ( isMobile.phone || isMobile.tablet ) { @@ -2388,10 +2368,6 @@ $(document).on('click', '.user-options-create-account-btn', async function (e) { }); }); -$(document).on('click', '.refer-btn', async function (e) { - UIWindowRefer(); -}); - $(document).on('click', '.start-app', async function (e) { launch_app({ name: $(this).attr('data-app-name'), diff --git a/src/gui/src/UI/UIItem.js b/src/gui/src/UI/UIItem.js index c13797eb02..87af3161e6 100644 --- a/src/gui/src/UI/UIItem.js +++ b/src/gui/src/UI/UIItem.js @@ -601,12 +601,14 @@ async function UIItem (options) { // If alt key is down, create shortcut items else if ( event.altKey && window.feature_flags.create_shortcut ) { items_to_move.forEach((item_to_move) => { - window.create_shortcut(path.basename($(item_to_move).attr('data-path')), - $(item_to_move).attr('data-is_dir') === '1', - options.is_dir ? $(el_item).attr('data-path') : path.dirname($(el_item).attr('data-path')), - null, - $(item_to_move).attr('data-shortcut_to') === '' ? $(item_to_move).attr('data-uid') : $(item_to_move).attr('data-shortcut_to'), - $(item_to_move).attr('data-shortcut_to_path') === '' ? $(item_to_move).attr('data-path') : $(item_to_move).attr('data-shortcut_to_path')); + window.create_shortcut( + path.basename($(item_to_move).attr('data-path')), + $(item_to_move).attr('data-is_dir') === '1', + options.is_dir ? $(el_item).attr('data-path') : path.dirname($(el_item).attr('data-path')), + null, + $(item_to_move).attr('data-shortcut_to') === '' ? $(item_to_move).attr('data-uid') : $(item_to_move).attr('data-shortcut_to'), + $(item_to_move).attr('data-shortcut_to_path') === '' ? $(item_to_move).attr('data-path') : $(item_to_move).attr('data-shortcut_to_path'), + ); }); } // Otherwise, move items @@ -1113,10 +1115,12 @@ async function UIItem (options) { buttons: [ { label: i18n('delete'), + value: 'Delete', type: 'primary', }, { label: i18n('cancel'), + value: 'Cancel', }, ], }); @@ -1156,12 +1160,14 @@ async function UIItem (options) { } if ( is_shared_with_me ) base_dir = window.desktop_path; // create shortcut - window.create_shortcut(path.basename($(this).attr('data-path')), - $(this).attr('data-is_dir') === '1', - base_dir, - $(this).closest('.item-container'), - $(this).attr('data-shortcut_to') === '' ? $(this).attr('data-uid') : $(this).attr('data-shortcut_to'), - $(this).attr('data-shortcut_to_path') === '' ? $(this).attr('data-path') : $(this).attr('data-shortcut_to_path')); + window.create_shortcut( + path.basename($(this).attr('data-path')), + $(this).attr('data-is_dir') === '1', + base_dir, + $(this).closest('.item-container'), + $(this).attr('data-shortcut_to') === '' ? $(this).attr('data-uid') : $(this).attr('data-shortcut_to'), + $(this).attr('data-shortcut_to_path') === '' ? $(this).attr('data-path') : $(this).attr('data-shortcut_to_path'), + ); }); }, }); @@ -1628,12 +1634,14 @@ async function UIItem (options) { if ( is_shared_with_me ) base_dir = window.desktop_path; - window.create_shortcut(path.basename($(el_item).attr('data-path')), - options.is_dir, - base_dir, - options.appendTo, - options.shortcut_to === '' ? options.uid : options.shortcut_to, - options.shortcut_to_path === '' ? options.path : options.shortcut_to_path); + window.create_shortcut( + path.basename($(el_item).attr('data-path')), + options.is_dir, + base_dir, + options.appendTo, + options.shortcut_to === '' ? options.uid : options.shortcut_to, + options.shortcut_to_path === '' ? options.path : options.shortcut_to_path, + ); }, }); } @@ -1660,10 +1668,12 @@ async function UIItem (options) { buttons: [ { label: i18n('delete'), + value: 'Delete', type: 'primary', }, { label: i18n('cancel'), + value: 'Cancel', }, ], }); @@ -1715,13 +1725,15 @@ async function UIItem (options) { let top = $(el_item).position().top + $(el_item).height(); top = top > (window.innerHeight - (window_height + window.taskbar_height + window.toolbar_height)) ? (window.innerHeight - (window_height + window.taskbar_height + window.toolbar_height)) : top; - UIWindowItemProperties($(el_item).attr('data-name'), - $(el_item).attr('data-path'), - $(el_item).attr('data-uid'), - left, - top, - window_width, - window_height); + UIWindowItemProperties( + $(el_item).attr('data-name'), + $(el_item).attr('data-path'), + $(el_item).attr('data-uid'), + left, + top, + window_width, + window_height, + ); }, }); } diff --git a/src/gui/src/UI/UIWindowClaimReferral.js b/src/gui/src/UI/UIWindowClaimReferral.js deleted file mode 100644 index 769bde9a15..0000000000 --- a/src/gui/src/UI/UIWindowClaimReferral.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright (C) 2024-present Puter Technologies Inc. - * - * This file is part of Puter. - * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import UIWindow from './UIWindow.js'; -import UIWindowSaveAccount from './UIWindowSaveAccount.js'; - -async function UIWindowClaimReferral (options) { - let h = ''; - - h += '
'; - h += '
×
'; - h += ``; - h += `

${i18n('you_have_been_referred_to_puter_by_a_friend')}

`; - h += `

${i18n('confirm_account_for_free_referral_storage_c2a')}

`; - h += ``; - h += '
'; - - const el_window = await UIWindow({ - title: 'Refer a friend!', - icon: null, - uid: null, - is_dir: false, - body_content: h, - has_head: false, - selectable_body: false, - draggable_body: true, - allow_context_menu: false, - is_draggable: true, - is_resizable: false, - is_droppable: false, - init_center: true, - allow_native_ctxmenu: true, - allow_user_select: true, - width: 400, - dominant: true, - window_css: { - height: 'initial', - }, - body_css: { - width: 'initial', - 'max-height': 'calc(100vh - 200px)', - 'background-color': 'rgb(241 246 251)', - 'backdrop-filter': 'blur(3px)', - 'padding': '10px 20px 20px 20px', - 'height': 'initial', - }, - }); - - $(el_window).find('.create-account-ref-btn').on('click', function (e) { - UIWindowSaveAccount(); - $(el_window).close(); - }); -} - -export default UIWindowClaimReferral; \ No newline at end of file diff --git a/src/gui/src/UI/UIWindowPublishWebsite.js b/src/gui/src/UI/UIWindowPublishWebsite.js index 28a7ac0bee..774b30e150 100644 --- a/src/gui/src/UI/UIWindowPublishWebsite.js +++ b/src/gui/src/UI/UIWindowPublishWebsite.js @@ -35,46 +35,7 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d // error msg h += '
'; - // Publishing options - h += '
'; - h += ``; - - // Check if user has active subscription for custom domains - const hasActiveSubscription = window.user && window.user.subscription && window.user.subscription.active; - - // Puter subdomain option - h += '
'; - h += ''; - h += '
'; - - // Custom domain option - h += '
'; - const customDomainDisabled = !hasActiveSubscription; - const customDomainStyle = customDomainDisabled ? - 'display: flex; align-items: center; cursor: not-allowed; padding: 10px; border: 2px solid #e1e8ed; border-radius: 8px; opacity: 0.5; background-color: #f8f9fa;' : - 'display: flex; align-items: center; cursor: pointer; padding: 10px; border: 2px solid #e1e8ed; border-radius: 8px;'; - - h += `'; - h += '
'; - h += '
'; - - // Puter subdomain input (shown by default) + // Subdomain input h += '
'; h += ``; h += '
'; @@ -84,12 +45,6 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d h += '
'; h += '
'; - // Custom domain input (hidden by default) - h += ''; - // uid h += ``; // Publish @@ -117,54 +72,6 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d onAppend: function (this_window) { $(this_window).find('.publish-website-subdomain').val(window.generate_identifier()); $(this_window).find('.publish-website-subdomain').get(0).focus({ preventScroll: true }); - - // Handle radio button changes - $(this_window).find('input[name="publishing-type"]:not(:disabled)').on('change', function () { - const selectedValue = $(this).val(); - const puterSection = $(this_window).find('.puter-subdomain-section'); - const customSection = $(this_window).find('.custom-domain-section'); - const puterLabel = $(this_window).find('input[value="puter"]').closest('.option-label'); - const customLabel = $(this_window).find('input[value="custom"]').closest('.option-label'); - - // Update visual selection (only if not disabled) - puterLabel.css('border-color', selectedValue === 'puter' ? '#007bff' : '#e1e8ed'); - if ( ! $(this_window).find('input[value="custom"]').is(':disabled') ) { - customLabel.css('border-color', selectedValue === 'custom' ? '#007bff' : '#e1e8ed'); - } - - if ( selectedValue === 'puter' ) { - puterSection.show(); - customSection.hide(); - $(this_window).find('.publish-website-subdomain').focus(); - } else if ( selectedValue === 'custom' ) { - puterSection.hide(); - customSection.show(); - $(this_window).find('.publish-website-custom-domain').focus(); - } - }); - - // Add click handler for disabled custom domain option to show upgrade message - $(this_window).find('.custom-domain-label').on('click', function (e) { - const radioButton = $(this).find('input[type="radio"]'); - if ( radioButton.is(':disabled') ) { - e.preventDefault(); - // Could show upgrade modal here in the future - if ( puter.defaultGUIOrigin === 'https://puter.com' ) { - $(this_window).find('.publish-website-error-msg').html( - 'Custom domains require a Premium subscription. Upgrade now to use your own domain name.'); - } else { - $(this_window).find('.publish-website-error-msg').html( - 'Custom domains are not available on this instance of Puter. Yet!'); - } - $(this_window).find('.publish-website-error-msg').fadeIn(); - setTimeout(() => { - $(this_window).find('.publish-website-error-msg').fadeOut(); - }, 5000); - } - }); - - // Style the selected option initially - $(this_window).find('input[value="puter"]').closest('.option-label').css('border-color', '#007bff'); }, window_class: 'window-publishWebsite', window_css: { @@ -178,116 +85,48 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d }, }); - // Function to load Entri SDK - async function loadEntriSDK () { - if ( ! window.entri ) { - await new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.type = 'text/javascript'; - script.src = 'https://cdn.goentri.com/entri.js'; - script.addEventListener('load', () => { - resolve(window.entri); - }); - script.addEventListener('error', () => { - reject(new Error('Failed to load the Entri SDK.')); - }); - document.body.appendChild(script); - }); - } - } - $(el_window).find('.publish-btn').on('click', async function (e) { e.preventDefault(); - // Get the selected publishing type - const publishingType = $(el_window).find('input[name="publishing-type"]:checked').val(); - // disable 'Publish' button $(el_window).find('.publish-btn').prop('disabled', true); try { - if ( publishingType === 'puter' ) { - // Handle Puter subdomain publishing - let subdomain = $(el_window).find('.publish-website-subdomain').val(); - - if ( ! subdomain.trim() ) { - throw new Error('Please enter a subdomain name'); - } - - const res = await puter.hosting.create(subdomain, target_dir_path); - let url = `https://${ subdomain }.${ window.hosting_domain }/`; - - // Show success - $(el_window).find('.window-publishWebsite-form').hide(100, function () { - $(el_window).find('.publishWebsite-published-link').attr('href', url); - $(el_window).find('.publishWebsite-published-link').text(url); - $(el_window).find('.window-publishWebsite-success').show(100); - $(`.item[data-uid="${target_dir_uid}"] .item-has-website-badge`).show(); - }); - - // find all items whose path starts with target_dir_path - $(`.item[data-path^="${target_dir_path}/"]`).each(function () { - // show the link badge - $(this).find('.item-has-website-url-badge').show(); - // update item's website_url attribute - $(this).attr('data-website_url', url + $(this).attr('data-path').substring(target_dir_path.length)); - }); + let subdomain = $(el_window).find('.publish-website-subdomain').val(); - window.update_sites_cache(); - } else if ( publishingType === 'custom' ) { - // Handle custom domain publishing with Entri - let customDomain = $(el_window).find('.publish-website-custom-domain').val(); - - if ( ! customDomain.trim() ) { - throw new Error('Please enter your custom domain'); - } - - // Step 1: First create a Puter subdomain to host the content - let subdomain = $(el_window).find('.publish-website-subdomain').val(); - if ( ! subdomain.trim() ) { - // Generate a subdomain if not provided - subdomain = window.generate_identifier(); - } - - const hostingRes = await puter.hosting.create(subdomain, target_dir_path); - const puterSiteUrl = `https://${ subdomain }.${ window.hosting_domain}`; - - // Step 2: Load Entri SDK - await loadEntriSDK(); - - // Step 3: Get Entri config from the backend using the Puter subdomain as userHostedSite - const entriConfig = await puter.drivers.call('entri', 'entri-service', 'getConfig', { - domain: customDomain, - userHostedSite: `${subdomain }.${ window.hosting_domain}`, - }); + if ( ! subdomain.trim() ) { + throw new Error('Please enter a subdomain name'); + } - // Step 4: Show Entri interface for custom domain setup - await entri.showEntri(entriConfig.result); + const res = await puter.hosting.create(subdomain, target_dir_path); + let url = `https://${ subdomain }.${ window.hosting_domain }/`; - // Step 5: Show success message with custom domain - let customUrl = `https://${ customDomain }/`; + // Show success + $(el_window).find('.window-publishWebsite-form').hide(100, function () { + $(el_window).find('.publishWebsite-published-link').attr('href', url); + $(el_window).find('.publishWebsite-published-link').text(url); + $(el_window).find('.window-publishWebsite-success').show(100); + $(`.item[data-uid="${target_dir_uid}"] .item-has-website-badge`).show(); + }); - // Update items to show both the Puter subdomain and custom domain - $(`.item[data-path^="${target_dir_path}/"]`).each(function () { - // show the link badge - $(this).find('.item-has-website-url-badge').show(); - // update item's website_url attribute to use custom domain - $(this).attr('data-website_url', customUrl + $(this).attr('data-path').substring(target_dir_path.length)); - // Also store the puter subdomain URL as backup - $(this).attr('data-puter_website_url', puterSiteUrl + $(this).attr('data-path').substring(target_dir_path.length)); - }); + // find all items whose path starts with target_dir_path + $(`.item[data-path^="${target_dir_path}/"]`).each(function () { + // show the link badge + $(this).find('.item-has-website-url-badge').show(); + // update item's website_url attribute + $(this).attr('data-website_url', url + $(this).attr('data-path').substring(target_dir_path.length)); + }); - window.update_sites_cache(); - $(el_window).close(); - } + window.update_sites_cache(); } catch ( err ) { const errorMessage = err.message || (err.error && err.error.message) || 'An error occurred while publishing'; $(el_window).find('.publish-website-error-msg').html( - errorMessage + ( - err.error && err.error.code === 'subdomain_limit_reached' ? - ` ${ i18n('manage_your_subdomains') }` : '' - )); + errorMessage + ( + err.error && err.error.code === 'subdomain_limit_reached' ? + ` ${ i18n('manage_your_subdomains') }` : '' + ), + ); $(el_window).find('.publish-website-error-msg').fadeIn(); // re-enable 'Publish' button $(el_window).find('.publish-btn').prop('disabled', false); diff --git a/src/gui/src/UI/UIWindowRefer.js b/src/gui/src/UI/UIWindowRefer.js deleted file mode 100644 index 8d052642ab..0000000000 --- a/src/gui/src/UI/UIWindowRefer.js +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright (C) 2024-present Puter Technologies Inc. - * - * This file is part of Puter. - * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import UIWindow from './UIWindow.js'; -import UIPopover from './UIPopover.js'; -import socialLink from '../helpers/socialLink.js'; - -async function UIWindowRefer (options) { - let h = ''; - const url = `${window.gui_origin}/?r=${window.user.referral_code}`; - - h += '
'; - h += '
×
'; - h += ``; - h += `

${i18n('refer_friends_c2a')}

`; - h += ``; - h += ''; - h += ``; - h += ``; - h += '
'; - - const el_window = await UIWindow({ - title: i18n('window_title_refer_friend'), - window_class: 'window-refer-friend', - icon: null, - uid: null, - is_dir: false, - body_content: h, - has_head: false, - selectable_body: false, - draggable_body: true, - allow_context_menu: false, - is_draggable: true, - is_resizable: false, - is_droppable: false, - init_center: true, - allow_native_ctxmenu: true, - allow_user_select: true, - width: 500, - dominant: true, - window_css: { - height: 'initial', - }, - body_css: { - width: 'initial', - 'max-height': 'calc(100vh - 200px)', - 'background-color': 'rgb(241 246 251)', - 'backdrop-filter': 'blur(3px)', - 'padding': '10px 20px 20px 20px', - 'height': 'initial', - }, - }); - - $(el_window).find('.window-body .downloadable-link').val(url); - - $(el_window).find('.window-body .share-copy-link-on-social').on('click', function (e) { - const social_links = socialLink({ url: url, title: i18n('refer_friends_social_media_c2a'), description: i18n('refer_friends_social_media_c2a') }); - - let social_links_html = ''; - social_links_html += '
'; - social_links_html += `

${i18n('share_to')}

`; - social_links_html += ``; - social_links_html += ``; - social_links_html += ``; - social_links_html += ``; - social_links_html += ``; - social_links_html += ``; - social_links_html += '
'; - - UIPopover({ - content: social_links_html, - snapToElement: this, - parent_element: this, - // width: 300, - height: 100, - position: 'bottom', - }); - }); - - $(el_window).find('.window-body .copy-downloadable-link').on('click', async function (e) { - var copy_btn = this; - if ( navigator.clipboard ) { - // Get link text - const selected_text = $(el_window).find('.window-body .downloadable-link').val(); - // copy selected text to clipboard - await navigator.clipboard.writeText(selected_text); - } - else { - // Get the text field - $(el_window).find('.window-body .downloadable-link').select(); - // Copy the text inside the text field - document.execCommand('copy'); - } - - $(this).html(i18n('link_copied')); - setTimeout(function () { - $(copy_btn).html(i18n('copy_link')); - }, 1000); - }); -} - -export default UIWindowRefer; \ No newline at end of file diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 7ca113424d..1fef2b9b00 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -318,7 +318,6 @@ function UIWindowSignup (options) { // Include captcha in request only if required const requestData = { username: username, - referral_code: window.referral_code, email: email, password: password, referrer: options.referrer ?? window.referrerStr, diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index b8fd477f5e..4667536da1 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -284,10 +284,6 @@ body { margin: 8px 4px; } -.dashboard-sidebar.collapsed .dashboard-sidebar-item.beta::after { - display: none; -} - .dashboard-sidebar.collapsed .dashboard-user-options { padding-top: 8px; } @@ -377,17 +373,6 @@ body { font-weight: 500; } -.dashboard-sidebar-item.beta:after { - content: "Beta"; - font-size: 12px; - color: var(--dashboard-background); - background: var(--dashboard-text-muted); - padding: 1px 4px; - border-radius: 4px; - position: absolute; - right: 4px; -} - /* User options button at bottom of sidebar */ .dashboard-user-options { border-top: 1px solid var(--dashboard-border); @@ -669,6 +654,7 @@ body { .myapps-container { min-height: 200px; + padding-bottom: 50px; } .myapps-empty { @@ -730,10 +716,19 @@ body { width: 100%; height: 100%; object-fit: cover; + filter: drop-shadow(0px 0px .3px rgb(173, 173, 173)); +} + +.myapps-tile.has-open-contextmenu{ + overflow: visible !important; +} + +.myapps-tile:hover{ + overflow: visible !important; } @media (hover: hover) { - .myapps-tile:hover .myapps-tile-icon { + .myapps-tile:hover .myapps-tile-icon, .myapps-tile.has-open-contextmenu .myapps-tile-icon{ transform: scale(1.08); } } diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index 4c55b55a1e..d30337affd 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -5417,13 +5417,6 @@ html.dark-mode .usage-table-show-less:hover { max-width: calc(100% - 30px); } -.device-phone .window.window-refer-friend { - left: 50% !important; - transform: translate(-50%) !important; - height: initial !important; - max-width: calc(100% - 30px); -} - .device-phone .window.window-task-manager { height: initial !important; } @@ -5682,15 +5675,7 @@ html.dark-mode .usage-table-show-less:hover { visibility: hidden; } -.refer-friend-c2a { - text-align: center; - font-size: 16px; - padding: 20px; - font-weight: 400; - margin: -10px 10px 20px 10px; - -webkit-font-smoothing: antialiased; - color: #5f626d; -} + .progress-report{ font-size:15px; overflow: hidden; diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index dcbb89b535..4ae28f7b3a 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -516,20 +516,6 @@ window.initgui = async function (options) { window.history.replaceState(null, document.title, cleanUrl); } - //-------------------------------------------------------------------------------------- - // Get user referral code from URL query params - // i.e. https://puter.com/?r=123456 - //-------------------------------------------------------------------------------------- - if ( window.url_query_params.has('r') ) { - window.referral_code = window.url_query_params.get('r'); - // remove 'r' from URL - window.history.pushState(null, document.title, '/'); - // show referral notice, this will be used later if Desktop is loaded - if ( window.first_visit_ever ) { - window.show_referral_notice = true; - } - } - //-------------------------------------------------------------------------------------- // Desktop background (early) // Set before action=login/signup so OIDC error redirects show the background behind the form. @@ -1161,7 +1147,6 @@ window.initgui = async function (options) { let spinner_init_ts = Date.now(); const requestData = { referrer: referrer, - referral_code: window.referral_code, is_temp: true, };