Added basic login.

This commit is contained in:
DebaucheryLibrarian 2024-02-29 05:08:54 +01:00
parent 76a831eb50
commit 78b389e33a
21 changed files with 3553 additions and 189 deletions

3
.gitignore vendored
View File

@ -2,4 +2,5 @@ node_modules/
dist/ dist/
config/* config/*
!config/default.*js !config/default.*js
log log/
media/

View File

@ -10,7 +10,7 @@
&:focus { &:focus {
outline: none; outline: none;
border-color: var(--primary-faded); border-color: var(--primary-light-10);
} }
} }
@ -52,7 +52,7 @@
} }
.button-submit { .button-submit {
background: var(--primary-light-30); background: var(--primary-light-10);
color: var(--text-light); color: var(--text-light);
&:hover:not(:disabled) { &:hover:not(:disabled) {

View File

@ -1,7 +1,6 @@
:root { :root {
--primary: #f65596; --primary: #f65596;
--primary-strong: #f90071; --primary-light-10: #f075a6;
--primary-faded: #ffcce4;
--grey-dark-50: #111; --grey-dark-50: #111;
--grey-dark-40: #222; --grey-dark-40: #222;

View File

@ -1,216 +1,194 @@
.resize-observer[data-v-b329ee4c] { /* Content */
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100%;
border: none;
background-color: transparent;
pointer-events: none;
display: block;
overflow: hidden;
opacity: 0
}
.resize-observer[data-v-b329ee4c] object {
display: block;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
overflow: hidden;
pointer-events: none;
z-index: -1
}
.v-popper__popper { .v-popper__popper {
z-index: 10000; z-index: 10000;
top: 0; top: 0;
left: 0; left: 0;
outline: none; outline: none;
max-height: 100%;
} }
.v-popper__popper.v-popper__popper--hidden { .v-popper__popper.v-popper__popper--hidden {
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
transition: opacity .15s, visibility .15s; transition: opacity .15s, visibility .15s;
pointer-events: none pointer-events: none;
} }
.v-popper__popper.v-popper__popper--shown { .v-popper__popper.v-popper__popper--shown {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
transition: opacity .15s transition: opacity .15s;
} }
.v-popper__popper.v-popper__popper--skip-transition, .v-popper__popper.v-popper__popper--skip-transition,
.v-popper__popper.v-popper__popper--skip-transition>.v-popper__wrapper { .v-popper__popper.v-popper__popper--skip-transition > .v-popper__wrapper {
transition: none !important transition: none !important;
} }
.v-popper__backdrop { .v-popper__backdrop {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: none display: none;
} }
.v-popper__inner { .v-popper__inner {
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
overflow-y: auto overflow-y: auto;
} }
.v-popper__inner>div { .v-popper__inner > div {
position: relative; position: relative;
z-index: 1; z-index: 1;
max-width: inherit; max-width: inherit;
max-height: inherit max-height: inherit;
} }
.v-popper__arrow-container { .v-popper__arrow-container {
position: absolute; position: absolute;
width: 10px; width: 10px;
height: 10px height: 10px;
} }
.v-popper__popper--arrow-overflow .v-popper__arrow-container, .v-popper__popper--arrow-overflow .v-popper__arrow-container,
.v-popper__popper--no-positioning .v-popper__arrow-container { .v-popper__popper--no-positioning .v-popper__arrow-container {
display: none display: none;
} }
.v-popper__arrow-inner, .v-popper__arrow-inner,
.v-popper__arrow-outer { .v-popper__arrow-outer {
border-style: solid; border-style: solid;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 0; width: 0;
height: 0 height: 0;
} }
.v-popper__arrow-inner { .v-popper__arrow-inner {
visibility: hidden; visibility: hidden;
border-width: 7px border-width: 7px;
} }
.v-popper__arrow-outer { .v-popper__arrow-outer {
border-width: 6px border-width: 6px;
} }
.v-popper__popper[data-popper-placement^=top] .v-popper__arrow-inner, .v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-inner,
.v-popper__popper[data-popper-placement^=bottom] .v-popper__arrow-inner { .v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner {
left: -2px left: -2px;
} }
.v-popper__popper[data-popper-placement^=top] .v-popper__arrow-outer, .v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-outer,
.v-popper__popper[data-popper-placement^=bottom] .v-popper__arrow-outer { .v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-outer {
left: -1px left: -1px;
} }
.v-popper__popper[data-popper-placement^=top] .v-popper__arrow-inner, .v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-inner,
.v-popper__popper[data-popper-placement^=top] .v-popper__arrow-outer { .v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-outer {
border-bottom-width: 0; border-bottom-width: 0;
border-left-color: transparent !important; border-left-color: transparent !important;
border-right-color: transparent !important; border-right-color: transparent !important;
border-bottom-color: transparent !important border-bottom-color: transparent !important;
} }
.v-popper__popper[data-popper-placement^=top] .v-popper__arrow-inner { .v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-inner {
top: -2px top: -2px;
} }
.v-popper__popper[data-popper-placement^=bottom] .v-popper__arrow-container { .v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-container {
top: 0 top: 0;
} }
.v-popper__popper[data-popper-placement^=bottom] .v-popper__arrow-inner, .v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner,
.v-popper__popper[data-popper-placement^=bottom] .v-popper__arrow-outer { .v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-outer {
border-top-width: 0; border-top-width: 0;
border-left-color: transparent !important; border-left-color: transparent !important;
border-right-color: transparent !important; border-right-color: transparent !important;
border-top-color: transparent !important border-top-color: transparent !important;
} }
.v-popper__popper[data-popper-placement^=bottom] .v-popper__arrow-inner { .v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner {
top: -4px top: -4px;
} }
.v-popper__popper[data-popper-placement^=bottom] .v-popper__arrow-outer { .v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-outer {
top: -6px top: -6px;
} }
.v-popper__popper[data-popper-placement^=left] .v-popper__arrow-inner, .v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-inner,
.v-popper__popper[data-popper-placement^=right] .v-popper__arrow-inner { .v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-inner {
top: -2px top: -2px;
} }
.v-popper__popper[data-popper-placement^=left] .v-popper__arrow-outer, .v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-outer,
.v-popper__popper[data-popper-placement^=right] .v-popper__arrow-outer { .v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-outer {
top: -1px top: -1px;
} }
.v-popper__popper[data-popper-placement^=right] .v-popper__arrow-inner, .v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-inner,
.v-popper__popper[data-popper-placement^=right] .v-popper__arrow-outer { .v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-outer {
border-left-width: 0; border-left-width: 0;
border-left-color: transparent !important; border-left-color: transparent !important;
border-top-color: transparent !important; border-top-color: transparent !important;
border-bottom-color: transparent !important border-bottom-color: transparent !important;
} }
.v-popper__popper[data-popper-placement^=right] .v-popper__arrow-inner { .v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-inner {
left: -4px left: -4px;
} }
.v-popper__popper[data-popper-placement^=right] .v-popper__arrow-outer { .v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-outer {
left: -6px left: -6px;
} }
.v-popper__popper[data-popper-placement^=left] .v-popper__arrow-container { .v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-container {
right: -10px right: -10px;
} }
.v-popper__popper[data-popper-placement^=left] .v-popper__arrow-inner, .v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-inner,
.v-popper__popper[data-popper-placement^=left] .v-popper__arrow-outer { .v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-outer {
border-right-width: 0; border-right-width: 0;
border-top-color: transparent !important; border-top-color: transparent !important;
border-right-color: transparent !important; border-right-color: transparent !important;
border-bottom-color: transparent !important border-bottom-color: transparent !important;
} }
.v-popper__popper[data-popper-placement^=left] .v-popper__arrow-inner { .v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-inner {
left: -2px left: -2px;
} }
.v-popper--theme-dropdown .v-popper__inner { /* Tooltip */
background: #fff;
color: #000;
border-radius: 6px;
border: 1px solid #ddd;
box-shadow: 0 6px 30px #0000001a
}
.v-popper--theme-dropdown .v-popper__arrow-inner {
visibility: visible;
border-color: #fff
}
.v-popper--theme-dropdown .v-popper__arrow-outer {
border-color: #ddd
}
.v-popper--theme-tooltip .v-popper__inner { .v-popper--theme-tooltip .v-popper__inner {
background: rgba(0, 0, 0, .8); background: rgba(0, 0, 0, .8);
color: #fff; color: white;
border-radius: 6px; border-radius: 6px;
padding: 7px 12px 6px padding: 7px 12px 6px;
} }
.v-popper--theme-tooltip .v-popper__arrow-outer { .v-popper--theme-tooltip .v-popper__arrow-outer {
border-color: #000c border-color: rgba(0, 0, 0, .8);
}
/* Dropdown */
.v-popper--theme-dropdown .v-popper__inner {
background: #fff;
color: black;
border-radius: 6px;
border: 1px solid #ddd;
box-shadow: 0 6px 30px rgba(0, 0, 0, .1);
}
.v-popper--theme-dropdown .v-popper__arrow-inner {
visibility: visible;
border-color: #fff;
}
.v-popper--theme-dropdown .v-popper__arrow-outer {
border-color: #ddd;
} }

