From e04e25385da14d9a823a102dc904eddc393dd62f Mon Sep 17 00:00:00 2001 From: Vlad Vladov Date: Fri, 5 Sep 2025 02:52:47 +0300 Subject: [PATCH] feat(bitbucket): Deprecate App password and replace it with API token --- .../git/bitbucket/add-bitbucket-provider.tsx | 74 ++++++++----------- .../git/bitbucket/edit-bitbucket-provider.tsx | 5 ++ .../settings/git/show-git-providers.tsx | 27 ++++++- .../pages/api/deploy/[refreshToken].ts | 22 ++++-- .../api/deploy/compose/[refreshToken].ts | 5 +- packages/server/src/db/schema/bitbucket.ts | 2 + packages/server/src/services/bitbucket.ts | 9 ++- .../server/src/utils/providers/bitbucket.ts | 48 ++++++++---- 8 files changed, 126 insertions(+), 66 deletions(-) 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..ba8101694 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 @@ -30,15 +30,9 @@ 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" }), + apiToken: z.string().min(1, { message: "API Token is required" }), workspaceName: z.string().optional(), }); @@ -47,14 +41,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 +55,7 @@ export const AddBitbucketProvider = () => { useEffect(() => { form.reset({ username: "", - password: "", + apiToken: "", workspaceName: "", }); }, [form, isOpen]); @@ -71,7 +63,7 @@ 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 || "", @@ -113,24 +105,28 @@ 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 + + +
+

- To integrate your Bitbucket account, you need to create a new - App Password in your Bitbucket settings. Follow these steps: + Make sure to create an API Token with the following permissions:

-
    -
  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
      • @@ -138,12 +134,7 @@ export const AddBitbucketProvider = () => {
      • Pull requests: Read
      • Webhooks: Read and write
      - -
    • - After creating, you'll receive an App Password. Copy it and - paste it below along with your Bitbucket username. -
    • -
+ { Name @@ -179,14 +170,13 @@ export const AddBitbucketProvider = () => { ( - App Password + API Token @@ -200,7 +190,7 @@ export const AddBitbucketProvider = () => { name="workspaceName" render={({ field }) => ( - Workspace Name (Optional) + Workspace Name (optional) { >
+

+ For security, credentials (API Token/App Password) can’t be + edited. To change them, create a new Bitbucket provider. +

+ {
-
+
+ {isBitbucket && + gitProvider.bitbucket?.appPassword && + !gitProvider.bitbucket?.apiToken ? ( + + + + + Deprecated + + + + App Passwords are deprecated in + Bitbucket. Add an API Token to keep + using this provider. + + + + ) : null} + {!haveGithubRequirements && isGithub && (
{ export const extractCommitedPaths = async ( body: any, bitbucketUsername: string | null, - bitbucketAppPassword: string | null, + credential: string | null, repository: string | null, + useApiToken: boolean, ) => { const changes = body.push?.changes || []; @@ -368,15 +373,16 @@ export const extractCommitedPaths = async ( const url = `https://api.bitbucket.org/2.0/repositories/${bitbucketUsername}/${repository}/diffstat/${commit}`; try { - const response = await fetch(url, { - headers: { - Authorization: `Basic ${Buffer.from(`${bitbucketUsername}:${bitbucketAppPassword}`).toString("base64")}`, - }, - }); + const headers = useApiToken + ? { Authorization: `Bearer ${credential}` } + : { + Authorization: `Basic ${Buffer.from(`${bitbucketUsername}:${credential}`).toString("base64")}`, + }; + const response = await fetch(url, { headers }); 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..fc05be5e1 100644 --- a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts @@ -100,8 +100,11 @@ export default async function handler( const commitedPaths = await extractCommitedPaths( req.body, composeResult.bitbucketOwner, - composeResult.bitbucket?.appPassword || "", + composeResult.bitbucket?.apiToken || + composeResult.bitbucket?.appPassword || + "", composeResult.bitbucketRepository || "", + !!composeResult.bitbucket?.apiToken, ); const shouldDeployPaths = shouldDeploy( diff --git a/packages/server/src/db/schema/bitbucket.ts b/packages/server/src/db/schema/bitbucket.ts index 0311202d7..79e16f6b6 100644 --- a/packages/server/src/db/schema/bitbucket.ts +++ b/packages/server/src/db/schema/bitbucket.ts @@ -12,6 +12,7 @@ export const bitbucket = pgTable("bitbucket", { .$defaultFn(() => nanoid()), bitbucketUsername: text("bitbucketUsername"), appPassword: text("appPassword"), + apiToken: text("apiToken"), bitbucketWorkspaceName: text("bitbucketWorkspaceName"), gitProviderId: text("gitProviderId") .notNull() @@ -30,6 +31,7 @@ const createSchema = createInsertSchema(bitbucket); export const apiCreateBitbucket = createSchema.extend({ bitbucketUsername: z.string().optional(), appPassword: z.string().optional(), + apiToken: z.string().optional(), bitbucketWorkspaceName: z.string().optional(), gitProviderId: z.string().optional(), authId: z.string().min(1), diff --git a/packages/server/src/services/bitbucket.ts b/packages/server/src/services/bitbucket.ts index 387dbdf14..565111340 100644 --- a/packages/server/src/services/bitbucket.ts +++ b/packages/server/src/services/bitbucket.ts @@ -68,10 +68,17 @@ export const updateBitbucket = async ( input: typeof apiUpdateBitbucket._type, ) => { return await db.transaction(async (tx) => { + // Explicitly omit credentials from updates for safety/back-compat + const { + apiToken: _ignoredApiToken, + appPassword: _ignoredAppPassword, + ...safeInput + } = input as any; + const result = await tx .update(bitbucket) .set({ - ...input, + ...safeInput, }) .where(eq(bitbucket.bitbucketId, bitbucketId)) .returning(); diff --git a/packages/server/src/utils/providers/bitbucket.ts b/packages/server/src/utils/providers/bitbucket.ts index 8f2752a2e..7a18e7889 100644 --- a/packages/server/src/utils/providers/bitbucket.ts +++ b/packages/server/src/utils/providers/bitbucket.ts @@ -23,6 +23,22 @@ 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 cloneBitbucketRepository = async ( entity: ApplicationWithBitbucket | ComposeWithBitbucket, logPath: string, @@ -51,7 +67,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 +119,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 +169,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 +222,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 +257,11 @@ 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: bitbucketProvider.apiToken + ? { Authorization: `Bearer ${bitbucketProvider.apiToken}` } + : { + Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`, + }, }); if (!response.ok) { @@ -284,9 +302,11 @@ export const getBitbucketBranches = async ( try { const response = await fetch(url, { method: "GET", - headers: { - Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`, - }, + headers: bitbucketProvider.apiToken + ? { Authorization: `Bearer ${bitbucketProvider.apiToken}` } + : { + Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`, + }, }); if (!response.ok) { @@ -335,9 +355,11 @@ export const testBitbucketConnection = async ( try { const response = await fetch(url, { method: "GET", - headers: { - Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`, - }, + headers: bitbucketProvider.apiToken + ? { Authorization: `Bearer ${bitbucketProvider.apiToken}` } + : { + Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`, + }, }); if (!response.ok) {