diff --git a/keyserver/src/creators/invite-link-creator.js b/keyserver/src/creators/invite-link-creator.js new file mode 100644 index 000000000..6f98680e0 --- /dev/null +++ b/keyserver/src/creators/invite-link-creator.js @@ -0,0 +1,121 @@ +// @flow + +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 createIDs from './id-creator.js'; +import { dbQuery, 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 { Viewer } from '../session/viewer.js'; + +const secretRegex = /^[a-zA-Z0-9]+$/; + +async function createOrUpdatePublicLink( + viewer: Viewer, + request: CreateOrUpdatePublicLinkRequest, +): Promise { + if (!secretRegex.test(request.name)) { + throw new ServerError('invalid_parameters'); + } + + const permissionPromise = checkThreadPermission( + viewer, + request.communityID, + threadPermissions.MANAGE_INVITE_LINKS, + ); + const existingPrimaryLinksPromise = fetchPrimaryInviteLinks(viewer); + const fetchThreadInfoPromise = fetchServerThreadInfos( + SQL`t.id = ${request.communityID}`, + ); + const [hasPermission, existingPrimaryLinks, { threadInfos }] = + await Promise.all([ + permissionPromise, + existingPrimaryLinksPromise, + fetchThreadInfoPromise, + ]); + if (!hasPermission) { + throw new ServerError('invalid_credentials'); + } + 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, + ); + if (existingPrimaryLink) { + const query = SQL` + UPDATE invite_links + SET name = ${request.name} + WHERE \`primary\` = 1 AND community = ${request.communityID} + `; + try { + await dbQuery(query); + } catch { + 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]; + + const createLinkQuery = SQL` + INSERT INTO invite_links(id, name, \`primary\`, community, role) + SELECT ${row} + WHERE NOT EXISTS ( + SELECT i.id + FROM invite_links i + WHERE i.\`primary\` = 1 AND i.community = ${request.communityID} + ) + `; + let result = null; + try { + result = (await dbQuery(createLinkQuery))[0]; + } catch { + throw new ServerError('invalid_parameters'); + } + + if (result.affectedRows === 0) { + const deleteIDs = SQL` + DELETE FROM ids + WHERE id = ${id} + `; + await dbQuery(deleteIDs); + throw new ServerError('invalid_parameters'); + } + + return { + name: request.name, + primary: true, + role: defaultRoleID, + communityID: request.communityID, + expirationTime: null, + limitOfUses: null, + numberOfUses: 0, + }; +} + +export { createOrUpdatePublicLink }; diff --git a/keyserver/src/endpoints.js b/keyserver/src/endpoints.js index 636bdfa5b..3f18dc148 100644 --- a/keyserver/src/endpoints.js +++ b/keyserver/src/endpoints.js @@ -1,298 +1,303 @@ // @flow import { baseLegalPolicies } from 'lib/facts/policies.js'; import type { Endpoint } from 'lib/types/endpoints.js'; import { updateActivityResponder, threadSetUnreadStatusResponder, } from './responders/activity-responders.js'; import { deviceTokenUpdateResponder } from './responders/device-responders.js'; import { entryFetchResponder, entryRevisionFetchResponder, entryCreationResponder, entryUpdateResponder, entryDeletionResponder, entryRestorationResponder, calendarQueryUpdateResponder, } from './responders/entry-responders.js'; import type { JSONResponder } from './responders/handlers.js'; import { getSessionPublicKeysResponder } from './responders/keys-responders.js'; import { + createOrUpdatePublicLinkResponder, fetchPrimaryInviteLinksResponder, inviteLinkVerificationResponder, } from './responders/link-responders.js'; import { messageReportCreationResponder } from './responders/message-report-responder.js'; import { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, reactionMessageCreationResponder, editMessageCreationResponder, fetchPinnedMessagesResponder, searchMessagesResponder, } from './responders/message-responders.js'; import { updateRelationshipsResponder } from './responders/relationship-responders.js'; import { reportCreationResponder, reportMultiCreationResponder, errorReportFetchInfosResponder, } from './responders/report-responders.js'; import { userSearchResponder, exactUserSearchResponder, } from './responders/search-responders.js'; import { siweNonceResponder } from './responders/siwe-nonce-responders.js'; import { threadDeletionResponder, roleUpdateResponder, memberRemovalResponder, threadLeaveResponder, threadUpdateResponder, threadCreationResponder, threadFetchMediaResponder, threadJoinResponder, toggleMessagePinResponder, } from './responders/thread-responders.js'; import { userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, siweAuthResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, policyAcknowledgmentResponder, updateUserAvatarResponder, } from './responders/user-responders.js'; import { codeVerificationResponder } from './responders/verification-responders.js'; import { uploadMediaMetadataResponder, uploadDeletionResponder, } from './uploads/uploads.js'; const jsonEndpoints: { [id: Endpoint]: JSONResponder } = { create_account: { responder: accountCreationResponder, requiredPolicies: [], }, create_entry: { responder: entryCreationResponder, requiredPolicies: baseLegalPolicies, }, create_error_report: { responder: reportCreationResponder, requiredPolicies: [], }, create_message_report: { responder: messageReportCreationResponder, requiredPolicies: baseLegalPolicies, }, create_multimedia_message: { responder: multimediaMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, + create_or_update_public_link: { + responder: createOrUpdatePublicLinkResponder, + requiredPolicies: baseLegalPolicies, + }, create_reaction_message: { responder: reactionMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, edit_message: { responder: editMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, create_report: { responder: reportCreationResponder, requiredPolicies: [], }, create_reports: { responder: reportMultiCreationResponder, requiredPolicies: [], }, create_text_message: { responder: textMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, create_thread: { responder: threadCreationResponder, requiredPolicies: baseLegalPolicies, }, delete_account: { responder: accountDeletionResponder, requiredPolicies: [], }, delete_entry: { responder: entryDeletionResponder, requiredPolicies: baseLegalPolicies, }, delete_thread: { responder: threadDeletionResponder, requiredPolicies: baseLegalPolicies, }, delete_upload: { responder: uploadDeletionResponder, requiredPolicies: baseLegalPolicies, }, exact_search_user: { responder: exactUserSearchResponder, requiredPolicies: [], }, fetch_entries: { responder: entryFetchResponder, requiredPolicies: baseLegalPolicies, }, fetch_entry_revisions: { responder: entryRevisionFetchResponder, requiredPolicies: baseLegalPolicies, }, fetch_error_report_infos: { responder: errorReportFetchInfosResponder, requiredPolicies: baseLegalPolicies, }, fetch_messages: { responder: messageFetchResponder, requiredPolicies: baseLegalPolicies, }, fetch_pinned_messages: { responder: fetchPinnedMessagesResponder, requiredPolicies: baseLegalPolicies, }, fetch_primary_invite_links: { responder: fetchPrimaryInviteLinksResponder, requiredPolicies: baseLegalPolicies, }, fetch_thread_media: { responder: threadFetchMediaResponder, requiredPolicies: baseLegalPolicies, }, get_session_public_keys: { responder: getSessionPublicKeysResponder, requiredPolicies: baseLegalPolicies, }, join_thread: { responder: threadJoinResponder, requiredPolicies: baseLegalPolicies, }, leave_thread: { responder: threadLeaveResponder, requiredPolicies: baseLegalPolicies, }, log_in: { responder: logInResponder, requiredPolicies: [], }, log_out: { responder: logOutResponder, requiredPolicies: [], }, policy_acknowledgment: { responder: policyAcknowledgmentResponder, requiredPolicies: [], }, remove_members: { responder: memberRemovalResponder, requiredPolicies: baseLegalPolicies, }, restore_entry: { responder: entryRestorationResponder, requiredPolicies: baseLegalPolicies, }, search_messages: { responder: searchMessagesResponder, requiredPolicies: baseLegalPolicies, }, search_users: { responder: userSearchResponder, requiredPolicies: baseLegalPolicies, }, send_password_reset_email: { responder: sendPasswordResetEmailResponder, requiredPolicies: [], }, send_verification_email: { responder: sendVerificationEmailResponder, requiredPolicies: [], }, set_thread_unread_status: { responder: threadSetUnreadStatusResponder, requiredPolicies: baseLegalPolicies, }, toggle_message_pin: { responder: toggleMessagePinResponder, requiredPolicies: baseLegalPolicies, }, update_account: { responder: passwordUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_activity: { responder: updateActivityResponder, requiredPolicies: baseLegalPolicies, }, update_calendar_query: { responder: calendarQueryUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_user_settings: { responder: updateUserSettingsResponder, requiredPolicies: baseLegalPolicies, }, update_device_token: { responder: deviceTokenUpdateResponder, requiredPolicies: [], }, update_entry: { responder: entryUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_password: { responder: oldPasswordUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_relationships: { responder: updateRelationshipsResponder, requiredPolicies: baseLegalPolicies, }, update_role: { responder: roleUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_thread: { responder: threadUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_user_subscription: { responder: userSubscriptionUpdateResponder, requiredPolicies: baseLegalPolicies, }, verify_code: { responder: codeVerificationResponder, requiredPolicies: baseLegalPolicies, }, verify_invite_link: { responder: inviteLinkVerificationResponder, requiredPolicies: baseLegalPolicies, }, siwe_nonce: { responder: siweNonceResponder, requiredPolicies: [], }, siwe_auth: { responder: siweAuthResponder, requiredPolicies: [], }, update_user_avatar: { responder: updateUserAvatarResponder, requiredPolicies: baseLegalPolicies, }, upload_media_metadata: { responder: uploadMediaMetadataResponder, requiredPolicies: baseLegalPolicies, }, }; export { jsonEndpoints }; diff --git a/keyserver/src/responders/link-responders.js b/keyserver/src/responders/link-responders.js index 9665d23aa..e1c336c6e 100644 --- a/keyserver/src/responders/link-responders.js +++ b/keyserver/src/responders/link-responders.js @@ -1,74 +1,100 @@ // @flow import t, { type TUnion, type TInterface } from 'tcomb'; import { type InviteLinkVerificationRequest, type InviteLinkVerificationResponse, type FetchInviteLinksResponse, + type InviteLink, inviteLinkValidator, + type CreateOrUpdatePublicLinkRequest, } from 'lib/types/link-types.js'; import { tShape, tID } from 'lib/utils/validation-utils.js'; +import { createOrUpdatePublicLink } from '../creators/invite-link-creator.js'; import { fetchPrimaryInviteLinks, verifyInviteLink, } from '../fetchers/link-fetchers.js'; import { Viewer } from '../session/viewer.js'; import { validateInput, validateOutput } from '../utils/validation-utils.js'; const inviteLinkVerificationRequestInputValidator: TInterface = tShape({ secret: t.String, }); export const inviteLinkVerificationResponseValidator: TUnion = t.union([ tShape({ status: t.enums.of(['valid', 'already_joined']), community: tShape({ name: t.String, id: tID, }), }), tShape({ status: t.enums.of(['invalid', 'expired']), }), ]); async function inviteLinkVerificationResponder( viewer: Viewer, input: any, ): Promise { const request = await validateInput( viewer, inviteLinkVerificationRequestInputValidator, input, ); const response = await verifyInviteLink(viewer, request); return validateOutput( viewer.platformDetails, inviteLinkVerificationResponseValidator, response, ); } export const fetchInviteLinksResponseValidator: TInterface = tShape({ links: t.list(inviteLinkValidator), }); async function fetchPrimaryInviteLinksResponder( viewer: Viewer, ): Promise { const primaryLinks = await fetchPrimaryInviteLinks(viewer); return validateOutput( viewer.platformDetails, fetchInviteLinksResponseValidator, { links: primaryLinks, }, ); } -export { inviteLinkVerificationResponder, fetchPrimaryInviteLinksResponder }; +const createOrUpdatePublicLinkInputValidator: TInterface = + tShape({ + name: t.String, + communityID: tID, + }); + +async function createOrUpdatePublicLinkResponder( + viewer: Viewer, + input: mixed, +): Promise { + const request = await validateInput( + viewer, + createOrUpdatePublicLinkInputValidator, + input, + ); + const response = await createOrUpdatePublicLink(viewer, request); + return validateOutput(viewer.platformDetails, inviteLinkValidator, response); +} + +export { + inviteLinkVerificationResponder, + fetchPrimaryInviteLinksResponder, + createOrUpdatePublicLinkResponder, +}; diff --git a/lib/types/endpoints.js b/lib/types/endpoints.js index 94b4e5eec..64bc4ca62 100644 --- a/lib/types/endpoints.js +++ b/lib/types/endpoints.js @@ -1,124 +1,125 @@ // @flow export type APIRequest = { endpoint: Endpoint, input?: Object, }; export type SocketAPIHandler = (request: APIRequest) => Promise; export type Endpoint = | HTTPOnlyEndpoint | SocketOnlyEndpoint | HTTPPreferredEndpoint | SocketPreferredEndpoint; // Endpoints that can cause session changes should occur over HTTP, since the // socket code does not currently support changing sessions. In the future they // could be made to work for native, but cookie changes on web require HTTP // since websockets aren't able to Set-Cookie. Note that technically any // endpoint can cause a sessionChange, and in that case the server will close // the socket with a specific error code, and the client will proceed via HTTP. const sessionChangingEndpoints = Object.freeze({ LOG_OUT: 'log_out', DELETE_ACCOUNT: 'delete_account', CREATE_ACCOUNT: 'create_account', LOG_IN: 'log_in', UPDATE_PASSWORD: 'update_password', POLICY_ACKNOWLEDGMENT: 'policy_acknowledgment', }); type SessionChangingEndpoint = $Values; // We do uploads over HTTP as well. This is because Websockets use TCP, which // guarantees ordering. That means that if we start an upload, any messages we // try to send the server after the upload starts will have to wait until the // upload ends. To avoid blocking other messages we upload using HTTP // multipart/form-data. const uploadEndpoints = Object.freeze({ UPLOAD_MULTIMEDIA: 'upload_multimedia', }); type UploadEndpoint = $Values; type HTTPOnlyEndpoint = SessionChangingEndpoint | UploadEndpoint; const socketOnlyEndpoints = Object.freeze({ UPDATE_ACTIVITY: 'update_activity', UPDATE_CALENDAR_QUERY: 'update_calendar_query', }); type SocketOnlyEndpoint = $Values; const socketPreferredEndpoints = Object.freeze({ CREATE_ENTRY: 'create_entry', CREATE_ERROR_REPORT: 'create_error_report', CREATE_MESSAGE_REPORT: 'create_message_report', CREATE_MULTIMEDIA_MESSAGE: 'create_multimedia_message', + CREATE_OR_UPDATE_PUBLIC_LINK: 'create_or_update_public_link', CREATE_REACTION_MESSAGE: 'create_reaction_message', EDIT_MESSAGE: 'edit_message', CREATE_TEXT_MESSAGE: 'create_text_message', CREATE_THREAD: 'create_thread', DELETE_ENTRY: 'delete_entry', DELETE_THREAD: 'delete_thread', DELETE_UPLOAD: 'delete_upload', EXACT_SEARCH_USER: 'exact_search_user', FETCH_ENTRIES: 'fetch_entries', FETCH_ENTRY_REVISIONS: 'fetch_entry_revisions', FETCH_ERROR_REPORT_INFOS: 'fetch_error_report_infos', FETCH_MESSAGES: 'fetch_messages', FETCH_PINNED_MESSAGES: 'fetch_pinned_messages', FETCH_PRIMARY_INVITE_LINKS: 'fetch_primary_invite_links', FETCH_THREAD_MEDIA: 'fetch_thread_media', GET_SESSION_PUBLIC_KEYS: 'get_session_public_keys', JOIN_THREAD: 'join_thread', LEAVE_THREAD: 'leave_thread', REMOVE_MEMBERS: 'remove_members', REQUEST_ACCESS: 'request_access', RESTORE_ENTRY: 'restore_entry', SEARCH_USERS: 'search_users', SEND_PASSWORD_RESET_EMAIL: 'send_password_reset_email', SEND_VERIFICATION_EMAIL: 'send_verification_email', SET_THREAD_UNREAD_STATUS: 'set_thread_unread_status', TOGGLE_MESSAGE_PIN: 'toggle_message_pin', UPDATE_ACCOUNT: 'update_account', UPDATE_USER_SETTINGS: 'update_user_settings', UPDATE_DEVICE_TOKEN: 'update_device_token', UPDATE_ENTRY: 'update_entry', UPDATE_RELATIONSHIPS: 'update_relationships', UPDATE_ROLE: 'update_role', UPDATE_THREAD: 'update_thread', UPDATE_USER_SUBSCRIPTION: 'update_user_subscription', VERIFY_CODE: 'verify_code', VERIFY_INVITE_LINK: 'verify_invite_link', SIWE_NONCE: 'siwe_nonce', SIWE_AUTH: 'siwe_auth', UPDATE_USER_AVATAR: 'update_user_avatar', UPLOAD_MEDIA_METADATA: 'upload_media_metadata', SEARCH_MESSAGES: 'search_messages', }); type SocketPreferredEndpoint = $Values; const httpPreferredEndpoints = Object.freeze({ CREATE_REPORT: 'create_report', CREATE_REPORTS: 'create_reports', }); type HTTPPreferredEndpoint = $Values; const socketPreferredEndpointSet = new Set([ ...Object.values(socketOnlyEndpoints), ...Object.values(socketPreferredEndpoints), ]); export function endpointIsSocketPreferred(endpoint: Endpoint): boolean { return socketPreferredEndpointSet.has(endpoint); } const socketSafeEndpointSet = new Set([ ...Object.values(socketOnlyEndpoints), ...Object.values(socketPreferredEndpoints), ...Object.values(httpPreferredEndpoints), ]); export function endpointIsSocketSafe(endpoint: Endpoint): boolean { return socketSafeEndpointSet.has(endpoint); } const socketOnlyEndpointSet = new Set(Object.values(socketOnlyEndpoints)); export function endpointIsSocketOnly(endpoint: Endpoint): boolean { return socketOnlyEndpointSet.has(endpoint); } diff --git a/lib/types/link-types.js b/lib/types/link-types.js index c28967fe5..b67baac92 100644 --- a/lib/types/link-types.js +++ b/lib/types/link-types.js @@ -1,57 +1,62 @@ // @flow import t, { type TInterface } from 'tcomb'; import { tID, tShape } from '../utils/validation-utils.js'; export type InviteLinkVerificationRequest = { +secret: string, }; export type InviteLinkVerificationResponse = | { +status: 'valid' | 'already_joined', +community: { +name: string, +id: string, }, } | { +status: 'invalid' | 'expired', }; export type InviteLink = { +name: string, +primary: boolean, +role: string, +communityID: string, +expirationTime: ?number, +limitOfUses: ?number, +numberOfUses: number, }; export const inviteLinkValidator: TInterface = tShape({ name: t.String, primary: t.Boolean, role: tID, communityID: tID, expirationTime: t.maybe(t.Number), limitOfUses: t.maybe(t.Number), numberOfUses: t.Number, }); export type FetchInviteLinksResponse = { +links: $ReadOnlyArray, }; export type CommunityLinks = { +primaryLink: ?InviteLink, }; export type InviteLinks = { +[communityID: string]: CommunityLinks, }; export type InviteLinksStore = { +links: InviteLinks, }; + +export type CreateOrUpdatePublicLinkRequest = { + +name: string, + +communityID: string, +};