diff --git a/apps/dokploy/components/dashboard/settings/git/bitbucket/add-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/settings/git/bitbucket/add-bitbucket-provider.tsx index d47dafdfd..c933a0b8c 100644 --- a/apps/dokploy/components/dashboard/settings/git/bitbucket/add-bitbucket-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/bitbucket/add-bitbucket-provider.tsx @@ -1,7 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { ExternalLink } from "lucide-react"; import Link from "next/link"; -import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -27,18 +26,12 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; -import { useUrl } from "@/utils/hooks/use-url"; const Schema = z.object({ - name: z.string().min(1, { - message: "Name is required", - }), - username: z.string().min(1, { - message: "Username is required", - }), - password: z.string().min(1, { - message: "App Password is required", - }), + name: z.string().min(1, { message: "Name is required" }), + username: z.string().min(1, { message: "Username is required" }), + email: z.string().email().optional(), + apiToken: z.string().min(1, { message: "API Token is required" }), workspaceName: z.string().optional(), }); @@ -47,14 +40,12 @@ type Schema = z.infer; export const AddBitbucketProvider = () => { const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); - const _url = useUrl(); const { mutateAsync, error, isError } = api.bitbucket.create.useMutation(); const { data: auth } = api.user.get.useQuery(); - const _router = useRouter(); const form = useForm({ defaultValues: { username: "", - password: "", + apiToken: "", workspaceName: "", }, resolver: zodResolver(Schema), @@ -63,7 +54,8 @@ export const AddBitbucketProvider = () => { useEffect(() => { form.reset({ username: "", - password: "", + email: "", + apiToken: "", workspaceName: "", }); }, [form, isOpen]); @@ -71,10 +63,11 @@ export const AddBitbucketProvider = () => { const onSubmit = async (data: Schema) => { await mutateAsync({ bitbucketUsername: data.username, - appPassword: data.password, + apiToken: data.apiToken, bitbucketWorkspaceName: data.workspaceName || "", authId: auth?.id || "", name: data.name || "", + bitbucketEmail: data.email || "", }) .then(async () => { await utils.gitProvider.getAll.invalidate(); @@ -113,37 +106,46 @@ export const AddBitbucketProvider = () => { >
+ + Bitbucket App Passwords are deprecated for new providers. Use + an API Token instead. Existing providers with App Passwords + will continue to work until 9th June 2026. + + +
+ Manage tokens in + + Bitbucket settings + + +
+
    +
  • + Click on Create API token with scopes +
  • +
  • + Select the expiration date (Max 1 year) +
  • +
  • + Select Bitbucket product. +
  • +

- To integrate your Bitbucket account, you need to create a new - App Password in your Bitbucket settings. Follow these steps: + Select the following scopes:

-
    -
  1. - Create new App Password{" "} - - - -
  2. -
  3. - When creating the App Password, ensure you grant the - following permissions: -
      -
    • Account: Read
    • -
    • Workspace membership: Read
    • -
    • Projects: Read
    • -
    • Repositories: Read
    • -
    • Pull requests: Read
    • -
    • Webhooks: Read and write
    • -
    -
  4. -
  5. - After creating, you'll receive an App Password. Copy it and - paste it below along with your Bitbucket username. -
  6. -
+ +
    +
  • read:repository:bitbucket
  • +
  • read:pullrequest:bitbucket
  • +
  • read:webhook:bitbucket
  • +
  • read:workspace:bitbucket
  • +
  • write:webhook:bitbucket
  • +
+ { Name @@ -179,14 +181,27 @@ export const AddBitbucketProvider = () => { ( - App Password + Bitbucket Email + + + + + + )} + /> + + ( + + API Token @@ -200,7 +215,7 @@ export const AddBitbucketProvider = () => { name="workspaceName" render={({ field }) => ( - Workspace Name (Optional) + Workspace Name (optional) ; @@ -60,19 +63,28 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => { const form = useForm({ defaultValues: { username: "", + email: "", workspaceName: "", + apiToken: "", + appPassword: "", }, resolver: zodResolver(Schema), }); const username = form.watch("username"); + const email = form.watch("email"); const workspaceName = form.watch("workspaceName"); + const apiToken = form.watch("apiToken"); + const appPassword = form.watch("appPassword"); useEffect(() => { form.reset({ username: bitbucket?.bitbucketUsername || "", + email: bitbucket?.bitbucketEmail || "", workspaceName: bitbucket?.bitbucketWorkspaceName || "", name: bitbucket?.gitProvider.name || "", + apiToken: bitbucket?.apiToken || "", + appPassword: bitbucket?.appPassword || "", }); }, [form, isOpen, bitbucket]); @@ -81,8 +93,11 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => { bitbucketId, gitProviderId: bitbucket?.gitProviderId || "", bitbucketUsername: data.username, + bitbucketEmail: data.email || "", bitbucketWorkspaceName: data.workspaceName || "", name: data.name || "", + apiToken: data.apiToken || "", + appPassword: data.appPassword || "", }) .then(async () => { await utils.gitProvider.getAll.invalidate(); @@ -121,6 +136,12 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => { >
+

+ Update your Bitbucket authentication. Use API Token for + enhanced security (recommended) or App Password for legacy + support. +

+ { )} /> + ( + + Email (Required for API Tokens) + + + + + + )} + /> + { )} /> +
+

+ Authentication (Update to use API Token) +

+ ( + + API Token (Recommended) + + + + + + )} + /> + + ( + + + App Password (Legacy - will be deprecated June 2026) + + + + + + + )} + /> +
+
-
+
+ {isBitbucket && + gitProvider.bitbucket?.appPassword && + !gitProvider.bitbucket?.apiToken ? ( + Deprecated + ) : null} + {!haveGithubRequirements && isGithub && (
{ export const extractCommitedPaths = async ( body: any, - bitbucketUsername: string | null, - bitbucketAppPassword: string | null, - repository: string | null, + bitbucket: Bitbucket | null, + repository: string, ) => { const changes = body.push?.changes || []; @@ -365,18 +369,16 @@ export const extractCommitedPaths = async ( .filter(Boolean); const commitedPaths: string[] = []; for (const commit of commitHashes) { - const url = `https://api.bitbucket.org/2.0/repositories/${bitbucketUsername}/${repository}/diffstat/${commit}`; + const url = `https://api.bitbucket.org/2.0/repositories/${bitbucket?.bitbucketUsername}/${repository}/diffstat/${commit}`; try { const response = await fetch(url, { - headers: { - Authorization: `Basic ${Buffer.from(`${bitbucketUsername}:${bitbucketAppPassword}`).toString("base64")}`, - }, + headers: getBitbucketHeaders(bitbucket!), }); const data = await response.json(); for (const value of data.values) { - commitedPaths.push(value.new?.path); + if (value?.new?.path) commitedPaths.push(value.new.path); } } catch (error) { console.error( diff --git a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts index b2232e900..61c7f7157 100644 --- a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts @@ -99,8 +99,7 @@ export default async function handler( const commitedPaths = await extractCommitedPaths( req.body, - composeResult.bitbucketOwner, - composeResult.bitbucket?.appPassword || "", + composeResult.bitbucket, composeResult.bitbucketRepository || "", ); diff --git a/packages/server/src/db/schema/bitbucket.ts b/packages/server/src/db/schema/bitbucket.ts index 0311202d7..cc7cbb8ea 100644 --- a/packages/server/src/db/schema/bitbucket.ts +++ b/packages/server/src/db/schema/bitbucket.ts @@ -11,7 +11,9 @@ export const bitbucket = pgTable("bitbucket", { .primaryKey() .$defaultFn(() => nanoid()), bitbucketUsername: text("bitbucketUsername"), + bitbucketEmail: text("bitbucketEmail"), appPassword: text("appPassword"), + apiToken: text("apiToken"), bitbucketWorkspaceName: text("bitbucketWorkspaceName"), gitProviderId: text("gitProviderId") .notNull() @@ -29,7 +31,9 @@ const createSchema = createInsertSchema(bitbucket); export const apiCreateBitbucket = createSchema.extend({ bitbucketUsername: z.string().optional(), + bitbucketEmail: z.string().email().optional(), appPassword: z.string().optional(), + apiToken: z.string().optional(), bitbucketWorkspaceName: z.string().optional(), gitProviderId: z.string().optional(), authId: z.string().min(1), @@ -46,9 +50,19 @@ export const apiBitbucketTestConnection = createSchema .extend({ bitbucketId: z.string().min(1), bitbucketUsername: z.string().optional(), + bitbucketEmail: z.string().email().optional(), workspaceName: z.string().optional(), + apiToken: z.string().optional(), + appPassword: z.string().optional(), }) - .pick({ bitbucketId: true, bitbucketUsername: true, workspaceName: true }); + .pick({ + bitbucketId: true, + bitbucketUsername: true, + bitbucketEmail: true, + workspaceName: true, + apiToken: true, + appPassword: true, + }); export const apiFindBitbucketBranches = z.object({ owner: z.string(), @@ -60,6 +74,9 @@ export const apiUpdateBitbucket = createSchema.extend({ bitbucketId: z.string().min(1), name: z.string().min(1), bitbucketUsername: z.string().optional(), + bitbucketEmail: z.string().email().optional(), + appPassword: z.string().optional(), + apiToken: z.string().optional(), bitbucketWorkspaceName: z.string().optional(), organizationId: z.string().optional(), }); diff --git a/packages/server/src/services/bitbucket.ts b/packages/server/src/services/bitbucket.ts index 387dbdf14..4cd48f957 100644 --- a/packages/server/src/services/bitbucket.ts +++ b/packages/server/src/services/bitbucket.ts @@ -68,10 +68,26 @@ export const updateBitbucket = async ( input: typeof apiUpdateBitbucket._type, ) => { return await db.transaction(async (tx) => { + // First get the current bitbucket provider to get gitProviderId + const currentProvider = await tx.query.bitbucket.findFirst({ + where: eq(bitbucket.bitbucketId, bitbucketId), + }); + + if (!currentProvider) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bitbucket provider not found", + }); + } + const result = await tx .update(bitbucket) .set({ - ...input, + bitbucketUsername: input.bitbucketUsername, + bitbucketEmail: input.bitbucketEmail, + appPassword: input.appPassword, + apiToken: input.apiToken, + bitbucketWorkspaceName: input.bitbucketWorkspaceName, }) .where(eq(bitbucket.bitbucketId, bitbucketId)) .returning(); @@ -83,7 +99,7 @@ export const updateBitbucket = async ( name: input.name, organizationId: input.organizationId, }) - .where(eq(gitProvider.gitProviderId, input.gitProviderId)) + .where(eq(gitProvider.gitProviderId, currentProvider.gitProviderId)) .returning(); } diff --git a/packages/server/src/utils/providers/bitbucket.ts b/packages/server/src/utils/providers/bitbucket.ts index 8f2752a2e..ca76a67fa 100644 --- a/packages/server/src/utils/providers/bitbucket.ts +++ b/packages/server/src/utils/providers/bitbucket.ts @@ -5,7 +5,10 @@ import type { apiBitbucketTestConnection, apiFindBitbucketBranches, } from "@dokploy/server/db/schema"; -import { findBitbucketById } from "@dokploy/server/services/bitbucket"; +import { + type Bitbucket, + findBitbucketById, +} from "@dokploy/server/services/bitbucket"; import type { Compose } from "@dokploy/server/services/compose"; import type { InferResultType } from "@dokploy/server/types/with"; import { TRPCError } from "@trpc/server"; @@ -23,6 +26,39 @@ export type ComposeWithBitbucket = InferResultType< { bitbucket: true } >; +export const getBitbucketCloneUrl = ( + bitbucketProvider: { + apiToken?: string | null; + bitbucketUsername?: string | null; + appPassword?: string | null; + } | null, + repoClone: string, +) => { + if (!bitbucketProvider) { + throw new Error("Bitbucket provider is required"); + } + return bitbucketProvider.apiToken + ? `https://x-token-auth:${bitbucketProvider.apiToken}@${repoClone}` + : `https://${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}@${repoClone}`; +}; + +export const getBitbucketHeaders = (bitbucketProvider: Bitbucket) => { + if (bitbucketProvider.apiToken) { + // For API tokens, use HTTP Basic auth with email and token + // According to Bitbucket docs: email:token for API calls + const email = + bitbucketProvider.bitbucketEmail || bitbucketProvider.bitbucketUsername; + return { + Authorization: `Basic ${Buffer.from(`${email}:${bitbucketProvider.apiToken}`).toString("base64")}`, + }; + } + + // For app passwords, use HTTP Basic auth with username and app password + return { + Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`, + }; +}; + export const cloneBitbucketRepository = async ( entity: ApplicationWithBitbucket | ComposeWithBitbucket, logPath: string, @@ -51,7 +87,7 @@ export const cloneBitbucketRepository = async ( const outputPath = join(basePath, appName, "code"); await recreateDirectory(outputPath); const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`; - const cloneUrl = `https://${bitbucket?.bitbucketUsername}:${bitbucket?.appPassword}@${repoclone}`; + const cloneUrl = getBitbucketCloneUrl(bitbucket, repoclone); try { writeStream.write(`\nCloning Repo ${repoclone} to ${outputPath}: ✅\n`); const cloneArgs = [ @@ -103,7 +139,7 @@ export const cloneRawBitbucketRepository = async (entity: Compose) => { const outputPath = join(basePath, appName, "code"); await recreateDirectory(outputPath); const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`; - const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`; + const cloneUrl = getBitbucketCloneUrl(bitbucketProvider, repoclone); try { const cloneArgs = [ @@ -153,7 +189,7 @@ export const cloneRawBitbucketRepositoryRemote = async (compose: Compose) => { const basePath = COMPOSE_PATH; const outputPath = join(basePath, appName, "code"); const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`; - const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`; + const cloneUrl = getBitbucketCloneUrl(bitbucketProvider, repoclone); try { const cloneCommand = ` @@ -206,7 +242,7 @@ export const getBitbucketCloneCommand = async ( const outputPath = join(basePath, appName, "code"); await recreateDirectory(outputPath); const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`; - const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`; + const cloneUrl = getBitbucketCloneUrl(bitbucketProvider, repoclone); const cloneCommand = ` rm -rf ${outputPath}; @@ -241,9 +277,7 @@ export const getBitbucketRepositories = async (bitbucketId?: string) => { while (url) { const response = await fetch(url, { method: "GET", - headers: { - Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`, - }, + headers: getBitbucketHeaders(bitbucketProvider), }); if (!response.ok) { @@ -279,35 +313,43 @@ export const getBitbucketBranches = async ( } const bitbucketProvider = await findBitbucketById(input.bitbucketId); const { owner, repo } = input; - const url = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/refs/branches?pagelen=100`; + let url = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/refs/branches?pagelen=1`; + let allBranches: { + name: string; + commit: { + sha: string; + }; + }[] = []; try { - const response = await fetch(url, { - method: "GET", - headers: { - Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`, - }, - }); - - if (!response.ok) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `HTTP error! status: ${response.status}`, + while (url) { + const response = await fetch(url, { + method: "GET", + headers: getBitbucketHeaders(bitbucketProvider), }); + + if (!response.ok) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `HTTP error! status: ${response.status}`, + }); + } + + const data = await response.json(); + + const mappedData = data.values.map((branch: any) => { + return { + name: branch.name, + commit: { + sha: branch.target.hash, + }, + }; + }); + allBranches = allBranches.concat(mappedData); + url = data.next || null; } - const data = await response.json(); - - const mappedData = data.values.map((branch: any) => { - return { - name: branch.name, - commit: { - sha: branch.target.hash, - }, - }; - }); - - return mappedData as { + return allBranches as { name: string; commit: { sha: string; @@ -335,9 +377,7 @@ export const testBitbucketConnection = async ( try { const response = await fetch(url, { method: "GET", - headers: { - Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`, - }, + headers: getBitbucketHeaders(bitbucketProvider), }); if (!response.ok) { diff --git a/packages/server/src/utils/providers/gitea.ts b/packages/server/src/utils/providers/gitea.ts index 21265bf37..874e2679e 100644 --- a/packages/server/src/utils/providers/gitea.ts +++ b/packages/server/src/utils/providers/gitea.ts @@ -362,17 +362,22 @@ export const testGiteaConnection = async (input: { giteaId: string }) => { } const baseUrl = provider.giteaUrl.replace(/\/+$/, ""); - const limit = 30; - let allRepos = 0; - let nextUrl = `${baseUrl}/api/v1/repos/search?limit=${limit}`; - while (nextUrl) { - const response = await fetch(nextUrl, { - headers: { - Accept: "application/json", - Authorization: `token ${provider.accessToken}`, + // Use /user/repos to get authenticated user's repositories with pagination + let allRepos = 0; + let page = 1; + const limit = 50; // Max per page + + while (true) { + const response = await fetch( + `${baseUrl}/api/v1/user/repos?page=${page}&limit=${limit}`, + { + headers: { + Accept: "application/json", + Authorization: `token ${provider.accessToken}`, + }, }, - }); + ); if (!response.ok) { throw new Error( @@ -381,22 +386,18 @@ export const testGiteaConnection = async (input: { giteaId: string }) => { } const repos = await response.json(); - allRepos += repos.data.length; - - const linkHeader = response.headers.get("link"); - nextUrl = ""; - - if (linkHeader) { - const nextLink = linkHeader - .split(",") - .find((link) => link.includes('rel="next"')); - if (nextLink) { - const matches = nextLink.match(/<([^>]+)>/); - if (matches?.[1]) { - nextUrl = matches[1]; - } - } + if (!Array.isArray(repos) || repos.length === 0) { + break; // No more repositories } + + allRepos += repos.length; + + // Check if there are more pages + if (repos.length < limit) { + break; // Last page (fewer results than limit) + } + + page++; } await updateGitea(giteaId, { @@ -418,17 +419,22 @@ export const getGiteaRepositories = async (giteaId?: string) => { const giteaProvider = await findGiteaById(giteaId); const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, ""); - const limit = 30; - let allRepositories: any[] = []; - let nextUrl = `${baseUrl}/api/v1/repos/search?limit=${limit}`; - while (nextUrl) { - const response = await fetch(nextUrl, { - headers: { - Accept: "application/json", - Authorization: `token ${giteaProvider.accessToken}`, + // Use /user/repos to get authenticated user's repositories with pagination + let allRepositories: any[] = []; + let page = 1; + const limit = 50; // Max per page + + while (true) { + const response = await fetch( + `${baseUrl}/api/v1/user/repos?page=${page}&limit=${limit}`, + { + headers: { + Accept: "application/json", + Authorization: `token ${giteaProvider.accessToken}`, + }, }, - }); + ); if (!response.ok) { throw new TRPCError({ @@ -437,23 +443,19 @@ export const getGiteaRepositories = async (giteaId?: string) => { }); } - const result = await response.json(); - allRepositories = [...allRepositories, ...result.data]; - - const linkHeader = response.headers.get("link"); - nextUrl = ""; - - if (linkHeader) { - const nextLink = linkHeader - .split(",") - .find((link) => link.includes('rel="next"')); - if (nextLink) { - const matches = nextLink.match(/<([^>]+)>/); - if (matches?.[1]) { - nextUrl = matches[1]; - } - } + const repos = await response.json(); + if (!Array.isArray(repos) || repos.length === 0) { + break; // No more repositories } + + allRepositories = [...allRepositories, ...repos]; + + // Check if there are more pages + if (repos.length < limit) { + break; // Last page (fewer results than limit) + } + + page++; } return ( @@ -482,25 +484,43 @@ export const getGiteaBranches = async (input: { const giteaProvider = await findGiteaById(input.giteaId); const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, ""); - const url = `${baseUrl}/api/v1/repos/${input.owner}/${input.repo}/branches`; - const response = await fetch(url, { - headers: { - Accept: "application/json", - Authorization: `token ${giteaProvider.accessToken}`, - }, - }); + // Handle pagination for branches + let allBranches: any[] = []; + let page = 1; + const limit = 50; // Max per page - if (!response.ok) { - throw new Error(`Failed to fetch branches: ${response.statusText}`); + while (true) { + const response = await fetch( + `${baseUrl}/api/v1/repos/${input.owner}/${input.repo}/branches?page=${page}&limit=${limit}`, + { + headers: { + Accept: "application/json", + Authorization: `token ${giteaProvider.accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch branches: ${response.statusText}`); + } + + const branches = await response.json(); + if (!Array.isArray(branches) || branches.length === 0) { + break; // No more branches + } + + allBranches = [...allBranches, ...branches]; + + // Check if there are more pages + if (branches.length < limit) { + break; // Last page (fewer results than limit) + } + + page++; } - const branches = await response.json(); - - if (!branches) { - return []; - } - return branches?.map((branch: any) => ({ + return allBranches?.map((branch: any) => ({ id: branch.name, name: branch.name, commit: {