feat: optimized saved view previews & thumbnails (#5563)

* init new API routes

* WIP output & migration

* WIP endpoint

* endpoint works

* frontend adjusted fully

* aiven extras fixx + migration

* simpler migration

* add deprecation notice

* test fixes

* gqlgen

* testss fix
This commit is contained in:
Kristaps Fabians Geikins
2025-09-30 10:08:08 +02:00
committed by GitHub
parent fc118bc82c
commit 43803b9517
29 changed files with 565 additions and 213 deletions
+3 -9
View File
@@ -2,9 +2,7 @@ services:
# Actual Speckle Server dependencies
postgres:
build:
context: .
dockerfile: utils/postgres/Dockerfile
image: 'postgres:16.4-alpine3.20@sha256:d898b0b78a2627cb4ee63464a14efc9d296884f1b28c841b0ab7d7c42f1fffdf'
restart: always
environment:
POSTGRES_DB: speckle
@@ -19,9 +17,7 @@ services:
command: postgres -c max_prepared_transactions=150
postgres-region1:
build:
context: .
dockerfile: utils/postgres/Dockerfile
image: 'postgres:16.4-alpine3.20@sha256:d898b0b78a2627cb4ee63464a14efc9d296884f1b28c841b0ab7d7c42f1fffdf'
restart: always
environment:
POSTGRES_DB: speckle
@@ -36,9 +32,7 @@ services:
command: postgres -c max_prepared_transactions=150
postgres-region2:
build:
context: .
dockerfile: utils/postgres/Dockerfile
image: 'postgres:16.4-alpine3.20@sha256:d898b0b78a2627cb4ee63464a14efc9d296884f1b28c841b0ab7d7c42f1fffdf'
restart: always
environment:
POSTGRES_DB: speckle
@@ -4,7 +4,7 @@
<form @submit="onSubmit">
<div class="flex flex-col gap-2">
<img
:src="slide?.screenshot"
:src="slide?.thumbnailUrl"
:alt="slide?.name"
class="w-full object-cover rounded-lg border border-outline-3 h-64"
/>
@@ -41,7 +41,7 @@ graphql(`
projectId
name
description
screenshot
thumbnailUrl
}
`)
@@ -6,7 +6,7 @@
@click="onSelectSlide"
>
<img
:src="slide.screenshot"
:src="slide.thumbnailUrl"
:alt="slide.name"
class="w-full aspect-[3/2] md:aspect-video object-cover"
/>
@@ -28,7 +28,7 @@ graphql(`
fragment PresentationSlideListSlide_SavedView on SavedView {
id
name
screenshot
thumbnailUrl
}
`)
@@ -12,7 +12,7 @@
<div class="flex items-center shrink-0">
<div class="relative">
<img
:src="view.screenshot"
:src="view.thumbnailUrl"
alt="View screenshot"
class="w-20 h-[60px] object-cover rounded border border-outline-3 bg-foundation-page cursor-pointer"
/>
@@ -149,7 +149,7 @@ graphql(`
id
name
description
screenshot
thumbnailUrl
visibility
isHomeView
resourceIds
@@ -77,8 +77,8 @@ type Documents = {
"\n mutation PresentationShareToken($input: SavedViewGroupShareInput!) {\n projectMutations {\n savedViewMutations {\n share(input: $input) {\n id\n revoked\n content\n }\n }\n }\n }\n": typeof types.PresentationShareTokenDocument,
"\n mutation PresentationShareEnableToken($input: SavedViewGroupShareUpdateInput!) {\n projectMutations {\n savedViewMutations {\n enableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n }\n": typeof types.PresentationShareEnableTokenDocument,
"\n mutation PresentationShareDisableToken($input: SavedViewGroupShareUpdateInput!) {\n projectMutations {\n savedViewMutations {\n disableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n }\n": typeof types.PresentationShareDisableTokenDocument,
"\n fragment PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n screenshot\n }\n": typeof types.PresentationSlideEditDialog_SavedViewFragmentDoc,
"\n fragment PresentationSlideListSlide_SavedView on SavedView {\n id\n name\n screenshot\n }\n": typeof types.PresentationSlideListSlide_SavedViewFragmentDoc,
"\n fragment PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n thumbnailUrl\n }\n": typeof types.PresentationSlideEditDialog_SavedViewFragmentDoc,
"\n fragment PresentationSlideListSlide_SavedView on SavedView {\n id\n name\n thumbnailUrl\n }\n": typeof types.PresentationSlideListSlide_SavedViewFragmentDoc,
"\n fragment PresentationSlideList_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n items {\n id\n ...PresentationSlideListSlide_SavedView\n }\n }\n }\n": typeof types.PresentationSlideList_SavedViewGroupFragmentDoc,
"\n fragment PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n totalCount\n items {\n id\n resourceIdString\n }\n }\n }\n": typeof types.PresentationViewerPageWrapper_SavedViewGroupFragmentDoc,
"\n fragment ProjectCardImportFileArea_Project on Project {\n id\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...UseFileImport_Project\n }\n": typeof types.ProjectCardImportFileArea_ProjectFragmentDoc,
@@ -190,7 +190,7 @@ type Documents = {
"\n fragment ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\n }\n }\n workspace {\n id\n seatType\n planSupportsSavedViews: hasAccessToFeature(featureName: savedViews)\n }\n }\n": typeof types.ViewerSavedViewsPanel_ProjectFragmentDoc,
"\n fragment ViewerSavedViewsPanelGroups_Project on Project {\n id\n savedViewGroups(input: $savedViewGroupsInput) {\n totalCount\n cursor\n items {\n id\n ...ViewerSavedViewsPanelViewsGroup_SavedViewGroup\n }\n }\n ...ViewerSavedViewsPanelViewsGroup_Project\n }\n": typeof types.ViewerSavedViewsPanelGroups_ProjectFragmentDoc,
"\n query ViewerSavedViewsPanelGroups_SavedViewGroups(\n $projectId: String!\n $savedViewGroupsInput: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n ...ViewerSavedViewsPanelGroups_Project\n }\n }\n": typeof types.ViewerSavedViewsPanelGroups_SavedViewGroupsDocument,
"\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n visibility\n isHomeView\n resourceIds\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_SavedView\n ...UseSavedViewValidationHelpers_SavedView\n ...UseDraggableView_SavedView\n }\n": typeof types.ViewerSavedViewsPanelView_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n thumbnailUrl\n visibility\n isHomeView\n resourceIds\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_SavedView\n ...UseSavedViewValidationHelpers_SavedView\n ...UseDraggableView_SavedView\n }\n": typeof types.ViewerSavedViewsPanelView_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewDeleteDialog_SavedView on SavedView {\n id\n name\n ...UseDeleteSavedView_SavedView\n }\n": typeof types.ViewerSavedViewsPanelViewDeleteDialog_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewEditDialog_SavedView on SavedView {\n id\n name\n description\n visibility\n group {\n ...FormSelectSavedViewGroup_SavedViewGroup\n }\n ...UseUpdateSavedView_SavedView\n ...UseSavedViewValidationHelpers_SavedView\n }\n": typeof types.ViewerSavedViewsPanelViewEditDialog_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewMoveDialog_SavedView on SavedView {\n id\n group {\n ...FormSelectSavedViewGroup_SavedViewGroup\n }\n ...UseUpdateSavedView_SavedView\n }\n": typeof types.ViewerSavedViewsPanelViewMoveDialog_SavedViewFragmentDoc,
@@ -302,7 +302,7 @@ type Documents = {
"\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n": typeof types.NavigationWorkspaceInvitesDocument,
"\n mutation UpdatePresentationSlide($input: UpdateSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n updateView(input: $input) {\n id\n name\n description\n }\n }\n }\n }\n": typeof types.UpdatePresentationSlideDocument,
"\n query PresentationAccessCheck($savedViewGroupId: ID!, $projectId: String!) {\n project(id: $projectId) {\n id\n savedViewGroup(id: $savedViewGroupId) {\n id\n }\n }\n }\n": typeof types.PresentationAccessCheckDocument,
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n limitedWorkspace {\n id\n ...PresentationLeftSidebar_LimitedWorkspace\n ...PresentationLoading_LimitedWorkspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n ...PresentationPageWrapper_SavedViewGroup\n ...PresentationLoading_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n": typeof types.ProjectPresentationPageDocument,
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n limitedWorkspace {\n id\n ...PresentationLeftSidebar_LimitedWorkspace\n ...PresentationLoading_LimitedWorkspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n ...PresentationPageWrapper_SavedViewGroup\n ...PresentationLoading_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n thumbnailUrl\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n": typeof types.ProjectPresentationPageDocument,
"\n fragment UseCopyModelLink_Model on Model {\n id\n projectId\n ...GetModelItemRoute_Model\n }\n": typeof types.UseCopyModelLink_ModelFragmentDoc,
"\n fragment UseCanCreatePersonalProject_User on User {\n permissions {\n canCreatePersonalProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseCanCreatePersonalProject_UserFragmentDoc,
"\n fragment UseCanCreateWorkspace_User on User {\n permissions {\n canCreateWorkspace {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseCanCreateWorkspace_UserFragmentDoc,
@@ -618,8 +618,8 @@ const documents: Documents = {
"\n mutation PresentationShareToken($input: SavedViewGroupShareInput!) {\n projectMutations {\n savedViewMutations {\n share(input: $input) {\n id\n revoked\n content\n }\n }\n }\n }\n": types.PresentationShareTokenDocument,
"\n mutation PresentationShareEnableToken($input: SavedViewGroupShareUpdateInput!) {\n projectMutations {\n savedViewMutations {\n enableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n }\n": types.PresentationShareEnableTokenDocument,
"\n mutation PresentationShareDisableToken($input: SavedViewGroupShareUpdateInput!) {\n projectMutations {\n savedViewMutations {\n disableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n }\n": types.PresentationShareDisableTokenDocument,
"\n fragment PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n screenshot\n }\n": types.PresentationSlideEditDialog_SavedViewFragmentDoc,
"\n fragment PresentationSlideListSlide_SavedView on SavedView {\n id\n name\n screenshot\n }\n": types.PresentationSlideListSlide_SavedViewFragmentDoc,
"\n fragment PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n thumbnailUrl\n }\n": types.PresentationSlideEditDialog_SavedViewFragmentDoc,
"\n fragment PresentationSlideListSlide_SavedView on SavedView {\n id\n name\n thumbnailUrl\n }\n": types.PresentationSlideListSlide_SavedViewFragmentDoc,
"\n fragment PresentationSlideList_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n items {\n id\n ...PresentationSlideListSlide_SavedView\n }\n }\n }\n": types.PresentationSlideList_SavedViewGroupFragmentDoc,
"\n fragment PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n totalCount\n items {\n id\n resourceIdString\n }\n }\n }\n": types.PresentationViewerPageWrapper_SavedViewGroupFragmentDoc,
"\n fragment ProjectCardImportFileArea_Project on Project {\n id\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...UseFileImport_Project\n }\n": types.ProjectCardImportFileArea_ProjectFragmentDoc,
@@ -731,7 +731,7 @@ const documents: Documents = {
"\n fragment ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\n }\n }\n workspace {\n id\n seatType\n planSupportsSavedViews: hasAccessToFeature(featureName: savedViews)\n }\n }\n": types.ViewerSavedViewsPanel_ProjectFragmentDoc,
"\n fragment ViewerSavedViewsPanelGroups_Project on Project {\n id\n savedViewGroups(input: $savedViewGroupsInput) {\n totalCount\n cursor\n items {\n id\n ...ViewerSavedViewsPanelViewsGroup_SavedViewGroup\n }\n }\n ...ViewerSavedViewsPanelViewsGroup_Project\n }\n": types.ViewerSavedViewsPanelGroups_ProjectFragmentDoc,
"\n query ViewerSavedViewsPanelGroups_SavedViewGroups(\n $projectId: String!\n $savedViewGroupsInput: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n ...ViewerSavedViewsPanelGroups_Project\n }\n }\n": types.ViewerSavedViewsPanelGroups_SavedViewGroupsDocument,
"\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n visibility\n isHomeView\n resourceIds\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_SavedView\n ...UseSavedViewValidationHelpers_SavedView\n ...UseDraggableView_SavedView\n }\n": types.ViewerSavedViewsPanelView_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n thumbnailUrl\n visibility\n isHomeView\n resourceIds\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_SavedView\n ...UseSavedViewValidationHelpers_SavedView\n ...UseDraggableView_SavedView\n }\n": types.ViewerSavedViewsPanelView_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewDeleteDialog_SavedView on SavedView {\n id\n name\n ...UseDeleteSavedView_SavedView\n }\n": types.ViewerSavedViewsPanelViewDeleteDialog_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewEditDialog_SavedView on SavedView {\n id\n name\n description\n visibility\n group {\n ...FormSelectSavedViewGroup_SavedViewGroup\n }\n ...UseUpdateSavedView_SavedView\n ...UseSavedViewValidationHelpers_SavedView\n }\n": types.ViewerSavedViewsPanelViewEditDialog_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewMoveDialog_SavedView on SavedView {\n id\n group {\n ...FormSelectSavedViewGroup_SavedViewGroup\n }\n ...UseUpdateSavedView_SavedView\n }\n": types.ViewerSavedViewsPanelViewMoveDialog_SavedViewFragmentDoc,
@@ -843,7 +843,7 @@ const documents: Documents = {
"\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n": types.NavigationWorkspaceInvitesDocument,
"\n mutation UpdatePresentationSlide($input: UpdateSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n updateView(input: $input) {\n id\n name\n description\n }\n }\n }\n }\n": types.UpdatePresentationSlideDocument,
"\n query PresentationAccessCheck($savedViewGroupId: ID!, $projectId: String!) {\n project(id: $projectId) {\n id\n savedViewGroup(id: $savedViewGroupId) {\n id\n }\n }\n }\n": types.PresentationAccessCheckDocument,
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n limitedWorkspace {\n id\n ...PresentationLeftSidebar_LimitedWorkspace\n ...PresentationLoading_LimitedWorkspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n ...PresentationPageWrapper_SavedViewGroup\n ...PresentationLoading_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n": types.ProjectPresentationPageDocument,
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n limitedWorkspace {\n id\n ...PresentationLeftSidebar_LimitedWorkspace\n ...PresentationLoading_LimitedWorkspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n ...PresentationPageWrapper_SavedViewGroup\n ...PresentationLoading_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n thumbnailUrl\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n": types.ProjectPresentationPageDocument,
"\n fragment UseCopyModelLink_Model on Model {\n id\n projectId\n ...GetModelItemRoute_Model\n }\n": types.UseCopyModelLink_ModelFragmentDoc,
"\n fragment UseCanCreatePersonalProject_User on User {\n permissions {\n canCreatePersonalProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseCanCreatePersonalProject_UserFragmentDoc,
"\n fragment UseCanCreateWorkspace_User on User {\n permissions {\n canCreateWorkspace {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseCanCreateWorkspace_UserFragmentDoc,
@@ -1365,11 +1365,11 @@ export function graphql(source: "\n mutation PresentationShareDisableToken($inp
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n screenshot\n }\n"): (typeof documents)["\n fragment PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n screenshot\n }\n"];
export function graphql(source: "\n fragment PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n thumbnailUrl\n }\n"): (typeof documents)["\n fragment PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n thumbnailUrl\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment PresentationSlideListSlide_SavedView on SavedView {\n id\n name\n screenshot\n }\n"): (typeof documents)["\n fragment PresentationSlideListSlide_SavedView on SavedView {\n id\n name\n screenshot\n }\n"];
export function graphql(source: "\n fragment PresentationSlideListSlide_SavedView on SavedView {\n id\n name\n thumbnailUrl\n }\n"): (typeof documents)["\n fragment PresentationSlideListSlide_SavedView on SavedView {\n id\n name\n thumbnailUrl\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1817,7 +1817,7 @@ export function graphql(source: "\n query ViewerSavedViewsPanelGroups_SavedView
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n visibility\n isHomeView\n resourceIds\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_SavedView\n ...UseSavedViewValidationHelpers_SavedView\n ...UseDraggableView_SavedView\n }\n"): (typeof documents)["\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n visibility\n isHomeView\n resourceIds\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_SavedView\n ...UseSavedViewValidationHelpers_SavedView\n ...UseDraggableView_SavedView\n }\n"];
export function graphql(source: "\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n thumbnailUrl\n visibility\n isHomeView\n resourceIds\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_SavedView\n ...UseSavedViewValidationHelpers_SavedView\n ...UseDraggableView_SavedView\n }\n"): (typeof documents)["\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n thumbnailUrl\n visibility\n isHomeView\n resourceIds\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_SavedView\n ...UseSavedViewValidationHelpers_SavedView\n ...UseDraggableView_SavedView\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2265,7 +2265,7 @@ export function graphql(source: "\n query PresentationAccessCheck($savedViewGro
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n limitedWorkspace {\n id\n ...PresentationLeftSidebar_LimitedWorkspace\n ...PresentationLoading_LimitedWorkspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n ...PresentationPageWrapper_SavedViewGroup\n ...PresentationLoading_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n limitedWorkspace {\n id\n ...PresentationLeftSidebar_LimitedWorkspace\n ...PresentationLoading_LimitedWorkspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n ...PresentationPageWrapper_SavedViewGroup\n ...PresentationLoading_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n"];
export function graphql(source: "\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n limitedWorkspace {\n id\n ...PresentationLeftSidebar_LimitedWorkspace\n ...PresentationLoading_LimitedWorkspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n ...PresentationPageWrapper_SavedViewGroup\n ...PresentationLoading_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n thumbnailUrl\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n limitedWorkspace {\n id\n ...PresentationLeftSidebar_LimitedWorkspace\n ...PresentationLoading_LimitedWorkspace\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationViewerPageWrapper_SavedViewGroup\n ...PresentationHeader_SavedViewGroup\n ...PresentationSlideList_SavedViewGroup\n ...PresentationPageWrapper_SavedViewGroup\n ...PresentationLoading_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n thumbnailUrl\n projectId\n visibility\n ...PresentationInfoSidebar_SavedView\n group {\n id\n }\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -38,7 +38,7 @@ export const projectPresentationPageQuery = graphql(`
id
name
description
screenshot
thumbnailUrl
projectId
visibility
...PresentationInfoSidebar_SavedView
@@ -39,9 +39,12 @@ type SavedView {
"""
viewerState: JSONObject!
"""
Encoded screenshot of the view
Encoded screenshot of the view. Can be a very large value, its preferred you
use the thumbnailUrl or previewUrl fields to load the image from a separate endpoint
"""
screenshot: String!
screenshot: String! @deprecated(reason: "Use thumbnailUrl or previewUrl instead")
thumbnailUrl: String!
previewUrl: String!
"""
For figuring out position in the group
"""
@@ -3691,13 +3691,19 @@ export type SavedView = {
permissions: SavedViewPermissionChecks;
/** For figuring out position in the group */
position: Scalars['Float']['output'];
previewUrl: Scalars['String']['output'];
projectId: Scalars['ID']['output'];
/** Original resource ID string that this view is associated with. */
resourceIdString: Scalars['String']['output'];
/** Same as resourceIdString, but split into an array of resource IDs. */
resourceIds: Array<Scalars['String']['output']>;
/** Encoded screenshot of the view */
/**
* Encoded screenshot of the view. Can be a very large value, its preferred you
* use the thumbnailUrl or previewUrl fields to load the image from a separate endpoint
* @deprecated Use thumbnailUrl or previewUrl instead
*/
screenshot: Scalars['String']['output'];
thumbnailUrl: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output'];
/** Viewer state, the actual view configuration */
viewerState: Scalars['JSONObject']['output'];
@@ -8274,10 +8280,12 @@ export type SavedViewResolvers<ContextType = GraphQLContext, ParentType extends
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
permissions?: Resolver<ResolversTypes['SavedViewPermissionChecks'], ParentType, ContextType>;
position?: Resolver<ResolversTypes['Float'], ParentType, ContextType>;
previewUrl?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
projectId?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
resourceIdString?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
resourceIds?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
screenshot?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
thumbnailUrl?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
viewerState?: Resolver<ResolversTypes['JSONObject'], ParentType, ContextType>;
visibility?: Resolver<ResolversTypes['SavedViewVisibility'], ParentType, ContextType>;
@@ -51,6 +51,7 @@ import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { throwIfResourceAccessNotAllowed } from '@/modules/core/helpers/token'
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
import { withOperationLogging } from '@/observability/domain/businessLogging'
import { getThumbnailUrl } from '@/modules/viewer/helpers/savedViews'
export default {
User: {
@@ -199,7 +200,10 @@ export default {
})
if (homeView) {
return homeView.screenshot
return getThumbnailUrl({
projectId: parent.streamId,
viewId: homeView.id
})
}
}
+16 -1
View File
@@ -20,7 +20,7 @@ import { moduleLogger } from '@/observability/logging'
import { addMocksToSchema } from '@graphql-tools/mock'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import type { Optional } from '@speckle/shared'
import { isNonNullable, TIME_MS } from '@speckle/shared'
import { Authz, isNonNullable, TIME_MS } from '@speckle/shared'
import type { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import type { Express } from 'express'
import type { RequestDataLoadersBuilder } from '@/modules/shared/helpers/graphqlHelper'
@@ -51,6 +51,7 @@ import type {
AuthCheckContextLoaders
} from '@speckle/shared/authz'
import { AuthCheckContextLoaderKeys } from '@speckle/shared/authz'
import type { AuthContext } from '@/modules/shared/authz'
/**
* Cached speckle module requires
@@ -462,3 +463,17 @@ export const moduleAuthLoaders = async (params: {
internalCache: cache
}
}
export const buildAuthPolicies = async (params: {
/**
* Undefined means - treat it as an anonymous req
*/
authContext?: AuthContext
}) => {
const dataLoaders: RequestDataLoaders | undefined = params.authContext
? await buildRequestLoaders(params.authContext)
: undefined
const authLoaders = await moduleAuthLoaders({ dataLoaders })
return Authz.authPoliciesFactory(authLoaders.loaders)
}
@@ -276,11 +276,11 @@ const dropReplicationIfExists = async ({
}
try {
const { rows: aivenExists } = await to.public.raw(
const { rows: aivenExists } = (await to.public.raw(
"SELECT * FROM pg_extension WHERE extname = 'aiven_extras';"
)
)) as { rows: Array<unknown> }
if (!aivenExists) return
if (!aivenExists.length) return
const {
rows: [sub]
+12 -9
View File
@@ -161,7 +161,7 @@ export const validateResourceAccess: AuthPipelineFunction = async ({
if (authHasFailed(authResult)) return { context, authResult }
if (!resourceAccessRules?.length) return authSuccess(context)
const streamId = context.stream?.id || params?.streamId
const streamId = context.stream?.id || params?.streamId || params?.projectId
if (!streamId) {
return authSuccess(context)
}
@@ -225,8 +225,9 @@ export const validateRequiredStreamFactory =
// IoC baby...
async ({ context, authResult, params }) => {
const { getStream } = deps
const streamId = params?.streamId || params?.projectId
if (!params?.streamId)
if (!streamId)
return authFailed(
context,
new ContextError("The context doesn't have a streamId")
@@ -240,7 +241,7 @@ export const validateRequiredStreamFactory =
// keep the pipeline rolling
try {
const stream = await getStream({
streamId: params.streamId,
streamId,
userId: context?.userId
})
@@ -250,12 +251,13 @@ export const validateRequiredStreamFactory =
new NotFoundError(
'Project ID is malformed and cannot be found, or the project does not exist',
{
info: { projectId: params.streamId }
info: { projectId: streamId }
}
),
true
)
context.stream = stream
context.project = stream
return { context, authResult }
} catch (err) {
// this prob needs some more detailing to not leak internal errors
@@ -328,8 +330,9 @@ const validateStreamPolicyAccessFactory =
const { context, params, authResult } = authData
if (authHasFailed(authResult)) return { context, authResult }
const streamId = params?.streamId || params?.projectId
if (!params?.streamId)
if (!streamId)
return authFailed(
context,
new ContextError("The context doesn't have a streamId")
@@ -348,7 +351,7 @@ const validateStreamPolicyAccessFactory =
new NotFoundError(
'Project ID is malformed and cannot be found, or the project does not exist',
{
info: { projectId: params.streamId }
info: { projectId: streamId }
}
),
true
@@ -369,7 +372,7 @@ export const streamWritePermissionsPipelineFactory = (deps: {
policyInvoker: async ({ authData, policies }) =>
policies.project.version.canCreate({
userId: authData.context.userId,
projectId: authData.params!.streamId!
projectId: authData.params!.streamId! || authData.params!.projectId!
})
})
]
@@ -385,7 +388,7 @@ export const streamCommentsWritePermissionsPipelineFactory = (deps: {
policyInvoker: async ({ authData, policies }) =>
policies.project.comment.canCreate({
userId: authData.context.userId,
projectId: authData.params!.streamId!
projectId: authData.params!.streamId! || authData.params!.projectId!
})
})
]
@@ -401,7 +404,7 @@ export const streamReadPermissionsPipelineFactory = (deps: {
policyInvoker: async ({ authData, policies }) =>
policies.project.canRead({
userId: authData.context.userId,
projectId: authData.params!.streamId!
projectId: authData.params!.streamId! || authData.params!.projectId!
})
})
]
@@ -11,6 +11,7 @@ export interface AuthContext {
token?: string
scopes?: string[]
stream?: StreamWithOptionalRole
project?: StreamWithOptionalRole
err?: Error | BaseError
/**
* Set if authenticated with an app token
@@ -28,6 +29,8 @@ export interface AuthResult {
export interface AuthParams {
streamId?: string
projectId?: string
viewId?: string
}
export interface AuthData {
@@ -4,8 +4,14 @@ import type {
SavedViewGroup,
SavedViewVisibility
} from '@/modules/viewer/domain/types/savedViews'
import type { MaybeNullOrUndefined, NullableKeysToOptional } from '@speckle/shared'
import type { StringEnumValues } from '@speckle/shared'
import {
StringEnum,
type MaybeNullOrUndefined,
type NullableKeysToOptional
} from '@speckle/shared'
import type { SerializedViewerState } from '@speckle/shared/viewer/state'
import type { Response } from 'express'
import type { Exact, SetOptional } from 'type-fest'
/////////////////////
@@ -307,3 +313,17 @@ export type UpdateSavedViewGroup = (params: {
}
userId: string
}) => Promise<SavedViewGroup>
export const SavedViewPreviewType = StringEnum(['preview', 'thumbnail'])
export type SavedViewPreviewType = StringEnumValues<typeof SavedViewPreviewType>
export type OutputSavedViewPreview = (params: {
res: Response
projectId: string
viewId: string
type: SavedViewPreviewType
}) => Promise<void>
export type DownscaleScreenshotForThumbnail = (params: {
screenshot: string
}) => Promise<string>
@@ -29,6 +29,7 @@ export type SavedView = {
visibility: SavedViewVisibility
viewerState: VersionedSerializedViewerState
screenshot: string
thumbnail: string
position: number
createdAt: Date
updatedAt: Date
@@ -47,3 +47,15 @@ export class SavedViewPositionUpdateError extends BaseError {
static defaultMessage = 'Failed to update saved view position'
static statusCode = 400
}
export class SavedViewPreviewRetrievalError extends BaseError {
static code = 'SAVED_VIEW_PREVIEW_RETRIEVAL_ERROR'
static defaultMessage = 'Could not retrieve saved view preview'
static statusCode = 400
}
export class SavedViewScreenshotError extends BaseError {
static code = 'SAVED_VIEW_SCREENSHOT_ERROR'
static defaultMessage = 'Could not process saved view screenshot'
static statusCode = 400
}
@@ -15,7 +15,11 @@ import { getStreamObjectsFactory } from '@/modules/core/repositories/objects'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { LogicError, NotFoundError, NotImplementedError } from '@/modules/shared/errors'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { buildDefaultGroupId } from '@/modules/viewer/helpers/savedViews'
import {
buildDefaultGroupId,
getPreviewUrl,
getThumbnailUrl
} from '@/modules/viewer/helpers/savedViews'
import {
deleteSavedViewGroupRecordFactory,
deleteSavedViewRecordFactory,
@@ -59,6 +63,7 @@ import {
} from '@/modules/viewer/repositories/dataLoaders/savedViews'
import type { RequestDataLoaders } from '@/modules/core/loaders'
import { omit } from 'lodash-es'
import { downscaleScreenshotForThumbnailFactory } from '@/modules/viewer/services/savedViewPreviews'
const buildGetViewerResourceGroups = (params: {
projectDb: Knex
@@ -238,6 +243,18 @@ const resolvers: Resolvers = {
}
return group
},
previewUrl(parent) {
return getPreviewUrl({
projectId: parent.projectId,
viewId: parent.id
})
},
thumbnailUrl(parent) {
return getThumbnailUrl({
projectId: parent.projectId,
viewId: parent.id
})
}
},
SavedViewGroup: {
@@ -316,7 +333,8 @@ const resolvers: Resolvers = {
getNewViewSpecificPosition: getNewViewSpecificPositionFactory({
db: projectDb
}),
rebalanceViewPositions: rebalancingViewPositionsFactory({ db: projectDb })
rebalanceViewPositions: rebalancingViewPositionsFactory({ db: projectDb }),
downscaleScreenshotForThumbnail: downscaleScreenshotForThumbnailFactory()
})
return await createSavedView({ input: args.input, authorId: ctx.userId! })
},
@@ -410,7 +428,8 @@ const resolvers: Resolvers = {
rebalanceViewPositions: rebalancingViewPositionsFactory({ db: projectDb }),
getNewViewSpecificPosition: getNewViewSpecificPositionFactory({
db: projectDb
})
}),
downscaleScreenshotForThumbnail: downscaleScreenshotForThumbnailFactory()
})
const updatedView = await updateSavedView({
@@ -1,3 +1,4 @@
import { getServerOrigin } from '@/modules/shared/helpers/envHelper'
import {
formatResourceIdsForGroup,
buildDefaultGroupId,
@@ -5,5 +6,24 @@ import {
type DefaultGroupMetadata
} from '@speckle/shared/saved-views'
export const thumbnailRoute =
'/api/v1/projects/:projectId/saved-views/:viewId/thumbnail'
export const fullPreviewRoute =
'/api/v1/projects/:projectId/saved-views/:viewId/preview'
export const getThumbnailUrl = (params: { projectId: string; viewId: string }) => {
const route = thumbnailRoute
.replace(':projectId', params.projectId)
.replace(':viewId', params.viewId)
return new URL(route, getServerOrigin()).toString()
}
export const getPreviewUrl = (params: { projectId: string; viewId: string }) => {
const route = fullPreviewRoute
.replace(':projectId', params.projectId)
.replace(':viewId', params.viewId)
return new URL(route, getServerOrigin()).toString()
}
export { formatResourceIdsForGroup, buildDefaultGroupId, decodeDefaultGroupId }
export type { DefaultGroupMetadata }
+4 -1
View File
@@ -1,11 +1,14 @@
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import type { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import { getSavedViewsRouter } from '@/modules/viewer/rest/savedViews'
import { viewerLogger } from '@/observability/logging'
const viewerModule: SpeckleModule = {
init: async () => {
init: async ({ app }) => {
if (!getFeatureFlags().FF_SAVED_VIEWS_ENABLED) return
viewerLogger.info('🤩 Initializing viewer module...')
app.use(getSavedViewsRouter())
}
}
@@ -0,0 +1,19 @@
import type { Knex } from 'knex'
const tableName = 'saved_views'
const col = 'thumbnail'
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable(tableName, (table) => {
table.text(col).defaultTo('')
})
// Update all existing rows to copy screenshot -> thumbnail
await knex.raw(`UPDATE ${tableName} SET ${col} = screenshot WHERE ${col} = ''`)
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable(tableName, (table) => {
table.dropColumn(col)
})
}
@@ -69,6 +69,7 @@ export const SavedViews = buildTableHelper('saved_views', [
'visibility',
'viewerState',
'screenshot',
'thumbnail',
'position',
'createdAt',
'updatedAt'
@@ -0,0 +1,95 @@
import type { ErrorRequestHandler, Request, Response } from 'express'
import { Router } from 'express'
import cors from 'cors'
import { allowCrossOriginResourceAccessMiddelware } from '@/modules/shared/middleware/security'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { outputSavedViewPreviewFactory } from '@/modules/viewer/services/savedViewPreviews'
import { getSavedViewFactory } from '@/modules/viewer/repositories/savedViews'
import { SavedViewPreviewType } from '@/modules/viewer/domain/operations/savedViews'
import { ensureError } from '@speckle/shared'
import { resolveStatusCode } from '@/modules/core/rest/defaultErrorHandler'
import { fileURLToPath } from 'node:url'
import { buildAuthPolicies } from '@/modules'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { StreamNotFoundError } from '@/modules/core/errors/stream'
import { NotFoundError } from '@/modules/shared/errors'
import { fullPreviewRoute, thumbnailRoute } from '@/modules/viewer/helpers/savedViews'
const previewErrorPath = () =>
fileURLToPath(import.meta.resolve('#/assets/previews/images/preview_error.png'))
const preview404Path = () =>
fileURLToPath(import.meta.resolve('#/assets/previews/images/preview_404.png'))
const preview401Path = () =>
fileURLToPath(import.meta.resolve('#/assets/previews/images/preview_401.png'))
const previewErrHandler: ErrorRequestHandler = (err, req, res, next) => {
if (!err) return next()
// Return failure image, instead of throwing
const error = ensureError(err)
const status = resolveStatusCode(error)
res.header('X-Error-Message', error.message)
res.header('Cache-Control', 'no-cache, no-store')
res.status(status)
if (error instanceof StreamNotFoundError || error instanceof NotFoundError) {
return res.sendFile(preview404Path())
} else if (status === 401) {
return res.sendFile(preview401Path())
} else {
return res.sendFile(previewErrorPath())
}
}
const buildPreviewRoute = (
router: Router,
type: SavedViewPreviewType,
route: string
) => {
router.options(route, cors(), allowCrossOriginResourceAccessMiddelware())
router.get(
route,
cors(),
allowCrossOriginResourceAccessMiddelware(),
async (req: Request, res: Response) => {
const projectId = req.params.projectId
const viewId = req.params.viewId
// Access check
const authz = await buildAuthPolicies({
authContext: req.context
})
const authResults = await Promise.all([
authz.project.canRead({
userId: req.context.userId,
projectId
}),
authz.project.savedViews.canRead({
userId: req.context.userId,
projectId,
savedViewId: viewId,
allowNonExistent: true // we check inside the service layer anyway
})
])
authResults.forEach(throwIfAuthNotOk)
// Access is fine - look for the view
const projectDb = await getProjectDbClient({ projectId })
const outputSavedViewPreview = outputSavedViewPreviewFactory({
getSavedView: getSavedViewFactory({ db: projectDb })
})
await outputSavedViewPreview({ res, projectId, viewId, type })
},
previewErrHandler
)
}
export const getSavedViewsRouter = (): Router => {
const router = Router()
buildPreviewRoute(router, SavedViewPreviewType.thumbnail, thumbnailRoute)
buildPreviewRoute(router, SavedViewPreviewType.preview, fullPreviewRoute)
return router
}
@@ -0,0 +1,73 @@
import {
SavedViewPreviewType,
type DownscaleScreenshotForThumbnail,
type GetSavedView,
type OutputSavedViewPreview
} from '@/modules/viewer/domain/operations/savedViews'
import { SavedViewPreviewRetrievalError } from '@/modules/viewer/errors/savedViews'
import sharp from 'sharp'
const THUMBNAIL_WIDTH = 420
const THUMBNAIL_HEIGHT = 240
const screenshotToBuffer = (screenshot: string) => {
// no `data:image/png;base64,` prefix
const preview = screenshot.replace(/^data:image\/png;base64,/, '')
return Buffer.from(preview, 'base64')
}
export const outputSavedViewPreviewFactory =
(deps: { getSavedView: GetSavedView }): OutputSavedViewPreview =>
async (params) => {
const { res, projectId, viewId, type } = params
const view = await deps.getSavedView({ projectId, id: viewId })
if (!view) {
throw new SavedViewPreviewRetrievalError('Could not find view', {
info: { projectId, viewId }
})
}
// both should be set, but early on in development we only had the one
const image =
(type === SavedViewPreviewType.preview ? view.screenshot : view.thumbnail) ||
view.screenshot
const imgBuffer = screenshotToBuffer(image)
res.writeHead(200, {
'Content-Type': 'image/png',
'Content-Length': imgBuffer.length,
'Cache-Control': 'no-cache, no-store'
})
res.end(imgBuffer)
}
export const downscaleScreenshotForThumbnailFactory =
(): DownscaleScreenshotForThumbnail => async (params: { screenshot: string }) => {
const { screenshot } = params
const imgBuffer = screenshotToBuffer(screenshot)
// Use sharp to get metadata
const image = sharp(imgBuffer)
const meta = await image.metadata()
const { width: srcW, height: srcH } = meta
// If source is already smaller or equal in both dimensions, do nothing
if (srcW <= THUMBNAIL_WIDTH && srcH <= THUMBNAIL_HEIGHT) {
return screenshot
}
// Otherwise, resize (downscale). Use withoutEnlargement to guard.
const outBuf = await image
.resize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, {
fit: 'inside', // ensures we maintain aspect ratio and fit *within* box
withoutEnlargement: true
})
// Optionally, set output format / quality depending on mimeType
.toFormat(meta.format || 'png', { quality: 100 })
.toBuffer()
// Convert back to base64 with prefix
const outB64 = outBuf.toString('base64')
const prefix = `data:image/png;base64,`
return `${prefix}${outB64}`
}
@@ -5,6 +5,7 @@ import type {
DeleteSavedViewGroup,
DeleteSavedViewGroupRecord,
DeleteSavedViewRecord,
DownscaleScreenshotForThumbnail,
GetGroupSavedViews,
GetGroupSavedViewsPageItems,
GetGroupSavedViewsTotalCount,
@@ -34,6 +35,7 @@ import {
SavedViewGroupUpdateValidationError,
SavedViewInvalidHomeViewSettingsError,
SavedViewInvalidResourceTargetError,
SavedViewScreenshotError,
SavedViewUpdateValidationError
} from '@/modules/viewer/errors/savedViews'
import type {
@@ -53,6 +55,25 @@ import { removeNullOrUndefinedKeys, firstDefinedValue } from '@speckle/shared'
import { isUngroupedGroup } from '@speckle/shared/saved-views'
import { NotFoundError } from '@/modules/shared/errors'
const formatIncomingScreenshotFactory =
(deps: { downscaleScreenshotForThumbnail: DownscaleScreenshotForThumbnail }) =>
async (params: { screenshot: string; errorMetadata: Record<string, unknown> }) => {
const screenshot = params.screenshot.trim()
if (!isValidBase64Image(screenshot)) {
throw new SavedViewScreenshotError(
'Invalid screenshot provided. Must be a valid base64 encoded image.',
{
info: params.errorMetadata
}
)
}
return {
screenshot,
thumbnail: await deps.downscaleScreenshotForThumbnail({ screenshot })
}
}
/**
* Validates an incoming resourceIdString against the resources in the project and returns the validated list (as a builder)
*/
@@ -231,6 +252,7 @@ export const createSavedViewFactory =
setNewHomeView: SetNewHomeView
getNewViewSpecificPosition: GetNewViewSpecificPosition
rebalanceViewPositions: RebalanceViewPositions
downscaleScreenshotForThumbnail: DownscaleScreenshotForThumbnail
}): CreateSavedView =>
async ({ input, authorId }) => {
const { resourceIdString, projectId, position: positionInput } = input
@@ -249,18 +271,10 @@ export const createSavedViewFactory =
}
})
const screenshot = input.screenshot.trim()
if (!isValidBase64Image(screenshot)) {
throw new SavedViewCreationValidationError(
'Invalid screenshot provided. Must be a valid base64 encoded image.',
{
info: {
input,
authorId
}
}
)
}
const { screenshot, thumbnail } = await formatIncomingScreenshotFactory(deps)({
screenshot: input.screenshot,
errorMetadata: { input, authorId }
})
// Validate state
const state = validateViewerStateFactory()({
@@ -333,6 +347,7 @@ export const createSavedViewFactory =
description,
viewerState: state,
screenshot,
thumbnail,
visibility,
position,
authorId,
@@ -494,6 +509,7 @@ export const updateSavedViewFactory =
setNewHomeView: SetNewHomeView
getNewViewSpecificPosition: GetNewViewSpecificPosition
rebalanceViewPositions: RebalanceViewPositions
downscaleScreenshotForThumbnail: DownscaleScreenshotForThumbnail
} & DependenciesOf<typeof validateProjectResourceIdStringFactory>
): UpdateSavedView =>
async (params) => {
@@ -521,7 +537,7 @@ export const updateSavedViewFactory =
const hasResourceIdString = 'resourceIdString' in input && input.resourceIdString
const hasViewerState = 'viewerState' in input && input.viewerState
const hasScreenshot = 'screenshot' in input && input.screenshot
if (hasResourceIdString || hasViewerState) {
if (hasResourceIdString || hasViewerState || hasScreenshot) {
if (!hasResourceIdString || !hasViewerState || !hasScreenshot) {
throw new SavedViewUpdateValidationError(
'If the resourceIdString or viewerState are being updated, resourceIdString, viewerState and screenshot must all be submitted.',
@@ -586,17 +602,16 @@ export const updateSavedViewFactory =
delete changes.groupId // the key shouldnt even be there
}
// Validate screenshot
if (changes.screenshot && !isValidBase64Image(changes.screenshot)) {
throw new SavedViewUpdateValidationError(
'Invalid screenshot provided. Must be a valid base64 encoded image.',
{
info: {
input,
userId
}
}
)
// Format screenshot
let newScreenshot: { screenshot: string; thumbnail: string } | undefined = undefined
if (changes.screenshot) {
const { screenshot, thumbnail } = await formatIncomingScreenshotFactory(deps)({
screenshot: changes.screenshot,
errorMetadata: { input, userId }
})
newScreenshot = { screenshot, thumbnail }
delete changes['screenshot']
}
// Validate name
@@ -659,7 +674,8 @@ export const updateSavedViewFactory =
viewerState
}
: {}),
...(!isUndefined(position) ? { position } : {})
...(!isUndefined(position) ? { position } : {}),
...(newScreenshot ? newScreenshot : {})
}
// Check if there's any actual changes
@@ -26,6 +26,7 @@ import {
setNewHomeViewFactory,
storeSavedViewFactory
} from '@/modules/viewer/repositories/savedViews'
import { downscaleScreenshotForThumbnailFactory } from '@/modules/viewer/services/savedViewPreviews'
import { createSavedViewFactory } from '@/modules/viewer/services/savedViewsManagement'
import { getViewerResourceGroupsFactory } from '@/modules/viewer/services/viewerResources'
import type { BasicTestUser } from '@/test/authHelper'
@@ -40,7 +41,7 @@ export const fakeScreenshot =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PiQ2YQAAAABJRU5ErkJggg=='
export const fakeScreenshot2 =
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAICAgICAgICAgICAgICAwUDAwMDAwYEBAMFBQYGBQYGBwcICQoJCQkJCQoMCgsMDAwMDAwP/2wBDAwMDAwQDBAgEBAgQEBAgMCAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgP/wAARCAABAAEDAREAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAHEAP/EABQQAQAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAQUCf//EABQRAQAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8BP//EABQRAQAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8BP//Z'
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAQ0lEQVR4nGLW+WrL4H6p2dAx/J4S05cr7eGufmJpqo8vsDGlf7q0bGK4Nu88wc+rGb79lZDwi7y6X3x15VpAAAAA//85FRbiEsMfqwAAAABJRU5ErkJggg=='
export const buildFakeSerializedViewerState = (
overrides?: PartialDeep<ViewerState.SerializedViewerState>
@@ -93,6 +94,7 @@ export const buildTestSavedView = (overrides?: Partial<SavedView>): SavedView =>
})
),
screenshot: fakeScreenshot,
thumbnail: fakeScreenshot,
position: 0,
createdAt: new Date(Date.now() - 10000),
updatedAt: new Date(Date.now() - 10000)
@@ -139,7 +141,8 @@ export const createTestSavedView = async (params?: {
getNewViewSpecificPosition: getNewViewSpecificPositionFactory({
db
}),
rebalanceViewPositions: rebalancingViewPositionsFactory({ db })
rebalanceViewPositions: rebalancingViewPositionsFactory({ db }),
downscaleScreenshotForThumbnail: downscaleScreenshotForThumbnailFactory()
})
const createdView = await createSavedView({
@@ -45,12 +45,12 @@ import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import type { FactoryResultOf } from '@/modules/shared/helpers/factory'
import { SavedViewVisibility } from '@/modules/viewer/domain/types/savedViews'
import {
SavedViewCreationValidationError,
SavedViewGroupCreationValidationError,
SavedViewGroupNotFoundError,
SavedViewGroupUpdateValidationError,
SavedViewInvalidHomeViewSettingsError,
SavedViewInvalidResourceTargetError,
SavedViewScreenshotError,
SavedViewUpdateValidationError
} from '@/modules/viewer/errors/savedViews'
import {
@@ -748,7 +748,7 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
)
expect(res).to.haveGraphQLErrors({
code: SavedViewCreationValidationError.code
code: SavedViewScreenshotError.code
})
expect(res.data?.projectMutations.savedViewMutations.createView).to.not.be.ok
})
@@ -1896,7 +1896,7 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
input: {
id: testView.id,
projectId: updatablesProject.id,
screenshot: 'invalid'
screenshot: fakeScreenshot2
}
})
expect(res).to.haveGraphQLErrors({ code: SavedViewUpdateValidationError.code })
@@ -1922,10 +1922,19 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
id: testView.id,
projectId: updatablesProject.id,
screenshot: 'not-base64',
name: 'x'
name: 'x',
resourceIdString: models[0].id,
viewerState: fakeViewerState({
projectId: updatablesProject.id,
resources: {
request: {
resourceIdString: models[0].id
}
}
})
}
})
expect(res).to.haveGraphQLErrors({ code: SavedViewUpdateValidationError.code })
expect(res).to.haveGraphQLErrors({ code: SavedViewScreenshotError.code })
expect(res.data?.projectMutations.savedViewMutations.updateView).to.not.be.ok
})
+1 -1
View File
@@ -128,7 +128,7 @@
"rate-limiter-flexible": "^2.4.1",
"response-time": "^2.3.2",
"sanitize-html": "^2.7.1",
"sharp": "^0.34.3",
"sharp": "^0.34.4",
"string-pixel-width": "^1.10.0",
"stripe": "^17.1.0",
"subscriptions-transport-ws": "^0.11.0",
+130 -107
View File
@@ -4288,7 +4288,7 @@ __metadata:
languageName: node
linkType: hard
"@emnapi/runtime@npm:^1.4.4, @emnapi/runtime@npm:^1.4.5":
"@emnapi/runtime@npm:^1.4.5":
version: 1.4.5
resolution: "@emnapi/runtime@npm:1.4.5"
dependencies:
@@ -4297,6 +4297,15 @@ __metadata:
languageName: node
linkType: hard
"@emnapi/runtime@npm:^1.5.0":
version: 1.5.0
resolution: "@emnapi/runtime@npm:1.5.0"
dependencies:
tslib: "npm:^2.4.0"
checksum: 10/5311ce854306babc77f4bd94c2f973722714a0fab93c126239104ad52dea16a147bfed4c4cff3ca1eb32709607221c25d2f747ae8524cbeb9088058f02ff962b
languageName: node
linkType: hard
"@emnapi/wasi-threads@npm:1.0.4":
version: 1.0.4
resolution: "@emnapi/wasi-threads@npm:1.0.4"
@@ -6722,11 +6731,18 @@ __metadata:
languageName: node
linkType: hard
"@img/sharp-darwin-arm64@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-darwin-arm64@npm:0.34.3"
"@img/colour@npm:^1.0.0":
version: 1.0.0
resolution: "@img/colour@npm:1.0.0"
checksum: 10/bd248d7c4b8ba99a72b22a005a63f1d3309ee8343a74b6d0d1314bae300a3096919991a09e9a9243cf6ca50e393b4c5a7e065488ed616c3b58d052473240b812
languageName: node
linkType: hard
"@img/sharp-darwin-arm64@npm:0.34.4":
version: 0.34.4
resolution: "@img/sharp-darwin-arm64@npm:0.34.4"
dependencies:
"@img/sharp-libvips-darwin-arm64": "npm:1.2.0"
"@img/sharp-libvips-darwin-arm64": "npm:1.2.3"
dependenciesMeta:
"@img/sharp-libvips-darwin-arm64":
optional: true
@@ -6734,11 +6750,11 @@ __metadata:
languageName: node
linkType: hard
"@img/sharp-darwin-x64@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-darwin-x64@npm:0.34.3"
"@img/sharp-darwin-x64@npm:0.34.4":
version: 0.34.4
resolution: "@img/sharp-darwin-x64@npm:0.34.4"
dependencies:
"@img/sharp-libvips-darwin-x64": "npm:1.2.0"
"@img/sharp-libvips-darwin-x64": "npm:1.2.3"
dependenciesMeta:
"@img/sharp-libvips-darwin-x64":
optional: true
@@ -6746,74 +6762,74 @@ __metadata:
languageName: node
linkType: hard
"@img/sharp-libvips-darwin-arm64@npm:1.2.0":
version: 1.2.0
resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.0"
"@img/sharp-libvips-darwin-arm64@npm:1.2.3":
version: 1.2.3
resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.3"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@img/sharp-libvips-darwin-x64@npm:1.2.0":
version: 1.2.0
resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.0"
"@img/sharp-libvips-darwin-x64@npm:1.2.3":
version: 1.2.3
resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.3"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@img/sharp-libvips-linux-arm64@npm:1.2.0":
version: 1.2.0
resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.0"
"@img/sharp-libvips-linux-arm64@npm:1.2.3":
version: 1.2.3
resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.3"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@img/sharp-libvips-linux-arm@npm:1.2.0":
version: 1.2.0
resolution: "@img/sharp-libvips-linux-arm@npm:1.2.0"
"@img/sharp-libvips-linux-arm@npm:1.2.3":
version: 1.2.3
resolution: "@img/sharp-libvips-linux-arm@npm:1.2.3"
conditions: os=linux & cpu=arm & libc=glibc
languageName: node
linkType: hard
"@img/sharp-libvips-linux-ppc64@npm:1.2.0":
version: 1.2.0
resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.0"
"@img/sharp-libvips-linux-ppc64@npm:1.2.3":
version: 1.2.3
resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.3"
conditions: os=linux & cpu=ppc64 & libc=glibc
languageName: node
linkType: hard
"@img/sharp-libvips-linux-s390x@npm:1.2.0":
version: 1.2.0
resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.0"
"@img/sharp-libvips-linux-s390x@npm:1.2.3":
version: 1.2.3
resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.3"
conditions: os=linux & cpu=s390x & libc=glibc
languageName: node
linkType: hard
"@img/sharp-libvips-linux-x64@npm:1.2.0":
version: 1.2.0
resolution: "@img/sharp-libvips-linux-x64@npm:1.2.0"
"@img/sharp-libvips-linux-x64@npm:1.2.3":
version: 1.2.3
resolution: "@img/sharp-libvips-linux-x64@npm:1.2.3"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@img/sharp-libvips-linuxmusl-arm64@npm:1.2.0":
version: 1.2.0
resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.0"
"@img/sharp-libvips-linuxmusl-arm64@npm:1.2.3":
version: 1.2.3
resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.3"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@img/sharp-libvips-linuxmusl-x64@npm:1.2.0":
version: 1.2.0
resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.0"
"@img/sharp-libvips-linuxmusl-x64@npm:1.2.3":
version: 1.2.3
resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.3"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@img/sharp-linux-arm64@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-linux-arm64@npm:0.34.3"
"@img/sharp-linux-arm64@npm:0.34.4":
version: 0.34.4
resolution: "@img/sharp-linux-arm64@npm:0.34.4"
dependencies:
"@img/sharp-libvips-linux-arm64": "npm:1.2.0"
"@img/sharp-libvips-linux-arm64": "npm:1.2.3"
dependenciesMeta:
"@img/sharp-libvips-linux-arm64":
optional: true
@@ -6821,11 +6837,11 @@ __metadata:
languageName: node
linkType: hard
"@img/sharp-linux-arm@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-linux-arm@npm:0.34.3"
"@img/sharp-linux-arm@npm:0.34.4":
version: 0.34.4
resolution: "@img/sharp-linux-arm@npm:0.34.4"
dependencies:
"@img/sharp-libvips-linux-arm": "npm:1.2.0"
"@img/sharp-libvips-linux-arm": "npm:1.2.3"
dependenciesMeta:
"@img/sharp-libvips-linux-arm":
optional: true
@@ -6833,11 +6849,11 @@ __metadata:
languageName: node
linkType: hard
"@img/sharp-linux-ppc64@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-linux-ppc64@npm:0.34.3"
"@img/sharp-linux-ppc64@npm:0.34.4":
version: 0.34.4
resolution: "@img/sharp-linux-ppc64@npm:0.34.4"
dependencies:
"@img/sharp-libvips-linux-ppc64": "npm:1.2.0"
"@img/sharp-libvips-linux-ppc64": "npm:1.2.3"
dependenciesMeta:
"@img/sharp-libvips-linux-ppc64":
optional: true
@@ -6845,11 +6861,11 @@ __metadata:
languageName: node
linkType: hard
"@img/sharp-linux-s390x@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-linux-s390x@npm:0.34.3"
"@img/sharp-linux-s390x@npm:0.34.4":
version: 0.34.4
resolution: "@img/sharp-linux-s390x@npm:0.34.4"
dependencies:
"@img/sharp-libvips-linux-s390x": "npm:1.2.0"
"@img/sharp-libvips-linux-s390x": "npm:1.2.3"
dependenciesMeta:
"@img/sharp-libvips-linux-s390x":
optional: true
@@ -6857,11 +6873,11 @@ __metadata:
languageName: node
linkType: hard
"@img/sharp-linux-x64@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-linux-x64@npm:0.34.3"
"@img/sharp-linux-x64@npm:0.34.4":
version: 0.34.4
resolution: "@img/sharp-linux-x64@npm:0.34.4"
dependencies:
"@img/sharp-libvips-linux-x64": "npm:1.2.0"
"@img/sharp-libvips-linux-x64": "npm:1.2.3"
dependenciesMeta:
"@img/sharp-libvips-linux-x64":
optional: true
@@ -6869,11 +6885,11 @@ __metadata:
languageName: node
linkType: hard
"@img/sharp-linuxmusl-arm64@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.3"
"@img/sharp-linuxmusl-arm64@npm:0.34.4":
version: 0.34.4
resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.4"
dependencies:
"@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.0"
"@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.3"
dependenciesMeta:
"@img/sharp-libvips-linuxmusl-arm64":
optional: true
@@ -6881,11 +6897,11 @@ __metadata:
languageName: node
linkType: hard
"@img/sharp-linuxmusl-x64@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-linuxmusl-x64@npm:0.34.3"
"@img/sharp-linuxmusl-x64@npm:0.34.4":
version: 0.34.4
resolution: "@img/sharp-linuxmusl-x64@npm:0.34.4"
dependencies:
"@img/sharp-libvips-linuxmusl-x64": "npm:1.2.0"
"@img/sharp-libvips-linuxmusl-x64": "npm:1.2.3"
dependenciesMeta:
"@img/sharp-libvips-linuxmusl-x64":
optional: true
@@ -6893,32 +6909,32 @@ __metadata:
languageName: node
linkType: hard
"@img/sharp-wasm32@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-wasm32@npm:0.34.3"
"@img/sharp-wasm32@npm:0.34.4":
version: 0.34.4
resolution: "@img/sharp-wasm32@npm:0.34.4"
dependencies:
"@emnapi/runtime": "npm:^1.4.4"
"@emnapi/runtime": "npm:^1.5.0"
conditions: cpu=wasm32
languageName: node
linkType: hard
"@img/sharp-win32-arm64@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-win32-arm64@npm:0.34.3"
"@img/sharp-win32-arm64@npm:0.34.4":
version: 0.34.4
resolution: "@img/sharp-win32-arm64@npm:0.34.4"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@img/sharp-win32-ia32@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-win32-ia32@npm:0.34.3"
"@img/sharp-win32-ia32@npm:0.34.4":
version: 0.34.4
resolution: "@img/sharp-win32-ia32@npm:0.34.4"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@img/sharp-win32-x64@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-win32-x64@npm:0.34.3"
"@img/sharp-win32-x64@npm:0.34.4":
version: 0.34.4
resolution: "@img/sharp-win32-x64@npm:0.34.4"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -11382,7 +11398,7 @@ __metadata:
response-time: "npm:^2.3.2"
rimraf: "npm:^5.0.7"
sanitize-html: "npm:^2.7.1"
sharp: "npm:^0.34.3"
sharp: "npm:^0.34.4"
string-pixel-width: "npm:^1.10.0"
stripe: "npm:^17.1.0"
subscriptions-transport-ws: "npm:^0.11.0"
@@ -20098,13 +20114,20 @@ __metadata:
languageName: node
linkType: hard
"detect-libc@npm:^2.0.0, detect-libc@npm:^2.0.2, detect-libc@npm:^2.0.4":
"detect-libc@npm:^2.0.0, detect-libc@npm:^2.0.2":
version: 2.0.4
resolution: "detect-libc@npm:2.0.4"
checksum: 10/136e995f8c5ffbc515955b0175d441b967defd3d5f2268e89fa695e9c7170d8bed17993e31a34b04f0fad33d844a3a598e0fd519a8e9be3cad5f67662d96fee0
languageName: node
linkType: hard
"detect-libc@npm:^2.1.0":
version: 2.1.1
resolution: "detect-libc@npm:2.1.1"
checksum: 10/23244632be44caa726f68f0b257f58d1fd86a60918674737bca9acf40d6509a919c60252998256c81e73d4a8350f0a53eef8a4eef538f80e3906986fb61a64eb
languageName: node
linkType: hard
"detect-newline@npm:^3.0.0":
version: 3.1.0
resolution: "detect-newline@npm:3.1.0"
@@ -35436,34 +35459,34 @@ __metadata:
languageName: node
linkType: hard
"sharp@npm:^0.34.3":
version: 0.34.3
resolution: "sharp@npm:0.34.3"
"sharp@npm:^0.34.4":
version: 0.34.4
resolution: "sharp@npm:0.34.4"
dependencies:
"@img/sharp-darwin-arm64": "npm:0.34.3"
"@img/sharp-darwin-x64": "npm:0.34.3"
"@img/sharp-libvips-darwin-arm64": "npm:1.2.0"
"@img/sharp-libvips-darwin-x64": "npm:1.2.0"
"@img/sharp-libvips-linux-arm": "npm:1.2.0"
"@img/sharp-libvips-linux-arm64": "npm:1.2.0"
"@img/sharp-libvips-linux-ppc64": "npm:1.2.0"
"@img/sharp-libvips-linux-s390x": "npm:1.2.0"
"@img/sharp-libvips-linux-x64": "npm:1.2.0"
"@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.0"
"@img/sharp-libvips-linuxmusl-x64": "npm:1.2.0"
"@img/sharp-linux-arm": "npm:0.34.3"
"@img/sharp-linux-arm64": "npm:0.34.3"
"@img/sharp-linux-ppc64": "npm:0.34.3"
"@img/sharp-linux-s390x": "npm:0.34.3"
"@img/sharp-linux-x64": "npm:0.34.3"
"@img/sharp-linuxmusl-arm64": "npm:0.34.3"
"@img/sharp-linuxmusl-x64": "npm:0.34.3"
"@img/sharp-wasm32": "npm:0.34.3"
"@img/sharp-win32-arm64": "npm:0.34.3"
"@img/sharp-win32-ia32": "npm:0.34.3"
"@img/sharp-win32-x64": "npm:0.34.3"
color: "npm:^4.2.3"
detect-libc: "npm:^2.0.4"
"@img/colour": "npm:^1.0.0"
"@img/sharp-darwin-arm64": "npm:0.34.4"
"@img/sharp-darwin-x64": "npm:0.34.4"
"@img/sharp-libvips-darwin-arm64": "npm:1.2.3"
"@img/sharp-libvips-darwin-x64": "npm:1.2.3"
"@img/sharp-libvips-linux-arm": "npm:1.2.3"
"@img/sharp-libvips-linux-arm64": "npm:1.2.3"
"@img/sharp-libvips-linux-ppc64": "npm:1.2.3"
"@img/sharp-libvips-linux-s390x": "npm:1.2.3"
"@img/sharp-libvips-linux-x64": "npm:1.2.3"
"@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.3"
"@img/sharp-libvips-linuxmusl-x64": "npm:1.2.3"
"@img/sharp-linux-arm": "npm:0.34.4"
"@img/sharp-linux-arm64": "npm:0.34.4"
"@img/sharp-linux-ppc64": "npm:0.34.4"
"@img/sharp-linux-s390x": "npm:0.34.4"
"@img/sharp-linux-x64": "npm:0.34.4"
"@img/sharp-linuxmusl-arm64": "npm:0.34.4"
"@img/sharp-linuxmusl-x64": "npm:0.34.4"
"@img/sharp-wasm32": "npm:0.34.4"
"@img/sharp-win32-arm64": "npm:0.34.4"
"@img/sharp-win32-ia32": "npm:0.34.4"
"@img/sharp-win32-x64": "npm:0.34.4"
detect-libc: "npm:^2.1.0"
semver: "npm:^7.7.2"
dependenciesMeta:
"@img/sharp-darwin-arm64":
@@ -35510,7 +35533,7 @@ __metadata:
optional: true
"@img/sharp-win32-x64":
optional: true
checksum: 10/b8ca871c99b48601c47f5dfabf32e38e60071a93e359b3c765d398f708a7cf3735d1bd804b72a957246a3b215fd281a17f887d9c36ebfa690c90fa5fe142d2cd
checksum: 10/8e6268e3b0fba7704291684e63c2829963a5ec311d8a8ebbcd32d750c4efb0b01594d925d289ccb5ac0ac373df40fedf5a05a8f331470db799b9c78c48923cba
languageName: node
linkType: hard