Merge remote-tracking branch 'refs/remotes/origin/canary' into feature/custom-entrypoint

# Conflicts:
#	apps/dokploy/drizzle/meta/0130_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
This commit is contained in:
mkarpats
2025-12-09 12:00:58 +02:00
14 changed files with 7158 additions and 25 deletions

View File

@@ -60,6 +60,30 @@ export const commonCronExpressions = [
{ label: "Custom", value: "custom" },
];
export const commonTimezones = [
{ label: "UTC (Coordinated Universal Time)", value: "UTC" },
{ label: "America/New_York (Eastern Time)", value: "America/New_York" },
{ label: "America/Chicago (Central Time)", value: "America/Chicago" },
{ label: "America/Denver (Mountain Time)", value: "America/Denver" },
{ label: "America/Los_Angeles (Pacific Time)", value: "America/Los_Angeles" },
{
label: "America/Mexico_City (Central Mexico)",
value: "America/Mexico_City",
},
{ label: "America/Sao_Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
{ label: "Europe/London (Greenwich Mean Time)", value: "Europe/London" },
{ label: "Europe/Paris (Central European Time)", value: "Europe/Paris" },
{ label: "Europe/Berlin (Central European Time)", value: "Europe/Berlin" },
{ label: "Asia/Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
{ label: "Asia/Shanghai (China Standard Time)", value: "Asia/Shanghai" },
{ label: "Asia/Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
{ label: "Asia/Kolkata (India Standard Time)", value: "Asia/Kolkata" },
{
label: "Australia/Sydney (Australian Eastern Time)",
value: "Australia/Sydney",
},
];
const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
@@ -75,6 +99,7 @@ const formSchema = z
"dokploy-server",
]),
script: z.string(),
timezone: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.scheduleType === "compose" && !data.serviceName) {
@@ -213,6 +238,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
serviceName: "",
scheduleType: scheduleType || "application",
script: "",
timezone: undefined,
},
});
@@ -251,6 +277,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
serviceName: schedule.serviceName || "",
scheduleType: schedule.scheduleType,
script: schedule.script || "",
timezone: schedule.timezone || undefined,
});
}
}, [form, schedule, scheduleId]);
@@ -464,6 +491,54 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
formControl={form.control}
/>
<FormField
control={form.control}
name="timezone"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Timezone
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Select a timezone for the schedule. If not
specified, UTC will be used.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="UTC (default)" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonTimezones.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Optional: Choose a timezone for the schedule execution time
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{(scheduleTypeForm === "application" ||
scheduleTypeForm === "compose") && (
<>

View File

@@ -0,0 +1 @@
ALTER TABLE "schedule" ADD COLUMN "timezone" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "domain" ADD COLUMN "customEntrypoint" text;

View File

@@ -1,5 +1,5 @@
{
"id": "0cf5b59d-2d0f-4f7e-88fd-726d739a1099",
"id": "cff546ae-01ea-40d4-b32d-0512e05c3856",
"prevId": "202e5dc1-5107-44cc-8c79-3f98a2fce763",
"version": "7",
"dialect": "postgresql",
@@ -2722,12 +2722,6 @@
"notNull": false,
"default": 3000
},
"customEntrypoint": {
"name": "customEntrypoint",
"type": "text",
"primaryKey": false,
"notNull": false
},
"path": {
"name": "path",
"type": "text",
@@ -5771,6 +5765,12 @@
"notNull": true,
"default": true
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": false
},
"createdAt": {
"name": "createdAt",
"type": "text",

File diff suppressed because it is too large Load Diff

View File

@@ -915,8 +915,15 @@
{
"idx": 130,
"version": "7",
"when": 1765161998200,
"tag": "0130_abandoned_dagger",
"when": 1765167657813,
"tag": "0130_perpetual_screwball",
"breakpoints": true
},
{
"idx": 131,
"version": "7",
"when": 1765274846213,
"tag": "0131_sharp_ozymandias",
"breakpoints": true
}
]

View File

@@ -32,8 +32,6 @@ export const organizationRouter = createTRPCRouter({
.returning()
.then((res) => res[0]);
console.log("result", result);
if (!result) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
@@ -82,7 +80,22 @@ export const organizationRouter = createTRPCRouter({
organizationId: z.string(),
}),
)
.query(async ({ input }) => {
.query(async ({ ctx, input }) => {
// Verify user is a member of this organization
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
return await db.query.organization.findFirst({
where: eq(organization.id, input.organizationId),
});
@@ -96,12 +109,45 @@ export const organizationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) {
// First, verify the organization exists
const org = await db.query.organization.findFirst({
where: eq(organization.id, input.organizationId),
});
if (!org) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
// Verify user is a member of this organization
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
// Only owners can update the organization
// Verify the user is either the organization owner or has the owner role
const isOwner =
org.ownerId === ctx.user.id || userMember.role === "owner";
if (!isOwner) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can update it",
});
}
const result = await db
.update(organization)
.set({
@@ -119,12 +165,7 @@ export const organizationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can delete it",
});
}
// First, verify the organization exists
const org = await db.query.organization.findFirst({
where: eq(organization.id, input.organizationId),
});
@@ -136,7 +177,27 @@ export const organizationRouter = createTRPCRouter({
});
}
if (org.ownerId !== ctx.user.id) {
// Verify user is a member of this organization
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
// Only owners can delete the organization
// Verify the user is either the organization owner or has the owner role
const isOwner =
org.ownerId === ctx.user.id || userMember.role === "owner";
if (!isOwner) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can delete it",

View File

@@ -30,6 +30,7 @@ export const scheduleRouter = createTRPCRouter({
scheduleId: newSchedule.scheduleId,
type: "schedule",
cronSchedule: newSchedule.cronExpression,
timezone: newSchedule.timezone,
});
} else {
scheduleJob(newSchedule);
@@ -49,6 +50,7 @@ export const scheduleRouter = createTRPCRouter({
scheduleId: updatedSchedule.scheduleId,
type: "schedule",
cronSchedule: updatedSchedule.cronExpression,
timezone: updatedSchedule.timezone,
});
} else {
await removeJob({

View File

@@ -430,6 +430,23 @@ export const userRouter = createTRPCRouter({
createApiKey: protectedProcedure
.input(apiCreateApiKey)
.mutation(async ({ input, ctx }) => {
// Verify user is a member of the organization specified in metadata
if (input.metadata?.organizationId) {
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.metadata.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
}
const apiKey = await createApiKey(ctx.user.id, input);
return apiKey;
}),
@@ -440,7 +457,35 @@ export const userRouter = createTRPCRouter({
userId: z.string(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
// Users can check their own organizations
// Admins and owners can check organizations of members in their active organization
if (input.userId !== ctx.user.id) {
// Verify the target user is a member of the active organization
const targetMember = await db.query.member.findFirst({
where: and(
eq(member.userId, input.userId),
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
});
if (!targetMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User is not a member of your active organization",
});
}
// Only admins and owners can check other users' organizations
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only admins and owners can check other users' organizations",
});
}
}
const organizations = await db.query.member.findMany({
where: eq(member.userId, input.userId),
});

View File

@@ -19,6 +19,7 @@ type QueueJob =
type: "schedule";
cronSchedule: string;
scheduleId: string;
timezone?: string | null;
}
| {
type: "volume-backup";

View File

@@ -40,6 +40,7 @@ export const scheduleJob = (job: QueueJob) => {
jobQueue.add(job.scheduleId, job, {
repeat: {
pattern: job.cronSchedule,
tz: job.timezone || "UTC",
},
});
} else if (job.type === "volume-backup") {

View File

@@ -15,6 +15,7 @@ export const jobQueueSchema = z.discriminatedUnion("type", [
cronSchedule: z.string(),
type: z.literal("schedule"),
scheduleId: z.string(),
timezone: z.string().optional(),
}),
z.object({
cronSchedule: z.string(),

View File

@@ -49,6 +49,7 @@ export const schedules = pgTable("schedule", {
onDelete: "cascade",
}),
enabled: boolean("enabled").notNull().default(true),
timezone: text("timezone"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),

View File

@@ -14,11 +14,21 @@ import { execAsyncRemote } from "../process/execAsync";
import { spawnAsync } from "../process/spawnAsync";
export const scheduleJob = (schedule: Schedule) => {
const { cronExpression, scheduleId } = schedule;
const { cronExpression, scheduleId, timezone } = schedule;
scheduleJobNode(scheduleId, cronExpression, async () => {
await runCommand(scheduleId);
});
// Use timezone from schedule, default to UTC if not specified
const tz = timezone || "UTC";
scheduleJobNode(
scheduleId,
{
tz,
rule: cronExpression,
},
async () => {
await runCommand(scheduleId);
},
);
};
export const removeScheduleJob = (scheduleId: string) => {