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/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/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..df97c6bb 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,
@@ -9,17 +10,25 @@ import {
GitHubRepository,
GitHubRepositoryRef
} 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 +176,22 @@ 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: e.auth ? {
+ type: e.auth.type,
+ username: this.encryptionService.decrypt(e.auth.encryptedUsername),
+ password: this.encryptionService.decrypt(e.auth.encryptedPassword)
+ } : undefined
+ };
+
+ 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,
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