feat: tokenized public activity URLs, private by default - #142
Merged
Conversation
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.
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Public activity pages were addressed by sequential primary keys
(
/:username/activities/123). Two problems:activity in the app, and the highest reachable id leaks the size of the
activity/user base.
activities.publicdefaulted totrue, so everyactivity — including heart rate, VO2 max and GPS traces — was born
shareable, whatever the athlete's intent.
What changed
activities.public_token(unique index) generated viahas_secure_token.get ":username/activities/:token", andthe controller does
find_by!(public_token: params[:token]). A numeric idin the URL now 404s.
activities.publicdefault flipped fromtruetofalse.Activity.tokenized),so untokenized rows cannot break URL generation before the backfill lands.
public_tokens:backfillrake task for existing rows.Visibility: what does and does not change for existing data
public: true. The migration only changes the columndefault; there is no retro-privatization and no mass update, so links
already shared with other people stay live.
Activity.logcaller that does notpass
public:explicitly now creates a private activity. The Strava importeralready passes
public: falseexplicitly and is unaffected; the manual form,guest seeds and dev seeds pass
public: truewhere visibility is intended.(
activities.form.public_label), so no new UI or locale keys were needed.It did, however, hardcode
checked: truefor new records, which would havekept submitting
public=1and silently defeated the new column default —the checkbox is now bound to the record and starts unchecked.
Post-deploy step
Run this as part of the deploy, not later. Until it completes, existing
activities have a
NULLtoken and are unreachable at their public URL, sopreviously 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 columndirectly rather than through
update!, so one legacy record that no longerpasses today's photo/GPX validations cannot abort the run for everyone else.
Failure mode is safe throughout:
NULLnever matches a token in the URL, sothere 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.