Skip to content

feat: tokenized public activity URLs, private by default - #142

Merged
0jonjo merged 4 commits into
mainfrom
feat/public-token-activities
Aug 15, 2026
Merged

feat: tokenized public activity URLs, private by default#142
0jonjo merged 4 commits into
mainfrom
feat/public-token-activities

Conversation

@0jonjo

@0jonjo 0jonjo commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Why

Public activity pages were addressed by sequential primary keys
(/:username/activities/123). Two problems:

  • Enumeration. Anyone could walk the id space to discover every shared
    activity in the app, and the highest reachable id leaks the size of the
    activity/user base.
  • Public by default. activities.public defaulted to true, so every
    activity — including heart rate, VO2 max and GPS traces — was born
    shareable, whatever the athlete's intent.

What changed

  • activities.public_token (unique index) generated via has_secure_token.
  • Public route now takes the token: get ":username/activities/:token", and
    the controller does find_by!(public_token: params[:token]). A numeric id
    in the URL now 404s.
  • activities.public default flipped from true to false.
  • The athlete profile page lists only tokenized activities (Activity.tokenized),
    so untokenized rows cannot break URL generation before the backfill lands.
  • public_tokens:backfill rake task for existing rows.

Visibility: what does and does not change for existing data

  • Existing rows keep public: true. The migration only changes the column
    default; there is no retro-privatization and no mass update, so links
    already shared with other people stay live.
  • New records default to private. Any Activity.log caller that does not
    pass public: explicitly now creates a private activity. The Strava importer
    already passes public: false explicitly and is unaffected; the manual form,
    guest seeds and dev seeds pass public: true where visibility is intended.
  • The manual activity form already exposed the visibility checkbox
    (activities.form.public_label), so no new UI or locale keys were needed.
    It did, however, hardcode checked: true for new records, which would have
    kept submitting public=1 and silently defeated the new column default —
    the checkbox is now bound to the record and starts unchecked.

Post-deploy step

heroku run rails public_tokens:backfill

Run this as part of the deploy, not later. Until it completes, existing
activities have a NULL token and are unreachable at their public URL, so
previously shared links 404 for that window. The profile page itself stays up
and simply omits untokenized activities rather than erroring.

The task is idempotent (where(public_token: nil)) and writes the token column
directly rather than through update!, so one legacy record that no longer
passes today's photo/GPX validations cannot abort the run for everyone else.

Failure mode is safe throughout: NULL never matches a token in the URL, so
there is no window in which an untokenized activity leaks.

Tests

501 runs, 1584 assertions, 0 failures, 0 errors, 0 skips — rubocop clean.

Coverage: token happy path, old numeric id 404s, private activity token 404s,
unknown token 404s, profile page renders when an activity has no token yet,
token generated on create, model default flipped to private, and guest seeds
staying public.

Public activity pages were addressed by sequential ids, letting anyone
enumerate every shared activity and infer the size of the athlete base.
Address them by an unguessable public_token instead.

Activities are also no longer born public: the column default flips to
false so health and GPS data is only exposed when the athlete opts in.
Existing rows keep their current visibility.

Run rails public_tokens:backfill after deploy to generate tokens for
existing activities.
Copilot AI lite review requested due to automatic review settings August 15, 2026 09:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

0jonjo added 3 commits August 15, 2026 06:47
The activity form pre-checked the visibility box for new records, so the
manual entry path still submitted public=1 by default and quietly worked
around the new private-by-default column. Bind the checkbox to the record
instead, letting new activities inherit the DB default.

Guest seed activities are demo showcase data rather than real health
data, so they now opt in to public explicitly and keep rendering on the
guest public page.
Rows created before the backfill runs have a NULL public_token, and
athlete_activity_path cannot build a URL without one — so every public
athlete profile page raised UrlGenerationError until the backfill
finished. List only tokenized activities on the profile page; the stats
header still summarises every public activity.

The backfill itself wrote through update! and ran full validations, so a
single legacy record failing today's photo or GPX rules would abort the
whole run. Write the token column directly instead.

Dev seeds join the guest seeds in opting into public explicitly,
otherwise the seeded athlete page comes up empty.
…ivities

# Conflicts:
#	test/controllers/athletes_controller_test.rb
@0jonjo
0jonjo merged commit e53db7c into main Aug 15, 2026
5 checks passed
0jonjo added a commit that referenced this pull request Aug 15, 2026
main's tokenized public activity URLs (#142) landed after this branch
was written; update the one test the merge didn't already adjust to
use activity.public_token instead of activity.id.
0jonjo added a commit that referenced this pull request Aug 15, 2026
* feat: translate split headers and add i18n key tripwire

Add activity.splits.{title,elapsed,pace} to all 15 locales and use
them in the shared _splits partial instead of hardcoded English
("Splits", "Elapsed", "Pace"). The partial's cache key already
includes I18n.locale, so this is cache-safe.

Add test/i18n_keys_test.rb, a regression test that scans app/views
for absolute i18n keys (t("foo.bar")) and asserts each exists in the
:en locale, catching the same "translation missing" bug class that
has shipped to production twice. Running it surfaced several
pre-existing broken keys, now fixed in en.yml (other locales fall
back to en until translated):

- activities.form.type_run
- activity.splits.{title,elapsed,pace} (bug we're fixing here)
- calculator.age_grade_formula
- calculator.age_grade_levels.{active_beginner,approximate_world_record_level,intermediate,local_class,national_class,recreational,regional_class,world_class}
- calculator.distance_formula
- calculator.environmental.description
- pages.terms.{feedback_title,feedback_desc,links_title,links_desc}

* fix: use public_token in merged locale-header test

main's tokenized public activity URLs (#142) landed after this branch
was written; update the one test the merge didn't already adjust to
use activity.public_token instead of activity.id.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants