diff --git a/.npmrc b/.npmrc
new file mode 100644
index 00000000..bf2e7648
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+shamefully-hoist=true
diff --git a/src/deep-link/deep-link.controller.ts b/src/deep-link/deep-link.controller.ts
index 19fb91b3..4768ae68 100644
--- a/src/deep-link/deep-link.controller.ts
+++ b/src/deep-link/deep-link.controller.ts
@@ -1,8 +1,11 @@
import { Controller, Get, Param, Res, Headers } from '@nestjs/common';
import { Response } from 'express';
+import { DeepLinkService } from './deep-link.service';
@Controller()
export class DeepLinkController {
+ constructor(private readonly deepLinkService: DeepLinkService) {}
+
@Get('.well-known/apple-app-site-association')
getAppleAASA(@Res() res: Response) {
const aasa = {
@@ -43,13 +46,9 @@ export class DeepLinkController {
@Res() res: Response,
) {
const isMobile = /Mobile|Android|iPhone|iPod|iPad/i.test(userAgent || '');
+ const type = isMobile ? 'app' : 'web';
+ const location = this.deepLinkService.buildDeepLink(type, 'course', id);
- if (isMobile) {
- // Redirect to custom URL scheme
- return res.redirect(`teachlink://course/${id}`);
- }
-
- // Redirect to web URL
- return res.redirect(`/course/${id}`);
+ return res.redirect(location);
}
}
diff --git a/src/deep-link/deep-link.module.ts b/src/deep-link/deep-link.module.ts
index 66c35693..8b41c4c3 100644
--- a/src/deep-link/deep-link.module.ts
+++ b/src/deep-link/deep-link.module.ts
@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { DeepLinkController } from './deep-link.controller';
+import { DeepLinkService } from './deep-link.service';
@Module({
controllers: [DeepLinkController],
+ providers: [DeepLinkService],
})
export class DeepLinkModule {}
diff --git a/src/deep-link/deep-link.service.spec.ts b/src/deep-link/deep-link.service.spec.ts
new file mode 100644
index 00000000..801a8ef8
--- /dev/null
+++ b/src/deep-link/deep-link.service.spec.ts
@@ -0,0 +1,125 @@
+import { DeepLinkService } from './deep-link.service';
+
+describe('DeepLinkService', () => {
+ let service: DeepLinkService;
+
+ beforeEach(() => {
+ service = new DeepLinkService();
+ });
+
+ describe('validateRoute', () => {
+ it('should return true for allowlisted routes', () => {
+ expect(service.validateRoute('course')).toBe(true);
+ expect(service.validateRoute('/course')).toBe(true);
+ });
+
+ it('should return false for non-allowlisted routes', () => {
+ expect(service.validateRoute('admin')).toBe(false);
+ expect(service.validateRoute('/admin')).toBe(false);
+ expect(service.validateRoute('settings')).toBe(false);
+ expect(service.validateRoute('')).toBe(false);
+ });
+ });
+
+ describe('validateParam', () => {
+ it('should accept valid alphanumeric params', () => {
+ expect(service.validateParam('123')).toBe('123');
+ expect(service.validateParam('abc')).toBe('abc');
+ expect(service.validateParam('ABC')).toBe('ABC');
+ expect(service.validateParam('course-123')).toBe('course-123');
+ expect(service.validateParam('course_456')).toBe('course_456');
+ });
+
+ it('should trim whitespace from params', () => {
+ expect(service.validateParam(' 123 ')).toBe('123');
+ });
+
+ it('should reject empty params', () => {
+ expect(() => service.validateParam('')).toThrow('Invalid parameter value');
+ expect(() => service.validateParam(' ')).toThrow('Parameter value cannot be empty');
+ });
+
+ it('should reject absolute URLs', () => {
+ expect(() => service.validateParam('http://evil.com')).toThrow('Absolute URLs are not allowed');
+ expect(() => service.validateParam('https://evil.com')).toThrow('Absolute URLs are not allowed');
+ expect(() => service.validateParam('ftp://evil.com')).toThrow('Absolute URLs are not allowed');
+ expect(() => service.validateParam('//evil.com')).toThrow('Absolute URLs are not allowed');
+ });
+
+ it('should reject external URL schemes', () => {
+ expect(() => service.validateParam('javascript:alert(1)')).toThrow('External URL schemes are not allowed');
+ expect(() => service.validateParam('data:text/html,')).toThrow('External URL schemes are not allowed');
+ expect(() => service.validateParam('vbscript:msgbox(1)')).toThrow('External URL schemes are not allowed');
+ });
+
+ it('should reject path traversal attempts', () => {
+ expect(() => service.validateParam('../secret')).toThrow('Path traversal is not allowed');
+ expect(() => service.validateParam('..\\secret')).toThrow('Path traversal is not allowed');
+ expect(() => service.validateParam('../../etc/passwd')).toThrow('Path traversal is not allowed');
+ expect(() => service.validateParam('foo/../bar')).toThrow('Path traversal is not allowed');
+ });
+
+ it('should reject injection characters', () => {
+ expect(() => service.validateParam('