feat: add public read-only sharing for computers
Build & Deploy / build (push) Failing after 5s
Build & Deploy / docker (push) Skipped

Adds the ability for an owner to share a computer build via a public,
unauthenticated link, inspired by a feature request from the original
pcbuilder repo (no such feature actually existed there — implemented
from scratch here).

- New columns on computers: share_token (unique, nullable), share_enabled,
  share_show_sell_price, share_show_cost
- computer_share_service.ts: generates a non-guessable share token
  (crypto.randomBytes, base64url), enables/disables sharing, and supports
  rotating the token to invalidate previously distributed links
- ComputerShareController (authenticated, owner-scoped like the rest of
  the app): PUT /computers/:id/share to update settings, POST
  /computers/:id/share/rotate to rotate the token
- PublicComputerController + GET /share/:token (outside any auth/guest
  group, reachable anonymously): resolves only computers with
  share_enabled = true, 404 otherwise
- ComputerPublicTransformer: dedicated read-only projection that never
  leaks userId, and only includes sellPrice/cost when the owner opted in
  via share_show_sell_price / share_show_cost — everything else (title,
  scores, state, components, pictures) is always visible on a shared link
- ComputerShareModal.vue: owner-facing UI (toggle sharing, choose which
  prices to expose, copy the public link, revoke/rotate it) wired into
  computers/show.vue via a new 'Share' button
- share/show.vue: public page rendered with EmptyLayout (no authenticated
  header/sidebar)
- Boolean columns use a  cast since MySQL returns tinyint(1),
  not native booleans, through Lucid
- 6 new functional tests covering: enabling sharing, price visibility
  toggles, disabling sharing revokes the link, cross-user protection on
  the share settings endpoint (404), token rotation invalidating the old
  link, and anonymous access to a shared computer
This commit is contained in:
Kevin L'Hereec committed 2026-07-23 11:43:38 +02:00
1 parent 84ae2b9617
commit da71d33450
22 files changed
+714 -8

No files matched your search

@@ -0,0 +1,46 @@
import type { HttpContext } from '@adonisjs/core/http'
import Computer from '#models/computer'
import { updateShareSettingsValidator } from '#validators/Computers/share'
import { disableSharing, enableSharing, rotateShareToken } from '#services/computer_share_service'
export default class ComputerShareController {
/**
* Update the share settings (enable/disable + which prices are visible)
* for a computer owned by the current user.
*/
async update({ auth, params, request, response }: HttpContext) {
const user = await auth.authenticate()
const computer = await Computer.query()
.where('user_id', user.id)
.andWhere('id', params.id)
.firstOrFail()
const { enabled, showSellPrice, showCost } = await request.validateUsing(
updateShareSettingsValidator
)
if (enabled) {
await enableSharing(computer, { showSellPrice, showCost })
} else {
await disableSharing(computer)
}
return response.redirect(`/computers/${computer.id}`)
}
/**
* Rotate the share token for a computer, invalidating any previously
* distributed public link.
*/
async rotate({ auth, params, response }: HttpContext) {
const user = await auth.authenticate()
const computer = await Computer.query()
.where('user_id', user.id)
.andWhere('id', params.id)
.firstOrFail()
await rotateShareToken(computer)
return response.redirect(`/computers/${computer.id}`)
}
}
+3 -1
View File
@@ -80,7 +80,9 @@ export default class ComputersController {
.where('user_id', user.id)
.andWhere('id', computerId)
.firstOrFail()
return inertia.render('computers/show', { computer: asPageProp(computer.serialize()) })
return inertia.render('computers/show', {
computer: asPageProp({ ...computer.serialize(), shareToken: computer.shareToken }),
})
}
async edit({ params, inertia, auth }: HttpContext) {
@@ -0,0 +1,32 @@
import type { HttpContext } from '@adonisjs/core/http'
import Computer from '#models/computer'
import ComputerPublicTransformer from '#transformers/computer_public_transformer'
export default class PublicComputerController {
/**
* Publicly display a computer via its share token. No authentication
* required. Only resolves computers with sharing explicitly enabled.
*/
async show({ params, inertia, response }: HttpContext) {
const computer = await Computer.query()
.where('share_token', params.token)
.andWhere('share_enabled', true)
.preload('state')
.preload('components', (componentsQuery) => {
componentsQuery.preload('type')
})
.preload('pictures', (picturesQuery) => {
picturesQuery.orderBy('order')
})
.first()
if (!computer) {
response.status(404)
return inertia.render('errors/not_found', {})
}
return inertia.render('share/show', {
computer: ComputerPublicTransformer.transform(computer),
})
}
}