diff --git a/inertia/pages/share/show.vue b/inertia/pages/share/show.vue
new file mode 100644
index 0000000..ff33068
--- /dev/null
+++ b/inertia/pages/share/show.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
Shared build
+
+ {{ computer.title }}
+
+
+
![Computer picture]()
+
+
+
+
+
+
+
+ | Name |
+ Type |
+
+
+
+
+ | {{ component.name }} |
+ {{ component.type?.name }} |
+
+
+
+
+
+
+
+
+
+
+ {{ computer.gpuScore }}
+
+
+
+
+
+ {{ computer.cpuScore }}
+
+
+
+
+
+ {{ computer.globalScore }}
+
+
+
+
+
+
+
+
+ {{ computer.state.name }}
+
+
+
+
+
+ ${{ computer.sellPrice.toFixed(2) }}
+
+
+
+
+
+ ${{ computer.cost.toFixed(2) }}
+
+
+
+
+
+
diff --git a/inertia/pages/types/UITypes.ts b/inertia/pages/types/UITypes.ts
index de466fe..0a3d4bd 100644
--- a/inertia/pages/types/UITypes.ts
+++ b/inertia/pages/types/UITypes.ts
@@ -20,6 +20,23 @@ export type Computer = {
cost: number
pictures: { link: string; order: number }[]
components: Component[]
+ shareEnabled: boolean
+ shareShowSellPrice: boolean
+ shareShowCost: boolean
+ shareToken?: string | null
+}
+
+export type PublicComputer = {
+ id: number
+ title: string
+ gpuScore: number
+ cpuScore: number
+ globalScore: number
+ state: { name: string }
+ sellPrice: number | null
+ cost: number | null
+ pictures: { link: string; order: number }[]
+ components: { id: number; name: string; type: { name: string } }[]
}
export type Component = {
diff --git a/inertia/pages/widgets/ComputerShareModal.vue b/inertia/pages/widgets/ComputerShareModal.vue
new file mode 100644
index 0000000..d20ca08
--- /dev/null
+++ b/inertia/pages/widgets/ComputerShareModal.vue
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
Share this build
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/start/routes.ts b/start/routes.ts
index 5fe2ae8..c166159 100644
--- a/start/routes.ts
+++ b/start/routes.ts
@@ -11,9 +11,11 @@ import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
import router from '@adonisjs/core/services/router'
-router.get('/', async ({ response }) => {
- return response.redirect('/auth/login')
-}).as('home')
+router
+ .get('/', async ({ response }) => {
+ return response.redirect('/auth/login')
+ })
+ .as('home')
router
.group(() => {
@@ -42,10 +44,16 @@ router
router.get('/:id/edit', [controllers.Computers, 'edit']).as('computers.edit')
router.put('/:id', [controllers.Computers, 'update']).as('computers.update')
router.delete('/:id', [controllers.Computers, 'destroy']).as('computers.destroy')
+ router.put('/:id/share', [controllers.ComputerShare, 'update']).as('computers.share.update')
+ router
+ .post('/:id/share/rotate', [controllers.ComputerShare, 'rotate'])
+ .as('computers.share.rotate')
})
.prefix('/computers')
.use(middleware.auth())
+router.get('/share/:token', [controllers.PublicComputer, 'show']).as('share.show')
+
router
.group(() => {
router.get('/', [controllers.Components, 'index']).as('components.index')
diff --git a/tests/functional/computer_share.spec.ts b/tests/functional/computer_share.spec.ts
new file mode 100644
index 0000000..8780134
--- /dev/null
+++ b/tests/functional/computer_share.spec.ts
@@ -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)
+ })
+})