Merge branch 'develop' into db-store

This commit is contained in:
Huskydog9988
2025-04-03 18:12:07 -04:00
25 changed files with 1469 additions and 172 deletions

64
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,64 @@
name: Release Workflow
on:
release:
types: [published]
# This can be used to automatically publish nightlies at UTC nighttime
schedule:
- cron: '0 2 * * *' # run at 2 AM UTC
jobs:
web:
name: Push website Docker image to registry
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
buildkitd-flags: --debug
- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/drop-OSS/drop
tags: |
type=schedule,pattern=nightly
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=ref,event=branch
type=ref,event=pr
type=sha
# set latest tag for stable releases
type=raw,value=latest,enable=${{ github.event.release.prerelease == false }}
- name: Build and push image
id: build-and-push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max

35
.vscode/settings.json vendored
View File

@ -1,18 +1,21 @@
{
"spellchecker.ignoreWordsList": [
"mTLS",
"Wireguard"
],
"sqltools.connections": [
{
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"driver": "PostgreSQL",
"name": "drop",
"database": "drop",
"username": "drop",
"password": "drop"
}
]
"spellchecker.ignoreWordsList": ["mTLS", "Wireguard"],
"sqltools.connections": [
{
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"driver": "PostgreSQL",
"name": "drop",
"database": "drop",
"username": "drop",
"password": "drop"
}
],
// allow autocomplete for ArkType expressions like "string | num"
"editor.quickSuggestions": {
"strings": "on"
},
// prioritize ArkType's "type" for autoimports
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"]
}

View File

@ -1,5 +1,5 @@
# pull pre-configured and updated build environment
FROM registry.deepcore.dev/drop-oss/drop-server-build-environment/main:latest AS build-system
FROM debian:12.10-slim AS build-system
# setup workdir
RUN mkdir /build

View File

@ -32,16 +32,18 @@ To just deploy Drop, we've set up a simple docker compose file in deploy-templat
3. Edit the compose.yml file (`nano compose.yml`) and copy your GiamtBomb API Key into the GIANT_BOMB_API_KEY environment variable
4. Run `docker compose up -d`
Your drop server should now be running. To register the admin user, navigate to http://your.drop.server.ip:3000/register?id=admin
Your drop server should now be running. To register the admin user, navigate to http://your.drop.server.ip:3000/register?id=admin
and fill in the required forms
### Adding a game
To add a game to the drop library, do as follows:
1. Ensure that the current user owns the library folder with `sudo chown -R $(id -u $(whoami)) library`
2. `cd library`
3. `mkdir <GAME_NAME>` with the name of the game which you would like to register
4. `cd <GAME_NAME>`
5. `mkdir <VERSION_NAME>` Upload files for the specific game version to this folder
5. `mkdir <VERSION_NAME>` Upload files for the specific game version to this folder
6. Navigate to http://your.drop.server.ip:3000/
7. Import game metadata (uses GiantBomb API Key) by selecting the game and specifying which entry to import
8. Navigate to http://your.drop.server.ip:3000/admin/library
@ -73,7 +75,7 @@ Steps:
As part of the first-time bootstrap, Drop creates an invitation with the fixed id of 'admin'. So, to create an admin account, go to:
http://localhost:3000/register?id=admin
http://localhost:3000/auth/register?id=admin
## Contributing

View File

@ -39,7 +39,11 @@
</NuxtLink>
<div class="h-0.5 rounded-full w-full bg-zinc-800" />
<div class="flex flex-col">
<MenuItem v-for="(nav, navIdx) in navigation" v-slot="{ active, close }">
<MenuItem
v-for="(nav, navIdx) in navigation"
v-slot="{ active, close }"
hydrate-on-visible
>
<button
:href="nav.route"
@click="() => navigateTo(nav.route, close)"
@ -48,8 +52,8 @@
'text-left transition block px-4 py-2 text-sm',
]"
>
{{ nav.label }}</button
>
{{ nav.label }}
</button>
</MenuItem>
</div>
</PanelWidget>
@ -81,7 +85,7 @@ const navigation: NavigationItem[] = [
},
{
label: "Sign out",
route: "/signout",
route: "/auth/signout",
prefix: "",
},
].filter((e) => e !== undefined);

View File

@ -16,7 +16,7 @@ const showSignIn = statusCode ? statusCode == 403 || statusCode == 401 : false;
async function signIn() {
clearError({
redirect: `/signin?redirect=${encodeURIComponent(route.fullPath)}`,
redirect: `/auth/signin?redirect=${encodeURIComponent(route.fullPath)}`,
});
}

View File

