ca groundwork

This commit is contained in:
DecDuck
2024-10-07 22:35:54 +11:00
parent 1bd19ad917
commit bfafd2a044
44 changed files with 628 additions and 130 deletions

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
DATABASE_URL="postgres://drop:drop@127.0.0.1:5432/drop"
CLIENT_CERTIFICATES="./.data/ca"
GIANT_BOMB_API_KEY=""

1
.yarnrc Normal file
View File

@ -0,0 +1 @@
"@drop:registry" "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/"

View File

@ -3,3 +3,7 @@
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
</template> </template>
<script setup lang="ts">
await updateUser();
</script>

View File

@ -36,4 +36,16 @@ $helvetica: (
font-weight: $weight; font-weight: $weight;
font-style: $style; font-style: $style;
} }
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter/InterVariable.ttf");
font-style: normal;
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter/InterVariable-Italic.ttf");
font-style: italic;
} }

View File

@ -0,0 +1,68 @@
<template>
<Menu as="div" class="relative inline-block">
<MenuButton>
<InlineWidget>
<div class="inline-flex items-center text-zinc-300 hover:text-white">
<img :src="userData.image" class="w-5 h-5 rounded-sm" />
<span class="ml-2 text-sm font-bold">{{ userData.name }}</span>
<ChevronDownIcon class="ml-3 h-4" />
</div>
</InlineWidget>
</MenuButton>
<transition enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95" enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75" leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95">
<MenuItems class="absolute right-0 top-10 z-10 w-56 origin-top-right focus:outline-none">
<PanelWidget class="flex-col gap-y-2">
<NuxtLink to="/id/me"
class="transition inline-flex items-center w-full py-3 px-4 hover:bg-zinc-800">
<div class="inline-flex items-center text-zinc-300">
<img :src="userData.image" class="w-5 h-5 rounded-sm" />
<span class="ml-2 text-sm font-bold">{{ userData.name }}</span>
</div>
</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 }">
<NuxtLink :href="nav.route"
:class="[active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400', 'transition block px-4 py-2 text-sm']">
{{
nav.label }}</NuxtLink>
</MenuItem>
</div>
</PanelWidget>
</MenuItems>
</transition>
</Menu>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { NavigationItem } from '../composables/types';
const userData = {
image: "https://avatars.githubusercontent.com/u/64579723?v=4",
name: "DecDuck",
}
const navigation: NavigationItem[] = [
{
label: "Admin Dashboard",
route: "/admin",
prefix: ""
},
{
label: "Account settings",
route: "/account",
prefix: "",
},
{
label: "Sign out",
route: "/signout",
prefix: ""
}
]
</script>

View File

@ -1,18 +0,0 @@
<template>
<InlineWidget>
<div class="inline-flex items-center text-zinc-300 hover:text-white">
<img :src="userData.image" class="w-5 h-5 rounded-sm" />
<span class="ml-2 -mb-1 text-sm font-bold">{{ userData.name }}</span>
<ChevronDownIcon class="ml-3 h-4" />
</div>
</InlineWidget>
</template>
<script setup lang="ts">
import { ChevronDownIcon } from '@heroicons/vue/16/solid';
const userData = {
image: "https://avatars.githubusercontent.com/u/64579723?v=4",
name: "DecDuck",
}
</script>

View File

@ -0,0 +1,31 @@
<template>
<button
type="submit"
class="inline-flex h-9 items-center justify-center rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<div v-if="props.loading" role="status">
<svg
aria-hidden="true"
class="w-5 h-5 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
<span v-else> <slot /> </span>
</button>
</template>
<script setup lang="ts">
const props = defineProps<{ loading: boolean }>();
</script>

View File

@ -0,0 +1,5 @@
<template>
<div class="flex rounded-sm px-2 py-2 bg-zinc-900 text-zinc-600">
<slot />
</div>
</template>

View File