View File

@ -53,36 +53,85 @@
</ul> </ul>
</nav> </nav>
<form <div class="header-section">
class="search" <form
@submit.prevent="search" class="search"
> @submit.prevent="search"
<input
v-model="query"
type="search"
placeholder="Search"
class="input"
> >
<input
v-model="query"
type="search"
placeholder="Search"
class="input"
>
<Icon icon="search" /> <Icon icon="search" />
</form> </form>
<VDropdown
v-if="user"
:triggers="['click']"
>
<div class="userpanel">
<img
:src="user.avatar"
class="avatar"
>
</div>
<template #popper>
<div class="menu">
<a
:href="`/user/${user.username}`"
class="menu-header"
>{{ user.username }}</a>
<ul class="menu-list nolist">
<li
class="menu-item logout"
@click="logout"
><Icon icon="exit2" />Log out</li>
</ul>
</div>
</template>
</VDropdown>
<div
v-else
class="userpanel"
>
<a
:href="`/login?r=${encodeURIComponent(currentPath)}`"
class="login button button-submit"
>Log in</a>
</div>
</div>
</header> </header>
</template> </template>
<script setup> <script setup>
import { ref, computed, inject } from 'vue'; import { ref, computed, inject } from 'vue';
import navigate from '#/src/navigate.js'; import navigate from '#/src/navigate.js';
import { del } from '#/src/api.js';
import logo from '../../assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved import logo from '../../assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const user = pageContext.user;
const query = ref(pageContext.urlParsed.search.q || ''); const query = ref(pageContext.urlParsed.search.q || '');
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]); const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
const currentPath = `${pageContext.urlParsed.pathnameOriginal}${pageContext.urlParsed.searchOriginal || ''}`;
function search() { function search() {
navigate('/search', { q: query.value }, { redirect: true }); navigate('/search', { q: query.value }, { redirect: true });
} }
async function logout() {
del('/session');
window.location.reload();
}
</script> </script>
<style scoped> <style scoped>
@ -134,18 +183,22 @@ function search() {
} }
} }
.search { .header-section {
height: 100%; height: 100%;
display: flex;
align-items: stretch;
}
.search {
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: row-reverse; flex-direction: row-reverse; /* allow icon to be selected */
.input { .input {
height: 100%; padding: .5rem 1rem;
border: none; border-radius: 1rem;
border-radius: 0;
border-left: solid 1px var(--shadow-weak-30);
background: var(--background); background: var(--background);
margin: 0;
} }
.icon { .icon {
@ -157,4 +210,63 @@ function search() {
fill: var(--primary); fill: var(--primary);
} }
} }
.userpanel {
height: 100%;
display: flex;
align-items: center;
padding: 0 1rem;
font-size: 0;
cursor: pointer;
&:hover .avatar {
box-shadow: 0 0 3px var(--shadow-weak-10);
}
}
.avatar {
width: 2rem;
height: 2rem;
border-radius: .25rem;
object-fit: cover;
}
.login {
text-decoration: none;
}
.menu-header {
display: flex;
justify-content: center;
padding: .75rem 1rem;
border-bottom: solid 1px var(--shadow-weak-30);
color: var(--shadow-strong-30);
text-decoration: none;
text-align: center;
font-weight: bold;
}
.menu-item {
display: flex;
align-items: center;
padding: .5rem;
.icon {
fill: var(--shadow);
margin-right: .5rem;
}
&:hover {
background: var(--shadow-weak-30);
cursor: pointer;
}
}
.logout {
color: var(--error);
.icon {
fill: var(--error);
}
}
</style> </style>

2770
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,8 +8,11 @@
}, },
"dependencies": { "dependencies": {
"@brillout/json-serializer": "^0.5.8", "@brillout/json-serializer": "^0.5.8",
"@dicebear/collection": "^7.0.5",
"@dicebear/core": "^7.0.5",
"@floating-ui/dom": "^1.5.3", "@floating-ui/dom": "^1.5.3",
"@floating-ui/vue": "^1.0.2", "@floating-ui/vue": "^1.0.2",
"@resvg/resvg-js": "^2.6.0",
"@toycode/markdown-it-class": "^1.2.4", "@toycode/markdown-it-class": "^1.2.4",
"@vitejs/plugin-vue": "^4.5.2", "@vitejs/plugin-vue": "^4.5.2",
"@vue/compiler-sfc": "^3.3.10", "@vue/compiler-sfc": "^3.3.10",
@ -18,6 +21,7 @@
"@vueuse/core": "^10.7.1", "@vueuse/core": "^10.7.1",
"compression": "^1.7.4", "compression": "^1.7.4",
"config": "^3.3.9", "config": "^3.3.9",
"connect-redis": "^7.1.1",
"convert": "^4.14.1", "convert": "^4.14.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"date-fns": "^3.0.0", "date-fns": "^3.0.0",
@ -25,6 +29,8 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-promise-router": "^4.1.1", "express-promise-router": "^4.1.1",
"express-query-boolean": "^2.0.0", "express-query-boolean": "^2.0.0",
"express-session": "^1.18.0",
"floating-vue": "^5.2.2",
"knex": "^3.1.0", "knex": "^3.1.0",
"manticoresearch": "^4.0.0", "manticoresearch": "^4.0.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
@ -34,13 +40,15 @@
"path-to-regexp": "^6.2.1", "path-to-regexp": "^6.2.1",
"pg": "^8.11.3", "pg": "^8.11.3",
"redis": "^4.6.12", "redis": "^4.6.12",
"sharp": "^0.32.6",
"sirv": "^2.0.3", "sirv": "^2.0.3",
"vike": "^0.4.150", "vike": "^0.4.150",
"vite": "^4.5.1", "vite": "^4.5.1",
"vue": "^3.3.10", "vue": "^3.3.10",
"vue-virtual-scroller": "^2.0.0-beta.8", "vue-virtual-scroller": "^2.0.0-beta.8",
"winston": "^3.11.0", "winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1" "winston-daily-rotate-file": "^4.7.1",
"yargs": "^17.7.2"
}, },
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {

186
pages/auth/login/+Page.vue Normal file
View File

@ -0,0 +1,186 @@
<template>
<div class="login-container">
<div v-if="user">
You are already logged in as {{ user.username }}.
<ul>
<li>
<a
:href="`/user/${user.username}`"
class="link"
>View my profile</a>
</li>
<li>
<a
:href="`/updates`"
class="link"
>Check out latest porn updates</a>
</li>
<li>
<a
:href="`/actors`"
class="link"
>Browse the hottest porn stars</a>
</li>
</ul>
</div>
<form
v-else
class="login-panel"
@submit.prevent="login"
>
<div
v-if="errorMsg"
class="error"
>{{ errorMsg }}</div>
<input
v-model="username"
placeholder="Username or e-mail"
class="input"
>
<div class="password-container">
<input
v-model="password"
:type="showPassword ? 'input' : 'password'"
placeholder="Password"
class="password input"
>
<div
class="password-show"
@click="showPassword = !showPassword"
>
<Icon
v-show="!showPassword"
icon="eye"
class="password-show"
/>
<Icon
v-show="showPassword"
icon="eye-blocked"
class="password-show"
/>
</div>
</div>
<button class="button button-submit">Log in</button>
<a
href="/signup"
class="link"
>Create an account</a>
</form>
</div>
</template>
<script setup>
import { ref, inject } from 'vue';
import { post } from '#/src/api.js';
import navigate from '#/src/navigate.js';
const pageContext = inject('pageContext');
const user = pageContext.user;
const username = ref('');
const password = ref('');
const errorMsg = ref(null);
const showPassword = ref(false);
async function login() {
errorMsg.value = null;
try {
await post('/session', {
username: username.value,
password: password.value,
redirect: pageContext.urlParsed.search.r,
});
navigate(decodeURIComponent(pageContext.urlParsed.search.r), null, { redirect: true });
} catch (error) {
errorMsg.value = error.message;
}
}
</script>
<style scoped>
.login-container {
display: flex;
flex-grow: 1;
justify-content: center;
align-items: center;
background: var(--background-base-10);
}
.login-panel {
width: 20rem;
max-width: 100%;
display: flex;
flex-direction: column;
gap: .5rem;
padding: 1rem;
margin: 1rem;
border-radius: .5rem;
box-shadow: 0 0 3px var(--shadow-weak-30);
background: var(--background-base);
font-size: 1rem;
.button {
justify-content: center;
}
.link {
margin-top: .5rem;
text-align: center;
}
}
.password-container {
display: flex;
position: relative;
}
.password {
flex-grow: 1;
}
.password-show {
height: 100%;
display: flex;
align-items: center;
position: absolute;
right: 0;
padding: 0 1rem 0 .5rem;
.icon {
fill: var(--shadow);
}
&:hover {
cursor: pointer;
.icon {
fill: var(--primary);
}
}
}
.error {
background: var(--error);
color: var(--text-light);
padding: .75rem 1rem;
border-radius: .25rem;
margin-bottom: .5rem;
font-size: .9rem;
font-weight: bold;
text-align: center;
}
</style>

View File

@ -0,0 +1 @@
export default '/login';

View File

@ -0,0 +1,43 @@
<template>
<div class="profile">
<div class="profile-header">
<img
v-if="profile.avatar"
:src="profile.avatar"
class="avatar"
>
<h2 class="username">{{ profile.username }}</h2>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue';
const pageContext = inject('pageContext');
const profile = pageContext.pageProps.profile;
console.log('profile', profile);
</script>
<style scoped>
.profile-header {
display: flex;
align-items: center;
padding: .5rem 1rem;
color: var(--highlight-strong-30);
background: var(--grey-dark-40);
}
.username {
margin: 0;
}
.avatar {
width: 2rem;
height: 2rem;
border-radius: .25rem;
margin-right: 1rem;
}
</style>

View File

@ -0,0 +1,14 @@
import { fetchUser } from '#/src/users.js';
export async function onBeforeRender(pageContext) {
const profile = await fetchUser(pageContext.routeParams.username);
return {
pageContext: {
title: profile.username,
pageProps: {
profile, // differentiate from authed 'user'
},
},
};
}

View File

@ -0,0 +1 @@
export default '/user/@username';

View File

@ -1,3 +1,3 @@
export default { export default {
passToClient: ['pageProps', 'urlPathname', 'routeParams', 'urlParsed', 'env'], passToClient: ['pageProps', 'urlPathname', 'routeParams', 'urlParsed', 'env', 'user'],
}; };

View File

@ -1,5 +1,6 @@
import { createSSRApp, h } from 'vue'; import { createSSRApp, h } from 'vue';
import VueVirtualScroller from 'vue-virtual-scroller'; import VueVirtualScroller from 'vue-virtual-scroller';
import FloatingVue from 'floating-vue';
import { setPageContext } from './usePageContext.js'; import { setPageContext } from './usePageContext.js';
@ -31,6 +32,7 @@ function createApp(Page, pageProps, pageContext) {
app.provide('pageContext', pageContext); app.provide('pageContext', pageContext);
app.use(FloatingVue);
app.use(VueVirtualScroller); app.use(VueVirtualScroller);
app.component('Link', Link); app.component('Link', Link);

View File

@ -1,4 +1,4 @@
import { parse } from '@brillout/json-serializer/parse'; import { parse } from '@brillout/json-serializer/parse'; /* eslint-disable-line import/extensions */
const postHeaders = { const postHeaders = {
mode: 'cors', mode: 'cors',
@ -26,7 +26,7 @@ export async function get(path, query = {}) {
return body; return body;
} }
throw new Error(body.message); throw new Error(body.statusMessage);
} }
export async function post(path, data, { query } = {}) { export async function post(path, data, { query } = {}) {
@ -66,7 +66,7 @@ export async function patch(path, data, { query } = {}) {
return body; return body;
} }
throw new Error(body.message); throw new Error(body.statusMessage);
} }
export async function del(path, { data, query } = {}) { export async function del(path, { data, query } = {}) {
@ -86,5 +86,5 @@ export async function del(path, { data, query } = {}) {
return body; return body;
} }
throw new Error(body.message); throw new Error(body.statusMessage);
} }

