Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 75 additions & 24 deletions server/handlers/links.handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,29 @@ const env = require("../env");
const CustomError = utils.CustomError;
const dnsLookup = promisify(dns.lookup);

function buildTargetURL(link) {
const utmFields = [
["utm_source", link.utm_source],
["utm_medium", link.utm_medium],
["utm_campaign", link.utm_campaign],
["utm_term", link.utm_term],
["utm_content", link.utm_content],
];

const hasUTM = utmFields.some(([, v]) => v);
if (!hasUTM) return link.target;

try {
const url = new globalThis.URL(link.target);
utmFields.forEach(([key, value]) => {
if (value) url.searchParams.set(key, value);
});
return url.toString();
} catch {
return link.target;
}
}

async function get(req, res) {
const { limit, skip } = req.context;
const search = req.query.search;
Expand Down Expand Up @@ -97,7 +120,8 @@ async function getAdmin(req, res) {
};

async function create(req, res) {
const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body;
const { reuse, password, customurl, description, target, fetched_domain, expire_in,
utm_source, utm_medium, utm_campaign, utm_term, utm_content } = req.body;
const domain_id = fetched_domain ? fetched_domain.id : null;

const targetDomain = utils.removeWww(URL.parse(target).hostname);
Comment on lines +123 to 127
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reuse mode currently looks up an existing link by { target, user_id, domain_id } only. With UTM fields now affecting the final redirect URL, this can return an existing link whose stored UTM parameters differ from the ones submitted, so the response/redirect won’t reflect the requested UTM values. Consider including the UTM fields in the reuse lookup (or disabling reuse when any UTM field is present) so “reuse” only triggers when the redirect behavior would be identical.

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -141,7 +165,12 @@ async function create(req, res) {
description,
target,
expire_in,
user_id: req.user && req.user.id
user_id: req.user && req.user.id,
utm_source,
utm_medium,
utm_campaign,
utm_term,
utm_content,
});

link.domain = fetched_domain?.address;
Expand Down Expand Up @@ -172,14 +201,19 @@ async function edit(req, res) {

let isChanged = false;
[
[req.body.address, "address"],
[req.body.target, "target"],
[req.body.description, "description"],
[req.body.expire_in, "expire_in"],
[req.body.password, "password"]
[req.body.address, "address"],
[req.body.target, "target"],
[req.body.description, "description"],
[req.body.expire_in, "expire_in"],
[req.body.password, "password"],
[req.body.utm_source, "utm_source"],
[req.body.utm_medium, "utm_medium"],
[req.body.utm_campaign, "utm_campaign"],
[req.body.utm_term, "utm_term"],
[req.body.utm_content, "utm_content"],
].forEach(([value, name]) => {
if (!value) {
if (name === "password" && link.password)
if (name === "password" && link.password)
req.body.password = null;
else {
delete req.body[name];
Expand All @@ -205,8 +239,9 @@ async function edit(req, res) {
throw new CustomError("Should at least update one field.");
}

const { address, target, description, expire_in, password } = req.body;

const { address, target, description, expire_in, password,
utm_source, utm_medium, utm_campaign, utm_term, utm_content } = req.body;

const targetDomain = target && utils.removeWww(URL.parse(target).hostname);
const domain_id = link.domain_id || null;

Expand Down Expand Up @@ -237,7 +272,12 @@ async function edit(req, res) {
...(description && { description }),
...(target && { target }),
...(expire_in && { expire_in }),
...((password || password === null) && { password })
...((password || password === null) && { password }),
...(utm_source !== undefined && { utm_source: utm_source || null }),
...(utm_medium !== undefined && { utm_medium: utm_medium || null }),
...(utm_campaign !== undefined && { utm_campaign: utm_campaign || null }),
...(utm_term !== undefined && { utm_term: utm_term || null }),
...(utm_content !== undefined && { utm_content: utm_content || null }),
}
);

Expand Down Expand Up @@ -265,14 +305,19 @@ async function editAdmin(req, res) {

let isChanged = false;
[
[req.body.address, "address"],
[req.body.target, "target"],
[req.body.description, "description"],
[req.body.expire_in, "expire_in"],
[req.body.password, "password"]
[req.body.address, "address"],
[req.body.target, "target"],
[req.body.description, "description"],
[req.body.expire_in, "expire_in"],
[req.body.password, "password"],
[req.body.utm_source, "utm_source"],
[req.body.utm_medium, "utm_medium"],
[req.body.utm_campaign, "utm_campaign"],
[req.body.utm_term, "utm_term"],
[req.body.utm_content, "utm_content"],
].forEach(([value, name]) => {
if (!value) {
if (name === "password" && link.password)
if (name === "password" && link.password)
req.body.password = null;
else {
delete req.body[name];
Expand All @@ -298,8 +343,9 @@ async function editAdmin(req, res) {
throw new CustomError("Should at least update one field.");
}

const { address, target, description, expire_in, password } = req.body;

const { address, target, description, expire_in, password,
utm_source, utm_medium, utm_campaign, utm_term, utm_content } = req.body;

const targetDomain = target && utils.removeWww(URL.parse(target).hostname);
const domain_id = link.domain_id || null;

Expand Down Expand Up @@ -330,7 +376,12 @@ async function editAdmin(req, res) {
...(description && { description }),
...(target && { target }),
...(expire_in && { expire_in }),
...((password || password === null) && { password })
...((password || password === null) && { password }),
...(utm_source !== undefined && { utm_source: utm_source || null }),
...(utm_medium !== undefined && { utm_medium: utm_medium || null }),
...(utm_campaign !== undefined && { utm_campaign: utm_campaign || null }),
...(utm_term !== undefined && { utm_term: utm_term || null }),
...(utm_content !== undefined && { utm_content: utm_content || null }),
}
);

Expand Down Expand Up @@ -516,7 +567,7 @@ async function redirect(req, res, next) {
if (colon !== -1) {
const password = decoded.slice(colon + 1);
const matches = await bcrypt.compare(password, link.password);
if (matches) return res.redirect(link.target);
if (matches) return res.redirect(buildTargetURL(link));
}
}
}
Expand All @@ -541,7 +592,7 @@ async function redirect(req, res, next) {
}

// 8. Redirect to target
return res.redirect(link.target);
return res.redirect(buildTargetURL(link));
};

async function redirectProtected(req, res) {
Expand Down Expand Up @@ -574,14 +625,14 @@ async function redirectProtected(req, res) {

// 5. Send target
if (req.isHTML) {
res.setHeader("HX-Redirect", link.target);
res.setHeader("HX-Redirect", buildTargetURL(link));
res.render("partials/protected/form", {
id: link.uuid,
message: "Redirecting...",
});
return;
}
return res.status(200).send({ target: link.target });
return res.status(200).send({ target: buildTargetURL(link) });
};

async function redirectCustomDomainHomepage(req, res, next) {
Expand Down
62 changes: 61 additions & 1 deletion server/handlers/validators.handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,37 @@ const createLink = [

if (!domain) return Promise.reject();
})
.withMessage("You can't use this domain.")
.withMessage("You can't use this domain."),
body("utm_source")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 1, max: 255 })
.withMessage("UTM Source length must be between 1 and 255."),
body("utm_medium")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 1, max: 255 })
.withMessage("UTM Medium length must be between 1 and 255."),
body("utm_campaign")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 1, max: 255 })
.withMessage("UTM Campaign length must be between 1 and 255."),
body("utm_term")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 1, max: 255 })
.withMessage("UTM Term length must be between 1 and 255."),
body("utm_content")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 1, max: 255 })
.withMessage("UTM Content length must be between 1 and 255."),
];

