Merge branch 'canary' into feat/internal-path-routing

This commit is contained in:
Jhonatan Caldeira
2025-07-05 15:50:04 -03:00
67 changed files with 347 additions and 181 deletions

View File

@@ -1,26 +0,0 @@
# License
## Core License (Apache License 2.0)
Copyright 2024 Mauricio Siu.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
## Additional Terms for Specific Features
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
For further inquiries or permissions, please contact us directly.

View File

@@ -130,7 +130,7 @@ const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
}
try {
return JSON.parse(str);
} catch (_e) {
} catch {
ctx.addIssue({ code: "custom", message: "Invalid JSON format" });
return z.NEVER;
}

View File

@@ -107,7 +107,7 @@ export const ShowImport = ({ composeId }: Props) => {
composeId,
});
setShowModal(false);
} catch (_error) {
} catch {
toast.error("Error importing template");
}
};
@@ -126,7 +126,7 @@ export const ShowImport = ({ composeId }: Props) => {
});
setTemplateInfo(result);
setShowModal(true);
} catch (_error) {
} catch {
toast.error("Error processing template");
}
};

View File

@@ -35,6 +35,9 @@ import { z } from "zod";
const AddPortSchema = z.object({
publishedPort: z.number().int().min(1).max(65535),
publishMode: z.enum(["ingress", "host"], {
required_error: "Publish mode is required",
}),
targetPort: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"], {
required_error: "Protocol is required",
@@ -80,6 +83,7 @@ export const HandlePorts = ({
useEffect(() => {
form.reset({
publishedPort: data?.publishedPort ?? 0,
publishMode: data?.publishMode ?? "ingress",
targetPort: data?.targetPort ?? 0,
protocol: data?.protocol ?? "tcp",
});
@@ -165,6 +169,32 @@ export const HandlePorts = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="publishMode"
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>Published Port Mode</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a publish mode for the port" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"ingress"}>Ingress</SelectItem>
<SelectItem value={"host"}>Host</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="targetPort"

View File

@@ -60,7 +60,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
{data?.ports.map((port) => (
<div key={port.portId}>
<div className="flex w-full flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-10 border rounded-lg p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 flex-col gap-4 sm:gap-8">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 flex-col gap-4 sm:gap-8">
<div className="flex flex-col gap-1">
<span className="font-medium">Published Port</span>
<span className="text-sm text-muted-foreground">
@@ -68,7 +68,13 @@ export const ShowPorts = ({ applicationId }: Props) => {
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium"> Target Port</span>
<span className="font-medium">Published Port Mode</span>
<span className="text-sm text-muted-foreground">
{port?.publishMode?.toUpperCase()}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Target Port</span>
<span className="text-sm text-muted-foreground">
{port.targetPort}
</span>

View File

@@ -77,7 +77,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
composeId,
});
})
.catch((_e) => {
.catch(() => {
toast.error("Error updating the Compose config");
});
};

View File

@@ -40,7 +40,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
.then(() => {
refetch();
})
.catch((_err) => {});
.catch(() => {});
}
}, [isOpen]);

View File

@@ -103,7 +103,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
projectId,
});
})
.catch((_e) => {
.catch(() => {
toast.error("Error creating the service");
});
};

View File

@@ -1063,7 +1063,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
});
}
toast.success("Connection Success");
} catch (_err) {
} catch {
toast.error("Error testing the provider");
}
}}

View File

@@ -63,7 +63,7 @@ export const Disable2FA = () => {
toast.success("2FA disabled successfully");
utils.user.get.invalidate();
setIsOpen(false);
} catch (_error) {
} catch {
form.setError("password", {
message: "Connection error. Please try again.",
});

View File

@@ -36,7 +36,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
await refetch();
}
toast.success("Docker Cleanup updated");
} catch (_error) {
} catch {
toast.error("Docker Cleanup Error");
}
};

View File

