mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 14:12:39 +02:00
feat(machine): let the owner read what their machine has downloaded
GET /machine/:id/downloads lists every game the machine is downloading, has ready or failed to download, with progress and each game's name. The library already carries a download per game, but it is the most recent one across every host, which is not what a person watching their own machine set up wants to see. Owner-scoped in the query: a machine that is not yours answers 404, the same as one that does not exist.
This commit is contained in:
@@ -2,6 +2,7 @@ import { Actor } from '@nestri/core/actor';
|
||||
import { Box } from '@nestri/core/box/index';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { GameDownload } from '@nestri/core/game/download';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Machine } from '@nestri/core/machine/index';
|
||||
import { InstallToken } from '@nestri/core/machine/install-token';
|
||||
@@ -596,6 +597,48 @@ export namespace MachineApi {
|
||||
return c.json({ data: machine });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/:id/downloads',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['Machine'],
|
||||
summary: 'What a machine has downloaded',
|
||||
description:
|
||||
'Every game this machine is downloading, has ready, or failed to download, with how far each has got. Only the machine’s owner can read it.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.array(
|
||||
GameDownload.Info.extend({
|
||||
game: z.object({ name: z.string(), steamAppId: z.number() })
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Downloads on this machine, least recently changed first'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const data = await GameDownload.listForOwnedMachine({
|
||||
machineId: c.req.param('id'),
|
||||
ownerUserId: Actor.userID
|
||||
});
|
||||
if (!data) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'No such machine, or it is not yours'
|
||||
);
|
||||
}
|
||||
return c.json({ data });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/',
|
||||
notPublic,
|
||||
|
||||
@@ -950,3 +950,38 @@ describe('POST /session/:id/stop', () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /machine/:id/downloads', () => {
|
||||
test('what a host says it is downloading is what its owner reads', async () => {
|
||||
const s = await scene('downloads-owner', 5701);
|
||||
const said = await app.request('/games/download-state', {
|
||||
method: 'POST',
|
||||
headers: s.host,
|
||||
body: JSON.stringify({
|
||||
steamAppId: 5701,
|
||||
status: 'downloading',
|
||||
progressBytes: 40,
|
||||
totalBytes: 100
|
||||
})
|
||||
});
|
||||
expect(said.status).toBe(200);
|
||||
|
||||
const res = await app.request(`/machine/${s.machineId}/downloads`, { headers: s.user });
|
||||
expect(res.status).toBe(200);
|
||||
const data = ((await res.json()) as any).data;
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0]).toMatchObject({
|
||||
status: 'downloading',
|
||||
progressBytes: 40,
|
||||
totalBytes: 100,
|
||||
game: { steamAppId: 5701, name: 'Session Route 5701' }
|
||||
});
|
||||
});
|
||||
|
||||
test('somebody else’s machine is not there to read', async () => {
|
||||
const s = await scene('downloads-mine', 5702);
|
||||
const other = await scene('downloads-theirs', 5703);
|
||||
const res = await app.request(`/machine/${s.machineId}/downloads`, { headers: other.user });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { MachineTable } from '../machine/machine.sql.js';
|
||||
import { GameTable } from './game.sql.js';
|
||||
import { GameDownloadStatus, GameDownloadTable } from './download.sql.js';
|
||||
|
||||
export namespace GameDownload {
|
||||
@@ -151,6 +153,44 @@ export namespace GameDownload {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* What one machine has downloaded or is downloading, with each game's name,
|
||||
* for the person who owns it. Null when the machine is not there or not
|
||||
* theirs: owner-scoped in the query, so the two cannot be told apart.
|
||||
*/
|
||||
export const listForOwnedMachine = fn(
|
||||
z.object({ machineId: z.string(), ownerUserId: z.string() }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
const [machine] = await tx
|
||||
.select({ id: MachineTable.id })
|
||||
.from(MachineTable)
|
||||
.where(
|
||||
and(
|
||||
eq(MachineTable.id, input.machineId),
|
||||
eq(MachineTable.ownerUserId, input.ownerUserId),
|
||||
isNull(MachineTable.timeDeleted)
|
||||
)
|
||||
);
|
||||
if (!machine) return null;
|
||||
return tx
|
||||
.select({ download: GameDownloadTable, game: GameTable })
|
||||
.from(GameDownloadTable)
|
||||
.innerJoin(GameTable, eq(GameDownloadTable.gameId, GameTable.id))
|
||||
.where(
|
||||
and(eq(GameDownloadTable.hostId, machine.id), isNull(GameDownloadTable.timeDeleted))
|
||||
)
|
||||
.orderBy(GameDownloadTable.timeUpdated)
|
||||
.then((rows) =>
|
||||
rows.map((row) => ({
|
||||
...serialize(row.download),
|
||||
game: { name: row.game.name, steamAppId: row.game.steamAppId }
|
||||
}))
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const listByGameIDs = fn(z.array(z.string()), async (gameIds) => {
|
||||
if (gameIds.length === 0) return [];
|
||||
return Database.use(async (tx) => {
|
||||
|
||||
Reference in New Issue
Block a user