diff --git a/client/src/api.ts b/client/src/api.ts index 3959d5e6..3ff4faae 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -188,6 +188,15 @@ class API { return res; } + async getProjectFileEmbedHTML(projectID: string, fileID: string) { + const res = await axios.get< + { + embedHTML: string; + } & ConductorBaseResponse + >(`/project/${projectID}/files/${fileID}/embed`); + return res; + } + public cloudflareStreamUploadURL: string = `${ import.meta.env.MODE === "development" && import.meta.env.VITE_DEV_BASE_URL }/api/v1/cloudflare/stream-url`; diff --git a/client/src/components/FilesManager/FilesManager.tsx b/client/src/components/FilesManager/FilesManager.tsx index 8b5a0750..3cec1c22 100644 --- a/client/src/components/FilesManager/FilesManager.tsx +++ b/client/src/components/FilesManager/FilesManager.tsx @@ -40,7 +40,7 @@ import { import api from "../../api"; import { saveAs } from "file-saver"; import { useTypedSelector } from "../../state/hooks"; -import { base64ToBlob } from "../../utils/misc"; +import { base64ToBlob, copyToClipboard } from "../../utils/misc"; import { useMediaQuery } from "react-responsive"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -49,6 +49,7 @@ interface FilesManagerProps extends SegmentProps { toggleFilesManager: () => void; canViewDetails: boolean; projectHasDefaultLicense?: boolean; + projectVisibility?: string; } type FileEntry = ProjectFile & { @@ -63,6 +64,7 @@ const FilesManager: React.FC = ({ toggleFilesManager, canViewDetails = false, projectHasDefaultLicense = false, + projectVisibility = "private", }) => { const TABLE_COLS: { key: string; @@ -407,6 +409,23 @@ const FilesManager: React.FC = ({ } } + async function handleGetEmbedCode(videoID: string) { + try { + const res = await api.getProjectFileEmbedHTML(projectID, videoID); + if (res.data.err) { + throw new Error(res.data.errMsg); + } + const code = res.data.embedHTML; + + await copyToClipboard( + code, + "Embed code copied to clipboard. NOTE: This code is only valid on libretexts.org or libretexts.net domains." + ); + } catch (err) { + handleGlobalError(err); + } + } + /** * Updates state with the a new directory to bring into view. * @@ -481,7 +500,7 @@ const FilesManager: React.FC = ({ /> )} - {item.storageType === "file" && ( + {item.storageType === "file" && !item.isURL && ( = ({ } /> )} + {item.storageType === "file" && + item.isVideo && + item.videoStorageID && + item.access === "public" && + projectVisibility === "public" && ( + { + await handleGetEmbedCode(item.fileID); + }} + /> + )} ); diff --git a/client/src/components/projects/RenderProjectModules.tsx b/client/src/components/projects/RenderProjectModules.tsx index 8600f67f..423e87e8 100644 --- a/client/src/components/projects/RenderProjectModules.tsx +++ b/client/src/components/projects/RenderProjectModules.tsx @@ -148,6 +148,7 @@ const RenderProjectModules: React.FC = ({ project.defaultFileLicense && Object.keys(project.defaultFileLicense).length > 0 } + projectVisibility={project.visibility} /> )} {!showFiles && ( diff --git a/client/src/components/util/CopyTextModal.tsx b/client/src/components/util/CopyTextModal.tsx new file mode 100644 index 00000000..e69de29b diff --git a/client/src/utils/misc.ts b/client/src/utils/misc.ts index 6e5df562..8893da25 100644 --- a/client/src/utils/misc.ts +++ b/client/src/utils/misc.ts @@ -51,10 +51,11 @@ export function parseAndFormatDate(date: Date | string, formatString: string) { return "Unknown Date"; } -export async function copyToClipboard(text: string) { +export async function copyToClipboard(text: string, msg?: string) { try { + const defaultMsg = "Copied text to clipboard."; await navigator.clipboard.writeText(text).then(() => { - alert("Copied text to clipboard."); + alert(msg || defaultMsg); }); } catch (e) { console.error(e); diff --git a/server/api.js b/server/api.js index f88535ad..e827545f 100644 --- a/server/api.js +++ b/server/api.js @@ -1502,6 +1502,11 @@ router.route('/project/:projectID/files/:fileID/captions') projectfilesAPI.updateProjectFileCaptions, ); +router.route('/project/:projectID/files/:fileID/embed').get( + middleware.validateZod(ProjectFileValidators.getProjectFileEmbedHTMLSchema), + projectfilesAPI.getProjectFileEmbedHTML, +) + router.route('/project/:projectID/book/readerresources') .get( authAPI.verifyRequest, diff --git a/server/api/projectfiles.ts b/server/api/projectfiles.ts index a959b3f7..ee7fbc3d 100644 --- a/server/api/projectfiles.ts +++ b/server/api/projectfiles.ts @@ -61,6 +61,7 @@ import { videoDataSchema, updateProjectFileCaptionsSchema, getProjectFileCaptionsSchema, + getProjectFileEmbedHTMLSchema, } from "./validators/projectfiles.js"; import { ZodReqWithOptionalUser, ZodReqWithUser } from "../types"; import { ZodReqWithFiles } from "../types/Express"; @@ -74,6 +75,7 @@ import axios from "axios"; const filesStorage = multer.memoryStorage(); const MAX_UPLOAD_FILES = 20; const MAX_UPLOAD_FILE_SIZE = 100000000; // 100mb +const LIBRETEXTS_ALLOWED_ORIGINS = ["*.libretexts.org", "*.libretexts.net"]; /** * Multer handler to process and validate Project File uploads. @@ -188,6 +190,7 @@ export async function addProjectFile( : req.body.videoData; if (parsedVideoData && parsedVideoData.length) { + const cloudflareUpdates: Promise[] = []; parsedVideoData.forEach((videoData: z.infer) => { const newID = v4(); filesToCreate.push({ @@ -216,8 +219,27 @@ export async function addProjectFile( videoStorageID: videoData.videoID, version: 1, // initial version }); + + // Set allowedOrigins on Cloudflare Stream + const ENDPOINT = `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_STREAM_ACCOUNT_ID}/stream/${videoData.videoID}`; + cloudflareUpdates.push( + axios.post( + ENDPOINT, + { + allowedOrigins: LIBRETEXTS_ALLOWED_ORIGINS, + }, + { + headers: { + Authorization: `Bearer ${process.env.CLOUDFLARE_STREAM_API_TOKEN}`, + "Content-Type": "application/json", + }, + } + ) + ); }); + await Promise.all(cloudflareUpdates); + await ProjectFile.insertMany(filesToCreate); filesToCreate.length = 0; // clear array for use by standard files below } @@ -1173,6 +1195,47 @@ async function getProjectFileCaptions( } } +async function getProjectFileEmbedHTML( + req: z.infer, + res: Response +) { + try { + const { projectID, fileID } = req.params; + + const file = await ProjectFile.findOne({ projectID, fileID }).lean(); + if (!file || !file.videoStorageID) { + return conductor404Err(res); + } + + // Check if file is public + if (file.access !== "public") { + return res.status(400).send({ + err: true, + errMsg: conductorErrors.err90, + }); + } + + const ENDPOINT = `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_STREAM_ACCOUNT_ID}/stream/${file.videoStorageID}/embed`; + const cloudFlareRes = await axios.get(ENDPOINT, { + headers: { + Authorization: `Bearer ${process.env.CLOUDFLARE_STREAM_API_TOKEN}`, + }, + }); + + if (cloudFlareRes.status !== 200) { + throw new Error("Failed to retrieve embed HTML"); + } + + return res.send({ + err: false, + embedHTML: cloudFlareRes.data, + }); + } catch (err) { + debugError(err); + return conductor500Err(res); + } +} + async function updateProjectFileCaptions( req: ZodReqWithFiles< ZodReqWithUser> @@ -1180,10 +1243,14 @@ async function updateProjectFileCaptions( res: Response ) { try { - if(!req.files || req.files.length === 0) { + if (!req.files || req.files.length === 0) { return conductor400Err(res); } - if(!req.body.language || typeof req.body.language !== "string" || req.body.language.length !== 2) { + if ( + !req.body.language || + typeof req.body.language !== "string" || + req.body.language.length !== 2 + ) { return conductor400Err(res); } @@ -1590,6 +1657,7 @@ export default { moveProjectFile, removeProjectFile, getProjectFileCaptions, + getProjectFileEmbedHTML, updateProjectFileCaptions, getPublicProjectFiles, createProjectFileStreamUploadURL, diff --git a/server/api/validators/projectfiles.ts b/server/api/validators/projectfiles.ts index c304bacc..d170564a 100644 --- a/server/api/validators/projectfiles.ts +++ b/server/api/validators/projectfiles.ts @@ -158,6 +158,10 @@ export const getProjectFileCaptionsSchema = z.object({ params: _projectFileParams, }); +export const getProjectFileEmbedHTMLSchema = z.object({ + params: _projectFileParams, +}); + export const getPublicProjectFilesSchema = z.object({ query: PaginationSchema, }); diff --git a/server/conductor-errors.ts b/server/conductor-errors.ts index 40b64111..1a30ea20 100644 --- a/server/conductor-errors.ts +++ b/server/conductor-errors.ts @@ -82,6 +82,7 @@ const conductorErrors = { "err85": "Unable to create chapter.", "err86": "Unable to create book. Please check that your book title is unique to its library and try again.", "err89": "Ticket is not in a valid status for this action.", + "err90": "Cannot get embed code for non-public resource." }; export default conductorErrors;