const editLink = [
Expand Down Expand Up @@ -146,6 +176,36 @@ const editLink = [
.trim()
.isLength({ min: 0, max: 2040 })
.withMessage("Description length must be between 0 and 2040."),
body("utm_source")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 1, max: 255 })
.withMessage("UTM Source length must be between 1 and 255."),
body("utm_medium")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 1, max: 255 })
.withMessage("UTM Medium length must be between 1 and 255."),
body("utm_campaign")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 1, max: 255 })
.withMessage("UTM Campaign length must be between 1 and 255."),
body("utm_term")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 1, max: 255 })
.withMessage("UTM Term length must be between 1 and 255."),
body("utm_content")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 1, max: 255 })
.withMessage("UTM Content length must be between 1 and 255."),
param("id", "ID is invalid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 36, max: 36 })
Expand Down
30 changes: 30 additions & 0 deletions server/migrations/20260325180240_utm_params.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
async function up(knex) {
const hasUtmSource = await knex.schema.hasColumn("links", "utm_source");
if (!hasUtmSource) {
await knex.schema.alterTable("links", table => {
table.string("utm_source");
table.string("utm_medium");
table.string("utm_campaign");
table.string("utm_term");
table.string("utm_content");
Comment on lines +5 to +9
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validators enforce a max length of 255 for UTM fields, but the migration defines them as table.string("utm_*" ) without an explicit length. To keep DB constraints aligned with validation across different DB backends, consider specifying the length explicitly (e.g., table.string("utm_source", 255) etc.).

Suggested change
table.string("utm_source");
table.string("utm_medium");
table.string("utm_campaign");
table.string("utm_term");
table.string("utm_content");
table.string("utm_source", 255);
table.string("utm_medium", 255);
table.string("utm_campaign", 255);
table.string("utm_term", 255);
table.string("utm_content", 255);

Copilot uses AI. Check for mistakes.
});
}
}

async function down(knex) {
const hasUtmSource = await knex.schema.hasColumn("links", "utm_source");
if (hasUtmSource) {
await knex.schema.alterTable("links", table => {
table.dropColumn("utm_source");
table.dropColumn("utm_medium");
table.dropColumn("utm_campaign");
table.dropColumn("utm_term");
table.dropColumn("utm_content");
});
}
}

module.exports = {
up,
down
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration file formatting is inconsistent with other migrations: module.exports typically ends with }; (see e.g. server/migrations/20250106070444_remove_cooldown.js). Consider adding the trailing semicolon here for consistency.

Suggested change
}
};

