diff --git a/.env.example b/.env.example
index 1e98a941..239f191a 100644
--- a/.env.example
+++ b/.env.example
@@ -22,3 +22,5 @@ GITHUB_CLIENT_ID=GitHub App client ID
GITHUB_CLIENT_SECRET=GitHub App client secret
GITHUB_APP_ID=123456
GITHUB_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key - see README.md for more info
+ENCRYPTION_PUBLIC_KEY_BASE_64=base 64 encoded version of the public key
+ENCRYPTION_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key
diff --git a/__test__/encrypt/EncryptionService.test.ts b/__test__/encrypt/EncryptionService.test.ts
new file mode 100644
index 00000000..3b378088
--- /dev/null
+++ b/__test__/encrypt/EncryptionService.test.ts
@@ -0,0 +1,80 @@
+import RsaEncryptionService from '../../src/features/encrypt/EncryptionService'
+
+const publicKey = `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1k4JT719AUz/wuXb2rt
+8933okfM2Iynmc6akSsZWEsW19byzO0UHp8b79xvsmNQKM1wBEBnXb5t+uLjJJZe
+rqCiTB7fBL64tExSKIDIRAlMnQtMfHs/rMgR+o/N2Yo2KimQw9G84goCEbBF2kbw
+5/MQfe43HeEoVWbNfgmRyP8VudO1UtVr07dGoUEWvFjudtd/h5H9THVdEpp2vH2Z
+pSGypn8hRAbOzhIM4ExLOH4ZHb8gPQGiHRGUYXk3Cy95RSf/SpEnRi0p4/63Nx5M
+JNXGM2Jk0RgGcYZcwJvLanT5Xdb9LM/IsDxLKXN+utDUgkzddvJbBC12aLaKaJA5
+LwIDAQAB
+-----END PUBLIC KEY-----`
+
+const privateKey = `-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7WTglPvX0BTP/
+C5dvau3z3feiR8zYjKeZzpqRKxlYSxbX1vLM7RQenxvv3G+yY1AozXAEQGddvm36
+4uMkll6uoKJMHt8Evri0TFIogMhECUydC0x8ez+syBH6j83ZijYqKZDD0bziCgIR
+sEXaRvDn8xB97jcd4ShVZs1+CZHI/xW507VS1WvTt0ahQRa8WO5213+Hkf1MdV0S
+mna8fZmlIbKmfyFEBs7OEgzgTEs4fhkdvyA9AaIdEZRheTcLL3lFJ/9KkSdGLSnj
+/rc3Hkwk1cYzYmTRGAZxhlzAm8tqdPld1v0sz8iwPEspc3660NSCTN128lsELXZo
+topokDkvAgMBAAECggEAAWQMl0laQ8OZfiqWY72Ry0oYPgFvFO1PpkQHObm3+S+d
+8Q81IgXNLNtWKSA4VpXYQ4zcJUpADmg1ZdxAfszUB4kcshHdpz4Z9Y849i6KW4l4
+qZsP3hbQWtTbgYWG71+M+y2sqJu0hgCkLPmm31AsJDG6zPtEKokKbYH7jWV0Xo5z
+0g6IUqepc1ElNzsJAU10hgX5UZUPxvzbWHxhBhFzC51GKpfx/W5ZOQtB+W8+nlmC
+OSVlZ9pfr6qxOZbSLWESU1xplywPTPLoYs/38oN5OHIJvB2j8kl+JfcR7v2ezLeV
+fx1Z+x9ME0at7AbGCfhjIfJtftPsoCR60nzN3wWoAQKBgQDfOmfzLaWhVkvt49Hn
+zeLdLI8pwqWXVYozsPMRlExwuIT1KeNolPzWWKx6dG38UzY4XWSvq+w3WAcQ7m6E
+qiRWoRPL3qlWu3pDJYr/EfR2haPMQMwbJM/hg+nC0bhUSVqBEjOZgaQUHStIyugb
+SWQFI3jE9fgj71DtbiVNrb1vAQKBgQDW2ljkotAjF81vI+EoN9QmuPYnejo42nK9
+jlSEU4hrDQMLiqxc5yJidQh75vZRfaO9rdUqHxoXK0DEU3Jk16Kb0n4nkM+xqKoc
+yHTtAgUyflpenbrr4pRZf783XgI0bn/FhoMFQtAvSblru3NfEFQUtKIY82+Xa5H5
+g+cezSDYLwKBgBeViB39GJ6vC16azzZ6XhmX95gl5HDUrMFBVKzqyhiupf1w64HF
+G+FZhP97BZO/Bt91nomg1FgUiMqVJkAF6cjtQ7YqVCHBtO0bLlA8iWNsQx31Spsj
+jIL6+NuIZL0i8tjoH2N8euVVH5mVNmiLnHGeicflZM4HHrm3BWHrlTQBAoGBALeW
+W98CQFe8Pw542ixDiESOR8fz6UwrXWAb/pwTxL20oKV8GUxJNFhtKJK3CEMZ2JB7
+uWoEqYairvUTWOxSVeBQPPwSAWcNeE6f+0mKMGa1EQNIRDDLq3fOcNYevkOPKB7g
+kZQtQzclCAvGYQ8aJL6MmvY3DWOVx2YuD4+COE6BAoGAEGdChfJW5QGXaXEO/PnA
+PbQCCzcqbs+0O6LVR1w68H0WQww94tZjfWPqn9kvwjzLd22ZMmdiBJ3bEbDeCjmG
+Ybt48kS7y9n22CDgL7JkatszYpybvBSrDQL7ms7x2kKPkTMb7C5zpIIzdtvwH+Jf
+6K3kQbqfFCM7VmyR7AmoyOk=
+-----END PRIVATE KEY-----`
+
+const encryptionService = new RsaEncryptionService({ publicKey, privateKey })
+
+describe('RsaEncryptionService', () => {
+ it('should encrypt and decrypt data correctly', () => {
+ const data = 'Hello, World!'
+ const encryptedData = encryptionService.encrypt(data)
+ const decryptedData = encryptionService.decrypt(encryptedData)
+
+ expect(decryptedData).toBe(data)
+ })
+
+ it('should throw an error when decrypting with incorrect data', () => {
+ const incorrectData = 'invalidEncryptedData'
+
+ expect(() => {
+ encryptionService.decrypt(incorrectData)
+ }).toThrow()
+ })
+
+ it('should throw an error when encrypting with an invalid public key', () => {
+ const invalidPublicKey = 'invalidPublicKey'
+ const invalidEncryptionService = new RsaEncryptionService({ publicKey: invalidPublicKey, privateKey })
+
+ expect(() => {
+ invalidEncryptionService.encrypt('test')
+ }).toThrow()
+ })
+
+ it('should throw an error when decrypting with an invalid private key', () => {
+ const data = 'Hello, World!'
+ const encryptedData = encryptionService.encrypt(data)
+ const invalidPrivateKey = 'invalidPrivateKey'
+ const invalidEncryptionService = new RsaEncryptionService({ publicKey, privateKey: invalidPrivateKey })
+
+ expect(() => {
+ invalidEncryptionService.decrypt(encryptedData)
+ }).toThrow()
+ })
+})
diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts
index ac01eaa1..6dcbda37 100644
--- a/__test__/projects/GitHubProjectDataSource.test.ts
+++ b/__test__/projects/GitHubProjectDataSource.test.ts
@@ -1,4 +1,29 @@
import { GitHubProjectDataSource } from "@/features/projects/data"
+import RemoteConfig from "@/features/projects/domain/RemoteConfig"
+
+/**
+ * Simple encryption service for testing. Does nothing.
+ */
+const noopEncryptionService = {
+ encrypt: function (data: string): string {
+ return data
+ },
+ decrypt: function (encryptedDataBase64: string): string {
+ return encryptedDataBase64
+ }
+}
+
+/**
+ * Simple encoder for testing
+ */
+const base64RemoteConfigEncoder = {
+ encode: function (remoteConfig: RemoteConfig): string {
+ return Buffer.from(JSON.stringify(remoteConfig)).toString("base64")
+ },
+ decode: function (encodedString: string): RemoteConfig {
+ return JSON.parse(Buffer.from(encodedString, "base64").toString())
+ }
+}
test("It loads repositories from data source", async () => {
let didLoadRepositories = false
@@ -9,7 +34,9 @@ test("It loads repositories from data source", async () => {
didLoadRepositories = true
return []
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
await sut.getProjects()
expect(didLoadRepositories).toBeTruthy()
@@ -43,7 +70,9 @@ test("It maps projects including branches and tags", async () => {
}]
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects).toEqual([{
@@ -107,7 +136,9 @@ test("It removes suffix from project name", async () => {
}]
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].id).toEqual("acme-foo")
@@ -147,7 +178,9 @@ test("It supports multiple OpenAPI specifications on a branch", async () => {
}]
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects).toEqual([{
@@ -209,7 +242,9 @@ test("It filters away projects with no versions", async () => {
tags: []
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects.length).toEqual(0)
@@ -243,7 +278,9 @@ test("It filters away branches with no specifications", async () => {
tags: []
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].versions.length).toEqual(1)
@@ -283,7 +320,9 @@ test("It filters away tags with no specifications", async () => {
}]
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].versions.length).toEqual(2)
@@ -314,7 +353,9 @@ test("It reads image from configuration file with .yml extension", async () => {
tags: []
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678")
@@ -345,7 +386,9 @@ test("It reads display name from configuration file with .yml extension", async
tags: []
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].id).toEqual("acme-foo")
@@ -378,7 +421,9 @@ test("It reads image from configuration file with .yaml extension", async () =>
tags: []
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678")
@@ -409,7 +454,9 @@ test("It reads display name from configuration file with .yaml extension", async
tags: []
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].id).toEqual("acme-foo")
@@ -478,7 +525,9 @@ test("It sorts projects alphabetically", async () => {
tags: []
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].name).toEqual("anne")
@@ -529,7 +578,9 @@ test("It sorts versions alphabetically", async () => {
}]
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].versions[0].name).toEqual("1.0")
@@ -593,7 +644,9 @@ test("It prioritizes main, master, develop, and development branch names when so
}]
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].versions[0].name).toEqual("main")
@@ -641,7 +694,9 @@ test("It identifies the default branch in returned versions", async () => {
tags: []
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
const defaultVersionNames = projects[0]
@@ -682,7 +737,9 @@ test("It adds remote versions from the project configuration", async () => {
tags: []
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].versions).toEqual([{
@@ -692,11 +749,11 @@ test("It adds remote versions from the project configuration", async () => {
specifications: [{
id: "huey",
name: "Huey",
- url: `/api/proxy?url=${encodeURIComponent("https://example.com/huey.yml")}`
+ url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/huey.yml" })}`
}, {
id: "dewey",
name: "Dewey",
- url: `/api/proxy?url=${encodeURIComponent("https://example.com/dewey.yml")}`
+ url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/dewey.yml" })}`
}]
}, {
id: "bobby",
@@ -705,7 +762,7 @@ test("It adds remote versions from the project configuration", async () => {
specifications: [{
id: "louie",
name: "Louie",
- url: `/api/proxy?url=${encodeURIComponent("https://example.com/louie.yml")}`
+ url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/louie.yml" })}`
}]
}])
})
@@ -745,7 +802,9 @@ test("It modifies ID of remote version if the ID already exists", async () => {
tags: []
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].versions).toEqual([{
@@ -766,7 +825,7 @@ test("It modifies ID of remote version if the ID already exists", async () => {
specifications: [{
id: "baz",
name: "Baz",
- url: `/api/proxy?url=${encodeURIComponent("https://example.com/baz.yml")}`
+ url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`
}]
}, {
id: "bar2",
@@ -775,7 +834,7 @@ test("It modifies ID of remote version if the ID already exists", async () => {
specifications: [{
id: "hello",
name: "Hello",
- url: `/api/proxy?url=${encodeURIComponent("https://example.com/hello.yml")}`
+ url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/hello.yml" })}`
}]
}])
})
@@ -806,7 +865,9 @@ test("It lets users specify the ID of a remote version", async () => {
tags: []
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].versions).toEqual([{
@@ -816,7 +877,7 @@ test("It lets users specify the ID of a remote version", async () => {
specifications: [{
id: "baz",
name: "Baz",
- url: `/api/proxy?url=${encodeURIComponent("https://example.com/baz.yml")}`
+ url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`
}]
}])
})
@@ -847,7 +908,9 @@ test("It lets users specify the ID of a remote specification", async () => {
tags: []
}]
}
- }
+ },
+ encryptionService: noopEncryptionService,
+ remoteConfigEncoder: base64RemoteConfigEncoder
})
const projects = await sut.getProjects()
expect(projects[0].versions).toEqual([{
@@ -857,7 +920,7 @@ test("It lets users specify the ID of a remote specification", async () => {
specifications: [{
id: "some-spec",
name: "Baz",
- url: `/api/proxy?url=${encodeURIComponent("https://example.com/baz.yml")}`
+ url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`
}]
}])
})
diff --git a/__test__/projects/RemoteConfigEncoder.test.ts b/__test__/projects/RemoteConfigEncoder.test.ts
new file mode 100644
index 00000000..924f5633
--- /dev/null
+++ b/__test__/projects/RemoteConfigEncoder.test.ts
@@ -0,0 +1,38 @@
+import RemoteConfigEncoder from "@/features/projects/domain/RemoteConfigEncoder"
+import { IEncryptionService } from "@/features/encrypt/EncryptionService"
+import RemoteConfig from "@/features/projects/domain/RemoteConfig"
+import { ZodError } from "zod"
+
+describe('RemoteConfigEncoder', () => {
+ const encryptionService: IEncryptionService = {
+ encrypt: (data: string) => `encrypted-${data}`,
+ decrypt: (data: string) => data.replace('encrypted-', '')
+ }
+
+ const encoder = new RemoteConfigEncoder(encryptionService)
+
+ it('should encode a remote config by first encrypting and then encoding with base64', () => {
+ const remoteConfig: RemoteConfig = { url: 'https://example.com/spec.yaml' }
+ const encoded = encoder.encode(remoteConfig)
+ const expectedEncoded = Buffer.from('encrypted-{"url":"https://example.com/spec.yaml"}').toString('base64')
+ expect(encoded).toEqual(expectedEncoded)
+ })
+
+ it('should decode an encoded string', () => {
+ const encodedString = Buffer.from('encrypted-{"url":"https://example.com/spec.yaml"}').toString('base64')
+ const decoded = encoder.decode(encodedString)
+ const expectedDecoded: RemoteConfig = { url: 'https://example.com/spec.yaml' }
+ expect(decoded).toEqual(expectedDecoded)
+ })
+
+ it('should throw an error if the decrypted string is not valid JSON', () => {
+ const invalidJson = Buffer.from('encrypted-invalid-json').toString('base64')
+ expect(() => encoder.decode(invalidJson)).toThrow(/Unexpected token/)
+ })
+
+ it('should throw an error if the remote config is not valid', () => {
+ const remoteConfig: RemoteConfig = { url: '' }
+ const encoded = encoder.encode(remoteConfig)
+ expect(() => encoder.decode(encoded)).toThrow(ZodError)
+ })
+})
diff --git a/package-lock.json b/package-lock.json
index 78afc9c5..9c9e2bb4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,18 +11,18 @@
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource/poppins": "^5.1.0",
- "@fortawesome/fontawesome-svg-core": "^6.6.0",
- "@fortawesome/free-brands-svg-icons": "^6.6.0",
- "@fortawesome/free-regular-svg-icons": "^6.6.0",
- "@fortawesome/free-solid-svg-icons": "^6.6.0",
+ "@fortawesome/fontawesome-svg-core": "^6.7.1",
+ "@fortawesome/free-brands-svg-icons": "^6.7.1",
+ "@fortawesome/free-regular-svg-icons": "^6.7.1",
+ "@fortawesome/free-solid-svg-icons": "^6.7.1",
"@fortawesome/react-fontawesome": "^0.2.2",
- "@mui/icons-material": "^6.1.6",
+ "@mui/icons-material": "^6.1.8",
"@mui/material": "^6.0.1",
- "@octokit/auth-app": "^7.1.2",
+ "@octokit/auth-app": "^7.1.3",
"@octokit/core": "^6.1.2",
"@octokit/webhooks": "^13.3.0",
"@stoplight/elements": "^8.4.5",
- "core-js": "^3.38.1",
+ "core-js": "^3.39.0",
"encoding": "^0.1.13",
"figma-squircle": "^1.1.0",
"install": "^0.13.0",
@@ -54,8 +54,8 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.0",
"@types/swagger-ui-react": "^4.18.3",
- "@typescript-eslint/eslint-plugin": "^8.13.0",
- "@typescript-eslint/parser": "^8.13.0",
+ "@typescript-eslint/eslint-plugin": "^8.15.0",
+ "@typescript-eslint/parser": "^8.15.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.14",
@@ -939,52 +939,52 @@
"integrity": "sha512-tpLXlnNi2fwQjiipvuj4uNFHCdoLA8izRsKdoexZuEzjx0r/g1aKLf4ta6lFgF7L+/+AFdmaXFlUwwvmDzYH+g=="
},
"node_modules/@fortawesome/fontawesome-common-types": {
- "version": "6.6.0",
- "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz",
- "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==",
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.1.tgz",
+ "integrity": "sha512-gbDz3TwRrIPT3i0cDfujhshnXO9z03IT1UKRIVi/VEjpNHtSBIP2o5XSm+e816FzzCFEzAxPw09Z13n20PaQJQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
- "version": "6.6.0",
- "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz",
- "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==",
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.1.tgz",
+ "integrity": "sha512-8dBIHbfsKlCk2jHQ9PoRBg2Z+4TwyE3vZICSnoDlnsHA6SiMlTwfmW6yX0lHsRmWJugkeb92sA0hZdkXJhuz+g==",
"dependencies": {
- "@fortawesome/fontawesome-common-types": "6.6.0"
+ "@fortawesome/fontawesome-common-types": "6.7.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
- "version": "6.6.0",
- "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz",
- "integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==",
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.7.1.tgz",
+ "integrity": "sha512-nJR76eqPzCnMyhbiGf6X0aclDirZriTPRcFm1YFvuupyJOGwlNF022w3YBqu+yrHRhnKRpzFX+8wJKqiIjWZkA==",
"dependencies": {
- "@fortawesome/fontawesome-common-types": "6.6.0"
+ "@fortawesome/fontawesome-common-types": "6.7.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
- "version": "6.6.0",
- "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz",
- "integrity": "sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ==",
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.1.tgz",
+ "integrity": "sha512-e13cp+bAx716RZOTQ59DhqikAgETA9u1qTBHO3e3jMQQ+4H/N1NC1ZVeFYt1V0m+Th68BrEL1/X6XplISutbXg==",
"dependencies": {
- "@fortawesome/fontawesome-common-types": "6.6.0"
+ "@fortawesome/fontawesome-common-types": "6.7.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
- "version": "6.6.0",
- "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz",
- "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==",
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.1.tgz",
+ "integrity": "sha512-BTKc0b0mgjWZ2UDKVgmwaE0qt0cZs6ITcDgjrti5f/ki7aF5zs+N91V6hitGo3TItCFtnKg6cUVGdTmBFICFRg==",
"dependencies": {
- "@fortawesome/fontawesome-common-types": "6.6.0"
+ "@fortawesome/fontawesome-common-types": "6.7.1"
},
"engines": {
"node": ">=6"
@@ -2031,18 +2031,18 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@mui/core-downloads-tracker": {
- "version": "6.1.6",
- "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.6.tgz",
- "integrity": "sha512-nz1SlR9TdBYYPz4qKoNasMPRiGb4PaIHFkzLzhju0YVYS5QSuFF2+n7CsiHMIDcHv3piPu/xDWI53ruhOqvZwQ==",
+ "version": "6.1.8",
+ "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.8.tgz",
+ "integrity": "sha512-TGAvzwUg9hybDacwfIGFjI2bXYXrIqky+vMfaeay8rvT56/PNAlvIDUJ54kpT5KRc9AWAihOvtDI7/LJOThOmQ==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
}
},
"node_modules/@mui/icons-material": {
- "version": "6.1.6",
- "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.1.6.tgz",
- "integrity": "sha512-5r9urIL2lxXb/sPN3LFfFYEibsXJUb986HhhIeu1gOcte460pwdSiEhBSxkAuyT8Dj7jvu9MjqSBmSumQELo8A==",
+ "version": "6.1.8",
+ "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.1.8.tgz",
+ "integrity": "sha512-6frsXcf1TcJKWevWwRup6V4L8lzI33cbHcAjT83YLgKw0vYRZKY0kjMI9fhrJZdRWXgFFgKKvEv3GjoxbqFF7A==",
"dependencies": {
"@babel/runtime": "^7.26.0"
},
@@ -2054,7 +2054,7 @@
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
- "@mui/material": "^6.1.6",
+ "@mui/material": "^6.1.8",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
@@ -2065,15 +2065,15 @@
}
},
"node_modules/@mui/material": {
- "version": "6.1.6",
- "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.6.tgz",
- "integrity": "sha512-1yvejiQ/601l5AK3uIdUlAVElyCxoqKnl7QA+2oFB/2qYPWfRwDgavW/MoywS5Y2gZEslcJKhe0s2F3IthgFgw==",
+ "version": "6.1.8",
+ "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.8.tgz",
+ "integrity": "sha512-QZdQFnXct+7NXIzHgT3qt+sQiO7HYGZU2vymP9Xl9tUMXEOA/S1mZMMb7+WGZrk5TzNlU/kP/85K0da5V1jXoQ==",
"dependencies": {
"@babel/runtime": "^7.26.0",
- "@mui/core-downloads-tracker": "^6.1.6",
- "@mui/system": "^6.1.6",
+ "@mui/core-downloads-tracker": "^6.1.8",
+ "@mui/system": "^6.1.8",
"@mui/types": "^7.2.19",
- "@mui/utils": "^6.1.6",
+ "@mui/utils": "^6.1.8",
"@popperjs/core": "^2.11.8",
"@types/react-transition-group": "^4.4.11",
"clsx": "^2.1.1",
@@ -2092,7 +2092,7 @@
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
- "@mui/material-pigment-css": "^6.1.6",
+ "@mui/material-pigment-css": "^6.1.8",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -2113,12 +2113,12 @@
}
},
"node_modules/@mui/private-theming": {
- "version": "6.1.6",
- "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.6.tgz",
- "integrity": "sha512-ioAiFckaD/fJSnTrUMWgjl9HYBWt7ixCh7zZw7gDZ+Tae7NuprNV6QJK95EidDT7K0GetR2rU3kAeIR61Myttw==",
+ "version": "6.1.8",
+ "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.8.tgz",
+ "integrity": "sha512-TuKl7msynCNCVvhX3c0ef1sF0Qb3VHcPs8XOGB/8bdOGBr/ynmIG1yTMjZeiFQXk8yN9fzK/FDEKMFxILNn3wg==",
"dependencies": {
"@babel/runtime": "^7.26.0",
- "@mui/utils": "^6.1.6",
+ "@mui/utils": "^6.1.8",
"prop-types": "^15.8.1"
},
"engines": {
@@ -2139,9 +2139,9 @@
}
},
"node_modules/@mui/styled-engine": {
- "version": "6.1.6",
- "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.6.tgz",
- "integrity": "sha512-I+yS1cSuSvHnZDBO7e7VHxTWpj+R7XlSZvTC4lS/OIbUNJOMMSd3UDP6V2sfwzAdmdDNBi7NGCRv2SZ6O9hGDA==",
+ "version": "6.1.8",
+ "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.8.tgz",
+ "integrity": "sha512-ZvEoT0U2nPLSLI+B4by4cVjaZnPT2f20f4JUPkyHdwLv65ZzuoHiTlwyhqX1Ch63p8bcJzKTHQVGisEoMK6PGA==",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@emotion/cache": "^11.13.1",
@@ -2172,15 +2172,15 @@
}
},
"node_modules/@mui/system": {
- "version": "6.1.6",
- "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.6.tgz",
- "integrity": "sha512-qOf1VUE9wK8syiB0BBCp82oNBAVPYdj4Trh+G1s+L+ImYiKlubWhhqlnvWt3xqMevR+D2h1CXzA1vhX2FvA+VQ==",
+ "version": "6.1.8",
+ "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.8.tgz",
+ "integrity": "sha512-i1kLfQoWxzFpXTBQIuPoA3xKnAnP3en4I2T8xIolovSolGQX5k8vGjw1JaydQS40td++cFsgCdEU458HDNTGUA==",
"dependencies": {
"@babel/runtime": "^7.26.0",
- "@mui/private-theming": "^6.1.6",
- "@mui/styled-engine": "^6.1.6",
+ "@mui/private-theming": "^6.1.8",
+ "@mui/styled-engine": "^6.1.8",
"@mui/types": "^7.2.19",
- "@mui/utils": "^6.1.6",
+ "@mui/utils": "^6.1.8",
"clsx": "^2.1.1",
"csstype": "^3.1.3",
"prop-types": "^15.8.1"
@@ -2224,9 +2224,9 @@
}
},
"node_modules/@mui/utils": {
- "version": "6.1.6",
- "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.6.tgz",
- "integrity": "sha512-sBS6D9mJECtELASLM+18WUcXF6RH3zNxBRFeyCRg8wad6NbyNrdxLuwK+Ikvc38sTZwBzAz691HmSofLqHd9sQ==",
+ "version": "6.1.8",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.8.tgz",
+ "integrity": "sha512-O2DWb1kz8hiANVcR7Z4gOB3SvPPsSQGUmStpyBDzde6dJIfBzgV9PbEQOBZd3EBsd1pB+Uv1z5LAJAbymmawrA==",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mui/types": "^7.2.19",
@@ -2454,16 +2454,16 @@
}
},
"node_modules/@octokit/auth-app": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-7.1.2.tgz",
- "integrity": "sha512-5cfWRr1hr0w/EW3StFIIOkMtYhOyGZ6/R3T0xeN6UgC/uL5pIyeood9N/8Z7W4NZUdz2QK1Fv0oM/1AzTME3/Q==",
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-7.1.3.tgz",
+ "integrity": "sha512-GZdkOp2kZTIy5dG9oXqvzUAZiPvDx4C/lMlN6yQjtG9d/+hYa7W8WXTJoOrXE8UdfL9A/sZMl206dmtkl9lwVQ==",
"dependencies": {
"@octokit/auth-oauth-app": "^8.1.0",
"@octokit/auth-oauth-user": "^5.1.0",
"@octokit/request": "^9.1.1",
"@octokit/request-error": "^6.1.1",
"@octokit/types": "^13.4.1",
- "lru-cache": "npm:@wolfy1339/lru-cache@^11.0.2-patch.1",
+ "toad-cache": "^3.7.0",
"universal-github-app-jwt": "^2.2.0",
"universal-user-agent": "^7.0.0"
},
@@ -2471,15 +2471,6 @@
"node": ">= 18"
}
},
- "node_modules/@octokit/auth-app/node_modules/lru-cache": {
- "name": "@wolfy1339/lru-cache",
- "version": "11.0.2-patch.1",
- "resolved": "https://registry.npmjs.org/@wolfy1339/lru-cache/-/lru-cache-11.0.2-patch.1.tgz",
- "integrity": "sha512-BgYZfL2ADCXKOw2wJtkM3slhHotawWkgIRRxq4wEybnZQPjvAp71SPX35xepMykTw8gXlzWcWPTY31hlbnRsDA==",
- "engines": {
- "node": "18 >=18.20 || 20 || >=22"
- }
- },
"node_modules/@octokit/auth-oauth-app": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-8.1.1.tgz",
@@ -4596,16 +4587,16 @@
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.13.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.13.0.tgz",
- "integrity": "sha512-nQtBLiZYMUPkclSeC3id+x4uVd1SGtHuElTxL++SfP47jR0zfkZBJHc+gL4qPsgTuypz0k8Y2GheaDYn6Gy3rg==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.15.0.tgz",
+ "integrity": "sha512-+zkm9AR1Ds9uLWN3fkoeXgFppaQ+uEVtfOV62dDmsy9QCNqlRHWNEck4yarvRNrvRcHQLGfqBNui3cimoz8XAg==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "8.13.0",
- "@typescript-eslint/type-utils": "8.13.0",
- "@typescript-eslint/utils": "8.13.0",
- "@typescript-eslint/visitor-keys": "8.13.0",
+ "@typescript-eslint/scope-manager": "8.15.0",
+ "@typescript-eslint/type-utils": "8.15.0",
+ "@typescript-eslint/utils": "8.15.0",
+ "@typescript-eslint/visitor-keys": "8.15.0",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
@@ -4629,15 +4620,15 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.13.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.13.0.tgz",
- "integrity": "sha512-w0xp+xGg8u/nONcGw1UXAr6cjCPU1w0XVyBs6Zqaj5eLmxkKQAByTdV/uGgNN5tVvN/kKpoQlP2cL7R+ajZZIQ==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.15.0.tgz",
+ "integrity": "sha512-7n59qFpghG4uazrF9qtGKBZXn7Oz4sOMm8dwNWDQY96Xlm2oX67eipqcblDj+oY1lLCbf1oltMZFpUso66Kl1A==",
"dev": true,
"dependencies": {
- "@typescript-eslint/scope-manager": "8.13.0",
- "@typescript-eslint/types": "8.13.0",
- "@typescript-eslint/typescript-estree": "8.13.0",
- "@typescript-eslint/visitor-keys": "8.13.0",
+ "@typescript-eslint/scope-manager": "8.15.0",
+ "@typescript-eslint/types": "8.15.0",
+ "@typescript-eslint/typescript-estree": "8.15.0",
+ "@typescript-eslint/visitor-keys": "8.15.0",
"debug": "^4.3.4"
},
"engines": {
@@ -4657,13 +4648,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.13.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.13.0.tgz",
- "integrity": "sha512-XsGWww0odcUT0gJoBZ1DeulY1+jkaHUciUq4jKNv4cpInbvvrtDoyBH9rE/n2V29wQJPk8iCH1wipra9BhmiMA==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.15.0.tgz",
+ "integrity": "sha512-QRGy8ADi4J7ii95xz4UoiymmmMd/zuy9azCaamnZ3FM8T5fZcex8UfJcjkiEZjJSztKfEBe3dZ5T/5RHAmw2mA==",
"dev": true,
"dependencies": {
- "@typescript-eslint/types": "8.13.0",
- "@typescript-eslint/visitor-keys": "8.13.0"
+ "@typescript-eslint/types": "8.15.0",
+ "@typescript-eslint/visitor-keys": "8.15.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4674,13 +4665,13 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.13.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.13.0.tgz",
- "integrity": "sha512-Rqnn6xXTR316fP4D2pohZenJnp+NwQ1mo7/JM+J1LWZENSLkJI8ID8QNtlvFeb0HnFSK94D6q0cnMX6SbE5/vA==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.15.0.tgz",
+ "integrity": "sha512-UU6uwXDoI3JGSXmcdnP5d8Fffa2KayOhUUqr/AiBnG1Gl7+7ut/oyagVeSkh7bxQ0zSXV9ptRh/4N15nkCqnpw==",
"dev": true,
"dependencies": {
- "@typescript-eslint/typescript-estree": "8.13.0",
- "@typescript-eslint/utils": "8.13.0",
+ "@typescript-eslint/typescript-estree": "8.15.0",
+ "@typescript-eslint/utils": "8.15.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@@ -4691,6 +4682,9 @@
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0"
+ },
"peerDependenciesMeta": {
"typescript": {
"optional": true
@@ -4698,9 +4692,9 @@
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.13.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.13.0.tgz",
- "integrity": "sha512-4cyFErJetFLckcThRUFdReWJjVsPCqyBlJTi6IDEpc1GWCIIZRFxVppjWLIMcQhNGhdWJJRYFHpHoDWvMlDzng==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.15.0.tgz",
+ "integrity": "sha512-n3Gt8Y/KyJNe0S3yDCD2RVKrHBC4gTUcLTebVBXacPy091E6tNspFLKRXlk3hwT4G55nfr1n2AdFqi/XMxzmPQ==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4711,13 +4705,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.13.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.13.0.tgz",
- "integrity": "sha512-v7SCIGmVsRK2Cy/LTLGN22uea6SaUIlpBcO/gnMGT/7zPtxp90bphcGf4fyrCQl3ZtiBKqVTG32hb668oIYy1g==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.15.0.tgz",
+ "integrity": "sha512-1eMp2JgNec/niZsR7ioFBlsh/Fk0oJbhaqO0jRyQBMgkz7RrFfkqF9lYYmBoGBaSiLnu8TAPQTwoTUiSTUW9dg==",
"dev": true,
"dependencies": {
- "@typescript-eslint/types": "8.13.0",
- "@typescript-eslint/visitor-keys": "8.13.0",
+ "@typescript-eslint/types": "8.15.0",
+ "@typescript-eslint/visitor-keys": "8.15.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -4739,15 +4733,15 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.13.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.13.0.tgz",
- "integrity": "sha512-A1EeYOND6Uv250nybnLZapeXpYMl8tkzYUxqmoKAWnI4sei3ihf2XdZVd+vVOmHGcp3t+P7yRrNsyyiXTvShFQ==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.15.0.tgz",
+ "integrity": "sha512-k82RI9yGhr0QM3Dnq+egEpz9qB6Un+WLYhmoNcvl8ltMEededhh7otBVVIDDsEEttauwdY/hQoSsOv13lxrFzQ==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
- "@typescript-eslint/scope-manager": "8.13.0",
- "@typescript-eslint/types": "8.13.0",
- "@typescript-eslint/typescript-estree": "8.13.0"
+ "@typescript-eslint/scope-manager": "8.15.0",
+ "@typescript-eslint/types": "8.15.0",
+ "@typescript-eslint/typescript-estree": "8.15.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4758,16 +4752,21 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.13.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.13.0.tgz",
- "integrity": "sha512-7N/+lztJqH4Mrf0lb10R/CbI1EaAMMGyF5y0oJvFoAhafwgiRA7TXyd8TFn8FC8k5y2dTsYogg238qavRGNnlw==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.15.0.tgz",
+ "integrity": "sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==",
"dev": true,
"dependencies": {
- "@typescript-eslint/types": "8.13.0",
- "eslint-visitor-keys": "^3.4.3"
+ "@typescript-eslint/types": "8.15.0",
+ "eslint-visitor-keys": "^4.2.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4777,6 +4776,18 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
+ "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
+ "dev": true,
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
@@ -6134,9 +6145,9 @@
}
},
"node_modules/core-js": {
- "version": "3.38.1",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.38.1.tgz",
- "integrity": "sha512-OP35aUorbU3Zvlx7pjsFdu1rGNnD4pgw/CWoYzRY3t2EzoVT7shKHY1dlAy3f41cGIO7ZDPQimhGFTlEYkG/Hw==",
+ "version": "3.39.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
+ "integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
@@ -19262,6 +19273,14 @@
"node": ">=8.0"
}
},
+ "node_modules/toad-cache": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
+ "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/toggle-selection": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
diff --git a/package.json b/package.json
index 6379c69b..f8e8dc03 100644
--- a/package.json
+++ b/package.json
@@ -18,18 +18,18 @@
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource/poppins": "^5.1.0",
- "@fortawesome/fontawesome-svg-core": "^6.6.0",
- "@fortawesome/free-brands-svg-icons": "^6.6.0",
- "@fortawesome/free-regular-svg-icons": "^6.6.0",
- "@fortawesome/free-solid-svg-icons": "^6.6.0",
+ "@fortawesome/fontawesome-svg-core": "^6.7.1",
+ "@fortawesome/free-brands-svg-icons": "^6.7.1",
+ "@fortawesome/free-regular-svg-icons": "^6.7.1",
+ "@fortawesome/free-solid-svg-icons": "^6.7.1",
"@fortawesome/react-fontawesome": "^0.2.2",
- "@mui/icons-material": "^6.1.6",
+ "@mui/icons-material": "^6.1.8",
"@mui/material": "^6.0.1",
- "@octokit/auth-app": "^7.1.2",
+ "@octokit/auth-app": "^7.1.3",
"@octokit/core": "^6.1.2",
"@octokit/webhooks": "^13.3.0",
"@stoplight/elements": "^8.4.5",
- "core-js": "^3.38.1",
+ "core-js": "^3.39.0",
"encoding": "^0.1.13",
"figma-squircle": "^1.1.0",
"install": "^0.13.0",
@@ -61,8 +61,8 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.0",
"@types/swagger-ui-react": "^4.18.3",
- "@typescript-eslint/eslint-plugin": "^8.13.0",
- "@typescript-eslint/parser": "^8.13.0",
+ "@typescript-eslint/eslint-plugin": "^8.15.0",
+ "@typescript-eslint/parser": "^8.15.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.14",
diff --git a/src/app/(authed)/(home)/encrypt/layout.tsx b/src/app/(authed)/(home)/encrypt/layout.tsx
new file mode 100644
index 00000000..d4114d0c
--- /dev/null
+++ b/src/app/(authed)/(home)/encrypt/layout.tsx
@@ -0,0 +1,20 @@
+import { Box, Stack } from "@mui/material"
+import SecondarySplitHeader from "@/features/sidebar/view/SecondarySplitHeader"
+
+export default function Page({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/app/(authed)/(home)/encrypt/page.tsx b/src/app/(authed)/(home)/encrypt/page.tsx
new file mode 100644
index 00000000..5e6c28d4
--- /dev/null
+++ b/src/app/(authed)/(home)/encrypt/page.tsx
@@ -0,0 +1,44 @@
+import { Box, Typography } from '@mui/material'
+import MessageLinkFooter from "@/common/ui/MessageLinkFooter"
+import { EncryptionForm } from "@/features/encrypt/view/EncryptionForm"
+import { env } from '@/common'
+
+const HELP_URL = env.getOrThrow("FRAMNA_DOCS_HELP_URL")
+const SITE_NAME = env.getOrThrow("FRAMNA_DOCS_TITLE")
+
+export default async function EncryptPage() {
+ const possessiveName = SITE_NAME + (SITE_NAME.endsWith('s') ? "'" : "'s")
+ return (
+
+
+
+ Encrypt secrets
+
+
+ Use the form below to encrypt a secret using {possessiveName} public key.
+
+ Authentication in remote specifications must be encrypted using {possessiveName} public key
+ before it is stored in a repository on GitHub.
+
+
+ {HELP_URL &&
+
+ }
+
+
+ )
+}
diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts
deleted file mode 100644
index 256ee955..00000000
--- a/src/app/api/proxy/route.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { NextRequest, NextResponse } from "next/server"
-import { env, makeAPIErrorResponse, makeUnauthenticatedAPIErrorResponse } from "@/common"
-import { session } from "@/composition"
-import { parse as parseYaml } from "yaml"
-
-const ErrorName = {
- MAX_FILE_SIZE_EXCEEDED: "MaxFileSizeExceededError",
- TIMEOUT: "TimeoutError",
- NOT_JSON_OR_YAML: "NotJsonOrYamlError",
-}
-
-export async function GET(req: NextRequest) {
- const isAuthenticated = await session.getIsAuthenticated()
- if (!isAuthenticated) {
- return makeUnauthenticatedAPIErrorResponse()
- }
- const rawURL = req.nextUrl.searchParams.get("url")
- if (!rawURL) {
- return makeAPIErrorResponse(400, "Missing \"url\" query parameter.")
- }
- let url: URL
- try {
- url = new URL(rawURL)
- } catch {
- return makeAPIErrorResponse(400, "Invalid \"url\" query parameter.")
- }
- try {
- const maxMegabytes = Number(env.getOrThrow("PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES"))
- const timeoutInSeconds = Number(env.getOrThrow("PROXY_API_TIMEOUT_IN_SECONDS"))
- const maxBytes = maxMegabytes * 1024 * 1024
- const fileText = await downloadFile({ url, maxBytes, timeoutInSeconds })
- checkIfJsonOrYaml(fileText)
- return new NextResponse(fileText, { status: 200, headers: { "Content-Type": "text/plain" } })
- } catch (error) {
- if (error instanceof Error == false) {
- return makeAPIErrorResponse(500, "An unknown error occurred.")
- }
- if (error.name === ErrorName.MAX_FILE_SIZE_EXCEEDED) {
- return makeAPIErrorResponse(413, "The operation was aborted.")
- } else if (error.name === ErrorName.TIMEOUT) {
- return makeAPIErrorResponse(408, "The operation timed out.")
- } else if (error.name === ErrorName.NOT_JSON_OR_YAML) {
- return makeAPIErrorResponse(400, "Url does not point to a JSON or YAML file.")
- } else {
- return makeAPIErrorResponse(500, error.message)
- }
- }
-}
-
-async function downloadFile(params: {
- url: URL,
- maxBytes: number,
- timeoutInSeconds: number
-}): Promise {
- const { url, maxBytes, timeoutInSeconds } = params
- const abortController = new AbortController()
- const timeoutSignal = AbortSignal.timeout(timeoutInSeconds * 1000)
- const headers: {[key: string]: string} = {}
- // Extract basic auth from URL and construct an Authorization header instead.
- if ((url.username && url.username.length > 0) || (url.password && url.password.length > 0)) {
- const username = decodeURIComponent(url.username)
- const password = decodeURIComponent(url.password)
- headers["Authorization"] = "Basic " + btoa(`${username}:${password}`)
- }
- // Make sure basic auth is removed from URL.
- const urlWithoutAuth = url
- urlWithoutAuth.username = ""
- urlWithoutAuth.password = ""
- const response = await fetch(urlWithoutAuth, {
- method: "GET",
- headers,
- signal: AbortSignal.any([abortController.signal, timeoutSignal])
- })
- if (!response.body) {
- throw new Error("Response body unavailable")
- }
- let totalBytes = 0
- let didExceedMaxBytes = false
- const reader = response.body.getReader()
- const chunks: Uint8Array[] = []
- // eslint-disable-next-line no-constant-condition
- while (true) {
- // eslint-disable-next-line no-await-in-loop
- const { done, value } = await reader.read()
- if (done) {
- break
- }
- totalBytes += value.length
- chunks.push(value)
- if (totalBytes >= maxBytes) {
- didExceedMaxBytes = true
- abortController.abort()
- break
- }
- }
- if (didExceedMaxBytes) {
- const error = new Error("Maximum file size exceeded")
- error.name = ErrorName.MAX_FILE_SIZE_EXCEEDED
- throw error
- }
- const blob = new Blob(chunks)
- const arrayBuffer = await blob.arrayBuffer()
- const decoder = new TextDecoder()
- return decoder.decode(arrayBuffer)
-}
-
-function checkIfJsonOrYaml(fileText: string) {
- try {
- parseYaml(fileText) // will also parse JSON as it is a subset of YAML
- } catch {
- const error = new Error("File is not JSON or YAML")
- error.name = ErrorName.NOT_JSON_OR_YAML
- throw error
- }
-}
diff --git a/src/app/api/remotes/[encodedRemoteConfig]/route.ts b/src/app/api/remotes/[encodedRemoteConfig]/route.ts
new file mode 100644
index 00000000..61950a87
--- /dev/null
+++ b/src/app/api/remotes/[encodedRemoteConfig]/route.ts
@@ -0,0 +1,64 @@
+import { NextRequest, NextResponse } from "next/server"
+import { remoteConfigEncoder, session } from "@/composition"
+import { env, makeAPIErrorResponse, makeUnauthenticatedAPIErrorResponse } from "@/common"
+import { downloadFile, checkIfJsonOrYaml, ErrorName } from "@/common/utils/fileUtils";
+
+interface RemoteSpecificationParams {
+ encodedRemoteConfig: string
+}
+
+export async function GET(_req: NextRequest, { params }: { params: RemoteSpecificationParams }) {
+ const isAuthenticated = await session.getIsAuthenticated()
+ if (!isAuthenticated) {
+ return makeUnauthenticatedAPIErrorResponse()
+ }
+
+ const remoteConfig = remoteConfigEncoder.decode(params.encodedRemoteConfig)
+
+ let url: URL
+ try {
+ url = new URL(remoteConfig.url)
+ } catch {
+ return makeAPIErrorResponse(400, "Invalid \"url\" query parameter.")
+ }
+ try {
+ const maxMegabytes = Number(env.getOrThrow("PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES"))
+ const timeoutInSeconds = Number(env.getOrThrow("PROXY_API_TIMEOUT_IN_SECONDS"))
+ const maxBytes = maxMegabytes * 1024 * 1024
+
+ const fileText = await downloadFile({
+ url,
+ maxBytes,
+ timeoutInSeconds,
+ basicAuthUsername: remoteConfig.auth?.username,
+ basicAuthPassword: remoteConfig.auth?.password
+ })
+
+ checkIfJsonOrYaml(fileText)
+
+ const fileName = url.pathname.split('/').pop()
+
+ return new NextResponse(fileText, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/plain",
+ "Content-Disposition": `attachment; filename="${fileName}"` // used for when downloading the file
+ }
+ })
+ } catch (error) {
+ if (error instanceof Error == false) {
+ return makeAPIErrorResponse(500, "An unknown error occurred.")
+ }
+ if (error.name === ErrorName.MAX_FILE_SIZE_EXCEEDED) {
+ return makeAPIErrorResponse(413, "The operation was aborted.")
+ } else if (error.name === ErrorName.TIMEOUT) {
+ return makeAPIErrorResponse(408, "The operation timed out.")
+ } else if (error.name === ErrorName.NOT_JSON_OR_YAML) {
+ return makeAPIErrorResponse(400, "Url does not point to a JSON or YAML file.")
+ } else if (error.name === ErrorName.URL_MAY_NOT_INCLUDE_BASIC_AITH) {
+ return makeAPIErrorResponse(400, "Url may not include basic auth.")
+ } else {
+ return makeAPIErrorResponse(500, error.message)
+ }
+ }
+}
diff --git a/src/common/utils/fileUtils.ts b/src/common/utils/fileUtils.ts
new file mode 100644
index 00000000..acc3b062
--- /dev/null
+++ b/src/common/utils/fileUtils.ts
@@ -0,0 +1,73 @@
+import { parse as parseYaml } from "yaml"
+
+export const ErrorName = {
+ MAX_FILE_SIZE_EXCEEDED: "MaxFileSizeExceededError",
+ TIMEOUT: "TimeoutError",
+ NOT_JSON_OR_YAML: "NotJsonOrYamlError",
+ URL_MAY_NOT_INCLUDE_BASIC_AITH: "UrlMayNotIncludeBasicAuth"
+}
+
+export async function downloadFile(params: {
+ url: URL;
+ maxBytes: number;
+ timeoutInSeconds: number;
+ basicAuthUsername?: string;
+ basicAuthPassword?: string;
+}): Promise {
+ const { url, maxBytes, timeoutInSeconds, basicAuthUsername, basicAuthPassword } = params;
+ const abortController = new AbortController();
+ const timeoutSignal = AbortSignal.timeout(timeoutInSeconds * 1000);
+ const headers: { [key: string]: string; } = {};
+ if (basicAuthUsername && basicAuthPassword) {
+ headers["Authorization"] = "Basic " + btoa(`${basicAuthUsername}:${basicAuthPassword}`);
+ }
+ // Make sure basic auth is removed from URL.
+ if ((url.username && url.username.length > 0) || (url.password && url.password.length > 0)) {
+ const error = new Error("URL may not include basic auth");
+ error.name = ErrorName.URL_MAY_NOT_INCLUDE_BASIC_AITH;
+ throw error;
+ }
+ const fetchSignal = AbortSignal.any([abortController.signal, timeoutSignal])
+ const response = await fetch(url, { method: "GET", headers, signal: fetchSignal })
+ if (!response.body) {
+ throw new Error("Response body unavailable");
+ }
+ let totalBytes = 0;
+ let didExceedMaxBytes = false;
+ const reader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ // eslint-disable-next-line no-await-in-loop
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ totalBytes += value.length;
+ chunks.push(value);
+ if (totalBytes >= maxBytes) {
+ didExceedMaxBytes = true;
+ abortController.abort();
+ break;
+ }
+ }
+ if (didExceedMaxBytes) {
+ const error = new Error("Maximum file size exceeded");
+ error.name = ErrorName.MAX_FILE_SIZE_EXCEEDED;
+ throw error;
+ }
+ const blob = new Blob(chunks);
+ const arrayBuffer = await blob.arrayBuffer();
+ const decoder = new TextDecoder();
+ return decoder.decode(arrayBuffer);
+}
+
+export function checkIfJsonOrYaml(fileText: string) {
+ try {
+ parseYaml(fileText) // will also parse JSON as it is a subset of YAML
+ } catch {
+ const error = new Error("File is not JSON or YAML")
+ error.name = ErrorName.NOT_JSON_OR_YAML
+ throw error
+ }
+}
diff --git a/src/composition.ts b/src/composition.ts
index 8514a16b..40a754bc 100644
--- a/src/composition.ts
+++ b/src/composition.ts
@@ -51,6 +51,8 @@ import {
PullRequestCommenter
} from "@/features/hooks/domain"
import { RepoRestrictedGitHubClient } from "./common/github/RepoRestrictedGitHubClient"
+import RsaEncryptionService from "./features/encrypt/EncryptionService"
+import RemoteConfigEncoder from "./features/projects/domain/RemoteConfigEncoder"
const gitHubAppCredentials = {
appId: env.getOrThrow("GITHUB_APP_ID"),
@@ -176,6 +178,13 @@ export const projectRepository = new ProjectRepository({
repository: projectUserDataRepository
})
+export const encryptionService = new RsaEncryptionService({
+ publicKey: Buffer.from(env.getOrThrow("ENCRYPTION_PUBLIC_KEY_BASE_64"), "base64").toString("utf-8"),
+ privateKey: Buffer.from(env.getOrThrow("ENCRYPTION_PRIVATE_KEY_BASE_64"), "base64").toString("utf-8")
+})
+
+export const remoteConfigEncoder = new RemoteConfigEncoder(encryptionService)
+
export const projectDataSource = new CachingProjectDataSource({
dataSource: new GitHubProjectDataSource({
repositoryDataSource: new FilteringGitHubRepositoryDataSource({
@@ -189,7 +198,9 @@ export const projectDataSource = new CachingProjectDataSource({
projectConfigurationFilename: env.getOrThrow("FRAMNA_DOCS_PROJECT_CONFIGURATION_FILENAME")
})
}),
- repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX")
+ repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"),
+ encryptionService: encryptionService,
+ remoteConfigEncoder: remoteConfigEncoder
}),
repository: projectRepository
})
@@ -219,4 +230,4 @@ export const gitHubHookHandler = new GitHubHookHandler({
})
})
})
-})
\ No newline at end of file
+})
diff --git a/src/features/docs/view/Swagger.tsx b/src/features/docs/view/Swagger.tsx
index 9aca9125..e821cdcf 100644
--- a/src/features/docs/view/Swagger.tsx
+++ b/src/features/docs/view/Swagger.tsx
@@ -3,6 +3,7 @@ import SwaggerUI from "swagger-ui-react"
import "swagger-ui-react/swagger-ui.css"
import { Box } from "@mui/material"
import LoadingWrapper from "./LoadingWrapper"
+import "./swagger.css"
const Swagger = ({ url }: { url: string }) => {
const [isLoading, setLoading] = useState(true)
diff --git a/src/features/docs/view/swagger.css b/src/features/docs/view/swagger.css
new file mode 100644
index 00000000..13551dba
--- /dev/null
+++ b/src/features/docs/view/swagger.css
@@ -0,0 +1,6 @@
+.swagger-ui .info span.url {
+ display: block;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ max-width: 80%;
+}
diff --git a/src/features/encrypt/EncryptionService.ts b/src/features/encrypt/EncryptionService.ts
new file mode 100644
index 00000000..67155e5c
--- /dev/null
+++ b/src/features/encrypt/EncryptionService.ts
@@ -0,0 +1,42 @@
+import { publicEncrypt, privateDecrypt, constants } from 'crypto';
+
+export interface IEncryptionService {
+ encrypt(data: string): string;
+ decrypt(encryptedDataBase64: string): string;
+}
+
+class RsaEncryptionService implements IEncryptionService {
+ private publicKey: string;
+ private privateKey: string;
+
+ constructor({ publicKey, privateKey }: { publicKey: string; privateKey: string }) {
+ this.publicKey = publicKey;
+ this.privateKey = privateKey;
+ }
+
+ encrypt(data: string): string {
+ const buffer = Buffer.from(data, 'utf-8');
+ const encrypted = publicEncrypt(
+ {
+ key: this.publicKey,
+ padding: constants.RSA_PKCS1_OAEP_PADDING,
+ oaepHash: 'sha256'
+ },
+ buffer
+ );
+ return encrypted.toString('base64');
+ }
+
+ decrypt(encryptedDataBase64: string): string {
+ return privateDecrypt(
+ {
+ key: this.privateKey,
+ padding: constants.RSA_PKCS1_OAEP_PADDING,
+ oaepHash: 'sha256'
+ },
+ Buffer.from(encryptedDataBase64, 'base64')
+ ).toString('utf-8')
+ }
+}
+
+export default RsaEncryptionService;
diff --git a/src/features/encrypt/view/EncryptionForm.tsx b/src/features/encrypt/view/EncryptionForm.tsx
new file mode 100644
index 00000000..1c23f660
--- /dev/null
+++ b/src/features/encrypt/view/EncryptionForm.tsx
@@ -0,0 +1,101 @@
+'use client'
+
+import { useState } from 'react'
+import { Box, Button, Snackbar, TextField, Tooltip, InputAdornment } from '@mui/material'
+import { styled } from '@mui/material/styles'
+import { encrypt } from "./encryptAction"
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
+import { faClipboard } from "@fortawesome/free-regular-svg-icons"
+
+export const EncryptionForm = () => {
+ const [inputText, setInputText] = useState('')
+ const [encryptedText, setEncryptedText] = useState('')
+ const [openSnackbar, setOpenSnackbar] = useState(false)
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault()
+ if (inputText.length > 0) {
+ const encrypted = await encrypt(inputText)
+ setEncryptedText(encrypted)
+ } else {
+ setEncryptedText("")
+ }
+ }
+
+ const handleCopy = () => {
+ if (encryptedText.length > 0) {
+ navigator.clipboard.writeText(encryptedText)
+ setOpenSnackbar(true)
+ }
+ }
+
+ const handleCloseSnackbar = () => {
+ setOpenSnackbar(false)
+ }
+
+ const EncryptedValueTextField = styled(TextField)({
+ '& .MuiInputBase-root': {
+ backgroundColor: '#F8F8F8'
+ }
+ })
+
+ return
+ setInputText(e.target.value)}
+ multiline
+ rows={8}
+ variant="outlined"
+ placeholder="Enter text to encrypt"
+ sx={{ width: "300px" }}
+ />
+
+
+
+
+
+
+
+ )
+ }
+ }}
+ placeholder="Encrypted text appears here"
+ />
+
+
+
+ ;
+}
diff --git a/src/features/encrypt/view/encryptAction.ts b/src/features/encrypt/view/encryptAction.ts
new file mode 100644
index 00000000..ed1161c1
--- /dev/null
+++ b/src/features/encrypt/view/encryptAction.ts
@@ -0,0 +1,7 @@
+'use server'
+
+import { encryptionService } from '@/composition'
+
+export async function encrypt(text: string): Promise {
+ return encryptionService.encrypt(text)
+}
diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts
index bc79fe25..943e99c0 100644
--- a/src/features/projects/data/GitHubProjectDataSource.ts
+++ b/src/features/projects/data/GitHubProjectDataSource.ts
@@ -1,3 +1,4 @@
+import { IEncryptionService } from "@/features/encrypt/EncryptionService"
import {
Project,
Version,
@@ -7,19 +8,28 @@ import {
ProjectConfigRemoteVersion,
IGitHubRepositoryDataSource,
GitHubRepository,
- GitHubRepositoryRef
+ GitHubRepositoryRef,
+ ProjectConfigRemoteSpecification
} from "../domain"
+import RemoteConfig from "../domain/RemoteConfig"
+import { IRemoteConfigEncoder } from "../domain/RemoteConfigEncoder"
export default class GitHubProjectDataSource implements IProjectDataSource {
private readonly repositoryDataSource: IGitHubRepositoryDataSource
private readonly repositoryNameSuffix: string
+ private readonly encryptionService: IEncryptionService
+ private readonly remoteConfigEncoder: IRemoteConfigEncoder
constructor(config: {
repositoryDataSource: IGitHubRepositoryDataSource
repositoryNameSuffix: string
+ encryptionService: IEncryptionService
+ remoteConfigEncoder: IRemoteConfigEncoder
}) {
this.repositoryDataSource = config.repositoryDataSource
this.repositoryNameSuffix = config.repositoryNameSuffix
+ this.encryptionService = config.encryptionService
+ this.remoteConfigEncoder = config.remoteConfigEncoder
}
async getProjects(): Promise {
@@ -167,11 +177,18 @@ export default class GitHubProjectDataSource implements IProjectDataSource {
const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length
const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "")
const specifications = remoteVersion.specifications.map(e => {
+ const remoteConfig: RemoteConfig = {
+ url: e.url,
+ auth: this.tryDecryptAuth(e)
+ };
+
+ const encodedRemoteConfig = this.remoteConfigEncoder.encode(remoteConfig);
+
return {
id: this.makeURLSafeID((e.id || e.name).toLowerCase()),
name: e.name,
- url: `/api/proxy?url=${encodeURIComponent(e.url)}`
- }
+ url: `/api/remotes/${encodedRemoteConfig}`
+ };
})
versions.push({
id: versionId,
@@ -212,4 +229,21 @@ export default class GitHubProjectDataSource implements IProjectDataSource {
.replace(/ /g, "-")
.replace(/[^A-Za-z0-9-]/g, "")
}
+
+ private tryDecryptAuth(projectConfigRemoteSpec: ProjectConfigRemoteSpecification): { type: string, username: string, password: string } | undefined {
+ if (!projectConfigRemoteSpec.auth) {
+ return undefined
+ }
+
+ try {
+ return {
+ type: projectConfigRemoteSpec.auth.type,
+ username: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedUsername),
+ password: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedPassword)
+ }
+ } catch (error) {
+ console.error(`Failed to decrypt remote specification auth for ${projectConfigRemoteSpec.url}. Perhaps a different public key was used?:`, error);
+ return undefined
+ }
+ }
}
diff --git a/src/features/projects/domain/IProjectConfig.ts b/src/features/projects/domain/IProjectConfig.ts
index ef4fd74b..08e27f7c 100644
--- a/src/features/projects/domain/IProjectConfig.ts
+++ b/src/features/projects/domain/IProjectConfig.ts
@@ -3,7 +3,12 @@ import { z } from "zod"
export const ProjectConfigRemoteSpecificationSchema = z.object({
id: z.coerce.string().optional(),
name: z.coerce.string(),
- url: z.string()
+ url: z.string(),
+ auth: z.object({
+ type: z.string(),
+ encryptedUsername: z.string(),
+ encryptedPassword: z.string()
+ }).optional(),
})
export const ProjectConfigRemoteVersionSchema = z.object({
diff --git a/src/features/projects/domain/RemoteConfig.ts b/src/features/projects/domain/RemoteConfig.ts
new file mode 100644
index 00000000..5afb559b
--- /dev/null
+++ b/src/features/projects/domain/RemoteConfig.ts
@@ -0,0 +1,11 @@
+import { z } from 'zod'
+import { RemoteSpecAuthSchema } from './RemoteSpecAuth'
+
+export const RemoteConfigSchema = z.object({
+ url: z.string().url(),
+ auth: RemoteSpecAuthSchema.optional(),
+})
+
+type RemoteConfig = z.infer
+
+export default RemoteConfig
diff --git a/src/features/projects/domain/RemoteConfigEncoder.ts b/src/features/projects/domain/RemoteConfigEncoder.ts
new file mode 100644
index 00000000..6336efec
--- /dev/null
+++ b/src/features/projects/domain/RemoteConfigEncoder.ts
@@ -0,0 +1,34 @@
+import { IEncryptionService } from "@/features/encrypt/EncryptionService";
+import RemoteConfig, { RemoteConfigSchema } from "./RemoteConfig";
+
+export interface IRemoteConfigEncoder {
+ encode(remoteConfig: RemoteConfig): string;
+ decode(encodedString: string): RemoteConfig;
+}
+
+/**
+ * Encodes and decodes remote configs.
+ *
+ * The remote config is first stringified to JSON, then encrypted, and finally encoded in base64.
+ *
+ * At the receiving end, the encoded string is first decoded from base64, then decrypted, and finally parsed as JSON.
+ */
+export default class RemoteConfigEncoder implements IRemoteConfigEncoder {
+ private readonly encryptionService: IEncryptionService;
+
+ constructor(encryptionService: IEncryptionService) {
+ this.encryptionService = encryptionService;
+ }
+
+ encode(remoteConfig: RemoteConfig): string {
+ const jsonString = JSON.stringify(remoteConfig);
+ const encryptedString = this.encryptionService.encrypt(jsonString);
+ return Buffer.from(encryptedString).toString('base64');
+ }
+
+ decode(encodedString: string): RemoteConfig {
+ const decodedString = Buffer.from(encodedString, 'base64').toString('utf-8');
+ const decryptedString = this.encryptionService.decrypt(decodedString);
+ return RemoteConfigSchema.parse(JSON.parse(decryptedString));
+ }
+}
diff --git a/src/features/projects/domain/RemoteSpecAuth.ts b/src/features/projects/domain/RemoteSpecAuth.ts
new file mode 100644
index 00000000..5d72a335
--- /dev/null
+++ b/src/features/projects/domain/RemoteSpecAuth.ts
@@ -0,0 +1,11 @@
+import { z } from 'zod'
+
+export const RemoteSpecAuthSchema = z.object({
+ type: z.string(),
+ username: z.string(),
+ password: z.string(),
+})
+
+type RemoteSpecAuth = z.infer
+
+export default RemoteSpecAuth