@ -1,10 +1,10 @@
<template>
<div v-if="!noWrapper" class="flex flex-col w-full min-h-screen bg-zinc-900">
<UserHeader class="z-50" />
<UserHeader class="z-50" hydrate-on-idle />
<div class="grow flex">
<NuxtPage />
</div>
<UserFooter class="z-50" hydrate-on-visible />
<UserFooter class="z-50" hydrate-on-interaction />
</div>
<div class="flex w-full min-h-screen bg-zinc-900" v-else>
<NuxtPage />

View File

@ -1,4 +1,4 @@
const whitelistedPrefixes = ["/signin", "/register", "/api", "/setup"];
const whitelistedPrefixes = ["/auth/signin", "/register", "/api", "/setup"];
const requireAdmin = ["/admin"];
export default defineNuxtRouteMiddleware(async (to, from) => {
@ -13,7 +13,10 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
await updateUser();
}
if (!user.value) {
return navigateTo({ path: "/signin", query: { redirect: to.fullPath } });
return navigateTo({
path: "/auth/signin",
query: { redirect: to.fullPath },
});
}
if (
requireAdmin.findIndex((e) => to.fullPath.startsWith(e)) != -1 &&

View File

@ -9,10 +9,12 @@ export default defineNuxtConfig({
devtools: { enabled: false },
css: ["~/assets/tailwindcss.css", "~/assets/core.scss"],
experimental: {
buildCache: true,
},
vite: {
plugins: [
tailwindcss()
]
plugins: [tailwindcss()],
},
app: {
@ -21,7 +23,22 @@ export default defineNuxtConfig({
},
},
routeRules: {
"/auth/signin": { prerender: true },
"/signout": { prerender: true },
"/api/**": { cors: true },
"/api/v1/client/object/*": {
security: {
rateLimiter: false,
},
},
},
nitro: {
minify: true,
experimental: {
websocket: true,
tasks: true,
@ -30,6 +47,8 @@ export default defineNuxtConfig({
scheduledTasks: {
"0 * * * *": ["cleanup:invitations"],
},
compressPublicAssets: true,
},
extends: ["./drop-base"],
@ -39,6 +58,7 @@ export default defineNuxtConfig({
"vue3-carousel-nuxt",
"nuxt-security",
"@nuxt/image",
"@nuxt/fonts",
],
carousel: {
@ -48,6 +68,8 @@ export default defineNuxtConfig({
security: {
headers: {
contentSecurityPolicy: {
"upgrade-insecure-requests": false,
"img-src": [
"'self'",
"data:",
@ -59,4 +81,4 @@ export default defineNuxtConfig({
strictTransportSecurity: false,
},
},
});
});

View File

@ -14,10 +14,13 @@
"@drop/droplet": "^0.7.0",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@nuxt/fonts": "^0.11.0",
"@nuxt/image": "1.9.0",
"@nuxtjs/tailwindcss": "^6.12.2",
"@prisma/client": "^6.1.0",
"@tailwindcss/vite": "^4.0.6",
"argon2": "^0.41.1",
"arktype": "^2.1.10",
"axios": "^1.7.7",
"bcryptjs": "^2.4.3",
"cookie-es": "^1.2.2",
@ -31,13 +34,14 @@
"nuxt-security": "2.2.0",
"prisma": "^6.1.0",
"sanitize-filename": "^1.6.3",
"sharp": "^0.33.5",
"stream": "^0.0.3",
"stream-mime-type": "^2.0.0",
"turndown": "^7.2.0",
"uuid": "^10.0.0",
"vue": "latest",
"vue-router": "latest",
"vue3-carousel-nuxt": "^1.1.3",
"vue3-carousel-nuxt": "^1.1.5",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
@ -48,7 +52,7 @@
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.20",
"h3": "^1.13.0",
"nitropack": "^2.9.7",
"nitropack": "2.11.6",
"postcss": "^8.4.47",
"sass": "^1.79.4",
"tailwindcss": "^4.0.0"
@ -59,5 +63,10 @@
"@drop/droplet-linux-x64-gnu": "^0.7.0",
"@drop/droplet-win32-x64-msvc": "^0.7.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"overrides": {
"vue3-carousel-nuxt": {
"vue3-carousel": "^0.15.0"
}
}
}

View File

@ -188,6 +188,7 @@
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/24/solid";
import { type } from "arktype";
const route = useRoute();
const router = useRouter();
@ -208,14 +209,20 @@ const username = ref(invitation.data.value?.username);
const password = ref("");
const confirmPassword = ref(undefined);
const mailRegex = /^\S+@\S+\.\S+$/;
const validEmail = computed(() => mailRegex.test(email.value ?? ""));
const validUsername = computed(
() =>
(username.value?.length ?? 0) >= 5 &&
username.value?.toLowerCase() == username.value
const emailValidator = type("string.email");
const validEmail = computed(
() => !(emailValidator(email.value) instanceof type.errors)
);
const usernameValidator = type("string.lower.preformatted >= 5");
const validUsername = computed(
() => !(usernameValidator(username.value) instanceof type.errors)
);
const passwordValidator = type("string >= 14");
const validPassword = computed(
() => !(passwordValidator(password.value) instanceof type.errors)
);
const validPassword = computed(() => (password.value?.length ?? 0) >= 14);
const validConfirmPassword = computed(
() => password.value == confirmPassword.value
);
@ -248,7 +255,7 @@ function register_wrapper() {
loading.value = true;
register()
.then(() => {
router.push("/signin");
router.push("/auth/signin");
})
.catch((response) => {
const message = response.statusMessage || "An unknown error occurred";

View File

@ -42,6 +42,6 @@ const user = useUser();
user.value = null;
// Redirect to signin page after signout
await $dropFetch("/signout");
router.push("/signin");
await $dropFetch("/api/v1/auth/signout"); //TODO: add signout api route
router.push("/auth/signin");
</script>

View File

@ -80,7 +80,7 @@
</div>
<!-- recently updated -->
<div class="px-4 sm:px-12 py-4">
<div class="px-4 sm:px-12 py-4" hydrate-on-visible>
<h1 class="text-zinc-100 text-2xl font-bold font-display">
Recently updated
</h1>

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LinkedAuthMec" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;

View File

@ -7,6 +7,7 @@ model LinkedAuthMec {
mec AuthMec
enabled Boolean @default(true)
version Int @default(1)
credentials Json
user User @relation(fields: [userId], references: [id])

View File

@ -1,7 +1,11 @@
import { AuthMec } from "@prisma/client";
import { JsonArray } from "@prisma/client/runtime/library";
import { type } from "arktype";
import prisma from "~/server/internal/db/database";
import { checkHash } from "~/server/internal/security/simple";
import {
checkHashArgon2,
checkHashBcrypt,
} from "~/server/internal/security/simple";
import sessionHandler from "~/server/internal/session";
export default defineEventHandler(async (h3) => {
@ -19,10 +23,10 @@ export default defineEventHandler(async (h3) => {
const authMek = await prisma.linkedAuthMec.findFirst({
where: {
mec: AuthMec.Simple,
credentials: {
array_starts_with: username,
},
enabled: true,
user: {
username,
},
},
include: {
user: {
@ -39,17 +43,46 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Invalid username or password.",
});
const credentials = authMek.credentials as JsonArray;
const hash = credentials.at(1);
if (!hash || !authMek.user.enabled)
if (!authMek.user.enabled)
throw createError({
statusCode: 403,
statusMessage:
"Invalid or disabled account. Please contact the server administrator.",
});
if (!(await checkHash(password, hash.toString())))
// LEGACY bcrypt
if (authMek.version == 1) {
const credentials = authMek.credentials as JsonArray | null;
const hash = credentials?.at(1)?.toString();
if (!hash)
throw createError({
statusCode: 403,
statusMessage:
"Invalid password state. Please contact the server administrator.",
});
if (!(await checkHashBcrypt(password, hash)))
throw createError({
statusCode: 401,
statusMessage: "Invalid username or password.",
});
// TODO: send user to forgot password screen or something to force them to change their password to new system
await sessionHandler.setUserId(h3, authMek.userId, rememberMe);
return { result: true, userId: authMek.userId };
}
// V2: argon2
const hash = authMek.credentials as string | undefined;
if (!hash || typeof hash !== "string")
throw createError({
statusCode: 500,
statusMessage:
"Invalid password state. Please contact the server administrator.",
});
if (!(await checkHashArgon2(password, hash)))
throw createError({
statusCode: 401,
statusMessage: "Invalid username or password.",

View File

@ -1,12 +1,20 @@
import { AuthMec, Invitation } from "@prisma/client";
import prisma from "~/server/internal/db/database";
import { createHash } from "~/server/internal/security/simple";
import {
createHashArgon2,
} from "~/server/internal/security/simple";
import { v4 as uuidv4 } from "uuid";
import * as jdenticon from "jdenticon";
import objectHandler from "~/server/internal/objects";
import { type } from "arktype";
import { writeNonLiteralDefaultMessage } from "arktype/internal/parser/shift/operator/default.ts";
// Only really a simple test, in case people mistype their emails
const mailRegex = /^\S+@\S+\.\S+$/;
const userValidator = type({
username: "string >= 5",
email: "string.email",
password: "string >= 14",
"displayName?": "string | undefined",
});
export default defineEventHandler(async (h3) => {
const body = await readBody(h3);
@ -27,59 +35,24 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Invalid or expired invitation.",
});
const useInvitationOrBodyRequirement = (
field: keyof Invitation,
check: (v: string) => boolean
) => {
if (invitation[field]) {
return invitation[field].toString();
}
const user = userValidator(body);
if (user instanceof type.errors) {
// hover out.summary to see validation errors
console.error(user.summary);
const v: string = body[field]?.toString();
const valid = check(v);
return valid ? v : undefined;
};
const username = useInvitationOrBodyRequirement(
"username",
(e) => e.length >= 5
);
const email = useInvitationOrBodyRequirement("email", (e) =>
mailRegex.test(e)
);
const password = body.password;
const displayName = body.displayName || username;
if (username === undefined)
throw createError({
statusCode: 400,
statusMessage: "Username is invalid. Must be more than 5 characters.",
});
if (username.toLowerCase() != username)
throw createError({
statusCode: 400,
statusMessage: "Username must be all lowercase",
statusMessage: user.summary,
});
}
if (email === undefined)
throw createError({
statusCode: 400,
statusMessage: "Invalid email. Must follow the format you@example.com",
});
// reuse items from invite
if (invitation.username !== null) user.username = invitation.username;
if (invitation.email !== null) user.email = invitation.email;
if (!password)
throw createError({
statusCode: 400,
statusMessage: "Password empty or missing.",
});
if (password.length < 14)
throw createError({
statusCode: 400,
statusMessage: "Password must be 14 or more characters.",
});
const existing = await prisma.user.count({ where: { username: username } });
const existing = await prisma.user.count({
where: { username: user.username },
});
if (existing > 0)
throw createError({
statusCode: 400,
@ -91,30 +64,33 @@ export default defineEventHandler(async (h3) => {
const profilePictureId = uuidv4();
await objectHandler.createFromSource(
profilePictureId,
async () => jdenticon.toPng(username, 256),
async () => jdenticon.toPng(user.username, 256),
{},
[`internal:read`, `${userId}:write`]
);
const user = await prisma.user.create({
data: {
username,
displayName,
email,
profilePicture: profilePictureId,
admin: invitation.isAdmin,
},
});
const [linkMec] = await prisma.$transaction([
prisma.linkedAuthMec.create({
data: {
mec: AuthMec.Simple,
credentials: await createHashArgon2(user.password),
version: 2,
user: {
create: {
id: userId,
username: user.username,
displayName: user.displayName ?? user.username,
email: user.email,
profilePicture: profilePictureId,
admin: invitation.isAdmin,
},
},
},
select: {
user: true,
},
}),
prisma.invitation.delete({ where: { id: invitationId } }),
]);
const hash = await createHash(password);
await prisma.linkedAuthMec.create({
data: {
mec: AuthMec.Simple,
credentials: [username, hash],
userId: user.id,
},
});
await prisma.invitation.delete({ where: { id: invitationId } });
return user;
return linkMec.user;
});

View File

@ -1,11 +1,15 @@
import bcrypt from 'bcryptjs';
import bcrypt from "bcryptjs";
import * as argon2 from "argon2";
import { type } from "arktype";
const rounds = 10;
export async function createHash(password: string) {
return bcrypt.hashSync(password, rounds);
export async function checkHashBcrypt(password: string, hash: string) {
return await bcrypt.compare(password, hash);
}
export async function checkHash(password: string, hash: string) {
return bcrypt.compareSync(password, hash);
}
export async function createHashArgon2(password: string) {
return await argon2.hash(password);
}
export async function checkHashArgon2(password: string, hash: string) {
return await argon2.verify(hash, password);
}

View File

@ -1,16 +1,13 @@
import { Platform } from "@prisma/client";
export function parsePlatform(platform: string) {
switch (platform) {
switch (platform.toLowerCase()) {
case "linux":
case "Linux":
return Platform.Linux;
case "windows":
case "Windows":
return Platform.Windows;
case "macOS":
case "MacOS":
case "mac":
case "macos":
return Platform.macOS;
}

View File

@ -18,7 +18,7 @@ export default defineNitroPlugin((nitro) => {
if (userId) break;
return sendRedirect(
event,
`/signin?redirect=${encodeURIComponent(event.path)}`
`/auth/signin?redirect=${encodeURIComponent(event.path)}`
);
}
});

View File

@ -3,5 +3,5 @@ import sessionHandler from "../internal/session";
export default defineEventHandler(async (h3) => {
await sessionHandler.clearSession(h3);
return sendRedirect(h3, "/signin");
return sendRedirect(h3, "/auth/signin");
});

View File

@ -1,3 +1,6 @@
{
"extends": "../.nuxt/tsconfig.server.json"
"extends": "../.nuxt/tsconfig.server.json",
"compilerOptions": {
"exactOptionalPropertyTypes": true
}
}

View File

@ -1,4 +1,7 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"exactOptionalPropertyTypes": true
}
}

1218
yarn.lock

File diff suppressed because it is too large Load Diff