Copilot uses AI. Check for mistakes.
12 changes: 11 additions & 1 deletion server/queries/link.queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ const selectable = [
"links.visit_count",
"links.user_id",
"links.uuid",
"links.utm_source",
"links.utm_medium",
"links.utm_campaign",
"links.utm_term",
"links.utm_content",
"domains.address as domain"
];

Expand Down Expand Up @@ -215,7 +220,12 @@ async function create(params) {
address: params.address,
description: params.description || null,
expire_in: params.expire_in || null,
target: params.target
target: params.target,
utm_source: params.utm_source || null,
utm_medium: params.utm_medium || null,
utm_campaign: params.utm_campaign || null,
utm_term: params.utm_term || null,
utm_content: params.utm_content || null,
},
"*"
);
Expand Down
46 changes: 45 additions & 1 deletion server/views/partials/admin/links/edit.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,51 @@
</label>
</div>
<div>
<button
<label class="{{#if errors.utm_source}}error{{/if}}">
UTM Source:
<input
id="edit-utm_source-{{id}}"
name="utm_source" type="text" placeholder="e.g. google"
value="{{utm_source}}" hx-preserve="true" />
{{#if errors.utm_source}}<p class="error">{{errors.utm_source}}</p>{{/if}}
</label>
<label class="{{#if errors.utm_medium}}error{{/if}}">
UTM Medium:
<input
id="edit-utm_medium-{{id}}"
name="utm_medium" type="text" placeholder="e.g. cpc"
value="{{utm_medium}}" hx-preserve="true" />
{{#if errors.utm_medium}}<p class="error">{{errors.utm_medium}}</p>{{/if}}
</label>
<label class="{{#if errors.utm_campaign}}error{{/if}}">
UTM Campaign:
<input
id="edit-utm_campaign-{{id}}"
name="utm_campaign" type="text" placeholder="e.g. spring_sale"
value="{{utm_campaign}}" hx-preserve="true" />
{{#if errors.utm_campaign}}<p class="error">{{errors.utm_campaign}}</p>{{/if}}
</label>
</div>
<div>
<label class="{{#if errors.utm_term}}error{{/if}}">
UTM Term:
<input
id="edit-utm_term-{{id}}"
name="utm_term" type="text" placeholder="e.g. running+shoes"
value="{{utm_term}}" hx-preserve="true" />
{{#if errors.utm_term}}<p class="error">{{errors.utm_term}}</p>{{/if}}
</label>
<label class="{{#if errors.utm_content}}error{{/if}}">
UTM Content:
<input
id="edit-utm_content-{{id}}"
name="utm_content" type="text" placeholder="e.g. logolink"
value="{{utm_content}}" hx-preserve="true" />
{{#if errors.utm_content}}<p class="error">{{errors.utm_content}}</p>{{/if}}
</label>
</div>
<div>
<button
type="button"
onclick="
const tr = closest('tr');
Expand Down
Loading
Loading