API: Add endpoint to check custom domain resolution (#6110)
This commit is contained in:
@@ -34,3 +34,11 @@ export const UrlsUnfurlSchema = BaseSchema.extend({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type UrlsUnfurlReq = z.infer<typeof UrlsUnfurlSchema>;
|
export type UrlsUnfurlReq = z.infer<typeof UrlsUnfurlSchema>;
|
||||||
|
|
||||||
|
export const UrlsCheckCnameSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
|
hostname: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UrlsCheckCnameReq = z.infer<typeof UrlsCheckCnameSchema>;
|
||||||
|
|||||||
@@ -4,6 +4,19 @@ import { buildDocument, buildUser } from "@server/test/factories";
|
|||||||
import { getTestServer } from "@server/test/support";
|
import { getTestServer } from "@server/test/support";
|
||||||
import resolvers from "@server/utils/unfurl";
|
import resolvers from "@server/utils/unfurl";
|
||||||
|
|
||||||
|
jest.mock("dns", () => ({
|
||||||
|
resolveCname: (
|
||||||
|
input: string,
|
||||||
|
callback: (err: Error | null, addresses: string[]) => void
|
||||||
|
) => {
|
||||||
|
if (input.includes("valid.custom.domain")) {
|
||||||
|
callback(null, ["secure.outline.dev"]);
|
||||||
|
} else {
|
||||||
|
callback(null, []);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
jest
|
jest
|
||||||
.spyOn(resolvers.Iframely, "unfurl")
|
.spyOn(resolvers.Iframely, "unfurl")
|
||||||
.mockImplementation(async (_: string) => false);
|
.mockImplementation(async (_: string) => false);
|
||||||
@@ -204,3 +217,27 @@ describe("#urls.unfurl", () => {
|
|||||||
expect(res.status).toEqual(204);
|
expect(res.status).toEqual(204);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("#urls.validateCustomDomain", () => {
|
||||||
|
it("should succeed with custom domain pointing at server", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const res = await server.post("/api/urls.validateCustomDomain", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
hostname: "valid.custom.domain",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with another domain", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const res = await server.post("/api/urls.validateCustomDomain", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
hostname: "google.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import dns from "dns";
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
import { parseDomain } from "@shared/utils/domains";
|
import { getBaseDomain, parseDomain } from "@shared/utils/domains";
|
||||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||||
import parseMentionUrl from "@shared/utils/parseMentionUrl";
|
import parseMentionUrl from "@shared/utils/parseMentionUrl";
|
||||||
import { isInternalUrl } from "@shared/utils/urls";
|
import { isInternalUrl } from "@shared/utils/urls";
|
||||||
@@ -7,7 +8,7 @@ import { NotFoundError, ValidationError } from "@server/errors";
|
|||||||
import auth from "@server/middlewares/authentication";
|
import auth from "@server/middlewares/authentication";
|
||||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||||
import validate from "@server/middlewares/validate";
|
import validate from "@server/middlewares/validate";
|
||||||
import { Document, User } from "@server/models";
|
import { Document, Share, Team, User } from "@server/models";
|
||||||
import { authorize } from "@server/policies";
|
import { authorize } from "@server/policies";
|
||||||
import { presentDocument, presentMention } from "@server/presenters/unfurls";
|
import { presentDocument, presentMention } from "@server/presenters/unfurls";
|
||||||
import presentUnfurl from "@server/presenters/unfurls/unfurl";
|
import presentUnfurl from "@server/presenters/unfurls/unfurl";
|
||||||
@@ -84,4 +85,63 @@ router.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"urls.validateCustomDomain",
|
||||||
|
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
|
||||||
|
auth(),
|
||||||
|
validate(T.UrlsCheckCnameSchema),
|
||||||
|
async (ctx: APIContext<T.UrlsCheckCnameReq>) => {
|
||||||
|
const { hostname } = ctx.input.body;
|
||||||
|
|
||||||
|
const [team, share] = await Promise.all([
|
||||||
|
Team.findOne({
|
||||||
|
where: {
|
||||||
|
domain: hostname,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Share.findOne({
|
||||||
|
where: {
|
||||||
|
domain: hostname,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (team || share) {
|
||||||
|
throw ValidationError("Domain is already in use");
|
||||||
|
}
|
||||||
|
|
||||||
|
let addresses;
|
||||||
|
try {
|
||||||
|
addresses = await new Promise<string[]>((resolve, reject) => {
|
||||||
|
dns.resolveCname(hostname, (err, addresses) => {
|
||||||
|
if (err) {
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
return resolve(addresses);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "ENOTFOUND") {
|
||||||
|
throw NotFoundError("No CNAME record found");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ValidationError("Invalid domain");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addresses.length === 0) {
|
||||||
|
throw ValidationError("No CNAME record found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = addresses[0];
|
||||||
|
const likelyValid = address.endsWith(getBaseDomain());
|
||||||
|
|
||||||
|
if (!likelyValid) {
|
||||||
|
throw ValidationError("CNAME is not configured correctly");
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -573,6 +573,7 @@
|
|||||||
"Custom link": "Custom link",
|
"Custom link": "Custom link",
|
||||||
"The document will be accessible at <2>{{url}}</2>": "The document will be accessible at <2>{{url}}</2>",
|
"The document will be accessible at <2>{{url}}</2>": "The document will be accessible at <2>{{url}}</2>",
|
||||||
"More options": "More options",
|
"More options": "More options",
|
||||||
|
"Custom domain": "Custom domain",
|
||||||
"Close": "Close",
|
"Close": "Close",
|
||||||
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
|
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
|
||||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
|
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
|
||||||
|
|||||||
Reference in New Issue
Block a user