mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
1
Commits
main
...
feat/signup-utm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e03d4df030 |
No files matched your search
@@ -22,6 +22,7 @@ import type {
|
||||
import { Actor } from '../../core';
|
||||
import type { UsageInput } from '../../services/metering/types';
|
||||
import { FSEntry } from '../../stores/fs/FSEntry';
|
||||
import type { SignupUtm } from '../../util/signupUtm';
|
||||
|
||||
// GUI write events spread an entry plus per-event metadata into `response`.
|
||||
// The exact field set varies by emit site (FSController / LegacyFSController /
|
||||
@@ -250,6 +251,8 @@ export type EventMap = {
|
||||
is_temp?: boolean;
|
||||
/** The bonus code `puter.signup-bonus.validate` accepted, if any. */
|
||||
bonus_code?: string;
|
||||
/** The `utm_*` params the signup carried, if any. Client-supplied. */
|
||||
utm?: SignupUtm;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// Signup bonus codes are pure mechanism here: an extension decides what a
|
||||
|
||||
@@ -1073,6 +1073,62 @@ describe('AuthController.handleSignup', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('announces the utm params a form signup carried', async () => {
|
||||
const baseline = heardSignupSuccess.length;
|
||||
const username = `utm_${uniq()}`;
|
||||
await controller.handleSignup(
|
||||
makeReq({
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
utm_source: 'newsletter',
|
||||
utm_campaign: 'launch',
|
||||
utm_id: 'not-tracked',
|
||||
}),
|
||||
makeRes(),
|
||||
);
|
||||
|
||||
const evt = heardSignupSuccess
|
||||
.slice(baseline)
|
||||
.find((e) => e.username === username);
|
||||
expect(evt?.utm).toEqual({ source: 'newsletter', campaign: 'launch' });
|
||||
expect(evt).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('announces utm on a temp signup without exposing its placeholder email', async () => {
|
||||
const baseline = heardSignupSuccess.length;
|
||||
const res = makeRes();
|
||||
await controller.handleSignup(
|
||||
makeReq({ is_temp: true, utm_source: 'ads' }),
|
||||
res,
|
||||
);
|
||||
|
||||
const evt = heardSignupSuccess
|
||||
.slice(baseline)
|
||||
.find((e) => e.is_temp === true);
|
||||
expect(evt?.utm).toEqual({ source: 'ads' });
|
||||
expect(evt?.email).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves utm off the event when the signup carried none', async () => {
|
||||
const baseline = heardSignupSuccess.length;
|
||||
const username = `noutm_${uniq()}`;
|
||||
await controller.handleSignup(
|
||||
makeReq({
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
);
|
||||
|
||||
const evt = heardSignupSuccess
|
||||
.slice(baseline)
|
||||
.find((e) => e.username === username);
|
||||
expect(evt).toBeDefined();
|
||||
expect(evt).not.toHaveProperty('utm');
|
||||
});
|
||||
|
||||
it('still allows claiming a pseudo-user row when registration is disabled', async () => {
|
||||
const authConfig = server.controllers.auth.config as {
|
||||
disable_user_signup?: boolean;
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
isAbsentBonusCode,
|
||||
normalizeBonusCode,
|
||||
} from '../../util/signupBonus.js';
|
||||
import { readSignupUtm } from '../../util/signupUtm.js';
|
||||
import { isTemporaryPasswordExpired } from '../../util/temporaryPassword.js';
|
||||
import { buildUserDetails, scrubSensitive } from '../../util/userDetails.js';
|
||||
import {
|
||||
@@ -888,6 +889,7 @@ export class AuthController extends PuterController {
|
||||
isTemp: is_temp,
|
||||
fingerprint,
|
||||
bonusCode,
|
||||
utm: readSignupUtm(body),
|
||||
});
|
||||
await this.#completeLogin(req, res, user);
|
||||
}
|
||||
|
||||
@@ -2386,4 +2386,46 @@ describe('OIDC signup bonus codes', () => {
|
||||
expect(captured.redirectUrl).toContain('message=signup_blocked');
|
||||
expect(captured.redirectUrl).toContain('bonusCode=startup3mabcd1234');
|
||||
});
|
||||
|
||||
it('carries shaped utm params in the signed state', async () => {
|
||||
const decoded = await startState({
|
||||
flow: 'signup',
|
||||
utm_source: 'ads',
|
||||
utm_campaign: 'launch',
|
||||
utm_id: 'not-tracked',
|
||||
utm_medium: '',
|
||||
});
|
||||
expect(decoded.utm).toEqual({ source: 'ads', campaign: 'launch' });
|
||||
});
|
||||
|
||||
it('leaves utm out of the state when none were sent', async () => {
|
||||
const decoded = await startState({ flow: 'signup' });
|
||||
expect(decoded).not.toHaveProperty('utm');
|
||||
});
|
||||
|
||||
it('announces the state utm on the new account, re-shaped', async () => {
|
||||
const email = `utm-${rand()}@test.local`;
|
||||
stubIdp(`sub-${rand()}`, email);
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
utm: { source: 'ads', medium: 7, extra: 'x' },
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/signup',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
const deadline = Date.now() + 2000;
|
||||
let success: Record<string, unknown> | undefined;
|
||||
while (!success && Date.now() < deadline) {
|
||||
success = heardSignupSuccess.find((e) => e.email === email);
|
||||
if (!success) await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
expect(success?.utm).toEqual({ source: 'ads' });
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,11 @@ import {
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { normalizeBonusCode } from '../../util/signupBonus.js';
|
||||
import {
|
||||
normalizeSignupUtm,
|
||||
readSignupUtm,
|
||||
type SignupUtm,
|
||||
} from '../../util/signupUtm.js';
|
||||
import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js';
|
||||
import {
|
||||
SHARE_DEEP_LINK_ITEMS_LIMIT,
|
||||
@@ -88,6 +93,7 @@ function resolutionErrorCode(code: string | undefined): string {
|
||||
interface OIDCSignupOptions {
|
||||
referrer?: string | null;
|
||||
bonusCode?: string | null;
|
||||
utm?: SignupUtm | null;
|
||||
}
|
||||
|
||||
function signupOptionsFromState(
|
||||
@@ -98,6 +104,7 @@ function signupOptionsFromState(
|
||||
// Normalized when the state was signed; re-checked since this is the
|
||||
// value signup acts on.
|
||||
bonusCode: normalizeBonusCode(stateDecoded.bonus_code),
|
||||
utm: normalizeSignupUtm(stateDecoded.utm),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -495,8 +502,10 @@ export class OIDCController extends PuterController {
|
||||
? req.query.bonusCode[0]
|
||||
: req.query.bonusCode;
|
||||
const bonusCode = normalizeBonusCode(rawBonusCode);
|
||||
if (bonusCode && (flow === 'login' || flow === 'signup')) {
|
||||
statePayload.bonus_code = bonusCode;
|
||||
const utm = readSignupUtm(req.query);
|
||||
if (flow === 'login' || flow === 'signup') {
|
||||
if (bonusCode) statePayload.bonus_code = bonusCode;
|
||||
if (utm) statePayload.utm = utm;
|
||||
}
|
||||
if (embeddedInPopup && msgId) {
|
||||
statePayload.embedded_in_popup = true;
|
||||
@@ -887,7 +896,7 @@ if (window.opener) {
|
||||
provider,
|
||||
userinfo as { sub: string; email?: string },
|
||||
signup.referrer ?? null,
|
||||
{ bonusCode: signup.bonusCode ?? null },
|
||||
{ bonusCode: signup.bonusCode ?? null, utm: signup.utm ?? null },
|
||||
);
|
||||
// A concurrent callback (a second tab, a provider retry) created the
|
||||
// account between step 2 and the insert. Nothing went wrong for the
|
||||
|
||||
@@ -1164,6 +1164,31 @@ describe('OIDCService.createUserFromOIDC', () => {
|
||||
server.clients.event.off('puter.signup.success', onSignup);
|
||||
});
|
||||
|
||||
it('announces the utm it was given', async () => {
|
||||
const email = `utm-${crypto.randomBytes(4).toString('hex')}@example.com`;
|
||||
const signups: Array<Record<string, unknown>> = [];
|
||||
const onSignup = (_k: string, data: unknown) =>
|
||||
signups.push(data as Record<string, unknown>);
|
||||
server.clients.event.on('puter.signup.success', onSignup);
|
||||
try {
|
||||
const result = await runWithContext({ req }, () =>
|
||||
oidc().createUserFromOIDC(
|
||||
'google',
|
||||
{ sub: `utm-sub-${email}`, email, email_verified: true },
|
||||
null,
|
||||
{ utm: { source: 'ads', campaign: 'launch' } },
|
||||
),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(signups[0]?.utm).toEqual({
|
||||
source: 'ads',
|
||||
campaign: 'launch',
|
||||
});
|
||||
} finally {
|
||||
server.clients.event.off('puter.signup.success', onSignup);
|
||||
}
|
||||
});
|
||||
|
||||
it('retries the generated username when its home path is already occupied', async () => {
|
||||
// The RNG is input to the candidate generator, not the code under
|
||||
// test: compute the same first candidate the service will try.
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Context } from '../../core';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import crypto from 'node:crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { SignupUtm } from '../../util/signupUtm.js';
|
||||
import { verifyOidcIdToken, type JwksCacheEntry } from './oidcIdToken';
|
||||
|
||||
const GOOGLE_DISCOVERY_URL =
|
||||
@@ -492,13 +493,17 @@ export class OIDCService extends PuterService {
|
||||
* re-resolve rather than surface an error — see
|
||||
* `#resolveOrCreateOIDCUser`.
|
||||
*
|
||||
* `bonusCode` must already be canonical (`normalizeBonusCode`).
|
||||
* `bonusCode` must already be canonical (`normalizeBonusCode`) and `utm`
|
||||
* already shaped (`normalizeSignupUtm`).
|
||||
*/
|
||||
async createUserFromOIDC(
|
||||
providerId: string,
|
||||
claims: OIDCUserInfo,
|
||||
referrer?: string | null,
|
||||
{ bonusCode = null }: { bonusCode?: string | null } = {},
|
||||
{
|
||||
bonusCode = null,
|
||||
utm = null,
|
||||
}: { bonusCode?: string | null; utm?: SignupUtm | null } = {},
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
user?: UserRow;
|
||||
@@ -686,6 +691,7 @@ export class OIDCService extends PuterService {
|
||||
bonusCode,
|
||||
emailConfirmed: true,
|
||||
saveAccount: true,
|
||||
utm,
|
||||
});
|
||||
|
||||
return { success: true, user };
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
checkSignupBonus,
|
||||
validateSignupBonus,
|
||||
} from '../../util/signupBonus.js';
|
||||
import type { SignupUtm } from '../../util/signupUtm.js';
|
||||
import { isUsernameTaken } from '../../util/username.js';
|
||||
import { isEmailGateLast } from '../../util/verificationOrder.js';
|
||||
import {
|
||||
@@ -83,6 +84,7 @@ export interface SignupInput {
|
||||
isTemp: boolean;
|
||||
fingerprint: string | null;
|
||||
bonusCode: string | null;
|
||||
utm?: SignupUtm | null;
|
||||
}
|
||||
|
||||
/** The trusted client address; `req.ip` honors `trust proxy`. */
|
||||
@@ -278,7 +280,7 @@ export class SignupService extends PuterService {
|
||||
* refused signup answers with.
|
||||
*/
|
||||
async signup(input: SignupInput): Promise<UserRow> {
|
||||
const { req, body, isTemp, fingerprint, bonusCode } = input;
|
||||
const { req, body, isTemp, fingerprint, bonusCode, utm } = input;
|
||||
|
||||
// Runs before the duplicate checks so a disabled endpoint doesn't
|
||||
// reveal which usernames or emails exist. Claiming a pre-existing
|
||||
@@ -547,6 +549,7 @@ export class SignupService extends PuterService {
|
||||
// The created/claimed row: a claimed placeholder has credentials.
|
||||
isTemp: user.password === null && user.email === null,
|
||||
saveAccount: !isTemp,
|
||||
utm,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
@@ -566,6 +569,7 @@ export class SignupService extends PuterService {
|
||||
/** The provider verified the address, so this is its confirmation. */
|
||||
emailConfirmed?: boolean;
|
||||
saveAccount: boolean;
|
||||
utm?: SignupUtm | null;
|
||||
},
|
||||
): void {
|
||||
if (opts.emailConfirmed) this.#emitEmailConfirmed(user);
|
||||
@@ -587,6 +591,7 @@ export class SignupService extends PuterService {
|
||||
// counters are written under one key and read under another.
|
||||
ip: opts.ip,
|
||||
...(opts.bonusCode ? { bonus_code: opts.bonusCode } : {}),
|
||||
...(opts.utm ? { utm: opts.utm } : {}),
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
normalizeSignupUtm,
|
||||
readSignupUtm,
|
||||
SIGNUP_UTM_MAX_LENGTH,
|
||||
} from './signupUtm.js';
|
||||
|
||||
describe('readSignupUtm', () => {
|
||||
it('reads the utm_* params, keyed without the prefix', () => {
|
||||
expect(
|
||||
readSignupUtm({
|
||||
utm_source: 'newsletter',
|
||||
utm_medium: 'email',
|
||||
utm_campaign: 'launch',
|
||||
utm_term: 'cloud os',
|
||||
utm_content: 'hero-cta',
|
||||
}),
|
||||
).toEqual({
|
||||
source: 'newsletter',
|
||||
medium: 'email',
|
||||
campaign: 'launch',
|
||||
term: 'cloud os',
|
||||
content: 'hero-cta',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops unknown keys, non-strings, blanks and oversized values', () => {
|
||||
expect(
|
||||
readSignupUtm({
|
||||
utm_source: ' ads ',
|
||||
utm_medium: 42,
|
||||
utm_campaign: ' ',
|
||||
utm_term: 'x'.repeat(SIGNUP_UTM_MAX_LENGTH + 1),
|
||||
utm_content: { nested: true },
|
||||
utm_id: 'not-tracked',
|
||||
password: 'secret',
|
||||
}),
|
||||
).toEqual({ source: 'ads' });
|
||||
});
|
||||
|
||||
it('takes the first value of a repeated query param', () => {
|
||||
expect(readSignupUtm({ utm_source: ['first', 'second'] })).toEqual({
|
||||
source: 'first',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when nothing usable is present', () => {
|
||||
expect(readSignupUtm({})).toBeNull();
|
||||
expect(readSignupUtm({ utm_source: '' })).toBeNull();
|
||||
expect(readSignupUtm(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeSignupUtm', () => {
|
||||
it('keeps a well-formed value and re-checks each field', () => {
|
||||
expect(
|
||||
normalizeSignupUtm({ source: 'ads', medium: 7, extra: 'x' }),
|
||||
).toEqual({ source: 'ads' });
|
||||
});
|
||||
|
||||
it('returns null for anything that is not a plain object', () => {
|
||||
expect(normalizeSignupUtm(null)).toBeNull();
|
||||
expect(normalizeSignupUtm('ads')).toBeNull();
|
||||
expect(normalizeSignupUtm(['ads'])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Campaign attribution a signup arrives with, as `utm_*` params. Core only
|
||||
* shapes and forwards the values; what is done with them is up to listeners on
|
||||
* `puter.signup.success`.
|
||||
*/
|
||||
|
||||
export const SIGNUP_UTM_KEYS = [
|
||||
'source',
|
||||
'medium',
|
||||
'campaign',
|
||||
'term',
|
||||
'content',
|
||||
] as const;
|
||||
|
||||
export type SignupUtmKey = (typeof SIGNUP_UTM_KEYS)[number];
|
||||
|
||||
/** The `utm_*` values a signup carried, keyed without the prefix. */
|
||||
export type SignupUtm = Partial<Record<SignupUtmKey, string>>;
|
||||
|
||||
/** Caps each value, which also bounds the OIDC state token that carries them. */
|
||||
export const SIGNUP_UTM_MAX_LENGTH = 255;
|
||||
|
||||
const shapeValue = (raw: unknown): string | null => {
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed && trimmed.length <= SIGNUP_UTM_MAX_LENGTH ? trimmed : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A `SignupUtm` holding only the known keys with usable string values, or null
|
||||
* when none are left.
|
||||
*/
|
||||
export function normalizeSignupUtm(raw: unknown): SignupUtm | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const src = raw as Record<string, unknown>;
|
||||
const utm: SignupUtm = {};
|
||||
for (const key of SIGNUP_UTM_KEYS) {
|
||||
const value = shapeValue(src[key]);
|
||||
if (value) utm[key] = value;
|
||||
}
|
||||
return Object.keys(utm).length > 0 ? utm : null;
|
||||
}
|
||||
|
||||
/** The `utm_*` params in a request body or query, or null when there are none. */
|
||||
export function readSignupUtm(params: unknown): SignupUtm | null {
|
||||
if (!params || typeof params !== 'object') return null;
|
||||
const src = params as Record<string, unknown>;
|
||||
return normalizeSignupUtm(
|
||||
Object.fromEntries(SIGNUP_UTM_KEYS.map((k) => [k, src[`utm_${k}`]])),
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { offersFederatedSignInInPopup } from '../util/popupAuth.js';
|
||||
import { get_auth_redirect_url, get_oidc_return_to } from '../helpers/authRedirect.js';
|
||||
import { authLogoHeader, wireAuthLogoHeader } from '../helpers/authLogoHeader.js';
|
||||
import { isAttestedOrigin } from '../helpers/attestedOrigin.js';
|
||||
import { signupUtmQuery } from '../helpers/signupUtm.js';
|
||||
|
||||
async function UIWindowLogin (options) {
|
||||
options = options ?? {};
|
||||
@@ -222,6 +223,7 @@ async function UIWindowLogin (options) {
|
||||
if ( window.signup_bonus_code ) {
|
||||
url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
|
||||
}
|
||||
url += signupUtmQuery(window.signup_utm);
|
||||
if ( window.embedded_in_popup && window.url_query_params?.get('msg_id') ) {
|
||||
url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`;
|
||||
if ( window.openerOrigin ) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { get_auth_redirect_url, get_oidc_return_to } from '../helpers/authRedire
|
||||
import { authLogoHeader, wireAuthLogoHeader } from '../helpers/authLogoHeader.js';
|
||||
import { isAttestedOrigin } from '../helpers/attestedOrigin.js';
|
||||
import { bonusRequirementsKey, checkSignupBonusCode } from '../helpers/signupBonusCode.js';
|
||||
import { signupUtmQuery } from '../helpers/signupUtm.js';
|
||||
|
||||
// What a checked bonus code grants, above the form (and the provider buttons).
|
||||
function renderBonusNotice(el_window, result) {
|
||||
@@ -324,6 +325,7 @@ function UIWindowSignup(options) {
|
||||
if (window.signup_bonus_code) {
|
||||
url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
|
||||
}
|
||||
url += signupUtmQuery(window.signup_utm);
|
||||
if (
|
||||
window.embedded_in_popup &&
|
||||
window.url_query_params?.get('msg_id')
|
||||
@@ -560,6 +562,7 @@ function UIWindowSignup(options) {
|
||||
|
||||
// Include captcha in request only if required
|
||||
const requestData = {
|
||||
...window.signup_utm,
|
||||
username: username,
|
||||
email: email,
|
||||
password: password,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Campaign `utm_*` params from the page a visitor landed on, sent with
|
||||
* whichever signup they end up making. Kept for the tab's session so a reload
|
||||
* or in-app navigation that drops the query doesn't lose them. The backend
|
||||
* re-checks every value.
|
||||
*/
|
||||
|
||||
export const SIGNUP_UTM_PARAMS = [
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'signup_utm';
|
||||
const MAX_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* The `utm_*` values in `params` (URLSearchParams or a plain object) as
|
||||
* `{ utm_source: ... }`, or null when there are none.
|
||||
*/
|
||||
export function readSignupUtm(params) {
|
||||
if (!params || typeof params !== 'object') return null;
|
||||
const get =
|
||||
typeof params.get === 'function'
|
||||
? (key) => params.get(key)
|
||||
: (key) => params[key];
|
||||
const utm = {};
|
||||
for (const key of SIGNUP_UTM_PARAMS) {
|
||||
const value = get(key);
|
||||
if (typeof value !== 'string') continue;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed && trimmed.length <= MAX_LENGTH) utm[key] = trimmed;
|
||||
}
|
||||
return Object.keys(utm).length > 0 ? utm : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's utm params, saved to `storage` for later loads; with none on the
|
||||
* page, the ones saved earlier this session.
|
||||
*/
|
||||
export function rememberSignupUtm(params, storage) {
|
||||
const fromPage = readSignupUtm(params);
|
||||
try {
|
||||
if (fromPage) {
|
||||
storage?.setItem(STORAGE_KEY, JSON.stringify(fromPage));
|
||||
return fromPage;
|
||||
}
|
||||
const saved = storage?.getItem(STORAGE_KEY);
|
||||
return saved ? readSignupUtm(JSON.parse(saved)) : null;
|
||||
} catch {
|
||||
return fromPage;
|
||||
}
|
||||
}
|
||||
|
||||
/** `utm` as `&key=value` pairs to append to a query string, or ''. */
|
||||
export function signupUtmQuery(utm) {
|
||||
return utm ? `&${new URLSearchParams(utm).toString()}` : '';
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
readSignupUtm,
|
||||
rememberSignupUtm,
|
||||
signupUtmQuery,
|
||||
} from './signupUtm.js';
|
||||
|
||||
const query = (search) => new URLSearchParams(search);
|
||||
|
||||
const memoryStorage = () => {
|
||||
const items = new Map();
|
||||
return {
|
||||
getItem: (k) => (items.has(k) ? items.get(k) : null),
|
||||
setItem: (k, v) => items.set(k, String(v)),
|
||||
};
|
||||
};
|
||||
|
||||
describe('readSignupUtm', () => {
|
||||
it('reads the known utm params from a query', () => {
|
||||
expect(
|
||||
readSignupUtm(
|
||||
query('?utm_source=ads&utm_campaign=launch&utm_id=x&ref=y'),
|
||||
),
|
||||
).toEqual({ utm_source: 'ads', utm_campaign: 'launch' });
|
||||
});
|
||||
|
||||
it('drops blank and oversized values', () => {
|
||||
expect(
|
||||
readSignupUtm(
|
||||
query(`?utm_source=%20&utm_term=${'x'.repeat(256)}`),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a plain object and ignores non-strings', () => {
|
||||
expect(readSignupUtm({ utm_source: 'ads', utm_medium: 3 })).toEqual({
|
||||
utm_source: 'ads',
|
||||
});
|
||||
expect(readSignupUtm(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rememberSignupUtm', () => {
|
||||
it('saves the page params and returns them on a later load without any', () => {
|
||||
const storage = memoryStorage();
|
||||
expect(rememberSignupUtm(query('?utm_source=ads'), storage)).toEqual({
|
||||
utm_source: 'ads',
|
||||
});
|
||||
expect(rememberSignupUtm(query(''), storage)).toEqual({
|
||||
utm_source: 'ads',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets new page params replace the saved ones', () => {
|
||||
const storage = memoryStorage();
|
||||
rememberSignupUtm(query('?utm_source=ads'), storage);
|
||||
expect(
|
||||
rememberSignupUtm(query('?utm_source=newsletter'), storage),
|
||||
).toEqual({ utm_source: 'newsletter' });
|
||||
});
|
||||
|
||||
it('still returns the page params when storage throws', () => {
|
||||
const storage = {
|
||||
getItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
expect(rememberSignupUtm(query('?utm_source=ads'), storage)).toEqual({
|
||||
utm_source: 'ads',
|
||||
});
|
||||
expect(rememberSignupUtm(query(''), storage)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a corrupted saved value', () => {
|
||||
const storage = memoryStorage();
|
||||
storage.setItem('signup_utm', '{not json');
|
||||
expect(rememberSignupUtm(query(''), storage)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('signupUtmQuery', () => {
|
||||
it('encodes utm as extra query pairs', () => {
|
||||
expect(signupUtmQuery({ utm_source: 'a b', utm_campaign: 'x&y' })).toBe(
|
||||
'&utm_source=a+b&utm_campaign=x%26y',
|
||||
);
|
||||
expect(signupUtmQuery(null)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,7 @@ import { installAppIconFallback } from './helpers/appIcon.js';
|
||||
import launch_app from './helpers/launchApp.js';
|
||||
import { urlFileLaunchOptions } from './helpers/confirmUrlFileAccess.js';
|
||||
import { readSignupBonusCode } from './helpers/signupBonusCode.js';
|
||||
import { rememberSignupUtm } from './helpers/signupUtm.js';
|
||||
import { parse_url_paths } from './helpers/urlPaths.js';
|
||||
import update_last_touch_coordinates from './helpers/updateLastTouchCoordinates.js';
|
||||
import update_mouse_position from './helpers/updateMousePosition.js';
|
||||
@@ -1413,6 +1414,13 @@ window.initgui = async function (options) {
|
||||
// GET query params provided
|
||||
window.url_query_params = new URLSearchParams(window.location.search);
|
||||
window.signup_bonus_code = readSignupBonusCode(window.url_query_params);
|
||||
let sessionStore = null;
|
||||
try {
|
||||
sessionStore = window.sessionStorage;
|
||||
} catch {
|
||||
// storage blocked; utm from this page load still applies
|
||||
}
|
||||
window.signup_utm = rememberSignupUtm(window.url_query_params, sessionStore);
|
||||
|
||||
// Install device signal helpers; collection is lazy. The fingerprint is
|
||||
// on by default (gui_params.thumbmarkEnabled = false kills it); the Prelude
|
||||
@@ -2575,6 +2583,7 @@ window.initgui = async function (options) {
|
||||
// if this is a popup, show a spinner
|
||||
let spinner_init_ts = Date.now();
|
||||
const requestData = {
|
||||
...window.signup_utm,
|
||||
referrer: referrer,
|
||||
is_temp: true,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user