diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 03d91c9dae..e737397fc8 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -1691,7 +1691,7 @@ class AssetsApi { /// View asset thumbnail /// - /// Retrieve the thumbnail image for the specified asset. + /// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission. /// /// Note: This method returns the HTTP [Response]. /// @@ -1747,7 +1747,7 @@ class AssetsApi { /// View asset thumbnail /// - /// Retrieve the thumbnail image for the specified asset. + /// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission. /// /// Parameters: /// diff --git a/mobile/openapi/lib/model/asset_media_size.dart b/mobile/openapi/lib/model/asset_media_size.dart index aa7e2a6f5c..087d19da1f 100644 --- a/mobile/openapi/lib/model/asset_media_size.dart +++ b/mobile/openapi/lib/model/asset_media_size.dart @@ -23,12 +23,14 @@ class AssetMediaSize { String toJson() => value; + static const original = AssetMediaSize._(r'original'); static const fullsize = AssetMediaSize._(r'fullsize'); static const preview = AssetMediaSize._(r'preview'); static const thumbnail = AssetMediaSize._(r'thumbnail'); /// List of all possible values in this [enum][AssetMediaSize]. static const values = [ + original, fullsize, preview, thumbnail, @@ -70,6 +72,7 @@ class AssetMediaSizeTypeTransformer { AssetMediaSize? decode(dynamic data, {bool allowNull = true}) { if (data != null) { switch (data) { + case r'original': return AssetMediaSize.original; case r'fullsize': return AssetMediaSize.fullsize; case r'preview': return AssetMediaSize.preview; case r'thumbnail': return AssetMediaSize.thumbnail; diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 37aecc8b9c..01bb689538 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -72,6 +72,7 @@ class Permission { static const facePeriodRead = Permission._(r'face.read'); static const facePeriodUpdate = Permission._(r'face.update'); static const facePeriodDelete = Permission._(r'face.delete'); + static const folderPeriodRead = Permission._(r'folder.read'); static const jobPeriodCreate = Permission._(r'job.create'); static const jobPeriodRead = Permission._(r'job.read'); static const libraryPeriodCreate = Permission._(r'library.create'); @@ -230,6 +231,7 @@ class Permission { facePeriodRead, facePeriodUpdate, facePeriodDelete, + folderPeriodRead, jobPeriodCreate, jobPeriodRead, libraryPeriodCreate, @@ -423,6 +425,7 @@ class PermissionTypeTransformer { case r'face.read': return Permission.facePeriodRead; case r'face.update': return Permission.facePeriodUpdate; case r'face.delete': return Permission.facePeriodDelete; + case r'folder.read': return Permission.folderPeriodRead; case r'job.create': return Permission.jobPeriodCreate; case r'job.read': return Permission.jobPeriodRead; case r'library.create': return Permission.libraryPeriodCreate; diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index 43292089d7..522063185f 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -27,7 +27,7 @@ function dart { } function typescript { - pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts + pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas immich-openapi-specs.json typescript-sdk/src/fetch-client.ts pnpm --filter @immich/sdk install --frozen-lockfile pnpm --filter @immich/sdk build } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index cb0c8f8a67..fb329d2653 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3173,6 +3173,7 @@ "state": "Stable" } ], + "x-immich-permission": "asset.upload", "x-immich-state": "Stable" } }, @@ -3225,6 +3226,7 @@ "state": "Stable" } ], + "x-immich-permission": "job.create", "x-immich-state": "Stable" } }, @@ -4277,7 +4279,7 @@ }, "/assets/{id}/thumbnail": { "get": { - "description": "Retrieve the thumbnail image for the specified asset.", + "description": "Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.", "operationId": "viewAsset", "parameters": [ { @@ -14618,6 +14620,7 @@ "state": "Stable" } ], + "x-immich-permission": "folder.read", "x-immich-state": "Stable" } }, @@ -14670,6 +14673,7 @@ "state": "Stable" } ], + "x-immich-permission": "folder.read", "x-immich-state": "Stable" } }, @@ -16301,6 +16305,7 @@ }, "AssetMediaSize": { "enum": [ + "original", "fullsize", "preview", "thumbnail" @@ -18958,6 +18963,7 @@ "face.read", "face.update", "face.delete", + "folder.read", "job.create", "job.read", "library.create", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 09a0860539..41d4f2689d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1875,6 +1875,210 @@ export type WorkflowUpdateDto = { name?: string; triggerType?: PluginTriggerType; }; +export type SyncAckV1 = {}; +export type SyncAlbumDeleteV1 = { + albumId: string; +}; +export type SyncAlbumToAssetDeleteV1 = { + albumId: string; + assetId: string; +}; +export type SyncAlbumToAssetV1 = { + albumId: string; + assetId: string; +}; +export type SyncAlbumUserDeleteV1 = { + albumId: string; + userId: string; +}; +export type SyncAlbumUserV1 = { + albumId: string; + role: AlbumUserRole; + userId: string; +}; +export type SyncAlbumV1 = { + createdAt: string; + description: string; + id: string; + isActivityEnabled: boolean; + name: string; + order: AssetOrder; + ownerId: string; + thumbnailAssetId: string | null; + updatedAt: string; +}; +export type SyncAssetDeleteV1 = { + assetId: string; +}; +export type SyncAssetExifV1 = { + assetId: string; + city: string | null; + country: string | null; + dateTimeOriginal: string | null; + description: string | null; + exifImageHeight: number | null; + exifImageWidth: number | null; + exposureTime: string | null; + fNumber: number | null; + fileSizeInByte: number | null; + focalLength: number | null; + fps: number | null; + iso: number | null; + latitude: number | null; + lensModel: string | null; + longitude: number | null; + make: string | null; + model: string | null; + modifyDate: string | null; + orientation: string | null; + profileDescription: string | null; + projectionType: string | null; + rating: number | null; + state: string | null; + timeZone: string | null; +}; +export type SyncAssetFaceDeleteV1 = { + assetFaceId: string; +}; +export type SyncAssetFaceV1 = { + assetId: string; + boundingBoxX1: number; + boundingBoxX2: number; + boundingBoxY1: number; + boundingBoxY2: number; + id: string; + imageHeight: number; + imageWidth: number; + personId: string | null; + sourceType: string; +}; +export type SyncAssetMetadataDeleteV1 = { + assetId: string; + key: string; +}; +export type SyncAssetMetadataV1 = { + assetId: string; + key: string; + value: object; +}; +export type SyncAssetV1 = { + checksum: string; + deletedAt: string | null; + duration: string | null; + fileCreatedAt: string | null; + fileModifiedAt: string | null; + height: number | null; + id: string; + isEdited: boolean; + isFavorite: boolean; + libraryId: string | null; + livePhotoVideoId: string | null; + localDateTime: string | null; + originalFileName: string; + ownerId: string; + stackId: string | null; + thumbhash: string | null; + "type": AssetTypeEnum; + visibility: AssetVisibility; + width: number | null; +}; +export type SyncAuthUserV1 = { + avatarColor: (UserAvatarColor) | null; + deletedAt: string | null; + email: string; + hasProfileImage: boolean; + id: string; + isAdmin: boolean; + name: string; + oauthId: string; + pinCode: string | null; + profileChangedAt: string; + quotaSizeInBytes: number | null; + quotaUsageInBytes: number; + storageLabel: string | null; +}; +export type SyncCompleteV1 = {}; +export type SyncMemoryAssetDeleteV1 = { + assetId: string; + memoryId: string; +}; +export type SyncMemoryAssetV1 = { + assetId: string; + memoryId: string; +}; +export type SyncMemoryDeleteV1 = { + memoryId: string; +}; +export type SyncMemoryV1 = { + createdAt: string; + data: object; + deletedAt: string | null; + hideAt: string | null; + id: string; + isSaved: boolean; + memoryAt: string; + ownerId: string; + seenAt: string | null; + showAt: string | null; + "type": MemoryType; + updatedAt: string; +}; +export type SyncPartnerDeleteV1 = { + sharedById: string; + sharedWithId: string; +}; +export type SyncPartnerV1 = { + inTimeline: boolean; + sharedById: string; + sharedWithId: string; +}; +export type SyncPersonDeleteV1 = { + personId: string; +}; +export type SyncPersonV1 = { + birthDate: string | null; + color: string | null; + createdAt: string; + faceAssetId: string | null; + id: string; + isFavorite: boolean; + isHidden: boolean; + name: string; + ownerId: string; + updatedAt: string; +}; +export type SyncResetV1 = {}; +export type SyncStackDeleteV1 = { + stackId: string; +}; +export type SyncStackV1 = { + createdAt: string; + id: string; + ownerId: string; + primaryAssetId: string; + updatedAt: string; +}; +export type SyncUserDeleteV1 = { + userId: string; +}; +export type SyncUserMetadataDeleteV1 = { + key: UserMetadataKey; + userId: string; +}; +export type SyncUserMetadataV1 = { + key: UserMetadataKey; + userId: string; + value: object; +}; +export type SyncUserV1 = { + avatarColor: (UserAvatarColor) | null; + deletedAt: string | null; + email: string; + hasProfileImage: boolean; + id: string; + name: string; + profileChangedAt: string; +}; /** * List all activities */ @@ -5524,6 +5728,7 @@ export enum Permission { FaceRead = "face.read", FaceUpdate = "face.update", FaceDelete = "face.delete", + FolderRead = "folder.read", JobCreate = "job.create", JobRead = "job.read", LibraryCreate = "library.create", @@ -5660,6 +5865,7 @@ export enum MirrorAxis { Vertical = "vertical" } export enum AssetMediaSize { + Original = "original", Fullsize = "fullsize", Preview = "preview", Thumbnail = "thumbnail" @@ -5937,3 +6143,8 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretPost = "client_secret_post", ClientSecretBasic = "client_secret_basic" } +export enum UserMetadataKey { + Preferences = "preferences", + License = "license", + Onboarding = "onboarding" +} diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 788ee0c0ed..ec6083cfa8 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -147,7 +147,8 @@ export class AssetMediaController { @Authenticated({ permission: Permission.AssetView, sharedLink: true }) @Endpoint({ summary: 'View asset thumbnail', - description: 'Retrieve the thumbnail image for the specified asset.', + description: + 'Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) async viewAsset( @@ -202,7 +203,7 @@ export class AssetMediaController { } @Post('exist') - @Authenticated() + @Authenticated({ permission: Permission.AssetUpload }) @Endpoint({ summary: 'Check existing assets', description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup', diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 988623360b..8eb3a5ce44 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -66,7 +66,7 @@ export class AssetController { } @Post('jobs') - @Authenticated() + @Authenticated({ permission: Permission.JobCreate }) @HttpCode(HttpStatus.NO_CONTENT) @Endpoint({ summary: 'Run an asset job', diff --git a/server/src/controllers/view.controller.ts b/server/src/controllers/view.controller.ts index 8a977e15bc..b07d83fe58 100644 --- a/server/src/controllers/view.controller.ts +++ b/server/src/controllers/view.controller.ts @@ -3,7 +3,7 @@ import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { ApiTag } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { ViewService } from 'src/services/view.service'; @@ -13,7 +13,7 @@ export class ViewController { constructor(private service: ViewService) {} @Get('folder/unique-paths') - @Authenticated() + @Authenticated({ permission: Permission.FolderRead }) @Endpoint({ summary: 'Retrieve unique paths', description: 'Retrieve a list of unique folder paths from asset original paths.', @@ -24,7 +24,7 @@ export class ViewController { } @Get('folder') - @Authenticated() + @Authenticated({ permission: Permission.FolderRead }) @Endpoint({ summary: 'Retrieve assets by original path', description: 'Retrieve assets that are children of a specific folder.', diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index f5207d3048..3935774f3e 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -7,6 +7,7 @@ import { AssetVisibility } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; export enum AssetMediaSize { + Original = 'original', /** * An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF. * or otherwise the original image itself. diff --git a/server/src/enum.ts b/server/src/enum.ts index 5a0f6bdbe0..8f509754da 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -146,6 +146,8 @@ export enum Permission { FaceUpdate = 'face.update', FaceDelete = 'face.delete', + FolderRead = 'folder.read', + JobCreate = 'job.create', JobRead = 'job.read', diff --git a/web/src/lib/components/album-page/__tests__/album-cover.spec.ts b/web/src/lib/components/album-page/__tests__/album-cover.spec.ts index 5fa8b96008..3d081a3d2d 100644 --- a/web/src/lib/components/album-page/__tests__/album-cover.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-cover.spec.ts @@ -1,5 +1,5 @@ import AlbumCover from '$lib/components/album-page/album-cover.svelte'; -import { getAssetThumbnailUrl } from '$lib/utils'; +import { getAssetMediaUrl } from '$lib/utils'; import { albumFactory } from '@test-data/factories/album-factory'; import { render } from '@testing-library/svelte'; @@ -7,7 +7,7 @@ vi.mock('$lib/utils'); describe('AlbumCover component', () => { it('renders an image when the album has a thumbnail', () => { - vi.mocked(getAssetThumbnailUrl).mockReturnValue('/asdf'); + vi.mocked(getAssetMediaUrl).mockReturnValue('/asdf'); const component = render(AlbumCover, { album: albumFactory.build({ albumName: 'someName', @@ -21,7 +21,7 @@ describe('AlbumCover component', () => { expect(img.getAttribute('loading')).toBe('lazy'); expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text'); expect(img.getAttribute('src')).toBe('/asdf'); - expect(getAssetThumbnailUrl).toHaveBeenCalledWith({ id: '123' }); + expect(getAssetMediaUrl).toHaveBeenCalledWith({ id: '123' }); }); it('renders an image when the album has no thumbnail', () => { diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte index 3f71bbe632..c6242c5fad 100644 --- a/web/src/lib/components/album-page/album-cover.svelte +++ b/web/src/lib/components/album-page/album-cover.svelte @@ -1,8 +1,8 @@ diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index 30ea8ff6bd..4120212165 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -7,7 +7,7 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { Route } from '$lib/route'; import { locale } from '$lib/stores/preferences.store'; - import { getAssetThumbnailUrl } from '$lib/utils'; + import { getAssetMediaUrl } from '$lib/utils'; import { getAssetType } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { isTenMinutesApart } from '$lib/utils/timesince'; @@ -142,7 +142,7 @@ Profile picture of {reaction.user.name}, who commented on this asset @@ -195,7 +195,7 @@ > Profile picture of {reaction.user.name}, who liked this asset diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index 212ba87846..8a616209fa 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -1,7 +1,7 @@ {#if projectionType === ProjectionType.EQUIRECTANGULAR} - + {:else} import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; - import { preloadManager } from '$lib/managers/PreloadManager.svelte'; + import { imageManager } from '$lib/managers/ImageManager.svelte'; import { Icon } from '@immich/ui'; import { mdiEyeOffOutline } from '@mdi/js'; import type { ActionReturn } from 'svelte/action'; @@ -60,7 +60,7 @@ onComplete?.(false); } return { - destroy: () => preloadManager.cancelPreloadUrl(url), + destroy: () => imageManager.cancelPreloadUrl(url), }; } diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 600b8cecbd..8270646470 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -5,7 +5,7 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte'; import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store'; - import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; + import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils'; import { timeToSeconds } from '$lib/utils/date-time'; import { moveFocus } from '$lib/utils/focus-util'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; @@ -333,7 +333,7 @@ import { assetViewerFadeDuration } from '$lib/constants'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; - import { getAssetThumbnailUrl } from '$lib/utils'; + import { getAssetMediaUrl } from '$lib/utils'; import { getAltText } from '$lib/utils/thumbnail-util'; import { AssetMediaSize } from '@immich/sdk'; import { LoadingSpinner } from '@immich/ui'; @@ -35,7 +35,7 @@ }; }); - const imageLoaderUrl = $derived(getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview })); + const imageLoaderUrl = $derived(getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })); {#if !imageLoaded} diff --git a/web/src/lib/components/memory-page/memory-video-viewer.svelte b/web/src/lib/components/memory-page/memory-video-viewer.svelte index 06a41e2ab9..45501aff0b 100644 --- a/web/src/lib/components/memory-page/memory-video-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-video-viewer.svelte @@ -2,7 +2,7 @@ import { assetViewerFadeDuration } from '$lib/constants'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { autoPlayVideo } from '$lib/stores/preferences.store'; - import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; + import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils'; import { AssetMediaSize } from '@immich/sdk'; import { onMount } from 'svelte'; import { fade } from 'svelte/transition'; @@ -32,7 +32,7 @@ playsinline class="h-full w-full rounded-2xl object-contain transition-all" src={getAssetPlaybackUrl({ id: asset.id })} - poster={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview })} + poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })} draggable="false" muted={videoViewerMuted} volume={videoViewerVolume} diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 1b82de6a4b..3c7ec4b0db 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -30,7 +30,7 @@ import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte'; import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { preferences } from '$lib/stores/user.store'; - import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; + import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk'; @@ -449,7 +449,7 @@ {#if current.previousMemory && current.previousMemory.assets.length > 0} {$t('previous_memory')} @@ -598,7 +598,7 @@ {#if current.nextMemory && current.nextMemory.assets.length > 0} {$t('next_memory')} diff --git a/web/src/lib/components/places-page/places-card-group.svelte b/web/src/lib/components/places-page/places-card-group.svelte index 6675014704..c50f2a0441 100644 --- a/web/src/lib/components/places-page/places-card-group.svelte +++ b/web/src/lib/components/places-page/places-card-group.svelte @@ -1,7 +1,7 @@ diff --git a/web/src/service-worker/broadcast-channel.ts b/web/src/service-worker/broadcast-channel.ts deleted file mode 100644 index ae6f1e1be6..0000000000 --- a/web/src/service-worker/broadcast-channel.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { handleCancel, handlePreload } from './request'; - -export const installBroadcastChannelListener = () => { - const broadcast = new BroadcastChannel('immich'); - // eslint-disable-next-line unicorn/prefer-add-event-listener - broadcast.onmessage = (event) => { - if (!event.data) { - return; - } - - const url = new URL(event.data.url, event.origin); - - switch (event.data.type) { - case 'preload': { - handlePreload(url); - break; - } - - case 'cancel': { - handleCancel(url); - break; - } - } - }; -}; diff --git a/web/src/service-worker/cache.ts b/web/src/service-worker/cache.ts deleted file mode 100644 index f91d8366ea..0000000000 --- a/web/src/service-worker/cache.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { version } from '$service-worker'; - -const CACHE = `cache-${version}`; - -let _cache: Cache | undefined; -const getCache = async () => { - if (_cache) { - return _cache; - } - _cache = await caches.open(CACHE); - return _cache; -}; - -export const get = async (key: string) => { - const cache = await getCache(); - if (!cache) { - return; - } - - return cache.match(key); -}; - -export const put = async (key: string, response: Response) => { - if (response.status !== 200) { - return; - } - - const cache = await getCache(); - if (!cache) { - return; - } - - cache.put(key, response.clone()); -}; - -export const prune = async () => { - for (const key of await caches.keys()) { - if (key !== CACHE) { - await caches.delete(key); - } - } -}; diff --git a/web/src/service-worker/index.ts b/web/src/service-worker/index.ts index 28336aca6a..377195b0c8 100644 --- a/web/src/service-worker/index.ts +++ b/web/src/service-worker/index.ts @@ -2,9 +2,9 @@ /// /// /// -import { installBroadcastChannelListener } from './broadcast-channel'; -import { prune } from './cache'; -import { handleRequest } from './request'; + +import { installMessageListener } from './messaging'; +import { handleFetch as handleAssetFetch } from './request'; const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/; @@ -12,12 +12,10 @@ const sw = globalThis as unknown as ServiceWorkerGlobalScope; const handleActivate = (event: ExtendableEvent) => { event.waitUntil(sw.clients.claim()); - event.waitUntil(prune()); }; const handleInstall = (event: ExtendableEvent) => { event.waitUntil(sw.skipWaiting()); - // do not preload app resources }; const handleFetch = (event: FetchEvent): void => { @@ -28,7 +26,7 @@ const handleFetch = (event: FetchEvent): void => { // Cache requests for thumbnails const url = new URL(event.request.url); if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) { - event.respondWith(handleRequest(event.request)); + event.respondWith(handleAssetFetch(event.request)); return; } }; @@ -36,4 +34,4 @@ const handleFetch = (event: FetchEvent): void => { sw.addEventListener('install', handleInstall, { passive: true }); sw.addEventListener('activate', handleActivate, { passive: true }); sw.addEventListener('fetch', handleFetch, { passive: true }); -installBroadcastChannelListener(); +installMessageListener(); diff --git a/web/src/service-worker/messaging.ts b/web/src/service-worker/messaging.ts new file mode 100644 index 0000000000..2dd2d51f72 --- /dev/null +++ b/web/src/service-worker/messaging.ts @@ -0,0 +1,53 @@ +/// +/// +/// +/// + +import { handleCancel } from './request'; + +const sw = globalThis as unknown as ServiceWorkerGlobalScope; + +/** + * Send acknowledgment for a request + */ +function sendAck(client: Client, requestId: string) { + client.postMessage({ + type: 'ack', + requestId, + }); +} + +/** + * Handle 'cancel' request: cancel a pending request + */ +const handleCancelRequest = (client: Client, url: URL, requestId: string) => { + sendAck(client, requestId); + handleCancel(url); +}; + +export const installMessageListener = () => { + sw.addEventListener('message', (event) => { + if (!event.data?.requestId || !event.data?.type) { + return; + } + + const requestId = event.data.requestId; + + switch (event.data.type) { + case 'cancel': { + const url = event.data.url ? new URL(event.data.url, self.location.origin) : undefined; + if (!url) { + return; + } + + const client = event.source; + if (!client) { + return; + } + + handleCancelRequest(client, url, requestId); + break; + } + } + }); +}; diff --git a/web/src/service-worker/request.ts b/web/src/service-worker/request.ts index aeb63be899..1060cd4b6c 100644 --- a/web/src/service-worker/request.ts +++ b/web/src/service-worker/request.ts @@ -1,73 +1,68 @@ -import { get, put } from './cache'; +/// +/// +/// +/// -const pendingRequests = new Map(); - -const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined; -const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined; - -const assertResponse = (response: Response) => { - if (!(response instanceof Response)) { - throw new TypeError('Fetch did not return a valid Response object'); - } +type PendingRequest = { + controller: AbortController; + promise: Promise; + cleanupTimeout?: ReturnType; }; -const getCacheKey = (request: URL | Request) => { - if (isURL(request)) { - return request.toString(); +const pendingRequests = new Map(); + +const getRequestKey = (request: URL | Request): string => (request instanceof URL ? request.href : request.url); + +const CANCELATION_MESSAGE = 'Request canceled by application'; +const CLEANUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +export const handleFetch = (request: URL | Request): Promise => { + const requestKey = getRequestKey(request); + const existing = pendingRequests.get(requestKey); + + if (existing) { + // Clone the response since response bodies can only be read once + // Each caller gets an independent clone they can consume + return existing.promise.then((response) => response.clone()); } - if (isRequest(request)) { - return request.url; - } + const pendingRequest: PendingRequest = { + controller: new AbortController(), + promise: undefined as unknown as Promise, + }; + pendingRequests.set(requestKey, pendingRequest); - throw new Error(`Invalid request: ${request}`); -}; + // NOTE: fetch returns after headers received, not the body + pendingRequest.promise = fetch(request, { signal: pendingRequest.controller.signal }) + .catch((error: unknown) => { + const standardError = error instanceof Error ? error : new Error(String(error)); + if (standardError.name === 'AbortError' || standardError.message === CANCELATION_MESSAGE) { + // dummy response avoids network errors in the console for these requests + return new Response(undefined, { status: 204 }); + } + throw standardError; + }) + .finally(() => { + // Schedule cleanup after timeout to allow response body streaming to complete + const cleanupTimeout = setTimeout(() => { + pendingRequests.delete(requestKey); + }, CLEANUP_TIMEOUT_MS); + pendingRequest.cleanupTimeout = cleanupTimeout; + }); -export const handlePreload = async (request: URL | Request) => { - try { - return await handleRequest(request); - } catch (error) { - console.error(`Preload failed: ${error}`); - } -}; - -export const handleRequest = async (request: URL | Request) => { - const cacheKey = getCacheKey(request); - const cachedResponse = await get(cacheKey); - if (cachedResponse) { - return cachedResponse; - } - - try { - const cancelToken = new AbortController(); - pendingRequests.set(cacheKey, cancelToken); - const response = await fetch(request, { signal: cancelToken.signal }); - - assertResponse(response); - put(cacheKey, response); - - return response; - } catch (error) { - if (error.name === 'AbortError') { - // dummy response avoids network errors in the console for these requests - return new Response(undefined, { status: 204 }); - } - - console.log('Not an abort error', error); - - throw error; - } finally { - pendingRequests.delete(cacheKey); - } + // Clone for the first caller to keep the original response unconsumed for future callers + return pendingRequest.promise.then((response) => response.clone()); }; export const handleCancel = (url: URL) => { - const cacheKey = getCacheKey(url); - const pendingRequest = pendingRequests.get(cacheKey); - if (!pendingRequest) { - return; - } + const requestKey = getRequestKey(url); - pendingRequest.abort(); - pendingRequests.delete(cacheKey); + const pendingRequest = pendingRequests.get(requestKey); + if (pendingRequest) { + pendingRequest.controller.abort(CANCELATION_MESSAGE); + if (pendingRequest.cleanupTimeout) { + clearTimeout(pendingRequest.cleanupTimeout); + } + pendingRequests.delete(requestKey); + } };