diff --git a/keyserver/src/creators/invite-link-creator.js b/keyserver/src/creators/invite-link-creator.js index 73cd45655..628403a76 100644 --- a/keyserver/src/creators/invite-link-creator.js +++ b/keyserver/src/creators/invite-link-creator.js @@ -1,236 +1,238 @@ // @flow import Filter from 'bad-words'; import uuid from 'uuid'; import { inviteLinkBlobHash } from 'lib/shared/invite-links.js'; import type { CreateOrUpdatePublicLinkRequest, InviteLink, } from 'lib/types/link-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { reservedUsernamesSet } from 'lib/utils/reserved-users.js'; import createIDs from './id-creator.js'; import { dbQuery, MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE, SQL, } from '../database/database.js'; import { fetchPrimaryInviteLinks } from '../fetchers/link-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; import { download, type BlobDownloadResult, assignHolder, uploadBlob, deleteBlob, + type BlobOperationResult, } from '../services/blob.js'; import { Viewer } from '../session/viewer.js'; import { fetchIdentityInfo } from '../user/identity.js'; import { getAndAssertKeyserverURLFacts } from '../utils/urls.js'; const secretRegex = /^[a-zA-Z0-9]+$/; const badWordsFilter = new Filter(); async function createOrUpdatePublicLink( viewer: Viewer, request: CreateOrUpdatePublicLinkRequest, ): Promise { if (!secretRegex.test(request.name)) { throw new ServerError('invalid_characters'); } if (badWordsFilter.isProfane(request.name)) { throw new ServerError('offensive_words'); } if (reservedUsernamesSet.has(request.name)) { throw new ServerError('link_reserved'); } const permissionPromise = checkThreadPermission( viewer, request.communityID, threadPermissions.MANAGE_INVITE_LINKS, ); const existingPrimaryLinksPromise = fetchPrimaryInviteLinks(viewer); const fetchThreadInfoPromise = fetchServerThreadInfos({ threadID: request.communityID, }); - const blobDownloadPromise = getInviteLinkBlob(request); + const blobDownloadPromise = getInviteLinkBlob(request.name); const [ hasPermission, existingPrimaryLinks, { threadInfos }, blobDownloadResult, ] = await Promise.all([ permissionPromise, existingPrimaryLinksPromise, fetchThreadInfoPromise, blobDownloadPromise, ]); if (!hasPermission) { throw new ServerError('invalid_credentials'); } if (blobDownloadResult.found) { throw new ServerError('already_in_use'); } const threadInfo = threadInfos[request.communityID]; if (!threadInfo) { throw new ServerError('invalid_parameters'); } const defaultRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].isDefault, ); if (!defaultRoleID) { throw new ServerError('invalid_parameters'); } const existingPrimaryLink = existingPrimaryLinks.find( link => link.communityID === request.communityID && link.primary, ); const blobHolder = uuid.v4(); const blobResult = await uploadInviteLinkBlob(request.name, blobHolder); if (!blobResult.success) { if (blobResult.reason === 'HASH_IN_USE') { throw new ServerError('already_in_use'); } else { throw new ServerError('unknown_error'); } } if (existingPrimaryLink) { const query = SQL` UPDATE invite_links SET name = ${request.name}, blob_holder = ${blobHolder} WHERE \`primary\` = 1 AND community = ${request.communityID} `; try { await dbQuery(query); const holder = existingPrimaryLink.blobHolder; if (holder) { await deleteBlob({ hash: inviteLinkBlobHash(existingPrimaryLink.name), holder, }); } } catch (e) { await deleteBlob({ hash: inviteLinkBlobHash(request.name), holder: blobHolder, }); if (e.errno === MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE) { throw new ServerError('already_in_use'); } throw new ServerError('invalid_parameters'); } return { name: request.name, primary: true, role: defaultRoleID, communityID: request.communityID, expirationTime: null, limitOfUses: null, numberOfUses: 0, }; } const [id] = await createIDs('invite_links', 1); const row = [ id, request.name, true, request.communityID, defaultRoleID, blobHolder, ]; const createLinkQuery = SQL` INSERT INTO invite_links(id, name, \`primary\`, community, role, blob_holder) SELECT ${row} WHERE NOT EXISTS ( SELECT i.id FROM invite_links i WHERE i.\`primary\` = 1 AND i.community = ${request.communityID} ) `; let result = null; const deleteIDs = SQL` DELETE FROM ids WHERE id = ${id} `; try { result = (await dbQuery(createLinkQuery))[0]; } catch (e) { await Promise.all([ dbQuery(deleteIDs), deleteBlob({ hash: inviteLinkBlobHash(request.name), holder: blobHolder, }), ]); if (e.errno === MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE) { throw new ServerError('already_in_use'); } throw new ServerError('invalid_parameters'); } if (result.affectedRows === 0) { await Promise.all([ dbQuery(deleteIDs), deleteBlob({ hash: inviteLinkBlobHash(request.name), holder: blobHolder, }), ]); throw new ServerError('invalid_parameters'); } return { name: request.name, primary: true, role: defaultRoleID, communityID: request.communityID, expirationTime: null, limitOfUses: null, numberOfUses: 0, }; } -function getInviteLinkBlob( - request: CreateOrUpdatePublicLinkRequest, -): Promise { - const hash = inviteLinkBlobHash(request.name); +function getInviteLinkBlob(secret: string): Promise { + const hash = inviteLinkBlobHash(secret); return download(hash); } -async function uploadInviteLinkBlob(linkSecret: string, holder: string) { +async function uploadInviteLinkBlob( + linkSecret: string, + holder: string, +): Promise { const identityInfo = await fetchIdentityInfo(); const keyserverID = identityInfo?.userId; if (!keyserverID) { throw new ServerError('invalid_credentials'); } const { baseDomain, basePath } = getAndAssertKeyserverURLFacts(); const keyserverURL = baseDomain + basePath; const payload = { keyserverID, keyserverURL, }; const payloadString = JSON.stringify(payload); const key = inviteLinkBlobHash(linkSecret); const blob = new Blob([payloadString]); const uploadResult = await uploadBlob(blob, key); if (!uploadResult.success) { return uploadResult; } return await assignHolder({ holder, hash: key }); } -export { createOrUpdatePublicLink }; +export { createOrUpdatePublicLink, uploadInviteLinkBlob, getInviteLinkBlob }; diff --git a/keyserver/src/services/blob.js b/keyserver/src/services/blob.js index f738ed587..e281cda73 100644 --- a/keyserver/src/services/blob.js +++ b/keyserver/src/services/blob.js @@ -1,174 +1,174 @@ // @flow import blobService from 'lib/facts/blob-service.js'; import { getBlobFetchableURL, makeBlobServiceEndpointURL, } from 'lib/utils/blob-service.js'; import { createHTTPAuthorizationHeader } from 'lib/utils/services-utils.js'; import { verifyUserLoggedIn } from '../user/login.js'; import { getContentSigningKey } from '../utils/olm-utils.js'; async function createRequestHeaders( includeContentType: boolean = true, ): Promise<{ [string]: string }> { const [{ userId: userID, accessToken }, deviceID] = await Promise.all([ verifyUserLoggedIn(), getContentSigningKey(), ]); const authorization = createHTTPAuthorizationHeader({ userID, deviceID, accessToken, }); return { Authorization: authorization, ...(includeContentType && { 'Content-Type': 'application/json' }), }; } type BlobDescriptor = { +hash: string, +holder: string, }; -type BlobOperationResult = +export type BlobOperationResult = | { +success: true, } | { +success: false, +reason: 'HASH_IN_USE' | 'OTHER', +status: number, +statusText: string, }; async function uploadBlob( blob: Blob, hash: string, ): Promise { const formData = new FormData(); formData.append('blob_hash', hash); formData.append('blob_data', blob); const headers = await createRequestHeaders(false); const uploadBlobResponse = await fetch( makeBlobServiceEndpointURL(blobService.httpEndpoints.UPLOAD_BLOB), { method: blobService.httpEndpoints.UPLOAD_BLOB.method, body: formData, headers, }, ); if (!uploadBlobResponse.ok) { const { status, statusText } = uploadBlobResponse; const reason = status === 409 ? 'HASH_IN_USE' : 'OTHER'; return { success: false, reason, status, statusText, }; } return { success: true }; } async function assignHolder( params: BlobDescriptor, ): Promise { const { hash, holder } = params; const headers = await createRequestHeaders(); const assignHolderResponse = await fetch( makeBlobServiceEndpointURL(blobService.httpEndpoints.ASSIGN_HOLDER), { method: blobService.httpEndpoints.ASSIGN_HOLDER.method, body: JSON.stringify({ holder, blob_hash: hash, }), headers, }, ); if (!assignHolderResponse.ok) { const { status, statusText } = assignHolderResponse; return { success: false, reason: 'OTHER', status, statusText }; } return { success: true }; } async function upload( blob: Blob, params: BlobDescriptor, ): Promise< | { +success: true, } | { +success: false, +assignHolderResult: BlobOperationResult, +uploadBlobResult: BlobOperationResult, }, > { const { hash, holder } = params; const [holderResult, uploadResult] = await Promise.all([ assignHolder({ hash, holder }), uploadBlob(blob, hash), ]); if (holderResult.success && uploadResult.success) { return { success: true }; } return { success: false, assignHolderResult: holderResult, uploadBlobResult: uploadResult, }; } export type BlobDownloadResult = | { +found: false, } | { +found: true, +blob: Blob, }; async function download(hash: string): Promise { const url = getBlobFetchableURL(hash); const headers = await createRequestHeaders(); const response = await fetch(url, { method: blobService.httpEndpoints.GET_BLOB.method, headers, }); if (!response.ok) { return { found: false }; } const blob = await response.blob(); return { found: true, blob }; } async function deleteBlob(params: BlobDescriptor, instant?: boolean) { const { hash, holder } = params; const endpoint = blobService.httpEndpoints.DELETE_BLOB; const url = makeBlobServiceEndpointURL(endpoint); const headers = await createRequestHeaders(); await fetch(url, { method: endpoint.method, body: JSON.stringify({ holder, blob_hash: hash, instant_delete: !!instant, }), headers, }); } export { upload, uploadBlob, assignHolder, download, deleteBlob }; diff --git a/keyserver/src/updaters/link-updaters.js b/keyserver/src/updaters/link-updaters.js index aa4a95a52..11235b1b8 100644 --- a/keyserver/src/updaters/link-updaters.js +++ b/keyserver/src/updaters/link-updaters.js @@ -1,14 +1,23 @@ // @flow import { dbQuery, SQL } from '../database/database.js'; async function reportLinkUsage(secret: string): Promise { const query = SQL` UPDATE invite_links SET number_of_uses = number_of_uses + 1 WHERE name = ${secret} `; await dbQuery(query); } -export { reportLinkUsage }; +async function setLinkHolder(secret: string, holder: string): Promise { + const query = SQL` + UPDATE invite_links + SET blob_holder = ${holder} + WHERE name = ${secret} + `; + await dbQuery(query); +} + +export { reportLinkUsage, setLinkHolder }; diff --git a/keyserver/src/utils/synchronizeInviteLinksWithBlobs.js b/keyserver/src/utils/synchronizeInviteLinksWithBlobs.js new file mode 100644 index 000000000..65ed5f95c --- /dev/null +++ b/keyserver/src/utils/synchronizeInviteLinksWithBlobs.js @@ -0,0 +1,44 @@ +// @flow + +import uuid from 'uuid'; + +import { inviteLinkBlobHash } from 'lib/shared/invite-links.js'; +import type { InviteLinkWithHolder } from 'lib/types/link-types.js'; + +import { + getInviteLinkBlob, + uploadInviteLinkBlob, +} from '../creators/invite-link-creator.js'; +import { fetchAllPrimaryInviteLinks } from '../fetchers/link-fetchers.js'; +import { setLinkHolder } from '../updaters/link-updaters.js'; + +async function synchronizeInviteLinksWithBlobs() { + const links = await fetchAllPrimaryInviteLinks(); + const promises = []; + for (const link: InviteLinkWithHolder of links) { + promises.push( + (async () => { + const isHolderPresent = !!link.blobHolder; + const holder = link.blobHolder ?? uuid.v4(); + if (isHolderPresent) { + const blobFetchResult = await getInviteLinkBlob( + inviteLinkBlobHash(link.name), + ); + if (blobFetchResult.found) { + return; + } + } + const uploadResult = await uploadInviteLinkBlob( + inviteLinkBlobHash(link.name), + holder, + ); + if (uploadResult.success && !isHolderPresent) { + await setLinkHolder(link.name, holder); + } + })(), + ); + } + await Promise.all(promises); +} + +export { synchronizeInviteLinksWithBlobs };