11
src/argv.js Executable file
View File

@ -0,0 +1,11 @@
import yargs from 'yargs';
const { argv } = yargs()
.command('npm start')
.option('debug', {
describe: 'Show error stack traces',
type: 'boolean',
default: process.env.NODE_ENV === 'development',
});
export default argv;

124
src/auth.js Executable file
View File

@ -0,0 +1,124 @@
import config from 'config';
import util from 'util';
import crypto from 'crypto';
import fs from 'fs/promises';
import { createAvatar } from '@dicebear/core';
import { shapes } from '@dicebear/collection';
import knex from './knex.js';
import { curateUser, fetchUser } from './users.js';
import { HttpError } from './errors.js';
const scrypt = util.promisify(crypto.scrypt);
async function verifyPassword(password, storedPassword) {
const [salt, hash] = storedPassword.split('/');
const hashedPassword = (await scrypt(password, salt, 64)).toString('hex');
if (hashedPassword === hash) {
return true;
}
throw new HttpError('Username or password incorrect', 401);
}
async function generateAvatar(user) {
const avatar = createAvatar(shapes, {
seed: user.username,
backgroundColor: ['f65596', '9b004b', '006b68', '5abab6'],
shape1Color: ['f65596', 'ff6d7e', 'ff8d69', 'ffb15b', 'ffd55c', 'f9f871'],
shape2Color: ['c162c6', '6074dd', '007dd2', '007ba9', '007170'],
shape3Color: ['f65596', 'ff6d7e', 'ff8d69', 'ffb15b', 'ffd55c', 'f9f871'],
});
await fs.mkdir('media/avatars', { recursive: true });
await avatar.png().toFile(`media/avatars/${user.id}_${user.username}.png`);
}
export async function login(credentials) {
if (!config.auth.login) {
throw new HttpError('Authentication is disabled', 405);
}
const user = await fetchUser(credentials.username.trim(), true);
if (!user) {
throw new HttpError('Username or password incorrect', 401);
}
await verifyPassword(credentials.password, user.password);
await knex('users')
.update('last_login', 'NOW()')
.where('id', user.id);
if (!user.avatar) {
await generateAvatar(user);
}
return curateUser(user);
}
export async function signup(credentials) {
if (!config.auth.signup) {
throw new HttpError('Authentication is disabled', 405);
}
const curatedUsername = credentials.username.trim();
if (!curatedUsername) {
throw new HttpError('Username required', 400);
}
if (curatedUsername.length < config.auth.usernameLength[0]) {
throw new HttpError('Username is too short', 400);
}
if (curatedUsername.length > config.auth.usernameLength[1]) {
throw new HttpError('Username is too long', 400);
}
if (!config.auth.usernamePattern.test(curatedUsername)) {
throw new HttpError('Username contains invalid characters', 400);
}
if (!credentials.email) {
throw new HttpError('E-mail required', 400);
}
if (credentials.password?.length < 3) {
throw new HttpError('Password must be 3 characters or longer', 400);
}
const existingUser = await knex('users')
.where('username', curatedUsername)
.orWhere('email', credentials.email)
.first();
if (existingUser) {
throw new HttpError('Username or e-mail already in use', 409);
}
const salt = crypto.randomBytes(16).toString('hex');
const hashedPassword = (await scrypt(credentials.password, salt, 64)).toString('hex');
const storedPassword = `${salt}/${hashedPassword}`;
const [{ id: userId }] = await knex('users')
.insert({
username: curatedUsername,
email: credentials.email,
password: storedPassword,
})
.returning('id');
await knex('stashes').insert({
user_id: userId,
name: 'Favorites',
slug: 'favorites',
public: false,
primary: true,
});
return fetchUser(userId);
}

