diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 89f5e60f84c..c5a89162f56 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -22,6 +22,12 @@ tags: - service + - name: Edu Sharing Service + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: api-edu-sharing-svc.yml.j2 + - name: FwuLearningContentsService kubernetes.core.k8s: kubeconfig: ~/.kube/config @@ -193,6 +199,19 @@ tags: - ingress + - name: Edu Sharing Deployment + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: api-edu-sharing-deployment.yml.j2 + + - name: Edu Sharing Ingress + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: api-edu-sharing-ingress.yml.j2 + apply: yes + - name: FwuLearningContentsDeployment kubernetes.core.k8s: kubeconfig: ~/.kube/config diff --git a/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 b/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 index 8b06a2a96ca..a97f01531ad 100644 --- a/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 @@ -21,3 +21,7 @@ data: IDENTITY_MANAGEMENT__EXTERNAL_URI: "{{ IDENTITY_MANAGEMENT__EXTERNAL_URI }}" IDENTITY_MANAGEMENT__TENANT: "{{ IDENTITY_MANAGEMENT__TENANT }}" IDENTITY_MANAGEMENT__CLIENTID: "{{ IDENTITY_MANAGEMENT__CLIENTID }}" + EDU_SHARING__APP_ID: "{{ EDU_SHARING__APP_ID }}" + EDU_SHARING__API_URL: "{{ EDU_SHARING__API_URL }}" + EDU_SHARING_CONNECT_SRC_URLS: "{{ EDU_SHARING_CONNECT_SRC_URLS }}" + EDU_SHARING_IMG_SRC_URLS: "{{ EDU_SHARING_IMG_SRC_URLS }}" diff --git a/ansible/roles/schulcloud-server-core/templates/api-edu-sharing-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/api-edu-sharing-deployment.yml.j2 new file mode 100644 index 00000000000..6c857216aea --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/api-edu-sharing-deployment.yml.j2 @@ -0,0 +1,123 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api-edu-sharing-deployment + namespace: {{ NAMESPACE }} + labels: + app: api-edu-sharing + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-edu-sharing + app.kubernetes.io/component: edu-sharing + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} +spec: + replicas: {{ API_EDU_SHARING_REPLICAS|default("1", true) }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + #maxUnavailable: 1 + revisionHistoryLimit: 4 + paused: false + selector: + matchLabels: + app: api-edu-sharing + template: + metadata: + labels: + app: api-edu-sharing + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-edu-sharing + app.kubernetes.io/component: edu-sharing + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + runAsNonRoot: true + containers: + - name: api-edu-sharing + image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} + imagePullPolicy: IfNotPresent + ports: + - containerPort: 4450 + name: api-edu-sharing + protocol: TCP + envFrom: + - configMapRef: + name: api-configmap + - secretRef: + name: api-secret + command: ['npm', 'run', 'nest:start:edu-sharing:prod'] + readinessProbe: + httpGet: + path: api/v3/docs + port: 4450 + timeoutSeconds: 4 + failureThreshold: 3 + periodSeconds: 5 + # liveless if unsatisfactory reply + livenessProbe: + httpGet: + path: api/v3/docs + port: 4450 + timeoutSeconds: 4 + failureThreshold: 3 + periodSeconds: 15 + startupProbe: + httpGet: + path: api/v3/docs + port: 4450 + timeoutSeconds: 4 + failureThreshold: 36 + periodSeconds: 5 + resources: + limits: + cpu: {{ API_EDU_SHARING_CPU_LIMITS|default("2000m", true) }} + memory: {{ API_EDU_SHARING_MEMORY_LIMITS|default("500Mi", true) }} + requests: + cpu: {{ API_EDU_SHARING_CPU_REQUESTS|default("100m", true) }} + memory: {{ API_EDU_SHARING_MEMORY_REQUESTS|default("50Mi", true) }} +{% if AFFINITY_ENABLE is defined and AFFINITY_ENABLE|bool %} + affinity: + podAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 9 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/part-of + operator: In + values: + - schulcloud-verbund + topologyKey: "kubernetes.io/hostname" + namespaceSelector: {} + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: +{% if ANIT_AFFINITY_NODEPOOL_ENABLE is defined and ANIT_AFFINITY_NODEPOOL_ENABLE|bool %} + - weight: 10 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/name + operator: In + values: + - api-edu-sharing + topologyKey: {{ ANIT_AFFINITY_NODEPOOL_TOPOLOGY_KEY }} +{% endif %} + - weight: 20 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/name + operator: In + values: + - api-edu-sharing + topologyKey: "topology.kubernetes.io/zone" +{% endif %} diff --git a/ansible/roles/schulcloud-server-core/templates/api-edu-sharing-ingress.yml.j2 b/ansible/roles/schulcloud-server-core/templates/api-edu-sharing-ingress.yml.j2 new file mode 100644 index 00000000000..ebfca4d74e5 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/api-edu-sharing-ingress.yml.j2 @@ -0,0 +1,42 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ NAMESPACE }}-api-edu-sharing-ingress + namespace: {{ NAMESPACE }} + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "{{ TLS_ENABLED|default("false") }}" + nginx.ingress.kubernetes.io/proxy-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" + nginx.org/client-max-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" + # The following properties added with BC-3606. + # The header size of the request is too big. For e.g. state and the permanent growing jwt. + # Nginx throws away the Location header, resulting in the 502 Bad Gateway. + nginx.ingress.kubernetes.io/client-header-buffer-size: 100k + nginx.ingress.kubernetes.io/http2-max-header-size: 96k + nginx.ingress.kubernetes.io/large-client-header-buffers: 4 100k + nginx.ingress.kubernetes.io/proxy-buffer-size: 96k + nginx.ingress.kubernetes.io/proxy-request-buffering: "off" +{% if CLUSTER_ISSUER is defined %} + cert-manager.io/cluster-issuer: {{ CLUSTER_ISSUER }} +{% endif %} + +spec: + ingressClassName: {{ INGRESS_CLASS }} +{% if CLUSTER_ISSUER is defined or (TLS_ENABLED is defined and TLS_ENABLED|bool) %} + tls: + - hosts: + - {{ DOMAIN }} +{% if CLUSTER_ISSUER is defined %} + secretName: {{ DOMAIN }}-tls +{% endif %} +{% endif %} + rules: + - host: {{ DOMAIN }} + http: + paths: + - path: /api/v3/edu-sharing/ + backend: + service: + name: api-edu-sharing-svc + port: + number: {{ PORT_EDU_SHARING_SERVICE }} + pathType: Prefix diff --git a/ansible/roles/schulcloud-server-core/templates/api-edu-sharing-svc.yml.j2 b/ansible/roles/schulcloud-server-core/templates/api-edu-sharing-svc.yml.j2 new file mode 100644 index 00000000000..594de916e4c --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/api-edu-sharing-svc.yml.j2 @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: api-edu-sharing-svc + namespace: {{ NAMESPACE }} + labels: + app: api-edu-sharing +spec: + type: ClusterIP + ports: + - port: {{ PORT_EDU_SHARING_SERVICE }} + targetPort: 4450 + protocol: TCP + name: api-edu-sharing + selector: + app: api-edu-sharing diff --git a/apps/server/src/apps/edu-sharing.app.ts b/apps/server/src/apps/edu-sharing.app.ts new file mode 100644 index 00000000000..f5ad0a622ee --- /dev/null +++ b/apps/server/src/apps/edu-sharing.app.ts @@ -0,0 +1,55 @@ +/* istanbul ignore file */ +/* eslint-disable no-console */ +import { NestFactory } from '@nestjs/core'; +import { ExpressAdapter } from '@nestjs/platform-express'; +import express from 'express'; + +// register source-map-support for debugging +import { install as sourceMapInstall } from 'source-map-support'; + +// application imports +import { EduSharingApiModule } from '@modules/edu-sharing/edu-sharing-api.module'; +import { API_VERSION_PATH } from '@modules/edu-sharing/edu-sharing.const'; +import { SwaggerDocumentOptions } from '@nestjs/swagger'; +import { LegacyLogger } from '@src/core/logger'; +import { enableOpenApiDocs } from '@src/shared/controller/swagger'; + +async function bootstrap() { + sourceMapInstall(); + + // create the NestJS application on a seperate express instance + const nestExpress = express(); + + const nestExpressAdapter = new ExpressAdapter(nestExpress); + const nestApp = await NestFactory.create(EduSharingApiModule, nestExpressAdapter); + + // WinstonLogger + nestApp.useLogger(await nestApp.resolve(LegacyLogger)); + + // customize nest app settings + nestApp.enableCors({ exposedHeaders: ['Content-Disposition'] }); + + const options: SwaggerDocumentOptions = { + operationIdFactory: (_controllerKey: string, methodKey: string) => methodKey, + }; + enableOpenApiDocs(nestApp, 'docs', options); + + await nestApp.init(); + + // mount instances + const rootExpress = express(); + + const port = 4450; + const basePath = API_VERSION_PATH; + + // exposed alias mounts + rootExpress.use(basePath, nestExpress); + rootExpress.listen(port); + + console.log('#################################'); + console.log(`### Start edu-sharing Server ###`); + console.log(`### Port: ${port} ###`); + console.log(`### Base path: ${basePath} ###`); + console.log('#################################'); +} +void bootstrap(); diff --git a/apps/server/src/modules/edu-sharing/README.md b/apps/server/src/modules/edu-sharing/README.md new file mode 100644 index 00000000000..7b69d22982d --- /dev/null +++ b/apps/server/src/modules/edu-sharing/README.md @@ -0,0 +1,6 @@ +# Module Description - edu-sharing + +## Summary + +This module is a stand alone service. +The service handles the retrieval of a valid "ticket" (session) from the respective edu-sharing back end. diff --git a/apps/server/src/modules/edu-sharing/controller/dto/edu-sharing.params.ts b/apps/server/src/modules/edu-sharing/controller/dto/edu-sharing.params.ts new file mode 100644 index 00000000000..9e6c8fdd341 --- /dev/null +++ b/apps/server/src/modules/edu-sharing/controller/dto/edu-sharing.params.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class TicketParams { + @IsString() + @ApiProperty({ + description: 'The ticket to be evaluated.', + required: true, + nullable: false, + }) + ticket!: string; +} diff --git a/apps/server/src/modules/edu-sharing/controller/dto/index.ts b/apps/server/src/modules/edu-sharing/controller/dto/index.ts new file mode 100644 index 00000000000..5a0e60cb440 --- /dev/null +++ b/apps/server/src/modules/edu-sharing/controller/dto/index.ts @@ -0,0 +1 @@ +export * from './edu-sharing.params'; diff --git a/apps/server/src/modules/edu-sharing/controller/edu-sharing.controller.ts b/apps/server/src/modules/edu-sharing/controller/edu-sharing.controller.ts new file mode 100644 index 00000000000..abd67628db1 --- /dev/null +++ b/apps/server/src/modules/edu-sharing/controller/edu-sharing.controller.ts @@ -0,0 +1,71 @@ +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; +import { + Controller, + ForbiddenException, + Get, + InternalServerErrorException, + NotAcceptableException, + NotFoundException, + Param, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { LoginDto } from '../dto'; +import { EduSharingUC } from '../uc'; +import { TicketParams } from './dto'; + +@ApiTags('edu-sharing') +@Controller('edu-sharing') +export class EduSharingController { + constructor(private readonly eduSharingUC: EduSharingUC) {} + + @JwtAuthentication() + @ApiOperation({ + summary: 'Fetches the edu-sharing ticket for a given user name.', + }) + @ApiResponse({ status: 200, type: String }) + @ApiResponse({ status: 206, type: String }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @ApiResponse({ status: 406, type: NotAcceptableException }) + @ApiResponse({ status: 500, type: InternalServerErrorException }) + @Get('/') + async getTicketForUser(@CurrentUser() currentUser: ICurrentUser): Promise { + const ticket = await this.eduSharingUC.getTicketForUser(currentUser.userId); + + return ticket; + } + + @JwtAuthentication() + @ApiOperation({ + summary: + 'Gets detailed information about a ticket. Will throw an exception if the given ticket is not valid anymore.', + }) + @ApiResponse({ status: 200, type: String }) + @ApiResponse({ status: 206, type: String }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @ApiResponse({ status: 406, type: NotAcceptableException }) + @ApiResponse({ status: 500, type: InternalServerErrorException }) + @Get('/validate/:ticket') + async getTicketAuthenticationInfo(@Param() params: TicketParams): Promise { + const result = await this.eduSharingUC.getTicketAuthenticationInfo(params); + + return result; + } + + @ApiOperation({ + summary: 'Returns the required XML for registering the service against an edu-sharing repository.', + }) + @ApiResponse({ status: 200, type: String }) + @ApiResponse({ status: 206, type: String }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @ApiResponse({ status: 406, type: NotAcceptableException }) + @ApiResponse({ status: 500, type: InternalServerErrorException }) + @Get('/register') + getEduAppXMLData(): string { + const xmlData = this.eduSharingUC.getEduAppXMLData(); + + return xmlData; + } +} diff --git a/apps/server/src/modules/edu-sharing/controller/index.ts b/apps/server/src/modules/edu-sharing/controller/index.ts new file mode 100644 index 00000000000..f9b5631aa06 --- /dev/null +++ b/apps/server/src/modules/edu-sharing/controller/index.ts @@ -0,0 +1 @@ +export * from './edu-sharing.controller'; diff --git a/apps/server/src/modules/edu-sharing/dto/authentication-token.dto.ts b/apps/server/src/modules/edu-sharing/dto/authentication-token.dto.ts new file mode 100644 index 00000000000..37c724313ab --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/authentication-token.dto.ts @@ -0,0 +1,4 @@ +export interface AuthenticationTokenDto { + ticket?: string; + userId?: string; +} diff --git a/apps/server/src/modules/edu-sharing/dto/collection.dto.ts b/apps/server/src/modules/edu-sharing/dto/collection.dto.ts new file mode 100644 index 00000000000..150f5f43f58 --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/collection.dto.ts @@ -0,0 +1,27 @@ +export interface CollectionDto { + authorFreetext?: string; + childCollectionsCount?: number; + childReferencesCount?: number; + color?: string; + description?: string; + + /** + * false + */ + fromUser: boolean; + + /** + * false + */ + level0: boolean; + orderAscending?: boolean; + orderMode?: string; + pinned?: boolean; + scope?: string; + title: string; + type: string; + viewtype: string; + x?: number; + y?: number; + z?: number; +} diff --git a/apps/server/src/modules/edu-sharing/dto/content.dto.ts b/apps/server/src/modules/edu-sharing/dto/content.dto.ts new file mode 100644 index 00000000000..3e2d238f126 --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/content.dto.ts @@ -0,0 +1,5 @@ +export interface ContentDto { + hash?: string; + url?: string; + version?: string; +} diff --git a/apps/server/src/modules/edu-sharing/dto/contributor.dto.ts b/apps/server/src/modules/edu-sharing/dto/contributor.dto.ts new file mode 100644 index 00000000000..4dcb87f8a6b --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/contributor.dto.ts @@ -0,0 +1,8 @@ +export interface ContributorDto { + email?: string; + firstname?: string; + lastname?: string; + org?: string; + property?: string; + vcard?: string; +} diff --git a/apps/server/src/modules/edu-sharing/dto/error-response.dto.ts b/apps/server/src/modules/edu-sharing/dto/error-response.dto.ts new file mode 100644 index 00000000000..0420b5fcbd9 --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/error-response.dto.ts @@ -0,0 +1,10 @@ +export interface ErrorResponseDto { + details?: { + [key: string]: {}; + }; + error: string; + logLevel?: string; + message: string; + stacktrace?: string; + stacktraceArray: Array; +} diff --git a/apps/server/src/modules/edu-sharing/dto/index.ts b/apps/server/src/modules/edu-sharing/dto/index.ts new file mode 100644 index 00000000000..dd7de179f34 --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/index.ts @@ -0,0 +1,3 @@ +export * from './authentication-token.dto'; +export * from './error-response.dto'; +export * from './login.dto'; diff --git a/apps/server/src/modules/edu-sharing/dto/license.dto.ts b/apps/server/src/modules/edu-sharing/dto/license.dto.ts new file mode 100644 index 00000000000..34dfdc97705 --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/license.dto.ts @@ -0,0 +1,4 @@ +export interface LicenseDto { + icon?: string; + url?: string; +} diff --git a/apps/server/src/modules/edu-sharing/dto/login.dto.ts b/apps/server/src/modules/edu-sharing/dto/login.dto.ts new file mode 100644 index 00000000000..da66a3a1f6c --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/login.dto.ts @@ -0,0 +1,18 @@ +import { LtiSessionDto } from './lti-session.dto'; +import { RemoteAuthDescriptionDto } from './remote-auth-description.dto'; + +export interface LoginDto { + authorityName?: string; + currentScope: string; + isAdmin: boolean; + isGuest: boolean; + isValidLogin: boolean; + ltiSession?: LtiSessionDto; + remoteAuthentications?: { + [key: string]: RemoteAuthDescriptionDto; + }; + sessionTimeout: number; + statusCode?: string; + toolPermissions?: Array; + userHome?: string; +} diff --git a/apps/server/src/modules/edu-sharing/dto/lti-session.dto.ts b/apps/server/src/modules/edu-sharing/dto/lti-session.dto.ts new file mode 100644 index 00000000000..e22a035538e --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/lti-session.dto.ts @@ -0,0 +1,12 @@ +import { NodeDto } from './node.dto'; + +export interface LtiSessionDto { + acceptMultiple?: boolean; + acceptPresentationDocumentTargets?: Array; + acceptTypes?: Array; + canConfirm?: boolean; + customContentNode?: NodeDto; + deeplinkReturnUrl?: string; + text?: string; + title?: string; +} diff --git a/apps/server/src/modules/edu-sharing/dto/node-lti-deep-link.dto.ts b/apps/server/src/modules/edu-sharing/dto/node-lti-deep-link.dto.ts new file mode 100644 index 00000000000..8850648984d --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/node-lti-deep-link.dto.ts @@ -0,0 +1,4 @@ +export interface NodeLtiDeepLinkDto { + jwtDeepLinkResponse?: string; + ltiDeepLinkReturnUrl?: string; +} diff --git a/apps/server/src/modules/edu-sharing/dto/node-ref.dto.ts b/apps/server/src/modules/edu-sharing/dto/node-ref.dto.ts new file mode 100644 index 00000000000..5886deb97aa --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/node-ref.dto.ts @@ -0,0 +1,6 @@ +export interface NodeRefDto { + archived: boolean; + id: string; + isHomeRepo?: boolean; + repo: string; +} diff --git a/apps/server/src/modules/edu-sharing/dto/node.dto.ts b/apps/server/src/modules/edu-sharing/dto/node.dto.ts new file mode 100644 index 00000000000..7d7f08bc82a --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/node.dto.ts @@ -0,0 +1,50 @@ +import { CollectionDto } from './collection.dto'; +import { ContentDto } from './content.dto'; +import { ContributorDto } from './contributor.dto'; +import { LicenseDto } from './license.dto'; +import { NodeLtiDeepLinkDto } from './node-lti-deep-link.dto'; +import { NodeRefDto } from './node-ref.dto'; +import { PersonDto } from './person.dto'; +import { PreviewDto } from './preview.dto'; +import { RatingDetailsDto } from './rating-details.dto'; +import { RemoteDto } from './remote.dto'; + +export interface NodeDto { + access: Array; + aspects?: Array; + collection: CollectionDto; + commentCount?: number; + content?: ContentDto; + contributors?: Array; + createdAt: string; + createdBy: PersonDto; + downloadUrl: string; + iconURL?: string; + isDirectory?: boolean; + isPublic?: boolean; + license?: LicenseDto; + mediatype?: string; + metadataset?: string; + mimetype?: string; + modifiedAt?: string; + modifiedBy?: PersonDto; + name: string; + nodeLTIDeepLink?: NodeLtiDeepLinkDto; + owner: PersonDto; + parent?: NodeRefDto; + preview?: PreviewDto; + properties?: { + [key: string]: Array; + }; + rating?: RatingDetailsDto; + ref: NodeRefDto; + relations?: { + [key: string]: NodeDto; + }; + remote?: RemoteDto; + repositoryType?: string; + size?: string; + title?: string; + type?: string; + usedInCollections?: Array; +} diff --git a/apps/server/src/modules/edu-sharing/dto/person.dto.ts b/apps/server/src/modules/edu-sharing/dto/person.dto.ts new file mode 100644 index 00000000000..7d228ecc6aa --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/person.dto.ts @@ -0,0 +1,8 @@ +import { UserProfileDto } from './user-profile.dto'; + +export interface PersonDto { + firstName?: string; + lastName?: string; + mailbox?: string; + profile?: UserProfileDto; +} diff --git a/apps/server/src/modules/edu-sharing/dto/preview.dto.ts b/apps/server/src/modules/edu-sharing/dto/preview.dto.ts new file mode 100644 index 00000000000..381de0b1efd --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/preview.dto.ts @@ -0,0 +1,10 @@ +export interface PreviewDto { + data?: string; + height: number; + isGenerated?: boolean; + isIcon: boolean; + mimetype?: string; + type?: string; + url: string; + width: number; +} diff --git a/apps/server/src/modules/edu-sharing/dto/rating-data.dto.ts b/apps/server/src/modules/edu-sharing/dto/rating-data.dto.ts new file mode 100644 index 00000000000..214673ae842 --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/rating-data.dto.ts @@ -0,0 +1,5 @@ +export interface RatingDataDto { + count?: number; + rating?: number; + sum?: number; +} diff --git a/apps/server/src/modules/edu-sharing/dto/rating-details.dto.ts b/apps/server/src/modules/edu-sharing/dto/rating-details.dto.ts new file mode 100644 index 00000000000..5a06eba419c --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/rating-details.dto.ts @@ -0,0 +1,9 @@ +import { RatingDataDto } from './rating-data.dto'; + +export interface RatingDetailsDto { + affiliation?: { + [key: string]: RatingDataDto; + }; + overall?: RatingDataDto; + user?: number; +} diff --git a/apps/server/src/modules/edu-sharing/dto/remote-auth-description.dto.ts b/apps/server/src/modules/edu-sharing/dto/remote-auth-description.dto.ts new file mode 100644 index 00000000000..bd7a1725e6f --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/remote-auth-description.dto.ts @@ -0,0 +1,4 @@ +export interface RemoteAuthDescriptionDto { + token?: string; + url?: string; +} diff --git a/apps/server/src/modules/edu-sharing/dto/remote.dto.ts b/apps/server/src/modules/edu-sharing/dto/remote.dto.ts new file mode 100644 index 00000000000..c5f39f3258b --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/remote.dto.ts @@ -0,0 +1,6 @@ +import { RepoDto } from './repo.dto'; + +export interface RemoteDto { + id?: string; + repository?: RepoDto; +} diff --git a/apps/server/src/modules/edu-sharing/dto/repo.dto.ts b/apps/server/src/modules/edu-sharing/dto/repo.dto.ts new file mode 100644 index 00000000000..051b01b156f --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/repo.dto.ts @@ -0,0 +1,9 @@ +export interface RepoDto { + icon?: string; + id?: string; + isHomeRepo?: boolean; + logo?: string; + renderingSupported?: boolean; + repositoryType?: string; + title?: string; +} diff --git a/apps/server/src/modules/edu-sharing/dto/user-profile.dto.ts b/apps/server/src/modules/edu-sharing/dto/user-profile.dto.ts new file mode 100644 index 00000000000..c2178b722fa --- /dev/null +++ b/apps/server/src/modules/edu-sharing/dto/user-profile.dto.ts @@ -0,0 +1,12 @@ +export interface UserProfileDto { + about?: string; + avatar?: string; + email?: string; + firstName?: string; + lastName?: string; + primaryAffiliation?: string; + skills?: Array; + type?: Array; + types?: Array; + vcard?: string; +} diff --git a/apps/server/src/modules/edu-sharing/edu-sharing-api.module.ts b/apps/server/src/modules/edu-sharing/edu-sharing-api.module.ts new file mode 100644 index 00000000000..89990699fee --- /dev/null +++ b/apps/server/src/modules/edu-sharing/edu-sharing-api.module.ts @@ -0,0 +1,27 @@ +import { AuthenticationModule } from '@modules/authentication'; +import { AuthorizationModule } from '@modules/authorization'; +import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { CoreModule } from '@src/core'; +import { EduSharingController } from './controller'; +import { config } from './edu-sharing.config'; +import { EduSharingModule } from './edu-sharing.module'; +import { EduSharingUC } from './uc'; + +@Module({ + imports: [ + AuthorizationModule, + AuthorizationReferenceModule, + EduSharingModule, + AuthenticationModule, + CoreModule, + HttpModule, + ConfigModule.forRoot(createConfigModuleOptions(config)), + ], + controllers: [EduSharingController], + providers: [EduSharingUC], +}) +export class EduSharingApiModule {} diff --git a/apps/server/src/modules/edu-sharing/edu-sharing-test.module.ts b/apps/server/src/modules/edu-sharing/edu-sharing-test.module.ts new file mode 100644 index 00000000000..e288d99ca9b --- /dev/null +++ b/apps/server/src/modules/edu-sharing/edu-sharing-test.module.ts @@ -0,0 +1,33 @@ +import { DynamicModule, Module } from '@nestjs/common'; + +import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; +import { AuthenticationModule } from '@modules/authentication'; +import { AuthorizationModule } from '@modules/authorization'; +import { ALL_ENTITIES } from '@shared/domain/entity'; +import { CoreModule } from '@src/core'; +import { LoggerModule } from '@src/core/logger'; + +const imports = [ + MongoMemoryDatabaseModule.forRoot({ entities: ALL_ENTITIES }), + AuthorizationModule, + AuthenticationModule, + CoreModule, + LoggerModule, +]; +const controllers = []; +const providers = []; +@Module({ + imports, + controllers, + providers, +}) +export class EduSharingTestModule { + static forRoot(options?: MongoDatabaseModuleOptions): DynamicModule { + return { + module: EduSharingTestModule, + imports: [...imports, MongoMemoryDatabaseModule.forRoot({ ...options })], + controllers, + providers, + }; + } +} diff --git a/apps/server/src/modules/edu-sharing/edu-sharing.config.ts b/apps/server/src/modules/edu-sharing/edu-sharing.config.ts new file mode 100644 index 00000000000..a4e5e8120db --- /dev/null +++ b/apps/server/src/modules/edu-sharing/edu-sharing.config.ts @@ -0,0 +1,20 @@ +import { Configuration } from '@hpi-schul-cloud/commons'; +import { CoreModuleConfig } from '@src/core'; + +export interface EduSharingConfig extends CoreModuleConfig { + APP_ID: string; + API_URL: string; +} + +export const defaultConfig = { + INCOMING_REQUEST_TIMEOUT: Configuration.get('EDU_SHARING__INCOMING_REQUEST_TIMEOUT') as number, + NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, +}; + +const eduSharingConfig: EduSharingConfig = { + APP_ID: Configuration.get('EDU_SHARING__APP_ID') as string, + API_URL: Configuration.get('EDU_SHARING__API_URL') as string, + ...defaultConfig, +}; + +export const config = () => eduSharingConfig; diff --git a/apps/server/src/modules/edu-sharing/edu-sharing.const.ts b/apps/server/src/modules/edu-sharing/edu-sharing.const.ts new file mode 100644 index 00000000000..e0e85f88f6b --- /dev/null +++ b/apps/server/src/modules/edu-sharing/edu-sharing.const.ts @@ -0,0 +1 @@ +export const API_VERSION_PATH = '/api/v3'; diff --git a/apps/server/src/modules/edu-sharing/edu-sharing.module.ts b/apps/server/src/modules/edu-sharing/edu-sharing.module.ts new file mode 100644 index 00000000000..7aad140a9f7 --- /dev/null +++ b/apps/server/src/modules/edu-sharing/edu-sharing.module.ts @@ -0,0 +1,39 @@ +import { RabbitMQWrapperModule } from '@infra/rabbitmq'; +import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; +import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; +import { HttpModule } from '@nestjs/axios'; +import { Module, NotFoundException } from '@nestjs/common'; +import { ALL_ENTITIES } from '@shared/domain/entity'; +import { DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; +import { LoggerModule } from '@src/core/logger'; +import { EduSharingService } from './service/edu-sharing.service'; + +const imports = [HttpModule, LoggerModule]; +const providers = [EduSharingService]; + +const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { + findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + new NotFoundException(`The requested ${entityName}: ${where} has not been found.`), +}; + +@Module({ + imports: [ + ...imports, + RabbitMQWrapperModule, + MikroOrmModule.forRoot({ + ...defaultMikroOrmOptions, + type: 'mongo', + // TODO add mongoose options as mongo options (see database.js) + clientUrl: DB_URL, + password: DB_PASSWORD, + user: DB_USERNAME, + entities: ALL_ENTITIES, + + // debug: true, // use it for locally debugging of querys + }), + ], + providers, + exports: [EduSharingService], +}) +export class EduSharingModule {} diff --git a/apps/server/src/modules/edu-sharing/index.ts b/apps/server/src/modules/edu-sharing/index.ts new file mode 100644 index 00000000000..6ee0883938f --- /dev/null +++ b/apps/server/src/modules/edu-sharing/index.ts @@ -0,0 +1,2 @@ +// this module has no exports +// it is an isolated module, it cannot be used in other modules diff --git a/apps/server/src/modules/edu-sharing/service/edu-sharing.service.ts b/apps/server/src/modules/edu-sharing/service/edu-sharing.service.ts new file mode 100644 index 00000000000..ae13c455e24 --- /dev/null +++ b/apps/server/src/modules/edu-sharing/service/edu-sharing.service.ts @@ -0,0 +1,184 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { LegacyLogger } from '@src/core/logger'; +import { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { createSign } from 'crypto'; +import { readFileSync } from 'fs'; +import { lastValueFrom } from 'rxjs'; +import { AuthenticationTokenDto, ErrorResponseDto, LoginDto } from '../dto'; +import { EduSharingConfig } from '../edu-sharing.config'; + +@Injectable() +export class EduSharingService { + private readonly appId: string; + + private readonly baseUrl: string; + + private readonly privateKey: string; + + private readonly publicKey: string; + + /** + * Constructor + * + * @param string appId + * @param string baseUrl + * @param string privateKey + * @param ConfigService configService + * @param LegacyLogger logger + */ + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + private readonly logger: LegacyLogger + ) { + this.appId = this.configService.get('APP_ID'); + this.baseUrl = this.configService.get('API_URL'); + this.privateKey = readFileSync('config/private.key', 'utf-8'); + this.publicKey = readFileSync('config/public.key', 'utf-8'); + this.logger.setContext(EduSharingService.name); + } + + /** + * Function getTicketForUser + * + * Fetches the edu-sharing ticket for a given username + * @param string username + * The username you want to generate a ticket for + * @param array|null additionalFields + * additional post fields to submit + * @return string + * The ticket, which you can use as an authentication header, see @getRESTAuthenticationHeader + * @throws AppAuthException + * @throws Exception + */ + async getTicketForUser( + userName = 'admin', + additionalFields?: { [key: string]: string | File } | undefined + ): Promise { + const options: AxiosRequestConfig = { + method: 'POST', + url: `${this.baseUrl}/rest/authentication/v1/appauth/${encodeURIComponent(userName)}`, + headers: this.getSignatureHeaders(userName), + timeout: 5000, + }; + if (additionalFields !== null) { + options.data = additionalFields; + } + const response: AxiosResponse = await lastValueFrom(this.httpService.request(options)); + const { data } = response; + const gotError = (data as ErrorResponseDto).error !== undefined; + const responseOk = !gotError; + if (responseOk && (data.userId === userName || data.userId?.startsWith(`${userName}@`))) { + return data.ticket; + } + throw new Error((data as ErrorResponseDto).message || ''); + } + + /** + * Function getTicketAuthenticationInfo + * + * Gets detailed information about a ticket + * Will throw an exception if the given ticket is not valid anymore + * @param string ticket + * The ticket, obtained by @getTicketForUser + * @return array + * Detailed information about the current session + * @throws Exception + * Thrown if the ticket is not valid anymore + */ + async getTicketAuthenticationInfo(ticket: string): Promise { + const headers = { + Authorization: this.getRESTAuthenticationHeader(ticket), + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + const options: AxiosRequestConfig = { + method: 'GET', + url: `${this.baseUrl}/rest/authentication/v1/validateSession`, + headers, + timeout: 5000, + }; + const response: AxiosResponse = await lastValueFrom(this.httpService.request(options)); + const { data } = response; + if (data.statusCode !== 'OK') { + throw new Error('The given ticket is not valid anymore.'); + } + return data; + } + + getEduAppXMLData(): string { + return this.generateEduAppXMLData(this.publicKey); + } + + /** + * Function getRESTAuthenticationHeader + * + * Generates the header to use for a given ticket to authenticate with any edu-sharing api endpoint + * @param string ticket + * The ticket, obtained by @getTicketForUser + * @return string + */ + private getRESTAuthenticationHeader(ticket: string): string { + return `EDU-TICKET ${ticket}`; + } + + /** + * Function getSignatureHeaders + * + * @param string signString + * @param string accept + * @param string contentType + * @return string[] + */ + private getSignatureHeaders(signString: string, accept = 'application/json', contentType = 'application/json') { + const ts = new Date().getTime(); + const toSign = `${this.appId}${signString}${ts}`; + const signature = this.sign(toSign); + return { + Accept: accept, + 'Content-Type': contentType, + 'X-Edu-App-Id': this.appId, + 'X-Edu-App-Signed': toSign, + 'X-Edu-App-Sig': signature, + 'X-Edu-App-Ts': ts, + }; + } + + /** + * Function sign + * + * @param string data + * @return string + */ + private sign(data: string): string { + const sign = createSign('SHA1'); + sign.write(data); + sign.end(); + return sign.sign(this.privateKey, 'base64'); + } + + /** + * Function generateEduAppXMLData + * + * Generates an edu-sharing compatible xml file for registering the application + * This is a very basic function and is only intended for demonstration or manual use. Data is not escaped! + */ + private generateEduAppXMLData(publicKey: string, type = 'LMS', publicIP = '*'): string { + return ( + '\n' + + '\n' + + '\n' + + ` ${this.appId}\n` + + ` ${publicKey}\n` + + ` ${type}\n` + + ' \n' + + ' \n' + + ` ${publicIP}\n` + + ' \n' + + ' true\n' + + '\n' + ); + } +} diff --git a/apps/server/src/modules/edu-sharing/service/index.ts b/apps/server/src/modules/edu-sharing/service/index.ts new file mode 100644 index 00000000000..74efd17737e --- /dev/null +++ b/apps/server/src/modules/edu-sharing/service/index.ts @@ -0,0 +1 @@ +export * from './edu-sharing.service'; diff --git a/apps/server/src/modules/edu-sharing/uc/edu-sharing.uc.ts b/apps/server/src/modules/edu-sharing/uc/edu-sharing.uc.ts new file mode 100644 index 00000000000..2f67352523a --- /dev/null +++ b/apps/server/src/modules/edu-sharing/uc/edu-sharing.uc.ts @@ -0,0 +1,39 @@ +import { AuthorizationService } from '@modules/authorization'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { LegacyLogger } from '@src/core/logger'; +import { TicketParams } from '../controller/dto'; +import { LoginDto } from '../dto'; +import { EduSharingService } from '../service/edu-sharing.service'; + +@Injectable() +export class EduSharingUC { + constructor( + @Inject(forwardRef(() => AuthorizationService)) + protected readonly authorizationService: AuthorizationService, + private readonly logger: LegacyLogger, + private readonly eduSharingService: EduSharingService + ) {} + + public async getTicketForUser(userId: EntityId): Promise { + const user = await this.authorizationService.getUserWithPermissions(userId); + + const userName = `${user.firstName}.${user.lastName}`; + + const ticket = await this.eduSharingService.getTicketForUser(userName); + + return ticket; + } + + public async getTicketAuthenticationInfo(params: TicketParams): Promise { + const ticketInfo = await this.eduSharingService.getTicketAuthenticationInfo(params.ticket); + + return ticketInfo; + } + + public getEduAppXMLData(): string { + const xmlData = this.eduSharingService.getEduAppXMLData(); + + return xmlData; + } +} diff --git a/apps/server/src/modules/edu-sharing/uc/index.ts b/apps/server/src/modules/edu-sharing/uc/index.ts new file mode 100644 index 00000000000..1997dd62206 --- /dev/null +++ b/apps/server/src/modules/edu-sharing/uc/index.ts @@ -0,0 +1 @@ +export * from './edu-sharing.uc'; diff --git a/apps/server/src/modules/server/api/dto/config.response.ts b/apps/server/src/modules/server/api/dto/config.response.ts index d92b29aed4d..13bd79d58f6 100644 --- a/apps/server/src/modules/server/api/dto/config.response.ts +++ b/apps/server/src/modules/server/api/dto/config.response.ts @@ -227,6 +227,9 @@ export class ConfigResponse { @ApiProperty() FEATURE_ROOMS_ENABLED: boolean; + @ApiProperty() + EDU_SHARING__API_URL: string; + constructor(config: ServerConfig) { this.ACCESSIBILITY_REPORT_EMAIL = config.ACCESSIBILITY_REPORT_EMAIL; this.ADMIN_TABLES_DISPLAY_CONSENT_COLUMN = config.ADMIN_TABLES_DISPLAY_CONSENT_COLUMN; @@ -301,5 +304,6 @@ export class ConfigResponse { this.FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED = config.FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED; this.FEATURE_AI_TUTOR_ENABLED = config.FEATURE_AI_TUTOR_ENABLED; this.FEATURE_ROOMS_ENABLED = config.FEATURE_ROOMS_ENABLED; + this.EDU_SHARING__API_URL = config.EDU_SHARING__API_URL; } } diff --git a/apps/server/src/modules/server/api/test/server.api.spec.ts b/apps/server/src/modules/server/api/test/server.api.spec.ts index 5627d34e17e..53fcb1635b4 100644 --- a/apps/server/src/modules/server/api/test/server.api.spec.ts +++ b/apps/server/src/modules/server/api/test/server.api.spec.ts @@ -103,6 +103,7 @@ describe('Server Controller (API)', () => { 'FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED', 'FEATURE_AI_TUTOR_ENABLED', 'FEATURE_ROOMS_ENABLED', + 'EDU_SHARING__API_URL', ]; expect(response.status).toEqual(HttpStatus.OK); diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index ceac280b176..004c55b9382 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -128,6 +128,7 @@ export interface ServerConfig FEATURE_AI_TUTOR_ENABLED: boolean; FEATURE_ROOMS_ENABLED: boolean; FEATURE_TSP_SYNC_ENABLED: boolean; + EDU_SHARING__API_URL: string; } const config: ServerConfig = { @@ -304,6 +305,7 @@ const config: ServerConfig = { FEATURE_SANIS_GROUP_PROVISIONING_ENABLED: Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED') as boolean, FEATURE_AI_TUTOR_ENABLED: Configuration.get('FEATURE_AI_TUTOR_ENABLED') as boolean, FEATURE_ROOMS_ENABLED: Configuration.get('FEATURE_ROOMS_ENABLED') as boolean, + EDU_SHARING__API_URL: Configuration.get('EDU_SHARING__API_URL') as string, }; export const serverConfig = () => config; diff --git a/config/default.schema.json b/config/default.schema.json index fa46bd303eb..741b5005139 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -230,6 +230,31 @@ "type": "string", "description": "The value is used to disable logging color in terminal and is set in production always to true. It is not connected to the loggers it self." }, + "EDU_SHARING": { + "type": "object", + "description": "edu-sharing server properties, required always to be defined", + "required": [], + "properties": { + "APP_ID": { + "type": "string", + "description": "The app ID for the edu-sharing repository.", + "default": "schul-cloud" + }, + "API_URL": { + "type": "string", + "format": "uri", + "pattern": ".*(?