diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index c896d27cdc..ddb2df9c83 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -23,6 +23,7 @@ import { appKeysSchema as make_zod_ts } from "./make/zod"; import { appKeysSchema as matomo_zod_ts } from "./matomo/zod"; import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod"; import { appKeysSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod"; +import { appKeysSchema as nextcloudtalk_zod_ts } from "./nextcloudtalk/zod"; import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod"; import { appKeysSchema as office365video_zod_ts } from "./office365video/zod"; import { appKeysSchema as paypal_zod_ts } from "./paypal/zod"; @@ -70,6 +71,7 @@ export const appKeysSchemas = { matomo: matomo_zod_ts, metapixel: metapixel_zod_ts, "mock-payment-app": mock_payment_app_zod_ts, + nextcloudtalk: nextcloudtalk_zod_ts, office365calendar: office365calendar_zod_ts, office365video: office365video_zod_ts, paypal: paypal_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 543756868f..c92d830168 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -48,6 +48,7 @@ import metapixel_config_json from "./metapixel/config.json"; import mirotalk_config_json from "./mirotalk/config.json"; import mock_payment_app_config_json from "./mock-payment-app/config.json"; import n8n_config_json from "./n8n/config.json"; +import nextcloudtalk_config_json from "./nextcloudtalk/config.json"; import { metadata as office365calendar__metadata_ts } from "./office365calendar/_metadata"; import office365video_config_json from "./office365video/config.json"; import paypal_config_json from "./paypal/config.json"; @@ -144,6 +145,7 @@ export const appStoreMetadata = { mirotalk: mirotalk_config_json, "mock-payment-app": mock_payment_app_config_json, n8n: n8n_config_json, + nextcloudtalk: nextcloudtalk_config_json, office365calendar: office365calendar__metadata_ts, office365video: office365video_config_json, paypal: paypal_config_json, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index 8fc3cd923d..69b6f7c09e 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -23,6 +23,7 @@ import { appDataSchema as make_zod_ts } from "./make/zod"; import { appDataSchema as matomo_zod_ts } from "./matomo/zod"; import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod"; import { appDataSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod"; +import { appDataSchema as nextcloudtalk_zod_ts } from "./nextcloudtalk/zod"; import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod"; import { appDataSchema as office365video_zod_ts } from "./office365video/zod"; import { appDataSchema as paypal_zod_ts } from "./paypal/zod"; @@ -70,6 +71,7 @@ export const appDataSchemas = { matomo: matomo_zod_ts, metapixel: metapixel_zod_ts, "mock-payment-app": mock_payment_app_zod_ts, + nextcloudtalk: nextcloudtalk_zod_ts, office365calendar: office365calendar_zod_ts, office365video: office365video_zod_ts, paypal: paypal_zod_ts, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 99552be1fd..10d24f62ba 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -48,6 +48,7 @@ export const apiHandlers = { mirotalk: import("./mirotalk/api"), "mock-payment-app": import("./mock-payment-app/api"), n8n: import("./n8n/api"), + nextcloudtalk: import("./nextcloudtalk/api"), office365calendar: import("./office365calendar/api"), office365video: import("./office365video/api"), paypal: import("./paypal/api"), diff --git a/packages/app-store/bookerApps.metadata.generated.ts b/packages/app-store/bookerApps.metadata.generated.ts index 58f21f48a6..4499b4afbb 100644 --- a/packages/app-store/bookerApps.metadata.generated.ts +++ b/packages/app-store/bookerApps.metadata.generated.ts @@ -22,6 +22,7 @@ import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata"; import matomo_config_json from "./matomo/config.json"; import metapixel_config_json from "./metapixel/config.json"; import mirotalk_config_json from "./mirotalk/config.json"; +import nextcloudtalk_config_json from "./nextcloudtalk/config.json"; import office365video_config_json from "./office365video/config.json"; import ping_config_json from "./ping/config.json"; import plausible_config_json from "./plausible/config.json"; @@ -66,6 +67,7 @@ export const appStoreMetadata = { matomo: matomo_config_json, metapixel: metapixel_config_json, mirotalk: mirotalk_config_json, + nextcloudtalk: nextcloudtalk_config_json, office365video: office365video_config_json, ping: ping_config_json, plausible: plausible_config_json, diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 8d8c7343f8..6cdcd1e5c9 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -15,6 +15,7 @@ const appStore = { jellyconferencing: () => import("./jelly"), jitsivideo: () => import("./jitsivideo"), larkcalendar: () => import("./larkcalendar"), + nextcloudtalkvideo: () => import("./nextcloudtalk"), office365calendar: () => import("./office365calendar"), office365video: () => import("./office365video"), plausible: () => import("./plausible"), diff --git a/packages/app-store/nextcloudtalk/DESCRIPTION.md b/packages/app-store/nextcloudtalk/DESCRIPTION.md new file mode 100644 index 0000000000..b25224b987 --- /dev/null +++ b/packages/app-store/nextcloudtalk/DESCRIPTION.md @@ -0,0 +1,7 @@ +--- +items: + - nextcloudtalk1.jpg + - nextcloudtalk2.jpg +--- + +{DESCRIPTION} diff --git a/packages/app-store/nextcloudtalk/README.md b/packages/app-store/nextcloudtalk/README.md new file mode 100644 index 0000000000..d095398e11 --- /dev/null +++ b/packages/app-store/nextcloudtalk/README.md @@ -0,0 +1,11 @@ +### Obtaining Nextcloud Client ID and Secret + +1. Create a [Nextcloud account](https://nextcloud.com/sign-up), if you don't already have one or [self-host Nextcloud](https://nextcloud.com/install/#instructions-server). +2. Sign in as admin on your Nextcloud. +3. On the upper right, click the profile icon and go to "Administration settings". +4. In the left panel under "Administration" click on "Security". +5. Scroll down until the end of the page to "OAuth 2.0 clients". +6. Under "Add client" give your client a name, e.g. "Cal.com". +7. Set the Redirection URI as `/api/integrations/nextcloudtalk/callback` replacing Cal.com URL with the URI at which your application runs. +8. Click "Add". +9. Copy the Client Identifier and Secret key and add these while enabling the app through Settings -> Admin -> Apps interface \ No newline at end of file diff --git a/packages/app-store/nextcloudtalk/api/add.ts b/packages/app-store/nextcloudtalk/api/add.ts new file mode 100644 index 0000000000..e9efc6e782 --- /dev/null +++ b/packages/app-store/nextcloudtalk/api/add.ts @@ -0,0 +1,36 @@ +import type { NextApiRequest } from "next"; +import { stringify } from "querystring"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState"; + +async function handler(req: NextApiRequest) { + const user = req?.session?.user; + if (!user) { + return { status: 401, body: { error: "Unauthorized" } }; + } + + const appKeys = await getAppKeysFromSlug("nextcloudtalk"); + const hostUrl = appKeys.nextcloudTalkHost as string; + const client_id = appKeys.nextcloudTalkClientId as string; + const client_secret = appKeys.nextcloudTalkClientSecret as string; + const state = encodeOAuthState(req); + + const params = { + response_type: "code", + client_id, + client_secret, + redirect_uri: `${WEBAPP_URL}/api/integrations/nextcloudtalk/callback`, + state, + }; + const query = stringify(params); + const url = `${hostUrl}/index.php/apps/oauth2/authorize?${query}`; + return { url }; +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/app-store/nextcloudtalk/api/callback.ts b/packages/app-store/nextcloudtalk/api/callback.ts new file mode 100644 index 0000000000..46c09bb9ad --- /dev/null +++ b/packages/app-store/nextcloudtalk/api/callback.ts @@ -0,0 +1,70 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import prisma from "@calcom/prisma"; + +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential"; +import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const state = decodeOAuthState(req); + const userId = req.session?.user.id; + if (!userId) { + return res.status(404).json({ message: "No user found" }); + } + const { code } = req.query; + const appKeys = await getAppKeysFromSlug("nextcloudtalk"); + const hostUrl = appKeys.nextcloudTalkHost as string; + const client_id = appKeys.nextcloudTalkClientId as string; + const client_secret = appKeys.nextcloudTalkClientSecret as string; + + const result = await fetch(`${hostUrl}/index.php/apps/oauth2/api/v1/token`, { + method: "POST", + body: JSON.stringify({ + grant_type: "authorization_code", + code, + client_id, + client_secret, + redirect_uri: `${WEBAPP_URL}/api/integrations/nextcloudtalk/callback`, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + if (result.status !== 200) { + let errorMessage = "Something is wrong with the Nextcloud API"; + try { + const responseBody = await result.json(); + errorMessage = responseBody.error; + } catch (e) {} + + res.status(400).json({ message: errorMessage }); + return; + } + + const responseBody = await result.json(); + if (responseBody.error) { + res.status(400).json({ message: responseBody.error }); + return; + } + + // Remove the previous credential if there were linked ones already + await prisma.credential.deleteMany({ + where: { + type: "nextcloudtalk_video", + userId, + appId: "nextcloudtalk", + }, + }); + + await createOAuthAppCredential({ appId: "nextcloudtalk", type: "nextcloudtalk_video" }, responseBody, req); + + res.redirect( + getSafeRedirectUrl(state?.returnTo) ?? + getInstalledAppPath({ variant: "conferencing", slug: "nextcloudtalk" }) + ); +} diff --git a/packages/app-store/nextcloudtalk/api/index.ts b/packages/app-store/nextcloudtalk/api/index.ts new file mode 100644 index 0000000000..eb12c1b4ed --- /dev/null +++ b/packages/app-store/nextcloudtalk/api/index.ts @@ -0,0 +1,2 @@ +export { default as add } from "./add"; +export { default as callback } from "./callback"; diff --git a/packages/app-store/nextcloudtalk/config.json b/packages/app-store/nextcloudtalk/config.json new file mode 100644 index 0000000000..6d0311f045 --- /dev/null +++ b/packages/app-store/nextcloudtalk/config.json @@ -0,0 +1,22 @@ +{ + "/*": "Don't modify slug - If required, do it using cli edit command", + "name": "Nextcloud Talk", + "slug": "nextcloudtalk", + "type": "nextcloudtalk_video", + "logo": "icon.svg", + "url": "https://nextcloud.com/talk", + "variant": "conferencing", + "categories": ["conferencing"], + "publisher": "Cal.com", + "email": "help@cal.com", + "appData": { + "location": { + "type": "integrations:{SLUG}_conferencing", + "label": "{TITLE}", + "linkType": "dynamic" + } + }, + "description": "Nextcloud Talk is a fully self hosted, on-premises audio/video and chat communication service. It features web and mobile apps and is designed to offer the highest degree of security while being easy to use.", + "isTemplate": false, + "__createdUsingCli": true +} diff --git a/packages/app-store/nextcloudtalk/index.ts b/packages/app-store/nextcloudtalk/index.ts new file mode 100644 index 0000000000..e2e9d7b029 --- /dev/null +++ b/packages/app-store/nextcloudtalk/index.ts @@ -0,0 +1,2 @@ +export * as api from "./api"; +export * as lib from "./lib"; diff --git a/packages/app-store/nextcloudtalk/lib/VideoApiAdapter.ts b/packages/app-store/nextcloudtalk/lib/VideoApiAdapter.ts new file mode 100644 index 0000000000..3283ac7aa9 --- /dev/null +++ b/packages/app-store/nextcloudtalk/lib/VideoApiAdapter.ts @@ -0,0 +1,209 @@ +import { stringify } from "querystring"; +import { v4 as uuidv4 } from "uuid"; +import { z } from "zod"; + +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import prisma from "@calcom/prisma"; +import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { CredentialPayload } from "@calcom/types/Credential"; +import type { PartialReference } from "@calcom/types/EventManager"; +import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter"; + +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug"; +import { invalidateCredential } from "../../_utils/invalidateCredential"; +import { OAuthManager } from "../../_utils/oauth/OAuthManager"; +import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired"; +import { oAuthManagerHelper } from "../../_utils/oauth/oAuthManagerHelper"; +import config from "../config.json"; +import { appKeysSchema } from "../zod"; + +const nextcloudEventResultSchema = z.object({ + ocs: z.object({ + meta: z.object({ + status: z.string(), + statuscode: z.number(), + message: z.string(), + }), + data: z.object({ + token: z.string(), + }), + }), +}); + +const NextcloudTalkVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => { + const tokenResponse = oAuthManagerHelper.getTokenObjectFromCredential(credential); + + const clientCredentials = getParsedAppKeysFromSlug(config.slug, appKeysSchema); + + const fetchNextcloudApi = async (endpoint: string, options?: RequestInit) => { + const auth = new OAuthManager({ + credentialSyncVariables: oAuthManagerHelper.credentialSyncVariables, + resourceOwner: { + type: "user", + id: credential.userId, + }, + appSlug: config.slug, + currentTokenObject: tokenResponse, + fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => { + const { nextcloudTalkClientId, nextcloudTalkClientSecret, nextcloudTalkHost } = + await clientCredentials; + if (!refreshToken) { + return null; + } + const params = { + client_id: nextcloudTalkClientId, + client_secret: nextcloudTalkClientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }; + const query = stringify(params); + return fetch(`${nextcloudTalkHost}/index.php/apps/oauth2/api/v1/token?${query}`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: new URLSearchParams({}), + }); + }, + isTokenObjectUnusable: async function (response) { + const myLog = logger.getSubLogger({ prefix: ["nextcloudtalkvideo:isTokenObjectUnusable"] }); + myLog.debug(safeStringify({ status: response.status, ok: response.ok })); + if (!response.ok || (response.status < 200 && response.status >= 300)) { + const responseBody = await response.json(); + myLog.debug(safeStringify({ responseBody })); + + if (responseBody.error === "invalid_grant") { + return { reason: responseBody.error }; + } + } + return null; + }, + isAccessTokenUnusable: async function (response) { + const myLog = logger.getSubLogger({ prefix: ["nextcloudtalkvideo:isAccessTokenUnusable"] }); + myLog.debug(safeStringify({ status: response.status, ok: response.ok })); + if (!response.ok || (response.status < 200 && response.status >= 300)) { + const responseBody = await response.json(); + myLog.debug(safeStringify({ responseBody })); + + if (responseBody.code === 124) { + return { reason: responseBody.message ?? "" }; + } + } + return null; + }, + invalidateTokenObject: () => invalidateCredential(credential.id), + expireAccessToken: () => markTokenAsExpired(credential), + updateTokenObject: async (newTokenObject) => { + await prisma.credential.update({ + where: { + id: credential.id, + }, + data: { + key: newTokenObject, + }, + }); + }, + }); + + const { nextcloudTalkHost } = await clientCredentials; + const { json } = await auth.request({ + url: `${nextcloudTalkHost}/${endpoint}`, + options: { + method: "GET", + ...options, + headers: { + ...options?.headers, + }, + }, + }); + + return json; + }; + + return { + getAvailability: () => { + return Promise.resolve([]); + }, + createMeeting: async (eventData: CalendarEvent): Promise => { + const appKeys = await getAppKeysFromSlug(config.slug); + const meetingPattern = (appKeys.nextcloudTalkPattern as string) || "{uuid}"; + const hostUrl = appKeys.nextcloudTalkHost as string; + + //Allows "/{Type}-with-{Attendees}" slug + const meetingID = meetingPattern + .replaceAll("{uuid}", uuidv4()) + .replaceAll("{Title}", eventData.title) + .replaceAll("{Event Type Title}", eventData.type) + .replaceAll("{Scheduler}", eventData.attendees.map((a) => a.name).join("-")) + .replaceAll("{Organizer}", eventData.organizer.name) + .replaceAll("{Location}", eventData.location || "") + .replaceAll("{Team}", eventData.team?.name || "") + .replaceAll(" ", "-"); //Last Rule! - Replace all blanks (%20) with dashes; + + try { + // Create video link with room type 3 (constant for a public room, see https://nextcloud-talk.readthedocs.io/en/stable/constants/#conversation-types) + const response = await fetchNextcloudApi(`ocs/v2.php/apps/spreed/api/v4/room`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "OCS-APIRequest": "true", + }, + body: JSON.stringify({ + roomType: 3, + roomName: `${meetingID}`, + }), + }); + const result = nextcloudEventResultSchema.parse(response); + + if (result.ocs && result.ocs.data) { + return { + type: config.type, + id: result.ocs.data.token, + password: "", + url: `${hostUrl}/call/${result.ocs.data.token}`, + }; + } + throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`); + } catch (err) { + console.error(err); + /* Prevents meeting creation failure when token is expired */ + throw new Error("Unexpected error"); + } + }, + deleteMeeting: async (uid: string): Promise => { + try { + // Remove video link from Nextcloud + const response = await fetchNextcloudApi(`ocs/v2.php/apps/spreed/api/v4/room/${uid}`, { + method: "DELETE", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "OCS-APIRequest": "true", + }, + }); + const result = nextcloudEventResultSchema.parse(response); + + if (result.ocs && result.ocs.meta && result.ocs.meta.status === "ok") { + return Promise.resolve(); + } + throw new Error(`Failed to delete meeting. Response is ${JSON.stringify(result)}`); + } catch (err) { + return Promise.reject(new Error("Failed to delete meeting")); + } + }, + updateMeeting: (bookingRef: PartialReference): Promise => { + return Promise.resolve({ + type: config.type, + id: bookingRef.meetingId as string, + password: bookingRef.meetingPassword as string, + url: bookingRef.meetingUrl as string, + }); + }, + }; +}; + +export default NextcloudTalkVideoApiAdapter; diff --git a/packages/app-store/nextcloudtalk/lib/index.ts b/packages/app-store/nextcloudtalk/lib/index.ts new file mode 100644 index 0000000000..dc61768d60 --- /dev/null +++ b/packages/app-store/nextcloudtalk/lib/index.ts @@ -0,0 +1 @@ +export { default as VideoApiAdapter } from "./VideoApiAdapter"; diff --git a/packages/app-store/nextcloudtalk/package.json b/packages/app-store/nextcloudtalk/package.json new file mode 100644 index 0000000000..30cb7d0439 --- /dev/null +++ b/packages/app-store/nextcloudtalk/package.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/nextcloudtalk", + "version": "0.0.0", + "main": "./index.ts", + "dependencies": { + "@calcom/lib": "*" + }, + "devDependencies": { + "@calcom/types": "*" + }, + "description": "Nextcloud Talk is a fully self hosted, on-premises audio/video and chat communication service. It features web and mobile apps and is designed to offer the highest degree of security while being easy to use." +} diff --git a/packages/app-store/nextcloudtalk/static/icon.svg b/packages/app-store/nextcloudtalk/static/icon.svg new file mode 100644 index 0000000000..28a7ec7f94 --- /dev/null +++ b/packages/app-store/nextcloudtalk/static/icon.svg @@ -0,0 +1,69 @@ + +image/svg+xml + diff --git a/packages/app-store/nextcloudtalk/static/nextcloudtalk1.jpg b/packages/app-store/nextcloudtalk/static/nextcloudtalk1.jpg new file mode 100644 index 0000000000..6796358e49 Binary files /dev/null and b/packages/app-store/nextcloudtalk/static/nextcloudtalk1.jpg differ diff --git a/packages/app-store/nextcloudtalk/static/nextcloudtalk2.jpg b/packages/app-store/nextcloudtalk/static/nextcloudtalk2.jpg new file mode 100644 index 0000000000..97e1df1545 Binary files /dev/null and b/packages/app-store/nextcloudtalk/static/nextcloudtalk2.jpg differ diff --git a/packages/app-store/nextcloudtalk/zod.ts b/packages/app-store/nextcloudtalk/zod.ts new file mode 100644 index 0000000000..6f3613232b --- /dev/null +++ b/packages/app-store/nextcloudtalk/zod.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const appKeysSchema = z.object({ + nextcloudTalkHost: z.string(), + nextcloudTalkPattern: z.string().optional(), + nextcloudTalkClientId: z.string(), + nextcloudTalkClientSecret: z.string(), +}); + +export const appDataSchema = z.object({}); diff --git a/yarn.lock b/yarn.lock index 982999ae07..620261400f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5051,6 +5051,15 @@ __metadata: languageName: unknown linkType: soft +"@calcom/nextcloudtalk@workspace:packages/app-store/nextcloudtalk": + version: 0.0.0-use.local + resolution: "@calcom/nextcloudtalk@workspace:packages/app-store/nextcloudtalk" + dependencies: + "@calcom/lib": "*" + "@calcom/types": "*" + languageName: unknown + linkType: soft + "@calcom/office365calendar@workspace:packages/app-store/office365calendar": version: 0.0.0-use.local resolution: "@calcom/office365calendar@workspace:packages/app-store/office365calendar"