UNPKG

@wmfs/sharepoint

Version:

A library that allows Node.js applications to interact with a Sharepoint Online site

520 lines (458 loc) 17.8 kB
const process = require('node:process') const crypto = require('node:crypto') const fs = require('node:fs') const msal = require('@azure/msal-node') const axios = require('axios') const { v4: uuid } = require('uuid') const DEFAULT_UPLOAD_CHUNK_SIZE = 1024 * 1024 * 5 // 5MB // see https://learn.microsoft.com/en-us/sharepoint/dev/sp-add-ins/get-to-know-the-sharepoint-rest-service?tabs=csom class Sharepoint { /** * Sets up an instance of the Sharepoint class to interact with the filesystem under the specified site url. * @constructor * @param siteUrl a tenant based url to the site whose file system you wish to interact with. For example https://example.sharepoint.com/sites/SiteName (replacing 'example' with your tenant name and 'SiteName' with your site name). */ constructor (siteUrl) { if (!siteUrl) { throw new Error('siteUrl has not been specified') } const authScope = process.env.SHAREPOINT_AUTH_SCOPE if (!authScope) { throw new Error('SHAREPOINT_AUTH_SCOPE environment variable has not been set') } if (!(authScope.toLowerCase().startsWith('https://') && authScope.toLowerCase().endsWith('.sharepoint.com/.default'))) { throw new Error('SHAREPOINT_AUTH_SCOPE environment variable value is not valid - it must begin with "https://" and end with ".sharepoint.com/.default"') } const clientId = process.env.SHAREPOINT_CLIENT_ID if (!clientId) { throw new Error('SHAREPOINT_CLIENT_ID environment variable has not been set') } const tenantId = process.env.SHAREPOINT_TENANT_ID if (!tenantId) { throw new Error('SHAREPOINT_TENANT_ID environment variable has not been set') } const certPassphrase = process.env.SHAREPOINT_CERT_PASSPHRASE if (!certPassphrase) { throw new Error('SHAREPOINT_CERT_PASSPHRASE environment variable has not been set') } const certFingerprint = process.env.SHAREPOINT_CERT_FINGERPRINT if (!certFingerprint) { throw new Error('SHAREPOINT_CERT_FINGERPRINT environment variable has not been set') } if (certFingerprint.length !== 40) { throw new Error('SHAREPOINT_CERT_FINGERPRINT environment variable value is not valid - it must be exactly 40 characters in length') } const certPrivateKeyFileFile = process.env.SHAREPOINT_CERT_PRIVATE_KEY_FILE if (!certPrivateKeyFileFile) { throw new Error('SHAREPOINT_CERT_PRIVATE_KEY_FILE environment variable has not been set') } if (!(fs.existsSync(certPrivateKeyFileFile) && fs.lstatSync(certPrivateKeyFileFile).isFile())) { throw new Error(`specified sharepoint certificate private key file ('${certPrivateKeyFileFile}') does not exist`) } this.siteUrl = siteUrl this.authScope = authScope this.accessToken = null this.baseUrl = null this.encodedBaseUrl = null const certPrivateKeyObject = crypto.createPrivateKey({ key: fs.readFileSync(certPrivateKeyFileFile, 'utf8'), passphrase: certPassphrase, format: 'pem' }) const certPrivateKey = certPrivateKeyObject.export({ format: 'pem', type: 'pkcs8' }) const config = { auth: { clientCertificate: { thumbprint: certFingerprint, privateKey: certPrivateKey }, clientId, authority: `https://login.microsoftonline.com/${tenantId}` } } this.debug = process.env.SHAREPOINT_DEBUG this.debug = this.debug && (this.debug.toUpperCase() === 'Y' || this.debug.toUpperCase() === 'YES' || this.debug.toUpperCase() === 'TRUE') if (this.debug) { // configure msal to log debugging information to the console config.system = { loggerOptions: { loggerCallback (loglevel, message, containsPii) { console.log(message) }, piiLoggingEnabled: false, logLevel: msal.LogLevel.Verbose } } } this.cca = new msal.ConfidentialClientApplication(config) } /** * Carries out the login process and then internally stores the access token, which is used when interacting with the Sharepoint REST API. * @returns {Promise<void>} */ async authenticate () { const { accessToken } = await this.cca.acquireTokenByClientCredential({ scopes: [this.authScope] }) this.accessToken = accessToken } /** * Determines the base path of the site and populates the baseUrl and encodedBaseUrl properties. * So for example, if your site url is 'https://example.sharepoint.com/sites/TestSite', then your base url would be '/sites/TestSite'. * This is used to construct paths when interacting with the sites file system. * @returns {Promise<void>} */ async getWebEndpoint () { checkHeaders(this.accessToken) let response try { response = await axios.get( `${this.siteUrl}/_api/web`, { headers: { Authorization: `Bearer ${this.accessToken}`, Accept: 'application/json;odata=verbose' } } ) } catch (err) { logAxiosError(this.debug, err, 'Unable to get web endpoint') } this.baseUrl = response.data.d.ServerRelativeUrl this.encodedBaseUrl = encodeURIComponent(response.data.d.ServerRelativeUrl) } /** * Returns an array of objects, each describing a file or folder in the specified folder. * Note that folders will appear in the array first, and both files and folders will be * sorted by name. * @param path The path representing a folder relative to the site folder. * @returns {Promise<*[]>} An array of objects, each describing a file or folder */ async getContents (path) { checkHeaders(this.accessToken) const get = type => { try { return axios.get( `${this.siteUrl}/_api/web/GetFolderByServerRelativeUrl('${this.encodedBaseUrl}${encodeURIComponent(path)}')/${type}`, { headers: { Authorization: `Bearer ${this.accessToken}`, Accept: 'application/json;odata=verbose' }, responseType: 'json' } ) } catch (err) { logAxiosError(this.debug, err, `Failed to get folder contents (type: ${type})`) } } const folders = await get('Folders') const files = await get('Files') // natural sort of files/folders const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) const nameCompare = (a, b) => { return collator.compare(a.Name, b.Name) } folders.data.d.results.sort(nameCompare) files.data.d.results.sort(nameCompare) return [...folders.data.d.results, ...files.data.d.results] } /** * Create the specified folder. * @param path The path of the folder you want to create relative to the site folder * @returns {Promise<void>} */ async createFolder (path) { if (!path) { throw new Error('You must provide a path.') } checkHeaders(this.accessToken) const formDigestValue = await getFormDigestValue(this.siteUrl, this.accessToken) try { await axios({ method: 'post', url: `${this.siteUrl}/_api/web/folders`, headers: { Authorization: `Bearer ${this.accessToken}`, Accept: 'application/json;odata=verbose', 'Content-Type': 'application/json;odata=verbose', // docs say use 'application/json', but call fails if we do 'X-RequestDigest': formDigestValue }, data: { __metadata: { type: 'SP.Folder' }, ServerRelativeUrl: `${this.encodedBaseUrl}${encodeURIComponent(path)}` }, responseType: 'json' }) } catch (err) { logAxiosError(this.debug, err, 'Failed to create specified folder') } } /** * Check the specified folder exists. * @param path The path of the folder you want to check exists * @returns {Promise<void>} */ async checkFolderExists (path) { if (!path) { throw new Error('You must provide a path.') } checkHeaders(this.accessToken) try { const res = await axios.get( `${this.siteUrl}/_api/web/GetFolderByServerRelativeUrl('${this.encodedBaseUrl}${encodeURIComponent(path)}')/Exists`, { headers: { Authorization: `Bearer ${this.accessToken}`, Accept: 'application/json;odata=verbose' }, responseType: 'json' } ) return res.data.d.Exists } catch (err) { logAxiosError(this.debug, err, 'Failed to check if folder exists') } } /** * Deletes the specified folder. * @param path The path of the folder you want to delete relative to the site folder * @returns {Promise<void>} */ async deleteFolder (path) { if (!path) { throw new Error('You must provide a path.') } checkHeaders(this.accessToken) const formDigestValue = await getFormDigestValue(this.siteUrl, this.accessToken) try { await axios({ method: 'post', url: `${this.siteUrl}/_api/web/GetFolderByServerRelativeUrl('${this.encodedBaseUrl}${encodeURIComponent(path)}')`, headers: { Authorization: `Bearer ${this.accessToken}`, 'X-RequestDigest': formDigestValue, 'X-HTTP-Method': 'DELETE' } }) } catch (err) { logAxiosError(this.debug, err, 'Unable to delete folder') } } /** * Creates and populates a (non-binary) file. Note that if the specified file already exists, it will be overwritten. * @param options An object that must contain a 'fileName' (the name of the file), 'path' (the path to a folder in * which the file will be created) and 'data' (the contents of the file) properties. * @returns {Promise<void>} */ async createFile (options) { if (!options.fileName) { throw new Error('You must provide a file name.') } if (!options.data) { throw new Error('You must provide data.') } checkHeaders(this.accessToken) const { data } = options const path = encodeURIComponent(options.path) const fileName = encodeURIComponent(options.fileName) const formDigestValue = await getFormDigestValue(this.siteUrl, this.accessToken) try { await axios({ method: 'post', url: `${this.siteUrl}/_api/web/GetFolderByServerRelativeUrl('${this.encodedBaseUrl}${path}')/Files/add(url='${fileName}', overwrite=true)`, data, headers: { Accept: 'application/json;odata=verbose', Authorization: `Bearer ${this.accessToken}`, 'X-RequestDigest': formDigestValue } }) } catch (err) { logAxiosError(this.debug, err, 'Unable to create file') } } // see https://learn.microsoft.com/en-us/previous-versions/office/developer/sharepoint-rest-reference/dn450841(v=office.15) /** * Creates a file and uploads its contents in chunks. * @param options An object that must contain a 'fileName' (the name of the file), 'path' (the path to a folder in * which the file will be created), 'stream' (a file data stream) and 'fileSize' (the size of the file in bytes) * properties. It can also optionally specify a 'chunkSize' property to specify the size (again in bytes) of each * chunk * @returns {Promise<{filePath: string, url: string, Name: string}>} */ async createFileChunked (options) { const { stream, fileSize } = options const path = encodeURIComponent(options.path) const fileName = encodeURIComponent(options.fileName) const chunkSize = options.chunkSize || DEFAULT_UPLOAD_CHUNK_SIZE checkHeaders(this.accessToken) const formDigestValue = await getFormDigestValue(this.siteUrl, this.accessToken) await this.createFile({ path, fileName, data: ' ' }) const uploadId = uuid() let firstChunk = true let sent = 0 const self = this const baseUploadUrl = `${self.siteUrl}/_api/web/GetFileByServerRelativeUrl('${self.encodedBaseUrl}${path}${encodeURIComponent('/')}${fileName}')` await new Promise(function (resolve, reject) { stream.on('data', async (data) => { try { stream.pause() if (firstChunk) { firstChunk = false const response = await axios({ method: 'post', url: `${baseUploadUrl}/startupload(uploadId=guid'${uploadId}')`, data, headers: { Authorization: `Bearer ${self.accessToken}`, 'X-RequestDigest': formDigestValue } }) sent = Number(response.data.value) if (sent >= fileSize) { await axios({ method: 'post', url: `${baseUploadUrl}/finishupload(uploadId=guid'${uploadId}',fileoffset=${sent})`, headers: { Authorization: `Bearer ${self.accessToken}`, 'X-RequestDigest': formDigestValue } }) resolve() } } else if (sent + chunkSize >= fileSize) { await axios({ method: 'post', url: `${baseUploadUrl}/finishupload(uploadId=guid'${uploadId}',fileoffset=${sent})`, data, headers: { Authorization: `Bearer ${self.accessToken}`, 'X-RequestDigest': formDigestValue } }) resolve() } else { const response = await axios({ method: 'post', url: `${baseUploadUrl}/continueupload(uploadId=guid'${uploadId}',fileoffset=${sent})`, data, headers: { Authorization: `Bearer ${self.accessToken}`, 'X-RequestDigest': formDigestValue } }) sent = Number(response.data.value) } stream.resume() } catch (e) { stream.destroy() await axios({ method: 'post', url: `${baseUploadUrl}/cancelupload(uploadId=guid'${uploadId}')`, headers: { Authorization: `Bearer ${self.accessToken}`, 'X-RequestDigest': formDigestValue } }) reject(e) } }) stream.on('error', async err => { await axios({ method: 'post', url: `${baseUploadUrl}/cancelupload(uploadId=guid'${uploadId}')`, headers: { Authorization: `Bearer ${self.accessToken}`, 'X-RequestDigest': formDigestValue } }) reject(err) }) }) return { Name: fileName, filePath: `${path}/${fileName}`, url: `${this.baseUrl}/${path}/${fileName}`, fileSize } } /** * Deletes the specified file * @param options An object that must contain a 'fileName' (the name of the file) and 'path' (the path to a folder in * which the file is to be deleted) properties. * @returns {Promise<void>} */ async deleteFile (options) { if (!options.fileName) { throw new Error('You must provide a file name.') } checkHeaders(this.accessToken) const path = encodeURIComponent(options.path) const fileName = encodeURIComponent(options.fileName) const formDigestValue = await getFormDigestValue(this.siteUrl, this.accessToken) try { await axios({ method: 'post', url: `${this.siteUrl}/_api/web/GetFileByServerRelativeUrl('${this.encodedBaseUrl}${path}/${fileName}')`, headers: { Authorization: `Bearer ${this.accessToken}`, 'X-RequestDigest': formDigestValue, 'X-HTTP-Method': 'DELETE' } }) } catch (err) { logAxiosError(this.debug, err, 'Unable to delete file') } } } // based on https://axios-http.com/docs/handling_errors function logAxiosError (debug, err, msg) { if (debug) { if (err.response) { // request was made but server responded with a non-2xx status code console.log(`server responded with status code ${err.response.status}`) console.log(`data: ${err.response.data}`) } else if (err.request) { // request was made but no response was received console.log(err.request) } else { // something happened in setting up the request that triggered an error console.log('Error', err.message) } console.log(err.config) } throw new Error(msg) } function checkHeaders (accessToken) { if (!accessToken) { throw new Error('Access token not available - please authenticate() prior to calling this function') } } async function getFormDigestValue (siteUrl, accessToken) { checkHeaders(accessToken) let response try { response = await axios({ method: 'post', url: `${siteUrl}/_api/contextinfo`, headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json;odata=verbose' }, responseType: 'json', data: {} }) } catch (err) { logAxiosError(err, 'Unable to get form digest value') } return response.data.d.GetContextWebInformation.FormDigestValue } module.exports = Sharepoint