@ -18,18 +18,18 @@
<div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0"> <div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
<div class="md:grid md:grid-cols-2 md:gap-8"> <div class="md:grid md:grid-cols-2 md:gap-8">
<div> <div>
<h3 class="text-sm font-semibold leading-6 text-white">Solutions</h3> <h3 class="text-sm font-semibold leading-6 text-white">Games</h3>
<ul role="list" class="mt-6 space-y-4"> <ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.solutions" :key="item.name"> <li v-for="item in navigation.games" :key="item.name">
<a :href="item.href" class="text-sm leading-6 text-gray-300 hover:text-white">{{ <a :href="item.href" class="text-sm leading-6 text-gray-300 hover:text-white">{{
item.name }}</a> item.name }}</a>
</li> </li>
</ul> </ul>
</div> </div>
<div class="mt-10 md:mt-0"> <div class="mt-10 md:mt-0">
<h3 class="text-sm font-semibold leading-6 text-white">Support</h3> <h3 class="text-sm font-semibold leading-6 text-white">Community</h3>
<ul role="list" class="mt-6 space-y-4"> <ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.support" :key="item.name"> <li v-for="item in navigation.community" :key="item.name">
<a :href="item.href" class="text-sm leading-6 text-gray-300 hover:text-white">{{ <a :href="item.href" class="text-sm leading-6 text-gray-300 hover:text-white">{{
item.name }}</a> item.name }}</a>
</li> </li>
@ -38,18 +38,18 @@
</div> </div>
<div class="md:grid md:grid-cols-2 md:gap-8"> <div class="md:grid md:grid-cols-2 md:gap-8">
<div> <div>
<h3 class="text-sm font-semibold leading-6 text-white">Company</h3> <h3 class="text-sm font-semibold leading-6 text-white">Documentation</h3>
<ul role="list" class="mt-6 space-y-4"> <ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.company" :key="item.name"> <li v-for="item in navigation.documentation" :key="item.name">
<a :href="item.href" class="text-sm leading-6 text-gray-300 hover:text-white">{{ <a :href="item.href" class="text-sm leading-6 text-gray-300 hover:text-white">{{
item.name }}</a> item.name }}</a>
</li> </li>
</ul> </ul>
</div> </div>
<div class="mt-10 md:mt-0"> <div class="mt-10 md:mt-0">
<h3 class="text-sm font-semibold leading-6 text-white">Legal</h3> <h3 class="text-sm font-semibold leading-6 text-white">About</h3>
<ul role="list" class="mt-6 space-y-4"> <ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.legal" :key="item.name"> <li v-for="item in navigation.about" :key="item.name">
<a :href="item.href" class="text-sm leading-6 text-gray-300 hover:text-white">{{ <a :href="item.href" class="text-sm leading-6 text-gray-300 hover:text-white">{{
item.name }}</a> item.name }}</a>
</li> </li>
@ -67,29 +67,26 @@ import GithubLogo from './GithubLogo.vue';
import DiscordLogo from './DiscordLogo.vue'; import DiscordLogo from './DiscordLogo.vue';
const navigation = { const navigation = {
solutions: [ games: [
{ name: 'Marketing', href: '#' }, { name: 'Newly Added', href: '#' },
{ name: 'Analytics', href: '#' }, { name: 'New Releases', href: '#' },
{ name: 'Commerce', href: '#' }, { name: 'Top Sellers', href: '#' },
{ name: 'Insights', href: '#' }, { name: 'Find a Game', href: '#' },
], ],
support: [ community: [
{ name: 'Pricing', href: '#' }, { name: 'Friends', href: '#' },
{ name: 'Documentation', href: '#' }, { name: 'Groups', href: '#' },
{ name: 'Guides', href: '#' }, { name: 'Servers', href: '#' },
{ name: 'API Status', href: '#' },
], ],
company: [ documentation: [
{ name: 'About', href: '#' }, { name: 'API', href: '#' },
{ name: 'Blog', href: '#' }, { name: 'Server Docs', href: '#' },
{ name: 'Jobs', href: '#' }, { name: 'Client Docs', href: '#' },
{ name: 'Press', href: '#' },
{ name: 'Partners', href: '#' },
], ],
legal: [ about: [
{ name: 'Claim', href: '#' }, { name: 'About Drop', href: '#' },
{ name: 'Privacy', href: '#' }, { name: 'Features', href: '#' },
{ name: 'Terms', href: '#' }, { name: 'FAQ', href: '#' },
], ],
social: [ social: [
{ {

View File

@ -3,7 +3,7 @@
<div class="grow inline-flex items-center gap-x-20"> <div class="grow inline-flex items-center gap-x-20">
<Wordmark class="h-8" /> <Wordmark class="h-8" />
<nav class="inline-flex items-center"> <nav class="inline-flex items-center">
<ol class="inline-flex items-center gap-x-12 mt-1"> <ol class="inline-flex items-center gap-x-12">
<li class="transition text-gray-300 hover:text-gray-100 uppercase font-display font-semibold text-md" <li class="transition text-gray-300 hover:text-gray-100 uppercase font-display font-semibold text-md"
v-for="(nav, navIdx) in navigation"> v-for="(nav, navIdx) in navigation">
{{ nav.label }} {{ nav.label }}
@ -18,7 +18,7 @@
<component class="h-5" :is="item.icon" /> <component class="h-5" :is="item.icon" />
</InlineWidget> </InlineWidget>
</li> </li>
<InlineUserWidget /> <HeaderUserWidget />
</ol> </ol>
</div> </div>
</div> </div>
@ -26,8 +26,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { BellIcon, UserGroupIcon } from '@heroicons/vue/16/solid'; import { BellIcon, UserGroupIcon } from '@heroicons/vue/16/solid';
import type { NavigationItem, QuickActionNav } from './types'; import type { NavigationItem, QuickActionNav } from '../composables/types';
const navigation: Array<NavigationItem> = [ const navigation: Array<NavigationItem> = [
{ {

16
composables/user.ts Normal file
View File

@ -0,0 +1,16 @@
import type { User } from "@prisma/client";
// undefined = haven't check
// null = check, no user
// {} = check, user
export const useUser = () => useState<User | undefined | null>(undefined);
export const updateUser = async () => {
const headers = useRequestHeaders(["cookie"]);
const user = useUser();
if (user.value === null) return;
// SSR calls have to be after uses
user.value = await $fetch<User | null>("/api/v1/whoami", { headers });
};

2
layouts/admin.vue Normal file
View File

@ -0,0 +1,2 @@
<template>
</template>

View File

@ -1,18 +1,18 @@
<template> <template>
<content class="flex flex-col w-full min-h-screen"> <content class="flex flex-col w-full min-h-screen">
<UserHeader /> <UserHeader />
<div class="grow flex"> <div class="grow flex">
<NuxtPage /> <NuxtPage />
</div> </div>
<UserFooter /> <UserFooter />
</content> </content>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ useHead({
titleTemplate(title) { titleTemplate(title) {
if (title) return `${title} | Drop`; if (title) return `${title} | Drop`;
return `Drop`; return `Drop`;
}, },
}) });
</script> </script>

View File

@ -0,0 +1,15 @@
const whitelistedPrefixes = ["/signin", "/register"];
export default defineNuxtRouteMiddleware(async (to, from) => {
if (import.meta.server) return;
if (whitelistedPrefixes.findIndex((e) => to.fullPath.startsWith(e)) != -1)
return;
const user = useUser();
if (user === undefined) {
await updateUser();
}
if (!user.value) {
return navigateTo({ path: "/signin", query: { redirect: to.fullPath } });
}
});

View File

@ -10,6 +10,8 @@
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {
"@drop/droplet": "^0.2.0",
"@drop/droplet-linux-x64-gnu": "^0.2.0",
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.1.5",
"@prisma/client": "5.20.0", "@prisma/client": "5.20.0",
@ -25,6 +27,7 @@
}, },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/turndown": "^5.0.5", "@types/turndown": "^5.0.5",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",

8
pages/admin/index.vue Normal file
View File

@ -0,0 +1,8 @@
<template>
</template>
<script setup lang="ts">
definePageMeta({
layout: "admin"
})
</script>

View File

@ -1,9 +1,11 @@
<template> <template>
{{ user ?? "no user" }}
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ useHead({
title: "Home" title: "Home",
}) });
</script>
const user = useUser();
</script>

View File

@ -12,7 +12,7 @@ const username = ref("");
const password = ref(""); const password = ref("");
async function register() { async function register() {
await $fetch('/api/v1/signup/simple', { await $fetch('/api/v1/auth/signup/simple', {
method: "POST", method: "POST",
body: { body: {
username: username.value, username: username.value,

View File

@ -1,23 +1,172 @@
<template> <template>
<form @submit.prevent="register"> <div class="flex min-h-screen flex-1 bg-zinc-900">
<input type="text" v-model="username" placeholder="username" /> <div
<input type="text" v-model="password" placeholder="password" /> class="flex flex-1 flex-col justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24"
>
<div class="mx-auto w-full max-w-sm lg:w-96">
<div>
<Logo class="h-10 w-auto" />
<h2
class="mt-8 text-2xl font-bold font-display leading-9 tracking-tight text-zinc-100"
>
Sign in to your account
</h2>
<p class="mt-2 text-sm leading-6 text-zinc-400">
Don't have an account? Ask an admin to create one for you.
</p>
</div>
<button type="submit">Submit</button> <div class="mt-10">
</form> <div>
<form @submit.prevent="signin_wrapper" class="space-y-6">
<div>
<label
for="username"
class="block text-sm font-medium leading-6 text-zinc-300"
>Username</label
>
<div class="mt-2">
<input
id="username"
name="username"
type="username"
autocomplete="username"
required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
v-model="username"
/>
</div>
</div>
<div>
<label
for="password"
class="block text-sm font-medium leading-6 text-zinc-300"
>Password</label
>
<div class="mt-2">
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
v-model="password"
required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
v-model="rememberMe"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600"
/>
<label
for="remember-me"
class="ml-3 block text-sm leading-6 text-zinc-400"
>Remember me</label
>
</div>
<div class="text-sm leading-6">
<a
href="#"
class="font-semibold text-blue-600 hover:text-blue-500"
>Forgot password?</a
>
</div>
</div>
<div>
<LoadingButton class="w-full" :loading="loading">
Sign in</LoadingButton
>
</div>
<div v-if="error" class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon
class="h-5 w-5 text-red-600"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="relative hidden w-0 flex-1 lg:block">
<img
class="absolute inset-0 h-full w-full object-cover"
src="/wallpapers/signin.jpg"
alt=""
/>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid";
import type { User } from "@prisma/client";
import LoadingButton from "~/components/LoadingButton.vue";
import Logo from "~/components/Logo.vue";
const username = ref(""); const username = ref("");
const password = ref(""); const password = ref("");
const rememberMe = ref(false);
const loading = ref(false);
async function register() { const route = useRoute();
await $fetch('/api/v1/signin/simple', { const router = useRouter();
method: "POST",
body: { const error = ref<string | undefined>();
username: username.value,
password: password.value function signin_wrapper() {
} loading.value = true;
signin()
.then(() => {
router.push(route.query.redirect?.toString() ?? "/");
}) })
.catch((response) => {
const message = response.statusMessage || "An unknown error occurred";
error.value = message;
})
.finally(() => {
loading.value = false;
});
} }
</script>
async function signin() {
await $fetch("/api/v1/auth/signin/simple", {
method: "POST",
body: {
username: username.value,
password: password.value,
rememberMe: rememberMe.value,
},
});
const user = useUser();
user.value = await $fetch<User | null>("/api/v1/whoami");
}
definePageMeta({
layout: false,
});
useHead({
title: "Sign in to Drop",
});
</script>

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "admin" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,15 @@
-- CreateEnum
CREATE TYPE "ClientCapabilities" AS ENUM ('DownloadAggregation');
-- CreateTable
CREATE TABLE "Client" (
"sharedToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"capabilities" "ClientCapabilities"[],
CONSTRAINT "Client_pkey" PRIMARY KEY ("sharedToken")
);
-- AddForeignKey
ALTER TABLE "Client" ADD CONSTRAINT "Client_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -14,10 +14,12 @@ datasource db {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
username String @unique username String @unique
admin Boolean @default(false)
authMecs LinkedAuthMec[] authMecs LinkedAuthMec[]
clients Client[]
} }
enum AuthMec { enum AuthMec {
@ -35,6 +37,20 @@ model LinkedAuthMec {
@@id([userId, mec]) @@id([userId, mec])
} }
enum ClientCapabilities {
DownloadAggregation
}
// References a device
model Client {
sharedToken String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
endpoint String
capabilities ClientCapabilities[]
}
enum MetadataSource { enum MetadataSource {
Custom Custom
GiantBomb GiantBomb

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 KiB

View File

@ -8,6 +8,7 @@ export default defineEventHandler(async (h3) => {
const username = body.username; const username = body.username;
const password = body.password; const password = body.password;
const rememberMe = body.rememberMe ?? false;
if (username === undefined || password === undefined) if (username === undefined || password === undefined)
throw createError({ statusCode: 403, statusMessage: "Username or password missing from request." }); throw createError({ statusCode: 403, statusMessage: "Username or password missing from request." });
@ -30,7 +31,7 @@ export default defineEventHandler(async (h3) => {
if (!await checkHash(password, hash.toString())) if (!await checkHash(password, hash.toString()))
throw createError({ statusCode: 401, statusMessage: "Invalid username or password." }); throw createError({ statusCode: 401, statusMessage: "Invalid username or password." });
await h3.context.session.setUserId(h3, authMek.userId); await h3.context.session.setUserId(h3, authMek.userId, rememberMe);
return { result: true, userId: authMek.userId } return { result: true, userId: authMek.userId }
}); });

View File

@ -0,0 +1,3 @@
export default defineEventHandler((h3) => {
})

View File

@ -0,0 +1,3 @@
export default defineEventHandler(async (h3) => {
});

View File

@ -0,0 +1,3 @@
export default defineEventHandler((h3) => {
})

View File

@ -1,5 +1,4 @@
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const user = await h3.context.session.getUser(h3); const user = await h3.context.session.getUser(h3);
return user ?? null;
return user ?? {};
}); });

View File

@ -0,0 +1,26 @@
# Client Handshake process
Drop clients need to complete a handshake in order to connect to a Drop server. It also trades certificates for encrypted P2P connections.
## 1. Client requests a handshake
Client makes request: `POST /api/v1/client/initiate` with information about the client.
Server responds with a URL to send the user to. It generates a device ID, which has all the metadata attached.
## 2. User signs in
Client sends user to the provided URL (in external browser). User signs in using the existing authentication stack.
Server sends redirect to drop://handshake/[id]/[token], where the token is an authentication token to generate the necessary certificates, and the ID is the client ID as generated by the server.
## 3. Client requests certificates
Client makes request: `POST /api/v1/client/handshake` with the token recieved in the previous step.
The server uses it's CA to generate a public-private key pair, the CN of the client ID. It then sends that pair, plus the CA's public key, to the client, which stores it all.
The certificate lasts for a year, and is rotated when it has 3 months or less left on it's expiry.
## 4.a Client requests one-time device endpoint
The client generates a nonce and signs it with their private key. This is then attached to any device-related request.
## 4.b Client wants a long-lived session
The client does the same as above, but instead makes the request to `POST /api/v1/client/session`, which generates a session token that lasts for a day. This can then be used in the request to provide authentication.

View File

@ -0,0 +1,34 @@
import path from "path";
import droplet from "@drop/droplet";
import { CertificateStore } from "./store";
export type CertificateBundle = {
priv: string;
pub: string;
cert: string;
};
/*
This is designed to handle client certificates, as described in the README.md
*/
export class CertificateAuthority {
private certificateStore: CertificateStore;
private root: CertificateBundle;
constructor(store: CertificateStore, root: CertificateBundle) {
this.certificateStore = store;
this.root = root;
}
static async new(store: CertificateStore) {
const root = await store.fetch("ca");
if (root === undefined) {
const [priv, pub, cert] = droplet.generateRootCa();
const bundle: CertificateBundle = { priv, pub, cert };
await store.store("ca", bundle);
return new CertificateAuthority(store, bundle);
}
return new CertificateAuthority(store, root);
}
}

View File

@ -0,0 +1,23 @@
import path from "path";
import fs from "fs";
import { CertificateBundle } from "./ca";
export type CertificateStore = {
store(name: string, data: CertificateBundle): Promise<void>;
fetch(name: string): Promise<CertificateBundle | undefined>;
};
export const fsCertificateStore = (base: string) => {
const store: CertificateStore = {
async store(name: string, data: CertificateBundle) {
const filepath = path.join(base, name);
fs.writeFileSync(filepath, JSON.stringify(data));
},
async fetch(name: string) {
const filepath = path.join(base, name);
if (!fs.existsSync(filepath)) return undefined;
return JSON.parse(fs.readFileSync(filepath, "utf-8"));
},
};
return store;
};

View File

@ -0,0 +1,5 @@
# Drop Download System
The Drop download system uses a torrent-*like* system. It is not torrenting, nor is it compatible with torrenting clients.
## Clients
Drop clients have built-in HTTP APIs that they forward with UPnP. This API exposes different capabilities for different Drop features, like download aggegration and P2P networking. When they sign on, they send a list of supported capabilities to the server.

View File

@ -0,0 +1,10 @@
/*
The download co-ordinator's job is to keep track of all the currently online clients.
When a client signs on and registers itself as a peer
*/
class DownloadCoordinator {
}

View File

@ -26,11 +26,11 @@ export class SessionHandler {
return data[userSessionKey]; return data[userSessionKey];
} }
async setSession(h3: H3Event, data: any) { async setSession(h3: H3Event, data: any, expend = false) {
const result = await this.sessionProvider.updateSession(h3, userSessionKey, data); const result = await this.sessionProvider.updateSession(h3, userSessionKey, data);
if (!result) { if (!result) {
const toCreate = { [userSessionKey]: data }; const toCreate = { [userSessionKey]: data };
await this.sessionProvider.setSession(h3, toCreate); await this.sessionProvider.setSession(h3, toCreate, expend);
} }
} }
async clearSession(h3: H3Event) { async clearSession(h3: H3Event) {
@ -52,11 +52,11 @@ export class SessionHandler {
return user; return user;
} }
async setUserId(h3: H3Event, userId: string) { async setUserId(h3: H3Event, userId: string, extend = false) {
const result = await this.sessionProvider.updateSession(h3, userIdKey, userId); const result = await this.sessionProvider.updateSession(h3, userIdKey, userId);
if (!result) { if (!result) {
const toCreate = { [userIdKey]: userId }; const toCreate = { [userIdKey]: userId };
await this.sessionProvider.setSession(h3, toCreate); await this.sessionProvider.setSession(h3, toCreate, extend);
} }
} }
} }

View File

@ -1,45 +1,45 @@
import moment from "moment"; import moment from "moment";
import { Session, SessionProvider } from "./types"; import { Session, SessionProvider } from "./types";
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from "uuid";
export default function createMemorySessionHandler() { export default function createMemorySessionHandler() {
const sessions: { [key: string]: Session } = {} const sessions: { [key: string]: Session } = {};
const sessionCookieName = "drop-session"; const sessionCookieName = "drop-session";
const memoryProvider: SessionProvider = { const memoryProvider: SessionProvider = {
async setSession(h3, data) { async setSession(h3, data, extend = false) {
const existingCookie = getCookie(h3, sessionCookieName); const existingCookie = getCookie(h3, sessionCookieName);
if (existingCookie) delete sessions[existingCookie]; // Clear any previous session if (existingCookie) delete sessions[existingCookie]; // Clear any previous session
const cookie = uuidv4(); const cookie = uuidv4();
const expiry = moment().add(31, 'day'); const expiry = moment().add(31, extend ? "month" : "day");
setCookie(h3, sessionCookieName, cookie, { expires: expiry.toDate() }); setCookie(h3, sessionCookieName, cookie, { expires: expiry.toDate() });
sessions[cookie] = data; sessions[cookie] = data;
return true; return true;
}, },
async updateSession(h3, key, data) { async updateSession(h3, key, data) {
const cookie = getCookie(h3, sessionCookieName); const cookie = getCookie(h3, sessionCookieName);
if (!cookie) return false; if (!cookie) return false;
sessions[cookie] = Object.assign({}, sessions[cookie], { [key]: data }); sessions[cookie] = Object.assign({}, sessions[cookie], { [key]: data });
return true; return true;
}, },
async getSession(h3) { async getSession(h3) {
const cookie = getCookie(h3, sessionCookieName); const cookie = getCookie(h3, sessionCookieName);
if (!cookie) return undefined; if (!cookie) return undefined;
return sessions[cookie] as any; // Wild type cast because we let the user specify types if they want return sessions[cookie] as any; // Wild type cast because we let the user specify types if they want
}, },
async clearSession(h3) { async clearSession(h3) {
const cookie = getCookie(h3, sessionCookieName); const cookie = getCookie(h3, sessionCookieName);
if (!cookie) return; if (!cookie) return;
delete sessions[cookie]; delete sessions[cookie];
deleteCookie(h3, sessionCookieName); deleteCookie(h3, sessionCookieName);
}, },
}; };
return memoryProvider; return memoryProvider;
} }

View File

@ -3,8 +3,12 @@ import { H3Event } from "h3";
export type Session = { [key: string]: any }; export type Session = { [key: string]: any };
export interface SessionProvider { export interface SessionProvider {
setSession: (h3: H3Event, data: Session) => Promise<boolean>; setSession: (
updateSession: (h3: H3Event, key: string, data: any) => Promise<boolean>; h3: H3Event,
getSession: <T extends Session>(h3: H3Event) => Promise<T | undefined>; data: Session,
clearSession: (h3: H3Event) => Promise<void>; extend?: boolean
} ) => Promise<boolean>;
updateSession: (h3: H3Event, key: string, data: any) => Promise<boolean>;
getSession: <T extends Session>(h3: H3Event) => Promise<T | undefined>;
clearSession: (h3: H3Event) => Promise<void>;
}

18
server/plugins/ca.ts Normal file
View File

@ -0,0 +1,18 @@
import { CertificateAuthority } from "../internal/clients/ca";
import fs from "fs";
import { fsCertificateStore } from "../internal/clients/store";
let ca: CertificateAuthority | undefined;
export const useGlobalCertificateAuthority = () => {
if (!ca) throw new Error("CA not initialised");
return ca;
};
export default defineNitroPlugin(async (nitro) => {
const basePath = process.env.CLIENT_CERTIFICATES ?? "./certs";
fs.mkdirSync(basePath, { recursive: true });
const store = fsCertificateStore(basePath);
ca = await CertificateAuthority.new(store);
});

View File

@ -0,0 +1,5 @@
export default defineEventHandler(async (h3) => {
await h3.context.session.clearSession(h3);
return sendRedirect(h3, "/signin");
});

View File

@ -11,11 +11,13 @@ export default {
theme: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {
sans: ["Helvetica"], sans: ["Inter"],
display: ["Motiva Sans"] display: ["Motiva Sans"]
} }
}, },
}, },
plugins: [], plugins: [
require('@tailwindcss/forms'),
],
} }

View File

@ -296,6 +296,24 @@
dependencies: dependencies:
mime "^3.0.0" mime "^3.0.0"
"@drop/droplet-linux-x64-gnu@0.2.0", "@drop/droplet-linux-x64-gnu@^0.2.0":
version "0.2.0"
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-linux-x64-gnu/-/@drop/droplet-linux-x64-gnu-0.2.0.tgz#e1c0133abc38cf63cc8beaf5826db1946beb1165"
integrity sha1-4cATOrw4z2PMi+r1gm2xlGvrEWU=
"@drop/droplet-win32-x64-msvc@0.2.0":
version "0.2.0"
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-win32-x64-msvc/-/@drop/droplet-win32-x64-msvc-0.2.0.tgz#0531e51e225530c277afcc7ac4230c8d99c8365e"
integrity sha1-BTHlHiJVMMJ3r8x6xCMMjZnINl4=
"@drop/droplet@^0.2.0":
version "0.2.0"
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet/-/@drop/droplet-0.2.0.tgz#e4b6d2cf2bd5c0416fd3452ffa5b7c34267e160a"
integrity sha1-5LbSzyvVwEFv00Uv+lt8NCZ+Fgo=
optionalDependencies:
"@drop/droplet-linux-x64-gnu" "0.2.0"
"@drop/droplet-win32-x64-msvc" "0.2.0"
"@esbuild/aix-ppc64@0.20.2": "@esbuild/aix-ppc64@0.20.2":
version "0.20.2" version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537"
@ -1279,6 +1297,13 @@
resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958" resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958"
integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg== integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==
"@tailwindcss/forms@^0.5.9":
version "0.5.9"
resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.9.tgz#b495c12575d6eae5865b2cbd9876b26d89f16f61"
integrity sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==
dependencies:
mini-svg-data-uri "^1.2.3"
"@tanstack/virtual-core@3.10.8": "@tanstack/virtual-core@3.10.8":
version "3.10.8" version "3.10.8"
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.10.8.tgz#975446a667755222f62884c19e5c3c66d959b8b4" resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.10.8.tgz#975446a667755222f62884c19e5c3c66d959b8b4"
@ -3502,6 +3527,11 @@ mimic-fn@^4.0.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc"
integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==
mini-svg-data-uri@^1.2.3:
version "1.4.4"
resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939"
integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==
minimatch@^3.0.4, minimatch@^3.1.1: minimatch@^3.0.4, minimatch@^3.1.1:
version "3.1.2" version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"