Audience: end users, customer support Difficulty: intermediate
What this covers
The two URLs that let an external party (typically a project customer
or a stakeholder) view a shared snapshot of a project, no login
required: /shared/projects/{token} and the shorter alias
/p/projects/{token}.
URL patterns
Two prefixes, identical handlers:
https://solavel.com/finance/shared/projects/{token}
https://solavel.com/finance/shared/projects/{token}/print
https://solavel.com/finance/p/projects/{token}
https://solavel.com/finance/p/projects/{token}/print
Defined in solavel-finance/routes/web.php (named projects.public-shares.*).
Both prefixes route to App\Http\Controllers\Projects\ProjectPublicShareController.
| URL | Method | Purpose |
|---|---|---|
/{token} |
GET | View the share — landing page with project summary |
/{token}/print |
GET | Printable / PDF-friendly view |
/p/projects/ is the short URL used in emails and SMS where character
count matters; /shared/projects/ is the full path. They are
interchangeable.
Auth & throttle
Middleware: throttle:portal-public only. No middleware-level token
verification — the controller validates the token itself before
rendering. This is inconsistent with the /i/{token} portal-link
flow, which has a dedicated middleware. The behaviour is acceptable
but worth noting if you are debugging an unexpected 200 response.
How a share is created
A staff user opens a project and clicks Share. The controller
issues a token (bin2hex(random_bytes(...))-style) and stores its
hash in the project share table. The URL is presented for copy/paste,
or emailed.
The exact share table schema and any expiry behaviour were not
extracted in the Phase 1 audit — confirm by reading
Projects/ProjectPublicShareController and the relevant migration.
Two design choices are likely:
- Token-only, no expiry. The share lives until staff revokes it.
- Token + expiry on the share row, validated in the controller.
What we do know:
- The route layer enforces only the throttle. Anything else is in the controller.
- The print sub-route works on the same token.
What the customer sees
Typical content (based on the controller's view name patterns):
- Project name, status, dates, customer.
- Milestones / deliverables.
- Tasks and their status — read-only.
- Optionally: time entries, budget vs actuals, attachments.
The page has no edit affordances. It is read-only by design.
When to use which
| You want… | Use |
|---|---|
| Send a customer a single document (invoice / quote) | secure-document-links |
| Send a customer a quote they can accept/decline | public-quote-links |
| Have a customer approve a time entry | customer-time-approvals |
| Show a customer the shape of an entire project | Project share (this page) |
| Give a customer ongoing self-serve access to all their docs | overview (account-based portal) |
Revocation
To kill a share, open the project, Shares tab (or the equivalent in the project sidebar), and click Revoke on the row. The link returns 404 immediately on next request. Token hashes are not deleted — just flagged.
Security notes
- Anyone with the URL can view. Treat it like a password.
- Tokens are long enough that brute-force is not a realistic concern.
- The page is not indexed by search engines (it should serve
noindexheaders — confirm in the controller). - Throttle bucket is shared with
/i/{token}(portal-public) so a burst of requests against one customer-facing surface affects the other on the same IP.