50
src/users.js Executable file
View File

@ -0,0 +1,50 @@
import knex from './knex.js';
// import { curateStash } from './stashes.js';
export function curateUser(user) {
if (!user) {
return null;
}
const ability = [...(user.role_abilities || []), ...(user.abilities || [])];
const curatedUser = {
id: user.id,
username: user.username,
email: user.email,
emailVerified: user.email_verified,
identityVerified: user.identity_verified,
ability,
avatar: `/media/avatars/${user.id}_${user.username}.png`,
createdAt: user.created_at,
// stashes: user.stashes?.filter(Boolean).map((stash) => curateStash(stash)) || [],
};
return curatedUser;
}
export async function fetchUser(userId, raw) {
const user = await knex('users')
.select(knex.raw('users.*, users_roles.abilities as role_abilities, COALESCE(json_agg(stashes ORDER BY stashes.created_at) FILTER (WHERE stashes.id IS NOT NULL), \'[]\') as stashes'))
.modify((builder) => {
if (typeof userId === 'number') {
builder.where('users.id', userId);
}
if (typeof userId === 'string') {
builder
.where('users.username', userId)
.orWhere('users.email', userId);
}
})
.leftJoin('users_roles', 'users_roles.role', 'users.role')
.leftJoin('stashes', 'stashes.user_id', 'users.id')
.groupBy('users.id', 'users_roles.role')
.first();
if (raw) {
return user;
}
return curateUser(user);
}

