From 1749ffe20dc61804db192a043110d8a04fc3d2f3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 18 Feb 2025 19:53:18 -0500 Subject: [PATCH] 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 --- ...0217230810-add-team-previous-subdomains.js | 27 +++++++++ server/models/Team.test.ts | 51 ++++++++++++----- server/models/Team.ts | 57 +++++++++++++++++++ server/models/TeamDomain.test.ts | 2 +- server/routes/index.ts | 27 +++++++-- server/utils/passport.ts | 4 +- 6 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 server/migrations/20250217230810-add-team-previous-subdomains.js diff --git a/server/migrations/20250217230810-add-team-previous-subdomains.js b/server/migrations/20250217230810-add-team-previous-subdomains.js new file mode 100644 index 0000000000..898348bc66 --- /dev/null +++ b/server/migrations/20250217230810-add-team-previous-subdomains.js @@ -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 }); + }); + }, +}; diff --git a/server/models/Team.test.ts b/server/models/Team.test.ts index 5c5194752b..25665bea59 100644 --- a/server/models/Team.test.ts +++ b/server/models/Team.test.ts @@ -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); }); }); diff --git a/server/models/Team.ts b/server/models/Team.ts index 43e052f840..2bf5d4c738 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -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; diff --git a/server/models/TeamDomain.test.ts b/server/models/TeamDomain.test.ts index b9c9b51e26..41610a0295 100644 --- a/server/models/TeamDomain.test.ts +++ b/server/models/TeamDomain.test.ts @@ -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(); diff --git a/server/routes/index.ts b/server/routes/index.ts index 417da32381..aeaa135052 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -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 diff --git a/server/utils/passport.ts b/server/utils/passport.ts index a538df88a6..503b5f5fb4 100644 --- a/server/utils/passport.ts +++ b/server/utils/passport.ts @@ -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;