diff --git a/.adonisjs/client/data.d.ts b/.adonisjs/client/data.d.ts index 3b8c719..4fb8e22 100644 --- a/.adonisjs/client/data.d.ts +++ b/.adonisjs/client/data.d.ts @@ -6,10 +6,15 @@ /// import type { InferData, InferVariants } from '@adonisjs/core/types/transformers' import type { InferSharedProps } from '@adonisjs/inertia/types' +import type ComputerPublicTransformer from '#transformers/computer_public_transformer' import type UserTransformer from '#transformers/user_transformer' import type InertiaMiddleware from '#middleware/inertia_middleware' export namespace Data { + export type ComputerPublic = InferData + export namespace ComputerPublic { + export type Variants = InferVariants + } export type User = InferData export namespace User { export type Variants = InferVariants diff --git a/.adonisjs/client/registry/index.ts b/.adonisjs/client/registry/index.ts index 5a1b2aa..b5e9771 100644 --- a/.adonisjs/client/registry/index.ts +++ b/.adonisjs/client/registry/index.ts @@ -84,6 +84,24 @@ const routes = { tokens: [{"old":"/computers/:id","type":0,"val":"computers","end":""},{"old":"/computers/:id","type":1,"val":"id","end":""}], types: placeholder as Registry['computers.destroy']['types'], }, + 'computers.share.update': { + methods: ["PUT"], + pattern: '/computers/:id/share', + tokens: [{"old":"/computers/:id/share","type":0,"val":"computers","end":""},{"old":"/computers/:id/share","type":1,"val":"id","end":""},{"old":"/computers/:id/share","type":0,"val":"share","end":""}], + types: placeholder as Registry['computers.share.update']['types'], + }, + 'computers.share.rotate': { + methods: ["POST"], + pattern: '/computers/:id/share/rotate', + tokens: [{"old":"/computers/:id/share/rotate","type":0,"val":"computers","end":""},{"old":"/computers/:id/share/rotate","type":1,"val":"id","end":""},{"old":"/computers/:id/share/rotate","type":0,"val":"share","end":""},{"old":"/computers/:id/share/rotate","type":0,"val":"rotate","end":""}], + types: placeholder as Registry['computers.share.rotate']['types'], + }, + 'share.show': { + methods: ["GET","HEAD"], + pattern: '/share/:token', + tokens: [{"old":"/share/:token","type":0,"val":"share","end":""},{"old":"/share/:token","type":1,"val":"token","end":""}], + types: placeholder as Registry['share.show']['types'], + }, 'components.index': { methods: ["GET","HEAD"], pattern: '/components', diff --git a/.adonisjs/client/registry/schema.d.ts b/.adonisjs/client/registry/schema.d.ts index 1784d5b..2c8df0c 100644 --- a/.adonisjs/client/registry/schema.d.ts +++ b/.adonisjs/client/registry/schema.d.ts @@ -163,6 +163,42 @@ export interface Registry { errorResponse: ExtractErrorResponse>> } } + 'computers.share.update': { + methods: ["PUT"] + pattern: '/computers/:id/share' + types: { + body: ExtractBody> + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'computers.share.rotate': { + methods: ["POST"] + pattern: '/computers/:id/share/rotate' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'share.show': { + methods: ["GET","HEAD"] + pattern: '/share/:token' + types: { + body: {} + paramsTuple: [ParamValue] + params: { token: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } 'components.index': { methods: ["GET","HEAD"] pattern: '/components' diff --git a/.adonisjs/client/registry/tree.d.ts b/.adonisjs/client/registry/tree.d.ts index b0050d9..03c555d 100644 --- a/.adonisjs/client/registry/tree.d.ts +++ b/.adonisjs/client/registry/tree.d.ts @@ -20,6 +20,13 @@ export interface ApiDefinition { edit: typeof routes['computers.edit'] update: typeof routes['computers.update'] destroy: typeof routes['computers.destroy'] + share: { + update: typeof routes['computers.share.update'] + rotate: typeof routes['computers.share.rotate'] + } + } + share: { + show: typeof routes['share.show'] } components: { index: typeof routes['components.index'] diff --git a/.adonisjs/server/controllers.ts b/.adonisjs/server/controllers.ts index 0b4ceb3..371cf81 100644 --- a/.adonisjs/server/controllers.ts +++ b/.adonisjs/server/controllers.ts @@ -5,7 +5,9 @@ export const controllers = { Components: () => import('#controllers/components_controller'), + ComputerShare: () => import('#controllers/computer_share_controller'), Computers: () => import('#controllers/computers_controller'), NewAccount: () => import('#controllers/new_account_controller'), + PublicComputer: () => import('#controllers/public_computer_controller'), Session: () => import('#controllers/session_controller'), } diff --git a/.adonisjs/server/pages.d.ts b/.adonisjs/server/pages.d.ts index 69d550b..89463a0 100644 --- a/.adonisjs/server/pages.d.ts +++ b/.adonisjs/server/pages.d.ts @@ -26,11 +26,13 @@ declare module '@adonisjs/inertia/types' { 'layout/emptyLayout': ExtractProps<(typeof import('../../inertia/pages/layout/emptyLayout.vue'))['default']> 'layout/header': ExtractProps<(typeof import('../../inertia/pages/layout/header.vue'))['default']> 'layout/sidebar': ExtractProps<(typeof import('../../inertia/pages/layout/sidebar.vue'))['default']> + 'share/show': ExtractProps<(typeof import('../../inertia/pages/share/show.vue'))['default']> 'widgets/AuthErrorAlert': ExtractProps<(typeof import('../../inertia/pages/widgets/AuthErrorAlert.vue'))['default']> 'widgets/ComponentEditor': ExtractProps<(typeof import('../../inertia/pages/widgets/ComponentEditor.vue'))['default']> 'widgets/ComponentsViewer': ExtractProps<(typeof import('../../inertia/pages/widgets/ComponentsViewer.vue'))['default']> 'widgets/ComputerCard': ExtractProps<(typeof import('../../inertia/pages/widgets/ComputerCard.vue'))['default']> 'widgets/ComputerComponentsTable': ExtractProps<(typeof import('../../inertia/pages/widgets/ComputerComponentsTable.vue'))['default']> + 'widgets/ComputerShareModal': ExtractProps<(typeof import('../../inertia/pages/widgets/ComputerShareModal.vue'))['default']> 'widgets/ComputersViewer': ExtractProps<(typeof import('../../inertia/pages/widgets/ComputersViewer.vue'))['default']> 'widgets/CustomSelect': ExtractProps<(typeof import('../../inertia/pages/widgets/CustomSelect.vue'))['default']> 'widgets/DeleteConfirmationModal': ExtractProps<(typeof import('../../inertia/pages/widgets/DeleteConfirmationModal.vue'))['default']> diff --git a/.adonisjs/server/routes.d.ts b/.adonisjs/server/routes.d.ts index c98acb8..b704291 100644 --- a/.adonisjs/server/routes.d.ts +++ b/.adonisjs/server/routes.d.ts @@ -17,6 +17,9 @@ export type ScannedRoutes = { 'computers.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'computers.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'computers.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'computers.share.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'computers.share.rotate': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'share.show': { paramsTuple: [ParamValue]; params: {'token': ParamValue} } 'components.index': { paramsTuple?: []; params?: {} } 'components.create': { paramsTuple?: []; params?: {} } 'components.store': { paramsTuple?: []; params?: {} } @@ -33,6 +36,7 @@ export type ScannedRoutes = { 'computers.create': { paramsTuple?: []; params?: {} } 'computers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'computers.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'share.show': { paramsTuple: [ParamValue]; params: {'token': ParamValue} } 'components.index': { paramsTuple?: []; params?: {} } 'components.create': { paramsTuple?: []; params?: {} } 'components.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } @@ -46,6 +50,7 @@ export type ScannedRoutes = { 'computers.create': { paramsTuple?: []; params?: {} } 'computers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'computers.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'share.show': { paramsTuple: [ParamValue]; params: {'token': ParamValue} } 'components.index': { paramsTuple?: []; params?: {} } 'components.create': { paramsTuple?: []; params?: {} } 'components.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } @@ -56,10 +61,12 @@ export type ScannedRoutes = { 'session.store': { paramsTuple?: []; params?: {} } 'session.destroy': { paramsTuple?: []; params?: {} } 'computers.store': { paramsTuple?: []; params?: {} } + 'computers.share.rotate': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'components.store': { paramsTuple?: []; params?: {} } } PUT: { 'computers.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'computers.share.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'components.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } } DELETE: { diff --git a/app/controllers/computer_share_controller.ts b/app/controllers/computer_share_controller.ts new file mode 100644 index 0000000..cf5cc1e --- /dev/null +++ b/app/controllers/computer_share_controller.ts @@ -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}`) + } +} diff --git a/app/controllers/computers_controller.ts b/app/controllers/computers_controller.ts index ffb2bd3..e4f912f 100644 --- a/app/controllers/computers_controller.ts +++ b/app/controllers/computers_controller.ts @@ -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) { diff --git a/app/controllers/public_computer_controller.ts b/app/controllers/public_computer_controller.ts new file mode 100644 index 0000000..716e321 --- /dev/null +++ b/app/controllers/public_computer_controller.ts @@ -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), + }) + } +} diff --git a/app/models/computer.ts b/app/models/computer.ts index f74e8f4..8ec18ff 100644 --- a/app/models/computer.ts +++ b/app/models/computer.ts @@ -44,6 +44,18 @@ export default class Computer extends BaseModel { @column({ serializeAs: null }) declare userId: number + @column({ serializeAs: null }) + declare shareToken: string | null + + @column({ consume: (value: unknown) => Boolean(value) }) + declare shareEnabled: boolean + + @column({ consume: (value: unknown) => Boolean(value) }) + declare shareShowSellPrice: boolean + + @column({ consume: (value: unknown) => Boolean(value) }) + declare shareShowCost: boolean + @column.dateTime({ autoCreate: true }) declare createdAt: DateTime diff --git a/app/services/computer_share_service.ts b/app/services/computer_share_service.ts new file mode 100644 index 0000000..5d87e76 --- /dev/null +++ b/app/services/computer_share_service.ts @@ -0,0 +1,50 @@ +import { randomBytes } from 'node:crypto' +import Computer from '#models/computer' + +/** + * Generates a URL-safe, non-guessable share token. + */ +function generateShareToken(): string { + return randomBytes(24).toString('base64url') +} + +/** + * Enables sharing for a computer, generating a share token if it doesn't + * already have one. Idempotent: calling it again just updates the display + * options without rotating the existing token. + */ +async function enableSharing( + computer: Computer, + options: { showSellPrice: boolean; showCost: boolean } +) { + if (!computer.shareToken) { + computer.shareToken = generateShareToken() + } + computer.shareEnabled = true + computer.shareShowSellPrice = options.showSellPrice + computer.shareShowCost = options.showCost + await computer.save() + return computer +} + +/** + * Disables sharing for a computer. The share token is kept so re-enabling + * later doesn't require distributing a new link, but the public route only + * resolves computers where shareEnabled is true. + */ +async function disableSharing(computer: Computer) { + computer.shareEnabled = false + await computer.save() + return computer +} + +/** + * Rotates the share token, invalidating any previously distributed link. + */ +async function rotateShareToken(computer: Computer) { + computer.shareToken = generateShareToken() + await computer.save() + return computer +} + +export { enableSharing, disableSharing, rotateShareToken } diff --git a/app/transformers/computer_public_transformer.ts b/app/transformers/computer_public_transformer.ts new file mode 100644 index 0000000..b39efc4 --- /dev/null +++ b/app/transformers/computer_public_transformer.ts @@ -0,0 +1,30 @@ +import type Computer from '#models/computer' + +/** + * Transforms a Computer into the shape exposed on the public share page. + * Unlike the regular serialize(), this never leaks the owner id, and only + * includes sellPrice/cost when the owner explicitly enabled that on the + * share settings. + */ +export default class ComputerPublicTransformer { + static transform(computer: typeof Computer.prototype) { + return { + id: computer.id, + title: computer.title, + gpuScore: computer.gpuScore, + cpuScore: computer.cpuScore, + globalScore: computer.globalScore, + state: { name: computer.state.name }, + sellPrice: computer.shareShowSellPrice ? computer.sellPrice : null, + cost: computer.shareShowCost ? computer.cost : null, + pictures: computer.pictures + .map((picture) => ({ link: picture.link, order: picture.order })) + .sort((a, b) => a.order - b.order), + components: computer.components.map((component) => ({ + id: component.id, + name: component.name, + type: { name: component.type.name }, + })), + } + } +} diff --git a/app/validators/Computers/share.ts b/app/validators/Computers/share.ts new file mode 100644 index 0000000..f2989ab --- /dev/null +++ b/app/validators/Computers/share.ts @@ -0,0 +1,9 @@ +import vine from '@vinejs/vine' + +export const updateShareSettingsValidator = vine.compile( + vine.object({ + enabled: vine.boolean(), + showSellPrice: vine.boolean(), + showCost: vine.boolean(), + }) +) diff --git a/database/migrations/1784799344283_alter_computers_table.ts b/database/migrations/1784799344283_alter_computers_table.ts new file mode 100644 index 0000000..b31f834 --- /dev/null +++ b/database/migrations/1784799344283_alter_computers_table.ts @@ -0,0 +1,23 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'computers' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.string('share_token', 64).nullable().unique() + table.boolean('share_enabled').notNullable().defaultTo(false) + table.boolean('share_show_sell_price').notNullable().defaultTo(false) + table.boolean('share_show_cost').notNullable().defaultTo(false) + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('share_token') + table.dropColumn('share_enabled') + table.dropColumn('share_show_sell_price') + table.dropColumn('share_show_cost') + }) + } +} diff --git a/database/schema.ts b/database/schema.ts index 11a42b3..128cd37 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -68,7 +68,7 @@ export class ComputerStateSchema extends BaseModel { } export class ComputerSchema extends BaseModel { - static $columns = ['cpuScore', 'createdAt', 'globalScore', 'gpuScore', 'id', 'sellPrice', 'stateId', 'title', 'updatedAt', 'userId'] as const + static $columns = ['cpuScore', 'createdAt', 'globalScore', 'gpuScore', 'id', 'sellPrice', 'shareEnabled', 'shareShowCost', 'shareShowSellPrice', 'shareToken', 'stateId', 'title', 'updatedAt', 'userId'] as const $columns = ComputerSchema.$columns @column() declare cpuScore: number @@ -83,6 +83,14 @@ export class ComputerSchema extends BaseModel { @column() declare sellPrice: number | null @column() + declare shareEnabled: boolean + @column() + declare shareShowCost: boolean + @column() + declare shareShowSellPrice: boolean + @column() + declare shareToken: string | null + @column() declare stateId: number @column() declare title: string diff --git a/inertia/pages/computers/show.vue b/inertia/pages/computers/show.vue index 1196235..8927146 100644 --- a/inertia/pages/computers/show.vue +++ b/inertia/pages/computers/show.vue @@ -1,15 +1,34 @@