49
src/web/auth.js Executable file
View File

@ -0,0 +1,49 @@
/* eslint-disable no-param-reassign */
import { login, signup } from '../auth.js';
import { fetchUser } from '../users.js';
export async function setUserApi(req, res, next) {
if (req.session.user) {
req.user = req.session.user;
}
next();
}
export async function loginApi(req, res) {
console.log('login!', req.body);
const user = await login(req.body);
req.session.user = user;
res.send(user);
}
export async function logoutApi(req, res) {
req.session.destroy((error) => {
if (error) {
res.status(500).send();
}
res.status(204).send();
});
}
export async function fetchMeApi(req, res) {
if (req.session.user) {
req.session.user = await fetchUser(req.session.user.id, false, req.session.user);
res.send(req.session.user);
return;
}
res.status(401).send();
}
export async function signupApi(req, res) {
const user = await signup(req.body);
req.session.user = user;
res.send(user);
}
/* eslint-enable no-param-reassign */

26
src/web/error.js Executable file
View File

@ -0,0 +1,26 @@
import argv from '../argv.js';
import initLogger from '../logger.js';
const logger = initLogger();
export default function errorHandler(error, req, res, _next) {
logger.warn(`Failed to fulfill request to ${req.path} (${error.httpCode || 500}): ${error.message}`);
if (argv.debug) {
logger.error(error);
}
if (error.httpCode) {
res.status(error.httpCode).send({
statusCode: error.httpCode,
statusMessage: error.message,
});
return;
}
res.status(500).send({
statusCode: 500,
statusMessage: 'Oops... our server messed up. We will be investigating this incident, our apologies for the inconvenience.',
});
}

