Compare commits
62 Commits
fab895b85e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 37a919770a | |||
| 4e59994806 | |||
| 699f7f0944 | |||
| bf220f6b4e | |||
| 02ee99d52f | |||
| e50dedca66 | |||
| 760025f414 | |||
| 07d9dfaaaa | |||
| d3f549c92f | |||
| bf64a3d30e | |||
| b27c4811da | |||
| c462c2b11a | |||
| a774254e71 | |||
| 0a665ab553 | |||
| c2d8cc3a40 | |||
| eed07ba84b | |||
| e8f1dca2a0 | |||
| 4037098927 | |||
| e007dd2b83 | |||
| b86cf8e30f | |||
| e46f174a1e | |||
| 32b8f00308 | |||
| c9e698c906 | |||
| 286248e403 | |||
| f9e03f47ba | |||
| b54174eb5e | |||
| 62d19d1a04 | |||
| 6b3866a99f | |||
| 18c30b6f98 | |||
| 77b27a56f7 | |||
| 4c48700086 | |||
| 6f68a50eb7 | |||
| ef9be8ef9a | |||
| a81343c519 | |||
| 60ad068939 | |||
| df6965cb3a | |||
| 9e95bec659 | |||
| 88b866c96a | |||
| 5784c08e1b | |||
| 753840f4f1 | |||
| dbe27d68d3 | |||
| 9b058fd44b | |||
| 17d34f6a80 | |||
| 17fd285d67 | |||
| 097baed957 | |||
| b983fbaa1f | |||
| afe337d44a | |||
| 6fe8c84ef4 | |||
| e71a6987fc | |||
| 938878d860 | |||
| 04abff06ec | |||
| 52d5a314de | |||
| 5b17eb15a2 | |||
| 49ef28a5bf | |||
| 0d481e7798 | |||
| 827566d57d | |||
| 6444cf21eb | |||
| 216e7f59ef | |||
| 479b044798 | |||
| 7c6bde690f | |||
| 981205efaa | |||
| b6408f3244 |
@@ -8,3 +8,5 @@
|
||||
@custom-media --small (max-width: 900px);
|
||||
@custom-media --compact (max-width: 1200px);
|
||||
@custom-media --big (max-width: 1500px);
|
||||
@custom-media --big-10 (max-width: 1750px);
|
||||
@custom-media --big-20 (max-width: 2000px);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.input {
|
||||
width: 15rem;
|
||||
box-sizing: border-box;
|
||||
padding: .5rem .75rem;
|
||||
font-size: 1rem;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Content */
|
||||
|
||||
.v-popper__popper {
|
||||
z-index: 10000;
|
||||
z-index: 1000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
outline: none;
|
||||
@@ -45,6 +45,10 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.no-outer-scroll .v-popper__inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.v-popper__inner > div {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -82,6 +86,10 @@
|
||||
border-width: 6px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-container {
|
||||
bottom: -6px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-inner,
|
||||
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner {
|
||||
left: -2px;
|
||||
@@ -105,7 +113,7 @@
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-container {
|
||||
top: 0;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner,
|
||||
@@ -151,7 +159,11 @@
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-container {
|
||||
right: -10px;
|
||||
right: -7px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-container {
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-inner,
|
||||
|
||||
5
assets/img/icons/drawer-out.svg
Executable file
@@ -0,0 +1,5 @@
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M15.89 10.188l-4-5c-0.172-0.216-0.487-0.251-0.703-0.078s-0.251 0.487-0.078 0.703l3.89 4.863v0.325h-3.5l-2 2h-3l-2-2h-3.5v-0.325l3.89-4.863c0.173-0.216 0.138-0.53-0.078-0.703s-0.53-0.138-0.703 0.078l-4 5c-0.071 0.089-0.11 0.199-0.11 0.312v4.5c0 0.552 0.448 1 1 1h14c0.552 0 1-0.448 1-1v-4.5c0-0.114-0.039-0.224-0.11-0.312z"></path>
|
||||
<path d="M8 0.5l-3.5 3.5h2.5v5h2v-5h2.5z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 528 B |
6
assets/img/icons/file-video.svg
Executable file
@@ -0,0 +1,6 @@
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M14.341 3.579c-0.347-0.473-0.831-1.027-1.362-1.558s-1.085-1.015-1.558-1.362c-0.806-0.591-1.197-0.659-1.421-0.659h-7.75c-0.689 0-1.25 0.561-1.25 1.25v13.5c0 0.689 0.561 1.25 1.25 1.25h11.5c0.689 0 1.25-0.561 1.25-1.25v-9.75c0-0.224-0.068-0.615-0.659-1.421v0 0zM12.271 2.729c0.48 0.48 0.856 0.912 1.134 1.271h-2.406v-2.405c0.359 0.278 0.792 0.654 1.271 1.134v0 0zM14 14.75c0 0.136-0.114 0.25-0.25 0.25h-11.5c-0.135 0-0.25-0.114-0.25-0.25v-13.5c0-0.135 0.115-0.25 0.25-0.25 0 0 7.749-0 7.75 0v3.5c0 0.276 0.224 0.5 0.5 0.5h3.5v9.75z"></path>
|
||||
<path d="M4 8h5v5h-5v-5z"></path>
|
||||
<path d="M9 10l3-2v5l-3-2z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 756 B |
4
assets/img/icons/profile.svg
Executable file
@@ -0,0 +1,4 @@
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M13.5 0h-12c-0.825 0-1.5 0.675-1.5 1.5v13c0 0.825 0.675 1.5 1.5 1.5h12c0.825 0 1.5-0.675 1.5-1.5v-13c0-0.825-0.675-1.5-1.5-1.5zM13 14h-11v-12h11v12zM4 9h7v1h-7zM4 11h7v1h-7zM5 4.5c0-0.828 0.672-1.5 1.5-1.5s1.5 0.672 1.5 1.5c0 0.828-0.672 1.5-1.5 1.5s-1.5-0.672-1.5-1.5zM7.5 6h-2c-0.825 0-1.5 0.45-1.5 1v1h5v-1c0-0.55-0.675-1-1.5-1z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 488 B |
18
assets/img/icons/sort-add-asc.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
id="svg3"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs3" /><path
|
||||
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||
id="path1" /><path
|
||||
id="path1-3"
|
||||
style="stroke-width:0.348033"
|
||||
d="M 10.443359 2.2773438 C 10.354073 2.2861032 10.283203 2.3587981 10.283203 2.4472656 L 10.283203 4.328125 L 8.3339844 4.328125 C 8.2362054 4.328125 8.1582031 4.4056346 8.1582031 4.5 L 8.1582031 5.5253906 C 8.1582031 5.6197556 8.2362047 5.6953125 8.3339844 5.6953125 L 10.283203 5.6953125 L 10.283203 7.5292969 L 11.556641 6.46875 A 0.14617623 0.14617623 0 0 0 11.556641 6.4667969 C 11.601003 6.4299705 11.650097 6.3991617 11.701172 6.375 L 11.701172 5.6953125 L 13.648438 5.6953125 C 13.746218 5.6953125 13.826172 5.619755 13.826172 5.5253906 L 13.826172 4.5 C 13.826172 4.405635 13.746225 4.328125 13.648438 4.328125 L 11.701172 4.328125 L 11.701172 2.4472656 C 11.701172 2.3529006 11.621224 2.2773437 11.523438 2.2773438 L 10.460938 2.2773438 C 10.454826 2.2773438 10.449312 2.2767598 10.443359 2.2773438 z " /><path
|
||||
d="M 16,5 C 16,2.243 13.757,0 11,0 8.243,0 6,2.243 6,5 V 5.5 C 6,5.776 6.224,6 6.5,6 6.776,6 7,5.776 7,5.5 V 5 c 0,-2.206 1.794,-4 4,-4 2.206,0 4,1.794 4,4 0,1.86 -1.277,3.428 -3,3.873 V 7 L 9,9.5 12,12 V 9.899 C 14.279,9.434 16,7.414 16,5 Z"
|
||||
id="path3" /></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
18
assets/img/icons/sort-add-desc.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
id="svg3"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs3" /><path
|
||||
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||
id="path1" /><path
|
||||
id="path1-3"
|
||||
style="stroke-width:0.348033"
|
||||
d="M 10.443359 2.2773438 C 10.354073 2.2861032 10.283203 2.3587981 10.283203 2.4472656 L 10.283203 4.328125 L 8.3339844 4.328125 C 8.2362054 4.328125 8.1582031 4.4056346 8.1582031 4.5 L 8.1582031 5.5253906 C 8.1582031 5.6197556 8.2362047 5.6953125 8.3339844 5.6953125 L 10.283203 5.6953125 L 10.283203 6.3671875 C 10.340222 6.3923929 10.394355 6.4261166 10.443359 6.4667969 A 0.14617623 0.14617623 0 0 1 10.443359 6.46875 L 11.701172 7.5175781 L 11.701172 5.6953125 L 13.648438 5.6953125 C 13.746218 5.6953125 13.826172 5.619755 13.826172 5.5253906 L 13.826172 4.5 C 13.826172 4.405635 13.746225 4.328125 13.648438 4.328125 L 11.701172 4.328125 L 11.701172 2.4472656 C 11.701172 2.3529006 11.621224 2.2773437 11.523438 2.2773438 L 10.460938 2.2773438 C 10.454826 2.2773438 10.449312 2.2767598 10.443359 2.2773438 z " /><path
|
||||
d="m 6,5 c 0,-2.757 2.243,-5 5,-5 2.757,0 5,2.243 5,5 V 5.5 C 16,5.776 15.776,6 15.5,6 15.224,6 15,5.776 15,5.5 V 5 C 15,2.794 13.206,1 11,1 8.794,1 7,2.794 7,5 7,6.86 8.277,8.428 10,8.873 V 7 L 13,9.5 10,12 V 9.899 C 7.721,9.434 6,7.414 6,5 Z"
|
||||
id="path3" /></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
16
assets/img/icons/sort-add.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
id="svg3"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs3" /><path
|
||||
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||
id="path1" /><path
|
||||
d="M 14.334849,4.1517112 H 11.883382 V 1.785868 c 0,-0.1187227 -0.09983,-0.2150766 -0.222859,-0.2150766 h -1.337165 c -0.12302,0 -0.222861,0.096353 -0.222861,0.2150766 V 4.1517112 H 7.6490291 c -0.1230181,0 -0.2228598,0.096353 -0.2228598,0.2150768 v 1.2904597 c 0,0.1187229 0.099841,0.2150767 0.2228598,0.2150767 h 2.4514679 v 2.365843 c 0,0.1187227 0.09983,0.2150768 0.222861,0.2150768 h 1.337165 c 0.123019,0 0.222859,-0.096353 0.222859,-0.2150768 v -2.365843 h 2.451467 c 0.12302,0 0.22286,-0.096355 0.22286,-0.2150767 V 4.366788 c 0,-0.1187229 -0.09983,-0.2150768 -0.22286,-0.2150768 z"
|
||||
id="path1-3"
|
||||
style="stroke-width:0.437868" /></svg>
|
||||
|
After Width: | Height: | Size: 1010 B |
38
assets/img/icons/sort-heart.svg
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
id="svg3"
|
||||
sodipodi:docname="sort-heart.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs3" /><sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="26.077075"
|
||||
inkscape:cx="4.0265253"
|
||||
inkscape:cy="4.640091"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1020"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="32"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg3"
|
||||
showguides="true" /><path
|
||||
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||
id="path1" /><path
|
||||
d="M 13.78723,0 C 12.941692,0 12.207573,0.47431375 11.834786,1.1709458 11.461999,0.47431375 10.72788,0 9.8823418,0 c -1.2219697,0 -2.21277,0.99080032 -2.21277,2.2127699 0,2.9937477 4.1652142,5.0763547 4.1652142,5.0763547 0,0 4.165214,-2.082607 4.165214,-5.0763547 C 16,0.99080032 15.0092,0 13.78723,0 Z"
|
||||
id="path1-5"
|
||||
style="stroke-width:0.520651" /></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -303,6 +303,7 @@ function updateFilter(prop, value, reload = true) {
|
||||
display: grid;
|
||||
flex-grow: 1;
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
grid-auto-rows: max-content; /* prevent row gaps on large screens */
|
||||
gap: .25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,10 @@ async function addActor() {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.load-container {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -77,12 +77,15 @@ function getSocialUrl(social) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const socials = props.actor.socials.slice(0, 10).map((social) => ({
|
||||
...social,
|
||||
handle: social.url
|
||||
? new URL(social.url).hostname
|
||||
: social.handle,
|
||||
}));
|
||||
const socials = props.actor.socials
|
||||
.filter((social) => social.platform)
|
||||
.slice(0, 10)
|
||||
.map((social) => ({
|
||||
...social,
|
||||
handle: social.url
|
||||
? new URL(social.url).hostname
|
||||
: social.handle,
|
||||
}));
|
||||
|
||||
const aliases = Object
|
||||
.entries(props.actor.aliases.reduce((acc, alias) => {
|
||||
|
||||
@@ -52,7 +52,8 @@ function getActorNames() {
|
||||
async function merge() {
|
||||
submitted.value = true;
|
||||
|
||||
await post(`/actors/${targetActor.value.id}/merge/${props.actors.map((actor) => actor.id).join(',')}`, {
|
||||
await post(`/actors/${targetActor.value.id}/merge`, {
|
||||
actorIds: props.actors.map((actor) => actor.id),
|
||||
comment: comment.value || null,
|
||||
}, {
|
||||
successFeedback: `Merged ${getActorNames()} into ${targetActor.value.name}`,
|
||||
@@ -147,6 +148,7 @@ onMounted(() => {
|
||||
:triggers="[]"
|
||||
:shown="actorResults.length > 0"
|
||||
:auto-hide="false"
|
||||
placement="auto"
|
||||
>
|
||||
<input
|
||||
ref="actorInput"
|
||||
|
||||
@@ -49,6 +49,17 @@ async function initCaches() {
|
||||
:class="{ active: pageContext.routeParams.section === 'entities' }"
|
||||
>Entity Health</a>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="pageContext.user.role === 'admin'"
|
||||
class="nav-item"
|
||||
>
|
||||
<a
|
||||
href="/admin/emails"
|
||||
class="nav-link nolink"
|
||||
:class="{ active: pageContext.urlPathname.startsWith('/admin/emails') }"
|
||||
>E-mails</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
|
||||
@@ -128,8 +128,6 @@ function toggleFilterCombined() {
|
||||
<template>
|
||||
<section class="profile-section">
|
||||
<div class="section-header">
|
||||
<h3 class="heading">Alerts</h3>
|
||||
|
||||
<button
|
||||
class="button"
|
||||
@click="showAlertDialog = true"
|
||||
@@ -320,16 +318,22 @@ function toggleFilterCombined() {
|
||||
<div class="alert-triggers">
|
||||
<Icon
|
||||
v-if="alert.notify && alert.isFromPreset"
|
||||
v-tooltip="{ content: 'Notify in traxxx, added as quick alert', ariaId: `alert-notify-preset-${alert.id}` }"
|
||||
v-tooltip="{ content: alert.notify ? 'Notify in traxxx, added as quick alert' : undefined, ariaId: `alert-notify-preset-${alert.id}` }"
|
||||
icon="bell-plus"
|
||||
class="trigger"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-else-if="alert.notify"
|
||||
v-tooltip="{ content: 'Notify in traxxx', ariaId: `alert-notify-${alert.id}` }"
|
||||
v-else
|
||||
v-tooltip="{ content: alert.notify ? 'Notify in traxxx' : undefined, ariaId: `alert-notify-${alert.id}` }"
|
||||
icon="bell2"
|
||||
class="trigger"
|
||||
:class="{ trigger: alert.notify }"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-tooltip="'E-mail me'"
|
||||
icon="envelop5"
|
||||
:class="{ trigger: alert.email }"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
@@ -340,19 +344,11 @@ function toggleFilterCombined() {
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-else-if="alert.stashes.length > 0"
|
||||
v-else
|
||||
v-tooltip="alert.stashes.length > 0 ? { content: 'Add to Favorites', ariaId: `alert-favorites-${alert.id}` } : undefined"
|
||||
icon="heart7"
|
||||
class="trigger"
|
||||
:class="{ trigger: alert.stashes.length > 0 }"
|
||||
/>
|
||||
|
||||
<!--
|
||||
<Icon
|
||||
icon="envelop5"
|
||||
title="E-mail me"
|
||||
:class="{ trigger: alert.email }"
|
||||
/>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<div class="alert-actions">
|
||||
@@ -379,6 +375,10 @@ function toggleFilterCombined() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-section .section-header {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.alerts {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
import { inject, ref, useId } from 'vue';
|
||||
|
||||
import Dialog from '#/components/dialog/dialog.vue';
|
||||
@@ -20,27 +21,34 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
presetScenes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['alert', 'close']);
|
||||
|
||||
const { assets } = inject('pageContext');
|
||||
const {
|
||||
assets,
|
||||
user,
|
||||
env,
|
||||
} = inject('pageContext');
|
||||
|
||||
const actors = ref(props.presetActors);
|
||||
const actorResults = ref([]);
|
||||
const actorInput = ref(null);
|
||||
const actorQuery = ref('');
|
||||
|
||||
const entities = ref(props.presetEntities);
|
||||
const entityResults = ref([]);
|
||||
const entityInput = ref(null);
|
||||
const entityQuery = ref('');
|
||||
|
||||
const tags = ref(props.presetTags);
|
||||
const tagResults = ref([]);
|
||||
const tagInput = ref(null);
|
||||
const tagQuery = ref('');
|
||||
|
||||
const scenes = ref(props.presetScenes);
|
||||
|
||||
const matches = ref([]);
|
||||
const matchProperty = ref('title');
|
||||
const matchExpression = ref('');
|
||||
@@ -50,6 +58,8 @@ const actorAnd = ref(true);
|
||||
const tagAnd = ref(true);
|
||||
const matchAnd = ref(true);
|
||||
|
||||
const inputs = {};
|
||||
|
||||
const fieldsLogicTooltipId = useId();
|
||||
const addActorDropdownId = useId();
|
||||
const addTagDropdownId = useId();
|
||||
@@ -57,8 +67,8 @@ const addEntityDropdownId = useId();
|
||||
const addMatchDropdownId = useId();
|
||||
const addStashDropdownId = useId();
|
||||
|
||||
const notify = ref(true);
|
||||
const email = ref(false);
|
||||
const notify = ref(JSON.parse(localStorage.getItem('alert_notify')) ?? true);
|
||||
const email = ref(JSON.parse(localStorage.getItem('alert_email')) ?? false);
|
||||
|
||||
const stashes = ref([]);
|
||||
|
||||
@@ -68,6 +78,7 @@ async function createAlert() {
|
||||
...tags.value.map((tag) => tag.name),
|
||||
...entities.value.map((entity) => entity.name),
|
||||
...matches.value.map((match) => match.expression),
|
||||
...scenes.value.map((scene) => scene.title),
|
||||
].filter(Boolean).join(', ');
|
||||
|
||||
const newAlert = await post('/alerts', {
|
||||
@@ -77,6 +88,7 @@ async function createAlert() {
|
||||
allMatches: matchAnd.value,
|
||||
actors: actors.value.map((actor) => actor.id),
|
||||
tagIds: tags.value.map((tag) => tag.id),
|
||||
sceneIds: scenes.value.map((scene) => scene.id),
|
||||
matches: matches.value,
|
||||
entityIds: entities.value.map((entity) => entity.id),
|
||||
notify: notify.value,
|
||||
@@ -119,9 +131,9 @@ async function searchTags() {
|
||||
tagResults.value = res;
|
||||
}
|
||||
|
||||
function focusActorInput() {
|
||||
function focusInput(target) {
|
||||
setTimeout(() => {
|
||||
actorInput.value.focus();
|
||||
inputs[target]?.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@@ -158,6 +170,10 @@ function selectStash(selectedStash) {
|
||||
stashes.value.push(selectedStash);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberThen(domain, checked) {
|
||||
localStorage.setItem(`alert_${domain}`, checked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -169,7 +185,33 @@ function selectStash(selectedStash) {
|
||||
class="dialog-body"
|
||||
@submit.prevent="createAlert"
|
||||
>
|
||||
<div class="dialog-section">
|
||||
<div
|
||||
v-if="scenes.length > 0"
|
||||
class="dialog-section"
|
||||
>
|
||||
<div class="section-header">
|
||||
<h3 class="heading">WHEN RELEASED</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="scene in scenes"
|
||||
:key="`alert-scene-${scene.id}`"
|
||||
class="scene"
|
||||
>
|
||||
<span class="scene-title">{{ scene.title }}</span>
|
||||
|
||||
<span class="scene-details">
|
||||
<strong class="scene-date"><time :datetime="scene.date.toISOString()">{{ format(scene.date, 'yyyy-MM-dd') }}</time></strong> on
|
||||
|
||||
<span class="scene-channel">{{ scene.channel?.name || scene.network?.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="dialog-section"
|
||||
>
|
||||
<div class="section-header">
|
||||
<h3 class="heading">IF</h3>
|
||||
</div>
|
||||
@@ -220,7 +262,8 @@ function selectStash(selectedStash) {
|
||||
<li class="field-add">
|
||||
<VDropdown
|
||||
:aria-id="addActorDropdownId"
|
||||
@show="focusActorInput"
|
||||
placement="auto"
|
||||
@show="focusInput('actor')"
|
||||
>
|
||||
<button
|
||||
class="button"
|
||||
@@ -229,7 +272,7 @@ function selectStash(selectedStash) {
|
||||
|
||||
<template #popper>
|
||||
<input
|
||||
ref="actorInput"
|
||||
:ref="(el) => inputs.actor = el"
|
||||
v-model="actorQuery"
|
||||
class="input"
|
||||
@input="searchActors"
|
||||
@@ -296,7 +339,11 @@ function selectStash(selectedStash) {
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown :aria-id="addTagDropdownId">
|
||||
<VDropdown
|
||||
placement="auto"
|
||||
:aria-id="addTagDropdownId"
|
||||
@show="focusInput('tag')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
@@ -304,7 +351,7 @@ function selectStash(selectedStash) {
|
||||
|
||||
<template #popper>
|
||||
<input
|
||||
ref="tagInput"
|
||||
:ref="(el) => inputs.tag = el"
|
||||
v-model="tagQuery"
|
||||
class="input"
|
||||
@input="searchTags"
|
||||
@@ -360,7 +407,11 @@ function selectStash(selectedStash) {
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown :aria-id="addEntityDropdownId">
|
||||
<VDropdown
|
||||
placement="auto"
|
||||
:aria-id="addEntityDropdownId"
|
||||
@show="focusInput('entity')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
@@ -368,7 +419,7 @@ function selectStash(selectedStash) {
|
||||
|
||||
<template #popper>
|
||||
<input
|
||||
ref="entityInput"
|
||||
:ref="(el) => inputs.entity = el"
|
||||
v-model="entityQuery"
|
||||
class="input"
|
||||
@input="searchEntities"
|
||||
@@ -422,7 +473,10 @@ function selectStash(selectedStash) {
|
||||
</li>
|
||||
|
||||
<li class="field-add">
|
||||
<VDropdown :aria-id="addMatchDropdownId">
|
||||
<VDropdown
|
||||
:aria-id="addMatchDropdownId"
|
||||
@show="focusInput('expression')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
@@ -439,6 +493,7 @@ function selectStash(selectedStash) {
|
||||
</select>
|
||||
|
||||
<input
|
||||
:ref="(el) => inputs.expression = el"
|
||||
v-model="matchExpression"
|
||||
placeholder="Expression, // for regex"
|
||||
class="input"
|
||||
@@ -458,22 +513,26 @@ function selectStash(selectedStash) {
|
||||
<span>Notify me in traxxx</span>
|
||||
<Checkbox
|
||||
:checked="notify"
|
||||
@change="(checked) => notify = checked"
|
||||
@change="(checked) => { notify = checked; rememberThen('notify', checked); }"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!--
|
||||
<label class="field email">
|
||||
<label
|
||||
v-if="env.emailEnabled && user.email"
|
||||
class="field email"
|
||||
>
|
||||
<span>E-mail me</span>
|
||||
|
||||
<Checkbox
|
||||
:checked="email"
|
||||
@change="(checked) => email = checked"
|
||||
@change="(checked) => { email = checked; rememberThen('email', checked); }"
|
||||
/>
|
||||
</label>
|
||||
-->
|
||||
|
||||
<div class="stash">
|
||||
<div
|
||||
v-if="scenes.length === 0"
|
||||
class="stash"
|
||||
>
|
||||
<ul class="field-items nolist noselect">
|
||||
<li
|
||||
v-for="stash in stashes"
|
||||
@@ -572,6 +631,20 @@ function selectStash(selectedStash) {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.scene {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.scene-title {
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.scene-date {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
@@ -31,6 +33,7 @@ onMounted(() => emit('open'));
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
v-bind="$attrs"
|
||||
@click.stop
|
||||
>
|
||||
<div class="dialog-header">
|
||||
|
||||
286
components/emails/alert.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
|
||||
defineProps({
|
||||
triggers: {
|
||||
type: Array,
|
||||
default: () => mockTriggers,
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
|
||||
const mockTriggers = [
|
||||
{
|
||||
scene: {
|
||||
id: 684061,
|
||||
title: 'Thick & Bootylicious: Vanessa Blake Takes A BBC Up Her Ass',
|
||||
slug: 'thick-bootylicious-vanessa-blake-takes-a-bbc-up',
|
||||
date: '2026-02-17T00:00:00.000Z',
|
||||
description: 'Vanessa Blake’s curves are impossible to ignore—especially her thick, round, bootylicious ass. The scene opens with the camera following Vanessa through the house as she struts confidently in a slow, teasing booty walk, giving viewers a full view of her luscious curves. She pauses at the stairs, turning back to flaunt her massive ass before letting her hands wander over her body. Feeling the heat build, Vanessa begins to pleasure herself, showcasing her insatiable appetite for big black cock. Moving to the couch, she grabs a dildo and slowly works it into her ass, preparing herself for what’s coming next. Sitting back and watching the show is Lexington Steele, enjoying every second as she stretches and teases in anticipation. Lexington finally steps in, and Vanessa eagerly drops to her knees, wrapping her lips around his massive BBC and giving him a deep, hungry blowjob. The action quickly escalates to the couch, where they fuck in multiple intense positions, Vanessa’s thick body bouncing with every thrust. When she’s fully ready, Vanessa has Lex slide his BBC deep into her ass, and the two go at it hard in a variety of explosive anal positions. The scene builds to a powerful climax as Lexington pulls out and finishes with a messy facial, leaving Vanessa satisfied and dripping after an unforgettable session.',
|
||||
actors: [
|
||||
{
|
||||
id: 919,
|
||||
name: 'Vanessa Blake',
|
||||
},
|
||||
{
|
||||
id: 1374,
|
||||
name: 'Lexington Steele',
|
||||
},
|
||||
],
|
||||
actorIds: [
|
||||
919,
|
||||
1374,
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: 'MILF',
|
||||
slug: 'milf',
|
||||
},
|
||||
{
|
||||
name: 'facial',
|
||||
slug: 'facial',
|
||||
},
|
||||
],
|
||||
tagIds: [
|
||||
4,
|
||||
12,
|
||||
19,
|
||||
30,
|
||||
29,
|
||||
37,
|
||||
41,
|
||||
68,
|
||||
82,
|
||||
100,
|
||||
112,
|
||||
119,
|
||||
null,
|
||||
130,
|
||||
],
|
||||
entityId: 522,
|
||||
parentEntityId: 47,
|
||||
},
|
||||
posterUrl: 'http://localhost:5100/media/posters/lazy/cd/b5/99375c7998a20acffef2217a8a21c3546924312cec65.jpeg',
|
||||
alertId: 123,
|
||||
alertActors: [],
|
||||
alertTags: [],
|
||||
alertEntities: [
|
||||
{
|
||||
id: 522,
|
||||
name: 'Jules Jordan',
|
||||
},
|
||||
],
|
||||
alertMatches: [],
|
||||
alertSceneIds: [],
|
||||
},
|
||||
{
|
||||
scene: {
|
||||
id: 684060,
|
||||
title: 'Elizabeth Skylar’s Big ASSets Drive The Milfomaniac Manuel Ferrara Wild',
|
||||
slug: 'elizabeth-skylar-s-big-assets-drive-the-manuel',
|
||||
date: '2026-02-21T00:00:00.000Z',
|
||||
description: 'Elizabeth Skylar is ready to indulge her cravings, and there’s only one man she wants—the Milfomaniac, Manuel Ferrara. The scene opens with Elizabeth stepping out onto the balcony in sexy lingerie, confidently showing off her busty curves as the camera captures every seductive angle. She teases the viewer with slow, deliberate poses before heading inside to the kitchen, where she perches on a stool with her ass on full display. Manuel joins her, grabbing and admiring her curves, letting the camera in on just how irresistible she is. The action moves to the living room, where Elizabeth stretches out on the couch and begins to pleasure herself, putting on a sultry show for Manuel. Unable to resist, she drops to her knees, her tits spilling free as she takes him deep into her mouth with an eager, hungry intensity. From there, the two tear into each other on the couch, fucking in multiple positions as Elizabeth moans for more. The scene builds to a explosive finish, ending with Manuel delivering a memorable facial that leaves Elizabeth completely satisfied.',
|
||||
actors: [
|
||||
{
|
||||
id: 44001,
|
||||
name: 'Elizabeth Skylar',
|
||||
},
|
||||
{
|
||||
id: 512,
|
||||
name: 'Manuel Ferrara',
|
||||
},
|
||||
],
|
||||
actorIds: [
|
||||
512,
|
||||
44001,
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: 'anal',
|
||||
slug: 'anal',
|
||||
},
|
||||
{
|
||||
name: 'ass to mouth',
|
||||
tag: 'atm',
|
||||
},
|
||||
],
|
||||
tagIds: [
|
||||
4,
|
||||
30,
|
||||
29,
|
||||
31,
|
||||
37,
|
||||
41,
|
||||
68,
|
||||
82,
|
||||
119,
|
||||
125,
|
||||
],
|
||||
entityId: 522,
|
||||
parentEntityId: 47,
|
||||
},
|
||||
posterUrl: 'http://localhost:5100/media/posters/lazy/3c/84/f53e772e4710ca71fb74bdb6896af3d180725d17710b.jpeg',
|
||||
alertId: 123,
|
||||
alertActors: [],
|
||||
alertTags: [],
|
||||
alertEntities: [
|
||||
{
|
||||
id: 522,
|
||||
name: 'Jules Jordan',
|
||||
},
|
||||
],
|
||||
alertMatches: [],
|
||||
alertSceneIds: [],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>{{ triggers.length }} new scene{{ triggers.length === 1 ? '' : 's' }} matching your alerts on traxxx</Preview>
|
||||
|
||||
<Container>
|
||||
<Img
|
||||
:src="`${address}/img/logo.png`"
|
||||
width="150"
|
||||
alt="traxxx"
|
||||
style="margin: 15px auto 25px auto;"
|
||||
/>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||
>Hi, {{ user.username }}</Heading>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">Scenes for which you set e-mail alerts have been added or released:</Text>
|
||||
|
||||
<div
|
||||
v-for="(trigger, index) in triggers"
|
||||
:key="trigger.scene.id"
|
||||
style="margin-bottom: 15px; font-size: 14px;"
|
||||
:style="{
|
||||
'padding-bottom': index < triggers.length - 1 ? '15px' : 0,
|
||||
'border-bottom': index < triggers.length - 1 ? 'solid 1px #ddd' : 'none',
|
||||
}"
|
||||
>
|
||||
<Row style="margin-bottom: 8px;">
|
||||
<Column valign="top">
|
||||
{{ format(trigger.scene.date, 'yyyy-MM-dd') }} |
|
||||
<span v-if="trigger.alertSceneIds.length > 0">Scene is released</span>
|
||||
|
||||
<span v-else>
|
||||
New
|
||||
|
||||
<template
|
||||
v-if="trigger.alertTags.length > 0"
|
||||
>{{ trigger.alertTags.map((tag) => tag.name).join(', ') }}</template>
|
||||
|
||||
scene
|
||||
|
||||
<template
|
||||
v-if="trigger.alertActors.length > 0"
|
||||
>with {{ trigger.alertActors.map((actor) => actor.name).join(', ') }} </template>
|
||||
|
||||
<template
|
||||
v-if="trigger.alertEntities.length > 0"
|
||||
>for {{ trigger.alertEntities[0].name }} </template>
|
||||
|
||||
<template
|
||||
v-if="trigger.alertMatches.length > 0"
|
||||
>matching {{ trigger.alertMatches.map((match) => match.expression).join(', ') }}</template>
|
||||
</span>
|
||||
</Column>
|
||||
|
||||
<Column
|
||||
width="50"
|
||||
valign="top"
|
||||
style="text-align: right;"
|
||||
>
|
||||
<span style="font-size: 14px; color: #aaa;">#{{ trigger.alertId }}</span>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<Row>
|
||||
<Column
|
||||
width="175"
|
||||
valign="top"
|
||||
>
|
||||
<Link :href="`${address}/scene/${trigger.scene.id}/${trigger.scene.slug}`">
|
||||
<Img
|
||||
v-if="trigger.posterUrl"
|
||||
:src="trigger.posterUrl"
|
||||
width="160"
|
||||
:alt="trigger.scene.title"
|
||||
style="margin-top: 2px; border-radius: 8px;"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
style="width: 144px; height: 74px; background: #eee; color: #aaa; text-align: center; border-radius: 8px; padding: 8px;"
|
||||
>No thumb</div>
|
||||
</Link>
|
||||
</Column>
|
||||
|
||||
<Column valign="top">
|
||||
<div style="margin-bottom: 5px;">
|
||||
<Link
|
||||
:href="`${address}/scene/${trigger.scene.id}/${trigger.scene.slug}`"
|
||||
style="font-weight: bold; line-height: 1.5; color: inherit;"
|
||||
>{{ trigger.scene.title }}</Link>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span style="font-size: 14px;">{{ trigger.scene.actors.slice(0, 3).map((actor) => actor.name).join(', ') }}</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span style="font-size: 14px; color: #666;">{{ trigger.scene.tags.slice(0, 5).map((tag) => tag.name).join(', ') }}</span>
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="margin-top: 30px;"
|
||||
>Enjoy!</Heading>
|
||||
|
||||
<span style="font-size: 14px; color: #aaa;">Delivered by the Automated Notification and Alert Link</span>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
106
components/emails/password.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100',
|
||||
},
|
||||
ip: {
|
||||
type: String,
|
||||
default: '127.0.0.1',
|
||||
},
|
||||
country: {
|
||||
type: String,
|
||||
default: 'Unknown',
|
||||
},
|
||||
timestamp: {
|
||||
type: Date,
|
||||
default: () => new Date(),
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Your traxxx password has been changed</Preview>
|
||||
|
||||
<Container>
|
||||
<Img
|
||||
:src="`${address}/img/logo.png`"
|
||||
width="150"
|
||||
alt="traxxx"
|
||||
style="margin: 15px auto 25px auto;"
|
||||
/>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||
>Hi, {{ user.username }}</Heading>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="margin-top: 30px;"
|
||||
>Your traxxx password was changed.</Heading>
|
||||
|
||||
<Text>Device used to change your password:</Text>
|
||||
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="font-size: 14px;"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding: 4px 8px 4px 0;">IP:</td>
|
||||
<td style="padding: 4px 0;">{{ ip }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 8px 4px 0;">Country:</td>
|
||||
<td style="padding: 4px 0;">{{ country }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 8px 4px 0;">Timestamp:</td>
|
||||
<td style="padding: 4px 0;">{{ format(timestamp, 'yyyy-MM-dd hh:mm:ss') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">If this change was made by you, you can ignore this e-mail. If this was not you, please <a
|
||||
href="mailto:account@traxxx.me"
|
||||
style="color: #f65596; text-decoration: none;"
|
||||
>contact us</a> immediately.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
79
components/emails/recovery.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
import config from 'config';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
verifyUrl: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100/recovery?userId=0&token=00000000000000000000000000000000',
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Reset your traxxx password</Preview>
|
||||
|
||||
<Container>
|
||||
<Img
|
||||
:src="`${address}/img/logo.png`"
|
||||
width="150"
|
||||
alt="traxxx"
|
||||
style="margin: 15px auto 25px auto;"
|
||||
/>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||
>Hi, {{ user.username }}</Heading>
|
||||
|
||||
<Text>
|
||||
<strong>You requested to reset the password for your traxxx account.</strong>
|
||||
<br>
|
||||
Please set a new password by clicking the button below:
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
:href="verifyUrl"
|
||||
target="_blank"
|
||||
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
|
||||
><strong>Reset password</strong></Button>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">
|
||||
The link will expire in {{ config.auth.passwordTokenExpiry }} minutes, or when you request another password reset.
|
||||
If you did not request a password reset, you can safely ignore this e-mail.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
80
components/emails/signup.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
import config from 'config';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
verifyUrl: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100/verify?userId=0&token=00000000000000000000000000000000',
|
||||
},
|
||||
trigger: {
|
||||
type: String,
|
||||
default: 'change',
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Sign up for traxxx</Preview>
|
||||
|
||||
<Container>
|
||||
<Img
|
||||
:src="`${address}/img/logo.png`"
|
||||
width="150"
|
||||
alt="traxxx"
|
||||
style="margin: 15px auto 25px auto;"
|
||||
/>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||
>Hi,</Heading>
|
||||
|
||||
<Text style="font-size: 16px;">
|
||||
<strong>Thank you for signing up for traxxx!</strong>
|
||||
<br>
|
||||
Please complete your registration by clicking the button below:
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
:href="verifyUrl"
|
||||
target="_blank"
|
||||
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
|
||||
><strong>Create my account</strong></Button>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">The link will expire in {{ config.auth.signupTokenExpiry }} minutes. If you did not sign up, you can safely ignore this e-mail.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
62
components/emails/unavailable.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Someone tried to use this e-mail address for traxxx</Preview>
|
||||
|
||||
<Container>
|
||||
<Img
|
||||
:src="`${address}/img/logo.png`"
|
||||
width="150"
|
||||
alt="traxxx"
|
||||
style="margin: 15px auto 25px auto;"
|
||||
/>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||
>Hi, {{ user.username }}</Heading>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="margin-top: 30px;"
|
||||
>Someone tried to assign this e-mail address to a traxxx account.</Heading>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">If this was you with this account ({{ user.username }}), your e-mail address is already set correctly. If this was you with another account, you will need to use another e-mail address. If this was not you, you can safely ignore this e-mail.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
81
components/emails/verify.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
import config from 'config';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => mockUser,
|
||||
},
|
||||
verifyUrl: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100/verify?userId=0&token=00000000000000000000000000000000',
|
||||
},
|
||||
trigger: {
|
||||
type: String,
|
||||
default: 'change',
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
email: 'debug@alerts.traxxx.me',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>Please verify your traxxx e-mail address</Preview>
|
||||
|
||||
<Container>
|
||||
<Img
|
||||
:src="`${address}/img/logo.png`"
|
||||
width="150"
|
||||
alt="traxxx"
|
||||
style="margin: 15px auto 25px auto;"
|
||||
/>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||
>Hi, {{ user.username }}</Heading>
|
||||
|
||||
<Text>
|
||||
<strong>You requested to {{ trigger === 'verify' ? 'verify' : 'change' }} the e-mail address for your traxxx account.</strong>
|
||||
<br>
|
||||
Please confirm your e-mail address by clicking the button below:
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
:href="verifyUrl"
|
||||
target="_blank"
|
||||
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
|
||||
><strong>Verify e-mail address</strong></Button>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">
|
||||
The link will expire in {{ config.auth.emailTokenExpiry }} minutes, or when you request an(other) e-mail change or verification. If you did not request this {{ trigger === 'verify' ? 'verification' : 'change' }}, you can safely ignore this e-mail.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { useId } from 'vue';
|
||||
|
||||
defineProps({
|
||||
checked: {
|
||||
type: Boolean,
|
||||
@@ -16,7 +18,7 @@ defineProps({
|
||||
|
||||
defineEmits(['change']);
|
||||
|
||||
const uid = String(Math.random()).slice(2);
|
||||
const uid = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
92
components/form/password.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
password: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Password',
|
||||
},
|
||||
autocomplete: {
|
||||
type: String,
|
||||
default: 'current-password',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change', 'input']);
|
||||
|
||||
const currentPassword = ref(props.password);
|
||||
const showPassword = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="password-container">
|
||||
<input
|
||||
v-model="currentPassword"
|
||||
:type="showPassword ? 'input' : 'password'"
|
||||
:placeholder="placeholder"
|
||||
:autocomplete="autocomplete"
|
||||
minlength="3"
|
||||
class="password input"
|
||||
@change="emit('change', currentPassword)"
|
||||
@input="emit('input', currentPassword)"
|
||||
>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
background: var(--background);
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.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(--glass);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -265,7 +265,7 @@ function blurSearch(event) {
|
||||
|
||||
<li
|
||||
v-if="user"
|
||||
class="menu-item"
|
||||
class="menu-item menu-div"
|
||||
>
|
||||
<a
|
||||
:href="`/user/${user.username}/alerts`"
|
||||
@@ -280,10 +280,29 @@ function blurSearch(event) {
|
||||
v-close-popper
|
||||
class="menu-item menu-button"
|
||||
@click="showSettings = true"
|
||||
@touchstart="showSettings = true"
|
||||
>
|
||||
<Icon icon="equalizer" />
|
||||
Settings
|
||||
<Icon icon="filter" />
|
||||
Filters
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="theme === 'dark'"
|
||||
v-close-popper
|
||||
class="menu-item menu-button menu-div"
|
||||
@click="setTheme('light')"
|
||||
>
|
||||
<Icon icon="sun3" />
|
||||
Light theme
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-else
|
||||
v-close-popper
|
||||
class="menu-item menu-button menu-div"
|
||||
@click="setTheme('dark')"
|
||||
>
|
||||
<Icon icon="moon" />
|
||||
Dark theme
|
||||
</li>
|
||||
|
||||
<li
|
||||
@@ -300,28 +319,6 @@ function blurSearch(event) {
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="theme === 'dark'"
|
||||
v-close-popper
|
||||
class="menu-item menu-button"
|
||||
@click="setTheme('light')"
|
||||
@touchstart="setTheme('light')"
|
||||
>
|
||||
<Icon icon="sun3" />
|
||||
Light theme
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-else
|
||||
v-close-popper
|
||||
class="menu-item menu-button"
|
||||
@click="setTheme('dark')"
|
||||
@touchstart="setTheme('dark')"
|
||||
>
|
||||
<Icon icon="moon" />
|
||||
Dark theme
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="user"
|
||||
class="menu-button menu-item logout"
|
||||
@@ -571,6 +568,7 @@ function blurSearch(event) {
|
||||
align-items: center;
|
||||
padding: .75rem .5rem .75rem .75rem;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--glass);
|
||||
@@ -580,7 +578,6 @@ function blurSearch(event) {
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
|
||||
&:not(.logout) .icon {
|
||||
fill: var(--primary);
|
||||
@@ -592,6 +589,10 @@ function blurSearch(event) {
|
||||
}
|
||||
}
|
||||
|
||||
.menu-div {
|
||||
border-bottom: solid 1px var(--glass-weak-30);
|
||||
}
|
||||
|
||||
/*
|
||||
.favorites .icon {
|
||||
fill: var(--primary);
|
||||
|
||||
@@ -279,7 +279,7 @@ function updateFilter(prop, value, reload = true) {
|
||||
.movies {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 1rem;
|
||||
gap: .5rem;
|
||||
padding: .5rem 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -370,6 +370,7 @@ function setView(newView) {
|
||||
<Campaign
|
||||
:campaign="sceneCampaign"
|
||||
:backdrop="true"
|
||||
class="campaign-scene"
|
||||
/>
|
||||
</li>
|
||||
|
||||
@@ -447,6 +448,7 @@ function setView(newView) {
|
||||
.scenes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
grid-auto-rows: min-content;
|
||||
gap: .5rem;
|
||||
padding: .5rem 1rem 1rem 1rem;
|
||||
}
|
||||
@@ -456,6 +458,11 @@ function setView(newView) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.campaign-scene {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 1; /* ensures banner can't be taller than scene tile */
|
||||
}
|
||||
|
||||
.scopes {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -253,6 +253,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
resize: none;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
76
components/settings/filters.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import Cookies from 'js-cookie';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
const cookies = Cookies.withConverter({
|
||||
write: (value) => value,
|
||||
});
|
||||
|
||||
const tags = {
|
||||
'anal': 'anal',
|
||||
'anal-prolapse': 'anal prolapse',
|
||||
'extreme-insertion': 'extreme insertion (oversized dildos)',
|
||||
'pissing': 'pissing',
|
||||
'gay': 'gay',
|
||||
'transsexual': 'transsexual',
|
||||
'bisexual': 'bisexual',
|
||||
'compilation': 'compilation',
|
||||
'bts': 'behind the scenes',
|
||||
'vr': 'virtual reality',
|
||||
};
|
||||
|
||||
const storedTags = cookies.get('tags');
|
||||
const checkedTags = ref(new Set(storedTags ? JSON.parse(storedTags) : []));
|
||||
|
||||
function toggleTag(tag, isChecked) {
|
||||
if (isChecked) {
|
||||
checkedTags.value.add(tag);
|
||||
}
|
||||
else {
|
||||
checkedTags.value.delete(tag);
|
||||
}
|
||||
|
||||
cookies.set('tags', JSON.stringify(Array.from(checkedTags.value)), { expires: 400 }); // 100 years from now
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dialog-section">
|
||||
<p class="dialog-description">Check the tags that you prefer to be <strong>excluded</strong> from the results. The filter is a courtesy, and provides no guarantees.</p>
|
||||
|
||||
<ul class="tags nolist">
|
||||
<li
|
||||
v-for="(label, slug) in tags"
|
||||
:key="`tag-${slug}`"
|
||||
class="tags-item"
|
||||
>
|
||||
<label class="tag noselect">
|
||||
<Checkbox
|
||||
:value="slug"
|
||||
:checked="checkedTags.has(slug)"
|
||||
class="minus"
|
||||
@change="(checked) => toggleTag(slug, checked)"
|
||||
/>{{ label }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tags-item {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
padding: .5rem 0;
|
||||
text-transform: capitalize;
|
||||
|
||||
.check-container {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
components/settings/notifications.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
// import Checkbox from '#/components/form/checkbox.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dialog-section">
|
||||
<div class="setting">
|
||||
Combine e-mail alerts
|
||||
<select
|
||||
class="input"
|
||||
>
|
||||
<option>Never</option>
|
||||
<option>Daily</option>
|
||||
<option>Weekly</option>
|
||||
<option>Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,67 +1,66 @@
|
||||
<script setup>
|
||||
import Cookies from 'js-cookie';
|
||||
import { ref } from 'vue';
|
||||
// this component is set up as a generic settings panel, but as most account settings will be handled through the profile page, it is parading as a dedicated filters dialog
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import Dialog from '#/components/dialog/dialog.vue';
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
import Filters from '#/components/settings/filters.vue';
|
||||
import Notifications from '#/components/settings/notifications.vue';
|
||||
|
||||
const cookies = Cookies.withConverter({
|
||||
write: (value) => value,
|
||||
defineProps({
|
||||
showNavigation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const tags = {
|
||||
'anal': 'anal',
|
||||
'anal-prolapse': 'anal prolapse',
|
||||
'extreme-insertion': 'extreme insertion (oversized dildos)',
|
||||
'pissing': 'pissing',
|
||||
'gay': 'gay',
|
||||
'transsexual': 'transsexual',
|
||||
'bisexual': 'bisexual',
|
||||
'compilation': 'compilation',
|
||||
'bts': 'behind the scenes',
|
||||
'vr': 'virtual reality',
|
||||
};
|
||||
const pageContext = inject('pageContext');
|
||||
const { user } = pageContext;
|
||||
|
||||
const storedTags = cookies.get('tags');
|
||||
const checkedTags = ref(new Set(storedTags ? JSON.parse(storedTags) : []));
|
||||
|
||||
function toggleTag(tag, isChecked) {
|
||||
if (isChecked) {
|
||||
checkedTags.value.add(tag);
|
||||
}
|
||||
else {
|
||||
checkedTags.value.delete(tag);
|
||||
}
|
||||
|
||||
cookies.set('tags', JSON.stringify(Array.from(checkedTags.value)), { expires: 400 }); // 100 years from now
|
||||
}
|
||||
const activeSection = ref('filters');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog title="Settings">
|
||||
<Dialog
|
||||
title="Filters"
|
||||
class="settings-dialog"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<div class="dialog-section">
|
||||
<h3 class="dialog-heading">Filter</h3>
|
||||
<ul
|
||||
v-if="!activeSection"
|
||||
class="sections"
|
||||
>
|
||||
<li
|
||||
class="section"
|
||||
@click="activeSection = 'filters'"
|
||||
>
|
||||
<Icon icon="filter" />
|
||||
Filters
|
||||
</li>
|
||||
|
||||
<p class="dialog-description">Check the tags that you prefer to be <strong>excluded</strong> from the results. The filter is a courtesy, and provides no guarantees.</p>
|
||||
<li
|
||||
v-if="user"
|
||||
class="section"
|
||||
@click="activeSection = 'notifications'"
|
||||
>
|
||||
<Icon icon="bell2" />
|
||||
Notifications
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="tags nolist">
|
||||
<li
|
||||
v-for="(label, slug) in tags"
|
||||
:key="`tag-${slug}`"
|
||||
class="tags-item"
|
||||
>
|
||||
<label class="tag noselect">
|
||||
<Checkbox
|
||||
:value="slug"
|
||||
:checked="checkedTags.has(slug)"
|
||||
class="minus"
|
||||
@change="(checked) => toggleTag(slug, checked)"
|
||||
/>{{ label }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
v-else-if="showNavigation"
|
||||
class="section-header"
|
||||
>
|
||||
<span
|
||||
class="section-back"
|
||||
@click="activeSection = null"
|
||||
><Icon icon="arrow-left3" />Back to overview</span>
|
||||
|
||||
<strong class="section-label">{{ activeSection }}</strong>
|
||||
</div>
|
||||
|
||||
<Filters v-if="activeSection === 'filters'" />
|
||||
<Notifications v-if="activeSection === 'notifications'" />
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -69,23 +68,74 @@ function toggleTag(tag, isChecked) {
|
||||
<style scoped>
|
||||
.dialog-body {
|
||||
width: 30rem;
|
||||
flex-grow: 1;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tags-item {
|
||||
display: block;
|
||||
.dialog-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
padding: .5rem 0;
|
||||
text-transform: capitalize;
|
||||
.sections {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.check-container {
|
||||
margin-right: 1rem;
|
||||
.section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: .25rem;
|
||||
font-size: 1.1rem;
|
||||
|
||||
.icon {
|
||||
fill: var(--glass-strong-10);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||
}
|
||||
|
||||
.section-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: .75rem 1rem;
|
||||
|
||||
.icon {
|
||||
fill: var(--glass);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-label {
|
||||
color: var(--primary);
|
||||
text-transform: capitalize;
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -125,6 +125,9 @@ async function alertItem() {
|
||||
...(props.domain === 'scenes' && { scenes: [props.item.id] }),
|
||||
notify: true,
|
||||
email: false,
|
||||
// probably too confusing and annoying to have quick alerts inherit email preference from dialog
|
||||
// notify: JSON.parse(localStorage.getItem('alert_notify')) ?? true,
|
||||
// email: JSON.parse(localStorage.getItem('alert_email')) ?? false,
|
||||
preset: true,
|
||||
}, {
|
||||
successFeedback: `Alert set for '${alertLabel}'`,
|
||||
@@ -173,7 +176,7 @@ async function removeAlert() {
|
||||
|
||||
function setAlerted(alert) {
|
||||
// only set alerted status if the current item is the only one in the new stash
|
||||
const items = [...alert.actors, ...alert.tags, ...alert.entities, ...alert.matches];
|
||||
const items = [...alert.actors, ...alert.tags, ...alert.entities, ...alert.matches, ...alert.scenes];
|
||||
|
||||
if (items.length === 1 && alert[props.domain][0]?.id === props.item.id) {
|
||||
itemAlerted.value = true;
|
||||
@@ -219,7 +222,6 @@ async function reloadStashes(newStash) {
|
||||
icon="bell2"
|
||||
class="alert active noselect"
|
||||
@click="removeAlert"
|
||||
@contextmenu.prevent="showAlertDialog = true"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
@@ -228,7 +230,6 @@ async function reloadStashes(newStash) {
|
||||
icon="bell-plus"
|
||||
class="alert partial noselect"
|
||||
@click="alertItem"
|
||||
@contextmenu.prevent="showAlertDialog = true"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
@@ -245,6 +246,8 @@ async function reloadStashes(newStash) {
|
||||
v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))"
|
||||
:aria-id="stashMenuDropdownId"
|
||||
:shown="showStashes"
|
||||
placement="auto"
|
||||
popper-class="no-outer-scroll"
|
||||
@hide="showStashes = false"
|
||||
>
|
||||
<Icon
|
||||
@@ -258,6 +261,7 @@ async function reloadStashes(newStash) {
|
||||
<StashMenu
|
||||
:stashes="stashes"
|
||||
:item-stashes="itemStashes"
|
||||
:domain="domain"
|
||||
@stash="(stash) => stashItem(stash)"
|
||||
@unstash="(stash) => unstashItem(stash)"
|
||||
@create="showStashDialog = true"
|
||||
@@ -271,6 +275,8 @@ async function reloadStashes(newStash) {
|
||||
:triggers="[]"
|
||||
:shown="showStashes"
|
||||
:disabled="showSecondary"
|
||||
placement="auto"
|
||||
popper-class="no-outer-scroll"
|
||||
@hide="showStashes = false"
|
||||
>
|
||||
<template v-if="pageStash?.user.id === user.id">
|
||||
@@ -313,6 +319,7 @@ async function reloadStashes(newStash) {
|
||||
<StashMenu
|
||||
:stashes="stashes"
|
||||
:item-stashes="itemStashes"
|
||||
:domain="domain"
|
||||
@stash="(stash) => stashItem(stash)"
|
||||
@unstash="(stash) => unstashItem(stash)"
|
||||
@create="showStashDialog = true"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
stashes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@@ -10,46 +12,141 @@ defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
domain: {
|
||||
type: String,
|
||||
default: 'scenes',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['stash', 'unstash', 'create']);
|
||||
|
||||
const sortings = ['used', 'name', 'size', 'created'];
|
||||
const sorting = ref(localStorage.getItem('stashMenuSorting') || 'used');
|
||||
|
||||
const domainKey = {
|
||||
actors: 'stashedActors',
|
||||
scenes: 'stashedScenes',
|
||||
movies: 'stashedMovies',
|
||||
}[props.domain];
|
||||
|
||||
const sortedStashes = computed(() => props.stashes
|
||||
.filter((stash) => !stash.isArchived)
|
||||
.toSorted((stashA, stashB) => {
|
||||
return stashA.createdAt - stashB.createdAt;
|
||||
})
|
||||
.toSorted((stashA, stashB) => {
|
||||
if (sorting.value === 'used') {
|
||||
return stashB.lastStashAt - stashA.lastStashAt;
|
||||
}
|
||||
|
||||
if (sorting.value === 'name') {
|
||||
return stashA.name.localeCompare(stashB.name);
|
||||
}
|
||||
|
||||
if (sorting.value === 'size') {
|
||||
return stashB[domainKey] - stashA[domainKey];
|
||||
}
|
||||
|
||||
if (sorting.value === 'created') {
|
||||
return stashA.createdAt - stashB.createdAt;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}));
|
||||
/*
|
||||
.toSorted((stashA, stashB) => {
|
||||
return stashB.isPrimary - stashA.isPrimary;
|
||||
}));
|
||||
*/
|
||||
|
||||
function toggleSorting() {
|
||||
sorting.value = sortings[(sortings.indexOf(sorting.value) + 1) % sortings.length];
|
||||
localStorage.setItem('stashMenuSorting', sorting.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="stash-menu nolist noselect">
|
||||
<li
|
||||
v-for="userStash in stashes"
|
||||
:key="`stash-${userStash.id}`"
|
||||
class="menu-item"
|
||||
>
|
||||
<label class="menu-stash">
|
||||
<Checkbox
|
||||
:checked="itemStashes.some((itemStash) => itemStash.id === userStash.id)"
|
||||
@change="(checked) => checked ? emit('stash', userStash) : emit('unstash', userStash)"
|
||||
/>{{ userStash.name }}
|
||||
</label>
|
||||
</li>
|
||||
<div class="stash-menu noselect">
|
||||
<ul class="stash-list nolist noselect">
|
||||
<li
|
||||
v-for="userStash in sortedStashes"
|
||||
:key="`stash-${userStash.id}`"
|
||||
class="menu-item"
|
||||
>
|
||||
<label class="menu-stash">
|
||||
<Checkbox
|
||||
:checked="itemStashes.some((itemStash) => itemStash.id === userStash.id)"
|
||||
@change="(checked) => checked ? emit('stash', userStash) : emit('unstash', userStash)"
|
||||
/>{{ userStash.name }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<li class="menu-item create">
|
||||
<label
|
||||
<div class="menu-actions">
|
||||
<div
|
||||
v-close-popper
|
||||
class="menu-stash"
|
||||
class="menu-item menu-stash menu-create"
|
||||
@click="emit('create')"
|
||||
>
|
||||
<Icon icon="plus3" />
|
||||
New
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'used'"
|
||||
v-tooltip="'By last stash'"
|
||||
icon="sort-heart"
|
||||
class="menu-sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'name'"
|
||||
v-tooltip="'By name'"
|
||||
icon="sort-alpha-asc"
|
||||
class="menu-sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'size'"
|
||||
v-tooltip="`By total stashed ${domain}`"
|
||||
icon="sort-amount-desc"
|
||||
class="menu-sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'created'"
|
||||
v-tooltip="'By stash age'"
|
||||
icon="sort-time-asc"
|
||||
class="menu-sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stash-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stash-list {
|
||||
overflow-y: auto;
|
||||
min-height: 0; /* lets this flex child actually shrink and scroll instead of pushing the container taller */
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
|
||||
.icon {
|
||||
fill: var(--shadow-weak-10);
|
||||
padding: 0 .55rem 0 .2rem;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +155,7 @@ const emit = defineEmits(['stash', 'unstash', 'create']);
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: .5rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
@@ -72,4 +170,21 @@ const emit = defineEmits(['stash', 'unstash', 'create']);
|
||||
margin-right: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
border-top: solid 1px var(--glass-weak-40);
|
||||
}
|
||||
|
||||
.menu-sort {
|
||||
padding: .75rem 1rem;
|
||||
fill: var(--glass);
|
||||
border-left: solid 1px var(--glass-weak-40);
|
||||
|
||||
&:hover {
|
||||
fill: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { inject, ref } from 'vue';
|
||||
import { computed, inject, ref } from 'vue';
|
||||
|
||||
import StashDialog from '#/components/stashes/create.vue';
|
||||
import StashTile from '#/components/stashes/tile.vue';
|
||||
@@ -13,18 +13,55 @@ const stashes = ref(pageContext.pageProps.stashes);
|
||||
const profile = ref(pageContext.pageProps.profile);
|
||||
|
||||
const showStashDialog = ref(false);
|
||||
const showArchived = ref(false);
|
||||
const sortings = ['used', 'name', 'size', 'created'];
|
||||
const sorting = ref('size');
|
||||
|
||||
async function reloadStashes() {
|
||||
// profile.value = await get(`/users/${profile.value.id}`);
|
||||
stashes.value = await get(`/users/${profile.value.id}/stashes`);
|
||||
}
|
||||
|
||||
const sortedStashes = computed(() => stashes.value
|
||||
.toSorted((stashA, stashB) => {
|
||||
return stashA.createdAt - stashB.createdAt;
|
||||
})
|
||||
.toSorted((stashA, stashB) => {
|
||||
if (sorting.value === 'used') {
|
||||
return stashB.lastStashAt - stashA.lastStashAt;
|
||||
}
|
||||
|
||||
if (sorting.value === 'name') {
|
||||
return stashA.name.localeCompare(stashB.name);
|
||||
}
|
||||
|
||||
if (sorting.value === 'size') {
|
||||
return (stashB.stashedScenes + stashB.stashedMovies + stashB.stashedActors)
|
||||
- (stashA.stashedScenes + stashA.stashedMovies + stashA.stashedActors);
|
||||
}
|
||||
|
||||
if (sorting.value === 'created') {
|
||||
return stashA.createdAt - stashB.createdAt;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.toSorted((stashA, stashB) => {
|
||||
// always put favorites first
|
||||
return stashB.isPrimary - stashA.isPrimary;
|
||||
}));
|
||||
|
||||
const activeStashes = computed(() => sortedStashes.value.filter((stash) => !stash.isArchived));
|
||||
const archivedStashes = computed(() => sortedStashes.value.filter((stash) => stash.isArchived));
|
||||
|
||||
function toggleSorting() {
|
||||
sorting.value = sortings[(sortings.indexOf(sorting.value) + 1) % sortings.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="profile-section">
|
||||
<div class="section-header">
|
||||
<h3 class="heading">Stashes</h3>
|
||||
|
||||
<div class="section-header noselect">
|
||||
<button
|
||||
v-if="profile.id === user?.id"
|
||||
class="button"
|
||||
@@ -33,6 +70,38 @@ async function reloadStashes() {
|
||||
<Icon icon="folder-plus2" />
|
||||
<span class="button-label">New stash</span>
|
||||
</button>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'used'"
|
||||
v-tooltip="'By last stash'"
|
||||
icon="sort-heart"
|
||||
class="sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'name'"
|
||||
v-tooltip="'By name'"
|
||||
icon="sort-alpha-asc"
|
||||
class="sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'size'"
|
||||
v-tooltip="'By total stashed'"
|
||||
icon="sort-amount-desc"
|
||||
class="sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-if="sorting === 'created'"
|
||||
v-tooltip="'By stash age'"
|
||||
icon="sort-time-asc"
|
||||
class="sort"
|
||||
@click="toggleSorting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StashDialog
|
||||
@@ -43,7 +112,29 @@ async function reloadStashes() {
|
||||
|
||||
<ul class="stashes nolist">
|
||||
<li
|
||||
v-for="stash in stashes"
|
||||
v-for="stash in activeStashes"
|
||||
:key="`stash-${stash.id}`"
|
||||
>
|
||||
<StashTile
|
||||
:stash="stash"
|
||||
:profile="profile"
|
||||
@reload="reloadStashes"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3
|
||||
v-if="archivedStashes.length > 0"
|
||||
class="archived-heading"
|
||||
@click="showArchived = !showArchived"
|
||||
><Icon icon="drawer3" />{{ showArchived ? 'Hide' : `Show ${archivedStashes.length}` }} archived {{ archivedStashes.length > 1 ? 'stashes' : 'stash' }}</h3>
|
||||
|
||||
<ul
|
||||
v-if="showArchived"
|
||||
class="stashes nolist"
|
||||
>
|
||||
<li
|
||||
v-for="stash in archivedStashes"
|
||||
:key="`stash-${stash.id}`"
|
||||
>
|
||||
<StashTile
|
||||
@@ -57,6 +148,13 @@ async function reloadStashes() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-section .section-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: .5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stashes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
@@ -64,6 +162,30 @@ async function reloadStashes() {
|
||||
padding: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.sort {
|
||||
padding: .5rem 1rem;
|
||||
fill: var(--glass);
|
||||
|
||||
&:hover {
|
||||
fill: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.archived-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
color: var(--glass);
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--glass);
|
||||
margin-right: .75rem;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@media(--compact) {
|
||||
.stashes {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
|
||||
@@ -91,6 +91,30 @@ async function rename() {
|
||||
|
||||
showRenameDialog.value = false;
|
||||
}
|
||||
|
||||
async function archive(isArchived = true) {
|
||||
if (done.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
done.value = false;
|
||||
|
||||
await patch(`/stashes/${props.stash.id}`, { isArchived }, {
|
||||
...isArchived
|
||||
? {
|
||||
undoFeedback: `Stash archived`,
|
||||
}
|
||||
: {
|
||||
successFeedback: `Stash revived`,
|
||||
},
|
||||
errorFeedback: `Failed to ${isArchived ? 'archive' : 'revive'} stash`,
|
||||
appendErrorMessage: true,
|
||||
}).finally(() => { done.value = true; });
|
||||
|
||||
emit('reload');
|
||||
|
||||
showRenameDialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -159,6 +183,28 @@ async function rename() {
|
||||
/>Rename
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="stash.isArchived"
|
||||
class="menu-item"
|
||||
@click="archive(false)"
|
||||
>
|
||||
<Icon
|
||||
icon="drawer-out"
|
||||
class="noselect"
|
||||
/>Revive
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-else
|
||||
class="menu-item"
|
||||
@click="archive()"
|
||||
>
|
||||
<Icon
|
||||
icon="drawer-in"
|
||||
class="noselect"
|
||||
/>Archive
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="!stash.isPrimary"
|
||||
class="menu-item remove"
|
||||
@@ -306,6 +352,7 @@ async function rename() {
|
||||
display: flex;
|
||||
margin-right: .75rem;
|
||||
fill: var(--glass);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.remove {
|
||||
|
||||
300
components/user/account.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<script setup>
|
||||
import { inject, reactive, ref } from 'vue';
|
||||
|
||||
import Password from '#/components/form/password.vue';
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
// import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
import { patch, post } from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { user } = pageContext;
|
||||
|
||||
const allowEmailUpdate = pageContext.env.emailEnabled;
|
||||
|
||||
const credentials = reactive({
|
||||
email: null,
|
||||
password: null,
|
||||
});
|
||||
|
||||
const currentPassword = ref(null);
|
||||
const saving = ref(false);
|
||||
const submitted = ref(false);
|
||||
const verifying = ref(false);
|
||||
const verified = ref(false);
|
||||
const feedback = ref(null);
|
||||
|
||||
function getFeedback() {
|
||||
if (credentials.email && Object.entries(credentials).some(([key, value]) => key !== 'email' && value !== null)) {
|
||||
return 'Account updated, please check your inbox to change e-mail.';
|
||||
}
|
||||
|
||||
if (credentials.email) {
|
||||
return 'Please check your inbox to change e-mail.';
|
||||
}
|
||||
|
||||
return 'Account updated.';
|
||||
}
|
||||
|
||||
async function updateAccount() {
|
||||
saving.value = true;
|
||||
feedback.value = null;
|
||||
|
||||
try {
|
||||
const feedback = getFeedback();
|
||||
|
||||
await patch('/me', {
|
||||
credentials,
|
||||
password: currentPassword.value,
|
||||
}, {
|
||||
successFeedback: feedback,
|
||||
errorFeedback: 'Failed to update account',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
currentPassword.value = null;
|
||||
credentials.email = null;
|
||||
credentials.password = null;
|
||||
|
||||
submitted.value = true;
|
||||
feedback.value = feedback;
|
||||
}
|
||||
finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyEmail() {
|
||||
verifying.value = true;
|
||||
|
||||
try {
|
||||
await post('/me/email_verification', null, {
|
||||
successFeedback: 'E-mail verification sent, please check your inbox',
|
||||
errorFeedback: 'Failed to request e-mail verification',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
verified.value = true;
|
||||
}
|
||||
finally {
|
||||
verifying.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="profile-section"
|
||||
@submit.prevent="updateAccount"
|
||||
>
|
||||
<div class="setting">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">E-mail address</strong>
|
||||
<span class="setting-description">Primarily used for alert notifications and password recovery</span>
|
||||
</span>
|
||||
|
||||
<span class="setting-value">
|
||||
<span class="setting-group">
|
||||
<VDropdown v-if="!user.isEmailVerified">
|
||||
<Icon
|
||||
icon="warning2"
|
||||
class="unverified"
|
||||
/>
|
||||
|
||||
<template #popper>
|
||||
<div class="unverified-tooltip">
|
||||
Your e-mail address is unverified.
|
||||
|
||||
<Ellipsis v-if="verifying" />
|
||||
|
||||
<strong
|
||||
v-else-if="verified"
|
||||
class="success"
|
||||
>Verification e-mail sent</strong>
|
||||
|
||||
<button
|
||||
v-else-if="allowEmailUpdate"
|
||||
type="button"
|
||||
class="button button-primary"
|
||||
@click="verifyEmail"
|
||||
>Verify now</button>
|
||||
|
||||
<span v-else>Verification is currently unavailable.</span>
|
||||
</div>
|
||||
</template>
|
||||
</VDropdown>
|
||||
|
||||
<input
|
||||
:value="user.email"
|
||||
type="email"
|
||||
class="input"
|
||||
disabled
|
||||
>
|
||||
</span>
|
||||
|
||||
<input
|
||||
v-if="allowEmailUpdate"
|
||||
v-model="credentials.email"
|
||||
type="email"
|
||||
placeholder="New e-mail"
|
||||
class="input"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">New password</strong>
|
||||
<span class="setting-description">Change the password you use for signing in</span>
|
||||
</span>
|
||||
|
||||
<span class="setting-value">
|
||||
<Password
|
||||
:password="credentials.password"
|
||||
placeholder="New password"
|
||||
autocomplete="new-password"
|
||||
@input="(password) => credentials.password = password"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">Current password</strong>
|
||||
<span class="setting-description">Enter your current password to save your changes</span>
|
||||
</span>
|
||||
|
||||
<span class="setting-value">
|
||||
<Password
|
||||
:password="currentPassword"
|
||||
placeholder="Current password"
|
||||
@input="(password) => currentPassword = password"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions">
|
||||
<Ellipsis v-if="saving" />
|
||||
|
||||
<strong
|
||||
v-else-if="submitted"
|
||||
class="success"
|
||||
>{{ feedback }}</strong>
|
||||
|
||||
<button
|
||||
v-else
|
||||
v-tooltip="(credentials.email || credentials.password) && currentPassword ? null : 'Please supply your new credentials and current password'"
|
||||
class="button button-primary"
|
||||
:disabled="!currentPassword"
|
||||
>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
flex-shrink: 0;
|
||||
flex-basis: auto;
|
||||
background: var(--background);
|
||||
|
||||
&:disabled {
|
||||
color: var(--glass);
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-section .heading {
|
||||
margin: .5rem 1rem 0 1rem;
|
||||
}
|
||||
|
||||
.setting {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: .75rem 1rem;
|
||||
border-bottom: solid 1px var(--glass-weak-30);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--glass-weak-20);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setting-title {
|
||||
color: var(--glass-strong-20);
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
|
||||
.setting-value {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
color: var(--glass);
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
|
||||
.button {
|
||||
flex-basis: 0;
|
||||
font-size: 1.1rem;
|
||||
padding: .5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success);
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.unverified {
|
||||
fill: var(--error);
|
||||
padding: .5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unverified-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.load-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media(--small-20) {
|
||||
.setting {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -55,16 +55,7 @@ function copyKey(event) {
|
||||
<template>
|
||||
<section class="profile-section">
|
||||
<div class="section-header">
|
||||
<h3 class="heading">API Keys</h3>
|
||||
|
||||
<div class="keys-actions">
|
||||
<Icon
|
||||
v-tooltip="{ content: 'Flush all keys', ariaId: flushTooltipId }"
|
||||
icon="stack-cancel"
|
||||
class="keys-flush"
|
||||
@click="flushKeys"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="button"
|
||||
@click="createKey"
|
||||
@@ -72,6 +63,13 @@ function copyKey(event) {
|
||||
<Icon icon="key" />
|
||||
<span class="button-label">New key</span>
|
||||
</button>
|
||||
|
||||
<Icon
|
||||
v-tooltip="{ content: 'Flush all keys', ariaId: flushTooltipId }"
|
||||
icon="stack-cancel"
|
||||
class="keys-flush"
|
||||
@click="flushKeys"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,6 +152,11 @@ function copyKey(event) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-section .section-header {
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.keys-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -164,6 +167,7 @@ function copyKey(event) {
|
||||
.keys-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
> .icon {
|
||||
padding: .5rem 1rem;
|
||||
|
||||
126
components/user/preferences.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup>
|
||||
import { inject, reactive } from 'vue';
|
||||
|
||||
import Checkbox from '#/components/form/checkbox.vue';
|
||||
|
||||
import { patch } from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { user } = pageContext;
|
||||
|
||||
const settings = reactive({
|
||||
alertNotificationsEnabled: true,
|
||||
alertEmailsEnabled: true,
|
||||
alertEmailsFrequency: null,
|
||||
...user.settings,
|
||||
});
|
||||
|
||||
async function updateSettings() {
|
||||
await patch('/me/settings', settings);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="profile-section"
|
||||
@submit.prevent
|
||||
>
|
||||
<div class="setting noflip">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">Enable alert notifications</strong>
|
||||
<span class="setting-description">Your alerts can trigger notifications in traxxx</span>
|
||||
</span>
|
||||
|
||||
<span class="setting-value">
|
||||
<Checkbox
|
||||
:checked="settings.alertNotificationsEnabled"
|
||||
@change="(checked) => { settings.alertNotificationsEnabled = checked; updateSettings(); }"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="setting noflip">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">Enable alert e-mails</strong>
|
||||
<span class="setting-description">Your alerts can trigger e-mails</span>
|
||||
</span>
|
||||
|
||||
<span class="setting-value">
|
||||
<Checkbox
|
||||
:checked="settings.alertEmailsEnabled"
|
||||
@change="(checked) => { settings.alertEmailsEnabled = checked; updateSettings(); }"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<span class="setting-label">
|
||||
<strong class="setting-title">Alert e-mail frequency</strong>
|
||||
<span class="setting-description">Alert e-mails are sent immediately, or accumulated and sent periodically</span>
|
||||
</span>
|
||||
|
||||
<span class="setting-value">
|
||||
<select
|
||||
v-model="settings.alertEmailsFrequency"
|
||||
class="input"
|
||||
@change="updateSettings"
|
||||
>
|
||||
<option :value="null">Immediately</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.profile-section .heading {
|
||||
margin: .5rem 1rem 0 1rem;
|
||||
}
|
||||
|
||||
.setting {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: .75rem 1rem;
|
||||
border-bottom: solid 1px var(--glass-weak-30);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--glass-weak-20);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setting-title {
|
||||
color: var(--glass-strong-20);
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
color: var(--glass);
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
@media(--small-20) {
|
||||
.setting:not(.noflip) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -33,6 +33,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
web: {
|
||||
address: 'https://traxxx.me',
|
||||
host: '0.0.0.0',
|
||||
port: 5000,
|
||||
sfwHost: '0.0.0.0',
|
||||
@@ -153,6 +154,14 @@ module.exports = {
|
||||
siteKey: '10000000-ffff-ffff-ffff-000000000001',
|
||||
secretKey: '0x0000000000000000000000000000000000000000',
|
||||
},
|
||||
loginCredentialAttempts: 5,
|
||||
loginCredentialCooldown: 10, // minutes,
|
||||
loginIpAttempts: 20, // shared IPs might result in more non-malicious failed attempt
|
||||
loginIpCooldown: 10, // minutes
|
||||
tokenCooldown: 10, // minutes, how often a token can be requested
|
||||
emailTokenExpiry: 120, // minutes
|
||||
passwordTokenExpiry: 120, // minutes
|
||||
signupTokenExpiry: 120, // minutes
|
||||
},
|
||||
bans: {
|
||||
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
|
||||
@@ -203,6 +212,8 @@ module.exports = {
|
||||
},
|
||||
email: {
|
||||
// Resend
|
||||
enabled: false,
|
||||
apiKey: null,
|
||||
from: 'traxxx <noreply@alerts.traxxx.me>',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import antfu from '@antfu/eslint-config';
|
||||
import importX, { createNodeResolver } from 'eslint-plugin-import-x';
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
@@ -18,11 +19,21 @@ export default antfu(
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: { 'import-x': importX },
|
||||
languageOptions: {
|
||||
globals: {
|
||||
CONFIG: 'readonly',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
// mirrors the '#'/'#root' aliases in vite.config.js, which is what actually resolves these at build/runtime
|
||||
'import-x/resolver-next': [createNodeResolver({
|
||||
alias: {
|
||||
'#': [import.meta.dirname],
|
||||
'#root': [import.meta.dirname],
|
||||
},
|
||||
})],
|
||||
},
|
||||
rules: {
|
||||
'default-param-last': 'off',
|
||||
'no-underscore-dangle': 'off',
|
||||
@@ -33,6 +44,7 @@ export default antfu(
|
||||
ignorePropertyModificationsFor: ['state', 'acc', 'req'],
|
||||
}],
|
||||
'prefer-destructuring': 'off',
|
||||
'import-x/no-unresolved': 'error',
|
||||
'import/prefer-default-export': 'off',
|
||||
'unused-imports/no-unused-vars': ['error', {
|
||||
args: 'after-used',
|
||||
|
||||
4209
package-lock.json
generated
@@ -19,6 +19,9 @@
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@toycode/markdown-it-class": "^1.2.4",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue-email/compiler": "^0.8.14",
|
||||
"@vue-email/components": "^0.0.21",
|
||||
"@vue-email/render": "^0.0.9",
|
||||
"@vue/compiler-sfc": "^3.5.39",
|
||||
"@vue/server-renderer": "^3.5.39",
|
||||
"@vueuse/components": "^14.3.0",
|
||||
@@ -34,6 +37,7 @@
|
||||
"ejs": "^6.0.1",
|
||||
"error-stack-parser": "^2.1.4",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"express": "^5.2.1",
|
||||
"express-session": "^1.19.0",
|
||||
"floating-vue": "^5.2.2",
|
||||
@@ -88,7 +92,7 @@
|
||||
"overrides": {
|
||||
"vite": "$vite"
|
||||
},
|
||||
"version": "0.53.0",
|
||||
"version": "0.57.15",
|
||||
"imports": {
|
||||
"#/*": "./*.js"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export default {
|
||||
meta: {
|
||||
title: {
|
||||
env: {
|
||||
server: true,
|
||||
client: true,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
env: {
|
||||
server: true,
|
||||
|
||||
@@ -16,7 +16,6 @@ export async function onBeforeRender(pageContext) {
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
title: 'actors',
|
||||
pageProps: {
|
||||
actors,
|
||||
countries,
|
||||
|
||||
1
pages/actors/+title.js
Normal file
@@ -0,0 +1 @@
|
||||
export default 'Actors';
|
||||
38
pages/admin/emails/+Page.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
import Admin from '#/components/admin/admin.vue';
|
||||
|
||||
const { pageProps } = inject('pageContext');
|
||||
|
||||
const { templates } = pageProps;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Admin class="page">
|
||||
<ul
|
||||
v-if="templates.length > 0"
|
||||
class="menu"
|
||||
>
|
||||
<li v-for="template in templates" :key="template">
|
||||
<a
|
||||
:href="`/admin/emails/${template}`"
|
||||
class="link"
|
||||
>{{ template }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else>No e-mail templates found in components/emails.</p>
|
||||
</Admin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu {
|
||||
margin: 0;
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
padding: .25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
18
pages/admin/emails/+onBeforeRender.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { render } from 'vike/abort';
|
||||
|
||||
import { listEmailTemplates } from '#/src/email-templates.js';
|
||||
|
||||
export default function onBeforeRender(pageContext) {
|
||||
if (!pageContext.user || pageContext.user.role === 'user') {
|
||||
throw render(404);
|
||||
}
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
title: 'E-mail Previews',
|
||||
pageProps: {
|
||||
templates: listEmailTemplates(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
70
pages/admin/emails/@template/+Page.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
import Admin from '#/components/admin/admin.vue';
|
||||
|
||||
const { pageProps } = inject('pageContext');
|
||||
|
||||
const {
|
||||
template,
|
||||
html,
|
||||
text,
|
||||
error,
|
||||
} = pageProps;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Admin class="page">
|
||||
<div class="header">
|
||||
<h2 class="heading">{{ template }}</h2>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">Failed to render template: {{ error }}</p>
|
||||
|
||||
<template v-else>
|
||||
<iframe
|
||||
:srcdoc="html"
|
||||
class="frame"
|
||||
title="E-mail preview"
|
||||
/>
|
||||
|
||||
<details v-if="text" class="text">
|
||||
<summary>Plain-text version</summary>
|
||||
<pre>{{ text }}</pre>
|
||||
</details>
|
||||
</template>
|
||||
</Admin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--warn);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.frame {
|
||||
width: 100%;
|
||||
min-height: 60rem;
|
||||
border: solid 1px var(--shadow-weak-30);
|
||||
border-radius: .5rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-top: 1rem;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
padding: 1rem;
|
||||
background: var(--background-dark-20);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
pages/admin/emails/@template/+onBeforeRender.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { render } from 'vike/abort';
|
||||
|
||||
import { emailTemplates, listEmailTemplates } from '#/src/email-templates.js';
|
||||
|
||||
export default async function onBeforeRender(pageContext) {
|
||||
if (!pageContext.user || pageContext.user.role === 'user') {
|
||||
throw render(404);
|
||||
}
|
||||
|
||||
const { template } = pageContext.routeParams;
|
||||
|
||||
if (!listEmailTemplates().includes(template)) {
|
||||
throw render(404);
|
||||
}
|
||||
|
||||
let html = null;
|
||||
let text = null;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
({ html, text } = await emailTemplates.render(`${template}.vue`));
|
||||
}
|
||||
catch (renderError) {
|
||||
error = renderError.message;
|
||||
}
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
title: `E-mail Preview: ${template}`,
|
||||
pageProps: {
|
||||
template,
|
||||
html,
|
||||
text,
|
||||
error,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
18
pages/admin/emails/@template/+route.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { match } from 'path-to-regexp';
|
||||
|
||||
const path = '/admin/emails/:template';
|
||||
const urlMatch = match(path, { decode: decodeURIComponent });
|
||||
|
||||
export default (pageContext) => {
|
||||
const matched = urlMatch(pageContext.urlPathname);
|
||||
|
||||
if (matched) {
|
||||
return {
|
||||
routeParams: {
|
||||
template: matched.params.template,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup>
|
||||
import { inject, onMounted, ref } from 'vue';
|
||||
|
||||
import Password from '#/components/form/password.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const user = pageContext.user;
|
||||
const allowSignup = pageContext.env.allowSignup;
|
||||
const allowRecovery = pageContext.env.emailEnabled;
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
@@ -14,7 +17,6 @@ const password = ref('');
|
||||
const submitted = ref(false);
|
||||
const errorMsg = ref(null);
|
||||
const userInput = ref(null);
|
||||
const showPassword = ref(false);
|
||||
|
||||
async function login() {
|
||||
submitted.value = true;
|
||||
@@ -88,31 +90,10 @@ onMounted(() => {
|
||||
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>
|
||||
<Password
|
||||
:password="password"
|
||||
@input="(newPassword) => password = newPassword"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="button button-submit"
|
||||
@@ -124,11 +105,21 @@ onMounted(() => {
|
||||
href="/signup"
|
||||
class="link"
|
||||
>Create an account</a>
|
||||
|
||||
<a
|
||||
v-if="allowRecovery"
|
||||
href="/recovery"
|
||||
class="link"
|
||||
>I forgot my password</a>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
@@ -200,4 +191,8 @@ onMounted(() => {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button-submit {
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
1
pages/auth/login/+title.js
Normal file
@@ -0,0 +1 @@
|
||||
export default 'Login';
|
||||
218
pages/auth/recovery/+Page.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<script setup>
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import Password from '#/components/form/password.vue';
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { userId, token } = pageContext.urlParsed.search;
|
||||
const allowRecovery = pageContext.env.emailEnabled;
|
||||
|
||||
const credential = ref('');
|
||||
|
||||
const submitted = ref(false);
|
||||
const success = ref(false);
|
||||
const errorMsg = ref(null);
|
||||
const password = ref(null);
|
||||
|
||||
async function request() {
|
||||
submitted.value = true;
|
||||
errorMsg.value = null;
|
||||
|
||||
try {
|
||||
await post('/passwords/request', {
|
||||
credential: credential.value,
|
||||
}, {
|
||||
successFeedback: 'Password reset requested, please check your e-mail inbox',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
errorMsg.value = error.message;
|
||||
}
|
||||
finally {
|
||||
submitted.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
submitted.value = true;
|
||||
errorMsg.value = null;
|
||||
|
||||
try {
|
||||
await post('/passwords/reset', {
|
||||
userId,
|
||||
token,
|
||||
password: password.value,
|
||||
}, {
|
||||
successFeedback: 'Password reset successfully',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
errorMsg.value = error.message;
|
||||
}
|
||||
finally {
|
||||
submitted.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recovery-container">
|
||||
<h3 class="heading">
|
||||
<template v-if="userId && token">Password reset</template>
|
||||
<template v-else>Account recovery</template>
|
||||
</h3>
|
||||
|
||||
<template v-if="allowRecovery">
|
||||
<strong
|
||||
v-if="errorMsg"
|
||||
class="error"
|
||||
>{{ errorMsg }}</strong>
|
||||
|
||||
<form
|
||||
v-else-if="userId && token"
|
||||
class="login-panel"
|
||||
@submit.prevent="reset"
|
||||
>
|
||||
<div
|
||||
v-if="success"
|
||||
class="success"
|
||||
>
|
||||
<strong class="success-heading">Password reset successfully</strong>
|
||||
|
||||
<a
|
||||
href="/login"
|
||||
class="link"
|
||||
>Go to login</a>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<Password
|
||||
:password="password"
|
||||
@input="(newPassword) => password = newPassword"
|
||||
/>
|
||||
|
||||
<Ellipsis v-if="submitted" />
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="button button-submit"
|
||||
>Reset password</button>
|
||||
</template>
|
||||
</form>
|
||||
|
||||
<form
|
||||
v-else-if="allowRecovery"
|
||||
class="login-panel"
|
||||
@submit.prevent="request"
|
||||
>
|
||||
<div
|
||||
v-if="success"
|
||||
class="success"
|
||||
>
|
||||
<strong class="success-heading">Password reset requested</strong>
|
||||
If the account exists, instructions have been sent to the associated e-mail address.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<input
|
||||
v-model="credential"
|
||||
placeholder="Username or e-mail"
|
||||
class="input"
|
||||
>
|
||||
|
||||
<Ellipsis v-if="submitted" />
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="button button-submit"
|
||||
>Request password reset</button>
|
||||
</template>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="error"
|
||||
>Password recovery is currently unavailable</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heading {
|
||||
text-align: center;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.recovery-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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: 0 1rem 1rem 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;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 1rem;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.success {
|
||||
justify-content: center;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.success-heading {
|
||||
display: block;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.load-container {
|
||||
justify-content: center;
|
||||
padding: 1rem 1rem .5rem 1rem;
|
||||
}
|
||||
</style>
|
||||
1
pages/auth/recovery/+route.js
Normal file
@@ -0,0 +1 @@
|
||||
export default '/recovery';
|
||||
1
pages/auth/recovery/+title.js
Normal file
@@ -0,0 +1 @@
|
||||
export default `Account recovery`;
|
||||
@@ -2,32 +2,30 @@
|
||||
import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import { inject, onMounted, ref } from 'vue';
|
||||
|
||||
import Password from '#/components/form/password.vue';
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { user, env } = pageContext;
|
||||
const { token } = pageContext.urlParsed.search;
|
||||
|
||||
const username = ref('');
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const passwordConfirm = ref('');
|
||||
|
||||
const errorMsg = ref(null);
|
||||
const submitted = ref(false);
|
||||
const userInput = ref(null);
|
||||
const showPassword = ref(false);
|
||||
const success = ref(false);
|
||||
const mainInput = ref(null);
|
||||
const captcha = ref(null);
|
||||
|
||||
async function signup() {
|
||||
errorMsg.value = null;
|
||||
submitted.value = true;
|
||||
|
||||
if (password.value !== passwordConfirm.value) {
|
||||
errorMsg.value = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
if (env.captcha.enabled && !captcha.value) {
|
||||
errorMsg.value = 'Please complete the CAPTCHA';
|
||||
return;
|
||||
@@ -35,11 +33,14 @@ async function signup() {
|
||||
|
||||
try {
|
||||
const newUser = await post('/users', {
|
||||
token,
|
||||
username: username.value,
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
redirect: pageContext.urlParsed.search.r,
|
||||
captcha: captcha.value,
|
||||
}, {
|
||||
successFeedback: 'Sign-up successful!',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
navigate(`/user/${newUser.username}`, null, { redirect: true });
|
||||
@@ -52,8 +53,30 @@ async function signup() {
|
||||
}
|
||||
}
|
||||
|
||||
async function request() {
|
||||
errorMsg.value = null;
|
||||
submitted.value = true;
|
||||
|
||||
try {
|
||||
await post('/signups', {
|
||||
email: email.value,
|
||||
}, {
|
||||
successFeedback: 'Sign-up requested, please check your inbox',
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
errorMsg.value = error.message;
|
||||
}
|
||||
finally {
|
||||
submitted.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
userInput.value.focus();
|
||||
mainInput.value.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -87,7 +110,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-else
|
||||
v-else-if="token"
|
||||
autocomplete="off"
|
||||
class="login-panel"
|
||||
@submit.prevent="signup"
|
||||
@@ -98,78 +121,18 @@ onMounted(() => {
|
||||
>{{ errorMsg }}</div>
|
||||
|
||||
<input
|
||||
ref="userInput"
|
||||
ref="mainInput"
|
||||
v-model="username"
|
||||
placeholder="Username"
|
||||
class="input"
|
||||
required
|
||||
>
|
||||
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="E-mail"
|
||||
class="input"
|
||||
required
|
||||
>
|
||||
|
||||
<div class="password-container">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'input' : 'password'"
|
||||
autocomplete="new-password"
|
||||
minlength="3"
|
||||
placeholder="Password"
|
||||
class="password input"
|
||||
required
|
||||
>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="password-container">
|
||||
<input
|
||||
v-model="passwordConfirm"
|
||||
:type="showPassword ? 'input' : 'password'"
|
||||
autocomplete="new-password"
|
||||
minlength="3"
|
||||
placeholder="Confirm password"
|
||||
class="password input"
|
||||
required
|
||||
>
|
||||
|
||||
<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>
|
||||
<Password
|
||||
:password="password"
|
||||
autocomplete="new-password"
|
||||
@input="(newPassword) => password = newPassword"
|
||||
/>
|
||||
|
||||
<VueHCaptcha
|
||||
v-if="env.captcha.enabled"
|
||||
@@ -179,20 +142,62 @@ onMounted(() => {
|
||||
@expired="captcha = null"
|
||||
/>
|
||||
|
||||
<Ellipsis v-if="submitted" />
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="button button-submit"
|
||||
:disabled="submitted"
|
||||
>Sign up</button>
|
||||
</form>
|
||||
|
||||
<a
|
||||
href="/login"
|
||||
class="link"
|
||||
>I already have an account</a>
|
||||
<form
|
||||
v-else
|
||||
autocomplete="off"
|
||||
class="login-panel"
|
||||
@submit.prevent="request"
|
||||
>
|
||||
<span
|
||||
v-if="success"
|
||||
class="success"
|
||||
>
|
||||
<strong class="success-heading">Thank you for signing up!</strong>
|
||||
Please check your e-mail inbox to continue.
|
||||
</span>
|
||||
|
||||
<template v-else>
|
||||
<input
|
||||
ref="mainInput"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="E-mail"
|
||||
class="input"
|
||||
required
|
||||
>
|
||||
|
||||
<Ellipsis v-if="submitted" />
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
class="button button-submit"
|
||||
:disabled="submitted"
|
||||
>Send signup e-mail</button>
|
||||
|
||||
<a
|
||||
href="/login"
|
||||
class="link login"
|
||||
>I already have an account</a>
|
||||
</template>
|
||||
</template>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
@@ -219,7 +224,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: .5rem;
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -261,6 +266,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.button-submit {
|
||||
padding: .75rem 1rem;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
@@ -274,4 +280,18 @@ onMounted(() => {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.success-heading {
|
||||
display: block;
|
||||
color: var(--primary);
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.load-container {
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
1
pages/auth/signup/+title.js
Normal file
@@ -0,0 +1 @@
|
||||
export default 'Sign up';
|
||||
@@ -4,7 +4,7 @@ export async function onBeforeRender(pageContext) {
|
||||
const networks = await fetchEntities(pageContext.urlParsed.search.q
|
||||
? { query: pageContext.urlParsed.search.q }
|
||||
: { type: 'primary' }, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,7 +21,7 @@ async function fetchReleases(pageContext, entityId) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ async function fetchReleases(pageContext, entityId) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function onBeforeRender(pageContext) {
|
||||
entityReleases,
|
||||
] = await Promise.all([
|
||||
fetchEntitiesById([Number(entityId)], { includeChildren: true }, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
}),
|
||||
fetchReleases(pageContext, entityId),
|
||||
]);
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function onBeforeRender(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 50,
|
||||
dedupe: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -18,7 +18,7 @@ function getTitle(movie) {
|
||||
|
||||
export async function onBeforeRender(pageContext) {
|
||||
const [[movie], movieScenes] = await Promise.all([
|
||||
fetchMoviesById([Number(pageContext.routeParams.movieId)], pageContext.user, { restriction: pageContext.restriction }),
|
||||
fetchMoviesById([Number(pageContext.routeParams.movieId)], pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
fetchScenes(await curateScenesQuery({
|
||||
...pageContext.urlQuery,
|
||||
scope: 'oldest',
|
||||
@@ -28,7 +28,7 @@ export async function onBeforeRender(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
1
pages/recovery/+title.js
Normal file
@@ -0,0 +1 @@
|
||||
export default 'Account recovery';
|
||||
@@ -18,7 +18,7 @@ export async function onBeforeRender(pageContext) {
|
||||
aggregate: true,
|
||||
dedupe: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
}),
|
||||
getRandomCampaigns([
|
||||
{ minRatio: 0.75, maxRatio: 1.25 },
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function onBeforeRender(pageContext) {
|
||||
includeAssets: true,
|
||||
includePartOf: true,
|
||||
actorStashes: true,
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
|
||||
if (!scene) {
|
||||
|
||||
@@ -16,24 +16,24 @@ export async function onBeforeRender(pageContext) {
|
||||
}), {
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 15,
|
||||
}, pageContext.user, { restriction: pageContext.restriction }),
|
||||
}, pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
fetchActors(curateActorsQuery(pageContext.urlQuery), {
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 10,
|
||||
order: ['results', 'desc'],
|
||||
}, pageContext.user, { restriction: pageContext.restriction }),
|
||||
}, pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
fetchMovies(await curateMoviesQuery({
|
||||
...pageContext.urlQuery,
|
||||
scope: pageContext.routeParams.scope || 'results',
|
||||
}), {
|
||||
page: Number(pageContext.routeParams.page) || 1,
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 5,
|
||||
}, pageContext.user, { restriction: pageContext.restriction }),
|
||||
}, pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
fetchEntities({
|
||||
query: pageContext.urlParsed.search.q,
|
||||
limit: 5,
|
||||
}, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -279,6 +279,7 @@ onMounted(() => {
|
||||
.tags {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
grid-auto-rows: max-content; /* prevent row gaps on large screens */
|
||||
gap: 1rem .5rem;
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ const tagSlugs = {
|
||||
};
|
||||
|
||||
async function searchTags(pageContext) {
|
||||
const tags = await fetchTags({ query: pageContext.urlParsed.search.q }, { restriction: pageContext.restriction });
|
||||
const tags = await fetchTags({ query: pageContext.urlParsed.search.q }, { restriction: pageContext.geo.restriction });
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
@@ -137,13 +137,13 @@ export async function onBeforeRender(pageContext) {
|
||||
return searchTags(pageContext);
|
||||
}
|
||||
|
||||
const tags = await fetchTagsById(Object.values(tagSlugs).flat(), {}, pageContext.user, { restriction: pageContext.restriction });
|
||||
const tags = await fetchTagsById(Object.values(tagSlugs).flat(), {}, pageContext.user, { restriction: pageContext.geo.restriction });
|
||||
|
||||
const filteredTags = tags.filter((tag) => !pageContext.tagFilter.includes(tag.name) && !pageContext.tagFilter.includes(tag.slug));
|
||||
const tagsBySlug = Object.fromEntries(filteredTags.map((tag) => [tag.slug, tag]));
|
||||
|
||||
const tagShowcase = Object.fromEntries(Object.entries(tagSlugs).map(([category, categorySlugs]) => [
|
||||
censor(category, pageContext.restriction),
|
||||
censor(category, pageContext.geo.restriction),
|
||||
categorySlugs.map((slug) => tagsBySlug[slug]).filter(Boolean),
|
||||
]));
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ async function fetchReleases(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,13 +37,13 @@ async function fetchReleases(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||
aggregate: true,
|
||||
}, pageContext.user, {
|
||||
restriction: pageContext.restriction,
|
||||
restriction: pageContext.geo.restriction,
|
||||
});
|
||||
}
|
||||
|
||||
export async function onBeforeRender(pageContext) {
|
||||
const tagSlug = pageContext.routeParams.tagSlug;
|
||||
const [tag] = await fetchTagsById([tagSlug], {}, pageContext.user, { restriction: pageContext.restriction });
|
||||
const [tag] = await fetchTagsById([tagSlug], {}, pageContext.user, { restriction: pageContext.geo.restriction });
|
||||
|
||||
if (tag.aliasFor) {
|
||||
throw redirect(`/tag/${tag.aliasFor}`);
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function onBeforeRender(pageContext) {
|
||||
limit: Number(pageContext.urlParsed.search.limit) || 29,
|
||||
aggregate: withQuery,
|
||||
dedupe: true,
|
||||
}, pageContext.user, { restriction: pageContext.restriction }),
|
||||
}, pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||
getRandomCampaigns([
|
||||
{ minRatio: 2.0, maxRatio: 5 },
|
||||
{ minRatio: 0.75, maxRatio: 1.25 },
|
||||
|
||||
@@ -6,9 +6,12 @@ import mockupRelease from '#/assets/mockup-release.ts';
|
||||
import Alerts from '#/components/alerts/alerts.vue';
|
||||
import Revisions from '#/components/edit/revisions.vue';
|
||||
import Summaries from '#/components/scenes/summaries.vue';
|
||||
// filters are implemented as generic settings panel in case we want to extend it in the future
|
||||
import Settings from '#/components/settings/settings.vue';
|
||||
import Stashes from '#/components/stashes/stashes.vue';
|
||||
|
||||
import Account from '#/components/user/account.vue';
|
||||
import ApiKeys from '#/components/user/api-keys.vue';
|
||||
import Preferences from '#/components/user/preferences.vue';
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
|
||||
@@ -17,6 +20,9 @@ const domain = pageContext.routeParams.domain;
|
||||
|
||||
const user = pageContext.user;
|
||||
const profile = ref(pageContext.pageProps.profile);
|
||||
const primaryStash = pageContext.assets?.primaryStash;
|
||||
|
||||
const showFilters = ref(false);
|
||||
|
||||
function scrollHorizontal(event) {
|
||||
event.currentTarget.scrollLeft += event.deltaY; // eslint-disable-line no-param-reassign
|
||||
@@ -27,60 +33,106 @@ function scrollHorizontal(event) {
|
||||
<div class="page">
|
||||
<div class="profile">
|
||||
<div class="profile-header">
|
||||
<div class="user">
|
||||
<a
|
||||
:href="`/user/${profile.username}/`"
|
||||
:title="`#${profile.id}`"
|
||||
class="user nolink"
|
||||
>
|
||||
<Icon
|
||||
v-if="section && profile.id === user?.id"
|
||||
icon="arrow-left3"
|
||||
class="profile-back"
|
||||
/>
|
||||
|
||||
<img
|
||||
v-if="profile.avatar"
|
||||
v-else-if="profile.avatar"
|
||||
:src="profile.avatar"
|
||||
class="avatar"
|
||||
>
|
||||
|
||||
<h2 class="username ellipsis">{{ profile.username }}</h2>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<span class="age">{{ formatDistanceStrict(Date.now(), profile.createdAt) }}</span>
|
||||
<strong
|
||||
v-if="section && profile.id === user?.id"
|
||||
class="profile-crumb"
|
||||
>
|
||||
<template v-if="domain">{{ domain }}</template>
|
||||
{{ section }}
|
||||
</strong>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="age"
|
||||
>{{ formatDistanceStrict(Date.now(), profile.createdAt) }}</span>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
v-if="profile.id === user?.id"
|
||||
v-if="!section && profile.id === user?.id"
|
||||
class="domains nobar"
|
||||
@wheel.prevent="scrollHorizontal"
|
||||
>
|
||||
<a
|
||||
v-if="primaryStash"
|
||||
:href="`/stash/${user.username}/${primaryStash.slug}`"
|
||||
class="domain nolink"
|
||||
><Icon :icon="primaryStash.slug === 'favorites' ? 'heart7' : 'folder-heart'" />{{ primaryStash.name }}</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/stashes`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'stashes' }"
|
||||
>Stashes</a>
|
||||
><Icon icon="folder-heart" />Stashes</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/alerts`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'alerts' }"
|
||||
>Alerts</a>
|
||||
><Icon icon="alarm" />Alerts</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/templates`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'templates' }"
|
||||
>Templates</a>
|
||||
><Icon icon="paste3" />Templates</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/revisions/scenes`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'revisions' && domain === 'scenes' }"
|
||||
>Scene Revisions</a>
|
||||
><Icon icon="file-video2" />Scene Revisions</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/revisions/actors`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'revisions' && domain === 'actors' }"
|
||||
>Actor Revisions</a>
|
||||
><Icon icon="profile" />Actor Revisions</a>
|
||||
|
||||
<span
|
||||
:href="`/user/${profile.username}/preferences`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'filters' }"
|
||||
@click="showFilters = true"
|
||||
><Icon icon="filter" />Filters</span>
|
||||
|
||||
<a
|
||||
v-if="profile.isIdentityVerified"
|
||||
:href="`/user/${profile.username}/api`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'api' }"
|
||||
>API Keys</a>
|
||||
><Icon icon="key" />API Keys</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/preferences`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'preferences' }"
|
||||
><Icon icon="equalizer" />Preferences</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/account`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'account' }"
|
||||
><Icon icon="user-tie" />Account</a>
|
||||
</nav>
|
||||
|
||||
<Stashes v-if="section === 'stashes'" />
|
||||
@@ -91,18 +143,22 @@ function scrollHorizontal(event) {
|
||||
:release="mockupRelease"
|
||||
/>
|
||||
|
||||
<ApiKeys
|
||||
v-if="section === 'api'"
|
||||
/>
|
||||
<ApiKeys v-if="section === 'api'" />
|
||||
<Preferences v-if="section === 'preferences'" />
|
||||
<Account v-if="section === 'account'" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="section === 'revisions' && profile.id === user?.id"
|
||||
class="profile-section revisions"
|
||||
>
|
||||
<h3 class="section-header heading">{{ domain.slice(0, -1) }} Revisions</h3>
|
||||
<Revisions context="user" />
|
||||
</div>
|
||||
|
||||
<Settings
|
||||
v-if="showFilters"
|
||||
@close="showFilters = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -160,11 +216,16 @@ function scrollHorizontal(event) {
|
||||
color: var(--highlight-strong-30);
|
||||
background: var(--shadow-strong-30);
|
||||
border-radius: 0 0 .5rem .5rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover .profile-back {
|
||||
fill: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.username {
|
||||
@@ -192,26 +253,53 @@ function scrollHorizontal(event) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.profile-back {
|
||||
padding: .25rem 1.25rem .25rem .25rem;
|
||||
fill: var(--highlight);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.profile-crumb {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.domains {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(30rem, 1fr));
|
||||
gap: .5rem;
|
||||
box-sizing: border-box;
|
||||
padding: .5rem 0;
|
||||
margin-top: .25rem;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.domain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--glass-strong-20);
|
||||
background: var(--background-dark-20);
|
||||
padding: .5rem 1rem;
|
||||
padding: 1.25rem 2rem;
|
||||
border-radius: 1rem;
|
||||
font-size: .9rem;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
|
||||
&.active {
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
transform: translateY(-1px);
|
||||
fill: var(--glass-strong-20);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--primary);
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
fill: var(--text-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +317,7 @@ function scrollHorizontal(event) {
|
||||
@media(--compact) {
|
||||
.domains {
|
||||
padding: .5rem 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(25rem, 1fr));
|
||||
}
|
||||
|
||||
.profile {
|
||||
@@ -240,6 +329,12 @@ function scrollHorizontal(event) {
|
||||
}
|
||||
}
|
||||
|
||||
@media(--small) {
|
||||
.domains {
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media(--small-20) {
|
||||
.profile-header {
|
||||
padding: .5rem;
|
||||
|
||||
@@ -31,7 +31,7 @@ async function fetchRevisions(pageContext) {
|
||||
|
||||
export async function onBeforeRender(pageContext) {
|
||||
const [profile, alerts, userRevisions, keys] = await Promise.all([
|
||||
fetchUser(pageContext.routeParams.username, {}, pageContext.user),
|
||||
fetchUser(pageContext.routeParams.username, { throwError: false }, pageContext.user),
|
||||
pageContext.routeParams.section === 'alerts' && pageContext.routeParams.username === pageContext.user?.username
|
||||
? fetchAlerts(pageContext.user)
|
||||
: [],
|
||||
|
||||
@@ -6,17 +6,18 @@ const urlMatch = match(path, { decode: decodeURIComponent });
|
||||
|
||||
export default (pageContext) => {
|
||||
const matched = urlMatch(pageContext.urlPathname);
|
||||
const isSelf = pageContext.user?.username === matched.params?.username;
|
||||
|
||||
if (matched) {
|
||||
if (![undefined, 'stashes'].includes(matched.params.domain) && pageContext.user?.username !== matched.params.username) {
|
||||
throw redirect(`/user/${matched.params.username}`);
|
||||
if (!isSelf && matched.params.section !== 'stashes') {
|
||||
throw redirect(`/user/${matched.params.username}/stashes`);
|
||||
}
|
||||
|
||||
return {
|
||||
routeParams: {
|
||||
username: matched.params.username,
|
||||
section: matched.params.section || 'stashes',
|
||||
domain: matched.params.domain || 'scenes',
|
||||
section: matched.params.section || '',
|
||||
domain: matched.params.domain || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
85
pages/verify/email/+Page.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
|
||||
const { urlParsed } = inject('pageContext');
|
||||
const { userId, token } = urlParsed.search;
|
||||
|
||||
const errorMsg = ref(null);
|
||||
const submitting = ref(false);
|
||||
const success = ref(false);
|
||||
|
||||
async function verify() {
|
||||
errorMsg.value = null;
|
||||
submitting.value = true;
|
||||
|
||||
try {
|
||||
await post('/emails/verify', {
|
||||
userId,
|
||||
token,
|
||||
}, {
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
errorMsg.value = error.message;
|
||||
}
|
||||
finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content">
|
||||
<Ellipsis v-if="submitting" />
|
||||
|
||||
<strong
|
||||
v-else-if="errorMsg"
|
||||
class="feedback error"
|
||||
>{{ errorMsg }}</strong>
|
||||
|
||||
<strong
|
||||
v-else-if="success"
|
||||
class="feedback success"
|
||||
>Verification successful</strong>
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="button button-primary"
|
||||
@click="verify"
|
||||
>Click here to verify your e-mail address</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success);
|
||||
}
|
||||
</style>
|
||||
BIN
public/img/logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
@@ -9,6 +9,6 @@ export default {
|
||||
'assets',
|
||||
'campaigns',
|
||||
'meta',
|
||||
'restriction',
|
||||
'geo',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,11 +5,15 @@ import { dangerouslySkipEscape, escapeInject } from 'vike/server';
|
||||
|
||||
import { createApp } from './app.js';
|
||||
|
||||
function getTitle(location) {
|
||||
function getTitle(location, pageContext) {
|
||||
if (!location) {
|
||||
return config.title;
|
||||
}
|
||||
|
||||
if (typeof location === 'function') {
|
||||
return getTitle(location(pageContext), pageContext);
|
||||
}
|
||||
|
||||
return `${config.title} - ${location.slice(0, 1).toUpperCase()}${location.slice(1)}`;
|
||||
}
|
||||
|
||||
@@ -44,8 +48,7 @@ async function onRenderHtml(pageContext) {
|
||||
const appHtml = await renderToString(app);
|
||||
|
||||
// See https://vike.dev/head
|
||||
const { documentProps } = pageContext.exports;
|
||||
const title = getTitle(documentProps?.title || pageContext.title);
|
||||
const title = getTitle(pageContext.title || pageContext.config.title, pageContext);
|
||||
|
||||
const documentHtml = escapeInject`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
@@ -32,7 +32,14 @@ function createApp(Page, pageProps, pageContext) {
|
||||
|
||||
app.provide('pageContext', pageContext);
|
||||
|
||||
app.use(FloatingVue, { container: '#container' });
|
||||
app.use(FloatingVue, {
|
||||
container: '#container',
|
||||
autoBoundaryMaxSize: true,
|
||||
shiftCrossAxis: true,
|
||||
overflowPadding: 10,
|
||||
arrowPadding: 10,
|
||||
});
|
||||
|
||||
app.use(VueVirtualScroller);
|
||||
|
||||
app.component('Link', Link);
|
||||
|
||||
@@ -189,7 +189,7 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
z-index: 1000;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -304,18 +304,18 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
|
||||
.orderBy(knex.raw('max(actors_avatars.created_at)'), 'desc'),
|
||||
knex('actors_socials')
|
||||
.whereIn('actor_id', actorIds),
|
||||
reqUser
|
||||
reqUser && !options.shallow
|
||||
? knex('stashes_actors')
|
||||
.leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id')
|
||||
.where('stashes.user_id', reqUser.id)
|
||||
.whereIn('stashes_actors.actor_id', actorIds)
|
||||
: [],
|
||||
reqUser
|
||||
reqUser && !options.shallow
|
||||
? knex('alerts_users_actors')
|
||||
.where('user_id', reqUser.id)
|
||||
.whereIn('actor_id', actorIds)
|
||||
: [],
|
||||
].slice(0, options.shallow ? 1 : -1));
|
||||
].slice(0, options.shallow ? 1 : undefined));
|
||||
|
||||
if (options.order) {
|
||||
return actors.map((actorEntry) => curateActor(actorEntry, {
|
||||
@@ -413,7 +413,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!filters.includeAliased) {
|
||||
if (!filters.includeAliased && !filters.stashId) {
|
||||
builder.where('alias_for', 0);
|
||||
}
|
||||
|
||||
@@ -867,7 +867,6 @@ export async function mergeActors(targetActorId, sourceActorIds, comment, reqUse
|
||||
.update('alias_for', targetActorId)
|
||||
.whereIn('id', sourceActorIds)
|
||||
.returning(['id', 'alias_for']),
|
||||
// some avatars are not matched to a profile, need to investigate why this happens and the avatar table needs a dedicated actor field
|
||||
trx('actors_avatars')
|
||||
.update('actor_id', targetActorId)
|
||||
.whereIn('actor_id', sourceActorIds),
|
||||
|
||||
300
src/alerts.js
@@ -1,9 +1,12 @@
|
||||
import config from 'config';
|
||||
import { CronJob } from 'cron';
|
||||
import { add } from 'date-fns';
|
||||
import escapeRegexp from 'escape-string-regexp';
|
||||
import markdownIt from 'markdown-it';
|
||||
|
||||
import promiseProps from '../utils/promise-props.js';
|
||||
import { getIdsBySlug } from './cache.js';
|
||||
import { sendEmails } from './email.js';
|
||||
import { HttpError } from './errors.js';
|
||||
import { knexOwner as knex } from './knex.js';
|
||||
import initLogger from './logger.js';
|
||||
@@ -12,6 +15,24 @@ import { fetchScenesById } from './scenes.js';
|
||||
|
||||
const logger = initLogger();
|
||||
|
||||
function getImagePath(media) {
|
||||
if (!media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (media.isS3) {
|
||||
return new URL(media.lazy, config.media.s3Path).href;
|
||||
}
|
||||
|
||||
return new URL(`${config.media.mediaPath}/${media.lazy}`, config.web.address).href;
|
||||
}
|
||||
|
||||
const md = markdownIt({
|
||||
html: true,
|
||||
});
|
||||
|
||||
md.disable('code');
|
||||
|
||||
function curateAlert(alert, context = {}) {
|
||||
return {
|
||||
id: alert.id,
|
||||
@@ -228,7 +249,7 @@ export async function createAlert(alert, reqUser) {
|
||||
entityIds?.length > 0 && knex('alerts_entities').insert(entityIds.map((entityId) => ({
|
||||
alert_id: alertId,
|
||||
entity_id: entityId,
|
||||
})).slice(0, alert.allEntities ? 1 : Infinity)), // one scene can never match multiple entities in AND mode
|
||||
})).slice(0, alert.allEntities ? 1 : undefined)), // one scene can never match multiple entities in AND mode
|
||||
sceneIds?.length > 0 && knex('alerts_scenes').insert(sceneIds.map((sceneId) => ({
|
||||
alert_id: alertId,
|
||||
scene_id: sceneId,
|
||||
@@ -352,6 +373,7 @@ export async function fetchNotifications(reqUser, options = {}) {
|
||||
.leftJoin('alerts_matches', 'alerts_matches.alert_id', 'alerts.id')
|
||||
.leftJoin('alerts_scenes', 'alerts_scenes.alert_id', 'alerts.id')
|
||||
.where('notifications.user_id', reqUser.id)
|
||||
.where('notifications.notify', true) // notification table is also used to defer e-mail alerts
|
||||
.limit(options.limit)
|
||||
.groupBy('notifications.id', 'alerts.id')
|
||||
.orderBy('created_at', 'desc'),
|
||||
@@ -400,18 +422,145 @@ function groupByKey(data, byKey, itemKey) {
|
||||
acc[item[byKey]] = [];
|
||||
}
|
||||
|
||||
acc[item[byKey]].push(item[itemKey]);
|
||||
acc[item[byKey]].push(itemKey ? item[itemKey] : item);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export async function notify(sceneIds) {
|
||||
const frequencies = {
|
||||
daily: { days: 1 },
|
||||
weekly: { weeks: 1 },
|
||||
monthly: { months: 1 },
|
||||
};
|
||||
|
||||
async function sendEmailAlerts() {
|
||||
if (!config.email.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const qualifyingUsers = await knex('notifications')
|
||||
.select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email'))
|
||||
.where('notifications.email', true)
|
||||
.innerJoin('users', 'users.id', 'notifications.user_id')
|
||||
.groupBy('users.id')
|
||||
.having(knex.raw('bool_or(emailed_at is null)'));
|
||||
|
||||
const dueUserIds = qualifyingUsers
|
||||
.filter(({ settings, last_email }) => {
|
||||
const frequency = frequencies[settings?.alertEmailFrequency];
|
||||
|
||||
if (!frequency || !last_email) {
|
||||
// email setting set to immediate or invalid, or no recent emails sent
|
||||
return true;
|
||||
}
|
||||
|
||||
// is this last e-mail sent before the frequency setting timeframe?
|
||||
return add(last_email, frequency) <= new Date();
|
||||
})
|
||||
.map((row) => row.user_id);
|
||||
|
||||
// immediately mark as e-mailed to prevent double e-mails if cronjob coincides with direct trigger
|
||||
const markedNotifications = await knex('notifications')
|
||||
.where('email', true)
|
||||
.whereNull('emailed_at')
|
||||
.whereIn('user_id', dueUserIds)
|
||||
.update('emailed_at', knex.fn.now())
|
||||
.returning('*');
|
||||
|
||||
const userIds = Array.from(new Set(markedNotifications.map((notification) => notification.user_id)));
|
||||
const sceneIds = Array.from(new Set(markedNotifications.map((notification) => notification.scene_id)));
|
||||
const alertIds = Array.from(new Set(markedNotifications.map((notification) => notification.alert_id)));
|
||||
|
||||
const scenes = await fetchScenesById(sceneIds);
|
||||
|
||||
const [
|
||||
users,
|
||||
alertsActors,
|
||||
alertsTags,
|
||||
alertsEntities,
|
||||
alertsScenes,
|
||||
alertsMatches,
|
||||
] = await Promise.all([
|
||||
knex('users')
|
||||
.select('users.id', 'users.username', 'users.email', 'users.settings')
|
||||
.whereIn('users.id', userIds),
|
||||
knex('alerts_actors')
|
||||
.select('alerts_actors.alert_id', 'actors.id', 'actors.name', 'actors.slug')
|
||||
.whereIn('alerts_actors.alert_id', alertIds)
|
||||
.leftJoin('actors', 'actors.id', 'alerts_actors.actor_id'),
|
||||
knex('alerts_tags')
|
||||
.select('alerts_tags.alert_id', 'tags.id', 'tags.name', 'tags.slug')
|
||||
.whereIn('alerts_tags.alert_id', alertIds)
|
||||
.leftJoin('tags', 'tags.id', 'alerts_tags.tag_id'),
|
||||
knex('alerts_entities')
|
||||
.select('alerts_entities.alert_id', 'entities.id', 'entities.type', 'entities.name', 'entities.slug')
|
||||
.whereIn('alerts_entities.alert_id', alertIds)
|
||||
.leftJoin('entities', 'entities.id', 'alerts_entities.entity_id'),
|
||||
knex('alerts_scenes')
|
||||
.whereIn('alerts_scenes.alert_id', alertIds),
|
||||
knex('alerts_matches')
|
||||
.whereIn('alerts_matches.alert_id', alertIds),
|
||||
]);
|
||||
|
||||
const usersByUserId = Object.fromEntries(users.map((user) => [user.id, user]));
|
||||
const scenesBySceneId = Object.fromEntries(scenes.map((scene) => [scene.id, scene]));
|
||||
const alertActorsByAlertId = groupByKey(alertsActors, 'alert_id');
|
||||
const alertTagsByAlertId = groupByKey(alertsTags, 'alert_id');
|
||||
const alertEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id');
|
||||
const alertMatchesByAlertId = groupByKey(alertsMatches, 'alert_id');
|
||||
const alertSceneIdsByAlertId = groupByKey(alertsScenes, 'alert_id', 'scene_id');
|
||||
|
||||
const triggers = markedNotifications
|
||||
.map((notification) => ({
|
||||
notificationId: notification.id,
|
||||
scene: scenesBySceneId[notification.scene_id] || null,
|
||||
posterUrl: getImagePath(scenesBySceneId[notification.scene_id]?.poster),
|
||||
user: usersByUserId[notification.user_id] || null,
|
||||
alertId: notification.alert_id,
|
||||
alertTags: alertTagsByAlertId[notification.alert_id] || [],
|
||||
alertActors: alertActorsByAlertId[notification.alert_id] || [],
|
||||
alertEntities: alertEntitiesByAlertId[notification.alert_id] || [],
|
||||
alertMatches: alertMatchesByAlertId[notification.alert_id] || [],
|
||||
alertSceneIds: alertSceneIdsByAlertId[notification.alert_id] || [],
|
||||
}))
|
||||
.filter((notification) => !!notification.user
|
||||
&& !!notification.scene
|
||||
&& notification.user.settings?.alertEmailsEnabled !== false); // keep the e-mails marked as 'sent', if emails get re-enabled there shouldn't be a barrage of old notifications
|
||||
|
||||
const emailItems = Object.values(usersByUserId).map((user) => {
|
||||
const userTriggers = Object.values(Object.fromEntries(triggers
|
||||
.filter((trigger) => trigger.user.id === user.id)
|
||||
.map((trigger) => [trigger.scene.id, trigger])))
|
||||
.toSorted((triggerA, triggerB) => triggerA.scene.effectiveDate - triggerB.scene.effectiveDate);
|
||||
|
||||
if (userTriggers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
template: 'alert',
|
||||
to: user.email,
|
||||
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
|
||||
props: {
|
||||
user,
|
||||
triggers: userTriggers,
|
||||
},
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
if (emailItems.length === 0) {
|
||||
logger.info(`No alert e-mails sent`);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEmails(emailItems);
|
||||
}
|
||||
|
||||
export async function notify(sceneIds, options = {}) {
|
||||
const scenes = await fetchScenesById(sceneIds);
|
||||
|
||||
const [
|
||||
releasesActors,
|
||||
releasesTags,
|
||||
rawAlerts,
|
||||
alertsActors,
|
||||
alertsTags,
|
||||
@@ -419,21 +568,19 @@ export async function notify(sceneIds) {
|
||||
alertsMatches,
|
||||
alertsStashes,
|
||||
] = await Promise.all([
|
||||
knex('releases_actors').whereIn('release_id', sceneIds),
|
||||
knex('releases_tags').whereIn('release_id', sceneIds),
|
||||
knex('alerts'),
|
||||
knex('alerts_actors'),
|
||||
knex('alerts_tags'),
|
||||
knex('alerts_entities'),
|
||||
knex('alerts')
|
||||
.select('alerts.*', 'users.username', 'users.email as user_email', 'users.settings as user_settings')
|
||||
.leftJoin('users', 'users.id', 'alerts.user_id'),
|
||||
knex('alerts_actors').select('alert_id', 'actor_id'),
|
||||
knex('alerts_tags').select('alert_id', 'tag_id'),
|
||||
knex('alerts_entities').select('alert_id', 'entity_id'),
|
||||
knex('alerts_matches'),
|
||||
knex('alerts_stashes'),
|
||||
]);
|
||||
|
||||
const actorIdsByReleaseId = groupByKey(releasesActors, 'release_id', 'actor_id');
|
||||
const tagIdsByReleaseId = groupByKey(releasesTags, 'release_id', 'tag_id');
|
||||
const alertsActorsByAlertId = groupByKey(alertsActors, 'alert_id', 'actor_id');
|
||||
const alertsTagsByAlertId = groupByKey(alertsTags, 'alert_id', 'tag_id');
|
||||
const alertsEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id', 'entity_id');
|
||||
const alertActorIdsByAlertId = groupByKey(alertsActors, 'alert_id', 'actor_id');
|
||||
const alertTagIdsByAlertId = groupByKey(alertsTags, 'alert_id', 'tag_id');
|
||||
const alertEntityIdsByAlertId = groupByKey(alertsEntities, 'alert_id', 'entity_id');
|
||||
const alertsStashesByAlertId = groupByKey(alertsStashes, 'alert_id', 'stash_id');
|
||||
|
||||
const alertsMatchesByAlertId = alertsMatches.reduce((acc, alertMatch) => {
|
||||
@@ -449,31 +596,37 @@ export async function notify(sceneIds) {
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const alerts = rawAlerts.map((alert) => ({
|
||||
id: alert.id,
|
||||
userId: alert.user_id,
|
||||
notify: alert.notify,
|
||||
email: alert.email,
|
||||
all: alert.all,
|
||||
allActors: alert.all_actors,
|
||||
allEntities: alert.all_entities,
|
||||
allTags: alert.all_tags,
|
||||
allMatches: alert.all_matches,
|
||||
actors: alertsActorsByAlertId[alert.id] || [],
|
||||
tags: alertsTagsByAlertId[alert.id] || [],
|
||||
entities: alertsEntitiesByAlertId[alert.id] || [],
|
||||
matches: alertsMatchesByAlertId[alert.id] || [],
|
||||
stashes: alertsStashesByAlertId[alert.id] || [],
|
||||
}));
|
||||
const alerts = rawAlerts
|
||||
.map((alert) => ({
|
||||
id: alert.id,
|
||||
userId: alert.user_id,
|
||||
username: alert.username,
|
||||
userEmail: alert.user_email,
|
||||
userSettings: alert.user_settings,
|
||||
notify: alert.notify,
|
||||
email: alert.email,
|
||||
all: alert.all,
|
||||
allActors: alert.all_actors,
|
||||
allEntities: alert.all_entities,
|
||||
allTags: alert.all_tags,
|
||||
allMatches: alert.all_matches,
|
||||
actorIds: alertActorIdsByAlertId[alert.id] || [],
|
||||
tagIds: alertTagIdsByAlertId[alert.id] || [],
|
||||
entityIds: alertEntityIdsByAlertId[alert.id] || [],
|
||||
matches: alertsMatchesByAlertId[alert.id] || [],
|
||||
stashIds: alertsStashesByAlertId[alert.id] || [],
|
||||
}))
|
||||
.filter((alert) => options.spoofUserIds ? options.spoofUserIds.includes(alert.userId) : true);
|
||||
|
||||
const curatedScenes = scenes
|
||||
.filter((scene) => scene.isNew)
|
||||
.filter((scene) => scene.isNew || options.spoofNew)
|
||||
.toSorted((sceneA, sceneB) => sceneB.date - sceneA.date)
|
||||
.map((scene) => ({
|
||||
id: scene.id,
|
||||
title: scene.title,
|
||||
description: scene.description,
|
||||
actorIds: actorIdsByReleaseId[scene.id] || [],
|
||||
tagIds: tagIdsByReleaseId[scene.id] || [],
|
||||
actorIds: scene.actors.map((actor) => actor.id),
|
||||
tagIds: scene.tags.map((tag) => tag.id),
|
||||
entityId: scene.channel?.id || scene.network?.id || null,
|
||||
parentEntityId: scene.network?.id,
|
||||
}));
|
||||
@@ -481,24 +634,24 @@ export async function notify(sceneIds) {
|
||||
const triggers = alerts.flatMap((alert) => {
|
||||
const alertScenes = curatedScenes.filter((scene) => {
|
||||
if (alert.all) {
|
||||
if (alert.actors.length === 0
|
||||
&& alert.tags.length === 0
|
||||
&& alert.entities.length === 0
|
||||
if (alert.actorIds.length === 0
|
||||
&& alert.tagIds.length === 0
|
||||
&& alert.entityIds.length === 0
|
||||
&& alert.matches.length === 0) {
|
||||
// we must match on *something*, this is likely a release date alert handled separately
|
||||
return false;
|
||||
}
|
||||
|
||||
if (alert.actors.length > 0 && !alert.actors[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
|
||||
if (alert.actorIds.length > 0 && !alert.actorIds[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (alert.tags.length > 0 && !alert.tags[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
|
||||
if (alert.tagIds.length > 0 && !alert.tagIds[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// multiple entities can only be matched in OR mode
|
||||
if (alert.entities.length > 0 && !alert.entities.some((alertEntityId) => alertEntityId === scene.entityId || alertEntityId === scene.parentEntityId)) {
|
||||
if (alert.entityIds.length > 0 && !alert.entityIds.some((entityId) => entityId === scene.entityId || entityId === scene.parentEntityId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -509,16 +662,16 @@ export async function notify(sceneIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.matches.length > 0 && alert.actors[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
|
||||
if (alert.matches.length > 0 && alert.actorIds[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (alert.tags.length > 0 && alert.tags[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
|
||||
if (alert.tagIds.length > 0 && alert.tagIds[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// multiple entities can only be matched in OR mode
|
||||
if (alert.entities.length > 0 && alert.entities.some((alertEntityId) => alertEntityId === scene.entityId || alertEntityId === scene.parentEntityId)) {
|
||||
if (alert.entityIds.length > 0 && alert.entityIds.some((entityId) => entityId === scene.entityId || entityId === scene.parentEntityId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -535,16 +688,40 @@ export async function notify(sceneIds) {
|
||||
}));
|
||||
});
|
||||
|
||||
const notifications = Object.values(Object.fromEntries(triggers // prevent multiple notifications for the same scene
|
||||
.filter((trigger) => trigger.alert.notify)
|
||||
.map((trigger) => [`${trigger.alert.userId}:${trigger.sceneId}`, trigger])))
|
||||
// e-mails are stored as notifications to facilitate the e-mail frequency buffer
|
||||
const notifications = Object.values(triggers // prevent multiple notifications for the same scene
|
||||
.map((trigger) => ({
|
||||
...trigger,
|
||||
notify: trigger.alert.notify && trigger.alert.userSettings?.alertNotificationsEnabled !== false,
|
||||
email: trigger.alert.email && trigger.alert.userEmail && trigger.alert.userSettings?.alertEmailsEnabled !== false,
|
||||
}))
|
||||
.filter((trigger) => trigger.notify || trigger.email)
|
||||
.reduce((acc, trigger) => {
|
||||
const key = `${trigger.alert.userId}:${trigger.sceneId}`;
|
||||
|
||||
acc[key] = {
|
||||
...trigger,
|
||||
notify: acc[key]?.notify || trigger.notify,
|
||||
email: acc[key]?.email || trigger.email,
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {}))
|
||||
.map((trigger) => ({
|
||||
user_id: trigger.alert.userId,
|
||||
alert_id: trigger.alert.id,
|
||||
scene_id: trigger.sceneId,
|
||||
// we assign the alert outputs to this specific notification, instead of joining them from the alerts table
|
||||
// this ensures changing the alert outputs later doesn't trigger a barrage of e-mails from old notifications
|
||||
notify: trigger.notify,
|
||||
email: trigger.email,
|
||||
}));
|
||||
|
||||
const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashes.map((stashId) => ({
|
||||
if (notifications.length === 0) {
|
||||
logger.verbose(`No alert notifications triggered`);
|
||||
}
|
||||
|
||||
const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashIds.map((stashId) => ({
|
||||
stashId,
|
||||
sceneId: trigger.sceneId,
|
||||
userId: trigger.alert.userId,
|
||||
@@ -577,7 +754,7 @@ export async function notify(sceneIds) {
|
||||
|
||||
const docs = stashedEntries.map((stash) => ({
|
||||
replace: {
|
||||
index: 'scenes_stashed',
|
||||
table: 'scenes_stashed',
|
||||
id: stash.id,
|
||||
doc: {
|
||||
scene_id: stash.scene_id,
|
||||
@@ -593,14 +770,15 @@ export async function notify(sceneIds) {
|
||||
}
|
||||
}
|
||||
|
||||
await sendEmailAlerts();
|
||||
|
||||
return triggers;
|
||||
}
|
||||
|
||||
async function alertSceneReleases() {
|
||||
const alerts = await knex('alerts_scenes')
|
||||
.select(
|
||||
'alerts.id',
|
||||
'alerts.user_id',
|
||||
'alerts.*',
|
||||
'releases.id as scene_id',
|
||||
'releases.title as scene_title',
|
||||
'releases.slug as scene_slug',
|
||||
@@ -623,6 +801,8 @@ async function alertSceneReleases() {
|
||||
user_id: alert.user_id,
|
||||
scene_id: alert.scene_id,
|
||||
alert_id: alert.id,
|
||||
notify: alert.notify,
|
||||
email: alert.email,
|
||||
})));
|
||||
|
||||
await trx('alerts')
|
||||
@@ -630,7 +810,13 @@ async function alertSceneReleases() {
|
||||
.update('is_expired', true);
|
||||
|
||||
await trx.commit();
|
||||
await knex.schema.refreshMaterializedView('alerts_users_scenes');
|
||||
|
||||
await Promise.all([
|
||||
knex.schema.refreshMaterializedView('alerts_users_actors'),
|
||||
knex.schema.refreshMaterializedView('alerts_users_tags'),
|
||||
knex.schema.refreshMaterializedView('alerts_users_entities'),
|
||||
knex.schema.refreshMaterializedView('alerts_users_scenes'),
|
||||
]);
|
||||
}
|
||||
catch (error) {
|
||||
await trx.rollback();
|
||||
@@ -660,7 +846,12 @@ async function purgeExpiredAlerts() {
|
||||
)
|
||||
.delete();
|
||||
|
||||
await knex.schema.refreshMaterializedView('alerts_users_scenes');
|
||||
await Promise.all([
|
||||
knex.schema.refreshMaterializedView('alerts_users_actors'),
|
||||
knex.schema.refreshMaterializedView('alerts_users_tags'),
|
||||
knex.schema.refreshMaterializedView('alerts_users_entities'),
|
||||
knex.schema.refreshMaterializedView('alerts_users_scenes'),
|
||||
]);
|
||||
|
||||
logger.info(`Purged ${purgedNotifications} notifications and ${purgedAlerts} alerts`);
|
||||
}
|
||||
@@ -673,6 +864,9 @@ export function initAlertCron() {
|
||||
alertSceneReleases(),
|
||||
purgeExpiredAlerts(),
|
||||
]);
|
||||
|
||||
// allow release alerts to set e-mail notifications
|
||||
await sendEmailAlerts();
|
||||
},
|
||||
start: config.alerts.enabled,
|
||||
runOnInit: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initAlertCron } from './alerts.js';
|
||||
import { initCaches } from './cache.js';
|
||||
import { initStashViewsCron } from './stashes.js';
|
||||
import { initSyncCron } from './sync.js';
|
||||
import initServer from './web/server.js';
|
||||
|
||||
@@ -9,6 +10,7 @@ async function init() {
|
||||
initServer();
|
||||
initSyncCron();
|
||||
initAlertCron();
|
||||
initStashViewsCron();
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
500
src/auth.js
@@ -1,3 +1,4 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import util from 'node:util';
|
||||
@@ -5,9 +6,10 @@ import { shapes } from '@dicebear/collection';
|
||||
import { createAvatar } from '@dicebear/core';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import config from 'config';
|
||||
import { verify } from 'hcaptcha';
|
||||
import { verify as verifyCaptcha } from 'hcaptcha';
|
||||
|
||||
import slugify from '../utils/slugify.js';
|
||||
import { sendEmails } from './email.js';
|
||||
import { HttpError } from './errors.js';
|
||||
import { knexOwner as knex } from './knex.js';
|
||||
import initLogger, { initAccessLogger } from './logger.js';
|
||||
@@ -44,19 +46,71 @@ async function generateAvatar(user) {
|
||||
logger.verbose(`Generated avatar for '${user.username}' (${user.id})`);
|
||||
}
|
||||
|
||||
async function checkLoginCooldown(identifier, attemptThreshold) {
|
||||
if (!identifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
|
||||
const attempts = JSON.parse(await redis.get(cooldownKey) || '0');
|
||||
|
||||
if (attempts >= attemptThreshold) {
|
||||
throw new HttpError(`Please wait a few minutes before attempting another login`, 409);
|
||||
}
|
||||
}
|
||||
|
||||
async function setLoginCooldown(identifier, attemptCooldown) {
|
||||
if (!identifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
|
||||
|
||||
await redis.incr(cooldownKey);
|
||||
await redis.expire(cooldownKey, attemptCooldown * 60);
|
||||
}
|
||||
|
||||
export async function login(credentials, userIp) {
|
||||
if (!config.auth.login) {
|
||||
throw new HttpError('Logins are currently disabled', 405);
|
||||
throw new HttpError('Logins are currently disabled', 503);
|
||||
}
|
||||
|
||||
if (!credentials.username) {
|
||||
throw new HttpError('Please provide a username or e-mail', 400);
|
||||
}
|
||||
|
||||
if (!credentials.password) {
|
||||
throw new HttpError('Please provide a password', 400);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
checkLoginCooldown(credentials.username, config.auth.loginCredentialAttempts),
|
||||
checkLoginCooldown(userIp, config.auth.loginIpAttempts),
|
||||
]);
|
||||
|
||||
const { user } = await fetchUser(credentials.username.trim(), {
|
||||
email: true,
|
||||
raw: true,
|
||||
}).catch(() => {
|
||||
}).catch(async () => {
|
||||
await Promise.all([
|
||||
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
|
||||
setLoginCooldown(userIp, config.auth.loginIpCooldown),
|
||||
]);
|
||||
|
||||
throw new HttpError('Username or password incorrect', 401);
|
||||
});
|
||||
|
||||
await verifyPassword(credentials.password, user.password);
|
||||
try {
|
||||
await verifyPassword(credentials.password, user.password);
|
||||
}
|
||||
catch (error) {
|
||||
await Promise.all([
|
||||
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
|
||||
setLoginCooldown(userIp, config.auth.loginIpCooldown),
|
||||
]);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
await knex('users')
|
||||
.update('last_login', 'NOW()')
|
||||
@@ -73,12 +127,281 @@ export async function login(credentials, userIp) {
|
||||
}
|
||||
|
||||
// fetched the raw user for password verification, don't return directly to user
|
||||
return curateUser(user);
|
||||
return curateUser(user, user);
|
||||
}
|
||||
|
||||
async function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hashedPassword = (await scrypt(password, salt, 64)).toString('hex');
|
||||
const storedPassword = `${salt}/${hashedPassword}`;
|
||||
|
||||
return {
|
||||
storedPassword,
|
||||
hashedPassword,
|
||||
salt,
|
||||
};
|
||||
}
|
||||
|
||||
async function tokenCooldown(identifier) {
|
||||
const cooldownKey = `traxxx:token_cooldown:${slugify(identifier, '_')}`;
|
||||
|
||||
if (await redis.exists(cooldownKey)) {
|
||||
throw new HttpError(`Please wait a few minutes before requesting another token`, 409);
|
||||
}
|
||||
|
||||
await redis.set(cooldownKey, JSON.stringify(true));
|
||||
await redis.expire(cooldownKey, config.auth.tokenCooldown * 60);
|
||||
}
|
||||
|
||||
export async function sendEmailVerification(user, customEmail, trigger) {
|
||||
if (!config.email.enabled) {
|
||||
throw new HttpError('E-mail verification is disabled', 503);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new HttpError('No user provided', 401);
|
||||
}
|
||||
|
||||
const email = customEmail || user.email;
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError('No e-mail address provided', 400);
|
||||
}
|
||||
|
||||
await tokenCooldown(email);
|
||||
|
||||
const tokenKey = `traxxx:token_email:${user.id}`;
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const tokenData = {
|
||||
email,
|
||||
token,
|
||||
};
|
||||
|
||||
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||
await redis.expire(tokenKey, config.auth.emailTokenExpiry * 60);
|
||||
|
||||
const verifyUrl = `${config.web.address}/verify/email?${new URLSearchParams({
|
||||
userId: user.id,
|
||||
token,
|
||||
}).toString()}`;
|
||||
|
||||
logger.info(`E-mail verification sent to ${user.username} (${user.id}): ${verifyUrl}`);
|
||||
|
||||
await sendEmails([{
|
||||
template: 'verify',
|
||||
to: email,
|
||||
subject: 'Please verify your traxxx e-mail address',
|
||||
props: {
|
||||
user,
|
||||
verifyUrl,
|
||||
trigger,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function verifyEmail(userId, emailToken) {
|
||||
if (!userId) {
|
||||
throw new HttpError('User ID not provided', 400);
|
||||
}
|
||||
|
||||
if (!emailToken) {
|
||||
throw new HttpError('Token not provided', 400);
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_email:${userId}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
if (!tokenEntry) {
|
||||
throw new HttpError('Invalid verification link', 401);
|
||||
}
|
||||
|
||||
const { token, email } = JSON.parse(tokenEntry);
|
||||
|
||||
if (!token
|
||||
|| !email
|
||||
|| emailToken.length !== token.length
|
||||
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
|
||||
throw new HttpError('Invalid verification link', 401);
|
||||
}
|
||||
|
||||
const updated = await knex('users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
email,
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
if (updated === 0) {
|
||||
throw new HttpError('E-mail verification failed because the user could not be found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPasswordReset(credential) {
|
||||
if (!config.email.enabled) {
|
||||
throw new HttpError('Password reset is disabled', 503);
|
||||
}
|
||||
|
||||
if (!credential) {
|
||||
throw new HttpError('No credential provided', 400);
|
||||
}
|
||||
|
||||
await tokenCooldown(credential);
|
||||
|
||||
const user = await fetchUser(credential, {
|
||||
email: true,
|
||||
includePrivate: true,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
// we don't throw errors because the requesting user shouldn't know whether the account exists or not
|
||||
if (!user) {
|
||||
logger.info(`Password reset requested for unknown user '${credential}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
logger.info(`Password reset requested for '${credential}', but user '${user.username}' (${user.id}) has no e-mail address known`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_password:${user.id}`;
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const tokenData = {
|
||||
token,
|
||||
};
|
||||
|
||||
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||
await redis.expire(tokenKey, config.auth.passwordTokenExpiry * 60);
|
||||
|
||||
const verifyUrl = `${config.web.address}/recovery?${new URLSearchParams({
|
||||
userId: user.id,
|
||||
token,
|
||||
}).toString()}`;
|
||||
|
||||
logger.info(`Password reset sent to ${user.username} (${user.id}): ${verifyUrl}`);
|
||||
|
||||
await sendEmails([{
|
||||
template: 'recovery',
|
||||
to: user.email,
|
||||
subject: 'Reset your traxxx password',
|
||||
props: {
|
||||
user,
|
||||
verifyUrl,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function resetPassword(userId, emailToken, newPassword) {
|
||||
if (!userId) {
|
||||
throw new HttpError('User ID not provided', 400);
|
||||
}
|
||||
|
||||
if (!emailToken) {
|
||||
throw new HttpError('Token not provided', 400);
|
||||
}
|
||||
|
||||
if (!newPassword) {
|
||||
throw new HttpError('New password not provided', 400);
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_password:${userId}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
if (!tokenEntry) {
|
||||
throw new HttpError('Invalid recovery link', 401);
|
||||
}
|
||||
|
||||
const { token } = JSON.parse(tokenEntry);
|
||||
|
||||
if (!token
|
||||
|| emailToken.length !== token.length
|
||||
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
|
||||
throw new HttpError('Invalid recovery link', 401);
|
||||
}
|
||||
|
||||
const { storedPassword } = await hashPassword(newPassword);
|
||||
|
||||
const updated = await knex('users')
|
||||
.where('id', userId)
|
||||
.update('password', storedPassword);
|
||||
|
||||
if (updated === 0) {
|
||||
throw new HttpError('Password reset failed because the user could not be found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSignup(email) {
|
||||
if (!config.auth.signup || !config.email.enabled) {
|
||||
throw new HttpError('Sign-ups are disabled', 503);
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError('No e-mail address provided', 400);
|
||||
}
|
||||
|
||||
await tokenCooldown(email);
|
||||
|
||||
const user = await fetchUser(email, {
|
||||
email: true,
|
||||
includePrivate: true,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
await sendEmails([{
|
||||
template: 'unavailable',
|
||||
to: email,
|
||||
subject: 'Someone tried to use your e-mail address for traxxx',
|
||||
props: {
|
||||
user,
|
||||
},
|
||||
}]);
|
||||
|
||||
logger.info(`Duplicate signup with '${email}' attempted, user e-mailed`);
|
||||
|
||||
// we don't throw an error because the requesting user shouldn't know whether the account exists or not
|
||||
return;
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const tokenKey = `traxxx:token_signup:${token}`;
|
||||
|
||||
const tokenData = {
|
||||
email,
|
||||
};
|
||||
|
||||
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||
await redis.expire(tokenKey, config.auth.signupTokenExpiry * 60);
|
||||
|
||||
const verifyUrl = `${config.web.address}/signup?${new URLSearchParams({
|
||||
token,
|
||||
}).toString()}`;
|
||||
|
||||
logger.info(`Signup e-mail sent to ${email}: ${verifyUrl}`);
|
||||
|
||||
await sendEmails([{
|
||||
template: 'signup',
|
||||
to: email,
|
||||
subject: 'Sign up for traxxx',
|
||||
props: {
|
||||
verifyUrl,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function signup(credentials, userIp) {
|
||||
if (!config.auth.signup) {
|
||||
throw new HttpError('Sign-ups are currently disabled', 405);
|
||||
throw new HttpError('Sign-ups are currently disabled', 503);
|
||||
}
|
||||
|
||||
if (!credentials.token) {
|
||||
throw new HttpError('Token required', 400);
|
||||
}
|
||||
|
||||
if (credentials.password?.length < 3) {
|
||||
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||
}
|
||||
|
||||
const curatedUsername = credentials.username.trim();
|
||||
@@ -99,41 +422,44 @@ export async function signup(credentials, userIp) {
|
||||
throw new HttpError('Username contains invalid characters', 400);
|
||||
}
|
||||
|
||||
if (!credentials.email) {
|
||||
throw new HttpError('E-mail required', 400);
|
||||
}
|
||||
const existingUser = await knex('users')
|
||||
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
|
||||
.first();
|
||||
|
||||
if (credentials.password?.length < 3) {
|
||||
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||
if (existingUser) {
|
||||
throw new HttpError('Username already in use', 409);
|
||||
}
|
||||
|
||||
if (config.auth.captcha.enabled) {
|
||||
const captchaVerification = await verify(config.auth.captcha.secretKey, credentials.captcha);
|
||||
const captchaVerification = await verifyCaptcha(config.auth.captcha.secretKey, credentials.captcha);
|
||||
|
||||
if (!captchaVerification.success) {
|
||||
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${credentials.email}, ${userIp})`);
|
||||
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${userIp})`);
|
||||
throw new HttpError('Invalid CAPTCHA', 400);
|
||||
}
|
||||
}
|
||||
|
||||
const existingUser = await knex('users')
|
||||
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
|
||||
.orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase())
|
||||
.first();
|
||||
const tokenKey = `traxxx:token_signup:${credentials.token}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
if (existingUser) {
|
||||
throw new HttpError('Username or e-mail already in use', 409);
|
||||
if (!tokenEntry) {
|
||||
throw new HttpError('Invalid sign-up link', 401);
|
||||
}
|
||||
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hashedPassword = (await scrypt(credentials.password, salt, 64)).toString('hex');
|
||||
const storedPassword = `${salt}/${hashedPassword}`;
|
||||
const { email } = JSON.parse(tokenEntry);
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError('Invalid sign-up link', 401);
|
||||
}
|
||||
|
||||
const { storedPassword } = await hashPassword(credentials.password);
|
||||
|
||||
const [{ id: userId }] = await knex('users')
|
||||
.insert({
|
||||
username: curatedUsername,
|
||||
email: credentials.email,
|
||||
email,
|
||||
password: storedPassword,
|
||||
email_verified: true,
|
||||
})
|
||||
.returning('id');
|
||||
|
||||
@@ -145,14 +471,138 @@ export async function signup(credentials, userIp) {
|
||||
primary: true,
|
||||
});
|
||||
|
||||
logger.info(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`);
|
||||
logger.info(`Signup from '${curatedUsername}' (${userId}, ${email}, ${userIp})`);
|
||||
|
||||
await generateAvatar({
|
||||
id: userId,
|
||||
username: curatedUsername,
|
||||
});
|
||||
|
||||
return fetchUser(userId);
|
||||
const user = await fetchUser(userId, {
|
||||
includePrivate: true,
|
||||
});
|
||||
|
||||
// no longer needed with token-first signup flow
|
||||
// await sendEmailVerification(user, null, 'signup');
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function updatePassword(password, reqUser, reqSession) {
|
||||
const now = new Date();
|
||||
const { storedPassword } = await hashPassword(password);
|
||||
|
||||
await knex('users')
|
||||
.where('id', reqUser.id)
|
||||
.update('password', storedPassword);
|
||||
|
||||
const country = reqSession.geo?.country && await knex('countries').where('alpha2', reqSession.geo.country).first();
|
||||
|
||||
await sendEmails([{
|
||||
template: 'password',
|
||||
to: reqUser.email,
|
||||
subject: 'Your traxxx password has been changed',
|
||||
props: {
|
||||
user: reqUser,
|
||||
ip: reqUser.ip,
|
||||
country: country?.name,
|
||||
timestamp: now,
|
||||
},
|
||||
}]);
|
||||
|
||||
logger.info(`Password changed by ${reqUser.username} (${reqUser.id}, ${reqUser.ip})`);
|
||||
}
|
||||
|
||||
async function updateEmail(email, reqUser) {
|
||||
const user = await fetchUser(email, {
|
||||
email: true,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
await sendEmails([{
|
||||
template: 'unavailable',
|
||||
to: email,
|
||||
subject: 'Someone tried to use your e-mail address for traxxx',
|
||||
props: {
|
||||
user, // not reqUser, we don't want to leak details about the requester
|
||||
},
|
||||
}]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEmailVerification(reqUser, email, 'change');
|
||||
}
|
||||
|
||||
export async function updateUser({ credentials, password }, reqUser, reqSession) {
|
||||
if (!reqUser) {
|
||||
throw new HttpError('You are not logged in', 401);
|
||||
}
|
||||
|
||||
if (!credentials?.email && !credentials.password) {
|
||||
throw new HttpError('No changeable credentials provided', 400);
|
||||
}
|
||||
|
||||
if (credentials.email && !config.email.enabled) {
|
||||
throw new HttpError('E-mail is currently disabled', 503);
|
||||
}
|
||||
|
||||
if (credentials.password && credentials.password?.length < 3) {
|
||||
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
throw new HttpError('No current password provided', 401);
|
||||
}
|
||||
|
||||
const { user } = await fetchUser(reqUser.id, {
|
||||
email: true,
|
||||
raw: true,
|
||||
}).catch(() => {
|
||||
throw new HttpError('Username or password incorrect', 401);
|
||||
});
|
||||
|
||||
await verifyPassword(password, user.password);
|
||||
|
||||
if (credentials.password) {
|
||||
await updatePassword(credentials.password, reqUser, reqSession);
|
||||
}
|
||||
|
||||
if (credentials.email) {
|
||||
await updateEmail(credentials.email, reqUser);
|
||||
}
|
||||
|
||||
return curateUser(user, reqUser);
|
||||
}
|
||||
|
||||
const validSettingKeys = [
|
||||
'alertNotificationsEnabled',
|
||||
'alertEmailsEnabled',
|
||||
'alertEmailsFrequency',
|
||||
];
|
||||
|
||||
export async function updateSettings(settings, reqUser) {
|
||||
if (!reqUser) {
|
||||
throw new HttpError('You need to be authenticated to change your settings', 401);
|
||||
}
|
||||
|
||||
const invalidKeys = Object.keys(settings).filter((key) => !validSettingKeys.includes(key));
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
throw new HttpError(`Invalid settings: ${invalidKeys.join()}`, 400);
|
||||
}
|
||||
|
||||
if (settings.alertEmailsFrequency && !['instant', 'daily', 'weekly', 'monthly'].includes(settings.alertEmailsFrequency)) {
|
||||
throw new HttpError('Invalid email frequency setting', 400);
|
||||
}
|
||||
|
||||
const [{ settings: updatedSettings }] = await knex('users')
|
||||
.where('id', reqUser.id)
|
||||
.update('settings', knex.raw('coalesce(settings, \'{}\'::jsonb) || ?::jsonb', [JSON.stringify(settings)]))
|
||||
.returning('settings');
|
||||
|
||||
return updatedSettings;
|
||||
}
|
||||
|
||||
function curateKey(key) {
|
||||
|
||||
15
src/email-templates.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { config as configureEmailTemplates } from '@vue-email/compiler';
|
||||
|
||||
export const emailsDir = path.join(process.cwd(), 'components/emails');
|
||||
|
||||
export const emailTemplates = configureEmailTemplates(emailsDir, { verbose: false });
|
||||
|
||||
export function listEmailTemplates() {
|
||||
return readdirSync(emailsDir)
|
||||
.filter((file) => file.endsWith('.vue'))
|
||||
.map((file) => file.replace(/\.vue$/, ''));
|
||||
}
|
||||
84
src/email.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import config from 'config';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
import { emailTemplates } from './email-templates.js';
|
||||
import initLogger from './logger.js';
|
||||
|
||||
const logger = initLogger();
|
||||
|
||||
const resend = (() => {
|
||||
if (config.email.enabled) {
|
||||
try {
|
||||
return new Resend(config.email.apiKey);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(`Failed to initialize Resend: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})();
|
||||
|
||||
export async function renderEmail(template, { to, subject, props }) {
|
||||
const { html, text } = await emailTemplates.render(`${template}.vue`, {
|
||||
props: {
|
||||
...props,
|
||||
address: config.web.address,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
from: config.email.from,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
// always batches, even for a single item: Resend's batch endpoint accepts a batch of one,
|
||||
// and passing batchValidation: 'permissive' means one bad recipient among many doesn't
|
||||
// sink the rest of the batch (the default 'strict' mode fails the whole call instead)
|
||||
export async function sendEmails(items) {
|
||||
if (!config.email.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!resend) {
|
||||
logger.warn('E-mails are enabled, but Resend is not initialized');
|
||||
return null;
|
||||
}
|
||||
|
||||
const rendered = await Promise.all(items.map(async (item) => {
|
||||
try {
|
||||
return await renderEmail(item.template, item);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(`Failed to render '${item.template}' e-mail for '${item.to}': ${error.message} — ${JSON.stringify(item.props)}`);
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
const sentItems = items.filter((item, index) => rendered[index]);
|
||||
const emails = rendered.filter(Boolean);
|
||||
|
||||
if (emails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data, error } = await resend.batch.send(emails, { batchValidation: 'permissive' });
|
||||
|
||||
if (error) {
|
||||
logger.error(`Failed to send ${emails.length} e-mail(s) (${error.statusCode} ${error.name}): ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
data.errors?.forEach(({ index, message }) => {
|
||||
const item = sentItems[index];
|
||||
logger.error(`Failed to send '${item.template}' e-mail to '${item.to}': ${message} — ${JSON.stringify(item.props)}`);
|
||||
});
|
||||
|
||||
logger.info(`Sent ${emails.length - (data.errors?.length || 0)}/${items.length} e-mail(s)`);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { pageContext } from '../renderer/usePageContext.js';
|
||||
|
||||
function getBasePath(media, options) {
|
||||
if (pageContext.restriction) {
|
||||
if (pageContext.geo.restriction) {
|
||||
return pageContext.env.media.assetPath;
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ function getBasePath(media, options) {
|
||||
}
|
||||
|
||||
function getFilename(media, type, options) {
|
||||
if (pageContext.restriction && type && !options?.original) {
|
||||
if (pageContext.geo.restriction && type && !options?.original) {
|
||||
return media.sfw?.[type];
|
||||
}
|
||||
|
||||
if (pageContext.restriction) {
|
||||
if (pageContext.geo.restriction) {
|
||||
return media.sfw?.path;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,10 +25,12 @@ export function curateStash(stash, assets = {}) {
|
||||
slug: stash.slug,
|
||||
isPrimary: stash.primary,
|
||||
isPublic: stash.public,
|
||||
isArchived: stash.archived,
|
||||
createdAt: stash.created_at,
|
||||
stashedScenes: stash.stashed_scenes ?? null,
|
||||
stashedMovies: stash.stashed_movies ?? null,
|
||||
stashedActors: stash.stashed_actors ?? null,
|
||||
lastStashAt: stash.last_stash_at,
|
||||
user: assets.user
|
||||
? {
|
||||
id: assets.user.id,
|
||||
@@ -64,7 +66,8 @@ function curateStashEntry(stash, user) {
|
||||
user_id: user?.id || undefined,
|
||||
name: stash.name || undefined,
|
||||
slug: slugify(stash.name) || undefined,
|
||||
public: stash.isPublic ?? false,
|
||||
public: stash.isPublic ?? undefined,
|
||||
archived: stash.isArchived ?? undefined,
|
||||
comment: stash.comment,
|
||||
meta: stash.meta,
|
||||
};
|
||||
@@ -126,6 +129,10 @@ export async function fetchStashByUsernameAndSlug(usernameOrId, stashSlug, sessi
|
||||
}
|
||||
|
||||
export async function fetchUserStashes(usernameOrId, reqUser) {
|
||||
if (!usernameOrId) {
|
||||
throw new HttpError(`No username or user ID provided`, 400);
|
||||
}
|
||||
|
||||
const userId = typeof usernameOrId === 'number'
|
||||
? usernameOrId
|
||||
: await knex('users')
|
||||
@@ -134,7 +141,7 @@ export async function fetchUserStashes(usernameOrId, reqUser) {
|
||||
.then((user) => user?.id);
|
||||
|
||||
if (!userId) {
|
||||
throw new HttpError(`Could not find user '${usernameOrId}'`);
|
||||
throw new HttpError(`Could not find user '${usernameOrId}'`, 404);
|
||||
}
|
||||
|
||||
const stashes = await knex('stashes')
|
||||
@@ -146,7 +153,12 @@ export async function fetchUserStashes(usernameOrId, reqUser) {
|
||||
if (userId !== reqUser?.id) {
|
||||
builder.where('public', true);
|
||||
}
|
||||
});
|
||||
})
|
||||
.orderBy([
|
||||
{ column: 'stashes.primary', order: 'desc' },
|
||||
{ column: 'stashes_meta.last_stash_at', order: 'desc' },
|
||||
{ column: 'stashes.name', order: 'asc' },
|
||||
]);
|
||||
|
||||
return stashes.map((stash) => curateStash(stash, { user: reqUser }));
|
||||
}
|
||||
@@ -602,16 +614,18 @@ export async function unstashMovie(movieId, stashId, sessionUser) {
|
||||
return curateStashed(unstashed);
|
||||
}
|
||||
|
||||
CronJob.from({
|
||||
cronTime: config.stashes.viewRefreshCrontab,
|
||||
async onTick() {
|
||||
logger.verbose('Updating stash views');
|
||||
export function initStashViewsCron() {
|
||||
CronJob.from({
|
||||
cronTime: config.stashes.viewRefreshCrontab,
|
||||
async onTick() {
|
||||
logger.verbose('Updating stash views');
|
||||
|
||||
await refreshView('scenes');
|
||||
await refreshView('actors');
|
||||
await refreshView('movies');
|
||||
await refreshView('stashes');
|
||||
},
|
||||
start: true,
|
||||
runOnInit: true,
|
||||
});
|
||||
await refreshView('scenes');
|
||||
await refreshView('actors');
|
||||
await refreshView('movies');
|
||||
await refreshView('stashes');
|
||||
},
|
||||
start: true,
|
||||
runOnInit: true,
|
||||
});
|
||||
}
|
||||
|
||||
27
src/users.js
@@ -14,23 +14,28 @@ function curateTemplate(template) {
|
||||
};
|
||||
}
|
||||
|
||||
export function curateUser(user, _assets = {}) {
|
||||
export function curateUser(user, reqUser, options = {}) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// const curatedStashes = assets.stashes?.filter(Boolean).map((stash) => curateStash(stash)) || [];
|
||||
|
||||
const privateUser = {
|
||||
email: user.email,
|
||||
settings: user.settings,
|
||||
};
|
||||
|
||||
const curatedUser = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isEmailVerified: user.email_verified,
|
||||
isIdentityVerified: user.identity_verified,
|
||||
avatar: `/media/avatars/${user.id}_${user.username}.png`,
|
||||
role: user.role,
|
||||
abilities: [...user.role_abilities || [], ...user.abilities || []],
|
||||
createdAt: user.created_at,
|
||||
...(reqUser?.id === user.id || options.includePrivate ? privateUser : null),
|
||||
};
|
||||
|
||||
return curatedUser;
|
||||
@@ -50,7 +55,15 @@ function whereUser(builder, userId, options = {}) {
|
||||
builder.where('users.id', Number(userId));
|
||||
}
|
||||
|
||||
export async function fetchUser(userId, options = {}, _reqUser) {
|
||||
export async function fetchUser(userId, options = {}, reqUser) {
|
||||
if (!userId) {
|
||||
if (options.raw) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await knex('users')
|
||||
.select(knex.raw('users.*, users_roles.abilities as role_abilities'))
|
||||
.modify((builder) => whereUser(builder, userId, options))
|
||||
@@ -58,17 +71,15 @@ export async function fetchUser(userId, options = {}, _reqUser) {
|
||||
.groupBy('users.id', 'users_roles.role')
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
if (!user && options.throwError !== false) {
|
||||
throw new HttpError(`User '${userId}' not found`, 404);
|
||||
}
|
||||
|
||||
if (options.raw) {
|
||||
// return { user, stashes, templates };
|
||||
return { user };
|
||||
}
|
||||
|
||||
// return curateUser(user, { stashes, templates });
|
||||
return curateUser(user, {});
|
||||
return curateUser(user, reqUser, options);
|
||||
}
|
||||
|
||||
export async function fetchUserTemplates(reqUser) {
|
||||
@@ -116,8 +127,6 @@ export async function removeTemplate(templateId, reqUser) {
|
||||
}
|
||||
|
||||
export async function createBan(ban, reqUser) {
|
||||
console.log(ban);
|
||||
|
||||
if (reqUser.role !== 'admin') {
|
||||
throw new HttpError('You do not have sufficient privileges to set a ban', 403);
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ export async function createActorApi(req, res) {
|
||||
export async function mergeActorsApi(req, res) {
|
||||
const result = await mergeActors(
|
||||
Number(req.params.targetActorId),
|
||||
req.params.sourceActorIds.split(',').map((actorId) => Number(actorId)),
|
||||
req.body.actorIds,
|
||||
req.body.comment,
|
||||
req.user,
|
||||
);
|
||||
@@ -225,7 +225,7 @@ export const actorsRouter = Router();
|
||||
actorsRouter.get('/api/actors', fetchActorsApi);
|
||||
actorsRouter.post('/api/actors', createActorApi);
|
||||
|
||||
actorsRouter.post('/api/actors/:targetActorId/merge/:sourceActorIds', mergeActorsApi);
|
||||
actorsRouter.post('/api/actors/:targetActorId/merge', mergeActorsApi);
|
||||
|
||||
actorsRouter.get('/api/revisions/actors', fetchActorRevisionsApi);
|
||||
actorsRouter.get('/api/revisions/actors/:revisionId', fetchActorRevisionsApi);
|
||||
|
||||
@@ -219,12 +219,12 @@ export async function updateNotificationGraphql(query, req) {
|
||||
return updatedNotification;
|
||||
}
|
||||
|
||||
export const router = Router();
|
||||
export const alertsRouter = Router();
|
||||
|
||||
router.get('/api/alerts', fetchAlertsApi);
|
||||
router.post('/api/alerts', createAlertApi);
|
||||
router.delete('/api/alerts/:alertId', removeAlertApi);
|
||||
alertsRouter.get('/api/alerts', fetchAlertsApi);
|
||||
alertsRouter.post('/api/alerts', createAlertApi);
|
||||
alertsRouter.delete('/api/alerts/:alertId', removeAlertApi);
|
||||
|
||||
router.get('/api/users/:userId/notifications', fetchNotificationsApi);
|
||||
router.patch('/api/users/:userId/notifications', updateNotificationsApi);
|
||||
router.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);
|
||||
alertsRouter.get('/api/users/:userId/notifications', fetchNotificationsApi);
|
||||
alertsRouter.patch('/api/users/:userId/notifications', updateNotificationsApi);
|
||||
alertsRouter.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);
|
||||
|
||||
181
src/web/auth.js
@@ -1,5 +1,7 @@
|
||||
import { stringify } from '@brillout/json-serializer/stringify';
|
||||
import { Router } from 'express';
|
||||
import IPCIDR from 'ip-cidr';
|
||||
|
||||
import runtimeId from '../../utils/runtime-id.js';
|
||||
import argv from '../argv.js';
|
||||
|
||||
import {
|
||||
@@ -8,10 +10,53 @@ import {
|
||||
flushUserKeys,
|
||||
login,
|
||||
removeUserKey,
|
||||
resetPassword,
|
||||
sendEmailVerification,
|
||||
sendPasswordReset,
|
||||
sendSignup,
|
||||
signup,
|
||||
updateSettings,
|
||||
updateUser,
|
||||
verifyEmail,
|
||||
} from '../auth.js';
|
||||
|
||||
import { fetchUser } from '../users.js';
|
||||
import {
|
||||
fetchUser,
|
||||
} from '../users.js';
|
||||
|
||||
// spread into a new object rather than mutating the user we're about to hand back to the
|
||||
// client (loginApi/signupApi send this straight to the browser) — runtimeId/refreshedAt are
|
||||
// session bookkeeping only, they shouldn't leak into the API response
|
||||
function toSession(user) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
runtimeId,
|
||||
refreshedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
// concurrent requests for the same user (e.g. the burst of asset/API requests a single page
|
||||
// load fires) share one in-flight refresh instead of each independently hitting the DB
|
||||
const pendingSessionRefreshes = new Map();
|
||||
|
||||
async function refreshSessionUser(sessionUser) {
|
||||
const pending = pendingSessionRefreshes.get(sessionUser.id);
|
||||
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const refresh = fetchUser(sessionUser.id, {}, sessionUser)
|
||||
.then((user) => toSession(user))
|
||||
.finally(() => pendingSessionRefreshes.delete(sessionUser.id));
|
||||
|
||||
pendingSessionRefreshes.set(sessionUser.id, refresh);
|
||||
|
||||
return refresh;
|
||||
}
|
||||
|
||||
function getIp(req) {
|
||||
if (argv.ip) {
|
||||
@@ -41,11 +86,23 @@ function getIp(req) {
|
||||
return expandedIp.addressStart?.addressMinusSuffix || null;
|
||||
}
|
||||
|
||||
// changes every time the process restarts, so a deploy invalidates all cached session users
|
||||
// even if they're within the staleness window below
|
||||
const sessionStaleThreshold = 5 * 60 * 1000;
|
||||
|
||||
function isSessionStale(sessionUser) {
|
||||
return !!sessionUser && (sessionUser.runtimeId !== runtimeId || Date.now() - sessionUser.refreshedAt > sessionStaleThreshold);
|
||||
}
|
||||
|
||||
export async function setUserApi(req, res, next) {
|
||||
const ip = getIp(req);
|
||||
|
||||
req.userIp = ip;
|
||||
|
||||
if (isSessionStale(req.session.user)) {
|
||||
req.session.user = await refreshSessionUser(req.session.user);
|
||||
}
|
||||
|
||||
if (req.session.user) {
|
||||
req.user = req.session.user;
|
||||
req.user.ip = ip;
|
||||
@@ -55,18 +112,20 @@ export async function setUserApi(req, res, next) {
|
||||
}
|
||||
|
||||
export async function updateSessionUser(req) {
|
||||
const user = await fetchUser(req.session.user.id, {}, req.session.user);
|
||||
const user = toSession(await fetchUser(req.session.user.id, {}, req.session.user));
|
||||
|
||||
req.session.user = user;
|
||||
if (user) {
|
||||
req.session.user = user;
|
||||
|
||||
req.user = user;
|
||||
req.user.ip = req.userIp;
|
||||
req.user = user;
|
||||
req.user.ip = req.userIp;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginApi(req, res) {
|
||||
const user = await login(req.body, req.userIp);
|
||||
|
||||
req.session.user = user;
|
||||
req.session.user = toSession(user);
|
||||
res.send(user);
|
||||
}
|
||||
|
||||
@@ -80,23 +139,97 @@ export async function logoutApi(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
async function sendSignupApi(req, res) {
|
||||
await sendSignup(req.body.email, req.userIp);
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
export async function signupApi(req, res) {
|
||||
const user = await signup(req.body, req.userIp);
|
||||
|
||||
req.session.user = user;
|
||||
req.session.user = toSession(user);
|
||||
res.send(user);
|
||||
}
|
||||
|
||||
async function updateUserApi(req, res) {
|
||||
const updatedUser = await updateUser(req.body, req.user, req.session);
|
||||
const sessions = await req.sessionStore.all();
|
||||
|
||||
req.session.user.refreshedAt = 0;
|
||||
|
||||
await Promise.all(sessions.map(async (session) => {
|
||||
if (session.user?.id === req.user.id && session.id !== req.sessionID) {
|
||||
if (req.body.credentials?.password) {
|
||||
// we changed the password, destroy all user sessions except our own (we already provided all the credentials)
|
||||
await req.sessionStore.destroy(session.id);
|
||||
}
|
||||
else {
|
||||
// mark our sessions as stale
|
||||
session.user.refreshedAt = 0; // eslint-disable-line no-param-reassign
|
||||
await req.sessionStore.set(session.id, session);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
res.send(updatedUser);
|
||||
}
|
||||
|
||||
async function sendEmailVerificationApi(req, res) {
|
||||
await sendEmailVerification(req.user, null, 'verify');
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function verifyEmailApi(req, res) {
|
||||
const userId = Number(req.body.userId) || null;
|
||||
|
||||
await verifyEmail(userId, req.body.token);
|
||||
|
||||
const sessions = await req.sessionStore.all();
|
||||
|
||||
await Promise.all(sessions.map(async (session) => {
|
||||
if (session.user?.id === userId) {
|
||||
session.user.refreshedAt = 0; // eslint-disable-line no-param-reassign
|
||||
await req.sessionStore.set(session.id, session);
|
||||
}
|
||||
}));
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function sendPasswordResetApi(req, res) {
|
||||
await sendPasswordReset(req.body.credential);
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function resetPasswordApi(req, res) {
|
||||
const userId = Number(req.body.userId) || null;
|
||||
|
||||
await resetPassword(userId, req.body.token, req.body.password);
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function updateSettingsApi(req, res) {
|
||||
const updatedSettings = await updateSettings(req.body, req.user);
|
||||
|
||||
req.session.user.settings = updatedSettings;
|
||||
|
||||
res.send(updatedSettings);
|
||||
}
|
||||
|
||||
export async function fetchUserKeysApi(req, res) {
|
||||
const keys = await fetchUserKeys(req.user);
|
||||
|
||||
res.send(stringify(keys));
|
||||
res.send(keys);
|
||||
}
|
||||
|
||||
export async function createKeyApi(req, res) {
|
||||
const key = await createKey(req.user);
|
||||
|
||||
res.send(stringify(key));
|
||||
res.send(key);
|
||||
}
|
||||
|
||||
export async function removeUserKeyApi(req, res) {
|
||||
@@ -110,3 +243,31 @@ export async function flushUserKeysApi(req, res) {
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
// SESSION
|
||||
// authRouter.use(setUserApi);
|
||||
|
||||
authRouter.post('/api/session', loginApi);
|
||||
authRouter.delete('/api/session', logoutApi);
|
||||
|
||||
// USERS
|
||||
authRouter.post('/api/signups', sendSignupApi);
|
||||
authRouter.post('/api/users', signupApi);
|
||||
|
||||
// SETTINGS
|
||||
authRouter.patch('/api/me', updateUserApi);
|
||||
authRouter.patch('/api/me/settings', updateSettingsApi);
|
||||
|
||||
// EMAIL VERIFICATION
|
||||
authRouter.post('/api/me/email_verification', sendEmailVerificationApi);
|
||||
authRouter.post('/api/emails/verify', verifyEmailApi); // usable without session
|
||||
authRouter.post('/api/passwords/request', sendPasswordResetApi);
|
||||
authRouter.post('/api/passwords/reset', resetPasswordApi);
|
||||
|
||||
// API KEYS
|
||||
authRouter.get('/api/me/keys', fetchUserKeysApi);
|
||||
authRouter.post('/api/me/keys', createKeyApi);
|
||||
authRouter.delete('/api/me/keys/:keyIdentifier', removeUserKeyApi);
|
||||
authRouter.delete('/api/me/keys', flushUserKeysApi);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { stringify } from '@brillout/json-serializer/stringify';
|
||||
|
||||
import argv from '../argv.js';
|
||||
import initLogger from '../logger.js';
|
||||
|
||||
@@ -11,16 +13,16 @@ export default function errorHandler(error, req, res, _next) {
|
||||
}
|
||||
|
||||
if (error.httpCode) {
|
||||
res.status(error.httpCode).send({
|
||||
res.status(error.httpCode).send(stringify({
|
||||
statusCode: error.httpCode,
|
||||
statusMessage: error.message,
|
||||
});
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).send({
|
||||
res.status(500).send(stringify({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Oops... our server messed up. We will be investigating this incident, our apologies for the inconvenience.',
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -26,9 +26,11 @@ export default async function mainHandler(req, res, next) {
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
email: req.user.email,
|
||||
settings: req.user.settings,
|
||||
role: req.user.role,
|
||||
abilities: req.user.abilities,
|
||||
avatar: req.user.avatar,
|
||||
isEmailVerified: req.user.isEmailVerified,
|
||||
},
|
||||
assets: req.user
|
||||
? {
|
||||
@@ -52,12 +54,13 @@ export default async function mainHandler(req, res, next) {
|
||||
psa: config.psa,
|
||||
links: config.links,
|
||||
socials,
|
||||
emailEnabled: config.email.enabled,
|
||||
captcha: {
|
||||
enabled: config.auth.captcha.enabled,
|
||||
siteKey: config.auth.captcha.siteKey,
|
||||
},
|
||||
},
|
||||
restriction: req.restriction,
|
||||
geo: req.geo,
|
||||
meta: {
|
||||
now: new Date().toISOString(),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { stringify } from '@brillout/json-serializer/stringify';
|
||||
|
||||
import promiseProps from '../../utils/promise-props.js';
|
||||
import slugify from '../../utils/slugify.js';
|
||||
import { getIdsBySlug } from '../cache.js';
|
||||
@@ -33,9 +31,9 @@ export async function fetchMoviesApi(req, res) {
|
||||
} = await fetchMovies(await curateMoviesQuery(req.query), {
|
||||
page: Number(req.query.page) || 1,
|
||||
limit: Number(req.query.limit) || 30,
|
||||
}, req.user, { restriction: req.restriction });
|
||||
}, req.user, { restriction: req.geo.restriction });
|
||||
|
||||
res.send(stringify({
|
||||
res.send({
|
||||
movies,
|
||||
aggYears,
|
||||
aggActors,
|
||||
@@ -43,11 +41,11 @@ export async function fetchMoviesApi(req, res) {
|
||||
aggChannels,
|
||||
limit,
|
||||
total,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMovieApi(req, res) {
|
||||
const [movie] = await fetchMoviesById([Number(req.params.movieId)], { reqUser: req.user }, { restriction: req.restriction });
|
||||
const [movie] = await fetchMoviesById([Number(req.params.movieId)], { reqUser: req.user }, { restriction: req.geo.restriction });
|
||||
|
||||
if (!movie) {
|
||||
throw new HttpError(`No movie with ID ${req.params.movieId} found`, 404);
|
||||
@@ -137,7 +135,7 @@ export async function fetchMoviesGraphql(query, req) {
|
||||
page: query.page || 1,
|
||||
limit: query.limit || 30,
|
||||
aggregate: false,
|
||||
}, req.user, { restriction: req.restriction });
|
||||
}, req.user, { restriction: req.geo.restriction });
|
||||
|
||||
return {
|
||||
nodes: movies,
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
import { Reader } from '@maxmind/geoip2-node';
|
||||
import config from 'config';
|
||||
|
||||
import runtimeId from '../../utils/runtime-id.js';
|
||||
import initLogger from '../logger.js';
|
||||
|
||||
const logger = initLogger();
|
||||
@@ -11,22 +12,47 @@ export default async function initRestrictionHandler() {
|
||||
const reader = await Reader.open('assets/GeoLite2-City.mmdb');
|
||||
|
||||
function getRestriction(req) {
|
||||
if (Object.hasOwn(req.session, 'restriction') && Object.hasOwn(req.session, 'country') && req.session.restrictionIp === req.userIp) {
|
||||
return {
|
||||
restriction: req.session.restriction,
|
||||
country: req.session.country,
|
||||
};
|
||||
if (req.session.geo
|
||||
&& Object.hasOwn(req.session.geo, 'country')
|
||||
&& req.session.geo.ip === req.userIp
|
||||
&& req.session.geo.runtimeId === runtimeId) {
|
||||
const { country, subdivision } = req.session.geo;
|
||||
|
||||
if (!config.restrictions.enabled) {
|
||||
return {
|
||||
restriction: 0,
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.hasOwn(req.session.geo, 'restriction')) {
|
||||
return {
|
||||
restriction: req.session.geo.restriction,
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const location = reader.city(req.userIp);
|
||||
const country = location.country.isoCode;
|
||||
const subdivision = location.subdivisions?.[0]?.isoCode;
|
||||
|
||||
if (!config.restrictions.enabled) {
|
||||
return {
|
||||
restriction: 0,
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
|
||||
if (regions[country]?.[subdivision]) {
|
||||
// state or province restriction
|
||||
return {
|
||||
restriction: config.restrictions.modes[regions[country][subdivision]],
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,23 +61,24 @@ export default async function initRestrictionHandler() {
|
||||
return {
|
||||
restriction: config.restrictions.modes[regions[country]],
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
restriction: null,
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
|
||||
function restrictionHandler(req, res, next) {
|
||||
if (!config.restrictions.enabled) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { restriction, country } = getRestriction(req);
|
||||
const {
|
||||
restriction,
|
||||
country,
|
||||
subdivision,
|
||||
} = getRestriction(req);
|
||||
|
||||
if (restriction === 'block' || req.path === '/sfw/') {
|
||||
res.render(path.join(import.meta.dirname, '../../assets/sfw.ejs'), {
|
||||
@@ -61,21 +88,38 @@ export default async function initRestrictionHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.session.restriction !== restriction) {
|
||||
req.session.restrictionIp = req.userIp;
|
||||
req.session.restriction = restriction;
|
||||
req.session.country = country;
|
||||
if (req.session.geo?.restriction !== restriction) {
|
||||
req.session.geo = {
|
||||
ip: req.userIp,
|
||||
runtimeId,
|
||||
restriction,
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
|
||||
req.restriction = restriction;
|
||||
req.country = country;
|
||||
req.geo = {
|
||||
restriction,
|
||||
country,
|
||||
subdivision,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(`Failed Maxmind IP lookup for ${req.ip}: ${error.message}`);
|
||||
|
||||
req.session.restrictionIp = req.userIp;
|
||||
req.session.restriction = 0;
|
||||
req.session.country = null;
|
||||
req.session.geo = {
|
||||
ip: req.userIp,
|
||||
runtimeId,
|
||||
restriction: 0,
|
||||
country: null,
|
||||
subdivision: null,
|
||||
};
|
||||
|
||||
req.geo = {
|
||||
restriction: 0,
|
||||
country: null,
|
||||
subdivision: null,
|
||||
};
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { stringify } from '@brillout/json-serializer/stringify';
|
||||
import { Router } from 'express';
|
||||
|
||||
import promiseProps from '../../utils/promise-props.js';
|
||||
@@ -71,10 +70,10 @@ async function fetchScenesApi(req, res) {
|
||||
page: Number(req.query.page) || 1,
|
||||
limit: Number(req.query.limit) || 30,
|
||||
}, req.user, {
|
||||
restriction: req.restriction,
|
||||
restriction: req.geo.restriction,
|
||||
});
|
||||
|
||||
res.send(stringify({
|
||||
res.send({
|
||||
scenes,
|
||||
aggYears,
|
||||
aggActors,
|
||||
@@ -83,7 +82,7 @@ async function fetchScenesApi(req, res) {
|
||||
aggChannels,
|
||||
limit,
|
||||
total,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export const scenesSchema = `
|
||||
@@ -255,7 +254,7 @@ export async function fetchScenesGraphql(query, req) {
|
||||
}
|
||||
|
||||
async function fetchSceneApi(req, res) {
|
||||
const [scene] = await fetchScenesById([Number(req.params.sceneId)], { reqUser: req.user }, { restriction: req.restriction });
|
||||
const [scene] = await fetchScenesById([Number(req.params.sceneId)], { reqUser: req.user }, { restriction: req.geo.restriction });
|
||||
|
||||
if (!scene) {
|
||||
throw new HttpError(`No scene with ID ${req.params.sceneId} found`, 404);
|
||||
@@ -268,7 +267,7 @@ export async function fetchScenesByIdGraphql(query, req) {
|
||||
const scenes = await fetchScenesById([].concat(query.id, query.ids).filter(Boolean), {
|
||||
reqUser: req.user,
|
||||
includePartOf: true,
|
||||
restriction: req.restriction,
|
||||
restriction: req.geo.restriction,
|
||||
});
|
||||
|
||||
if (query.ids) {
|
||||
|
||||
13
src/web/serialize.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { stringify } from '@brillout/json-serializer/stringify';
|
||||
|
||||
export default function serializeApiResponses(req, res, next) {
|
||||
res.json = function json(obj) { // eslint-disable-line no-param-reassign
|
||||
if (!this.get('Content-Type')) {
|
||||
this.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
return this.send(stringify(obj));
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -12,19 +12,11 @@ import initLogger from '../logger.js';
|
||||
|
||||
import redis from '../redis.js';
|
||||
import queryBoolean from '../utils/query-boolean.js';
|
||||
import { actorsRouter } from './actors.js';
|
||||
|
||||
import { router as alertsRouter } from './alerts.js';
|
||||
import {
|
||||
createKeyApi,
|
||||
fetchUserKeysApi,
|
||||
flushUserKeysApi,
|
||||
loginApi,
|
||||
logoutApi,
|
||||
removeUserKeyApi,
|
||||
setUserApi,
|
||||
signupApi,
|
||||
} from './auth.js';
|
||||
import { actorsRouter } from './actors.js';
|
||||
import { alertsRouter } from './alerts.js';
|
||||
import { authRouter, setUserApi } from './auth.js';
|
||||
|
||||
import consentHandler from './consent.js';
|
||||
|
||||
import { fetchEntitiesApi } from './entities.js';
|
||||
@@ -37,6 +29,7 @@ import { fetchMoviesApi } from './movies.js';
|
||||
import initRestrictionHandler from './restrictions.js';
|
||||
|
||||
import { scenesRouter } from './scenes.js';
|
||||
import serializeApiResponses from './serialize.js';
|
||||
|
||||
import { router as stashesRouter } from './stashes.js';
|
||||
import { syncRouter } from './sync.js';
|
||||
@@ -90,8 +83,6 @@ export default async function initServer() {
|
||||
store: redisStore,
|
||||
}));
|
||||
|
||||
router.use(setUserApi);
|
||||
|
||||
// Vite integration
|
||||
if (isProduction) {
|
||||
app.enable('trust proxy');
|
||||
@@ -117,12 +108,15 @@ export default async function initServer() {
|
||||
router.use(viteDevMiddleware);
|
||||
}
|
||||
|
||||
router.use(setUserApi); // we need to set the IP before the restriction handler
|
||||
router.use(restrictionHandler);
|
||||
|
||||
router.get('/consent', (_req, res) => {
|
||||
res.sendFile(path.join(import.meta.dirname, '../../assets/consent.html'));
|
||||
});
|
||||
|
||||
router.use('/api/{*splat}', serializeApiResponses);
|
||||
|
||||
router.use('/api/{*splat}', async (req, _res, next) => {
|
||||
if (req.headers['api-user']) {
|
||||
req.user = await verifyKey(req.headers['api-user'], req.headers['api-key'], req);
|
||||
@@ -131,22 +125,10 @@ export default async function initServer() {
|
||||
next();
|
||||
});
|
||||
|
||||
// SESSION
|
||||
router.post('/api/session', loginApi);
|
||||
router.delete('/api/session', logoutApi);
|
||||
|
||||
// USERS
|
||||
router.post('/api/users', signupApi);
|
||||
|
||||
// API KEYS
|
||||
router.get('/api/me/keys', fetchUserKeysApi);
|
||||
router.post('/api/me/keys', createKeyApi);
|
||||
router.delete('/api/me/keys/:keyIdentifier', removeUserKeyApi);
|
||||
router.delete('/api/me/keys', flushUserKeysApi);
|
||||
|
||||
router.use(userRouter);
|
||||
router.use(stashesRouter);
|
||||
router.use(alertsRouter);
|
||||
router.use(authRouter);
|
||||
router.use(scenesRouter);
|
||||
router.use(actorsRouter);
|
||||
router.use(syncRouter);
|
||||
|
||||
@@ -4,7 +4,7 @@ export async function fetchTagsApi(req, res) {
|
||||
const tags = await fetchTags({
|
||||
query: req.query.query,
|
||||
}, {
|
||||
restriction: req.restriction,
|
||||
restriction: req.geo.restriction,
|
||||
});
|
||||
|
||||
res.send(tags);
|
||||
|
||||