mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff9fa7418f | |||
| b6190ff314 | |||
| abe774650f | |||
| 1f04ebf3cd | |||
| 472ebed06c | |||
| 4ce24dfdd3 | |||
| 06b67d4519 | |||
| 5dd2b5beec | |||
| 35af10ca07 | |||
| 75448f358e | |||
| 4e675d6f3f | |||
| 57fae73853 | |||
| dd542994a4 | |||
| bcf97f92ab | |||
| e19d7bbf0e | |||
| 3e4ed0c299 | |||
| 112b2c8358 | |||
| b7fdbd4704 | |||
| 13b897c01d | |||
| 83008205b9 | |||
| 0f0102ff2d | |||
| cbe78950cc | |||
| 49ee74c89d | |||
| e88d13f67b | |||
| 2f89981597 | |||
| c7a96ed82c | |||
| d4e087e128 | |||
| 7f41b098f9 | |||
| 43229c89da | |||
| c6607f3a19 | |||
| 6e84f39a9f | |||
| b024c9c2fd | |||
| 982f4d70f7 | |||
| ba24ae6797 | |||
| 2f8af90da4 | |||
| 3f98208f03 | |||
| 5d231bf3e3 | |||
| 0124613516 | |||
| 3495fc89bf | |||
| 5ae289cae3 | |||
| 108541c957 | |||
| 14be318502 | |||
| d6c2e38cb0 | |||
| ff39cb51e6 | |||
| 1cf81e3802 | |||
| a49615038f | |||
| a376b00a71 | |||
| 1ca03d3dc2 | |||
| 2df02f596a | |||
| 1da4db2905 | |||
| b3feeed3e4 |
@@ -140,6 +140,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
|
npm_version: ${{ steps.get_version.outputs.npm_version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -151,6 +152,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Fetch install.sh
|
- name: Fetch install.sh
|
||||||
run: |
|
run: |
|
||||||
@@ -164,6 +166,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.get_version.outputs.version }}
|
tag_name: ${{ steps.get_version.outputs.version }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
name: ${{ steps.get_version.outputs.version }}
|
name: ${{ steps.get_version.outputs.version }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
draft: false
|
draft: false
|
||||||
@@ -180,71 +183,82 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.22.0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24.4.0
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Generate OpenAPI specification
|
||||||
|
run: |
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm generate:openapi
|
||||||
|
|
||||||
- name: Sync version to MCP repository
|
- name: Sync version to MCP repository
|
||||||
run: |
|
run: |
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
||||||
cd /tmp/mcp-repo
|
cd /tmp/mcp-repo
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||||
mv package.json.tmp package.json
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
npm install -g pnpm
|
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run fetch-openapi
|
|
||||||
pnpm run generate
|
pnpm run generate
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
|
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
--allow-empty
|
--allow-empty
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
|
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||||
|
|
||||||
- name: Sync version to CLI repository
|
- name: Sync version to CLI repository
|
||||||
run: |
|
run: |
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
||||||
cd /tmp/cli-repo
|
cd /tmp/cli-repo
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||||
mv package.json.tmp package.json
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||||
npm install -g pnpm
|
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run generate
|
pnpm run generate
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
|
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
--allow-empty
|
--allow-empty
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
|
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||||
|
|
||||||
- name: Sync version to SDK repository
|
- name: Sync version to SDK repository
|
||||||
run: |
|
run: |
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
||||||
cd /tmp/sdk-repo
|
cd /tmp/sdk-repo
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||||
mv package.json.tmp package.json
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||||
npm install -g pnpm
|
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run generate
|
pnpm run generate
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
|
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
--allow-empty
|
--allow-empty
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
|
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: Hotfix Cherry-Pick
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [closed, labeled]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: hotfix-to-main
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cherry-pick:
|
||||||
|
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'hotfix')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout main
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Cherry-pick fix to main
|
||||||
|
run: |
|
||||||
|
git config user.name "Dokploy Bot"
|
||||||
|
git config user.email "bot@dokploy.com"
|
||||||
|
SHA="${{ github.event.pull_request.merge_commit_sha }}"
|
||||||
|
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
|
||||||
|
git cherry-pick -x -m 1 "$SHA"
|
||||||
|
else
|
||||||
|
git cherry-pick -x "$SHA"
|
||||||
|
fi
|
||||||
|
git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
|
||||||
|
git push origin main
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: Hotfix Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: hotfix-to-main
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout main
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Bump patch version and push
|
||||||
|
run: |
|
||||||
|
git config user.name "Dokploy Bot"
|
||||||
|
git config user.email "bot@dokploy.com"
|
||||||
|
CURRENT=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
|
NEW=$(echo "$CURRENT" | awk -F. -v OFS=. '{$NF++; print}')
|
||||||
|
sed -i "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW\"/" apps/dokploy/package.json
|
||||||
|
git commit -am "chore: release ${NEW}"
|
||||||
|
git push origin main
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Regression coverage for https://github.com/Dokploy/dokploy/issues/4694 —
|
||||||
|
// values must survive Docker Compose's own `.env` parsing, not just base64 decode.
|
||||||
|
const appName = `env-file-literals-${process.pid}`;
|
||||||
|
const projectPath = join(process.cwd(), ".docker", "compose", appName);
|
||||||
|
const codePath = join(projectPath, "code");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
execFileSync("docker", ["compose", "down", "--remove-orphans"], {
|
||||||
|
cwd: codePath,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Project may not have been created (e.g. an earlier assertion failed).
|
||||||
|
}
|
||||||
|
rmSync(projectPath, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const cases: Record<string, string> = {
|
||||||
|
PASSWORD: "pa$$word",
|
||||||
|
SPECIAL: '!"#$%&/()=?',
|
||||||
|
NESTED_JSON: '{"nested":{"a":1}}',
|
||||||
|
MAIL_PASSWORD: "abc#de",
|
||||||
|
TRAILING_BACKSLASH: "trailing\\",
|
||||||
|
QUOTE_INSIDE: 'she said "hi"',
|
||||||
|
APOSTROPHE: "it's a test",
|
||||||
|
UNICODE: "héllo wörld 日本語 🚀",
|
||||||
|
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
|
||||||
|
};
|
||||||
|
|
||||||
|
// How each value must be typed in the UI so the (unchanged) dotenv input
|
||||||
|
// parser resolves it to the raw string in `cases` above.
|
||||||
|
const inputEncoding: Record<string, string> = {
|
||||||
|
PASSWORD: "pa$$word",
|
||||||
|
SPECIAL: `'!"#$%&/()=?'`,
|
||||||
|
NESTED_JSON: '{"nested":{"a":1}}',
|
||||||
|
MAIL_PASSWORD: `"abc#de"`,
|
||||||
|
TRAILING_BACKSLASH: "trailing\\",
|
||||||
|
QUOTE_INSIDE: 'she said "hi"',
|
||||||
|
APOSTROPHE: "it's a test",
|
||||||
|
UNICODE: "héllo wörld 日本語 🚀",
|
||||||
|
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("getCreateEnvFileCommand", () => {
|
||||||
|
it("writes special environment values that Docker Compose reads back literally", () => {
|
||||||
|
mkdirSync(codePath, { recursive: true });
|
||||||
|
|
||||||
|
const serviceEnv = Object.entries(inputEncoding)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const command = getCreateEnvFileCommand({
|
||||||
|
appName,
|
||||||
|
composePath: "docker-compose.yml",
|
||||||
|
env: serviceEnv,
|
||||||
|
randomize: false,
|
||||||
|
suffix: "",
|
||||||
|
serverId: null,
|
||||||
|
environment: { project: { env: "" }, env: "" },
|
||||||
|
} as Parameters<typeof getCreateEnvFileCommand>[0]);
|
||||||
|
|
||||||
|
execFileSync("bash", ["-c", command]);
|
||||||
|
|
||||||
|
const composeFile = `services:\n test:\n image: busybox\n environment:\n${Object.keys(
|
||||||
|
cases,
|
||||||
|
)
|
||||||
|
.map((key) => ` - ${key}=\${${key}}`)
|
||||||
|
.join("\n")}\n`;
|
||||||
|
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
|
||||||
|
|
||||||
|
const dumpScript = `for k in ${Object.keys(cases).join(" ")}; do printf '%s\\0' "$k"; eval "printf '%s\\0' \\"\\$$k\\""; done`;
|
||||||
|
|
||||||
|
const out = execFileSync(
|
||||||
|
"docker",
|
||||||
|
["compose", "run", "--rm", "-T", "test", "sh", "-c", dumpScript],
|
||||||
|
{ cwd: codePath, encoding: "utf8" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const parts = out.split("\0");
|
||||||
|
const actual: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < parts.length - 1; i += 2) {
|
||||||
|
actual[parts[i] as string] = parts[i + 1] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(cases)) {
|
||||||
|
expect(actual[key], key).toBe(value);
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createEnvFileCommand } from "@dokploy/server/utils/builders/utils";
|
||||||
|
import { parse } from "dotenv";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Unlike compose's .env, this one is read by the app's own build tooling
|
||||||
|
// (generic dotenv, e.g. Next.js/Vite) — must stay unquoted, not Compose-escaped.
|
||||||
|
const appName = `env-file-literals-dockerfile-${process.pid}`;
|
||||||
|
const projectPath = join(process.cwd(), ".docker", "compose", appName);
|
||||||
|
const codePath = join(projectPath, "code");
|
||||||
|
const dockerFilePath = join(codePath, "Dockerfile");
|
||||||
|
|
||||||
|
afterEach(() => rmSync(projectPath, { force: true, recursive: true }));
|
||||||
|
|
||||||
|
const cases: Record<string, string> = {
|
||||||
|
PASSWORD: "pa$$word",
|
||||||
|
NESTED_JSON: '{"nested":{"a":1}}',
|
||||||
|
QUOTE_INSIDE: 'she said "hi"',
|
||||||
|
BACKSLASH: "back\\slash",
|
||||||
|
UNICODE: "héllo wörld 日本語 🚀",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createEnvFileCommand", () => {
|
||||||
|
it("writes special environment values that a generic dotenv parser reads back literally", () => {
|
||||||
|
mkdirSync(codePath, { recursive: true });
|
||||||
|
|
||||||
|
const serviceEnv = Object.entries(cases)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const command = createEnvFileCommand(dockerFilePath, serviceEnv, "", "");
|
||||||
|
execFileSync("bash", ["-c", command]);
|
||||||
|
|
||||||
|
const written = readFileSync(join(codePath, ".env"), "utf8");
|
||||||
|
const parsed = parse(written);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(cases)) {
|
||||||
|
expect(parsed[key], key).toBe(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// cloneGithubRepository builds a shell command; the only thing under test here
|
||||||
|
// is which host ends up in the clone URL, so the app auth is stubbed out.
|
||||||
|
const mockFindGithubById = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/github", () => ({
|
||||||
|
findGithubById: mockFindGithubById,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@octokit/auth-app", () => ({
|
||||||
|
createAppAuth: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("octokit", () => ({
|
||||||
|
Octokit: class {
|
||||||
|
auth = async () => ({ token: "gh-token" });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { cloneGithubRepository } = await import(
|
||||||
|
"@dokploy/server/utils/providers/github"
|
||||||
|
);
|
||||||
|
|
||||||
|
const provider = (githubUrl: string) => ({
|
||||||
|
githubId: "gh-1",
|
||||||
|
githubUrl,
|
||||||
|
githubAppId: 1,
|
||||||
|
githubPrivateKey: "key",
|
||||||
|
githubInstallationId: "42",
|
||||||
|
});
|
||||||
|
|
||||||
|
const clone = async () => {
|
||||||
|
const command = await cloneGithubRepository({
|
||||||
|
appName: "my-app",
|
||||||
|
owner: "acme",
|
||||||
|
repository: "web",
|
||||||
|
branch: "main",
|
||||||
|
githubId: "gh-1",
|
||||||
|
enableSubmodules: false,
|
||||||
|
serverId: null,
|
||||||
|
});
|
||||||
|
return command.replace(/\\/g, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("cloneGithubRepository host", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clones from github.com for a default provider", async () => {
|
||||||
|
mockFindGithubById.mockResolvedValue(provider("https://github.com"));
|
||||||
|
|
||||||
|
const command = await clone();
|
||||||
|
|
||||||
|
expect(command).toContain(
|
||||||
|
"https://oauth2:gh-token@github.com/acme/web.git",
|
||||||
|
);
|
||||||
|
expect(command).not.toContain("ghe.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clones from the Enterprise host, not github.com", async () => {
|
||||||
|
mockFindGithubById.mockResolvedValue(provider("https://acme.ghe.com"));
|
||||||
|
|
||||||
|
const command = await clone();
|
||||||
|
|
||||||
|
expect(command).toContain(
|
||||||
|
"https://oauth2:gh-token@acme.ghe.com/acme/web.git",
|
||||||
|
);
|
||||||
|
expect(command).not.toContain("github.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clones from a self-hosted Enterprise Server host", async () => {
|
||||||
|
mockFindGithubById.mockResolvedValue(
|
||||||
|
provider("https://github.corp.acme.com"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = await clone();
|
||||||
|
|
||||||
|
expect(command).toContain(
|
||||||
|
"https://oauth2:gh-token@github.corp.acme.com/acme/web.git",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicit port in the clone host", async () => {
|
||||||
|
mockFindGithubById.mockResolvedValue(
|
||||||
|
provider("https://github.acme.com:8443"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = await clone();
|
||||||
|
|
||||||
|
expect(command).toContain(
|
||||||
|
"https://oauth2:gh-token@github.acme.com:8443/acme/web.git",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to github.com for a provider stored before this feature", async () => {
|
||||||
|
mockFindGithubById.mockResolvedValue(provider(""));
|
||||||
|
|
||||||
|
const command = await clone();
|
||||||
|
|
||||||
|
expect(command).toContain(
|
||||||
|
"https://oauth2:gh-token@github.com/acme/web.git",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_GITHUB_API_URL,
|
||||||
|
DEFAULT_GITHUB_URL,
|
||||||
|
deriveGithubApiUrl,
|
||||||
|
normalizeGithubUrl,
|
||||||
|
parseGithubBaseUrl,
|
||||||
|
} from "@dokploy/server/utils/providers/github";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const urlOf = (result: ReturnType<typeof parseGithubBaseUrl>) =>
|
||||||
|
"url" in result ? result.url : null;
|
||||||
|
|
||||||
|
describe("normalizeGithubUrl", () => {
|
||||||
|
it("defaults to github.com when empty", () => {
|
||||||
|
expect(normalizeGithubUrl("")).toBe(DEFAULT_GITHUB_URL);
|
||||||
|
expect(normalizeGithubUrl(null)).toBe(DEFAULT_GITHUB_URL);
|
||||||
|
expect(normalizeGithubUrl(undefined)).toBe(DEFAULT_GITHUB_URL);
|
||||||
|
expect(normalizeGithubUrl(" ")).toBe(DEFAULT_GITHUB_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assumes https when no scheme is given", () => {
|
||||||
|
expect(normalizeGithubUrl("acme.ghe.com")).toBe("https://acme.ghe.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips paths, queries and trailing slashes", () => {
|
||||||
|
expect(normalizeGithubUrl("https://acme.ghe.com/")).toBe(
|
||||||
|
"https://acme.ghe.com",
|
||||||
|
);
|
||||||
|
expect(normalizeGithubUrl("https://acme.ghe.com///")).toBe(
|
||||||
|
"https://acme.ghe.com",
|
||||||
|
);
|
||||||
|
expect(normalizeGithubUrl("https://github.acme.com/some/path?x=1")).toBe(
|
||||||
|
"https://github.acme.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicit port", () => {
|
||||||
|
expect(normalizeGithubUrl("https://github.acme.com:8443")).toBe(
|
||||||
|
"https://github.acme.com:8443",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to github.com on unusable input", () => {
|
||||||
|
expect(normalizeGithubUrl("ftp://github.acme.com")).toBe(
|
||||||
|
DEFAULT_GITHUB_URL,
|
||||||
|
);
|
||||||
|
expect(normalizeGithubUrl("http://github.internal")).toBe(
|
||||||
|
DEFAULT_GITHUB_URL,
|
||||||
|
);
|
||||||
|
expect(normalizeGithubUrl("https://")).toBe(DEFAULT_GITHUB_URL);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseGithubBaseUrl", () => {
|
||||||
|
it("accepts github.com and Enterprise hosts", () => {
|
||||||
|
expect(urlOf(parseGithubBaseUrl("https://acme.ghe.com"))).toBe(
|
||||||
|
"https://acme.ghe.com",
|
||||||
|
);
|
||||||
|
// A self-hosted instance behind the corporate network is a valid target.
|
||||||
|
expect(urlOf(parseGithubBaseUrl("https://github.corp.acme.com"))).toBe(
|
||||||
|
"https://github.corp.acme.com",
|
||||||
|
);
|
||||||
|
expect(urlOf(parseGithubBaseUrl("https://github.acme.com:8443"))).toBe(
|
||||||
|
"https://github.acme.com:8443",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an absent value as github.com", () => {
|
||||||
|
// Not specified is different from specified wrong.
|
||||||
|
expect(urlOf(parseGithubBaseUrl(undefined))).toBe(DEFAULT_GITHUB_URL);
|
||||||
|
expect(urlOf(parseGithubBaseUrl(null))).toBe(DEFAULT_GITHUB_URL);
|
||||||
|
expect(urlOf(parseGithubBaseUrl(" "))).toBe(DEFAULT_GITHUB_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects plaintext http", () => {
|
||||||
|
expect(parseGithubBaseUrl("http://acme.ghe.com")).toHaveProperty("error");
|
||||||
|
expect(parseGithubBaseUrl("http://localhost:2375")).toHaveProperty("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects dotless hostnames", () => {
|
||||||
|
expect(parseGithubBaseUrl("https://metadata")).toHaveProperty("error");
|
||||||
|
expect(parseGithubBaseUrl("https://localhost")).toHaveProperty("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a dotless hostname hidden behind the DNS root label", () => {
|
||||||
|
// "metadata." resolves like "metadata" but the trailing dot would satisfy
|
||||||
|
// a naive `includes(".")` check.
|
||||||
|
expect(parseGithubBaseUrl("https://metadata.")).toHaveProperty("error");
|
||||||
|
expect(parseGithubBaseUrl("https://localhost.")).toHaveProperty("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never falls back to github.com on a typo", () => {
|
||||||
|
// The symptom that opened the ticket: a provider silently pointing at
|
||||||
|
// github.com and reporting that it cannot find the repositories.
|
||||||
|
for (const typo of [
|
||||||
|
"htps://acme.ghe.com",
|
||||||
|
"ftp://acme.ghe.com",
|
||||||
|
"https://",
|
||||||
|
"acme .ghe.com",
|
||||||
|
]) {
|
||||||
|
const result = parseGithubBaseUrl(typo);
|
||||||
|
expect(result, typo).toHaveProperty("error");
|
||||||
|
expect(urlOf(result), typo).not.toBe(DEFAULT_GITHUB_URL);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveGithubApiUrl", () => {
|
||||||
|
it("maps github.com to api.github.com", () => {
|
||||||
|
expect(deriveGithubApiUrl("https://github.com")).toBe(
|
||||||
|
DEFAULT_GITHUB_API_URL,
|
||||||
|
);
|
||||||
|
expect(deriveGithubApiUrl("https://www.github.com")).toBe(
|
||||||
|
DEFAULT_GITHUB_API_URL,
|
||||||
|
);
|
||||||
|
expect(deriveGithubApiUrl(undefined)).toBe(DEFAULT_GITHUB_API_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefixes api. for data residency tenants", () => {
|
||||||
|
expect(deriveGithubApiUrl("https://acme.ghe.com")).toBe(
|
||||||
|
"https://api.acme.ghe.com",
|
||||||
|
);
|
||||||
|
expect(deriveGithubApiUrl("americancreditacceptance.ghe.com")).toBe(
|
||||||
|
"https://api.americancreditacceptance.ghe.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses /api/v3 for Enterprise Server", () => {
|
||||||
|
expect(deriveGithubApiUrl("https://github.acme.com")).toBe(
|
||||||
|
"https://github.acme.com/api/v3",
|
||||||
|
);
|
||||||
|
expect(deriveGithubApiUrl("https://github.acme.com:8443")).toBe(
|
||||||
|
"https://github.acme.com:8443/api/v3",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a lookalike host as data residency", () => {
|
||||||
|
// Must not match the .ghe.com branch just because the string contains it.
|
||||||
|
expect(deriveGithubApiUrl("https://ghe.com.acme.io")).toBe(
|
||||||
|
"https://ghe.com.acme.io/api/v3",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still detects data residency behind the DNS root label", () => {
|
||||||
|
// "acme.ghe.com." would otherwise miss endsWith(".ghe.com") and fall
|
||||||
|
// through to the /api/v3 branch.
|
||||||
|
expect(deriveGithubApiUrl("https://acme.ghe.com.")).toBe(
|
||||||
|
"https://api.acme.ghe.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps www.github.com, which a user may well type", () => {
|
||||||
|
// Not dead weight: GitHub never redirects a manifest there, but the value
|
||||||
|
// comes from a text field. Without this it would derive
|
||||||
|
// https://www.github.com/api/v3.
|
||||||
|
expect(deriveGithubApiUrl("https://www.github.com")).toBe(
|
||||||
|
DEFAULT_GITHUB_API_URL,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("providers created before Enterprise support", () => {
|
||||||
|
it("keeps pointing at github.com", () => {
|
||||||
|
// The column defaults to https://github.com, but a null must not break it.
|
||||||
|
expect(deriveGithubApiUrl(null)).toBe(DEFAULT_GITHUB_API_URL);
|
||||||
|
expect(new URL(normalizeGithubUrl(null)).host).toBe("github.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// The gh_init branch runs on a GET the user can be linked into, so a rejected
|
||||||
|
// host must produce a 400 *before* any outbound request is made.
|
||||||
|
const mockValidateRequest = vi.hoisted(() => vi.fn());
|
||||||
|
const mockHasPermission = vi.hoisted(() => vi.fn());
|
||||||
|
const mockCreateGithub = vi.hoisted(() => vi.fn());
|
||||||
|
const mockOctokitRequest = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@dokploy/server")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
validateRequest: mockValidateRequest,
|
||||||
|
createGithub: mockCreateGithub,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/permission", () => ({
|
||||||
|
hasPermission: mockHasPermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("octokit", () => ({
|
||||||
|
Octokit: class {
|
||||||
|
request = mockOctokitRequest;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { default: handler } = await import("@/pages/api/providers/github/setup");
|
||||||
|
|
||||||
|
const ORG = "org-1";
|
||||||
|
const USER = "user-1";
|
||||||
|
|
||||||
|
const buildRes = () => {
|
||||||
|
const res = {
|
||||||
|
statusCode: 0,
|
||||||
|
body: undefined as unknown,
|
||||||
|
redirectedTo: undefined as string | undefined,
|
||||||
|
status(code: number) {
|
||||||
|
res.statusCode = code;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
json(payload: unknown) {
|
||||||
|
res.body = payload;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
redirect(_code: number, url: string) {
|
||||||
|
res.redirectedTo = url;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const call = async (githubUrl?: string | string[]) => {
|
||||||
|
const res = buildRes();
|
||||||
|
const req = {
|
||||||
|
query: {
|
||||||
|
code: "manifest-code",
|
||||||
|
state: `gh_init:${ORG}:${USER}`,
|
||||||
|
...(githubUrl === undefined ? {} : { githubUrl }),
|
||||||
|
},
|
||||||
|
headers: {},
|
||||||
|
} as unknown as Parameters<typeof handler>[0];
|
||||||
|
|
||||||
|
await handler(req, res as unknown as Parameters<typeof handler>[1]);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("github setup handler — host validation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockValidateRequest.mockResolvedValue({
|
||||||
|
user: { id: USER },
|
||||||
|
session: { activeOrganizationId: ORG },
|
||||||
|
});
|
||||||
|
mockHasPermission.mockResolvedValue(true);
|
||||||
|
mockOctokitRequest.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
name: "Dokploy",
|
||||||
|
html_url: "https://acme.ghe.com/apps/dokploy",
|
||||||
|
id: 1,
|
||||||
|
client_id: "cid",
|
||||||
|
client_secret: "csecret",
|
||||||
|
webhook_secret: "wsecret",
|
||||||
|
pem: "key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockCreateGithub.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["http://acme.ghe.com", "plaintext http"],
|
||||||
|
["http://localhost:2375", "internal service over http"],
|
||||||
|
["https://metadata", "dotless hostname"],
|
||||||
|
["htps://acme.ghe.com", "scheme typo"],
|
||||||
|
])("rejects %s (%s) with 400 and no outbound request", async (githubUrl) => {
|
||||||
|
const res = await call(githubUrl);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(mockOctokitRequest).not.toHaveBeenCalled();
|
||||||
|
expect(mockCreateGithub).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a repeated parameter instead of silently picking one", async () => {
|
||||||
|
// ?githubUrl=a&githubUrl=b reaches .trim() on an array.
|
||||||
|
await expect(
|
||||||
|
call(["https://acme.ghe.com", "https://evil.com"]),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(mockCreateGithub).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a data residency tenant", async () => {
|
||||||
|
await call("https://acme.ghe.com");
|
||||||
|
|
||||||
|
expect(mockCreateGithub).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ githubUrl: "https://acme.ghe.com" }),
|
||||||
|
ORG,
|
||||||
|
USER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a self-hosted Enterprise Server host", async () => {
|
||||||
|
await call("https://github.corp.acme.com");
|
||||||
|
|
||||||
|
expect(mockCreateGithub).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ githubUrl: "https://github.corp.acme.com" }),
|
||||||
|
ORG,
|
||||||
|
USER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an absent parameter as github.com", async () => {
|
||||||
|
await call(undefined);
|
||||||
|
|
||||||
|
expect(mockCreateGithub).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ githubUrl: "https://github.com" }),
|
||||||
|
ORG,
|
||||||
|
USER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { parseGithubBaseUrl } from "@dokploy/server/utils/providers/github";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client cannot import from @dokploy/server (node-only modules), so
|
||||||
|
* resolveGithubBaseUrl duplicates parseGithubBaseUrl. Nothing but this file
|
||||||
|
* stops the two from drifting apart — and they already did once, when the
|
||||||
|
* client stripped trailing slashes but not paths and the form action disagreed
|
||||||
|
* with the persisted row.
|
||||||
|
*
|
||||||
|
* The invariant: for the same input, both must make the same accept/reject
|
||||||
|
* decision, and agree on the URL when they accept.
|
||||||
|
*/
|
||||||
|
const INPUTS = [
|
||||||
|
// Accepted
|
||||||
|
"https://github.com",
|
||||||
|
"github.com",
|
||||||
|
"https://acme.ghe.com",
|
||||||
|
"acme.ghe.com",
|
||||||
|
"https://github.corp.acme.com",
|
||||||
|
"https://github.acme.com:8443",
|
||||||
|
"https://acme.ghe.com/",
|
||||||
|
"https://acme.ghe.com///",
|
||||||
|
"https://acme.ghe.com/enterprises/foo",
|
||||||
|
"https://acme.ghe.com/some/path?x=1",
|
||||||
|
" acme.ghe.com ",
|
||||||
|
"https://acme.ghe.com.",
|
||||||
|
"https://169.254.169.254",
|
||||||
|
"https://127.0.0.1",
|
||||||
|
"https://2130706433",
|
||||||
|
"https://0x7f.1",
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
// Rejected
|
||||||
|
"http://acme.ghe.com",
|
||||||
|
"http://localhost:2375",
|
||||||
|
"https://[::1]",
|
||||||
|
"https://[::ffff:127.0.0.1]",
|
||||||
|
"https://metadata",
|
||||||
|
"https://metadata.",
|
||||||
|
"https://localhost",
|
||||||
|
"https://localhost.",
|
||||||
|
"htps://acme.ghe.com",
|
||||||
|
"ftp://acme.ghe.com",
|
||||||
|
"https://",
|
||||||
|
"acme .ghe.com",
|
||||||
|
"esto no es una url",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("client and server agree on GitHub base URLs", () => {
|
||||||
|
it.each(INPUTS)("%j", (input) => {
|
||||||
|
const client = resolveGithubBaseUrl(input);
|
||||||
|
const server = parseGithubBaseUrl(input);
|
||||||
|
|
||||||
|
const clientAccepted = !client.error;
|
||||||
|
const serverAccepted = "url" in server;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
clientAccepted,
|
||||||
|
`accept/reject differs for ${JSON.stringify(input)}`,
|
||||||
|
).toBe(serverAccepted);
|
||||||
|
|
||||||
|
if (clientAccepted && "url" in server) {
|
||||||
|
expect(client.baseUrl, `resolved URL differs for ${input}`).toBe(
|
||||||
|
server.url,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("both treat an empty value as github.com", () => {
|
||||||
|
expect(resolveGithubBaseUrl("").baseUrl).toBe(DEFAULT_GITHUB_URL);
|
||||||
|
expect(parseGithubBaseUrl("")).toEqual({ url: DEFAULT_GITHUB_URL });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,9 @@ describe("VALID_HOSTNAME_REGEX", () => {
|
|||||||
"a.b.c.example.co",
|
"a.b.c.example.co",
|
||||||
"xn--80ak6aa92e.com",
|
"xn--80ak6aa92e.com",
|
||||||
"123.example.com",
|
"123.example.com",
|
||||||
|
"example",
|
||||||
|
"dokploy-server",
|
||||||
|
"localhost",
|
||||||
])("accepts valid hostname %s", (host) => {
|
])("accepts valid hostname %s", (host) => {
|
||||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
|
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -17,7 +20,6 @@ describe("VALID_HOSTNAME_REGEX", () => {
|
|||||||
"bbn_client.example.com",
|
"bbn_client.example.com",
|
||||||
"-example.com",
|
"-example.com",
|
||||||
"example-.com",
|
"example-.com",
|
||||||
"example",
|
|
||||||
"exa mple.com",
|
"exa mple.com",
|
||||||
"example..com",
|
"example..com",
|
||||||
"",
|
"",
|
||||||
|
|||||||
+7
-1
@@ -47,6 +47,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
|
||||||
|
|
||||||
const GithubProviderSchema = z.object({
|
const GithubProviderSchema = z.object({
|
||||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||||
@@ -96,6 +97,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
const repository = form.watch("repository");
|
const repository = form.watch("repository");
|
||||||
const githubId = form.watch("githubId");
|
const githubId = form.watch("githubId");
|
||||||
|
|
||||||
|
// Enterprise repositories do not live on github.com.
|
||||||
|
const providerUrl =
|
||||||
|
githubProviders?.find((provider) => provider.githubId === githubId)
|
||||||
|
?.githubUrl ?? DEFAULT_GITHUB_URL;
|
||||||
const triggerType = form.watch("triggerType");
|
const triggerType = form.watch("triggerType");
|
||||||
|
|
||||||
const { data: repositories, isPending: isLoadingRepositories } =
|
const { data: repositories, isPending: isLoadingRepositories } =
|
||||||
@@ -227,7 +233,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.owner && field.value.repo && (
|
{field.value.owner && field.value.repo && (
|
||||||
<Link
|
<Link
|
||||||
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
|
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
|
|||||||
+7
-1
@@ -47,6 +47,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
|
||||||
|
|
||||||
const GithubProviderSchema = z.object({
|
const GithubProviderSchema = z.object({
|
||||||
composePath: z.string().min(1),
|
composePath: z.string().min(1),
|
||||||
@@ -98,6 +99,11 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
const repository = form.watch("repository");
|
const repository = form.watch("repository");
|
||||||
const githubId = form.watch("githubId");
|
const githubId = form.watch("githubId");
|
||||||
const triggerType = form.watch("triggerType");
|
const triggerType = form.watch("triggerType");
|
||||||
|
|
||||||
|
// Enterprise repositories do not live on github.com.
|
||||||
|
const providerUrl =
|
||||||
|
githubProviders?.find((provider) => provider.githubId === githubId)
|
||||||
|
?.githubUrl ?? DEFAULT_GITHUB_URL;
|
||||||
const { data: repositories, isPending: isLoadingRepositories } =
|
const { data: repositories, isPending: isLoadingRepositories } =
|
||||||
api.github.getGithubRepositories.useQuery(
|
api.github.getGithubRepositories.useQuery(
|
||||||
{
|
{
|
||||||
@@ -220,7 +226,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.owner && field.value.repo && (
|
{field.value.owner && field.value.repo && (
|
||||||
<Link
|
<Link
|
||||||
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
|
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export const RestoreBackup = ({
|
|||||||
Restore Backup
|
Restore Backup
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center">
|
<DialogTitle className="flex items-center">
|
||||||
<RotateCcw className="mr-2 size-4" />
|
<RotateCcw className="mr-2 size-4" />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils";
|
||||||
|
|
||||||
export const AddGithubProvider = () => {
|
export const AddGithubProvider = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -23,6 +24,9 @@ export const AddGithubProvider = () => {
|
|||||||
const [manifest, setManifest] = useState("");
|
const [manifest, setManifest] = useState("");
|
||||||
const [isOrganization, setIsOrganization] = useState(false);
|
const [isOrganization, setIsOrganization] = useState(false);
|
||||||
const [organizationName, setOrganization] = useState("");
|
const [organizationName, setOrganization] = useState("");
|
||||||
|
const [githubUrl, setGithubUrl] = useState(DEFAULT_GITHUB_URL);
|
||||||
|
|
||||||
|
const { baseUrl, error: githubUrlError } = resolveGithubBaseUrl(githubUrl);
|
||||||
|
|
||||||
const randomString = () => Math.random().toString(36).slice(2, 8);
|
const randomString = () => Math.random().toString(36).slice(2, 8);
|
||||||
|
|
||||||
@@ -30,7 +34,7 @@ export const AddGithubProvider = () => {
|
|||||||
const url = document.location.origin;
|
const url = document.location.origin;
|
||||||
const manifest = JSON.stringify(
|
const manifest = JSON.stringify(
|
||||||
{
|
{
|
||||||
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}`,
|
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}&githubUrl=${encodeURIComponent(baseUrl)}`,
|
||||||
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}-${randomString()}`,
|
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}-${randomString()}`,
|
||||||
url: origin,
|
url: origin,
|
||||||
hook_attributes: {
|
hook_attributes: {
|
||||||
@@ -52,7 +56,7 @@ export const AddGithubProvider = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setManifest(manifest);
|
setManifest(manifest);
|
||||||
}, [activeOrganization?.id, session?.user?.id]);
|
}, [activeOrganization?.id, session?.user?.id, baseUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
@@ -79,6 +83,25 @@ export const AddGithubProvider = () => {
|
|||||||
below to get started.
|
below to get started.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm">GitHub URL</span>
|
||||||
|
<Input
|
||||||
|
placeholder={DEFAULT_GITHUB_URL}
|
||||||
|
value={githubUrl}
|
||||||
|
onChange={(e) => setGithubUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Leave as is for github.com. For GitHub Enterprise, use your
|
||||||
|
instance URL (e.g. https://acme.ghe.com or
|
||||||
|
https://github.acme.com).
|
||||||
|
</span>
|
||||||
|
{githubUrlError && (
|
||||||
|
<span className="text-destructive text-xs">
|
||||||
|
{githubUrlError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-4">
|
||||||
<span>Organization?</span>
|
<span>Organization?</span>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -98,8 +121,8 @@ export const AddGithubProvider = () => {
|
|||||||
<form
|
<form
|
||||||
action={
|
action={
|
||||||
isOrganization
|
isOrganization
|
||||||
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
? `${baseUrl}/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
||||||
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
: `${baseUrl}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
||||||
}
|
}
|
||||||
method="post"
|
method="post"
|
||||||
>
|
>
|
||||||
@@ -116,8 +139,8 @@ export const AddGithubProvider = () => {
|
|||||||
<a
|
<a
|
||||||
href={
|
href={
|
||||||
isOrganization && organizationName
|
isOrganization && organizationName
|
||||||
? `https://github.com/organizations/${organizationName}/settings/installations`
|
? `${baseUrl}/organizations/${organizationName}/settings/installations`
|
||||||
: "https://github.com/settings/installations"
|
: `${baseUrl}/settings/installations`
|
||||||
}
|
}
|
||||||
className={`text-muted-foreground text-sm hover:underline duration-300
|
className={`text-muted-foreground text-sm hover:underline duration-300
|
||||||
${
|
${
|
||||||
@@ -131,7 +154,10 @@ export const AddGithubProvider = () => {
|
|||||||
Unsure if you already have an app?
|
Unsure if you already have an app?
|
||||||
</a>
|
</a>
|
||||||
<Button
|
<Button
|
||||||
disabled={isOrganization && organizationName.length < 1}
|
disabled={
|
||||||
|
!!githubUrlError ||
|
||||||
|
(isOrganization && organizationName.length < 1)
|
||||||
|
}
|
||||||
type="submit"
|
type="submit"
|
||||||
className="self-end"
|
className="self-end"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -147,6 +147,15 @@ export const EditGithubProvider = ({ githubId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-medium">GitHub URL</span>
|
||||||
|
<Input value={github?.githubUrl ?? ""} readOnly />
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Set when the app was created and not editable, the app
|
||||||
|
credentials belong to this instance.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full justify-between gap-4 mt-4">
|
<div className="flex w-full justify-between gap-4 mt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
|
||||||
import { useUrl } from "@/utils/hooks/use-url";
|
import { useUrl } from "@/utils/hooks/use-url";
|
||||||
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
|
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
|
||||||
import { EditBitbucketProvider } from "./bitbucket/edit-bitbucket-provider";
|
import { EditBitbucketProvider } from "./bitbucket/edit-bitbucket-provider";
|
||||||
@@ -161,6 +162,14 @@ export const ShowGitProviders = () => {
|
|||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{gitProvider.name}
|
{gitProvider.name}
|
||||||
</span>
|
</span>
|
||||||
|
{isGithub &&
|
||||||
|
gitProvider.github?.githubUrl &&
|
||||||
|
gitProvider.github.githubUrl !==
|
||||||
|
DEFAULT_GITHUB_URL && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{gitProvider.github.githubUrl}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{formatDate(
|
{formatDate(
|
||||||
gitProvider.createdAt,
|
gitProvider.createdAt,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "github" ADD COLUMN "githubUrl" text DEFAULT 'https://github.com' NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1254,6 +1254,13 @@
|
|||||||
"when": 1785751631838,
|
"when": 1785751631838,
|
||||||
"tag": "0178_silky_randall",
|
"tag": "0178_silky_randall",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 179,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785800152143,
|
||||||
|
"tag": "0179_foamy_marauders",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.29.13",
|
"version": "v0.29.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -119,9 +119,11 @@ export default async function handler(
|
|||||||
}
|
}
|
||||||
// If webhook doesn't provide image info, we'll use the configured image (old behavior)
|
// If webhook doesn't provide image info, we'll use the configured image (old behavior)
|
||||||
} else if (sourceType === "github") {
|
} else if (sourceType === "github") {
|
||||||
const normalizedCommits = req.body?.commits?.flatMap(
|
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
|
|
||||||
const shouldDeployPaths = shouldDeploy(
|
const shouldDeployPaths = shouldDeploy(
|
||||||
application.watchPaths,
|
application.watchPaths,
|
||||||
@@ -150,21 +152,29 @@ export default async function handler(
|
|||||||
let normalizedCommits: string[] = [];
|
let normalizedCommits: string[] = [];
|
||||||
|
|
||||||
if (provider === "github") {
|
if (provider === "github") {
|
||||||
normalizedCommits = req.body?.commits?.flatMap(
|
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
} else if (provider === "gitlab") {
|
} else if (provider === "gitlab") {
|
||||||
normalizedCommits = req.body?.commits?.flatMap(
|
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
} else if (provider === "gitea") {
|
} else if (provider === "gitea") {
|
||||||
normalizedCommits = req.body?.commits?.flatMap(
|
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
} else if (provider === "soft-serve") {
|
} else if (provider === "soft-serve") {
|
||||||
normalizedCommits = req.body?.commits?.flatMap(
|
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldDeployPaths = shouldDeploy(
|
const shouldDeployPaths = shouldDeploy(
|
||||||
@@ -179,9 +189,11 @@ export default async function handler(
|
|||||||
} else if (sourceType === "gitlab") {
|
} else if (sourceType === "gitlab") {
|
||||||
const branchName = extractBranchName(req.headers, req.body);
|
const branchName = extractBranchName(req.headers, req.body);
|
||||||
|
|
||||||
const normalizedCommits = req.body?.commits?.flatMap(
|
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
|
|
||||||
const shouldDeployPaths = shouldDeploy(
|
const shouldDeployPaths = shouldDeploy(
|
||||||
application.watchPaths,
|
application.watchPaths,
|
||||||
@@ -225,9 +237,11 @@ export default async function handler(
|
|||||||
} else if (sourceType === "gitea") {
|
} else if (sourceType === "gitea") {
|
||||||
const branchName = extractBranchName(req.headers, req.body);
|
const branchName = extractBranchName(req.headers, req.body);
|
||||||
|
|
||||||
const normalizedCommits = req.body?.commits?.flatMap(
|
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
|
|
||||||
const shouldDeployPaths = shouldDeploy(
|
const shouldDeployPaths = shouldDeploy(
|
||||||
application.watchPaths,
|
application.watchPaths,
|
||||||
|
|||||||
@@ -54,9 +54,11 @@ export default async function handler(
|
|||||||
|
|
||||||
if (sourceType === "github") {
|
if (sourceType === "github") {
|
||||||
const branchName = extractBranchName(req.headers, req.body);
|
const branchName = extractBranchName(req.headers, req.body);
|
||||||
const normalizedCommits = req.body?.commits?.flatMap(
|
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
|
|
||||||
const shouldDeployPaths = shouldDeploy(
|
const shouldDeployPaths = shouldDeploy(
|
||||||
composeResult.watchPaths,
|
composeResult.watchPaths,
|
||||||
@@ -74,9 +76,11 @@ export default async function handler(
|
|||||||
}
|
}
|
||||||
} else if (sourceType === "gitlab") {
|
} else if (sourceType === "gitlab") {
|
||||||
const branchName = extractBranchName(req.headers, req.body);
|
const branchName = extractBranchName(req.headers, req.body);
|
||||||
const normalizedCommits = req.body?.commits?.flatMap(
|
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
|
|
||||||
const shouldDeployPaths = shouldDeploy(
|
const shouldDeployPaths = shouldDeploy(
|
||||||
composeResult.watchPaths,
|
composeResult.watchPaths,
|
||||||
@@ -125,17 +129,23 @@ export default async function handler(
|
|||||||
let normalizedCommits: string[] = [];
|
let normalizedCommits: string[] = [];
|
||||||
|
|
||||||
if (provider === "github") {
|
if (provider === "github") {
|
||||||
normalizedCommits = req.body?.commits?.flatMap(
|
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
} else if (provider === "gitlab") {
|
} else if (provider === "gitlab") {
|
||||||
normalizedCommits = req.body?.commits?.flatMap(
|
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
} else if (provider === "gitea") {
|
} else if (provider === "gitea") {
|
||||||
normalizedCommits = req.body?.commits?.flatMap(
|
normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldDeployPaths = shouldDeploy(
|
const shouldDeployPaths = shouldDeploy(
|
||||||
@@ -150,9 +160,11 @@ export default async function handler(
|
|||||||
} else if (sourceType === "gitea") {
|
} else if (sourceType === "gitea") {
|
||||||
const branchName = extractBranchName(req.headers, req.body);
|
const branchName = extractBranchName(req.headers, req.body);
|
||||||
|
|
||||||
const normalizedCommits = req.body?.commits?.flatMap(
|
const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
|
|
||||||
const shouldDeployPaths = shouldDeploy(
|
const shouldDeployPaths = shouldDeploy(
|
||||||
composeResult.watchPaths,
|
composeResult.watchPaths,
|
||||||
|
|||||||
@@ -223,9 +223,11 @@ export default async function handler(
|
|||||||
const deploymentTitle = extractCommitMessage(req.headers, req.body);
|
const deploymentTitle = extractCommitMessage(req.headers, req.body);
|
||||||
const deploymentHash = extractHash(req.headers, req.body);
|
const deploymentHash = extractHash(req.headers, req.body);
|
||||||
const owner = getGithubRepositoryOwner(githubBody);
|
const owner = getGithubRepositoryOwner(githubBody);
|
||||||
const normalizedCommits = githubBody?.commits?.flatMap(
|
const normalizedCommits = githubBody?.commits?.flatMap((commit: any) => [
|
||||||
(commit: any) => commit.modified,
|
...(commit.added || []),
|
||||||
);
|
...(commit.modified || []),
|
||||||
|
...(commit.removed || []),
|
||||||
|
]);
|
||||||
|
|
||||||
const apps = await db.query.applications.findMany({
|
const apps = await db.query.applications.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { createGithub, validateRequest } from "@dokploy/server";
|
import {
|
||||||
|
createGithub,
|
||||||
|
deriveGithubApiUrl,
|
||||||
|
parseGithubBaseUrl,
|
||||||
|
validateRequest,
|
||||||
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { hasPermission } from "@dokploy/server/services/permission";
|
import { hasPermission } from "@dokploy/server/services/permission";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -11,13 +16,14 @@ type Query = {
|
|||||||
state: string;
|
state: string;
|
||||||
installation_id: string;
|
installation_id: string;
|
||||||
setup_action: string;
|
setup_action: string;
|
||||||
|
githubUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
) {
|
) {
|
||||||
const { code, state, installation_id }: Query = req.query as Query;
|
const { code, state, installation_id, githubUrl }: Query = req.query as Query;
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return res.status(400).json({ error: "Missing code parameter" });
|
return res.status(400).json({ error: "Missing code parameter" });
|
||||||
@@ -38,7 +44,15 @@ export default async function handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "gh_init") {
|
if (action === "gh_init") {
|
||||||
const octokit = new Octokit({});
|
// Reject before any outbound request: this runs on a GET the user can be
|
||||||
|
// linked into, so the host is not trusted.
|
||||||
|
const parsed = parseGithubBaseUrl(githubUrl);
|
||||||
|
if ("error" in parsed) {
|
||||||
|
return res.status(400).json({ error: parsed.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = parsed.url;
|
||||||
|
const octokit = new Octokit({ baseUrl: deriveGithubApiUrl(baseUrl) });
|
||||||
const { data } = await octokit.request(
|
const { data } = await octokit.request(
|
||||||
"POST /app-manifests/{code}/conversions",
|
"POST /app-manifests/{code}/conversions",
|
||||||
{
|
{
|
||||||
@@ -55,6 +69,7 @@ export default async function handler(
|
|||||||
githubClientSecret: data.client_secret,
|
githubClientSecret: data.client_secret,
|
||||||
githubWebhookSecret: data.webhook_secret,
|
githubWebhookSecret: data.webhook_secret,
|
||||||
githubPrivateKey: data.pem,
|
githubPrivateKey: data.pem,
|
||||||
|
githubUrl: baseUrl,
|
||||||
},
|
},
|
||||||
session.activeOrganizationId,
|
session.activeOrganizationId,
|
||||||
user.id,
|
user.id,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const gitProviderRouter = createTRPCRouter({
|
|||||||
githubAppName: r.github.githubAppName,
|
githubAppName: r.github.githubAppName,
|
||||||
githubAppId: r.github.githubAppId,
|
githubAppId: r.github.githubAppId,
|
||||||
githubInstallationId: r.github.githubInstallationId,
|
githubInstallationId: r.github.githubInstallationId,
|
||||||
|
githubUrl: r.github.githubUrl,
|
||||||
isConfigured: !!(
|
isConfigured: !!(
|
||||||
r.github.githubPrivateKey &&
|
r.github.githubPrivateKey &&
|
||||||
r.github.githubAppId &&
|
r.github.githubAppId &&
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export const githubRouter = createTRPCRouter({
|
|||||||
.map((provider) => {
|
.map((provider) => {
|
||||||
return {
|
return {
|
||||||
githubId: provider.githubId,
|
githubId: provider.githubId,
|
||||||
|
githubUrl: provider.githubUrl,
|
||||||
gitProvider: {
|
gitProvider: {
|
||||||
...provider.gitProvider,
|
...provider.gitProvider,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// utils/github-utils.ts
|
||||||
|
// This file contains client-safe utilities for GitHub integration.
|
||||||
|
// Duplicates parseGithubBaseUrl from @dokploy/server, which cannot be imported
|
||||||
|
// here (node-only modules). Kept in step by github-url-parity.test.ts.
|
||||||
|
|
||||||
|
export const DEFAULT_GITHUB_URL = "https://github.com";
|
||||||
|
|
||||||
|
interface ResolvedGithubBaseUrl {
|
||||||
|
baseUrl: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveGithubBaseUrl = (value: string): ResolvedGithubBaseUrl => {
|
||||||
|
const raw = value.trim() || DEFAULT_GITHUB_URL;
|
||||||
|
const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(withScheme);
|
||||||
|
} catch {
|
||||||
|
// Keep the raw value rather than defaulting: the failure stays visible.
|
||||||
|
return { baseUrl: withScheme, error: `"${raw}" is not a valid URL` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== "https:") {
|
||||||
|
return {
|
||||||
|
baseUrl: parsed.origin,
|
||||||
|
error:
|
||||||
|
"Only https is supported. Creating the app over http would leave it orphaned on your instance.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// "acme.ghe.com." resolves the same as "acme.ghe.com", but the trailing dot
|
||||||
|
// would slip past the checks below.
|
||||||
|
if (parsed.hostname.endsWith(".")) {
|
||||||
|
parsed.hostname = parsed.hostname.replace(/\.+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hostname } = parsed;
|
||||||
|
|
||||||
|
if (!hostname.includes(".")) {
|
||||||
|
return {
|
||||||
|
baseUrl: parsed.origin,
|
||||||
|
error: `"${hostname}" is not a fully qualified hostname`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { baseUrl: parsed.origin };
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@ export const github = pgTable("github", {
|
|||||||
githubInstallationId: text("githubInstallationId"),
|
githubInstallationId: text("githubInstallationId"),
|
||||||
githubPrivateKey: text("githubPrivateKey"),
|
githubPrivateKey: text("githubPrivateKey"),
|
||||||
githubWebhookSecret: text("githubWebhookSecret"),
|
githubWebhookSecret: text("githubWebhookSecret"),
|
||||||
|
githubUrl: text("githubUrl").default("https://github.com").notNull(),
|
||||||
gitProviderId: text("gitProviderId")
|
gitProviderId: text("gitProviderId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => gitProvider.gitProviderId, { onDelete: "cascade" }),
|
.references(() => gitProvider.gitProviderId, { onDelete: "cascade" }),
|
||||||
@@ -36,6 +37,10 @@ export const apiCreateGithub = z.object({
|
|||||||
githubInstallationId: z.string().optional(),
|
githubInstallationId: z.string().optional(),
|
||||||
githubPrivateKey: z.string().optional(),
|
githubPrivateKey: z.string().optional(),
|
||||||
githubWebhookSecret: z.string().nullable(),
|
githubWebhookSecret: z.string().nullable(),
|
||||||
|
githubUrl: z
|
||||||
|
.url()
|
||||||
|
.startsWith("https://", "Only https is supported for GitHub instances")
|
||||||
|
.optional(),
|
||||||
gitProviderId: z.string().optional(),
|
gitProviderId: z.string().optional(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { writeDomainsToCompose } from "../docker/domain";
|
|||||||
import {
|
import {
|
||||||
encodeBase64,
|
encodeBase64,
|
||||||
getEnvironmentVariablesObject,
|
getEnvironmentVariablesObject,
|
||||||
prepareEnvironmentVariables,
|
prepareEnvironmentVariablesForFile,
|
||||||
} from "../docker/utils";
|
} from "../docker/utils";
|
||||||
|
|
||||||
export type ComposeNested = InferResultType<
|
export type ComposeNested = InferResultType<
|
||||||
@@ -127,7 +127,7 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
|
|||||||
envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`;
|
envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const envFileContent = prepareEnvironmentVariables(
|
const envFileContent = prepareEnvironmentVariablesForFile(
|
||||||
envContent,
|
envContent,
|
||||||
compose.environment.project.env,
|
compose.environment.project.env,
|
||||||
compose.environment.env,
|
compose.environment.env,
|
||||||
|
|||||||
@@ -465,6 +465,27 @@ export const prepareEnvironmentVariablesForShell = (
|
|||||||
return envVars.map((env) => quote([env]));
|
return envVars.map((env) => quote([env]));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const prepareEnvironmentVariablesForFile = (
|
||||||
|
serviceEnv: string | null,
|
||||||
|
projectEnv?: string | null,
|
||||||
|
environmentEnv?: string | null,
|
||||||
|
): string[] => {
|
||||||
|
const envVars = prepareEnvironmentVariables(
|
||||||
|
serviceEnv,
|
||||||
|
projectEnv,
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
return envVars.map((pair) => {
|
||||||
|
const [key, value] = parseEnvironmentKeyValuePair(pair);
|
||||||
|
const escapedValue = value
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\$/g, "\\$");
|
||||||
|
return `${key}="${escapedValue}"`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const parseEnvironmentKeyValuePair = (
|
export const parseEnvironmentKeyValuePair = (
|
||||||
pair: string,
|
pair: string,
|
||||||
): [string, string] => {
|
): [string, string] => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// Valid hostname per RFC 1123: labels of letters, digits and hyphens
|
// Valid hostname per RFC 1123: labels of letters, digits and hyphens
|
||||||
// (no leading/trailing hyphen), separated by dots. Underscores are rejected
|
// (no leading/trailing hyphen), optionally separated by dots. Underscores are rejected
|
||||||
// because Let's Encrypt refuses to issue certificates for them.
|
// because Let's Encrypt refuses to issue certificates for them.
|
||||||
export const VALID_HOSTNAME_REGEX =
|
export const VALID_HOSTNAME_REGEX =
|
||||||
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||||
|
|
||||||
export const INVALID_HOSTNAME_MESSAGE =
|
export const INVALID_HOSTNAME_MESSAGE =
|
||||||
"Invalid domain name. Use only letters, numbers, hyphens and dots (e.g. example.com). Underscores are not allowed.";
|
"Invalid domain name. Use only letters, numbers, hyphens and dots (e.g. example.com). Underscores are not allowed.";
|
||||||
|
|||||||
@@ -9,6 +9,68 @@ import { Octokit } from "octokit";
|
|||||||
import { quote } from "shell-quote";
|
import { quote } from "shell-quote";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
export const DEFAULT_GITHUB_URL = "https://github.com";
|
||||||
|
export const DEFAULT_GITHUB_API_URL = "https://api.github.com";
|
||||||
|
|
||||||
|
export const parseGithubBaseUrl = (
|
||||||
|
githubUrl?: string | null,
|
||||||
|
): { url: string } | { error: string } => {
|
||||||
|
const raw = githubUrl?.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return { url: DEFAULT_GITHUB_URL };
|
||||||
|
}
|
||||||
|
|
||||||
|
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)
|
||||||
|
? raw
|
||||||
|
: `https://${raw}`;
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(withScheme);
|
||||||
|
} catch {
|
||||||
|
return { error: `"${raw}" is not a valid URL` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== "https:") {
|
||||||
|
return { error: "Only https is supported for GitHub instances" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// "acme.ghe.com." resolves the same as "acme.ghe.com", but the trailing dot
|
||||||
|
// would slip past the checks below.
|
||||||
|
if (parsed.hostname.endsWith(".")) {
|
||||||
|
parsed.hostname = parsed.hostname.replace(/\.+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hostname } = parsed;
|
||||||
|
|
||||||
|
if (!hostname.includes(".")) {
|
||||||
|
return { error: `"${hostname}" is not a fully qualified hostname` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url: `${parsed.protocol}//${parsed.host}` };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeGithubUrl = (githubUrl?: string | null): string => {
|
||||||
|
const result = parseGithubBaseUrl(githubUrl);
|
||||||
|
return "url" in result ? result.url : DEFAULT_GITHUB_URL;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deriveGithubApiUrl = (githubUrl?: string | null): string => {
|
||||||
|
const normalized = normalizeGithubUrl(githubUrl);
|
||||||
|
const { protocol, host } = new URL(normalized);
|
||||||
|
|
||||||
|
if (host === "github.com" || host === "www.github.com") {
|
||||||
|
return DEFAULT_GITHUB_API_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data residency tenants keep the api. prefix; GHES does not have one.
|
||||||
|
if (host === "ghe.com" || host.endsWith(".ghe.com")) {
|
||||||
|
return `${protocol}//api.${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${protocol}//${host}/api/v3`;
|
||||||
|
};
|
||||||
|
|
||||||
export const authGithub = (githubProvider: Github): Octokit => {
|
export const authGithub = (githubProvider: Github): Octokit => {
|
||||||
if (!haveGithubRequirements(githubProvider)) {
|
if (!haveGithubRequirements(githubProvider)) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -24,6 +86,7 @@ export const authGithub = (githubProvider: Github): Octokit => {
|
|||||||
privateKey: githubProvider?.githubPrivateKey || "",
|
privateKey: githubProvider?.githubPrivateKey || "",
|
||||||
installationId: githubProvider?.githubInstallationId,
|
installationId: githubProvider?.githubInstallationId,
|
||||||
},
|
},
|
||||||
|
baseUrl: deriveGithubApiUrl(githubProvider?.githubUrl),
|
||||||
});
|
});
|
||||||
|
|
||||||
return octokit;
|
return octokit;
|
||||||
@@ -162,10 +225,11 @@ export const cloneGithubRepository = async ({
|
|||||||
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
|
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
|
||||||
const octokit = authGithub(githubProvider);
|
const octokit = authGithub(githubProvider);
|
||||||
const token = await getGithubToken(octokit);
|
const token = await getGithubToken(octokit);
|
||||||
const repoclone = `github.com/${owner}/${repository}.git`;
|
const cloneBase = new URL(normalizeGithubUrl(githubProvider.githubUrl));
|
||||||
|
const repoclone = `${cloneBase.host}/${owner}/${repository}.git`;
|
||||||
command += `rm -rf ${outputPath};`;
|
command += `rm -rf ${outputPath};`;
|
||||||
command += `mkdir -p ${outputPath};`;
|
command += `mkdir -p ${outputPath};`;
|
||||||
const cloneUrl = `https://oauth2:${token}@${repoclone}`;
|
const cloneUrl = `${cloneBase.protocol}//oauth2:${token}@${repoclone}`;
|
||||||
|
|
||||||
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
|
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
|
||||||
command += `git clone --branch ${quote([String(branch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
command += `git clone --branch ${quote([String(branch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||||
@@ -187,6 +251,7 @@ export const getGithubRepositories = async (githubId?: string) => {
|
|||||||
privateKey: githubProvider.githubPrivateKey,
|
privateKey: githubProvider.githubPrivateKey,
|
||||||
installationId: githubProvider.githubInstallationId,
|
installationId: githubProvider.githubInstallationId,
|
||||||
},
|
},
|
||||||
|
baseUrl: deriveGithubApiUrl(githubProvider.githubUrl),
|
||||||
});
|
});
|
||||||
|
|
||||||
const repositories = (await octokit.paginate(
|
const repositories = (await octokit.paginate(
|
||||||
@@ -213,6 +278,7 @@ export const getGithubBranches = async (
|
|||||||
privateKey: githubProvider.githubPrivateKey,
|
privateKey: githubProvider.githubPrivateKey,
|
||||||
installationId: githubProvider.githubInstallationId,
|
installationId: githubProvider.githubInstallationId,
|
||||||
},
|
},
|
||||||
|
baseUrl: deriveGithubApiUrl(githubProvider.githubUrl),
|
||||||
});
|
});
|
||||||
|
|
||||||
const branches = (await octokit.paginate(octokit.rest.repos.listBranches, {
|
const branches = (await octokit.paginate(octokit.rest.repos.listBranches, {
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ export const runCommand = async (scheduleId: string) => {
|
|||||||
"This feature is not available in the cloud version.",
|
"This feature is not available in the cloud version.",
|
||||||
);
|
);
|
||||||
writeStream.end();
|
writeStream.end();
|
||||||
return { ...deployment, status: "running" as const };
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
|
return { ...deployment, status: "error" as const };
|
||||||
}
|
}
|
||||||
writeStream.write(
|
writeStream.write(
|
||||||
`docker exec ${containerId} ${shellType} -c ${command}\n`,
|
`docker exec ${containerId} ${shellType} -c ${command}\n`,
|
||||||
|
|||||||
Reference in New Issue
Block a user