diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts new file mode 100644 index 00000000000..7eae6a91db5 --- /dev/null +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts @@ -0,0 +1,442 @@ +import { + extractDraftIdFromEntityId, + extractEntityUuidFromEntityId, + extractWebIdFromEntityId, +} from "@blockprotocol/type-system"; +import { getWebMachineId } from "@local/hash-backend-utils/machine-actors"; +import { queryEntities } from "@local/hash-graph-sdk/entity"; +import { queryPolicies } from "@local/hash-graph-sdk/policy"; +import { generateUuid } from "@local/hash-isomorphic-utils/generate-uuid"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; +import { systemLinkEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; + +import { createLinkEntity } from "../../../knowledge/primitive/link-entity"; +import { getExistingUsersAndOrgs, queryAllEntityPages } from "../util"; + +import type { ImpureGraphContext } from "../../../context-types"; +import type { MigrationFunction } from "../types"; +import type { + ActorEntityUuid, + EntityId, + EntityUuid, + WebId, +} from "@blockprotocol/type-system"; +import type { AllFilter, Filter } from "@local/hash-graph-client"; +import type { AuthenticationContext } from "@local/hash-graph-sdk/authentication-context"; +import type { + CreateEntityParameters, + HashEntity, +} from "@local/hash-graph-sdk/entity"; + +const hasAvatarBaseUrl = systemLinkEntityTypes.hasAvatar.linkEntityTypeBaseUrl; + +const publicViewPolicyName = (entityUuid: EntityUuid) => + `public-view-entity-${entityUuid}`; + +type AvatarLink = { + linkEntity: HashEntity; + linkEntityId: EntityId; + leftEntityId: EntityId; + rightEntityId: EntityId; + webId: WebId; + webBotAuthentication: AuthenticationContext; + createdAt: number; +}; + +const hasAvatarLinkFilter = (webId: WebId): AllFilter["all"] => [ + { + equal: [{ path: ["type", "baseUrl"] }, { parameter: hasAvatarBaseUrl }], + }, + { equal: [{ path: ["webId"] }, { parameter: webId }] }, + { equal: [{ path: ["archived"] }, { parameter: false }] }, +]; + +const rightEntityFilter = (rightEntityId: EntityId): AllFilter["all"] => [ + { + equal: [ + { path: ["rightEntity", "uuid"] }, + { parameter: extractEntityUuidFromEntityId(rightEntityId) }, + ], + }, + { + equal: [ + { path: ["rightEntity", "webId"] }, + { parameter: extractWebIdFromEntityId(rightEntityId) }, + ], + }, +]; + +const endpointFilter = (params: { + leftEntityId: EntityId; + rightEntityId: EntityId; +}): AllFilter["all"] => [ + { + equal: [ + { path: ["leftEntity", "uuid"] }, + { parameter: extractEntityUuidFromEntityId(params.leftEntityId) }, + ], + }, + { + equal: [ + { path: ["leftEntity", "webId"] }, + { parameter: extractWebIdFromEntityId(params.leftEntityId) }, + ], + }, + ...rightEntityFilter(params.rightEntityId), +]; + +const compareNewestFirst = (first: AvatarLink, second: AvatarLink): number => { + if (first.createdAt !== second.createdAt) { + return second.createdAt - first.createdAt; + } + + const firstInImageWeb = + first.webId === extractWebIdFromEntityId(first.rightEntityId); + const secondInImageWeb = + second.webId === extractWebIdFromEntityId(second.rightEntityId); + + return Number(secondInImageWeb) - Number(firstInImageWeb); +}; + +const createWebBotAuthenticationGetter = ( + context: ImpureGraphContext, + authentication: AuthenticationContext, +) => { + const webBotAuthentications = new Map(); + + return async (webId: WebId): Promise => { + const cachedAuthentication = webBotAuthentications.get(webId); + + if (cachedAuthentication) { + return cachedAuthentication; + } + + const webBotAccountId = await getWebMachineId(context, authentication, { + webId, + }); + + if (!webBotAccountId) { + throw new Error(`Failed to get web bot account ID for web ID: ${webId}`); + } + + const webBotAuthentication = { + actorId: webBotAccountId as ActorEntityUuid, + }; + + webBotAuthentications.set(webId, webBotAuthentication); + + return webBotAuthentication; + }; +}; + +const collectWebAvatarLinks = async ( + context: ImpureGraphContext, + webBotAuthentication: AuthenticationContext, + webId: WebId, +): Promise => { + const linkEntities = await queryAllEntityPages( + context, + webBotAuthentication, + { + filter: { all: hasAvatarLinkFilter(webId) }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: true, + includePermissions: false, + }, + ); + + return linkEntities.map((linkEntity) => { + const linkEntityId = linkEntity.metadata.recordId.entityId; + + if (!linkEntity.linkData) { + throw new Error(`Entity ${linkEntityId} has no link data`); + } + + return { + linkEntity, + linkEntityId, + leftEntityId: linkEntity.linkData.leftEntityId, + rightEntityId: linkEntity.linkData.rightEntityId, + webId, + webBotAuthentication, + createdAt: new Date( + linkEntity.metadata.provenance.createdAtDecisionTime, + ).getTime(), + }; + }); +}; + +const archiveAvatarLink = async ( + context: ImpureGraphContext, + avatarLink: AvatarLink, +) => + avatarLink.linkEntity.archive( + context.graphApi, + avatarLink.webBotAuthentication, + context.provenance, + ); + +const moveAvatarLinkToImageWeb = async ( + context: ImpureGraphContext, + authentication: AuthenticationContext, + imageWebBotAuthentication: AuthenticationContext, + avatarLink: AvatarLink, +) => { + const { linkEntity, linkEntityId, leftEntityId, rightEntityId } = avatarLink; + const imageWebId = extractWebIdFromEntityId(rightEntityId); + + const { entities: existingReplacements } = await queryEntities( + context, + imageWebBotAuthentication, + { + filter: { + all: [ + ...hasAvatarLinkFilter(imageWebId), + ...endpointFilter({ leftEntityId, rightEntityId }), + ], + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: true, + includePermissions: false, + }, + ); + + if (existingReplacements.length === 0) { + const publiclyViewable = await queryPolicies( + context.graphApi, + authentication, + { + name: publicViewPolicyName(extractEntityUuidFromEntityId(linkEntityId)), + }, + ).then((policies) => policies.length > 0); + + const entityUuid = generateUuid() as EntityUuid; + + const policies: CreateEntityParameters["policies"] = publiclyViewable + ? [ + { + name: publicViewPolicyName(entityUuid), + effect: "permit", + actions: ["viewEntity"], + principal: null, + } as const, + ] + : undefined; + + await createLinkEntity(context, imageWebBotAuthentication, { + webId: imageWebId, + entityUuid, + entityTypeIds: linkEntity.metadata.entityTypeIds, + properties: linkEntity.propertiesWithMetadata, + linkData: { leftEntityId, rightEntityId }, + draft: extractDraftIdFromEntityId(linkEntityId) !== undefined, + policies, + }); + } + + await archiveAvatarLink(context, avatarLink); +}; + +const findReferencedImages = async ( + context: ImpureGraphContext, + webBotAuthentication: AuthenticationContext, + webId: WebId, + imageEntityIds: EntityId[], +): Promise> => { + const incomingLinkFilter: Filter = { + all: [ + { equal: [{ path: ["webId"] }, { parameter: webId }] }, + { equal: [{ path: ["archived"] }, { parameter: false }] }, + { + any: imageEntityIds.map((imageEntityId) => ({ + all: rightEntityFilter(imageEntityId), + })), + }, + ], + }; + + const incomingLinks = await queryAllEntityPages( + context, + webBotAuthentication, + { + filter: incomingLinkFilter, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: true, + includePermissions: false, + }, + ); + + return new Set( + incomingLinks.flatMap((incomingLink) => + incomingLink.linkData ? [incomingLink.linkData.rightEntityId] : [], + ), + ); +}; + +const archiveImage = async ( + context: ImpureGraphContext, + imageWebBotAuthentication: AuthenticationContext, + imageEntityId: EntityId, +) => { + const { entities: images } = await queryEntities( + context, + imageWebBotAuthentication, + { + filter: { + all: [ + { + equal: [ + { path: ["uuid"] }, + { parameter: extractEntityUuidFromEntityId(imageEntityId) }, + ], + }, + { + equal: [ + { path: ["webId"] }, + { parameter: extractWebIdFromEntityId(imageEntityId) }, + ], + }, + { equal: [{ path: ["archived"] }, { parameter: false }] }, + ], + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: true, + includePermissions: false, + }, + ); + + for (const image of images) { + await image.archive( + context.graphApi, + imageWebBotAuthentication, + context.provenance, + ); + } +}; + +/** + * Leaves each user and organization with a single `Has Avatar` link, in the web of the image it points at. + * + * A link outside the image's web is only visible to actors with a role in the web it is in, which for an + * organization's avatar excludes the other members of the organization. An organization whose avatar was + * invisible to a member looked to that member as having none, so several links to several images can exist for + * the same organization, spread across the personal webs of whoever uploaded them. + * + * The webs are visited one at a time as each web's bot, since no single actor can see the entities of every web, + * and the links found are grouped by the user or organization they belong to. Within a group the link with the + * latest `createdAtDecisionTime` wins; a winner outside its image's web is archived and recreated there with the + * same endpoints, properties, draft state and public visibility, and every other link in the group is archived. + * An image left with no non-archived link pointing at it, in any web, is archived too. + * + * A winner which already has a replacement in the image's web is only archived, and a run which finds one link + * per user or organization, in the image's web, changes nothing, so the migration can be rerun or resumed after + * an interruption without leaving duplicates. + */ +const migrate: MigrationFunction = async ({ + context, + authentication, + migrationState, +}) => { + const getWebBotAuthentication = createWebBotAuthenticationGetter( + context, + authentication, + ); + + const { users, orgs } = await getExistingUsersAndOrgs( + context, + authentication, + {}, + ); + + const webIds = [...users, ...orgs].map((webEntity) => + extractWebIdFromEntityId(webEntity.metadata.recordId.entityId), + ); + + const avatarLinksByLeftEntity = new Map(); + + for (const webId of webIds) { + const avatarLinks = await collectWebAvatarLinks( + context, + await getWebBotAuthentication(webId), + webId, + ); + + for (const avatarLink of avatarLinks) { + const leftEntityLinks = + avatarLinksByLeftEntity.get(avatarLink.leftEntityId) ?? []; + + leftEntityLinks.push(avatarLink); + avatarLinksByLeftEntity.set(avatarLink.leftEntityId, leftEntityLinks); + } + } + + const keptImageEntityIds = new Set(); + const supersededImageEntityIds = new Set(); + + for (const avatarLinks of avatarLinksByLeftEntity.values()) { + const [newestLink, ...supersededLinks] = avatarLinks + .slice() + .sort(compareNewestFirst); + + if (!newestLink) { + continue; + } + + keptImageEntityIds.add(newestLink.rightEntityId); + + const imageWebId = extractWebIdFromEntityId(newestLink.rightEntityId); + + if (newestLink.webId !== imageWebId) { + await moveAvatarLinkToImageWeb( + context, + authentication, + await getWebBotAuthentication(imageWebId), + newestLink, + ); + } + + for (const supersededLink of supersededLinks) { + await archiveAvatarLink(context, supersededLink); + supersededImageEntityIds.add(supersededLink.rightEntityId); + } + } + + const candidateImageEntityIds = [...supersededImageEntityIds].filter( + (imageEntityId) => !keptImageEntityIds.has(imageEntityId), + ); + + if (candidateImageEntityIds.length === 0) { + return migrationState; + } + + const referencedImageEntityIds = new Set(); + + for (const webId of webIds) { + const referencedInWeb = await findReferencedImages( + context, + await getWebBotAuthentication(webId), + webId, + candidateImageEntityIds, + ); + + for (const imageEntityId of referencedInWeb) { + referencedImageEntityIds.add(imageEntityId); + } + } + + for (const imageEntityId of candidateImageEntityIds) { + if (referencedImageEntityIds.has(imageEntityId)) { + continue; + } + + await archiveImage( + context, + await getWebBotAuthentication(extractWebIdFromEntityId(imageEntityId)), + imageEntityId, + ); + } + + return migrationState; +}; + +export default migrate; diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts index df6ca6a7a8f..e3b8f94d62c 100644 --- a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts @@ -68,6 +68,10 @@ import type { VersionedUrl, } from "@blockprotocol/type-system"; import type { UpdatePropertyType } from "@local/hash-graph-client"; +import type { + HashEntity, + QueryEntitiesRequest, +} from "@local/hash-graph-sdk/entity"; import type { ConstructDataTypeParams } from "@local/hash-graph-sdk/ontology"; import type { SchemaKind, @@ -933,12 +937,32 @@ export const getEntitiesByType: ImpureGraphFunction< includePermissions: false, }).then(({ entities }) => entities); +export const queryAllEntityPages: ImpureGraphFunction< + QueryEntitiesRequest, + Promise +> = async (context, authentication, request) => { + const entities: HashEntity[] = []; + let cursor = request.cursor; + + do { + const response = await queryEntities(context, authentication, { + ...request, + cursor, + }); + + entities.push(...response.entities); + cursor = response.cursor; + } while (cursor); + + return entities; +}; + export const getExistingUsersAndOrgs: ImpureGraphFunction< Record, Promise<{ users: Entity[]; orgs: Entity[] }> > = async (context, authentication) => { - const [{ entities: users }, { entities: orgs }] = await Promise.all([ - queryEntities(context, authentication, { + const [users, orgs] = await Promise.all([ + queryAllEntityPages(context, authentication, { filter: { all: [ { @@ -953,7 +977,7 @@ export const getExistingUsersAndOrgs: ImpureGraphFunction< includeDrafts: false, includePermissions: false, }), - queryEntities(context, authentication, { + queryAllEntityPages(context, authentication, { filter: { all: [ { diff --git a/apps/hash-frontend/src/shared/file-upload-context.tsx b/apps/hash-frontend/src/shared/file-upload-context.tsx index 3a3d613aeab..8c058086cb0 100644 --- a/apps/hash-frontend/src/shared/file-upload-context.tsx +++ b/apps/hash-frontend/src/shared/file-upload-context.tsx @@ -8,6 +8,7 @@ import { } from "react"; import { v4 as uuid } from "uuid"; +import { extractWebIdFromEntityId } from "@blockprotocol/type-system"; import { HashEntity, HashLinkEntity, @@ -493,6 +494,9 @@ export const FileUploadsProvider = ({ children }: PropsWithChildren) => { ? mergePropertyObjectAndMetadata(linkProperties, undefined) : { value: {} }, makePublic, + webId: extractWebIdFromEntityId( + fileEntity.metadata.recordId.entityId, + ), }, }); diff --git a/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/workspace-switcher.tsx b/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/workspace-switcher.tsx index d39582aead4..77ce44a90e6 100644 --- a/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/workspace-switcher.tsx +++ b/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/workspace-switcher.tsx @@ -123,6 +123,11 @@ export const WorkspaceSwitcher = () => { src={activeWorkspace.avatarSrc} title={activeWorkspace.name} borderRadius={activeWorkspace.isOrg ? "4px" : undefined} + sx={ + activeWorkspace.avatarSrc + ? { backgroundColor: ({ palette }) => palette.common.white } + : undefined + } /> { src={avatarSrc} size={26} borderRadius={isOrg ? "4px" : undefined} + sx={ + avatarSrc + ? { + backgroundColor: ({ palette }) => palette.common.white, + } + : undefined + } title={ webId === authenticatedUser.accountId ? authenticatedUser.displayName