diff --git a/packages/api/src/@core/connections/filestorage/services/sharepoint/sharepoint.service.ts b/packages/api/src/@core/connections/filestorage/services/sharepoint/sharepoint.service.ts index 084e537b1..2db6e673e 100644 --- a/packages/api/src/@core/connections/filestorage/services/sharepoint/sharepoint.service.ts +++ b/packages/api/src/@core/connections/filestorage/services/sharepoint/sharepoint.service.ts @@ -139,6 +139,17 @@ export class SharepointConnectionService extends AbstractBaseConnectionService { 'OAuth credentials : sharepoint filestorage ' + JSON.stringify(data), ); + // get site_id from tenant and sitename + const site_details = await axios.get( + `https://graph.microsoft.com/v1.0/sites/${tenant}.sharepoint.com:/sites/${site}`, + { + headers: { + Authorization: `Bearer ${data.access_token}`, + }, + }, + ); + const site_id = site_details.data.id; + let db_res; const connection_token = uuidv4(); @@ -153,7 +164,7 @@ export class SharepointConnectionService extends AbstractBaseConnectionService { account_url: ( CONNECTORS_METADATA['filestorage']['sharepoint'].urls .apiUrl as DynamicApiUrl - )(site), + )(site_id), expiration_timestamp: new Date( new Date().getTime() + Number(data.expires_in) * 1000, ), @@ -172,7 +183,7 @@ export class SharepointConnectionService extends AbstractBaseConnectionService { account_url: ( CONNECTORS_METADATA['filestorage']['sharepoint'].urls .apiUrl as DynamicApiUrl - )(site), + )(site_id), access_token: this.cryptoService.encrypt(data.access_token), refresh_token: this.cryptoService.encrypt(data.refresh_token), expiration_timestamp: new Date( diff --git a/packages/api/src/@core/sync/sync.service.ts b/packages/api/src/@core/sync/sync.service.ts index 00a5c9633..3681361f5 100644 --- a/packages/api/src/@core/sync/sync.service.ts +++ b/packages/api/src/@core/sync/sync.service.ts @@ -546,6 +546,7 @@ export class CoreSyncService { try { await task(); } catch (error) { + console.log(error); this.logger.error(`File Storage Task failed: ${error.message}`, error); } } diff --git a/packages/api/src/@core/utils/types/original/original.file-storage.ts b/packages/api/src/@core/utils/types/original/original.file-storage.ts index 09353fcb9..454af846e 100644 --- a/packages/api/src/@core/utils/types/original/original.file-storage.ts +++ b/packages/api/src/@core/utils/types/original/original.file-storage.ts @@ -1,7 +1,5 @@ -import { - BoxSharedLinkInput, - BoxSharedLinkOutput, -} from '@filestorage/sharedlink/services/box/types'; +/* INPUT */ + import { OnedriveSharedLinkInput, OnedriveSharedLinkOutput, @@ -37,7 +35,40 @@ import { OnedriveDriveOutput, } from '@filestorage/drive/services/onedrive/types'; -/* INPUT */ +import { + SharepointSharedLinkInput, + SharepointSharedLinkOutput, +} from '@filestorage/sharedlink/services/sharepoint/types'; + +import { + SharepointPermissionInput, + SharepointPermissionOutput, +} from '@filestorage/permission/services/sharepoint/types'; + +import { + SharepointGroupInput, + SharepointGroupOutput, +} from '@filestorage/group/services/sharepoint/types'; + +import { + SharepointUserInput, + SharepointUserOutput, +} from '@filestorage/user/services/sharepoint/types'; + +import { + SharepointFolderInput, + SharepointFolderOutput, +} from '@filestorage/folder/services/sharepoint/types'; + +import { + SharepointFileInput, + SharepointFileOutput, +} from '@filestorage/file/services/sharepoint/types'; + +import { + SharepointDriveInput, + SharepointDriveOutput, +} from '@filestorage/drive/services/sharepoint/types'; import { BoxFileInput, @@ -56,6 +87,9 @@ import { BoxUserOutput, } from '@filestorage/user/services/box/types'; import { + BoxSharedLinkInput, + BoxSharedLinkOutput, +} from '@filestorage/sharedlink/services/box/types'; GoogleDriveFileInput, GoogleDriveFileOutput, } from '@filestorage/file/services/googledrive/types'; @@ -72,28 +106,39 @@ import { export type OriginalFileInput = | BoxFileInput | OnedriveFileInput + | SharepointFileInput; | GoogleDriveFileInput; /* folder */ export type OriginalFolderInput = | BoxFolderInput | OnedriveFolderInput + | SharepointFolderInput; | GoogleDriveFolderInput; /* permission */ -export type OriginalPermissionInput = any | OnedrivePermissionInput; +export type OriginalPermissionInput = + | any + | OnedrivePermissionInput + | SharepointPermissionInput; /* shared link */ export type OriginalSharedLinkInput = any; /* drive */ -export type OriginalDriveInput = GoogleDriveDriveInput | OnedriveDriveInput; +export type OriginalDriveInput = GoogleDriveDriveInput | OnedriveDriveInput | SharepointDriveInput; /* group */ -export type OriginalGroupInput = BoxGroupInput | OnedriveGroupInput; +export type OriginalGroupInput = + | BoxGroupInput + | OnedriveGroupInput + | SharepointGroupInput; /* user */ -export type OriginalUserInput = BoxUserInput | OnedriveUserInput; +export type OriginalUserInput = + | BoxUserInput + | OnedriveUserInput + | SharepointUserInput; export type FileStorageObjectInput = | OriginalFileInput @@ -110,28 +155,39 @@ export type FileStorageObjectInput = export type OriginalFileOutput = | BoxFileOutput | OnedriveFileOutput + | SharepointFileOutput; | GoogleDriveFileOutput; /* folder */ export type OriginalFolderOutput = | BoxFolderOutput | OnedriveFolderOutput + | SharepointFolderOutput; | GoogleDriveFolderOutput; /* permission */ -export type OriginalPermissionOutput = any | OnedrivePermissionOutput; +export type OriginalPermissionOutput = + | any + | OnedrivePermissionOutput + | SharepointPermissionOutput; /* shared link */ export type OriginalSharedLinkOutput = any; /* drive */ -export type OriginalDriveOutput = GoogleDriveDriveOutput | OnedriveDriveOutput; +export type OriginalDriveOutput = GoogleDriveDriveOutput | OnedriveDriveOutput | SharepointDriveOutput; /* group */ -export type OriginalGroupOutput = BoxGroupOutput | OnedriveGroupOutput; +export type OriginalGroupOutput = + | BoxGroupOutput + | OnedriveGroupOutput + | SharepointGroupOutput; /* user */ -export type OriginalUserOutput = BoxUserOutput | OnedriveUserOutput; +export type OriginalUserOutput = + | BoxUserOutput + | OnedriveUserOutput + | SharepointUserOutput; export type FileStorageObjectOutput = | OriginalFileOutput @@ -144,8 +200,10 @@ export type FileStorageObjectOutput = export type OriginalSharedlinkInput = | BoxSharedLinkInput - | OnedriveSharedLinkInput; + | OnedriveSharedLinkInput + | SharepointSharedLinkInput; export type OriginalSharedlinkOutput = | BoxSharedLinkOutput - | OnedriveSharedLinkOutput; + | OnedriveSharedLinkOutput + | SharepointSharedLinkOutput; diff --git a/packages/api/src/filestorage/drive/drive.module.ts b/packages/api/src/filestorage/drive/drive.module.ts index 72e78a5b2..65d4a6c0d 100644 --- a/packages/api/src/filestorage/drive/drive.module.ts +++ b/packages/api/src/filestorage/drive/drive.module.ts @@ -1,3 +1,8 @@ +import { OnedriveDriveMapper } from './services/onedrive/mappers'; +import { OnedriveService } from './services/onedrive'; +import { SharepointDriveMapper } from './services/sharepoint/mappers'; +import { SharepointService } from './services/sharepoint'; +import { BullQueueModule } from '@@core/@core-services/queues/queue.module'; import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; import { Utils } from '@filestorage/@lib/@utils'; @@ -24,6 +29,8 @@ import { SyncService } from './sync/sync.service'; GoogleDriveService, GoogleDriveMapper, OnedriveDriveMapper, + SharepointService, + SharepointDriveMapper, ], exports: [SyncService], }) diff --git a/packages/api/src/filestorage/drive/services/sharepoint/index.ts b/packages/api/src/filestorage/drive/services/sharepoint/index.ts new file mode 100644 index 000000000..9fece13b1 --- /dev/null +++ b/packages/api/src/filestorage/drive/services/sharepoint/index.ts @@ -0,0 +1,71 @@ +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { ApiResponse } from '@@core/utils/types'; +import { SyncParam } from '@@core/utils/types/interface'; +import { FileStorageObject } from '@filestorage/@lib/@types'; +import { IDriveService } from '@filestorage/drive/types'; +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { ServiceRegistry } from '../registry.service'; +import { SharepointDriveOutput } from './types'; +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { OriginalDriveOutput } from '@@core/utils/types/original/original.file-storage'; + +@Injectable() +export class SharepointService implements IDriveService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + `${FileStorageObject.file.toUpperCase()}:${SharepointService.name}`, + ); + this.registry.registerService('sharepoint', this); + } + + async addDrive( + driveData: DesunifyReturnType, + linkedUserId: string, + ): Promise> { + // No API to add drive in Sharepoint + return; + } + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'sharepoint', + vertical: 'filestorage', + }, + }); + + const resp = await axios.get(`${connection.account_url}/drives`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + + const drives: SharepointDriveOutput[] = resp.data.value; + this.logger.log(`Synced sharepoint drives !`); + + return { + data: drives, + message: 'Sharepoint drives retrived', + statusCode: 200, + }; + } catch (error) { + console.log(error.response); + throw error; + } + } +} diff --git a/packages/api/src/filestorage/drive/services/sharepoint/mappers.ts b/packages/api/src/filestorage/drive/services/sharepoint/mappers.ts new file mode 100644 index 000000000..4b11e0283 --- /dev/null +++ b/packages/api/src/filestorage/drive/services/sharepoint/mappers.ts @@ -0,0 +1,86 @@ +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { Utils } from '@filestorage/@lib/@utils'; +import { + UnifiedFilestorageDriveInput, + UnifiedFilestorageDriveOutput, +} from '@filestorage/drive/types/model.unified'; +import { Injectable } from '@nestjs/common'; +import { SharepointDriveInput, SharepointDriveOutput } from './types'; +import { IDriveMapper } from '@filestorage/drive/types'; + +@Injectable() +export class SharepointDriveMapper implements IDriveMapper { + constructor( + private mappersRegistry: MappersRegistry, + private utils: Utils, + private coreUnificationService: CoreUnification, + ) { + this.mappersRegistry.registerService( + 'filestorage', + 'drive', + 'sharepoint', + this, + ); + } + + async desunify( + source: UnifiedFilestorageDriveInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } + + async unify( + source: SharepointDriveOutput | SharepointDriveOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleDriveToUnified( + source, + connectionId, + customFieldMappings, + ); + } + // Handling array of SharepointDriveOutput + return Promise.all( + source.map((drive) => + this.mapSingleDriveToUnified(drive, connectionId, customFieldMappings), + ), + ); + } + + private async mapSingleDriveToUnified( + drive: SharepointDriveOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = drive[mapping.remote_id]; + } + } + + const result: UnifiedFilestorageDriveOutput = { + remote_id: drive.id, + remote_data: drive, + name: drive.name, + remote_created_at: drive.createdDateTime, + drive_url: drive.webUrl, + field_mappings, + }; + + return result; + } +} diff --git a/packages/api/src/filestorage/drive/services/sharepoint/types.ts b/packages/api/src/filestorage/drive/services/sharepoint/types.ts new file mode 100644 index 000000000..8ce603089 --- /dev/null +++ b/packages/api/src/filestorage/drive/services/sharepoint/types.ts @@ -0,0 +1,130 @@ +/** + * Represents the response from the Sharepoint API for a specific drive. + * @see https://learn.microsoft.com/en-us/graph/api/resources/drive?view=graph-rest-1.0 + */ +export interface SharepointDriveOutput { + /** The date and time when the drive was created. */ + readonly createdDateTime: string; + /** A user-visible description of the drive. */ + description: string; + /** The unique identifier of the drive. */ + readonly id: string; + /** The date and time when the drive was last modified. */ + readonly lastModifiedDateTime: string; + /** The name of the drive. */ + name: string; + /** URL that displays the resource in the browser. */ + readonly webUrl: string; + /** Describes the type of drive represented by this resource. */ + readonly driveType: 'personal' | 'business' | 'documentLibrary'; + /** Identity of the user, device, or application which created the item. Read-only. */ + readonly createdBy: IdentitySet; + /** Identity of the user, device, and application which last modified the item. Read-only. */ + readonly lastModifiedBy: IdentitySet; + /** The user account that owns the drive. */ + readonly owner?: IdentitySet; + /** Information about the drive's storage space quota. */ + readonly quota?: Quota; + /** SharePoint identifiers for REST compatibility. */ + readonly sharepointIds?: SharepointIds; + /** Indicates that this is a system-managed drive. */ + readonly system?: SystemFacet; +} + +/** + * Represents a set of identities, such as user, device, or application identities. + * @see https://learn.microsoft.com/en-us/graph/api/resources/identityset?view=graph-rest-1.0 + */ +export interface IdentitySet { + /** Identity representing an application. */ + readonly application?: Identity; + /** Identity representing an application instance. */ + readonly applicationInstance?: Identity; + /** Identity representing a conversation. */ + readonly conversation?: Identity; + /** Identity representing a conversation identity type. */ + readonly conversationIdentityType?: Identity; + /** Identity representing a device. */ + readonly device?: Identity; + /** Identity representing encrypted identity information. */ + readonly encrypted?: Identity; + /** Identity representing an on-premises identity. */ + readonly onPremises?: Identity; + /** Identity representing a guest user. */ + readonly guest?: Identity; + /** Identity representing a phone identity. */ + readonly phone?: Identity; + /** Identity representing a user. */ + readonly user?: Identity; +} + +/** + * Represents a generic identity used in various identity sets. + * @see https://learn.microsoft.com/en-us/graph/api/resources/identity?view=graph-rest-1.0 + */ +export interface Identity { + /** The display name of the identity. */ + readonly displayName?: string; + /** The ID of the identity. */ + readonly id?: string; + /** The identity type (such as user, application, or device). */ + readonly identityType?: string; + /** The email address of the identity. */ + readonly email?: string; +} + +/** + * Represents the storage quota information of a drive. + */ +export interface Quota { + /** The total number of bytes deleted from the drive. */ + readonly deleted: number; + /** The total number of bytes remaining in the drive's quota. */ + readonly remaining: number; + /** The state of the drive's quota (e.g., normal, nearing, exceeded). */ + readonly state: 'normal' | 'nearing' | 'critical' | 'exceeded'; + /** The total number of bytes in the drive's quota. */ + readonly total: number; + /** The total number of bytes used in the drive. */ + readonly used: number; + /** Information about storage plan upgrades, if available. */ + readonly storagePlanInformation?: StoragePlanInformation; +} + +/** + * Represents storage plan upgrade information. + */ +export interface StoragePlanInformation { + /** Indicates whether an upgrade is available for the storage plan. */ + readonly upgradeAvailable: boolean; +} + +/** + * Represents SharePoint-specific identifiers for an item. + */ +export interface SharepointIds { + /** The unique identifier (GUID) for the item's list in SharePoint. */ + readonly listId: string; + /** An integer identifier for the item within the containing list. */ + readonly listItemId: string; + /** The unique identifier (GUID) for the item within OneDrive for Business or a SharePoint site. */ + readonly listItemUniqueId: string; + /** The unique identifier (GUID) for the item's site collection (SPSite). */ + readonly siteId: string; + /** The SharePoint URL for the site that contains the item. */ + readonly siteUrl: string; + /** The unique identifier (GUID) for the tenancy. */ + readonly tenantId: string; + /** The unique identifier (GUID) for the item's site (SPWeb). */ + readonly webId: string; +} + +/** + * Represents system-related metadata for the drive. + */ +export interface SystemFacet { + // Add properties specific to the system facet if needed. + readonly [key: string]: any; +} + +export type SharepointDriveInput = Partial; diff --git a/packages/api/src/filestorage/file/file.module.ts b/packages/api/src/filestorage/file/file.module.ts index aa6754405..c24773887 100644 --- a/packages/api/src/filestorage/file/file.module.ts +++ b/packages/api/src/filestorage/file/file.module.ts @@ -1,3 +1,8 @@ +import { SharepointFileMapper } from './services/sharepoint/mappers'; +import { SharepointService } from './services/sharepoint'; +import { OnedriveFileMapper } from './services/onedrive/mappers'; +import { OnedriveService } from './services/onedrive'; +import { BullQueueModule } from '@@core/@core-services/queues/queue.module'; import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; import { Module } from '@nestjs/common'; import { FileController } from './file.controller'; @@ -28,6 +33,8 @@ import { GoogleDriveFileMapper } from './services/googledrive/mappers'; GoogleDriveFileMapper, /* PROVIDERS SERVICES */ BoxService, + SharepointService, + SharepointFileMapper, OnedriveService, GoogleDriveService, ], diff --git a/packages/api/src/filestorage/file/services/sharepoint/index.ts b/packages/api/src/filestorage/file/services/sharepoint/index.ts new file mode 100644 index 000000000..44d5bc8a7 --- /dev/null +++ b/packages/api/src/filestorage/file/services/sharepoint/index.ts @@ -0,0 +1,92 @@ +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { ApiResponse } from '@@core/utils/types'; +import { SyncParam } from '@@core/utils/types/interface'; +import { FileStorageObject } from '@filestorage/@lib/@types'; +import { IFileService } from '@filestorage/file/types'; +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { ServiceRegistry } from '../registry.service'; +import { SharepointFileOutput } from './types'; + +@Injectable() +export class SharepointService implements IFileService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + `${FileStorageObject.file.toUpperCase()}:${SharepointService.name}`, + ); + this.registry.registerService('sharepoint', this); + } + + // todo: add addFile method + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId, id_folder } = data; + if (!id_folder) return; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'sharepoint', + vertical: 'filestorage', + }, + }); + + const folder = await this.prisma.fs_folders.findUnique({ + where: { + id_fs_folder: id_folder as string, + }, + }); + + const resp = await axios.get( + `${connection.account_url}/drive/items/${folder.remote_id}/children`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + + const files: SharepointFileOutput[] = resp.data.value.filter( + (elem) => !elem.folder, // files don't have a folder property + ); + + // Add permission shared link is also included in permissions in one-drive) + await Promise.all( + files.map(async (driveItem) => { + const resp = await axios.get( + `${connection.account_url}/drive/items/${driveItem.id}/permissions`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + driveItem.permissions = resp.data.value; + }), + ); + + this.logger.log(`Synced sharepoint files !`); + return { + data: files, + message: "One Drive's files retrieved", + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/filestorage/file/services/sharepoint/mappers.ts b/packages/api/src/filestorage/file/services/sharepoint/mappers.ts new file mode 100644 index 000000000..7fd0f227b --- /dev/null +++ b/packages/api/src/filestorage/file/services/sharepoint/mappers.ts @@ -0,0 +1,136 @@ +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { + OriginalPermissionOutput, + OriginalSharedLinkOutput, +} from '@@core/utils/types/original/original.file-storage'; +import { FileStorageObject } from '@filestorage/@lib/@types'; +import { Utils } from '@filestorage/@lib/@utils'; +import { IFileMapper } from '@filestorage/file/types'; +import { + UnifiedFilestorageFileInput, + UnifiedFilestorageFileOutput, +} from '@filestorage/file/types/model.unified'; +import { UnifiedFilestorageSharedlinkOutput } from '@filestorage/sharedlink/types/model.unified'; +import { Injectable } from '@nestjs/common'; +import { SharepointFileInput, SharepointFileOutput } from './types'; + +@Injectable() +export class SharepointFileMapper implements IFileMapper { + constructor( + private mappersRegistry: MappersRegistry, + private utils: Utils, + private coreUnificationService: CoreUnification, + ) { + this.mappersRegistry.registerService( + 'filestorage', + 'file', + 'sharepoint', + this, + ); + } + + async desunify( + source: UnifiedFilestorageFileInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + // todo: do something with customFieldMappings + return { + name: source.name, + file: { + mimeType: source.mime_type, + }, + size: parseInt(source.size), + parentReference: { + id: source.folder_id, + }, + }; + } + + async unify( + source: SharepointFileOutput | SharepointFileOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleFileToUnified( + source, + connectionId, + customFieldMappings, + ); + } + // Handling array of SharepointFileOutput + return Promise.all( + source.map((file) => + this.mapSingleFileToUnified(file, connectionId, customFieldMappings), + ), + ); + } + + private async mapSingleFileToUnified( + file: SharepointFileOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = file[mapping.remote_id]; + } + } + + const opts: any = {}; + if (file.permissions?.length) { + const permissions = await this.coreUnificationService.unify< + OriginalPermissionOutput[] + >({ + sourceObject: file.permissions, + targetType: FileStorageObject.permission, + providerName: 'sharepoint', + vertical: 'filestorage', + connectionId, + customFieldMappings: [], + }); + opts.permissions = permissions; + + // shared link + if (file.permissions.some((p) => p.link)) { + const sharedLinks = + await this.coreUnificationService.unify({ + sourceObject: file.permissions.find((p) => p.link), + targetType: FileStorageObject.sharedlink, + providerName: 'sharepoint', + vertical: 'filestorage', + connectionId, + customFieldMappings: [], + }); + opts.shared_links = sharedLinks; + } + } + + // todo: handle folder + + return { + remote_id: file.id, + remote_data: file, + name: file.name, + file_url: file.webUrl, + mime_type: file.file.mimeType, + size: file.size.toString(), + folder_id: null, + // permission: opts.permissions?.[0] || null, + permission: null, + shared_link: opts.shared_links?.[0] || null, + field_mappings, + }; + } +} diff --git a/packages/api/src/filestorage/file/services/sharepoint/types.ts b/packages/api/src/filestorage/file/services/sharepoint/types.ts new file mode 100644 index 000000000..32c34810a --- /dev/null +++ b/packages/api/src/filestorage/file/services/sharepoint/types.ts @@ -0,0 +1,210 @@ +import { IdentitySet } from '@filestorage/drive/services/sharepoint/types'; +import { + Deleted, + FileSystemInfo, + ItemReference, +} from '@filestorage/folder/services/sharepoint/types'; +import { SharepointPermissionOutput } from '@filestorage/permission/services/sharepoint/types'; + +/** + * Represents the input for a folder item in Sharepoint. + * @see https://learn.microsoft.com/en-us/graph/api/resources/driveitem?view=graph-rest-1.0 + */ +export interface SharepointFileOutput { + /** The unique identifier of the item within the Drive. */ + readonly id?: string; + /** The name of the item (filename and extension). */ + name?: string; + /** The URL that displays the resource in the browser. */ + readonly webUrl?: string; + /** File system information on the client. */ + fileSystemInfo?: FileSystemInfo; + /** Parent information, if the item has a parent. */ + parentReference?: ItemReference; + /** The unique identifier of the drive instance that contains the driveItem. */ + readonly driveId?: string; + /** Identifies the type of drive. */ + readonly driveType?: string; + /** Information about the deleted state of the item. */ + deleted?: Deleted; + /** Description of the item. */ + description?: string; + /** Permissions associated with the folder. */ + permissions?: SharepointPermissionOutput[]; + /** Date and time the item was last modified. Read-only. */ + readonly lastModifiedDateTime?: string; + /** Size of the item in bytes. Read-only. */ + readonly size?: number; + /** Identity of the user, device, and application that created the item. Read-only. */ + readonly createdBy?: IdentitySet; + /** Identity of the user, device, and application that last modified the item. Read-only. */ + readonly lastModifiedBy?: IdentitySet; + /** Date and time of item creation. Read-only. */ + readonly createdDateTime?: string; + /** File metadata Read-only. */ + readonly file: File; + /** Audio metadata, if the item is an audio file. Read-only. Read-only. Only on OneDrive Personal. */ + readonly audio?: Audio; + /** Bundle metadata, if the item is a bundle. Read-only. */ + readonly bundle?: Bundle; + /** The content stream, if the item represents a file. */ + content?: string; + /** Image metadata, if the item is an image. Read-only. */ + readonly image?: Image; + /** Photo metadata, if the item is a photo. Read-only. */ + readonly photo?: Photo; + /** Video metadata, if the item is a video. Read-only. */ + readonly video?: Video; + /** WebDAV compatible URL for the item. */ + readonly webDavUrl?: string; +} + +/** + * Represents the input for a folder item in OneDrive. + * @see https://learn.microsoft.com/en-us/graph/api/resources/file?view=graph-rest-1.0 + */ +export interface File { + /**The MIME type for the file. This is determined by logic on the server and might not be the value provided when the file was uploaded. Read-only. */ + mimeType: string; + /** Hashes of the file's binary content, if available. Read-only. */ + hashes?: Hashes; +} + +/** + * The hashes resource groups available hashes into a single structure for an item. + * @see https://learn.microsoft.com/en-us/graph/api/resources/hashes?view=graph-rest-1.0 + */ +export interface Hashes { + /** The CRC32 value of the file in little endian (if available). Read-only. */ + readonly crc32Hash?: string; + /** A proprietary hash of the file that can be used to determine if the contents of the file have changed (if available). Read-only. */ + readonly quickXorHash?: string; + /** SHA1 hash for the contents of the file (if available). Read-only. */ + readonly sha1Hash?: string; + /** SHA256 hash for the contents of the file (if available). Read-only. */ + readonly sha256Hash?: string; +} + +/** + * Represents metadata for an audio file. + * @see https://learn.microsoft.com/en-us/graph/api/resources/audio?view=graph-rest-1.0 + */ +export interface Audio { + /** The title of the album for this audio file. */ + album?: string; + /** The artist named on the album for the audio file. */ + albumArtist?: string; + /** The performing artist for the audio file. */ + artist?: string; + /** Bitrate expressed in kbps. */ + bitrate?: number; + /** The name of the composer of the audio file. */ + composers?: string; + /** Copyright information for the audio file. */ + copyright?: string; + /** The number of the disc this audio file came from. */ + disc?: number; + /** The total number of discs in this album. */ + discCount?: number; + /** Duration of the audio file, expressed in milliseconds. */ + duration?: number; + /** The genre of this audio file. */ + genre?: string; + /** Indicates if the file is protected with digital rights management. */ + hasDrm?: boolean; + /** Indicates if the file is encoded with a variable bitrate. */ + isVariableBitrate?: boolean; + /** The title of the audio file. */ + title?: string; + /** The number of the track on the original disc for this audio file. */ + track?: number; + /** The total number of tracks on the original disc for this audio file. */ + trackCount?: number; + /** The year the audio file was recorded. */ + year?: number; +} + +/** + * Represents metadata for a bundle. + * @see https://learn.microsoft.com/en-us/graph/api/resources/bundle?view=graph-rest-1.0 + */ +export interface Bundle { + /** If the bundle is an album, then the album property is included. */ + album?: Album; + /** Number of children contained immediately within this container. */ + childCount?: number; +} + +/** + * Represents album-specific metadata for a bundle. + * @see https://learn.microsoft.com/en-us/graph/api/resources/album?view=graph-rest-1.0 + */ +export interface Album { + /** Unique identifier of the driveItem that is the cover of the album. */ + coverImageItemId?: string; +} + +/** + * Represents metadata for an image. + * @see https://learn.microsoft.com/en-us/graph/api/resources/image?view=graph-rest-1.0 + */ +export interface Image { + /** Optional. Height of the image, in pixels. Read-only. */ + readonly height?: number; + /** Optional. Width of the image, in pixels. Read-only. */ + readonly width?: number; +} + +/** + * Represents metadata for a photo. + * @see https://learn.microsoft.com/en-us/graph/api/resources/photo?view=graph-rest-1.0 + */ +export interface Photo { + /** Camera manufacturer. Read-only. */ + readonly cameraMake?: string; + /** Camera model. Read-only. */ + readonly cameraModel?: string; + /** The denominator for the exposure time fraction from the camera. Read-only. */ + readonly exposureDenominator?: number; + /** The numerator for the exposure time fraction from the camera. Read-only. */ + readonly exposureNumerator?: number; + /** The F-stop value from the camera. Read-only. */ + readonly fNumber?: number; + /** The focal length from the camera. Read-only. */ + readonly focalLength?: number; + /** The ISO value from the camera. Read-only. */ + readonly iso?: number; + /** The orientation value from the camera. Writable on OneDrive Personal. */ + orientation?: number; + /** Represents the date and time the photo was taken. Read-only. */ + readonly takenDateTime?: string; +} + +/** + * Represents metadata for a video file. + * @see https://learn.microsoft.com/en-us/graph/api/resources/video?view=graph-rest-1.0 + */ +export interface Video { + /** Number of audio bits per sample. */ + audioBitsPerSample?: number; + /** Number of audio channels. */ + audioChannels?: number; + /** Name of the audio format (AAC, MP3, etc.). */ + audioFormat?: string; + /** Number of audio samples per second. */ + audioSamplesPerSecond?: number; + /** Bit rate of the video in bits per second. */ + bitrate?: number; + /** Duration of the file in milliseconds. */ + duration?: number; + /** "Four character code" name of the video format. */ + fourCC?: string; + /** Frame rate of the video. */ + frameRate?: number; + /** Height of the video, in pixels. */ + height?: number; + /** Width of the video, in pixels. */ + width?: number; +} + +export type SharepointFileInput = Partial; diff --git a/packages/api/src/filestorage/folder/folder.module.ts b/packages/api/src/filestorage/folder/folder.module.ts index 2777cf9a4..44bec8300 100644 --- a/packages/api/src/filestorage/folder/folder.module.ts +++ b/packages/api/src/filestorage/folder/folder.module.ts @@ -1,3 +1,8 @@ +import { SharepointFolderMapper } from './services/sharepoint/mappers'; +import { SharepointService } from './services/sharepoint'; +import { OnedriveFolderMapper } from './services/onedrive/mappers'; +import { OnedriveService } from './services/onedrive'; +import { BullQueueModule } from '@@core/@core-services/queues/queue.module'; import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; @@ -29,6 +34,8 @@ import { SyncService } from './sync/sync.service'; GoogleDriveFolderMapper, /* PROVIDERS SERVICES */ BoxService, + SharepointService, + SharepointFolderMapper, OnedriveService, GoogleDriveFolderService, ], diff --git a/packages/api/src/filestorage/folder/services/sharepoint/index.ts b/packages/api/src/filestorage/folder/services/sharepoint/index.ts new file mode 100644 index 000000000..62731fc19 --- /dev/null +++ b/packages/api/src/filestorage/folder/services/sharepoint/index.ts @@ -0,0 +1,188 @@ +import { Injectable } from '@nestjs/common'; +import { IFolderService } from '@filestorage/folder/types'; +import { FileStorageObject } from '@filestorage/@lib/@types'; +import axios from 'axios'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { ActionType, handle3rdPartyServiceError } from '@@core/utils/errors'; +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { ServiceRegistry } from '../registry.service'; +import { SharepointFolderInput, SharepointFolderOutput } from './types'; +import { SyncParam } from '@@core/utils/types/interface'; +import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; +import { UnifiedFilestorageFileOutput } from '@filestorage/file/types/model.unified'; +import { SharepointFileOutput } from '@filestorage/file/services/sharepoint/types'; + +@Injectable() +export class SharepointService implements IFolderService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + private ingestService: IngestDataService, + ) { + this.logger.setContext( + `${FileStorageObject.folder.toUpperCase()}:${SharepointService.name}`, + ); + this.registry.registerService('sharepoint', this); + } + + async addFolder( + folderData: SharepointFolderInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'sharepoint', + vertical: 'filestorage', + }, + }); + + // Currently adding in root folder, might need to change + const resp = await axios.post( + `${connection.account_url}/drive/root/children`, + JSON.stringify({ + name: folderData.name, + folder: {}, + '@microsoft.graph.conflictBehavior': 'rename', // 'rename' | 'fail' | 'replace' + }), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + + return { + data: resp.data, + message: 'Sharepoint folder created', + statusCode: 201, + }; + } catch (error) { + console.log(error.response?.data); + throw error; + } + } + + async iterativeGetSharepointFolders( + remote_folder_id: string, + linkedUserId: string, + ): Promise { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'sharepoint', + vertical: 'filestorage', + }, + }); + + let result = [], + depth = 0, + batch = [remote_folder_id]; + + while (batch.length > 0) { + if (depth > 5) { + // todo: handle this better + break; + } + + const nestedFolders = await Promise.all( + batch.map(async (folder_id) => { + const resp = await axios.get( + `${connection.account_url}/drive/items/${folder_id}/children`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + + // Add permissions (shared link is also included in permissions in one-drive) + // await Promise.all( + // resp.data.value.map(async (driveItem) => { + // const resp = await axios.get( + // `${connection.account_url}/drive/items/${driveItem.id}/permissions`, + // { + // headers: { + // 'Content-Type': 'application/json', + // Authorization: `Bearer ${this.cryptoService.decrypt( + // connection.access_token, + // )}`, + // }, + // }, + // ); + // driveItem.permissions = resp.data.value; + // }), + // ); + + const folders = resp.data.value.filter( + (driveItem) => driveItem.folder, + ); + + // const files = resp.data.value.filter( + // (driveItem) => !driveItem.folder, + // ); + + // await this.ingestService.ingestData< + // UnifiedFilestorageFileOutput, + // SharepointFileOutput + // >( + // files, + // 'sharepoint', + // connection.id_connection, + // 'filestorage', + // FileStorageObject.file, + // ); + + return folders; + }), + ); + + // nestedFolders = [[subfolder1, subfolder2], [subfolder3, subfolder4]] + result = result.concat(nestedFolders.flat()); + batch = nestedFolders.flat().map((folder) => folder.id); + this.logger.log(`Batch size: ${batch.length} at depth ${depth}`); + depth++; + } + + return result; + } catch (error) { + throw error; + } + } + + async sync(data: SyncParam): Promise> { + try { + this.logger.log('Syncing sharepoint folders'); + const { linkedUserId } = data; + + const folders = await this.iterativeGetSharepointFolders( + 'root', + linkedUserId, + ); + + this.logger.log(`${folders.length} sharepoint folders found`); + this.logger.log(`Synced sharepoint folders !`); + + return { + data: folders, + message: 'Sharepoint folders synced', + statusCode: 200, + }; + } catch (error) { + this.logger.log('Error in sharepoint sync '); + throw error; + } + } +} diff --git a/packages/api/src/filestorage/folder/services/sharepoint/mappers.ts b/packages/api/src/filestorage/folder/services/sharepoint/mappers.ts new file mode 100644 index 000000000..b0c7f106b --- /dev/null +++ b/packages/api/src/filestorage/folder/services/sharepoint/mappers.ts @@ -0,0 +1,146 @@ +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { OriginalSharedLinkOutput } from '@@core/utils/types/original/original.file-storage'; +import { Utils } from '@filestorage/@lib/@utils'; +import { IFolderMapper } from '@filestorage/folder/types'; +import { + UnifiedFilestorageFolderInput, + UnifiedFilestorageFolderOutput, +} from '@filestorage/folder/types/model.unified'; +import { UnifiedFilestorageSharedlinkOutput } from '@filestorage/sharedlink/types/model.unified'; +import { Injectable } from '@nestjs/common'; +import { SharepointFolderInput, SharepointFolderOutput } from './types'; +import { FileStorageObject } from '@filestorage/@lib/@types'; +import { OriginalPermissionOutput } from '@@core/utils/types/original/original.file-storage'; + +@Injectable() +export class SharepointFolderMapper implements IFolderMapper { + constructor( + private mappersRegistry: MappersRegistry, + private utils: Utils, + private coreUnificationService: CoreUnification, + ) { + this.mappersRegistry.registerService( + 'filestorage', + 'folder', + 'sharepoint', + this, + ); + } + + async desunify( + source: UnifiedFilestorageFolderInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const result = { + name: source.name, + folder: {}, + description: source.description, + }; + + if (customFieldMappings && source.field_mappings) { + for (const [k, v] of Object.entries(source.field_mappings)) { + const mapping = customFieldMappings.find( + (mapping) => mapping.slug === k, + ); + if (mapping) { + result[mapping.remote_id] = v; + } + } + } + + return result; + } + + async unify( + source: SharepointFolderOutput | SharepointFolderOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise< + UnifiedFilestorageFolderOutput | UnifiedFilestorageFolderOutput[] + > { + if (!Array.isArray(source)) { + return await this.mapSingleFolderToUnified( + source, + connectionId, + customFieldMappings, + ); + } + return await Promise.all( + source.map((s) => + this.mapSingleFolderToUnified(s, connectionId, customFieldMappings), + ), + ); + } + + private async mapSingleFolderToUnified( + folder: SharepointFolderOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = folder[mapping.remote_id]; + } + } + + const opts: any = {}; + if (folder.permissions?.length) { + const permissions = await this.coreUnificationService.unify< + OriginalPermissionOutput[] + >({ + sourceObject: folder.permissions, + targetType: FileStorageObject.permission, + providerName: 'sharepoint', + vertical: 'filestorage', + connectionId, + customFieldMappings: [], + }); + opts.permissions = permissions; + + // shared link + if (folder.permissions.some((p) => p.link)) { + const sharedLinks = + await this.coreUnificationService.unify({ + sourceObject: folder.permissions.find((p) => p.link), + targetType: FileStorageObject.sharedlink, + providerName: 'sharepoint', + vertical: 'filestorage', + connectionId, + customFieldMappings: [], + }); + opts.shared_links = sharedLinks; + } + } + + const result = { + remote_id: folder.id, + remote_data: folder, + name: folder.name, + folder_url: folder.webUrl, + description: folder.description, + drive_id: null, + parent_folder_id: await this.utils.getFolderIdFromRemote( + folder.parentReference?.id, + connectionId, + ), + // permission: opts.permissions?.[0] || null, + permission: null, + size: folder.size.toString(), + shared_link: opts.shared_links?.[0] || null, + field_mappings, + }; + + return result; + } +} diff --git a/packages/api/src/filestorage/folder/services/sharepoint/types.ts b/packages/api/src/filestorage/folder/services/sharepoint/types.ts new file mode 100644 index 000000000..5bf8784a4 --- /dev/null +++ b/packages/api/src/filestorage/folder/services/sharepoint/types.ts @@ -0,0 +1,155 @@ +import { + IdentitySet, + SharepointIds, +} from '@filestorage/drive/services/sharepoint/types'; +import { SharepointPermissionOutput } from '@filestorage/permission/services/sharepoint/types'; + +/** + * Represents the input for a folder item in OneDrive. + * @see https://learn.microsoft.com/en-us/graph/api/resources/driveitem?view=graph-rest-1.0 + */ +export interface SharepointFolderInput { + /** The unique identifier of the item within the Drive. */ + readonly id?: string; + /** The name of the item (filename and extension). */ + name?: string; + /** The URL that displays the resource in the browser. */ + readonly webUrl?: string; + /** Folder metadata. */ + folder?: Folder; + /** File system information on the client. */ + fileSystemInfo?: FileSystemInfo; + /** Parent information, if the item has a parent. */ + parentReference?: ItemReference; + /** The unique identifier of the drive instance that contains the driveItem. */ + readonly driveId?: string; + /** Identifies the type of drive. */ + readonly driveType?: string; + /** Information about the deleted state of the item. */ + deleted?: Deleted; + /** Description of the item. */ + description?: string; + /** Indicates the number of children contained immediately within this folder. */ + readonly childCount?: number; + /** Information about pending operations on the item. */ + pendingOperations?: PendingOperations; + /** View recommendations for the folder. */ + folderView?: FolderView; + /** SharePoint identifiers useful for REST compatibility. */ + readonly sharepointIds?: SharepointIds; + /** Special folder metadata. */ + readonly specialFolder?: SpecialFolder; + /** Identity of the user who created the folder. */ + readonly createdByUser?: IdentitySet; + /** Identity of the user who last modified the folder. */ + readonly lastModifiedByUser?: IdentitySet; + /** Permissions associated with the folder. */ + permissions?: SharepointPermissionOutput[]; + /** Date and time the item was last modified. Read-only. */ + readonly lastModifiedDateTime?: string; + /** Date and time of item creation. Read-only. */ + readonly createdDateTime?: string; + /** Size of the item in bytes. Read-only. */ + readonly size?: number; + /** Identity of the user, device, and application that created the item. Read-only. */ + readonly createdBy?: IdentitySet; + /** Identity of the user, device, and application that last modified the item. Read-only. */ + readonly lastModifiedBy?: IdentitySet; +} + +/** + * Represents the folder metadata. + */ +export interface Folder { + /** The number of children contained immediately within this container. */ + readonly childCount?: number; + /** A collection of properties defining the recommended view for the folder. */ + view?: FolderView; +} + +/** + * Represents file system information for a client. + */ +export interface FileSystemInfo { + /** The UTC date and time the file was created on a client. */ + readonly createdDateTime?: string; + /** The UTC date and time the file was last accessed. */ + readonly lastAccessedDateTime?: string; + /** The UTC date and time the file was last modified on a client. */ + readonly lastModifiedDateTime?: string; +} + +/** + * Represents folder view recommendations. + */ +export interface FolderView { + /** How items in the folder are sorted. */ + sortBy?: + | 'default' + | 'name' + | 'type' + | 'size' + | 'takenOrCreatedDateTime' + | 'lastModifiedDateTime' + | 'sequence'; + /** The order in which items are sorted. */ + sortOrder?: 'ascending' | 'descending'; + /** The type of view recommended for the folder. */ + viewType?: 'default' | 'icons' | 'details' | 'thumbnails'; +} + +/** + * Represents the reference to an item. + */ +export interface ItemReference { + /** Unique identifier of the drive instance that contains the driveItem. */ + readonly driveId?: string; + /** Identifies the type of drive. */ + readonly driveType?: string; + /** Unique identifier of the driveItem in the drive or listItem in a list. */ + readonly id?: string; + /** The name of the item being referenced. */ + readonly name?: string; + /** Percent-encoded path to navigate to the item. */ + readonly path?: string; + /** Unique identifier for a shared resource. */ + readonly shareId?: string; + /** SharePoint identifiers useful for REST compatibility. */ + readonly sharepointIds?: SharepointIds; + /** ID of the site containing the parent document library or list. */ + readonly siteId?: string; +} + +/** + * Represents information about pending operations on an item. + */ +export interface PendingOperations { + /** Indicates that an operation that might update the binary content of a file is pending completion. */ + readonly pendingContentUpdate?: PendingContentUpdate; +} + +/** + * Represents information about an operation that might affect the binary content of the driveItem. + */ +export interface PendingContentUpdate { + /** Date and time the pending binary operation was queued in UTC time. */ + readonly queuedDateTime?: string; +} + +/** + * Represents special folder metadata. + */ +export interface SpecialFolder { + /** The unique identifier for this item in the /drive/special collection. */ + readonly name?: string; +} + +/** + * Represents information about the deleted state of an item. + */ +export interface Deleted { + /** Represents the state of the deleted item. */ + state?: string; +} + +export type SharepointFolderOutput = SharepointFolderInput; diff --git a/packages/api/src/filestorage/group/group.module.ts b/packages/api/src/filestorage/group/group.module.ts index 7dd16cb33..78c5ed835 100644 --- a/packages/api/src/filestorage/group/group.module.ts +++ b/packages/api/src/filestorage/group/group.module.ts @@ -1,3 +1,5 @@ +import { SharepointGroupMapper } from './services/sharepoint/mappers'; +import { SharepointService } from './services/sharepoint'; import { OnedriveGroupMapper } from './services/onedrive/mappers'; import { OnedriveService } from './services/onedrive'; import { BullQueueModule } from '@@core/@core-services/queues/queue.module'; @@ -26,6 +28,8 @@ import { SyncService } from './sync/sync.service'; BoxGroupMapper, /* PROVIDERS SERVICES */ BoxService, + SharepointService, + SharepointGroupMapper, OnedriveService, OnedriveGroupMapper, ], diff --git a/packages/api/src/filestorage/group/services/onedrive/mappers.ts b/packages/api/src/filestorage/group/services/onedrive/mappers.ts index e4f7d0dec..14588c33a 100644 --- a/packages/api/src/filestorage/group/services/onedrive/mappers.ts +++ b/packages/api/src/filestorage/group/services/onedrive/mappers.ts @@ -76,7 +76,7 @@ export class OnedriveGroupMapper implements IGroupMapper { name: group.mailNickname, remote_was_deleted: group.deletedDateTime !== null, field_mappings, - users: null, + users: [], }; } } diff --git a/packages/api/src/filestorage/group/services/sharepoint/index.ts b/packages/api/src/filestorage/group/services/sharepoint/index.ts new file mode 100644 index 000000000..f839eccdf --- /dev/null +++ b/packages/api/src/filestorage/group/services/sharepoint/index.ts @@ -0,0 +1,61 @@ +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { ApiResponse } from '@@core/utils/types'; +import { SyncParam } from '@@core/utils/types/interface'; +import { FileStorageObject } from '@filestorage/@lib/@types'; +import { IGroupService } from '@filestorage/group/types'; +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { ServiceRegistry } from '../registry.service'; +import { SharepointGroupOutput } from './types'; + +@Injectable() +export class SharepointService implements IGroupService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + `${FileStorageObject.group.toUpperCase()}:${SharepointService.name}`, + ); + this.registry.registerService('sharepoint', this); + } + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId } = data; + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'sharepoint', + vertical: 'filestorage', + }, + }); + // remove /sites/site_id from account_url + const url = connection.account_url.replace(/\/sites\/.+$/, ''); + + // ref: https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0&tabs=http + const resp = await axios.get(`${url}/groups`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + + this.logger.log(`Synced sharepoint groups !`); + + return { + data: resp.data.value, + message: 'Sharepoint groups retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/filestorage/group/services/sharepoint/mappers.ts b/packages/api/src/filestorage/group/services/sharepoint/mappers.ts new file mode 100644 index 000000000..3cd197384 --- /dev/null +++ b/packages/api/src/filestorage/group/services/sharepoint/mappers.ts @@ -0,0 +1,82 @@ +import { + UnifiedFilestorageGroupInput, + UnifiedFilestorageGroupOutput, +} from '@filestorage/group/types/model.unified'; +import { IGroupMapper } from '@filestorage/group/types'; +import { Utils } from '@filestorage/@lib/@utils'; +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { Injectable } from '@nestjs/common'; +import { SharepointGroupInput, SharepointGroupOutput } from './types'; + +@Injectable() +export class SharepointGroupMapper implements IGroupMapper { + constructor(private mappersRegistry: MappersRegistry, private utils: Utils) { + this.mappersRegistry.registerService( + 'filestorage', + 'group', + 'sharepoint', + this, + ); + } + + async desunify( + source: UnifiedFilestorageGroupInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } + + async unify( + source: SharepointGroupOutput | SharepointGroupOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleGroupToUnified( + source, + connectionId, + customFieldMappings, + ); + } + // Handling array of SharepointGroupOutput + return Promise.all( + source.map((group) => + this.mapSingleGroupToUnified(group, connectionId, customFieldMappings), + ), + ); + } + + private async mapSingleGroupToUnified( + group: SharepointGroupOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = group[mapping.remote_id]; + } + } + + // todo: do something about users + // https://graph.microsoft.com/groups/group-id/members + + return { + remote_id: group.id, + remote_data: group, + name: group.mailNickname, + remote_was_deleted: group.deletedDateTime !== null, + field_mappings, + users: [], + }; + } +} diff --git a/packages/api/src/filestorage/group/services/sharepoint/types.ts b/packages/api/src/filestorage/group/services/sharepoint/types.ts new file mode 100644 index 000000000..a54c0b5e6 --- /dev/null +++ b/packages/api/src/filestorage/group/services/sharepoint/types.ts @@ -0,0 +1,145 @@ +export interface SharepointGroupInput { + /** Unique identifier for the group. */ + id?: string; + /** + * Timestamp of when the group was created. The value can't be modified and is automatically populated when the group is + * created. The Timestamp type represents date and time information using ISO 8601 format and is always in UTC time. For + * example, midnight UTC on January 1, 2014 is 2014-01-01T00:00:00Z. Returned by default. Read-only. + */ + createdDateTime?: string; + /** + * An optional description for the group. Returned by default. Supports $filter (eq, ne, not, ge, le, startsWith) and + * $search. + */ + description?: string; + /** + * Date and time when this object was deleted. Always null when the object hasn't been deleted. + */ + deletedDateTime?: string; + /** + * The display name for the group. This property is required when a group is created and can't be cleared during updates. + * Maximum length is 256 characters. Returned by default. Supports $filter (eq, ne, not, ge, le, in, startsWith, and eq on + * null values), $search, and $orderby. + */ + displayName?: string; + /** + * Timestamp of when the group is set to expire. It's null for security groups, but for Microsoft 365 groups, it + * represents when the group is set to expire as defined in the groupLifecyclePolicy. The Timestamp type represents date + * and time information using ISO 8601 format and is always in UTC. For example, midnight UTC on January 1, 2014 is + * 2014-01-01T00:00:00Z. Returned by default. Supports $filter (eq, ne, not, ge, le, in). Read-only. + */ + expirationDateTime?: string; + /** + * Specifies the group type and its membership. If the collection contains Unified, the group is a Microsoft 365 group; + * otherwise, it's either a security group or a distribution group. For details, see groups overview.If the collection + * includes DynamicMembership, the group has dynamic membership; otherwise, membership is static. Returned by default. + * Supports $filter (eq, not). + */ + groupTypes?: string[]; + /** + * When a group is associated with a team, this property determines whether the team is in read-only mode.To read this + * property, use the /group/{groupId}/team endpoint or the Get team API. To update this property, use the archiveTeam and + * unarchiveTeam APIs. + */ + isArchived?: boolean; + /** + * The SMTP address for the group, for example, 'serviceadmins@contoso.com'. Returned by default. Read-only. Supports + * $filter (eq, ne, not, ge, le, in, startsWith, and eq on null values). + */ + mail?: string; + // Specifies whether the group is mail-enabled. Required. Returned by default. Supports $filter (eq, ne, not). + mailEnabled?: boolean; + /** + * The mail alias for the group, unique for Microsoft 365 groups in the organization. Maximum length is 64 characters. + * This property can contain only characters in the ASCII character set 0 - 127 except the following characters: @ () / [] + * ' ; : &lt;&gt; , SPACE. Required. Returned by default. Supports $filter (eq, ne, not, ge, le, in, startsWith, + * and eq on null values). + */ + mailNickname?: string; + /** + * The preferred data location for the Microsoft 365 group. By default, the group inherits the group creator's preferred + * data location. To set this property, the calling app must be granted the Directory.ReadWrite.All permission and the + * user be assigned at least one of the following Microsoft Entra roles: User Account Administrator Directory Writer + * Exchange Administrator SharePoint Administrator For more information about this property, see OneDrive Online + * Multi-Geo. Nullable. Returned by default. + */ + preferredDataLocation?: string; + /** + * The preferred language for a Microsoft 365 group. Should follow ISO 639-1 Code; for example, en-US. Returned by + * default. Supports $filter (eq, ne, not, ge, le, in, startsWith, and eq on null values). + */ + preferredLanguage?: string; + /** + * Email addresses for the group that direct to the same group mailbox. For example: ['SMTP: bob@contoso.com', 'smtp: + * bob@sales.contoso.com']. The any operator is required to filter expressions on multi-valued properties. Returned by + * default. Read-only. Not nullable. Supports $filter (eq, not, ge, le, startsWith, endsWith, /$count eq 0, /$count ne 0). + */ + proxyAddresses?: string[]; + /** + * Timestamp of when the group was last renewed. This value can't be modified directly and is only updated via the renew + * service action. The Timestamp type represents date and time information using ISO 8601 format and is always in UTC. For + * example, midnight UTC on January 1, 2014 is 2014-01-01T00:00:00Z. Returned by default. Supports $filter (eq, ne, not, + * ge, le, in). Read-only. + */ + + renewedDateTime?: string; + /** + * Specifies whether the group is a security group. Required. Returned by default. Supports $filter (eq, ne, not, in). + */ + securityEnabled?: boolean; + /** + * Security identifier of the group, used in Windows scenarios. Read-only. Returned by default. + */ + securityIdentifier?: string; + // The unique identifier that can be assigned to a group and used as an alternate key. Immutable. Read-only. + uniqueName?: string; + /** + * Specifies the group join policy and group content visibility for groups. Possible values are: Private, Public, or + * HiddenMembership. HiddenMembership can be set only for Microsoft 365 groups when the groups are created. It can't be + * updated later. Other values of visibility can be updated after group creation. If visibility value isn't specified + * during group creation on Microsoft Graph, a security group is created as Private by default, and the Microsoft 365 + * group is Public. Groups assignable to roles are always Private. To learn more, see group visibility options. Returned + * by default. Nullable. + */ + visibility?: string; + /** + * The user (or application) that created the group. NOTE: This property isn't set if the user is an administrator. + * Read-only. + */ + createdOnBehalfOf?: DirectoryObject; + /** + * Groups that this group is a member of. HTTP Methods: GET (supported for all groups). Read-only. Nullable. Supports + * $expand. + */ + memberOf?: DirectoryObject[]; + /** + * The members of this group, who can be users, devices, other groups, or service principals. Supports the List members, + * Add member, and Remove member operations. Nullable. Supports $expand including nested $select. For example, + * /groups?$filter=startsWith(displayName,'Role')&$select=id,displayName&$expand=members($select=id,userPrincipalName,displayName). + */ + members?: DirectoryObject[]; + /** + * The owners of the group. Limited to 100 owners. Nullable. If this property isn't specified when creating a Microsoft + * 365 group, the calling user is automatically assigned as the group owner. Supports $filter (/$count eq 0, /$count ne 0, + * /$count eq 1, /$count ne 1). Supports $expand including nested $select. For example, + * /groups?$filter=startsWith(displayName,'Role')&$select=id,displayName&$expand=owners($select=id,userPrincipalName,displayName). + */ + owners?: DirectoryObject[]; +} + +/** + * Base type for all directory objects. + * @interface + */ +export interface DirectoryObject { + /** + * The unique identifier for an entity. Read-only. + */ + id?: string; + /** + * Date and time when this object was deleted. Always null when the object hasn't been deleted. + */ + deletedDateTime?: string; +} + +export type SharepointGroupOutput = Partial; diff --git a/packages/api/src/filestorage/permission/permission.module.ts b/packages/api/src/filestorage/permission/permission.module.ts index 12fea19a2..a15ae2253 100644 --- a/packages/api/src/filestorage/permission/permission.module.ts +++ b/packages/api/src/filestorage/permission/permission.module.ts @@ -1,3 +1,5 @@ +import { SharepointPermissionMapper } from './services/sharepoint/mappers'; +import { SharepointService } from './services/sharepoint'; import { OnedrivePermissionMapper } from './services/onedrive/mappers'; import { OnedriveService } from './services/onedrive'; import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; @@ -31,6 +33,8 @@ import { Utils } from '@filestorage/@lib/@utils'; IngestDataService, /* PROVIDERS SERVICES */ + SharepointService, + SharepointPermissionMapper, OnedriveService, OnedrivePermissionMapper, ], diff --git a/packages/api/src/filestorage/permission/services/sharepoint/index.ts b/packages/api/src/filestorage/permission/services/sharepoint/index.ts new file mode 100644 index 000000000..2168542de --- /dev/null +++ b/packages/api/src/filestorage/permission/services/sharepoint/index.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { IPermissionService } from '@filestorage/permission/types'; +import { FileStorageObject } from '@panora/shared'; +import axios from 'axios'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { ServiceRegistry } from '../registry.service'; +import { SharepointPermissionInput, SharepointPermissionOutput } from './types'; +import { SyncParam } from '@@core/utils/types/interface'; + +@Injectable() +export class SharepointService implements IPermissionService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + `${FileStorageObject.permission.toUpperCase()}:${SharepointService.name}`, + ); + this.registry.registerService('sharepoint', this); + } + + async sync( + data: SyncParam, + ): Promise> { + try { + const { linkedUserId, extra } = data; + // TODO: where it comes from ?? extra?: { object_name: 'folder' | 'file'; value: string }, + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'sharepoint', + vertical: 'filestorage', + }, + }); + let remote_id; + if (extra.object_name == 'folder') { + const a = await this.prisma.fs_folders.findUnique({ + where: { + id_fs_folder: extra.value, + }, + }); + remote_id = a.remote_id; + } + if (extra.object_name == 'file') { + const a = await this.prisma.fs_files.findUnique({ + where: { + id_fs_file: extra.value, + }, + }); + + remote_id = a.remote_id; + } + + const resp = await axios.get( + `${connection.account_url}/drive/items/${remote_id}/permissions`, + { + headers: { + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + + return { + data: resp.data.value as SharepointPermissionOutput[], + message: 'Synced sharepoint permissions !', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/filestorage/permission/services/sharepoint/mappers.ts b/packages/api/src/filestorage/permission/services/sharepoint/mappers.ts new file mode 100644 index 000000000..5eb501301 --- /dev/null +++ b/packages/api/src/filestorage/permission/services/sharepoint/mappers.ts @@ -0,0 +1,95 @@ +import { + UnifiedFilestoragePermissionInput, + UnifiedFilestoragePermissionOutput, +} from '@filestorage/permission/types/model.unified'; +import { IPermissionMapper } from '@filestorage/permission/types'; +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { Injectable } from '@nestjs/common'; +import { OriginalPermissionOutput } from '@@core/utils/types/original/original.file-storage'; +import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; +import { SharepointPermissionInput, SharepointPermissionOutput } from './types'; + +@Injectable() +export class SharepointPermissionMapper implements IPermissionMapper { + constructor( + private mappersRegistry: MappersRegistry, + private ingestService: IngestDataService, + ) { + this.mappersRegistry.registerService( + 'filestorage', + 'permission', + 'sharepoint', + this, + ); + } + + async desunify( + source: UnifiedFilestoragePermissionInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } + + async unify( + source: SharepointPermissionOutput | SharepointPermissionOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise< + UnifiedFilestoragePermissionOutput | UnifiedFilestoragePermissionOutput[] + > { + if (!Array.isArray(source)) { + return await this.mapSinglePermissionToUnified( + source, + connectionId, + customFieldMappings, + ); + } + // Handling array of SharepointPermissionOutput + return Promise.all( + source.map((permission) => + this.mapSinglePermissionToUnified( + permission, + connectionId, + customFieldMappings, + ), + ), + ); + } + + private async mapSinglePermissionToUnified( + permission: SharepointPermissionOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const field_mappings: { [key: string]: any } = {}; + if (customFieldMappings) { + for (const mapping of customFieldMappings) { + field_mappings[mapping.slug] = permission[mapping.remote_id]; + } + } + + return { + remote_id: permission.id, + remote_data: permission, + roles: permission.roles.map((role) => role.toUpperCase()), + type: + permission.link?.type === 'edit' + ? 'WRITE' + : permission.link?.type === 'view' + ? 'READ' + : permission.link?.type, + user_id: null, + group_id: null, + field_mappings, + }; + } +} diff --git a/packages/api/src/filestorage/permission/services/sharepoint/types.ts b/packages/api/src/filestorage/permission/services/sharepoint/types.ts new file mode 100644 index 000000000..1f2639914 --- /dev/null +++ b/packages/api/src/filestorage/permission/services/sharepoint/types.ts @@ -0,0 +1,109 @@ +import { + Identity, + IdentitySet, +} from '@filestorage/drive/services/sharepoint/types'; +import { ItemReference } from '@filestorage/folder/services/sharepoint/types'; + +/** + * Represents a permission associated with a folder item. + * @see https://learn.microsoft.com/en-us/graph/api/driveitem-invite?view=graph-rest-1.0&tabs=http + */ +export interface SharepointPermissionOutput { + /** The unique identifier of the permission among all permissions on the item. */ + id?: string; + /** Indicates whether the password is set for this permission. */ + hasPassword?: boolean; + /** For link type permissions, the details of the users to whom permission was granted. */ + grantedToV2?: SharePointIdentitySet; + /** Provides a reference to the ancestor of the current permission, if it's inherited from an ancestor. */ + inheritedFrom?: ItemReference; + /** Details of any associated sharing invitation for this permission. */ + invitation?: SharingInvitation; + /** Provides the link details of the current permission, if it's a link type permission. */ + link?: SharingLink; + /** The type of permission, for example, read. */ + roles?: ('read' | 'write' | 'owner')[]; + /** A unique token that can be used to access this shared item via the shares API. */ + shareId?: string; + /** A format of yyyy-MM-ddTHH:mm:ssZ of DateTimeOffset indicates the expiration time of the permission. DateTime.MinValue indicates there's no expiration set for this permission. Optional. */ + expirationDateTime?: string; +} + +/** + * Represents the sharing invitation details for a permission. + * @see https://learn.microsoft.com/en-us/graph/api/resources/sharinginvitation?view=graph-rest-1.0 + */ +export interface SharingInvitation { + /** The email address of the recipient. */ + readonly email?: string; + /** Provides information about who sent the invitation that created this permission, if that information is available. Read-only. */ + readonly readonlyinvitedBy?: IdentitySet; + /** If true the recipient of the invitation needs to sign in in order to access the shared item. Read-only. */ + readonly signInRequired?: boolean; +} + +/** + * Represents the sharing link details for a permission. + * @see https://learn.microsoft.com/en-us/graph/api/resources/sharinglink?view=graph-rest-1.0 + */ +export interface SharingLink { + /** The URL that opens the item in the browser on the OneDrive website. */ + webUrl?: string; + /** The type of sharing link. */ + type?: 'view' | 'edit' | 'embed'; + /** The scope of the link represented by this permission. */ + scope?: 'anonymous' | 'organization' | 'existingAccess' | 'users'; + /** If true, then the user can only use this link to view the item on the web, and cannot use it to download the contents of the item. */ + preventsDownload?: boolean; + /** For embed links, this property contains the HTML code for an