feat: Redirect to previous subdomains (#8477)

* Migration

* Store previous subdomains

* Redirect previous subdomains at service layer

* refactor

* refactor

* change index

* Guard logic to hosted only
This commit is contained in:
Tom Moor
2025-02-18 19:53:18 -05:00
committed by GitHub
parent b9c6f9c9e6
commit 1749ffe20d
6 changed files with 146 additions and 22 deletions

View File

@@ -0,0 +1,27 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn("teams", "previousSubdomains", {
type: Sequelize.ARRAY(Sequelize.STRING),
allowNull: true,
}, { transaction });
await queryInterface.sequelize.query(
`CREATE INDEX teams_previous_subdomains ON teams USING GIN ("previousSubdomains");`,
{ transaction }
);
});
},
async down(queryInterface) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.sequelize.query(
`DROP INDEX teams_previous_subdomains;`,
{ transaction }
);
await queryInterface.removeColumn("teams", "previousSubdomains", { transaction });
});
},
};

View File

@@ -1,20 +1,43 @@
import { buildTeam, buildCollection } from "@server/test/factories";
describe("collectionIds", () => {
it("should return non-private collection ids", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
describe("Team", () => {
describe("collectionIds", () => {
it("should return non-private collection ids", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
});
// build a collection in another team
await buildCollection();
// build a private collection
await buildCollection({
teamId: team.id,
permission: null,
});
const response = await team.collectionIds();
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
});
// build a collection in another team
await buildCollection();
// build a private collection
await buildCollection({
teamId: team.id,
permission: null,
});
describe("previousSubdomains", () => {
it("should list the previous subdomains", async () => {
const team = await buildTeam({
subdomain: "example",
});
const subdomain = "updated";
await team.update({ subdomain });
expect(team.subdomain).toEqual(subdomain);
expect(team.previousSubdomains?.length).toEqual(1);
expect(team.previousSubdomains?.[0]).toEqual("example");
const subdomain2 = "another";
await team.update({ subdomain: subdomain2 });
expect(team.subdomain).toEqual(subdomain2);
expect(team.previousSubdomains?.length).toEqual(2);
expect(team.previousSubdomains?.[0]).toEqual("example");
expect(team.previousSubdomains?.[1]).toEqual(subdomain);
});
const response = await team.collectionIds();
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
});
});

View File

@@ -171,6 +171,9 @@ class Team extends ParanoidModel<
@Column
lastActiveAt: Date | null;
@Column(DataType.ARRAY(DataType.STRING))
previousSubdomains: string[] | null;
// getters
/**
@@ -369,6 +372,25 @@ class Team extends ParanoidModel<
return model;
}
@BeforeUpdate
static async savePreviousSubdomain(model: Team) {
const previousSubdomain = model.previous("subdomain");
if (previousSubdomain && previousSubdomain !== model.subdomain) {
model.previousSubdomains = model.previousSubdomains || [];
if (!model.previousSubdomains.includes(previousSubdomain)) {
// Add the previous subdomain to the list of previous subdomains
// upto a maximum of 3 previous subdomains
model.previousSubdomains.push(previousSubdomain);
if (model.previousSubdomains.length > 3) {
model.previousSubdomains.shift();
}
}
}
return model;
}
@AfterUpdate
static deletePreviousAvatar = async (model: Team) => {
const previousAvatarUrl = model.previous("avatarUrl");
@@ -393,6 +415,41 @@ class Team extends ParanoidModel<
}
}
};
/**
* Find a team by its current or previous subdomain.
*
* @param subdomain - The subdomain to search for.
* @returns The team with the given or previous subdomain, or null if not found.
*/
static async findBySubdomain(subdomain: string) {
// Preference is always given to the team with the subdomain currently
// otherwise we can try and find a team that previously used the subdomain.
return (
(await this.findOne({
where: {
subdomain,
},
})) || (await this.findByPreviousSubdomain(subdomain))
);
}
/**
* Find a team by its previous subdomain.
*
* @param previousSubdomain - The previous subdomain to search for.
* @returns The team with the given previous subdomain, or null if not found.
*/
static async findByPreviousSubdomain(previousSubdomain: string) {
return this.findOne({
where: {
previousSubdomains: {
[Op.contains]: [previousSubdomain],
},
},
order: [["updatedAt", "DESC"]],
});
}
}
export default Team;

View File

@@ -1,7 +1,7 @@
import { buildAdmin, buildTeam } from "@server/test/factories";
import TeamDomain from "./TeamDomain";
describe("team domain model", () => {
describe("TeamDomain", () => {
describe("create", () => {
it("should allow creation of domains", async () => {
const team = await buildTeam();

View File

@@ -7,6 +7,7 @@ import send from "koa-send";
import userAgent, { UserAgentContext } from "koa-useragent";
import { languages } from "@shared/i18n";
import { IntegrationType, TeamPreference } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import { Day } from "@shared/utils/time";
import env from "@server/env";
import { NotFoundError } from "@server/errors";
@@ -138,11 +139,29 @@ router.get("*", shareDomains(), async (ctx, next) => {
}
const team = await getTeamFromContext(ctx);
let redirectUrl;
// Redirect all requests to custom domain if one is set
if (team?.domain && team.domain !== ctx.hostname) {
ctx.redirect(ctx.href.replace(ctx.hostname, team.domain));
return;
if (env.isCloudHosted) {
// Redirect all requests to custom domain if one is set
if (team?.domain && team.domain !== ctx.hostname) {
redirectUrl = ctx.href.replace(ctx.hostname, team.domain);
}
// Redirect if subdomain is not the current team's subdomain
else if (team?.subdomain) {
const { teamSubdomain } = parseDomain(ctx.href);
if (team?.subdomain !== teamSubdomain) {
redirectUrl = ctx.href.replace(
`//${teamSubdomain}.`,
`//${team.subdomain}.`
);
}
}
if (redirectUrl) {
ctx.redirect(redirectUrl);
return;
}
}
const analytics = team

View File

@@ -127,9 +127,7 @@ export async function getTeamFromContext(ctx: Context) {
} else if (domain.custom) {
team = await Team.findOne({ where: { domain: domain.host } });
} else if (domain.teamSubdomain) {
team = await Team.findOne({
where: { subdomain: domain.teamSubdomain },
});
team = await Team.findBySubdomain(domain.teamSubdomain);
}
return team;