feat: add public read-only sharing for computers
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:
1 parent
84ae2b9617
commit
da71d33450
22 files changed
+714
-8
No files matched your search
@@ -0,0 +1,170 @@
|
||||
import { test } from '@japa/runner'
|
||||
import User from '#models/user'
|
||||
import Computer from '#models/computer'
|
||||
import ComputerState from '#models/computer_state'
|
||||
|
||||
async function createUser(email: string) {
|
||||
return User.create({
|
||||
name: 'Test User',
|
||||
email,
|
||||
password: 'secret@123A',
|
||||
})
|
||||
}
|
||||
|
||||
async function createState(name = 'ON_GOING') {
|
||||
return ComputerState.firstOrCreate({ name }, { name })
|
||||
}
|
||||
|
||||
async function createComputer(userId: number, stateId: number) {
|
||||
return Computer.create({
|
||||
title: 'Shared PC',
|
||||
sellPrice: 999,
|
||||
gpuScore: 5,
|
||||
cpuScore: 5,
|
||||
globalScore: 5,
|
||||
stateId,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
|
||||
test.group('Computer sharing', () => {
|
||||
test('owner can enable sharing and the public page becomes reachable', async ({
|
||||
client,
|
||||
assert,
|
||||
}) => {
|
||||
const owner = await createUser('share-owner-a@example.com')
|
||||
const state = await createState()
|
||||
const computer = await createComputer(owner.id, state.id)
|
||||
|
||||
const enableResponse = await client
|
||||
.put(`/computers/${computer.id}/share`)
|
||||
.loginAs(owner)
|
||||
.withCsrfToken()
|
||||
.redirects(0)
|
||||
.json({ enabled: true, showSellPrice: true, showCost: false })
|
||||
|
||||
enableResponse.assertStatus(302)
|
||||
await computer.refresh()
|
||||
assert.isTrue(computer.shareEnabled)
|
||||
assert.isString(computer.shareToken)
|
||||
|
||||
const publicResponse = await client
|
||||
.get(`/share/${computer.shareToken}`)
|
||||
.header('X-Inertia', 'true')
|
||||
.header('X-Inertia-Version', '1')
|
||||
.redirects(0)
|
||||
publicResponse.assertStatus(200)
|
||||
publicResponse.assertBodyContains({ props: { computer: { title: 'Shared PC' } } })
|
||||
})
|
||||
|
||||
test('sell price and cost are hidden unless explicitly enabled', async ({ client }) => {
|
||||
const owner = await createUser('share-owner-b@example.com')
|
||||
const state = await createState()
|
||||
const computer = await createComputer(owner.id, state.id)
|
||||
|
||||
await client
|
||||
.put(`/computers/${computer.id}/share`)
|
||||
.loginAs(owner)
|
||||
.withCsrfToken()
|
||||
.redirects(0)
|
||||
.json({ enabled: true, showSellPrice: false, showCost: false })
|
||||
await computer.refresh()
|
||||
|
||||
const publicResponse = await client
|
||||
.get(`/share/${computer.shareToken}`)
|
||||
.header('X-Inertia', 'true')
|
||||
.header('X-Inertia-Version', '1')
|
||||
.redirects(0)
|
||||
publicResponse.assertStatus(200)
|
||||
publicResponse.assertBodyContains({ props: { computer: { sellPrice: null, cost: null } } })
|
||||
})
|
||||
|
||||
test('disabling sharing makes the public link unreachable', async ({ client }) => {
|
||||
const owner = await createUser('share-owner-c@example.com')
|
||||
const state = await createState()
|
||||
const computer = await createComputer(owner.id, state.id)
|
||||
|
||||
await client
|
||||
.put(`/computers/${computer.id}/share`)
|
||||
.loginAs(owner)
|
||||
.withCsrfToken()
|
||||
.redirects(0)
|
||||
.json({ enabled: true, showSellPrice: false, showCost: false })
|
||||
await computer.refresh()
|
||||
const token = computer.shareToken
|
||||
|
||||
await client
|
||||
.put(`/computers/${computer.id}/share`)
|
||||
.loginAs(owner)
|
||||
.withCsrfToken()
|
||||
.redirects(0)
|
||||
.json({ enabled: false, showSellPrice: false, showCost: false })
|
||||
|
||||
const publicResponse = await client.get(`/share/${token}`).redirects(0)
|
||||
publicResponse.assertStatus(404)
|
||||
})
|
||||
|
||||
test('a user cannot enable sharing on another user computer', async ({ client }) => {
|
||||
const owner = await createUser('share-owner-d@example.com')
|
||||
const attacker = await createUser('share-attacker-d@example.com')
|
||||
const state = await createState()
|
||||
const computer = await createComputer(owner.id, state.id)
|
||||
|
||||
const response = await client
|
||||
.put(`/computers/${computer.id}/share`)
|
||||
.loginAs(attacker)
|
||||
.withCsrfToken()
|
||||
.redirects(0)
|
||||
.json({ enabled: true, showSellPrice: true, showCost: true })
|
||||
|
||||
response.assertStatus(404)
|
||||
})
|
||||
|
||||
test('rotating the share token invalidates the previous link', async ({ client, assert }) => {
|
||||
const owner = await createUser('share-owner-e@example.com')
|
||||
const state = await createState()
|
||||
const computer = await createComputer(owner.id, state.id)
|
||||
|
||||
await client
|
||||
.put(`/computers/${computer.id}/share`)
|
||||
.loginAs(owner)
|
||||
.withCsrfToken()
|
||||
.redirects(0)
|
||||
.json({ enabled: true, showSellPrice: false, showCost: false })
|
||||
await computer.refresh()
|
||||
const oldToken = computer.shareToken
|
||||
|
||||
await client
|
||||
.post(`/computers/${computer.id}/share/rotate`)
|
||||
.loginAs(owner)
|
||||
.withCsrfToken()
|
||||
.redirects(0)
|
||||
await computer.refresh()
|
||||
|
||||
assert.notEqual(computer.shareToken, oldToken)
|
||||
|
||||
const oldLinkResponse = await client.get(`/share/${oldToken}`).redirects(0)
|
||||
oldLinkResponse.assertStatus(404)
|
||||
|
||||
const newLinkResponse = await client.get(`/share/${computer.shareToken}`).redirects(0)
|
||||
newLinkResponse.assertStatus(200)
|
||||
})
|
||||
|
||||
test('an unauthenticated visitor can view a shared computer', async ({ client }) => {
|
||||
const owner = await createUser('share-owner-f@example.com')
|
||||
const state = await createState()
|
||||
const computer = await createComputer(owner.id, state.id)
|
||||
|
||||
await client
|
||||
.put(`/computers/${computer.id}/share`)
|
||||
.loginAs(owner)
|
||||
.withCsrfToken()
|
||||
.redirects(0)
|
||||
.json({ enabled: true, showSellPrice: true, showCost: true })
|
||||
await computer.refresh()
|
||||
|
||||
// No .loginAs() here: simulates a fully anonymous visitor.
|
||||
const response = await client.get(`/share/${computer.shareToken}`).redirects(0)
|
||||
response.assertStatus(200)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user