View File

@ -15,15 +15,27 @@ import config from 'config';
import express from 'express'; import express from 'express';
import boolParser from 'express-query-boolean'; import boolParser from 'express-query-boolean';
import Router from 'express-promise-router'; import Router from 'express-promise-router';
import session from 'express-session';
import RedisStore from 'connect-redis';
import compression from 'compression'; import compression from 'compression';
import { renderPage } from 'vike/server'; // eslint-disable-line import/extensions import { renderPage } from 'vike/server'; // eslint-disable-line import/extensions
// import root from './root.js'; // import root from './root.js';
import redis from '../redis.js';
import errorHandler from './error.js';
import { fetchScenesApi } from './scenes.js'; import { fetchScenesApi } from './scenes.js';
import { fetchActorsApi } from './actors.js'; import { fetchActorsApi } from './actors.js';
import { fetchMoviesApi } from './movies.js'; import { fetchMoviesApi } from './movies.js';
import {
setUserApi,
loginApi,
logoutApi,
} from './auth.js';
import initLogger from '../logger.js'; import initLogger from '../logger.js';
const logger = initLogger(); const logger = initLogger();
@ -42,6 +54,20 @@ export default async function initServer() {
router.use('/', express.static('static')); router.use('/', express.static('static'));
router.use('/media', express.static(config.media.path)); router.use('/media', express.static(config.media.path));
router.use(express.json());
const redisStore = new RedisStore({
client: redis,
prefix: 'traxxx:session:',
});
router.use(session({
...config.web.session,
store: redisStore,
}));
router.use(setUserApi);
// Vite integration // Vite integration
if (isProduction) { if (isProduction) {
// In production, we need to serve our static assets ourselves. // In production, we need to serve our static assets ourselves.
@ -69,6 +95,9 @@ export default async function initServer() {
router.get('/api/movies', fetchMoviesApi); router.get('/api/movies', fetchMoviesApi);
router.post('/api/session', loginApi);
router.delete('/api/session', logoutApi);
// ... // ...
// Other middlewares (e.g. some RPC middleware such as Telefunc) // Other middlewares (e.g. some RPC middleware such as Telefunc)
// ... // ...
@ -79,6 +108,7 @@ export default async function initServer() {
const pageContextInit = { const pageContextInit = {
urlOriginal: req.originalUrl, urlOriginal: req.originalUrl,
urlQuery: req.query, // vike's own query does not apply boolean parser urlQuery: req.query, // vike's own query does not apply boolean parser
user: req.user,
env: { env: {
maxAggregateSize: config.database.manticore.maxAggregateSize, maxAggregateSize: config.database.manticore.maxAggregateSize,
}, },
@ -110,6 +140,7 @@ export default async function initServer() {
res.send(body); res.send(body);
}); });
router.use(errorHandler);
app.use(router); app.use(router);
const port = process.env.PORT || config.web.port || 3000; const port = process.env.PORT || config.web.port || 3000;