mirror of
https://github.com/Drop-OSS/drop.git
synced 2026-07-26 17:54:44 +10:00
feat: add cloud save backend
This commit is contained in:
@@ -50,7 +50,7 @@ export default defineNuxtConfig({
|
|||||||
// Module config from here down
|
// Module config from here down
|
||||||
modules: [
|
modules: [
|
||||||
"vue3-carousel-nuxt",
|
"vue3-carousel-nuxt",
|
||||||
"nuxt-security",
|
// "nuxt-security",
|
||||||
"@nuxt/image",
|
"@nuxt/image",
|
||||||
"@nuxt/fonts",
|
"@nuxt/fonts",
|
||||||
],
|
],
|
||||||
@@ -59,6 +59,7 @@ export default defineNuxtConfig({
|
|||||||
prefix: "Vue",
|
prefix: "Vue",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/*
|
||||||
security: {
|
security: {
|
||||||
headers: {
|
headers: {
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
@@ -76,4 +77,5 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
rateLimiter: false
|
rateLimiter: false
|
||||||
},
|
},
|
||||||
|
*/
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-es": "^1.2.2",
|
"cookie-es": "^1.2.2",
|
||||||
|
"crypto": "^1.0.1",
|
||||||
"fast-fuzzy": "^1.12.0",
|
"fast-fuzzy": "^1.12.0",
|
||||||
"file-type-mime": "^0.4.3",
|
"file-type-mime": "^0.4.3",
|
||||||
"jdenticon": "^3.3.0",
|
"jdenticon": "^3.3.0",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^22.13.16",
|
||||||
"@types/turndown": "^5.0.5",
|
"@types/turndown": "^5.0.5",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ClientCapabilities" ADD VALUE 'save';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SaveSlot" (
|
||||||
|
"gameId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"index" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"playtime" DOUBLE PRECISION NOT NULL,
|
||||||
|
"lastUsedClientId" TEXT NOT NULL,
|
||||||
|
"data" TEXT[],
|
||||||
|
|
||||||
|
CONSTRAINT "SaveSlot_pkey" PRIMARY KEY ("gameId","userId","index")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SaveSlot" ADD CONSTRAINT "SaveSlot_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SaveSlot" ADD CONSTRAINT "SaveSlot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SaveSlot" ADD CONSTRAINT "SaveSlot_lastUsedClientId_fkey" FOREIGN KEY ("lastUsedClientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ApplicationSettings" ADD COLUMN "saveSlotCountLimit" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
ADD COLUMN "saveSlotSizeLimit" DOUBLE PRECISION NOT NULL DEFAULT 10;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The values [save] on the enum `ClientCapabilities` will be removed. If these variants are still used in the database, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterEnum
|
||||||
|
BEGIN;
|
||||||
|
CREATE TYPE "ClientCapabilities_new" AS ENUM ('peerAPI', 'userStatus', 'cloudSaves');
|
||||||
|
ALTER TABLE "Client" ALTER COLUMN "capabilities" TYPE "ClientCapabilities_new"[] USING ("capabilities"::text::"ClientCapabilities_new"[]);
|
||||||
|
ALTER TYPE "ClientCapabilities" RENAME TO "ClientCapabilities_old";
|
||||||
|
ALTER TYPE "ClientCapabilities_new" RENAME TO "ClientCapabilities";
|
||||||
|
DROP TYPE "ClientCapabilities_old";
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ApplicationSettings" ADD COLUMN "saveSlotHistoryLimit" INTEGER NOT NULL DEFAULT 3;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SaveSlot" ALTER COLUMN "playtime" SET DEFAULT 0;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `data` on the `SaveSlot` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SaveSlot" DROP COLUMN "data",
|
||||||
|
ADD COLUMN "history" TEXT[],
|
||||||
|
ADD COLUMN "historyChecksums" TEXT[];
|
||||||
@@ -3,6 +3,10 @@ model ApplicationSettings {
|
|||||||
|
|
||||||
enabledAuthencationMechanisms AuthMec[]
|
enabledAuthencationMechanisms AuthMec[]
|
||||||
metadataProviders String[]
|
metadataProviders String[]
|
||||||
|
|
||||||
|
saveSlotCountLimit Int @default(5)
|
||||||
|
saveSlotSizeLimit Float @default(10) // MB
|
||||||
|
saveSlotHistoryLimit Int @default(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Platform {
|
enum Platform {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
enum ClientCapabilities {
|
enum ClientCapabilities {
|
||||||
PeerAPI @map("peerAPI") // other clients can use the HTTP API to P2P with this client
|
PeerAPI @map("peerAPI") // other clients can use the HTTP API to P2P with this client
|
||||||
UserStatus @map("userStatus") // this client can report this user's status (playing, online, etc etc)
|
UserStatus @map("userStatus") // this client can report this user's status (playing, online, etc etc)
|
||||||
|
CloudSaves @map("cloudSaves") // ability to save to save slots
|
||||||
}
|
}
|
||||||
|
|
||||||
// References a device
|
// References a device
|
||||||
@@ -16,6 +17,8 @@ model Client {
|
|||||||
lastConnected DateTime
|
lastConnected DateTime
|
||||||
|
|
||||||
peerAPI ClientPeerAPIConfiguration?
|
peerAPI ClientPeerAPIConfiguration?
|
||||||
|
|
||||||
|
lastAccessedSaves SaveSlot[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model ClientPeerAPIConfiguration {
|
model ClientPeerAPIConfiguration {
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ model Game {
|
|||||||
mImageLibrary String[] // linked to objects in s3
|
mImageLibrary String[] // linked to objects in s3
|
||||||
|
|
||||||
versions GameVersion[]
|
versions GameVersion[]
|
||||||
libraryBasePath String @unique // Base dir for all the game versions
|
libraryBasePath String @unique // Base dir for all the game versions
|
||||||
|
|
||||||
collections CollectionEntry[]
|
collections CollectionEntry[]
|
||||||
|
saves SaveSlot[]
|
||||||
|
|
||||||
@@unique([metadataSource, metadataId], name: "metadataKey")
|
@@unique([metadataSource, metadataId], name: "metadataKey")
|
||||||
}
|
}
|
||||||
@@ -48,9 +49,9 @@ model GameVersion {
|
|||||||
|
|
||||||
platform Platform
|
platform Platform
|
||||||
|
|
||||||
launchCommand String @default("") // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine
|
launchCommand String @default("") // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine
|
||||||
launchArgs String[]
|
launchArgs String[]
|
||||||
setupCommand String @default("") // Command to setup game (dependencies and such)
|
setupCommand String @default("") // Command to setup game (dependencies and such)
|
||||||
setupArgs String[]
|
setupArgs String[]
|
||||||
onlySetup Boolean @default(false)
|
onlySetup Boolean @default(false)
|
||||||
|
|
||||||
@@ -64,6 +65,26 @@ model GameVersion {
|
|||||||
@@id([gameId, versionName])
|
@@id([gameId, versionName])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A save slot for a game
|
||||||
|
model SaveSlot {
|
||||||
|
gameId String
|
||||||
|
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
index Int
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
playtime Float @default(0) // hours
|
||||||
|
|
||||||
|
lastUsedClientId String
|
||||||
|
lastUsedClient Client @relation(fields: [lastUsedClientId], references: [id])
|
||||||
|
|
||||||
|
history String[] // list of objects
|
||||||
|
historyChecksums String[] // list of hashes
|
||||||
|
|
||||||
|
@@id([gameId, userId, index], name: "id")
|
||||||
|
}
|
||||||
|
|
||||||
model Developer {
|
model Developer {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ model User {
|
|||||||
articles Article[]
|
articles Article[]
|
||||||
|
|
||||||
tokens APIToken[]
|
tokens APIToken[]
|
||||||
|
|
||||||
|
saves SaveSlot[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Notification {
|
model Notification {
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ export default defineClientEventHandler(async (h3, { clientId }) => {
|
|||||||
statusMessage: "configuration must be an object",
|
statusMessage: "configuration must be an object",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!(rawCapability in validCapabilities))
|
const capability = rawCapability as InternalClientCapability;
|
||||||
|
|
||||||
|
if (!validCapabilities.includes(capability))
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: "Invalid capability.",
|
statusMessage: "Invalid capability.",
|
||||||
});
|
});
|
||||||
|
|
||||||
const capability = rawCapability as InternalClientCapability;
|
|
||||||
|
|
||||||
const isValid = await capabilityManager.validateCapabilityConfiguration(
|
const isValid = await capabilityManager.validateCapabilityConfiguration(
|
||||||
capability,
|
capability,
|
||||||
configuration
|
configuration
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ClientCapabilities } from "@prisma/client";
|
||||||
|
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
||||||
|
import prisma from "~/server/internal/db/database";
|
||||||
|
|
||||||
|
export default defineClientEventHandler(
|
||||||
|
async (h3, { fetchClient, fetchUser }) => {
|
||||||
|
const client = await fetchClient();
|
||||||
|
if (!client.capabilities.includes(ClientCapabilities.CloudSaves))
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: "Capability not allowed.",
|
||||||
|
});
|
||||||
|
const user = await fetchUser();
|
||||||
|
const gameId = getRouterParam(h3, "gameid");
|
||||||
|
if (!gameId)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "No gameID in route params",
|
||||||
|
});
|
||||||
|
|
||||||
|
const slotIndexString = getRouterParam(h3, "slotindex");
|
||||||
|
if (!slotIndexString)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "No slotIndex in route params",
|
||||||
|
});
|
||||||
|
const slotIndex = parseInt(slotIndexString);
|
||||||
|
if (Number.isNaN(slotIndex))
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Invalid slotIndex",
|
||||||
|
});
|
||||||
|
|
||||||
|
const game = await prisma.game.findUnique({
|
||||||
|
where: { id: gameId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!game)
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid game ID" });
|
||||||
|
|
||||||
|
const save = await prisma.saveSlot.delete({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
userId: user.id,
|
||||||
|
gameId: gameId,
|
||||||
|
index: slotIndex,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!save)
|
||||||
|
throw createError({ statusCode: 404, statusMessage: "Save not found" });
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { ClientCapabilities } from "@prisma/client";
|
||||||
|
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
||||||
|
import prisma from "~/server/internal/db/database";
|
||||||
|
|
||||||
|
export default defineClientEventHandler(
|
||||||
|
async (h3, { fetchClient, fetchUser }) => {
|
||||||
|
const client = await fetchClient();
|
||||||
|
if (!client.capabilities.includes(ClientCapabilities.CloudSaves))
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: "Capability not allowed.",
|
||||||
|
});
|
||||||
|
const user = await fetchUser();
|
||||||
|
const gameId = getRouterParam(h3, "gameid");
|
||||||
|
if (!gameId)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "No gameID in route params",
|
||||||
|
});
|
||||||
|
|
||||||
|
const slotIndexString = getRouterParam(h3, "slotindex");
|
||||||
|
if (!slotIndexString)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "No slotIndex in route params",
|
||||||
|
});
|
||||||
|
const slotIndex = parseInt(slotIndexString);
|
||||||
|
if (Number.isNaN(slotIndex))
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Invalid slotIndex",
|
||||||
|
});
|
||||||
|
|
||||||
|
const game = await prisma.game.findUnique({
|
||||||
|
where: { id: gameId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!game)
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid game ID" });
|
||||||
|
|
||||||
|
const save = await prisma.saveSlot.findUnique({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
userId: user.id,
|
||||||
|
gameId: gameId,
|
||||||
|
index: slotIndex,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!save)
|
||||||
|
throw createError({ statusCode: 404, statusMessage: "Save not found" });
|
||||||
|
|
||||||
|
return save;
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ClientCapabilities } from "@prisma/client";
|
||||||
|
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
||||||
|
import prisma from "~/server/internal/db/database";
|
||||||
|
import saveManager from "~/server/internal/saves";
|
||||||
|
|
||||||
|
export default defineClientEventHandler(
|
||||||
|
async (h3, { fetchClient, fetchUser }) => {
|
||||||
|
const client = await fetchClient();
|
||||||
|
if (!client.capabilities.includes(ClientCapabilities.CloudSaves))
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: "Capability not allowed.",
|
||||||
|
});
|
||||||
|
const user = await fetchUser();
|
||||||
|
const gameId = getRouterParam(h3, "gameid");
|
||||||
|
if (!gameId)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "No gameID in route params",
|
||||||
|
});
|
||||||
|
|
||||||
|
const slotIndexString = getRouterParam(h3, "slotindex");
|
||||||
|
if (!slotIndexString)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "No slotIndex in route params",
|
||||||
|
});
|
||||||
|
const slotIndex = parseInt(slotIndexString);
|
||||||
|
if (Number.isNaN(slotIndex))
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Invalid slotIndex",
|
||||||
|
});
|
||||||
|
|
||||||
|
const game = await prisma.game.findUnique({
|
||||||
|
where: { id: gameId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!game)
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid game ID" });
|
||||||
|
|
||||||
|
await saveManager.pushSave(gameId, user.id, slotIndex, h3.node.req);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ClientCapabilities } from "@prisma/client";
|
||||||
|
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
||||||
|
import prisma from "~/server/internal/db/database";
|
||||||
|
|
||||||
|
export default defineClientEventHandler(
|
||||||
|
async (h3, { fetchClient, fetchUser }) => {
|
||||||
|
const client = await fetchClient();
|
||||||
|
if (!client.capabilities.includes(ClientCapabilities.CloudSaves))
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: "Capability not allowed.",
|
||||||
|
});
|
||||||
|
const user = await fetchUser();
|
||||||
|
const gameId = getRouterParam(h3, "gameid");
|
||||||
|
if (!gameId)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "No gameID in route params",
|
||||||
|
});
|
||||||
|
|
||||||
|
const game = await prisma.game.findUnique({
|
||||||
|
where: { id: gameId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!game)
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid game ID" });
|
||||||
|
|
||||||
|
const saves = await prisma.saveSlot.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
gameId: gameId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return saves;
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { ClientCapabilities } from "@prisma/client";
|
||||||
|
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
||||||
|
import { applicationSettings } from "~/server/internal/config/application-configuration";
|
||||||
|
import prisma from "~/server/internal/db/database";
|
||||||
|
|
||||||
|
export default defineClientEventHandler(
|
||||||
|
async (h3, { fetchClient, fetchUser }) => {
|
||||||
|
const client = await fetchClient();
|
||||||
|
if (!client.capabilities.includes(ClientCapabilities.CloudSaves))
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: "Capability not allowed.",
|
||||||
|
});
|
||||||
|
const user = await fetchUser();
|
||||||
|
const gameId = getRouterParam(h3, "gameid");
|
||||||
|
if (!gameId)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "No gameID in route params",
|
||||||
|
});
|
||||||
|
|
||||||
|
const game = await prisma.game.findUnique({
|
||||||
|
where: { id: gameId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!game)
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid game ID" });
|
||||||
|
|
||||||
|
const saves = await prisma.saveSlot.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
gameId: gameId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
index: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const limit = await applicationSettings.get("saveSlotCountLimit");
|
||||||
|
if (saves.length + 1 > limit)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Out of save slots",
|
||||||
|
});
|
||||||
|
|
||||||
|
let firstIndex = 0;
|
||||||
|
for (const save of saves) {
|
||||||
|
if (firstIndex == save.index) firstIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSlot = await prisma.saveSlot.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
gameId: gameId,
|
||||||
|
index: firstIndex,
|
||||||
|
lastUsedClientId: client.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return newSlot;
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ClientCapabilities } from "@prisma/client";
|
||||||
|
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
||||||
|
import prisma from "~/server/internal/db/database";
|
||||||
|
|
||||||
|
export default defineClientEventHandler(
|
||||||
|
async (h3, { fetchClient, fetchUser }) => {
|
||||||
|
const client = await fetchClient();
|
||||||
|
if (!client.capabilities.includes(ClientCapabilities.CloudSaves))
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: "Capability not allowed.",
|
||||||
|
});
|
||||||
|
const user = await fetchUser();
|
||||||
|
|
||||||
|
const saves = await prisma.saveSlot.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return saves;
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ClientCapabilities } from "@prisma/client";
|
||||||
|
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
||||||
|
import { applicationSettings } from "~/server/internal/config/application-configuration";
|
||||||
|
import prisma from "~/server/internal/db/database";
|
||||||
|
|
||||||
|
export default defineClientEventHandler(
|
||||||
|
async (h3, { fetchClient, fetchUser }) => {
|
||||||
|
const client = await fetchClient();
|
||||||
|
if (!client.capabilities.includes(ClientCapabilities.CloudSaves))
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: "Capability not allowed.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const slotLimit = await applicationSettings.get("saveSlotCountLimit");
|
||||||
|
const sizeLimit = await applicationSettings.get("saveSlotSizeLimit");
|
||||||
|
const history = await applicationSettings.get("saveSlotHistoryLimit");
|
||||||
|
return { slotLimit, sizeLimit, history };
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -4,7 +4,6 @@ import { useCertificateAuthority } from "~/server/plugins/ca";
|
|||||||
import prisma from "../db/database";
|
import prisma from "../db/database";
|
||||||
import { ClientCapabilities } from "@prisma/client";
|
import { ClientCapabilities } from "@prisma/client";
|
||||||
|
|
||||||
|
|
||||||
// These values are technically mapped to the database,
|
// These values are technically mapped to the database,
|
||||||
// but Typescript/Prisma doesn't let me link them
|
// but Typescript/Prisma doesn't let me link them
|
||||||
// They are also what are required by clients in the API
|
// They are also what are required by clients in the API
|
||||||
@@ -12,6 +11,7 @@ import { ClientCapabilities } from "@prisma/client";
|
|||||||
export enum InternalClientCapability {
|
export enum InternalClientCapability {
|
||||||
PeerAPI = "peerAPI",
|
PeerAPI = "peerAPI",
|
||||||
UserStatus = "userStatus",
|
UserStatus = "userStatus",
|
||||||
|
CloudSaves = "cloudSaves",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const validCapabilities = Object.values(InternalClientCapability);
|
export const validCapabilities = Object.values(InternalClientCapability);
|
||||||
@@ -19,6 +19,7 @@ export const validCapabilities = Object.values(InternalClientCapability);
|
|||||||
export type CapabilityConfiguration = {
|
export type CapabilityConfiguration = {
|
||||||
[InternalClientCapability.PeerAPI]: { endpoints: string[] };
|
[InternalClientCapability.PeerAPI]: { endpoints: string[] };
|
||||||
[InternalClientCapability.UserStatus]: {};
|
[InternalClientCapability.UserStatus]: {};
|
||||||
|
[InternalClientCapability.CloudSaves]: {};
|
||||||
};
|
};
|
||||||
|
|
||||||
class CapabilityManager {
|
class CapabilityManager {
|
||||||
@@ -75,6 +76,7 @@ class CapabilityManager {
|
|||||||
return valid;
|
return valid;
|
||||||
},
|
},
|
||||||
[InternalClientCapability.UserStatus]: async () => true, // No requirements for user status
|
[InternalClientCapability.UserStatus]: async () => true, // No requirements for user status
|
||||||
|
[InternalClientCapability.CloudSaves]: async () => true, // No requirements for cloud saves
|
||||||
};
|
};
|
||||||
|
|
||||||
async validateCapabilityConfiguration(
|
async validateCapabilityConfiguration(
|
||||||
@@ -82,6 +84,7 @@ class CapabilityManager {
|
|||||||
configuration: object
|
configuration: object
|
||||||
) {
|
) {
|
||||||
const validationFunction = this.validationFunctions[capability];
|
const validationFunction = this.validationFunctions[capability];
|
||||||
|
if (!validationFunction) return false;
|
||||||
return validationFunction(configuration);
|
return validationFunction(configuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +93,11 @@ class CapabilityManager {
|
|||||||
rawCapability: object,
|
rawCapability: object,
|
||||||
clientId: string
|
clientId: string
|
||||||
) {
|
) {
|
||||||
switch (capability) {
|
const upsertFunctions: EnumDictionary<
|
||||||
case InternalClientCapability.PeerAPI:
|
InternalClientCapability,
|
||||||
|
() => Promise<void> | void
|
||||||
|
> = {
|
||||||
|
[InternalClientCapability.PeerAPI]: async function () {
|
||||||
const configuration =
|
const configuration =
|
||||||
rawCapability as CapabilityConfiguration[InternalClientCapability.PeerAPI];
|
rawCapability as CapabilityConfiguration[InternalClientCapability.PeerAPI];
|
||||||
|
|
||||||
@@ -127,9 +133,32 @@ class CapabilityManager {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
},
|
||||||
}
|
[InternalClientCapability.UserStatus]: function (): Promise<void> | void {
|
||||||
throw new Error("Cannot upsert client capability for: " + capability);
|
throw new Error("Function not implemented.");
|
||||||
|
},
|
||||||
|
[InternalClientCapability.CloudSaves]: async function () {
|
||||||
|
const currentClient = await prisma.client.findUnique({
|
||||||
|
where: { id: clientId },
|
||||||
|
select: {
|
||||||
|
capabilities: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!currentClient) throw new Error("Invalid client ID");
|
||||||
|
if (currentClient.capabilities.includes(ClientCapabilities.CloudSaves))
|
||||||
|
return;
|
||||||
|
|
||||||
|
await prisma.client.update({
|
||||||
|
where: { id: clientId },
|
||||||
|
data: {
|
||||||
|
capabilities: {
|
||||||
|
push: ClientCapabilities.CloudSaves,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await upsertFunctions[capability]();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
|
|||||||
|
|
||||||
let clientId: string;
|
let clientId: string;
|
||||||
switch (method) {
|
switch (method) {
|
||||||
|
case "Debug":
|
||||||
|
if (!process.dev) throw createError({ statusCode: 403 });
|
||||||
|
const client = await prisma.client.findFirst({ select: { id: true } });
|
||||||
|
if (!client)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "No clients created.",
|
||||||
|
});
|
||||||
|
clientId = client.id;
|
||||||
|
break;
|
||||||
case "Nonce":
|
case "Nonce":
|
||||||
clientId = parts[0];
|
clientId = parts[0];
|
||||||
const nonce = parts[1];
|
const nonce = parts[1];
|
||||||
@@ -49,7 +59,9 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const certificateAuthority = useCertificateAuthority();
|
const certificateAuthority = useCertificateAuthority();
|
||||||
const certBundle = await certificateAuthority.fetchClientCertificate(clientId);
|
const certBundle = await certificateAuthority.fetchClientCertificate(
|
||||||
|
clientId
|
||||||
|
);
|
||||||
// This does the blacklist check already
|
// This does the blacklist check already
|
||||||
if (!certBundle)
|
if (!certBundle)
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Object, ObjectBackend, ObjectMetadata, ObjectReference, Source } from "./objectHandler";
|
import {
|
||||||
|
Object,
|
||||||
|
ObjectBackend,
|
||||||
|
ObjectMetadata,
|
||||||
|
ObjectReference,
|
||||||
|
Source,
|
||||||
|
} from "./objectHandler";
|
||||||
|
|
||||||
import sanitize from "sanitize-filename";
|
import sanitize from "sanitize-filename";
|
||||||
|
|
||||||
@@ -44,6 +50,12 @@ export class FsObjectBackend extends ObjectBackend {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
async startWriteStream(id: ObjectReference) {
|
||||||
|
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
||||||
|
if (!fs.existsSync(objectPath)) return undefined;
|
||||||
|
|
||||||
|
return fs.createWriteStream(objectPath);
|
||||||
|
}
|
||||||
async create(
|
async create(
|
||||||
id: string,
|
id: string,
|
||||||
source: Source,
|
source: Source,
|
||||||
@@ -68,6 +80,23 @@ export class FsObjectBackend extends ObjectBackend {
|
|||||||
|
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
async createWithWriteStream(id: string, metadata: ObjectMetadata) {
|
||||||
|
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
||||||
|
const metadataPath = path.join(
|
||||||
|
this.baseMetadataPath,
|
||||||
|
`${sanitize(id)}.json`
|
||||||
|
);
|
||||||
|
if (fs.existsSync(objectPath) || fs.existsSync(metadataPath))
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
// Write metadata
|
||||||
|
fs.writeFileSync(metadataPath, JSON.stringify(metadata));
|
||||||
|
|
||||||
|
// Create file so write passes
|
||||||
|
fs.writeFileSync(objectPath, "");
|
||||||
|
|
||||||
|
return this.startWriteStream(id);
|
||||||
|
}
|
||||||
async delete(id: ObjectReference): Promise<boolean> {
|
async delete(id: ObjectReference): Promise<boolean> {
|
||||||
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
||||||
if (!fs.existsSync(objectPath)) return true;
|
if (!fs.existsSync(objectPath)) return true;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { parse as getMimeTypeBuffer } from "file-type-mime";
|
import { parse as getMimeTypeBuffer } from "file-type-mime";
|
||||||
import { Readable } from "stream";
|
import Stream, { Readable, Writable } from "stream";
|
||||||
import { getMimeType as getMimeTypeStream } from "stream-mime-type";
|
import { getMimeType as getMimeTypeStream } from "stream-mime-type";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
@@ -46,11 +46,16 @@ export abstract class ObjectBackend {
|
|||||||
// They don't check permissions to provide any utilities
|
// They don't check permissions to provide any utilities
|
||||||
abstract fetch(id: ObjectReference): Promise<Source | undefined>;
|
abstract fetch(id: ObjectReference): Promise<Source | undefined>;
|
||||||
abstract write(id: ObjectReference, source: Source): Promise<boolean>;
|
abstract write(id: ObjectReference, source: Source): Promise<boolean>;
|
||||||
|
abstract startWriteStream(id: ObjectReference): Promise<Writable | undefined>;
|
||||||
abstract create(
|
abstract create(
|
||||||
id: string,
|
id: string,
|
||||||
source: Source,
|
source: Source,
|
||||||
metadata: ObjectMetadata
|
metadata: ObjectMetadata
|
||||||
): Promise<ObjectReference | undefined>;
|
): Promise<ObjectReference | undefined>;
|
||||||
|
abstract createWithWriteStream(
|
||||||
|
id: string,
|
||||||
|
metadata: ObjectMetadata
|
||||||
|
): Promise<Writable | undefined>;
|
||||||
abstract delete(id: ObjectReference): Promise<boolean>;
|
abstract delete(id: ObjectReference): Promise<boolean>;
|
||||||
abstract fetchMetadata(
|
abstract fetchMetadata(
|
||||||
id: ObjectReference
|
id: ObjectReference
|
||||||
@@ -60,30 +65,31 @@ export abstract class ObjectBackend {
|
|||||||
metadata: ObjectMetadata
|
metadata: ObjectMetadata
|
||||||
): Promise<boolean>;
|
): Promise<boolean>;
|
||||||
|
|
||||||
|
private async fetchMimeType(source: Source) {
|
||||||
|
if (source instanceof ReadableStream) {
|
||||||
|
source = Readable.from(source);
|
||||||
|
}
|
||||||
|
if (source instanceof Readable) {
|
||||||
|
const { stream, mime } = await getMimeTypeStream(source);
|
||||||
|
return { source: Readable.from(stream), mime: mime };
|
||||||
|
}
|
||||||
|
if (source instanceof Buffer) {
|
||||||
|
const mime =
|
||||||
|
getMimeTypeBuffer(new Uint8Array(source).buffer)?.mime ??
|
||||||
|
"application/octet-stream";
|
||||||
|
return { source: source, mime };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { source: undefined, mime: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
async createFromSource(
|
async createFromSource(
|
||||||
id: string,
|
id: string,
|
||||||
sourceFetcher: () => Promise<Source>,
|
sourceFetcher: () => Promise<Source>,
|
||||||
metadata: { [key: string]: string },
|
metadata: { [key: string]: string },
|
||||||
permissions: Array<string>
|
permissions: Array<string>
|
||||||
) {
|
) {
|
||||||
async function fetchMimeType(source: Source) {
|
const { source, mime } = await this.fetchMimeType(await sourceFetcher());
|
||||||
if (source instanceof ReadableStream) {
|
|
||||||
source = Readable.from(source);
|
|
||||||
}
|
|
||||||
if (source instanceof Readable) {
|
|
||||||
const { stream, mime } = await getMimeTypeStream(source);
|
|
||||||
return { source: Readable.from(stream), mime: mime };
|
|
||||||
}
|
|
||||||
if (source instanceof Buffer) {
|
|
||||||
const mime =
|
|
||||||
getMimeTypeBuffer(new Uint8Array(source).buffer)?.mime ??
|
|
||||||
"application/octet-stream";
|
|
||||||
return { source: source, mime };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { source: undefined, mime: undefined };
|
|
||||||
}
|
|
||||||
const { source, mime } = await fetchMimeType(await sourceFetcher());
|
|
||||||
if (!mime)
|
if (!mime)
|
||||||
throw new Error("Unable to calculate MIME type - is the source empty?");
|
throw new Error("Unable to calculate MIME type - is the source empty?");
|
||||||
|
|
||||||
@@ -94,6 +100,18 @@ export abstract class ObjectBackend {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createWithStream(
|
||||||
|
id: string,
|
||||||
|
metadata: { [key: string]: string },
|
||||||
|
permissions: Array<string>
|
||||||
|
) {
|
||||||
|
return this.createWithWriteStream(id, {
|
||||||
|
permissions,
|
||||||
|
userMetadata: metadata,
|
||||||
|
mime: "application/octet-stream",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async fetchWithPermissions(id: ObjectReference, userId?: string) {
|
async fetchWithPermissions(id: ObjectReference, userId?: string) {
|
||||||
const metadata = await this.fetchMetadata(id);
|
const metadata = await this.fetchMetadata(id);
|
||||||
if (!metadata) return;
|
if (!metadata) return;
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import Stream, { Readable } from "stream";
|
||||||
|
import prisma from "../db/database";
|
||||||
|
import { applicationSettings } from "../config/application-configuration";
|
||||||
|
import objectHandler from "../objects";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { IncomingMessage } from "http";
|
||||||
|
|
||||||
|
class SaveManager {
|
||||||
|
async deleteObjectFromSave(
|
||||||
|
gameId: string,
|
||||||
|
userId: string,
|
||||||
|
index: number,
|
||||||
|
objectId: string
|
||||||
|
) {
|
||||||
|
await objectHandler.delete(objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushSave(
|
||||||
|
gameId: string,
|
||||||
|
userId: string,
|
||||||
|
index: number,
|
||||||
|
stream: IncomingMessage
|
||||||
|
) {
|
||||||
|
const save = await prisma.saveSlot.findUnique({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
userId,
|
||||||
|
gameId,
|
||||||
|
index,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!save)
|
||||||
|
throw createError({ statusCode: 404, statusMessage: "Save not found" });
|
||||||
|
|
||||||
|
const newSaveObjectId = uuidv4();
|
||||||
|
const newSaveStream = await objectHandler.createWithStream(
|
||||||
|
newSaveObjectId,
|
||||||
|
{ saveSlot: JSON.stringify({ userId, gameId, index }) },
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
if (!newSaveStream)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "Failed to create writing stream to storage backend.",
|
||||||
|
});
|
||||||
|
|
||||||
|
let hash: string | undefined;
|
||||||
|
const hashPromise = Stream.promises.pipeline(
|
||||||
|
stream,
|
||||||
|
crypto.createHash("sha256").setEncoding("hex"),
|
||||||
|
async function (source) {
|
||||||
|
// Not sure how to get this to be typed
|
||||||
|
// @ts-expect-error
|
||||||
|
hash = (await source.toArray())[0];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadStream = Stream.promises.pipeline(stream, newSaveStream);
|
||||||
|
|
||||||
|
await Promise.all([hashPromise, uploadStream]);
|
||||||
|
|
||||||
|
if (!hash) {
|
||||||
|
await objectHandler.delete(newSaveObjectId);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "Hash failed to generate",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSave = await prisma.saveSlot.update({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
userId,
|
||||||
|
gameId,
|
||||||
|
index,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
history: {
|
||||||
|
push: newSaveObjectId,
|
||||||
|
},
|
||||||
|
historyChecksums: {
|
||||||
|
push: hash,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const historyLimit = await applicationSettings.get("saveSlotHistoryLimit");
|
||||||
|
if (newSave.history.length > historyLimit) {
|
||||||
|
// Delete previous
|
||||||
|
const safeFromIndex = newSave.history.length - historyLimit;
|
||||||
|
|
||||||
|
const toDelete = newSave.history.slice(0, safeFromIndex);
|
||||||
|
const toKeepObjects = newSave.history.slice(safeFromIndex);
|
||||||
|
const toKeepHashes = newSave.historyChecksums.slice(safeFromIndex);
|
||||||
|
|
||||||
|
// Delete objects first, so if we error out, we don't lose track of objects in backend
|
||||||
|
for (const objectId of toDelete) {
|
||||||
|
await this.deleteObjectFromSave(gameId, userId, index, objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.saveSlot.update({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
userId,
|
||||||
|
gameId,
|
||||||
|
index,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
history: toKeepObjects,
|
||||||
|
historyChecksums: toKeepHashes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveManager = new SaveManager();
|
||||||
|
export default saveManager;
|
||||||
@@ -1754,6 +1754,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types "~6.20.0"
|
undici-types "~6.20.0"
|
||||||
|
|
||||||
|
"@types/node@^22.13.16":
|
||||||
|
version "22.13.16"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.16.tgz#802cff8e4c3b3fc7461c2adcc92d73d89779edad"
|
||||||
|
integrity sha512-15tM+qA4Ypml/N7kyRdvfRjBQT2RL461uF1Bldn06K0Nzn1lY3nAPgHlsVrJxdZ9WhZiW0Fmc1lOYMtDsAuB3w==
|
||||||
|
dependencies:
|
||||||
|
undici-types "~6.20.0"
|
||||||
|
|
||||||
"@types/parse-path@^7.0.0":
|
"@types/parse-path@^7.0.0":
|
||||||
version "7.0.3"
|
version "7.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/parse-path/-/parse-path-7.0.3.tgz#cec2da2834ab58eb2eb579122d9a1fc13bd7ef36"
|
resolved "https://registry.yarnpkg.com/@types/parse-path/-/parse-path-7.0.3.tgz#cec2da2834ab58eb2eb579122d9a1fc13bd7ef36"
|
||||||
@@ -2767,6 +2774,11 @@ cross-spawn@^7.0.3, cross-spawn@^7.0.6:
|
|||||||
dependencies:
|
dependencies:
|
||||||
uncrypto "^0.1.3"
|
uncrypto "^0.1.3"
|
||||||
|
|
||||||
|
crypto@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037"
|
||||||
|
integrity sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==
|
||||||
|
|
||||||
css-declaration-sorter@^7.2.0:
|
css-declaration-sorter@^7.2.0:
|
||||||
version "7.2.0"
|
version "7.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz#6dec1c9523bc4a643e088aab8f09e67a54961024"
|
resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz#6dec1c9523bc4a643e088aab8f09e67a54961024"
|
||||||
|
|||||||
Reference in New Issue
Block a user