@@ -56,7 +56,7 @@ export function GPUSupport({ serverId }: GPUSupportProps) {
try {
await utils.settings.checkGPUStatus.invalidate({ serverId });
await refetch();
} catch (_error) {
} catch {
toast.error("Failed to refresh GPU status");
} finally {
setIsRefreshing(false);
@@ -74,7 +74,7 @@ export function GPUSupport({ serverId }: GPUSupportProps) {
try {
await setupGPU.mutateAsync({ serverId });
} catch (_error) {
} catch {
// Error handling is done in mutation's onError
}
};

View File

@@ -146,7 +146,7 @@ export const ShowInvitations = () => {
{invitation.status === "pending" && (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(_e) => {
onSelect={() => {
copy(
`${origin}/invitation?token=${invitation.id}`,
);
@@ -162,7 +162,7 @@ export const ShowInvitations = () => {
{invitation.status === "pending" && (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={async (_e) => {
onSelect={async () => {
const result =
await authClient.organization.cancelInvitation(
{
@@ -189,7 +189,7 @@ export const ShowInvitations = () => {
)}
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={async (_e) => {
onSelect={async () => {
await removeInvitation({
invitationId: invitation.id,
}).then(() => {

View File

@@ -91,7 +91,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
});
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
setOpen(false);
} catch (_error) {}
} catch {}
};
return (

View File

@@ -17,7 +17,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 w-full rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}

View File

@@ -0,0 +1,2 @@
CREATE TYPE "public"."publishModeType" AS ENUM('ingress', 'host');--> statement-breakpoint
ALTER TABLE "port" ADD COLUMN "publishMode" "publishModeType" DEFAULT 'host' NOT NULL;

View File

@@ -1,5 +1,5 @@
{
"id": "4d928914-5268-4921-aa21-c1b087cc15cc",
"id": "71f68c87-ddb4-4e8c-b9fc-1db7fbcedf56",
"prevId": "edde8c54-b715-4db6-bc3a-85d435226083",
"version": "7",
"dialect": "postgresql",
@@ -1209,20 +1209,6 @@
"primaryKey": false,
"notNull": true,
"default": "'none'"
},
"internalPath": {
"name": "internalPath",
"type": "text",
"primaryKey": false,
"notNull": false,
"default": "'/'"
},
"stripPath": {
"name": "stripPath",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
@@ -2847,6 +2833,14 @@
"primaryKey": false,
"notNull": true
},
"publishMode": {
"name": "publishMode",
"type": "publishModeType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'host'"
},
"targetPort": {
"name": "targetPort",
"type": "integer",
@@ -5730,6 +5724,14 @@
"udp"
]
},
"public.publishModeType": {
"name": "publishModeType",
"schema": "public",
"values": [
"ingress",
"host"
]
},
"public.applicationStatus": {
"name": "applicationStatus",
"schema": "public",

View File

@@ -698,8 +698,8 @@
{
"idx": 99,
"version": "7",
"when": 1751293280505,
"tag": "0099_fast_the_order",
"when": 1751693569786,
"tag": "0099_wise_golden_guardian",
"breakpoints": true
}
]

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.23.6",
"version": "v0.23.7",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

@@ -72,7 +72,7 @@ export async function getServerSideProps(
trpcState: helpers.dehydrate(),
},
};
} catch (_error) {
} catch {
return {
props: {},
};

View File

@@ -390,7 +390,7 @@ const Project = (
break;
}
success++;
} catch (_error) {
} catch {
toast.error(`Error starting service ${serviceId}`);
}
}
@@ -437,7 +437,7 @@ const Project = (
break;
}
success++;
} catch (_error) {
} catch {
toast.error(`Error stopping service ${serviceId}`);
}
}
@@ -1107,7 +1107,7 @@ export async function getServerSideProps(
projectId: params?.projectId,
},
};
} catch (_error) {
} catch {
return {
redirect: {
permanent: false,

View File

@@ -413,7 +413,7 @@ export async function getServerSideProps(
activeTab: (activeTab || "general") as TabState,
},
};
} catch (_error) {
} catch {
return {
redirect: {
permanent: false,

View File

@@ -409,7 +409,7 @@ export async function getServerSideProps(
activeTab: (activeTab || "general") as TabState,
},
};
} catch (_error) {
} catch {
return {
redirect: {
permanent: false,

View File

@@ -338,7 +338,7 @@ export async function getServerSideProps(
activeTab: (activeTab || "general") as TabState,
},
};
} catch (_error) {
} catch {
return {
redirect: {
permanent: false,

View File

@@ -340,7 +340,7 @@ export async function getServerSideProps(
activeTab: (activeTab || "general") as TabState,
},
};
} catch (_error) {
} catch {
return {
redirect: {
permanent: false,

View File

@@ -324,7 +324,7 @@ export async function getServerSideProps(
activeTab: (activeTab || "general") as TabState,
},
};
} catch (_error) {
} catch {
return {
redirect: {
permanent: false,

View File

@@ -321,7 +321,7 @@ export async function getServerSideProps(
activeTab: (activeTab || "general") as TabState,
},
};
} catch (_error) {
} catch {
return {
redirect: {
permanent: false,

View File

@@ -328,7 +328,7 @@ export async function getServerSideProps(
activeTab: (activeTab || "general") as TabState,
},
};
} catch (_error) {
} catch {
return {
redirect: {
permanent: false,

View File

@@ -68,7 +68,7 @@ export async function getServerSideProps(
trpcState: helpers.dehydrate(),
},
};
} catch (_error) {
} catch {
return {
props: {},
};

View File

@@ -69,7 +69,7 @@ export async function getServerSideProps(
trpcState: helpers.dehydrate(),
},
};
} catch (_error) {
} catch {
return {
props: {},
};

View File

@@ -72,7 +72,7 @@ export async function getServerSideProps(
trpcState: helpers.dehydrate(),
},
};
} catch (_error) {
} catch {
return {
props: {},
};

View File

@@ -72,7 +72,7 @@ export async function getServerSideProps(
trpcState: helpers.dehydrate(),
},
};
} catch (_error) {
} catch {
return {
props: {},
};

View File

@@ -96,7 +96,7 @@ export default function Home({ IS_CLOUD }: Props) {
toast.success("Logged in successfully");
router.push("/dashboard/projects");
} catch (_error) {
} catch {
toast.error("An error occurred while logging in");
} finally {
setIsLoginLoading(false);
@@ -124,7 +124,7 @@ export default function Home({ IS_CLOUD }: Props) {
toast.success("Logged in successfully");
router.push("/dashboard/projects");
} catch (_error) {
} catch {
toast.error("An error occurred while verifying 2FA code");
} finally {
setIsTwoFactorLoading(false);
@@ -154,7 +154,7 @@ export default function Home({ IS_CLOUD }: Props) {
toast.success("Logged in successfully");
router.push("/dashboard/projects");
} catch (_error) {
} catch {
toast.error("An error occurred while verifying backup code");
} finally {
setIsBackupCodeLoading(false);
@@ -478,7 +478,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
};
}
} catch (_error) {}
} catch {}
return {
props: {

View File

@@ -133,7 +133,7 @@ const Invitation = ({
toast.success("Account created successfully");
router.push("/dashboard/projects");
} catch (_error) {
} catch {
toast.error("An error occurred while creating your account");
}
};

View File

@@ -1,5 +1,6 @@
import {
containerRestart,
findServerById,
getConfig,
getContainers,
getContainersByAppLabel,
@@ -9,6 +10,9 @@ import {
} from "@dokploy/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { TRPCError } from "@trpc/server";
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
export const dockerRouter = createTRPCRouter({
getContainers: protectedProcedure
@@ -17,14 +21,23 @@ export const dockerRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return await getContainers(input.serverId);
}),
restartContainer: protectedProcedure
.input(
z.object({
containerId: z.string().min(1),
containerId: z
.string()
.min(1)
.regex(containerIdRegex, "Invalid container id."),
}),
)
.mutation(async ({ input }) => {
@@ -34,11 +47,20 @@ export const dockerRouter = createTRPCRouter({
getConfig: protectedProcedure
.input(
z.object({
containerId: z.string().min(1),
containerId: z
.string()
.min(1)
.regex(containerIdRegex, "Invalid container id."),
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return await getConfig(input.containerId, input.serverId);
}),
@@ -48,11 +70,17 @@ export const dockerRouter = createTRPCRouter({
appType: z
.union([z.literal("stack"), z.literal("docker-compose")])
.optional(),
appName: z.string().min(1),
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return await getContainersByAppNameMatch(
input.appName,
input.appType,
@@ -63,12 +91,18 @@ export const dockerRouter = createTRPCRouter({
getContainersByAppLabel: protectedProcedure
.input(
z.object({
appName: z.string().min(1),
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
serverId: z.string().optional(),
type: z.enum(["standalone", "swarm"]),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return await getContainersByAppLabel(
input.appName,
input.type,
@@ -79,22 +113,34 @@ export const dockerRouter = createTRPCRouter({
getStackContainersByAppName: protectedProcedure
.input(
z.object({
appName: z.string().min(1),
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return await getStackContainersByAppName(input.appName, input.serverId);
}),
getServiceContainersByAppName: protectedProcedure
.input(
z.object({
appName: z.string().min(1),
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return await getServiceContainersByAppName(input.appName, input.serverId);
}),
});

View File

@@ -459,6 +459,15 @@ export const settingsRouter = createTRPCRouter({
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return readConfigInPath(input.path, input.serverId);
}),
getIp: protectedProcedure.query(async ({ ctx }) => {

View File

@@ -6,6 +6,9 @@ import {
} from "@dokploy/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { TRPCError } from "@trpc/server";
import { findServerById } from "@dokploy/server";
import { containerIdRegex } from "./docker";
export const swarmRouter = createTRPCRouter({
getNodes: protectedProcedure
@@ -14,12 +17,24 @@ export const swarmRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return await getSwarmNodes(input.serverId);
}),
getNodeInfo: protectedProcedure
.input(z.object({ nodeId: z.string(), serverId: z.string().optional() }))
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return await getNodeInfo(input.nodeId, input.serverId);
}),
getNodeApps: protectedProcedure
@@ -28,17 +43,29 @@ export const swarmRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return getNodeApplications(input.serverId);
}),
getAppInfos: protectedProcedure
.input(
z.object({
appName: z.string(),
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return await getApplicationInfo(input.appName, input.serverId);
}),
});

View File

@@ -75,6 +75,24 @@ export const userRouter = createTRPCRouter({
},
});
// If user not found in the organization, deny access
if (!memberResult) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found in this organization",
});
}
// Allow access if:
// 1. User is requesting their own information
// 2. User has owner role (admin permissions) AND user is in the same organization
if (memberResult.userId !== ctx.user.id && ctx.user.role !== "owner") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this user",
});
}
return memberResult;
}),
get: protectedProcedure.query(async ({ ctx }) => {

View File

@@ -6,7 +6,7 @@ export const isWSL = async () => {
const { stdout } = await execAsync("uname -r");
const isWSL = stdout.includes("microsoft");
return isWSL;
} catch (_error) {
} catch {
return false;
}
};

View File

@@ -101,7 +101,7 @@ export const setupDeploymentLogsWebSocketServer = (
ws.close();
});
}
} catch (_error) {
} catch {
// @ts-ignore
// const errorMessage = error?.message as unknown as string;
// ws.send(errorMessage);

View File

@@ -12,6 +12,7 @@ import {
initializeNetwork,
initializeSwarm,
} from "@dokploy/server/setup/setup";
import { execAsync } from "@dokploy/server";
(async () => {
try {
setupDirectories();
@@ -20,6 +21,7 @@ import {
await initializeNetwork();
createDefaultTraefikConfig();
createDefaultServerTraefikConfig();
await execAsync("docker pull traefik:v3.1.2");
await initializeTraefik();
await initializeRedis();
await initializePostgres();

View File

@@ -242,3 +242,8 @@
background-color: var(--terminal-paste) !important;
color: currentColor !important;
}
.cm-content,
.cm-lineWrapping {
@apply font-mono;
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright 2025 Mauricio Siu.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.