Sharing: public links, crew links, the public trip page¶
| Rails | |
|---|---|
| Status | ✅ |
| Code | app/models/{trip_link,crew_membership}.rb, app/controllers/{trip_links,crew_memberships,public_trips,crew_invites}_controller.rb, app/controllers/concerns/{shared_trip_link,trip_page}.rb, app/views/trips/{_link_dialog_body,_crew,_link_field,_map}.html.erb, app/views/components/_link_dialog.html.erb, app/views/{public_trips,crew_invites}/, app/views/layouts/public_trip.html.erb, app/helpers/sharing_helper.rb, app/jobs/trip_link_cleanup_job.rb, app/javascript/controllers/{link_dialog,share_link,live_refresh}_controller.js, config/locales/sharing.{en,de}.yml |
Verified against code: 2026-11-03
Purpose¶
Every trip is private. The owner can show it to anyone with a public link, and everyone on board can bring more crew in with a crew link: the crew then have the trip in their own logbook, read-only, and its miles count for them. Decisions and reasons: ADR 010.
Terms: trip, track, day as in glossary.md; public link, crew link, crew member are defined there too.
Rules¶
A third way to show a trip is the public skipper profile: a trip a user picked opens read-only through their profile, independent of links.
| Rule | Detail |
|---|---|
| Private by default | Only the owner and the trip's crew open /my/trips/:id; anyone else gets 404. There are no visibility levels. |
| Links expire | Every link, public or crew, works for 30 days from its creation (TripLink::LIFETIME), for everybody, the owner included. |
| Created by a click | A link is only created by a click: a public link by the owner or the skipper, the crew link by anyone on board (owner, skipper, crew). Opening the Share or Invite crew dialog creates one when none of that kind works, and "New link" always does. Opening a page never creates one. |
| Public links add up | The owner can have several working public links. "New link" adds one; "Stop sharing" ends all of them. |
| One crew link | Creating a crew link ends every crew link of the trip that wasn't ended yet. A partial unique index (index_trip_links_one_crew_link) keeps it at one, even with two clicks at once. People who joined stay on the crew. |
| Tokens only | A link is /t/<token> or /crew/<token>, the token 22 base58 characters (SecureRandom.base58(22)). The public page has no record ids in its HTML, URLs or JSON. A public token never opens a crew link and the other way round (each page looks up only its kind). |
| Roles | Everyone on a trip has one role: the owner, the crew's skipper (at most one) or crew. Crew look, edit the description, invite more crew and can leave; the skipper has the owner's rights except deleting the trip, deleting tracks they didn't upload and moving tracks. |
| Never indexed | Both pages send X-Robots-Tag: noindex, nofollow (the public page also as <meta name="robots">), and robots.txt disallows /t/ and /crew/ in every locale (public-site.md). |
| Rate limits | Per IP: 120 requests a minute on /t/, 60 on /crew/. |
Access¶
| Action | Owner | Skipper | Crew member | Anyone else |
|---|---|---|---|---|
Trip page /my/trips/:id |
✅ | ✅ | ✅ read-only | 404 |
Public page /t/<token> |
while the link works | while the link works | while the link works | while the link works |
Join via /crew/<token> |
goes to the trip | goes to the trip | goes to the trip | while the link works, with an account |
Trip replay /my/trips/:id/replay (trip-replay.md) |
✅ | ✅ | ✅ | 404; with a working public link /t/<token>/replay |
| All other actions | see Roles | see Roles | see Roles | 404 |
Roles¶
Everyone who sees a trip has one role on it (TripRole, app/models/trip_role.rb): the owner (the trip's account), from a crew membership skipper or crew (crew_memberships.role), or public on a public link. What each role may do is one table in code (TripRole::PERMISSIONS); controllers and views only ask for a permission (trip_can?), never for a role. Every trip lookup by id goes through Trip.with_permission(user, permission) (ADR 015); anything not allowed answers 404. A new role is one more value in CrewMembership::ROLES and one more row here.
| Permission | Owner | Skipper | Crew | Public |
|---|---|---|---|---|
view: trip page, Days, Tracks |
✅ | ✅ | ✅ | the public page |
edit_trip: title, area, boat (from the owner's boats), time zone, bridge gaps, night override |
✅ | ✅ | – | – |
edit_description: the trip's description |
✅ | ✅ | ✅ | – |
edit_tracks: track editor (trims, states, names) |
✅ | ✅ | – | – |
upload: upload into the trip |
✅ | ✅ | – | – |
export: GPX |
✅ | ✅ | – | – |
share: create and end public links |
✅ | ✅ | – | – |
invite_crew: the Invite crew dialog, create a new crew link (ADR 020) |
✅ | ✅ | ✅ | – |
manage_crew: remove crew, set or clear the skipper |
✅ | ✅ | – | – |
delete_track |
every track | only tracks they uploaded | – | – |
move_track: to another of the owner's trips |
✅ | – | – | – |
delete_trip |
✅ | – | – | – |
leave |
– | ✅ | ✅ | – |
- "I was the skipper" in Edit trip is the owner's own statement and only the owner sees it. It and a skipper in the crew exclude each other: setting one clears the other. At most one skipper per trip (
index_crew_memberships_one_skipper). - Tracks belong to whoever recorded them: a trip only holds its owner's tracks (ADR 015). A track the skipper uploads becomes the trip owner's track;
tracks.uploaded_by_idkeeps who uploaded it, so the skipper can delete it. The owner can edit and delete it like any track. It stays in the trip when the skipper leaves or deletes their account (uploaded_by_idis cleared). - The skipper's own logbook counts the trip as skipper: the Skipper badge, the card band and the "As skipper" filter (logbook.md). For the owner the trip then counts as not skippered.
Link dialog¶
One reusable modal for both kinds (components/_link_dialog with link_dialog_controller.js; body trips/_link_dialog_body).
| Opened by | Title and (?) | Opening |
|---|---|---|
The Share icon button in the owner's page head (#share-trip) |
"SHARE THIS TRIP", (?) "Anyone with this link can see the trip, its map and its numbers, without an account. After 30 days the link shows that it has expired, for you too." | POST /my/trips/:id/links/open?kind=public |
The orange Invite crew button on the right of the crew card's headline (#invite-crew), for everyone on board |
"INVITE CREW", (?) "Your crew need a SailEver account. The trip then shows up in their logbook, read-only, and its miles count for them." | POST /my/trips/:id/links/open?kind=crew |
Opening shows the newest working link of the kind; when none works it creates one first, so one click gives a link to copy. An open dialog never replaces a link already sent. The body is loaded without leaving the page and is kept through the page's own refreshes (data-turbo-permanent).
| Body | Content |
|---|---|
| A working link | Field "Public link" / "Crew link" (https://<host>/t/<token> or /crew/<token>, no locale prefix), "Works until navigator.share exists), Open (public only, new tab). Public with more than one working: "1 older link also works, until |
| No working link (after Stop sharing) | "Your last link stopped working on |
| Public footer | "Your first name is shown on the page." when the owner's account setting is on and they have a first name; otherwise "Your name is not shown. Change in Profile" (link to /my/profile). |
| Loading failed | "The link couldn't be loaded. Close and try again." |
New link (POST /my/trips/:id/links?kind=…&dialog=1) and Stop sharing (DELETE /my/trips/:id/links/public?dialog=1) answer with the new body. Without dialog (no JavaScript) they go back to the trip page with a notice. Dates are in the trip's time zone, long format.
Crew section¶
A card "CREW" (crew icon) below Days | Tracks on the trip page (#crew).
| Element | Owner and skipper | Crew member |
|---|---|---|
| First row | The owner: "You" (or their first name + last initial for the skipper) · "Skipper" (if "I was the skipper" is set) or "Owner" | The owner's first name + last initial ("Anna S.", or "Owner" without a name) · "Skipper" / "Owner" |
| One row per crew member (by joining) | First name + last initial, or the masked email m•••@example.com without a name; "Skipper" for the skipper, otherwise "joined |
Names only (first name + last initial, or "Crew member"), "Skipper" for the skipper; themselves as "You" with Leave this trip (confirm "Leave this trip? It disappears from your logbook and its miles no longer count for you."; then /my/trips with "You left |
| Empty | "No crew yet." | — |
| Headline | "CREW" (crew icon); on the right the orange Invite crew button, which opens the link dialog | The same |
Removing and leaving: DELETE /my/trips/:trip_id/crew_memberships/:id; crew and skipper can delete their own membership, owner and skipper anyone's. Make skipper / Not skipper: PATCH /my/trips/:trip_id/crew_memberships/:id/skipper?skipper=1|0 (owner and skipper).
Public trip page¶
GET /t/<token> (also with a locale prefix, /de/t/<token>), PublicTripsController, layout public_trip: the public header (signed in: "My Logbook"), the trip, the site footer. Signed-out visitors get the page in the URL's language (English without a prefix); new texts exist in English and German.
| Link | Answer |
|---|---|
| Working public link | 200, the page below |
| Ended (expired or stopped) | 410, "This link has expired" / "Shared trip links work for 30 days. Ask the person who sent it for a new one." and "Discover SailEver" → /. No trip details. The same for the owner. |
| Unknown token, or a crew token | 404 |
Content. Title; the description with its "Last edited by" line (the editor's first name only if they show it publicly, else "a crew member" / "the skipper"); meta line: sailing area, "/t/<token>/replay: the same replay as signed in, by token, without Edit track); the three readouts and the split bar (no Edit track button); the Days table only, under the heading "DAYS" ("TAGE"; no Tracks tab, ?view=tracks shows Days too); a footer line "Shared until /). <title> is the trip title, the description (also Open Graph) " ·
Left out. Back link, every button and form, export, the crew section, night override, track edit and Move, processing / recalculating / sync badges, the Device column and "Recorded with dom_ids, event ids in the map JSON; the page has no Turbo stream, whose signed name holds the trip's id), emails and last names.
On board. People are the owner, then the crew by joining. Only those who turned on "Show my first name on public trip pages" and have a first name are named, the skipper as "Anna (skipper)" (the owner with "I was the skipper", or the crew's skipper); the rest are counted: "On board: Anna (skipper), Lena, and 1 more". Nobody named: "On board: 3 sailors" ("1 sailor").
Live. While a track is being sent (trip.md § Map, live position), the page refreshes itself every 60 s while the tab is visible (live_refresh_controller.js, Turbo morph; the map redraws). An unchanged page is a 304.
Cache. ETag over the link, the trip (its description included), boat, the description's last editor, days, bridges, tracks, owner, crew (their names and settings), the tab, the frame, the locale and the release; Cache-Control: max-age=0, private, must-revalidate, so an ended link answers 410 on the next request.
Crew link¶
/crew/<token> (CrewInvitesController, layout auth). The link is checked on every request, including "Join trip" and the return after signing in.
| Who | GET | POST (Join trip) |
|---|---|---|
| Signed out | Invitation page: like the confirm page, but the owner's first name only if they show it on public pages (otherwise "A friend"; German "Jemand"), and one button Sign in to join (a POST, without Turbo). This is also what messengers show as the link preview. | Redirect to the sign-in page with redirect_to=/<locale>/crew/<token> and the notice "Sign in or create an account to join |
| Signed in, privacy or terms not current | The consent page first, then back here | — |
| Signed in | Confirm page: eyebrow "CREW", "Join /my) |
Creates the membership (once), then the trip page with "You're on the crew of |
| Already crew | Redirect to the trip | Redirect to the trip |
| The owner | Redirect to the trip, "This is your trip. Send the link to your crew." | Redirect to the trip, no membership |
| Ended link | 410, "This crew link has expired" / "Crew links work for 30 days, and a new one replaces the old one. Ask the person who sent it for a new one." | 410 |
| Unknown token, or a public token | 404 | 404 |
Opening the link never joins by itself: messenger previews open URLs too.
New accounts and the beta. The sign-in page creates an account for an unknown email after the privacy and terms checkboxes (auth.md). During the beta, unknown emails are normally sent to the beta form; when redirect_to is a working crew link (TripLink.crew_invite_path?), that counts as an approved beta invite: the account is created and has the full logbook (pairing, uploads), not only the crewed trip. A pending beta application with that email is set to invited. The magic link brings the new user back to the crew link.
Link previews¶
Messengers (WhatsApp, Signal, Telegram, iMessage, …) fetch a pasted link signed out and show its Open Graph tags. Both kinds of link set them with share_meta (DesignHelper):
| Tag | Public link | Crew link | Ended link (410) |
|---|---|---|---|
og:title |
trip title | trip title | "This link has expired" / "This crew link has expired" · SailEver |
og:description |
" · trip_summary) |
the same + " · Crew invitation" | the site description |
og:image |
the preview image once it is made, else the default image | the same | the default image |
Every page also has og:type website, og:url (the URL without query), og:image:width / height / alt and twitter:card summary_large_image. The default image is films/wa-poster.jpg (1280 × 720 JPEG; messengers don't show WebP reliably).
Preview image¶
The trip card's map as a JPEG of 1500 × 785 px (1.91:1, the shape messengers show best): the card's 300 px width at 5× resolution, 157 px high. Same base map, lines, colours and padding as the trip card; no live position, badges or skipper band. Under 300 kB (JPEG quality 80, else 65, else 50).
| Step | |
|---|---|
| Made | TripPreviewJob (queue background, one at a time) when a link is created, and 10 minutes after the card's lines change while a link works. Skipped when the stored image shows the same lines (TripPreview.digest, in the file name) and when no preview host is configured. |
| How | TripPreview: headless Chromium (Ferrum, in the jobs role, ADR 014) opens /previews/<signed trip id> (valid 5 minutes, no session, noindex) at 300 × 157 CSS px and device pixel ratio 5, waits until the page sets <html data-preview-ready> (tiles loaded, at most 15 s) and takes the screenshot. |
| Stored | trips.preview_image (Active Storage, private bucket). |
| Served | /t/<token>/preview.jpg and /crew/<token>/preview.jpg, only while the link works (ended: 410, unknown: 404), Cache-Control: public, max-age=3600; before it is made, a redirect to the default image. og:image carries ?v=<digest>, so a changed image gets a new URL. The crew invitation page shows it above the trip summary. |
| Deleted | By TripLinkCleanupJob (daily) once no link of the trip works; with the trip or the account. |
Account setting¶
"Show my first name on public trip pages" on /my/profile (users.show_first_name_publicly, default off): see auth.md. Signed-in pages (trip page, crew section, confirm page) show first names regardless, because only owner and crew see them.
Data¶
trip_links and crew_memberships, users.show_first_name_publicly: data-model.md. Deleting the trip or the owner's account deletes its links and memberships; deleting a crew member's account deletes their memberships (data-model.md § Deletion). TripLinkCleanupJob (daily 05:00) deletes links that ended more than 30 days ago (until then they answer 410 instead of 404) and the preview image of every trip without a working link.
Totals¶
Crewed trips count in the crew member's totals and year chart, each date once: logbook.md § Counted days.
Known deviations¶
- The live position follows the watch's upload rhythm (first segment after about 15 s, then about hourly), not real time.
- The other five languages have no texts for the public page and the crew link yet; they show English.
- The crew invitation page names the trip's owner ("Anna invited you") also when a crew member created the link.
- The mobile apps' share sheet (../mobile/trips.md) is not built.