Compare commits

..

50 Commits

Author SHA1 Message Date
37a919770a 0.57.15 2026-07-28 23:46:12 +02:00
4e59994806 Fixed notify stash manticoresearch call still using obsolete 'index' key. 2026-07-28 23:46:10 +02:00
699f7f0944 0.57.14 2026-07-26 03:40:37 +02:00
bf220f6b4e Added stash archiving. 2026-07-26 03:40:32 +02:00
02ee99d52f 0.57.13 2026-07-26 03:15:52 +02:00
e50dedca66 Fixed profile page breaking when logged out. 2026-07-26 03:15:50 +02:00
760025f414 0.57.12 2026-07-26 00:26:51 +02:00
07d9dfaaaa Added stash sorting to stash tooltip menu and profile page. 2026-07-26 00:26:49 +02:00
d3f549c92f 0.57.11 2026-07-25 03:11:39 +02:00
bf64a3d30e Only listing social links from known platforms in bio. 2026-07-25 03:11:36 +02:00
b27c4811da 0.57.10 2026-07-25 03:02:42 +02:00
c462c2b11a Tweaked merge and alert search result tooltips. 2026-07-25 03:02:40 +02:00
a774254e71 0.57.9 2026-07-25 02:28:09 +02:00
0a665ab553 Ordering stashes by last stashed item. 2026-07-25 02:28:06 +02:00
c2d8cc3a40 0.57.8 2026-07-25 01:17:15 +02:00
eed07ba84b Improved tooltip positioning and sizing, in particular for stashing (heart). 2026-07-25 01:17:12 +02:00
e8f1dca2a0 0.57.7 2026-07-24 02:06:47 +02:00
4037098927 Fixed scene banner pushing grid row spacing. 2026-07-24 02:06:45 +02:00
e007dd2b83 0.57.6 2026-07-24 00:33:58 +02:00
b86cf8e30f Added grid rule to prevent row gaps on large screens. 2026-07-24 00:33:56 +02:00
e46f174a1e 0.57.5 2026-07-23 23:36:21 +02:00
32b8f00308 Removed inop actor merge avatar conflict ignore. 2026-07-23 23:36:18 +02:00
c9e698c906 0.57.4 2026-07-23 23:06:21 +02:00
286248e403 Widened actor merge avatar conflict ignore scope. 2026-07-23 23:06:18 +02:00
f9e03f47ba 0.57.3 2026-07-23 22:54:39 +02:00
b54174eb5e Ignoring actor merge avatar conflicts, put actor merge source IDs in post body. 2026-07-23 22:54:35 +02:00
62d19d1a04 0.57.2 2026-07-23 22:12:34 +02:00
6b3866a99f Changed sign-up flow to require e-mail verification up front, preventing account existence leakage. 2026-07-23 22:12:33 +02:00
18c30b6f98 0.57.1 2026-07-23 05:34:32 +02:00
77b27a56f7 Determining location with restrictions disabled, cleaned up. 2026-07-23 05:34:28 +02:00
4c48700086 0.57.0 2026-07-23 04:40:27 +02:00
6f68a50eb7 Added password recovery and login attempt guards. 2026-07-23 04:40:25 +02:00
ef9be8ef9a 0.56.4 2026-07-22 17:07:26 +02:00
a81343c519 Improved mobile scaling of preferences and account settings. 2026-07-22 17:07:23 +02:00
60ad068939 0.56.3 2026-07-22 06:06:46 +02:00
df6965cb3a Fixed stray headings on account page, fixed summary editor not full-width. 2026-07-22 06:06:43 +02:00
9e95bec659 0.56.2 2026-07-22 06:02:44 +02:00
88b866c96a Setting e-mail web address in email module. 2026-07-22 06:02:42 +02:00
5784c08e1b 0.56.1 2026-07-22 05:59:13 +02:00
753840f4f1 Disabling account e-mail fields if e-mail service is disabled (verification e-mail couldn't be sent). 2026-07-22 05:59:11 +02:00
dbe27d68d3 0.56.0 2026-07-22 05:49:43 +02:00
9b058fd44b Added account update form and e-mail verification. 2026-07-22 05:49:39 +02:00
17d34f6a80 0.55.4 2026-07-21 05:54:45 +02:00
17fd285d67 Fixed actor alert button malfunctioning due missing query. Refreshing alert meta views after batch processing alerts. 2026-07-21 05:54:43 +02:00
097baed957 0.55.3 2026-07-21 05:29:47 +02:00
b983fbaa1f Fixed address prop not being passed to email template. 2026-07-21 05:29:45 +02:00
afe337d44a 0.55.2 2026-07-21 05:17:40 +02:00
6fe8c84ef4 Added profile ID as username title on profile page. 2026-07-21 05:17:36 +02:00
e71a6987fc 0.55.1 2026-07-21 05:14:34 +02:00
938878d860 Fixed email component import path. 2026-07-21 05:14:32 +02:00
82 changed files with 3130 additions and 443 deletions

View File

@@ -8,3 +8,5 @@
@custom-media --small (max-width: 900px); @custom-media --small (max-width: 900px);
@custom-media --compact (max-width: 1200px); @custom-media --compact (max-width: 1200px);
@custom-media --big (max-width: 1500px); @custom-media --big (max-width: 1500px);
@custom-media --big-10 (max-width: 1750px);
@custom-media --big-20 (max-width: 2000px);

View File

@@ -1,4 +1,5 @@
.input { .input {
width: 15rem;
box-sizing: border-box; box-sizing: border-box;
padding: .5rem .75rem; padding: .5rem .75rem;
font-size: 1rem; font-size: 1rem;

View File

@@ -1,7 +1,7 @@
/* Content */ /* Content */
.v-popper__popper { .v-popper__popper {
z-index: 10000; z-index: 1000;
top: 0; top: 0;
left: 0; left: 0;
outline: none; outline: none;
@@ -45,6 +45,10 @@
overflow-y: auto; overflow-y: auto;
} }
.no-outer-scroll .v-popper__inner {
overflow: hidden;
}
.v-popper__inner > div { .v-popper__inner > div {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -82,6 +86,10 @@
border-width: 6px; 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^="top"] .v-popper__arrow-inner,
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner { .v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner {
left: -2px; left: -2px;
@@ -105,7 +113,7 @@
} }
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-container { .v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-container {
top: 0; top: -2px;
} }
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner, .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 { .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, .v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-inner,

View 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

View 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

View 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

View 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

View 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

View File

@@ -303,6 +303,7 @@ function updateFilter(prop, value, reload = true) {
display: grid; display: grid;
flex-grow: 1; flex-grow: 1;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-auto-rows: max-content; /* prevent row gaps on large screens */
gap: .25rem; gap: .25rem;
} }

View File

@@ -148,6 +148,10 @@ async function addActor() {
gap: 1.25rem; gap: 1.25rem;
} }
.input {
width: 100%;
}
.load-container { .load-container {
width: 100%; width: 100%;
display: inline-flex; display: inline-flex;

View File

@@ -77,12 +77,15 @@ function getSocialUrl(social) {
return null; return null;
} }
const socials = props.actor.socials.slice(0, 10).map((social) => ({ const socials = props.actor.socials
...social, .filter((social) => social.platform)
handle: social.url .slice(0, 10)
? new URL(social.url).hostname .map((social) => ({
: social.handle, ...social,
})); handle: social.url
? new URL(social.url).hostname
: social.handle,
}));
const aliases = Object const aliases = Object
.entries(props.actor.aliases.reduce((acc, alias) => { .entries(props.actor.aliases.reduce((acc, alias) => {

View File

@@ -52,7 +52,8 @@ function getActorNames() {
async function merge() { async function merge() {
submitted.value = true; 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, comment: comment.value || null,
}, { }, {
successFeedback: `Merged ${getActorNames()} into ${targetActor.value.name}`, successFeedback: `Merged ${getActorNames()} into ${targetActor.value.name}`,
@@ -147,6 +148,7 @@ onMounted(() => {
:triggers="[]" :triggers="[]"
:shown="actorResults.length > 0" :shown="actorResults.length > 0"
:auto-hide="false" :auto-hide="false"
placement="auto"
> >
<input <input
ref="actorInput" ref="actorInput"

View File

@@ -37,17 +37,14 @@ const {
const actors = ref(props.presetActors); const actors = ref(props.presetActors);
const actorResults = ref([]); const actorResults = ref([]);
const actorInput = ref(null);
const actorQuery = ref(''); const actorQuery = ref('');
const entities = ref(props.presetEntities); const entities = ref(props.presetEntities);
const entityResults = ref([]); const entityResults = ref([]);
const entityInput = ref(null);
const entityQuery = ref(''); const entityQuery = ref('');
const tags = ref(props.presetTags); const tags = ref(props.presetTags);
const tagResults = ref([]); const tagResults = ref([]);
const tagInput = ref(null);
const tagQuery = ref(''); const tagQuery = ref('');
const scenes = ref(props.presetScenes); const scenes = ref(props.presetScenes);
@@ -61,6 +58,8 @@ const actorAnd = ref(true);
const tagAnd = ref(true); const tagAnd = ref(true);
const matchAnd = ref(true); const matchAnd = ref(true);
const inputs = {};
const fieldsLogicTooltipId = useId(); const fieldsLogicTooltipId = useId();
const addActorDropdownId = useId(); const addActorDropdownId = useId();
const addTagDropdownId = useId(); const addTagDropdownId = useId();
@@ -132,9 +131,9 @@ async function searchTags() {
tagResults.value = res; tagResults.value = res;
} }
function focusActorInput() { function focusInput(target) {
setTimeout(() => { setTimeout(() => {
actorInput.value.focus(); inputs[target]?.focus();
}, 100); }, 100);
} }
@@ -263,7 +262,8 @@ function rememberThen(domain, checked) {
<li class="field-add"> <li class="field-add">
<VDropdown <VDropdown
:aria-id="addActorDropdownId" :aria-id="addActorDropdownId"
@show="focusActorInput" placement="auto"
@show="focusInput('actor')"
> >
<button <button
class="button" class="button"
@@ -272,7 +272,7 @@ function rememberThen(domain, checked) {
<template #popper> <template #popper>
<input <input
ref="actorInput" :ref="(el) => inputs.actor = el"
v-model="actorQuery" v-model="actorQuery"
class="input" class="input"
@input="searchActors" @input="searchActors"
@@ -339,7 +339,11 @@ function rememberThen(domain, checked) {
</li> </li>
<li class="field-add"> <li class="field-add">
<VDropdown :aria-id="addTagDropdownId"> <VDropdown
placement="auto"
:aria-id="addTagDropdownId"
@show="focusInput('tag')"
>
<button <button
type="button" type="button"
class="button" class="button"
@@ -347,7 +351,7 @@ function rememberThen(domain, checked) {
<template #popper> <template #popper>
<input <input
ref="tagInput" :ref="(el) => inputs.tag = el"
v-model="tagQuery" v-model="tagQuery"
class="input" class="input"
@input="searchTags" @input="searchTags"
@@ -403,7 +407,11 @@ function rememberThen(domain, checked) {
</li> </li>
<li class="field-add"> <li class="field-add">
<VDropdown :aria-id="addEntityDropdownId"> <VDropdown
placement="auto"
:aria-id="addEntityDropdownId"
@show="focusInput('entity')"
>
<button <button
type="button" type="button"
class="button" class="button"
@@ -411,7 +419,7 @@ function rememberThen(domain, checked) {
<template #popper> <template #popper>
<input <input
ref="entityInput" :ref="(el) => inputs.entity = el"
v-model="entityQuery" v-model="entityQuery"
class="input" class="input"
@input="searchEntities" @input="searchEntities"
@@ -465,7 +473,10 @@ function rememberThen(domain, checked) {
</li> </li>
<li class="field-add"> <li class="field-add">
<VDropdown :aria-id="addMatchDropdownId"> <VDropdown
:aria-id="addMatchDropdownId"
@show="focusInput('expression')"
>
<button <button
type="button" type="button"
class="button" class="button"
@@ -482,6 +493,7 @@ function rememberThen(domain, checked) {
</select> </select>
<input <input
:ref="(el) => inputs.expression = el"
v-model="matchExpression" v-model="matchExpression"
placeholder="Expression, // for regex" placeholder="Expression, // for regex"
class="input" class="input"

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -279,7 +279,7 @@ function updateFilter(prop, value, reload = true) {
.movies { .movies {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 1rem; gap: .5rem;
padding: .5rem 1rem 1rem 1rem; padding: .5rem 1rem 1rem 1rem;
} }

View File

@@ -370,6 +370,7 @@ function setView(newView) {
<Campaign <Campaign
:campaign="sceneCampaign" :campaign="sceneCampaign"
:backdrop="true" :backdrop="true"
class="campaign-scene"
/> />
</li> </li>
@@ -447,6 +448,7 @@ function setView(newView) {
.scenes { .scenes {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
grid-auto-rows: min-content;
gap: .5rem; gap: .5rem;
padding: .5rem 1rem 1rem 1rem; padding: .5rem 1rem 1rem 1rem;
} }
@@ -456,6 +458,11 @@ function setView(newView) {
width: auto; width: auto;
} }
.campaign-scene {
width: 100%;
aspect-ratio: 2 / 1; /* ensures banner can't be taller than scene tile */
}
.scopes { .scopes {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;

View File

@@ -253,6 +253,7 @@ onMounted(() => {
} }
.input { .input {
width: 100%;
resize: none; resize: none;
background: var(--background); background: var(--background);
} }

View File

@@ -1,6 +1,4 @@
<script setup> <script setup>
import { ref } from 'vue';
// import Checkbox from '#/components/form/checkbox.vue'; // import Checkbox from '#/components/form/checkbox.vue';
</script> </script>

View File

@@ -246,6 +246,8 @@ async function reloadStashes(newStash) {
v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))" v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))"
:aria-id="stashMenuDropdownId" :aria-id="stashMenuDropdownId"
:shown="showStashes" :shown="showStashes"
placement="auto"
popper-class="no-outer-scroll"
@hide="showStashes = false" @hide="showStashes = false"
> >
<Icon <Icon
@@ -259,6 +261,7 @@ async function reloadStashes(newStash) {
<StashMenu <StashMenu
:stashes="stashes" :stashes="stashes"
:item-stashes="itemStashes" :item-stashes="itemStashes"
:domain="domain"
@stash="(stash) => stashItem(stash)" @stash="(stash) => stashItem(stash)"
@unstash="(stash) => unstashItem(stash)" @unstash="(stash) => unstashItem(stash)"
@create="showStashDialog = true" @create="showStashDialog = true"
@@ -272,6 +275,8 @@ async function reloadStashes(newStash) {
:triggers="[]" :triggers="[]"
:shown="showStashes" :shown="showStashes"
:disabled="showSecondary" :disabled="showSecondary"
placement="auto"
popper-class="no-outer-scroll"
@hide="showStashes = false" @hide="showStashes = false"
> >
<template v-if="pageStash?.user.id === user.id"> <template v-if="pageStash?.user.id === user.id">
@@ -314,6 +319,7 @@ async function reloadStashes(newStash) {
<StashMenu <StashMenu
:stashes="stashes" :stashes="stashes"
:item-stashes="itemStashes" :item-stashes="itemStashes"
:domain="domain"
@stash="(stash) => stashItem(stash)" @stash="(stash) => stashItem(stash)"
@unstash="(stash) => unstashItem(stash)" @unstash="(stash) => unstashItem(stash)"
@create="showStashDialog = true" @create="showStashDialog = true"

View File

@@ -1,7 +1,9 @@
<script setup> <script setup>
import { computed, ref } from 'vue';
import Checkbox from '#/components/form/checkbox.vue'; import Checkbox from '#/components/form/checkbox.vue';
defineProps({ const props = defineProps({
stashes: { stashes: {
type: Array, type: Array,
default: () => [], default: () => [],
@@ -10,46 +12,141 @@ defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
domain: {
type: String,
default: 'scenes',
},
}); });
const emit = defineEmits(['stash', 'unstash', 'create']); 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> </script>
<template> <template>
<ul class="stash-menu nolist noselect"> <div class="stash-menu noselect">
<li <ul class="stash-list nolist noselect">
v-for="userStash in stashes" <li
:key="`stash-${userStash.id}`" v-for="userStash in sortedStashes"
class="menu-item" :key="`stash-${userStash.id}`"
> class="menu-item"
<label class="menu-stash"> >
<Checkbox <label class="menu-stash">
:checked="itemStashes.some((itemStash) => itemStash.id === userStash.id)" <Checkbox
@change="(checked) => checked ? emit('stash', userStash) : emit('unstash', userStash)" :checked="itemStashes.some((itemStash) => itemStash.id === userStash.id)"
/>{{ userStash.name }} @change="(checked) => checked ? emit('stash', userStash) : emit('unstash', userStash)"
</label> />{{ userStash.name }}
</li> </label>
</li>
</ul>
<li class="menu-item create"> <div class="menu-actions">
<label <div
v-close-popper v-close-popper
class="menu-stash" class="menu-item menu-stash menu-create"
@click="emit('create')" @click="emit('create')"
> >
<Icon icon="plus3" /> <Icon icon="plus3" />
New New
</label> </div>
</li>
</ul> <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> </template>
<style scoped> <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 { .menu-item {
display: flex; display: flex;
.icon { .icon {
fill: var(--shadow-weak-10); fill: var(--shadow-weak-10);
padding: 0 .55rem 0 .2rem; padding: 0 .55rem 0 .2rem;
transform: translateY(-1px);
} }
} }
@@ -58,6 +155,7 @@ const emit = defineEmits(['stash', 'unstash', 'create']);
width: 100%; width: 100%;
align-items: center; align-items: center;
padding: .5rem; padding: .5rem;
box-sizing: border-box;
&:hover { &:hover {
cursor: pointer; cursor: pointer;
@@ -72,4 +170,21 @@ const emit = defineEmits(['stash', 'unstash', 'create']);
margin-right: .5rem; 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> </style>

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { inject, ref } from 'vue'; import { computed, inject, ref } from 'vue';
import StashDialog from '#/components/stashes/create.vue'; import StashDialog from '#/components/stashes/create.vue';
import StashTile from '#/components/stashes/tile.vue'; import StashTile from '#/components/stashes/tile.vue';
@@ -13,16 +13,55 @@ const stashes = ref(pageContext.pageProps.stashes);
const profile = ref(pageContext.pageProps.profile); const profile = ref(pageContext.pageProps.profile);
const showStashDialog = ref(false); const showStashDialog = ref(false);
const showArchived = ref(false);
const sortings = ['used', 'name', 'size', 'created'];
const sorting = ref('size');
async function reloadStashes() { async function reloadStashes() {
// profile.value = await get(`/users/${profile.value.id}`); // profile.value = await get(`/users/${profile.value.id}`);
stashes.value = await get(`/users/${profile.value.id}/stashes`); 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> </script>
<template> <template>
<section class="profile-section"> <section class="profile-section">
<div class="section-header"> <div class="section-header noselect">
<button <button
v-if="profile.id === user?.id" v-if="profile.id === user?.id"
class="button" class="button"
@@ -31,6 +70,38 @@ async function reloadStashes() {
<Icon icon="folder-plus2" /> <Icon icon="folder-plus2" />
<span class="button-label">New stash</span> <span class="button-label">New stash</span>
</button> </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> </div>
<StashDialog <StashDialog
@@ -41,7 +112,29 @@ async function reloadStashes() {
<ul class="stashes nolist"> <ul class="stashes nolist">
<li <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}`" :key="`stash-${stash.id}`"
> >
<StashTile <StashTile
@@ -59,6 +152,7 @@ async function reloadStashes() {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-bottom: .5rem; margin-bottom: .5rem;
gap: 1rem;
} }
.stashes { .stashes {
@@ -68,6 +162,30 @@ async function reloadStashes() {
padding: 0 0 1rem 0; 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) { @media(--compact) {
.stashes { .stashes {
padding: 0 1rem 1rem 1rem; padding: 0 1rem 1rem 1rem;

View File

@@ -91,6 +91,30 @@ async function rename() {
showRenameDialog.value = false; 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> </script>
<template> <template>
@@ -159,6 +183,28 @@ async function rename() {
/>Rename />Rename
</li> </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 <li
v-if="!stash.isPrimary" v-if="!stash.isPrimary"
class="menu-item remove" class="menu-item remove"
@@ -306,6 +352,7 @@ async function rename() {
display: flex; display: flex;
margin-right: .75rem; margin-right: .75rem;
fill: var(--glass); fill: var(--glass);
transform: translateY(-1px);
} }
&.remove { &.remove {

300
components/user/account.vue Normal file
View 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>

View File

@@ -25,9 +25,7 @@ async function updateSettings() {
class="profile-section" class="profile-section"
@submit.prevent @submit.prevent
> >
<h3 class="heading settings-heading">Alerts</h3> <div class="setting noflip">
<div class="setting">
<span class="setting-label"> <span class="setting-label">
<strong class="setting-title">Enable alert notifications</strong> <strong class="setting-title">Enable alert notifications</strong>
<span class="setting-description">Your alerts can trigger notifications in traxxx</span> <span class="setting-description">Your alerts can trigger notifications in traxxx</span>
@@ -41,7 +39,7 @@ async function updateSettings() {
</span> </span>
</div> </div>
<div class="setting"> <div class="setting noflip">
<span class="setting-label"> <span class="setting-label">
<strong class="setting-title">Enable alert e-mails</strong> <strong class="setting-title">Enable alert e-mails</strong>
<span class="setting-description">Your alerts can trigger e-mails</span> <span class="setting-description">Your alerts can trigger e-mails</span>
@@ -55,7 +53,7 @@ async function updateSettings() {
</span> </span>
</div> </div>
<label class="setting"> <div class="setting">
<span class="setting-label"> <span class="setting-label">
<strong class="setting-title">Alert e-mail frequency</strong> <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 class="setting-description">Alert e-mails are sent immediately, or accumulated and sent periodically</span>
@@ -73,7 +71,7 @@ async function updateSettings() {
<option value="monthly">Monthly</option> <option value="monthly">Monthly</option>
</select> </select>
</span> </span>
</label> </div>
</form> </form>
</template> </template>
@@ -114,4 +112,15 @@ async function updateSettings() {
color: var(--glass); color: var(--glass);
font-size: .9rem; font-size: .9rem;
} }
@media(--small-20) {
.setting:not(.noflip) {
flex-direction: column;
align-items: flex-start;
}
.setting-label {
margin-bottom: .75rem;
}
}
</style> </style>

View File

@@ -154,6 +154,14 @@ module.exports = {
siteKey: '10000000-ffff-ffff-ffff-000000000001', siteKey: '10000000-ffff-ffff-ffff-000000000001',
secretKey: '0x0000000000000000000000000000000000000000', 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: { bans: {
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
@@ -206,6 +214,6 @@ module.exports = {
// Resend // Resend
enabled: false, enabled: false,
apiKey: null, apiKey: null,
from: 'noreply@alerts.traxxx.me', from: 'traxxx <noreply@alerts.traxxx.me>',
}, },
}; };

View File

@@ -1,4 +1,5 @@
import antfu from '@antfu/eslint-config'; import antfu from '@antfu/eslint-config';
import importX, { createNodeResolver } from 'eslint-plugin-import-x';
export default antfu( export default antfu(
{ {
@@ -18,11 +19,21 @@ export default antfu(
}, },
}, },
{ {
plugins: { 'import-x': importX },
languageOptions: { languageOptions: {
globals: { globals: {
CONFIG: 'readonly', 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: { rules: {
'default-param-last': 'off', 'default-param-last': 'off',
'no-underscore-dangle': 'off', 'no-underscore-dangle': 'off',
@@ -33,6 +44,7 @@ export default antfu(
ignorePropertyModificationsFor: ['state', 'acc', 'req'], ignorePropertyModificationsFor: ['state', 'acc', 'req'],
}], }],
'prefer-destructuring': 'off', 'prefer-destructuring': 'off',
'import-x/no-unresolved': 'error',
'import/prefer-default-export': 'off', 'import/prefer-default-export': 'off',
'unused-imports/no-unused-vars': ['error', { 'unused-imports/no-unused-vars': ['error', {
args: 'after-used', args: 'after-used',

482
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "traxxx-web", "name": "traxxx-web",
"version": "0.55.0", "version": "0.57.15",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"version": "0.55.0", "version": "0.57.15",
"dependencies": { "dependencies": {
"@brillout/json-serializer": "^0.5.25", "@brillout/json-serializer": "^0.5.25",
"@dicebear/collection": "7.0.5", "@dicebear/collection": "7.0.5",
@@ -36,6 +36,7 @@
"ejs": "^6.0.1", "ejs": "^6.0.1",
"error-stack-parser": "^2.1.4", "error-stack-parser": "^2.1.4",
"escape-string-regexp": "^5.0.0", "escape-string-regexp": "^5.0.0",
"eslint-plugin-import-x": "^4.17.1",
"express": "^5.2.1", "express": "^5.2.1",
"express-session": "^1.19.0", "express-session": "^1.19.0",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
@@ -6113,7 +6114,7 @@
"version": "8.63.0", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz",
"integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/tsconfig-utils": "^8.63.0",
@@ -6339,7 +6340,7 @@
"version": "8.63.0", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz",
"integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.63.0", "@typescript-eslint/types": "8.63.0",
@@ -6357,7 +6358,7 @@
"version": "8.63.0", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz",
"integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6399,7 +6400,6 @@
"version": "8.63.0", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
"integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6413,7 +6413,7 @@
"version": "8.63.0", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz",
"integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/project-service": "8.63.0",
@@ -6441,7 +6441,7 @@
"version": "7.8.5", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -6454,7 +6454,7 @@
"version": "8.63.0", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz",
"integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.1", "@eslint-community/eslint-utils": "^4.9.1",
@@ -6478,7 +6478,7 @@
"version": "8.63.0", "version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz",
"integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.63.0", "@typescript-eslint/types": "8.63.0",
@@ -6496,7 +6496,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
@@ -6663,6 +6663,328 @@
"@universal-middleware/core": "^0.4.18" "@universal-middleware/core": "^0.4.18"
} }
}, },
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz",
"integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@unrs/resolver-binding-android-arm64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz",
"integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@unrs/resolver-binding-darwin-arm64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz",
"integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@unrs/resolver-binding-darwin-x64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz",
"integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@unrs/resolver-binding-freebsd-x64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz",
"integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz",
"integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz",
"integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz",
"integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz",
"integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-loong64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz",
"integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-loong64-musl": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz",
"integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz",
"integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz",
"integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz",
"integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz",
"integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz",
"integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-x64-musl": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz",
"integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-openharmony-arm64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz",
"integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz",
"integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
"integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz",
"integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz",
"integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@videojs/http-streaming": { "node_modules/@videojs/http-streaming": {
"version": "3.17.5", "version": "3.17.5",
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.5.tgz", "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.5.tgz",
@@ -8363,7 +8685,6 @@
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz",
"integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==", "integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
@@ -9756,6 +10077,30 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/eslint-import-context": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz",
"integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==",
"license": "MIT",
"dependencies": {
"get-tsconfig": "^4.10.1",
"stable-hash-x": "^0.2.0"
},
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint-import-context"
},
"peerDependencies": {
"unrs-resolver": "^1.0.0"
},
"peerDependenciesMeta": {
"unrs-resolver": {
"optional": true
}
}
},
"node_modules/eslint-import-resolver-node": { "node_modules/eslint-import-resolver-node": {
"version": "0.3.10", "version": "0.3.10",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz",
@@ -9932,6 +10277,54 @@
"eslint": "^9.0.0 || ^10.0.0" "eslint": "^9.0.0 || ^10.0.0"
} }
}, },
"node_modules/eslint-plugin-import-x": {
"version": "4.17.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.17.1.tgz",
"integrity": "sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "^8.56.0",
"comment-parser": "^1.4.1",
"debug": "^4.4.1",
"eslint-import-context": "^0.1.9",
"is-glob": "^4.0.3",
"minimatch": "^9.0.3 || ^10.1.2",
"semver": "^7.7.2",
"stable-hash-x": "^0.2.0",
"unrs-resolver": "^1.9.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint-plugin-import-x"
},
"peerDependencies": {
"@typescript-eslint/utils": "^8.56.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"eslint-import-resolver-node": "*"
},
"peerDependenciesMeta": {
"@typescript-eslint/utils": {
"optional": true
},
"eslint-import-resolver-node": {
"optional": true
}
}
},
"node_modules/eslint-plugin-import-x/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-plugin-jsdoc": { "node_modules/eslint-plugin-jsdoc": {
"version": "63.0.13", "version": "63.0.13",
"resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-63.0.13.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-63.0.13.tgz",
@@ -11318,7 +11711,6 @@
"version": "4.14.0", "version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"resolve-pkg-maps": "^1.0.0" "resolve-pkg-maps": "^1.0.0"
@@ -15871,6 +16263,21 @@
"optional": true, "optional": true,
"peer": true "peer": true
}, },
"node_modules/napi-postinstall": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
"integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==",
"license": "MIT",
"bin": {
"napi-postinstall": "lib/cli.js"
},
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/napi-postinstall"
}
},
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -17694,7 +18101,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
@@ -18478,6 +18884,15 @@
"node": ">=20.16.0" "node": ">=20.16.0"
} }
}, },
"node_modules/stable-hash-x": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz",
"integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/stack-trace": { "node_modules/stack-trace": {
"version": "0.0.10", "version": "0.0.10",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
@@ -19412,7 +19827,7 @@
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.12" "node": ">=18.12"
@@ -20320,6 +20735,43 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/unrs-resolver": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
"integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"napi-postinstall": "^0.3.4"
},
"funding": {
"url": "https://opencollective.com/unrs-resolver"
},
"optionalDependencies": {
"@unrs/resolver-binding-android-arm-eabi": "1.12.2",
"@unrs/resolver-binding-android-arm64": "1.12.2",
"@unrs/resolver-binding-darwin-arm64": "1.12.2",
"@unrs/resolver-binding-darwin-x64": "1.12.2",
"@unrs/resolver-binding-freebsd-x64": "1.12.2",
"@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2",
"@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2",
"@unrs/resolver-binding-linux-arm64-gnu": "1.12.2",
"@unrs/resolver-binding-linux-arm64-musl": "1.12.2",
"@unrs/resolver-binding-linux-loong64-gnu": "1.12.2",
"@unrs/resolver-binding-linux-loong64-musl": "1.12.2",
"@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2",
"@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2",
"@unrs/resolver-binding-linux-riscv64-musl": "1.12.2",
"@unrs/resolver-binding-linux-s390x-gnu": "1.12.2",
"@unrs/resolver-binding-linux-x64-gnu": "1.12.2",
"@unrs/resolver-binding-linux-x64-musl": "1.12.2",
"@unrs/resolver-binding-openharmony-arm64": "1.12.2",
"@unrs/resolver-binding-wasm32-wasi": "1.12.2",
"@unrs/resolver-binding-win32-arm64-msvc": "1.12.2",
"@unrs/resolver-binding-win32-ia32-msvc": "1.12.2",
"@unrs/resolver-binding-win32-x64-msvc": "1.12.2"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View File

@@ -37,6 +37,7 @@
"ejs": "^6.0.1", "ejs": "^6.0.1",
"error-stack-parser": "^2.1.4", "error-stack-parser": "^2.1.4",
"escape-string-regexp": "^5.0.0", "escape-string-regexp": "^5.0.0",
"eslint-plugin-import-x": "^4.17.1",
"express": "^5.2.1", "express": "^5.2.1",
"express-session": "^1.19.0", "express-session": "^1.19.0",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
@@ -91,7 +92,7 @@
"overrides": { "overrides": {
"vite": "$vite" "vite": "$vite"
}, },
"version": "0.55.0", "version": "0.57.15",
"imports": { "imports": {
"#/*": "./*.js" "#/*": "./*.js"
}, },

View File

@@ -1,5 +1,11 @@
export default { export default {
meta: { meta: {
title: {
env: {
server: true,
client: true,
},
},
data: { data: {
env: { env: {
server: true, server: true,

View File

@@ -16,7 +16,6 @@ export async function onBeforeRender(pageContext) {
return { return {
pageContext: { pageContext: {
title: 'actors',
pageProps: { pageProps: {
actors, actors,
countries, countries,

1
pages/actors/+title.js Normal file
View File

@@ -0,0 +1 @@
export default 'Actors';

View File

@@ -1,12 +1,15 @@
<script setup> <script setup>
import { inject, onMounted, ref } from 'vue'; import { inject, onMounted, ref } from 'vue';
import Password from '#/components/form/password.vue';
import { post } from '#/src/api.js'; import { post } from '#/src/api.js';
import navigate from '#/src/navigate.js'; import navigate from '#/src/navigate.js';
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const user = pageContext.user; const user = pageContext.user;
const allowSignup = pageContext.env.allowSignup; const allowSignup = pageContext.env.allowSignup;
const allowRecovery = pageContext.env.emailEnabled;
const username = ref(''); const username = ref('');
const password = ref(''); const password = ref('');
@@ -14,7 +17,6 @@ const password = ref('');
const submitted = ref(false); const submitted = ref(false);
const errorMsg = ref(null); const errorMsg = ref(null);
const userInput = ref(null); const userInput = ref(null);
const showPassword = ref(false);
async function login() { async function login() {
submitted.value = true; submitted.value = true;
@@ -88,31 +90,10 @@ onMounted(() => {
class="input" class="input"
> >
<div class="password-container"> <Password
<input :password="password"
v-model="password" @input="(newPassword) => password = newPassword"
:type="showPassword ? 'input' : 'password'" />
placeholder="Password"
class="password input"
>
<div
class="password-show"
@click="showPassword = !showPassword"
>
<Icon
v-show="!showPassword"
icon="eye"
class="password-show"
/>
<Icon
v-show="showPassword"
icon="eye-blocked"
class="password-show"
/>
</div>
</div>
<button <button
class="button button-submit" class="button button-submit"
@@ -124,11 +105,21 @@ onMounted(() => {
href="/signup" href="/signup"
class="link" class="link"
>Create an account</a> >Create an account</a>
<a
v-if="allowRecovery"
href="/recovery"
class="link"
>I forgot my password</a>
</form> </form>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.input {
width: 100%;
}
.login-container { .login-container {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
@@ -200,4 +191,8 @@ onMounted(() => {
font-weight: bold; font-weight: bold;
text-align: center; text-align: center;
} }
.button-submit {
padding: .75rem 1rem;
}
</style> </style>

View File

@@ -0,0 +1 @@
export default 'Login';

View 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>

View File

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

View File

@@ -0,0 +1 @@
export default `Account recovery`;

View File

@@ -2,32 +2,30 @@
import VueHCaptcha from '@hcaptcha/vue3-hcaptcha'; import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
import { inject, onMounted, ref } from 'vue'; 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 { post } from '#/src/api.js';
import navigate from '#/src/navigate.js'; import navigate from '#/src/navigate.js';
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const { user, env } = pageContext; const { user, env } = pageContext;
const { token } = pageContext.urlParsed.search;
const username = ref(''); const username = ref('');
const email = ref(''); const email = ref('');
const password = ref(''); const password = ref('');
const passwordConfirm = ref('');
const errorMsg = ref(null); const errorMsg = ref(null);
const submitted = ref(false); const submitted = ref(false);
const userInput = ref(null); const success = ref(false);
const showPassword = ref(false); const mainInput = ref(null);
const captcha = ref(null); const captcha = ref(null);
async function signup() { async function signup() {
errorMsg.value = null; errorMsg.value = null;
submitted.value = true; submitted.value = true;
if (password.value !== passwordConfirm.value) {
errorMsg.value = 'Passwords do not match';
return;
}
if (env.captcha.enabled && !captcha.value) { if (env.captcha.enabled && !captcha.value) {
errorMsg.value = 'Please complete the CAPTCHA'; errorMsg.value = 'Please complete the CAPTCHA';
return; return;
@@ -35,11 +33,14 @@ async function signup() {
try { try {
const newUser = await post('/users', { const newUser = await post('/users', {
token,
username: username.value, username: username.value,
email: email.value,
password: password.value, password: password.value,
redirect: pageContext.urlParsed.search.r, redirect: pageContext.urlParsed.search.r,
captcha: captcha.value, captcha: captcha.value,
}, {
successFeedback: 'Sign-up successful!',
appendErrorMessage: true,
}); });
navigate(`/user/${newUser.username}`, null, { redirect: 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(() => { onMounted(() => {
userInput.value.focus(); mainInput.value.focus();
}); });
</script> </script>
@@ -87,7 +110,7 @@ onMounted(() => {
</div> </div>
<form <form
v-else v-else-if="token"
autocomplete="off" autocomplete="off"
class="login-panel" class="login-panel"
@submit.prevent="signup" @submit.prevent="signup"
@@ -98,78 +121,18 @@ onMounted(() => {
>{{ errorMsg }}</div> >{{ errorMsg }}</div>
<input <input
ref="userInput" ref="mainInput"
v-model="username" v-model="username"
placeholder="Username" placeholder="Username"
class="input" class="input"
required required
> >
<input <Password
v-model="email" :password="password"
type="email" autocomplete="new-password"
placeholder="E-mail" @input="(newPassword) => password = newPassword"
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>
<VueHCaptcha <VueHCaptcha
v-if="env.captcha.enabled" v-if="env.captcha.enabled"
@@ -179,20 +142,62 @@ onMounted(() => {
@expired="captcha = null" @expired="captcha = null"
/> />
<Ellipsis v-if="submitted" />
<button <button
v-else
class="button button-submit" class="button button-submit"
:disabled="submitted" :disabled="submitted"
>Sign up</button> >Sign up</button>
</form>
<a <form
href="/login" v-else
class="link" autocomplete="off"
>I already have an account</a> 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> </form>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.input {
width: 100%;
}
.login-container { .login-container {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
@@ -219,7 +224,7 @@ onMounted(() => {
} }
.link { .link {
margin-top: .5rem; margin-top: 1rem;
text-align: center; text-align: center;
} }
} }
@@ -261,6 +266,7 @@ onMounted(() => {
} }
.button-submit { .button-submit {
padding: .75rem 1rem;
margin-top: .5rem; margin-top: .5rem;
} }
@@ -274,4 +280,18 @@ onMounted(() => {
font-weight: bold; font-weight: bold;
text-align: center; text-align: center;
} }
.success {
line-height: 1.25;
}
.success-heading {
display: block;
color: var(--primary);
margin-bottom: .5rem;
}
.load-container {
justify-content: center;
}
</style> </style>

View File

@@ -0,0 +1 @@
export default 'Sign up';

View File

@@ -4,7 +4,7 @@ export async function onBeforeRender(pageContext) {
const networks = await fetchEntities(pageContext.urlParsed.search.q const networks = await fetchEntities(pageContext.urlParsed.search.q
? { query: pageContext.urlParsed.search.q } ? { query: pageContext.urlParsed.search.q }
: { type: 'primary' }, { : { type: 'primary' }, {
restriction: pageContext.restriction, restriction: pageContext.geo.restriction,
}); });
return { return {

View File

@@ -21,7 +21,7 @@ async function fetchReleases(pageContext, entityId) {
limit: Number(pageContext.urlParsed.search.limit) || 30, limit: Number(pageContext.urlParsed.search.limit) || 30,
aggregate: true, aggregate: true,
}, pageContext.user, { }, 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, limit: Number(pageContext.urlParsed.search.limit) || 30,
aggregate: true, aggregate: true,
}, pageContext.user, { }, pageContext.user, {
restriction: pageContext.restriction, restriction: pageContext.geo.restriction,
}); });
} }
@@ -52,7 +52,7 @@ export async function onBeforeRender(pageContext) {
entityReleases, entityReleases,
] = await Promise.all([ ] = await Promise.all([
fetchEntitiesById([Number(entityId)], { includeChildren: true }, pageContext.user, { fetchEntitiesById([Number(entityId)], { includeChildren: true }, pageContext.user, {
restriction: pageContext.restriction, restriction: pageContext.geo.restriction,
}), }),
fetchReleases(pageContext, entityId), fetchReleases(pageContext, entityId),
]); ]);

View File

@@ -10,7 +10,7 @@ export async function onBeforeRender(pageContext) {
limit: Number(pageContext.urlParsed.search.limit) || 50, limit: Number(pageContext.urlParsed.search.limit) || 50,
dedupe: true, dedupe: true,
}, pageContext.user, { }, pageContext.user, {
restriction: pageContext.restriction, restriction: pageContext.geo.restriction,
}); });
return { return {

View File

@@ -18,7 +18,7 @@ function getTitle(movie) {
export async function onBeforeRender(pageContext) { export async function onBeforeRender(pageContext) {
const [[movie], movieScenes] = await Promise.all([ 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({ fetchScenes(await curateScenesQuery({
...pageContext.urlQuery, ...pageContext.urlQuery,
scope: 'oldest', scope: 'oldest',
@@ -28,7 +28,7 @@ export async function onBeforeRender(pageContext) {
limit: Number(pageContext.urlParsed.search.limit) || 30, limit: Number(pageContext.urlParsed.search.limit) || 30,
aggregate: true, aggregate: true,
}, pageContext.user, { }, pageContext.user, {
restriction: pageContext.restriction, restriction: pageContext.geo.restriction,
}), }),
]); ]);

1
pages/recovery/+title.js Normal file
View File

@@ -0,0 +1 @@
export default 'Account recovery';

View File

@@ -18,7 +18,7 @@ export async function onBeforeRender(pageContext) {
aggregate: true, aggregate: true,
dedupe: true, dedupe: true,
}, pageContext.user, { }, pageContext.user, {
restriction: pageContext.restriction, restriction: pageContext.geo.restriction,
}), }),
getRandomCampaigns([ getRandomCampaigns([
{ minRatio: 0.75, maxRatio: 1.25 }, { minRatio: 0.75, maxRatio: 1.25 },

View File

@@ -25,7 +25,7 @@ export async function onBeforeRender(pageContext) {
includeAssets: true, includeAssets: true,
includePartOf: true, includePartOf: true,
actorStashes: true, actorStashes: true,
restriction: pageContext.restriction, restriction: pageContext.geo.restriction,
}); });
if (!scene) { if (!scene) {

View File

@@ -16,24 +16,24 @@ export async function onBeforeRender(pageContext) {
}), { }), {
page: Number(pageContext.routeParams.page) || 1, page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 15, limit: Number(pageContext.urlParsed.search.limit) || 15,
}, pageContext.user, { restriction: pageContext.restriction }), }, pageContext.user, { restriction: pageContext.geo.restriction }),
fetchActors(curateActorsQuery(pageContext.urlQuery), { fetchActors(curateActorsQuery(pageContext.urlQuery), {
page: Number(pageContext.routeParams.page) || 1, page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 10, limit: Number(pageContext.urlParsed.search.limit) || 10,
order: ['results', 'desc'], order: ['results', 'desc'],
}, pageContext.user, { restriction: pageContext.restriction }), }, pageContext.user, { restriction: pageContext.geo.restriction }),
fetchMovies(await curateMoviesQuery({ fetchMovies(await curateMoviesQuery({
...pageContext.urlQuery, ...pageContext.urlQuery,
scope: pageContext.routeParams.scope || 'results', scope: pageContext.routeParams.scope || 'results',
}), { }), {
page: Number(pageContext.routeParams.page) || 1, page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 5, limit: Number(pageContext.urlParsed.search.limit) || 5,
}, pageContext.user, { restriction: pageContext.restriction }), }, pageContext.user, { restriction: pageContext.geo.restriction }),
fetchEntities({ fetchEntities({
query: pageContext.urlParsed.search.q, query: pageContext.urlParsed.search.q,
limit: 5, limit: 5,
}, { }, {
restriction: pageContext.restriction, restriction: pageContext.geo.restriction,
}), }),
]); ]);

View File

@@ -279,6 +279,7 @@ onMounted(() => {
.tags { .tags {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
grid-auto-rows: max-content; /* prevent row gaps on large screens */
gap: 1rem .5rem; gap: 1rem .5rem;
padding: .75rem 1rem; padding: .75rem 1rem;
} }

View File

@@ -118,7 +118,7 @@ const tagSlugs = {
}; };
async function searchTags(pageContext) { 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 { return {
pageContext: { pageContext: {
@@ -137,13 +137,13 @@ export async function onBeforeRender(pageContext) {
return searchTags(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 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 tagsBySlug = Object.fromEntries(filteredTags.map((tag) => [tag.slug, tag]));
const tagShowcase = Object.fromEntries(Object.entries(tagSlugs).map(([category, categorySlugs]) => [ 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), categorySlugs.map((slug) => tagsBySlug[slug]).filter(Boolean),
])); ]));

View File

@@ -23,7 +23,7 @@ async function fetchReleases(pageContext) {
limit: Number(pageContext.urlParsed.search.limit) || 30, limit: Number(pageContext.urlParsed.search.limit) || 30,
aggregate: true, aggregate: true,
}, pageContext.user, { }, pageContext.user, {
restriction: pageContext.restriction, restriction: pageContext.geo.restriction,
}); });
} }
@@ -37,13 +37,13 @@ async function fetchReleases(pageContext) {
limit: Number(pageContext.urlParsed.search.limit) || 30, limit: Number(pageContext.urlParsed.search.limit) || 30,
aggregate: true, aggregate: true,
}, pageContext.user, { }, pageContext.user, {
restriction: pageContext.restriction, restriction: pageContext.geo.restriction,
}); });
} }
export async function onBeforeRender(pageContext) { export async function onBeforeRender(pageContext) {
const tagSlug = pageContext.routeParams.tagSlug; 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) { if (tag.aliasFor) {
throw redirect(`/tag/${tag.aliasFor}`); throw redirect(`/tag/${tag.aliasFor}`);

View File

@@ -19,7 +19,7 @@ export async function onBeforeRender(pageContext) {
limit: Number(pageContext.urlParsed.search.limit) || 29, limit: Number(pageContext.urlParsed.search.limit) || 29,
aggregate: withQuery, aggregate: withQuery,
dedupe: true, dedupe: true,
}, pageContext.user, { restriction: pageContext.restriction }), }, pageContext.user, { restriction: pageContext.geo.restriction }),
getRandomCampaigns([ getRandomCampaigns([
{ minRatio: 2.0, maxRatio: 5 }, { minRatio: 2.0, maxRatio: 5 },
{ minRatio: 0.75, maxRatio: 1.25 }, { minRatio: 0.75, maxRatio: 1.25 },

View File

@@ -9,6 +9,7 @@ import Summaries from '#/components/scenes/summaries.vue';
// filters are implemented as generic settings panel in case we want to extend it in the future // 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 Settings from '#/components/settings/settings.vue';
import Stashes from '#/components/stashes/stashes.vue'; import Stashes from '#/components/stashes/stashes.vue';
import Account from '#/components/user/account.vue';
import ApiKeys from '#/components/user/api-keys.vue'; import ApiKeys from '#/components/user/api-keys.vue';
import Preferences from '#/components/user/preferences.vue'; import Preferences from '#/components/user/preferences.vue';
@@ -19,6 +20,7 @@ const domain = pageContext.routeParams.domain;
const user = pageContext.user; const user = pageContext.user;
const profile = ref(pageContext.pageProps.profile); const profile = ref(pageContext.pageProps.profile);
const primaryStash = pageContext.assets?.primaryStash;
const showFilters = ref(false); const showFilters = ref(false);
@@ -33,6 +35,7 @@ function scrollHorizontal(event) {
<div class="profile-header"> <div class="profile-header">
<a <a
:href="`/user/${profile.username}/`" :href="`/user/${profile.username}/`"
:title="`#${profile.id}`"
class="user nolink" class="user nolink"
> >
<Icon <Icon
@@ -69,6 +72,12 @@ function scrollHorizontal(event) {
class="domains nobar" class="domains nobar"
@wheel.prevent="scrollHorizontal" @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 <a
:href="`/user/${profile.username}/stashes`" :href="`/user/${profile.username}/stashes`"
class="domain nolink" class="domain nolink"
@@ -87,13 +96,6 @@ function scrollHorizontal(event) {
:class="{ active: section === 'templates' }" :class="{ active: section === 'templates' }"
><Icon icon="paste3" />Templates</a> ><Icon icon="paste3" />Templates</a>
<a
v-if="profile.isIdentityVerified"
:href="`/user/${profile.username}/api`"
class="domain nolink"
:class="{ active: section === 'api' }"
><Icon icon="key" />API Keys</a>
<a <a
:href="`/user/${profile.username}/revisions/scenes`" :href="`/user/${profile.username}/revisions/scenes`"
class="domain nolink" class="domain nolink"
@@ -113,11 +115,24 @@ function scrollHorizontal(event) {
@click="showFilters = true" @click="showFilters = true"
><Icon icon="filter" />Filters</span> ><Icon icon="filter" />Filters</span>
<a
v-if="profile.isIdentityVerified"
:href="`/user/${profile.username}/api`"
class="domain nolink"
:class="{ active: section === 'api' }"
><Icon icon="key" />API Keys</a>
<a <a
:href="`/user/${profile.username}/preferences`" :href="`/user/${profile.username}/preferences`"
class="domain nolink" class="domain nolink"
:class="{ active: section === 'preferences' }" :class="{ active: section === 'preferences' }"
><Icon icon="equalizer" />Preferences</a> ><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> </nav>
<Stashes v-if="section === 'stashes'" /> <Stashes v-if="section === 'stashes'" />
@@ -128,13 +143,9 @@ function scrollHorizontal(event) {
:release="mockupRelease" :release="mockupRelease"
/> />
<ApiKeys <ApiKeys v-if="section === 'api'" />
v-if="section === 'api'" <Preferences v-if="section === 'preferences'" />
/> <Account v-if="section === 'account'" />
<Preferences
v-if="section === 'preferences'"
/>
</div> </div>
<div <div
@@ -259,7 +270,7 @@ function scrollHorizontal(event) {
gap: .5rem; gap: .5rem;
box-sizing: border-box; box-sizing: border-box;
padding: .5rem 0; padding: .5rem 0;
margin-top: .5rem; margin-bottom: 1rem;
} }
.domain { .domain {

View File

@@ -31,7 +31,7 @@ async function fetchRevisions(pageContext) {
export async function onBeforeRender(pageContext) { export async function onBeforeRender(pageContext) {
const [profile, alerts, userRevisions, keys] = await Promise.all([ 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 pageContext.routeParams.section === 'alerts' && pageContext.routeParams.username === pageContext.user?.username
? fetchAlerts(pageContext.user) ? fetchAlerts(pageContext.user)
: [], : [],

View 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>

View File

@@ -9,6 +9,6 @@ export default {
'assets', 'assets',
'campaigns', 'campaigns',
'meta', 'meta',
'restriction', 'geo',
], ],
}; };

View File

@@ -5,11 +5,15 @@ import { dangerouslySkipEscape, escapeInject } from 'vike/server';
import { createApp } from './app.js'; import { createApp } from './app.js';
function getTitle(location) { function getTitle(location, pageContext) {
if (!location) { if (!location) {
return config.title; return config.title;
} }
if (typeof location === 'function') {
return getTitle(location(pageContext), pageContext);
}
return `${config.title} - ${location.slice(0, 1).toUpperCase()}${location.slice(1)}`; return `${config.title} - ${location.slice(0, 1).toUpperCase()}${location.slice(1)}`;
} }
@@ -44,8 +48,7 @@ async function onRenderHtml(pageContext) {
const appHtml = await renderToString(app); const appHtml = await renderToString(app);
// See https://vike.dev/head // See https://vike.dev/head
const { documentProps } = pageContext.exports; const title = getTitle(pageContext.title || pageContext.config.title, pageContext);
const title = getTitle(documentProps?.title || pageContext.title);
const documentHtml = escapeInject`<!DOCTYPE html> const documentHtml = escapeInject`<!DOCTYPE html>
<html lang="en"> <html lang="en">

View File

@@ -32,7 +32,14 @@ function createApp(Page, pageProps, pageContext) {
app.provide('pageContext', 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.use(VueVirtualScroller);
app.component('Link', Link); app.component('Link', Link);

View File

@@ -189,7 +189,7 @@ onMounted(() => {
justify-content: center; justify-content: center;
position: fixed; position: fixed;
bottom: 2rem; bottom: 2rem;
z-index: 1000; z-index: 10000;
pointer-events: none; pointer-events: none;
} }

View File

@@ -304,18 +304,18 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
.orderBy(knex.raw('max(actors_avatars.created_at)'), 'desc'), .orderBy(knex.raw('max(actors_avatars.created_at)'), 'desc'),
knex('actors_socials') knex('actors_socials')
.whereIn('actor_id', actorIds), .whereIn('actor_id', actorIds),
reqUser reqUser && !options.shallow
? knex('stashes_actors') ? knex('stashes_actors')
.leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id') .leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id')
.where('stashes.user_id', reqUser.id) .where('stashes.user_id', reqUser.id)
.whereIn('stashes_actors.actor_id', actorIds) .whereIn('stashes_actors.actor_id', actorIds)
: [], : [],
reqUser reqUser && !options.shallow
? knex('alerts_users_actors') ? knex('alerts_users_actors')
.where('user_id', reqUser.id) .where('user_id', reqUser.id)
.whereIn('actor_id', actorIds) .whereIn('actor_id', actorIds)
: [], : [],
].slice(0, options.shallow ? 1 : -1)); ].slice(0, options.shallow ? 1 : undefined));
if (options.order) { if (options.order) {
return actors.map((actorEntry) => curateActor(actorEntry, { 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); builder.where('alias_for', 0);
} }
@@ -867,7 +867,6 @@ export async function mergeActors(targetActorId, sourceActorIds, comment, reqUse
.update('alias_for', targetActorId) .update('alias_for', targetActorId)
.whereIn('id', sourceActorIds) .whereIn('id', sourceActorIds)
.returning(['id', 'alias_for']), .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') trx('actors_avatars')
.update('actor_id', targetActorId) .update('actor_id', targetActorId)
.whereIn('actor_id', sourceActorIds), .whereIn('actor_id', sourceActorIds),

View File

@@ -3,11 +3,10 @@ import { CronJob } from 'cron';
import { add } from 'date-fns'; import { add } from 'date-fns';
import escapeRegexp from 'escape-string-regexp'; import escapeRegexp from 'escape-string-regexp';
import markdownIt from 'markdown-it'; import markdownIt from 'markdown-it';
import { Resend } from 'resend';
import promiseProps from '../utils/promise-props.js'; import promiseProps from '../utils/promise-props.js';
import { getIdsBySlug } from './cache.js'; import { getIdsBySlug } from './cache.js';
import { emailTemplates } from './email-templates.js'; import { sendEmails } from './email.js';
import { HttpError } from './errors.js'; import { HttpError } from './errors.js';
import { knexOwner as knex } from './knex.js'; import { knexOwner as knex } from './knex.js';
import initLogger from './logger.js'; import initLogger from './logger.js';
@@ -15,18 +14,6 @@ import { indexApi } from './manticore.js';
import { fetchScenesById } from './scenes.js'; import { fetchScenesById } from './scenes.js';
const logger = initLogger(); 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;
})();
function getImagePath(media) { function getImagePath(media) {
if (!media) { if (!media) {
@@ -262,7 +249,7 @@ export async function createAlert(alert, reqUser) {
entityIds?.length > 0 && knex('alerts_entities').insert(entityIds.map((entityId) => ({ entityIds?.length > 0 && knex('alerts_entities').insert(entityIds.map((entityId) => ({
alert_id: alertId, alert_id: alertId,
entity_id: entityId, 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) => ({ sceneIds?.length > 0 && knex('alerts_scenes').insert(sceneIds.map((sceneId) => ({
alert_id: alertId, alert_id: alertId,
scene_id: sceneId, scene_id: sceneId,
@@ -452,11 +439,6 @@ async function sendEmailAlerts() {
return; return;
} }
if (!resend) {
logger.warn('E-mails are enabled, but Resend is not initialized');
return;
}
const qualifyingUsers = await knex('notifications') const qualifyingUsers = await knex('notifications')
.select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email')) .select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email'))
.where('notifications.email', true) .where('notifications.email', true)
@@ -546,7 +528,7 @@ async function sendEmailAlerts() {
&& !!notification.scene && !!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 && 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 emails = await Promise.all(Object.values(usersByUserId).map(async (user) => { const emailItems = Object.values(usersByUserId).map((user) => {
const userTriggers = Object.values(Object.fromEntries(triggers const userTriggers = Object.values(Object.fromEntries(triggers
.filter((trigger) => trigger.user.id === user.id) .filter((trigger) => trigger.user.id === user.id)
.map((trigger) => [trigger.scene.id, trigger]))) .map((trigger) => [trigger.scene.id, trigger])))
@@ -556,41 +538,23 @@ async function sendEmailAlerts() {
return null; return null;
} }
try { return {
const { html, text } = await emailTemplates.render('alert.vue', { template: 'alert',
props: { to: user.email,
user, subject: `Notification for ${userTriggers.length} scenes on traxxx`,
triggers: userTriggers, props: {
}, user,
}); triggers: userTriggers,
},
};
}).filter(Boolean);
return { if (emailItems.length === 0) {
from: config.email.from,
to: user.email,
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
html,
text,
};
}
catch (error) {
logger.warn(`Failed to render e-mail for '${user.username}' with scenes ${userTriggers.map((trigger) => `${trigger.notificationId}: ${trigger.scene.id}`)}: ${error.message}`);
return null;
}
})).then((allEmails) => allEmails.filter(Boolean));
if (emails.length === 0) {
logger.info(`No alert e-mails sent`); logger.info(`No alert e-mails sent`);
return; return;
} }
const { data, error } = await resend.batch.send(emails); await sendEmails(emailItems);
if (error) {
logger.error(`Failed to send ${emails.length} alert e-mails (${error.statusCode} ${error.name}): ${error.message}`);
}
else {
logger.info(`Sent ${data.data.length}/${emails.length} alert e-mails`);
}
} }
export async function notify(sceneIds, options = {}) { export async function notify(sceneIds, options = {}) {
@@ -790,7 +754,7 @@ export async function notify(sceneIds, options = {}) {
const docs = stashedEntries.map((stash) => ({ const docs = stashedEntries.map((stash) => ({
replace: { replace: {
index: 'scenes_stashed', table: 'scenes_stashed',
id: stash.id, id: stash.id,
doc: { doc: {
scene_id: stash.scene_id, scene_id: stash.scene_id,
@@ -846,7 +810,13 @@ async function alertSceneReleases() {
.update('is_expired', true); .update('is_expired', true);
await trx.commit(); 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) { catch (error) {
await trx.rollback(); await trx.rollback();
@@ -876,7 +846,12 @@ async function purgeExpiredAlerts() {
) )
.delete(); .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`); logger.info(`Purged ${purgedNotifications} notifications and ${purgedAlerts} alerts`);
} }

View File

@@ -1,3 +1,4 @@
import { Buffer } from 'node:buffer';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import util from 'node:util'; import util from 'node:util';
@@ -5,9 +6,10 @@ import { shapes } from '@dicebear/collection';
import { createAvatar } from '@dicebear/core'; import { createAvatar } from '@dicebear/core';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import config from 'config'; import config from 'config';
import { verify } from 'hcaptcha'; import { verify as verifyCaptcha } from 'hcaptcha';
import slugify from '../utils/slugify.js'; import slugify from '../utils/slugify.js';
import { sendEmails } from './email.js';
import { HttpError } from './errors.js'; import { HttpError } from './errors.js';
import { knexOwner as knex } from './knex.js'; import { knexOwner as knex } from './knex.js';
import initLogger, { initAccessLogger } from './logger.js'; import initLogger, { initAccessLogger } from './logger.js';
@@ -44,19 +46,71 @@ async function generateAvatar(user) {
logger.verbose(`Generated avatar for '${user.username}' (${user.id})`); 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) { export async function login(credentials, userIp) {
if (!config.auth.login) { 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(), { const { user } = await fetchUser(credentials.username.trim(), {
email: true, email: true,
raw: 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); 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') await knex('users')
.update('last_login', 'NOW()') .update('last_login', 'NOW()')
@@ -76,9 +130,278 @@ export async function login(credentials, userIp) {
return curateUser(user, 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) { export async function signup(credentials, userIp) {
if (!config.auth.signup) { 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(); const curatedUsername = credentials.username.trim();
@@ -99,41 +422,44 @@ export async function signup(credentials, userIp) {
throw new HttpError('Username contains invalid characters', 400); throw new HttpError('Username contains invalid characters', 400);
} }
if (!credentials.email) { const existingUser = await knex('users')
throw new HttpError('E-mail required', 400); .where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
} .first();
if (credentials.password?.length < 3) { if (existingUser) {
throw new HttpError('Password must be 3 characters or longer', 400); throw new HttpError('Username already in use', 409);
} }
if (config.auth.captcha.enabled) { 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) { 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); throw new HttpError('Invalid CAPTCHA', 400);
} }
} }
const existingUser = await knex('users') const tokenKey = `traxxx:token_signup:${credentials.token}`;
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase()) const tokenEntry = await redis.getDel(tokenKey);
.orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase())
.first();
if (existingUser) { if (!tokenEntry) {
throw new HttpError('Username or e-mail already in use', 409); throw new HttpError('Invalid sign-up link', 401);
} }
const salt = crypto.randomBytes(16).toString('hex'); const { email } = JSON.parse(tokenEntry);
const hashedPassword = (await scrypt(credentials.password, salt, 64)).toString('hex');
const storedPassword = `${salt}/${hashedPassword}`; if (!email) {
throw new HttpError('Invalid sign-up link', 401);
}
const { storedPassword } = await hashPassword(credentials.password);
const [{ id: userId }] = await knex('users') const [{ id: userId }] = await knex('users')
.insert({ .insert({
username: curatedUsername, username: curatedUsername,
email: credentials.email, email,
password: storedPassword, password: storedPassword,
email_verified: true,
}) })
.returning('id'); .returning('id');
@@ -145,14 +471,138 @@ export async function signup(credentials, userIp) {
primary: true, primary: true,
}); });
logger.info(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`); logger.info(`Signup from '${curatedUsername}' (${userId}, ${email}, ${userIp})`);
await generateAvatar({ await generateAvatar({
id: userId, id: userId,
username: curatedUsername, 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) { function curateKey(key) {

View File

@@ -1,10 +1,10 @@
import { readdirSync } from 'node:fs'; import { readdirSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import process from 'node:process';
import { config as configureEmailTemplates } from '@vue-email/compiler'; import { config as configureEmailTemplates } from '@vue-email/compiler';
export const emailsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../components/emails'); export const emailsDir = path.join(process.cwd(), 'components/emails');
export const emailTemplates = configureEmailTemplates(emailsDir, { verbose: false }); export const emailTemplates = configureEmailTemplates(emailsDir, { verbose: false });

84
src/email.js Normal file
View 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;
}

View File

@@ -1,7 +1,7 @@
import { pageContext } from '../renderer/usePageContext.js'; import { pageContext } from '../renderer/usePageContext.js';
function getBasePath(media, options) { function getBasePath(media, options) {
if (pageContext.restriction) { if (pageContext.geo.restriction) {
return pageContext.env.media.assetPath; return pageContext.env.media.assetPath;
} }
@@ -17,11 +17,11 @@ function getBasePath(media, options) {
} }
function getFilename(media, type, options) { function getFilename(media, type, options) {
if (pageContext.restriction && type && !options?.original) { if (pageContext.geo.restriction && type && !options?.original) {
return media.sfw?.[type]; return media.sfw?.[type];
} }
if (pageContext.restriction) { if (pageContext.geo.restriction) {
return media.sfw?.path; return media.sfw?.path;
} }

View File

@@ -25,10 +25,12 @@ export function curateStash(stash, assets = {}) {
slug: stash.slug, slug: stash.slug,
isPrimary: stash.primary, isPrimary: stash.primary,
isPublic: stash.public, isPublic: stash.public,
isArchived: stash.archived,
createdAt: stash.created_at, createdAt: stash.created_at,
stashedScenes: stash.stashed_scenes ?? null, stashedScenes: stash.stashed_scenes ?? null,
stashedMovies: stash.stashed_movies ?? null, stashedMovies: stash.stashed_movies ?? null,
stashedActors: stash.stashed_actors ?? null, stashedActors: stash.stashed_actors ?? null,
lastStashAt: stash.last_stash_at,
user: assets.user user: assets.user
? { ? {
id: assets.user.id, id: assets.user.id,
@@ -64,7 +66,8 @@ function curateStashEntry(stash, user) {
user_id: user?.id || undefined, user_id: user?.id || undefined,
name: stash.name || undefined, name: stash.name || undefined,
slug: slugify(stash.name) || undefined, slug: slugify(stash.name) || undefined,
public: stash.isPublic ?? false, public: stash.isPublic ?? undefined,
archived: stash.isArchived ?? undefined,
comment: stash.comment, comment: stash.comment,
meta: stash.meta, meta: stash.meta,
}; };
@@ -126,6 +129,10 @@ export async function fetchStashByUsernameAndSlug(usernameOrId, stashSlug, sessi
} }
export async function fetchUserStashes(usernameOrId, reqUser) { export async function fetchUserStashes(usernameOrId, reqUser) {
if (!usernameOrId) {
throw new HttpError(`No username or user ID provided`, 400);
}
const userId = typeof usernameOrId === 'number' const userId = typeof usernameOrId === 'number'
? usernameOrId ? usernameOrId
: await knex('users') : await knex('users')
@@ -134,7 +141,7 @@ export async function fetchUserStashes(usernameOrId, reqUser) {
.then((user) => user?.id); .then((user) => user?.id);
if (!userId) { if (!userId) {
throw new HttpError(`Could not find user '${usernameOrId}'`); throw new HttpError(`Could not find user '${usernameOrId}'`, 404);
} }
const stashes = await knex('stashes') const stashes = await knex('stashes')
@@ -146,7 +153,12 @@ export async function fetchUserStashes(usernameOrId, reqUser) {
if (userId !== reqUser?.id) { if (userId !== reqUser?.id) {
builder.where('public', true); 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 })); return stashes.map((stash) => curateStash(stash, { user: reqUser }));
} }

View File

@@ -14,7 +14,7 @@ function curateTemplate(template) {
}; };
} }
export function curateUser(user, reqUser) { export function curateUser(user, reqUser, options = {}) {
if (!user) { if (!user) {
return null; return null;
} }
@@ -35,7 +35,7 @@ export function curateUser(user, reqUser) {
role: user.role, role: user.role,
abilities: [...user.role_abilities || [], ...user.abilities || []], abilities: [...user.role_abilities || [], ...user.abilities || []],
createdAt: user.created_at, createdAt: user.created_at,
...(reqUser?.id === user.id ? privateUser : null), ...(reqUser?.id === user.id || options.includePrivate ? privateUser : null),
}; };
return curatedUser; return curatedUser;
@@ -56,6 +56,14 @@ function whereUser(builder, userId, options = {}) {
} }
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') const user = await knex('users')
.select(knex.raw('users.*, users_roles.abilities as role_abilities')) .select(knex.raw('users.*, users_roles.abilities as role_abilities'))
.modify((builder) => whereUser(builder, userId, options)) .modify((builder) => whereUser(builder, userId, options))
@@ -63,7 +71,7 @@ export async function fetchUser(userId, options = {}, reqUser) {
.groupBy('users.id', 'users_roles.role') .groupBy('users.id', 'users_roles.role')
.first(); .first();
if (!user) { if (!user && options.throwError !== false) {
throw new HttpError(`User '${userId}' not found`, 404); throw new HttpError(`User '${userId}' not found`, 404);
} }
@@ -71,7 +79,7 @@ export async function fetchUser(userId, options = {}, reqUser) {
return { user }; return { user };
} }
return curateUser(user, reqUser); return curateUser(user, reqUser, options);
} }
export async function fetchUserTemplates(reqUser) { export async function fetchUserTemplates(reqUser) {
@@ -119,8 +127,6 @@ export async function removeTemplate(templateId, reqUser) {
} }
export async function createBan(ban, reqUser) { export async function createBan(ban, reqUser) {
console.log(ban);
if (reqUser.role !== 'admin') { if (reqUser.role !== 'admin') {
throw new HttpError('You do not have sufficient privileges to set a ban', 403); throw new HttpError('You do not have sufficient privileges to set a ban', 403);
} }
@@ -136,32 +142,3 @@ export async function createBan(ban, reqUser) {
await knex('bans').insert(curatedBan); await knex('bans').insert(curatedBan);
} }
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;
}

View File

@@ -194,7 +194,7 @@ export async function createActorApi(req, res) {
export async function mergeActorsApi(req, res) { export async function mergeActorsApi(req, res) {
const result = await mergeActors( const result = await mergeActors(
Number(req.params.targetActorId), Number(req.params.targetActorId),
req.params.sourceActorIds.split(',').map((actorId) => Number(actorId)), req.body.actorIds,
req.body.comment, req.body.comment,
req.user, req.user,
); );
@@ -225,7 +225,7 @@ export const actorsRouter = Router();
actorsRouter.get('/api/actors', fetchActorsApi); actorsRouter.get('/api/actors', fetchActorsApi);
actorsRouter.post('/api/actors', createActorApi); 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', fetchActorRevisionsApi);
actorsRouter.get('/api/revisions/actors/:revisionId', fetchActorRevisionsApi); actorsRouter.get('/api/revisions/actors/:revisionId', fetchActorRevisionsApi);

View File

@@ -219,12 +219,12 @@ export async function updateNotificationGraphql(query, req) {
return updatedNotification; return updatedNotification;
} }
export const router = Router(); export const alertsRouter = Router();
router.get('/api/alerts', fetchAlertsApi); alertsRouter.get('/api/alerts', fetchAlertsApi);
router.post('/api/alerts', createAlertApi); alertsRouter.post('/api/alerts', createAlertApi);
router.delete('/api/alerts/:alertId', removeAlertApi); alertsRouter.delete('/api/alerts/:alertId', removeAlertApi);
router.get('/api/users/:userId/notifications', fetchNotificationsApi); alertsRouter.get('/api/users/:userId/notifications', fetchNotificationsApi);
router.patch('/api/users/:userId/notifications', updateNotificationsApi); alertsRouter.patch('/api/users/:userId/notifications', updateNotificationsApi);
router.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi); alertsRouter.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);

View File

@@ -1,5 +1,7 @@
import crypto from 'node:crypto'; import { Router } from 'express';
import IPCIDR from 'ip-cidr'; import IPCIDR from 'ip-cidr';
import runtimeId from '../../utils/runtime-id.js';
import argv from '../argv.js'; import argv from '../argv.js';
import { import {
@@ -8,31 +10,34 @@ import {
flushUserKeys, flushUserKeys,
login, login,
removeUserKey, removeUserKey,
resetPassword,
sendEmailVerification,
sendPasswordReset,
sendSignup,
signup, signup,
updateSettings,
updateUser,
verifyEmail,
} from '../auth.js'; } from '../auth.js';
import { fetchUser } from '../users.js'; import {
fetchUser,
// changes every time the process restarts, so a deploy invalidates all cached session users } from '../users.js';
// even if they're within the staleness window below
const runtimeId = crypto.randomUUID();
const sessionStaleThreshold = 5 * 60 * 1000;
// spread into a new object rather than mutating the user we're about to hand back to the // 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 // client (loginApi/signupApi send this straight to the browser) — runtimeId/refreshedAt are
// session bookkeeping only, they shouldn't leak into the API response // session bookkeeping only, they shouldn't leak into the API response
function toSession(user) { function toSession(user) {
if (!user) {
return null;
}
return { return {
...user, ...user,
runtimeId, runtimeId,
refreshedAt: Date.now(), refreshedAt: Date.now(),
}; };
} }
function isSessionStale(sessionUser) {
return sessionUser.runtimeId !== runtimeId || Date.now() - sessionUser.refreshedAt > sessionStaleThreshold;
}
// concurrent requests for the same user (e.g. the burst of asset/API requests a single page // 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 // load fires) share one in-flight refresh instead of each independently hitting the DB
const pendingSessionRefreshes = new Map(); const pendingSessionRefreshes = new Map();
@@ -81,16 +86,24 @@ function getIp(req) {
return expandedIp.addressStart?.addressMinusSuffix || null; 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) { export async function setUserApi(req, res, next) {
const ip = getIp(req); const ip = getIp(req);
req.userIp = ip; req.userIp = ip;
if (req.session.user) { if (isSessionStale(req.session.user)) {
if (isSessionStale(req.session.user)) { req.session.user = await refreshSessionUser(req.session.user);
req.session.user = await refreshSessionUser(req.session.user); }
}
if (req.session.user) {
req.user = req.session.user; req.user = req.session.user;
req.user.ip = ip; req.user.ip = ip;
} }
@@ -101,10 +114,12 @@ export async function setUserApi(req, res, next) {
export async function updateSessionUser(req) { export async function updateSessionUser(req) {
const user = toSession(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 = user;
req.user.ip = req.userIp; req.user.ip = req.userIp;
}
} }
export async function loginApi(req, res) { export async function loginApi(req, res) {
@@ -124,6 +139,12 @@ 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) { export async function signupApi(req, res) {
const user = await signup(req.body, req.userIp); const user = await signup(req.body, req.userIp);
@@ -131,6 +152,74 @@ export async function signupApi(req, res) {
res.send(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) { export async function fetchUserKeysApi(req, res) {
const keys = await fetchUserKeys(req.user); const keys = await fetchUserKeys(req.user);
@@ -154,3 +243,31 @@ export async function flushUserKeysApi(req, res) {
res.status(204).send(); 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);

View File

@@ -30,6 +30,7 @@ export default async function mainHandler(req, res, next) {
role: req.user.role, role: req.user.role,
abilities: req.user.abilities, abilities: req.user.abilities,
avatar: req.user.avatar, avatar: req.user.avatar,
isEmailVerified: req.user.isEmailVerified,
}, },
assets: req.user assets: req.user
? { ? {
@@ -59,7 +60,7 @@ export default async function mainHandler(req, res, next) {
siteKey: config.auth.captcha.siteKey, siteKey: config.auth.captcha.siteKey,
}, },
}, },
restriction: req.restriction, geo: req.geo,
meta: { meta: {
now: new Date().toISOString(), now: new Date().toISOString(),
}, },

View File

@@ -31,7 +31,7 @@ export async function fetchMoviesApi(req, res) {
} = await fetchMovies(await curateMoviesQuery(req.query), { } = await fetchMovies(await curateMoviesQuery(req.query), {
page: Number(req.query.page) || 1, page: Number(req.query.page) || 1,
limit: Number(req.query.limit) || 30, limit: Number(req.query.limit) || 30,
}, req.user, { restriction: req.restriction }); }, req.user, { restriction: req.geo.restriction });
res.send({ res.send({
movies, movies,
@@ -45,7 +45,7 @@ export async function fetchMoviesApi(req, res) {
} }
export async function fetchMovieApi(req, res) { 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) { if (!movie) {
throw new HttpError(`No movie with ID ${req.params.movieId} found`, 404); throw new HttpError(`No movie with ID ${req.params.movieId} found`, 404);
@@ -135,7 +135,7 @@ export async function fetchMoviesGraphql(query, req) {
page: query.page || 1, page: query.page || 1,
limit: query.limit || 30, limit: query.limit || 30,
aggregate: false, aggregate: false,
}, req.user, { restriction: req.restriction }); }, req.user, { restriction: req.geo.restriction });
return { return {
nodes: movies, nodes: movies,

View File

@@ -2,6 +2,7 @@ import path from 'node:path';
import { Reader } from '@maxmind/geoip2-node'; import { Reader } from '@maxmind/geoip2-node';
import config from 'config'; import config from 'config';
import runtimeId from '../../utils/runtime-id.js';
import initLogger from '../logger.js'; import initLogger from '../logger.js';
const logger = initLogger(); const logger = initLogger();
@@ -11,22 +12,47 @@ export default async function initRestrictionHandler() {
const reader = await Reader.open('assets/GeoLite2-City.mmdb'); const reader = await Reader.open('assets/GeoLite2-City.mmdb');
function getRestriction(req) { function getRestriction(req) {
if (Object.hasOwn(req.session, 'restriction') && Object.hasOwn(req.session, 'country') && req.session.restrictionIp === req.userIp) { if (req.session.geo
return { && Object.hasOwn(req.session.geo, 'country')
restriction: req.session.restriction, && req.session.geo.ip === req.userIp
country: req.session.country, && 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 location = reader.city(req.userIp);
const country = location.country.isoCode; const country = location.country.isoCode;
const subdivision = location.subdivisions?.[0]?.isoCode; const subdivision = location.subdivisions?.[0]?.isoCode;
if (!config.restrictions.enabled) {
return {
restriction: 0,
country,
subdivision,
};
}
if (regions[country]?.[subdivision]) { if (regions[country]?.[subdivision]) {
// state or province restriction // state or province restriction
return { return {
restriction: config.restrictions.modes[regions[country][subdivision]], restriction: config.restrictions.modes[regions[country][subdivision]],
country, country,
subdivision,
}; };
} }
@@ -35,23 +61,24 @@ export default async function initRestrictionHandler() {
return { return {
restriction: config.restrictions.modes[regions[country]], restriction: config.restrictions.modes[regions[country]],
country, country,
subdivision,
}; };
} }
return { return {
restriction: null, restriction: null,
country, country,
subdivision,
}; };
} }
function restrictionHandler(req, res, next) { function restrictionHandler(req, res, next) {
if (!config.restrictions.enabled) {
next();
return;
}
try { try {
const { restriction, country } = getRestriction(req); const {
restriction,
country,
subdivision,
} = getRestriction(req);
if (restriction === 'block' || req.path === '/sfw/') { if (restriction === 'block' || req.path === '/sfw/') {
res.render(path.join(import.meta.dirname, '../../assets/sfw.ejs'), { res.render(path.join(import.meta.dirname, '../../assets/sfw.ejs'), {
@@ -61,21 +88,38 @@ export default async function initRestrictionHandler() {
return; return;
} }
if (req.session.restriction !== restriction) { if (req.session.geo?.restriction !== restriction) {
req.session.restrictionIp = req.userIp; req.session.geo = {
req.session.restriction = restriction; ip: req.userIp,
req.session.country = country; runtimeId,
restriction,
country,
subdivision,
};
} }
req.restriction = restriction; req.geo = {
req.country = country; restriction,
country,
subdivision,
};
} }
catch (error) { catch (error) {
logger.error(`Failed Maxmind IP lookup for ${req.ip}: ${error.message}`); logger.error(`Failed Maxmind IP lookup for ${req.ip}: ${error.message}`);
req.session.restrictionIp = req.userIp; req.session.geo = {
req.session.restriction = 0; ip: req.userIp,
req.session.country = null; runtimeId,
restriction: 0,
country: null,
subdivision: null,
};
req.geo = {
restriction: 0,
country: null,
subdivision: null,
};
} }
next(); next();

View File

@@ -70,7 +70,7 @@ async function fetchScenesApi(req, res) {
page: Number(req.query.page) || 1, page: Number(req.query.page) || 1,
limit: Number(req.query.limit) || 30, limit: Number(req.query.limit) || 30,
}, req.user, { }, req.user, {
restriction: req.restriction, restriction: req.geo.restriction,
}); });
res.send({ res.send({
@@ -254,7 +254,7 @@ export async function fetchScenesGraphql(query, req) {
} }
async function fetchSceneApi(req, res) { 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) { if (!scene) {
throw new HttpError(`No scene with ID ${req.params.sceneId} found`, 404); throw new HttpError(`No scene with ID ${req.params.sceneId} found`, 404);
@@ -267,7 +267,7 @@ export async function fetchScenesByIdGraphql(query, req) {
const scenes = await fetchScenesById([].concat(query.id, query.ids).filter(Boolean), { const scenes = await fetchScenesById([].concat(query.id, query.ids).filter(Boolean), {
reqUser: req.user, reqUser: req.user,
includePartOf: true, includePartOf: true,
restriction: req.restriction, restriction: req.geo.restriction,
}); });
if (query.ids) { if (query.ids) {

View File

@@ -12,19 +12,11 @@ import initLogger from '../logger.js';
import redis from '../redis.js'; import redis from '../redis.js';
import queryBoolean from '../utils/query-boolean.js'; import queryBoolean from '../utils/query-boolean.js';
import { actorsRouter } from './actors.js';
import { router as alertsRouter } from './alerts.js'; import { actorsRouter } from './actors.js';
import { import { alertsRouter } from './alerts.js';
createKeyApi, import { authRouter, setUserApi } from './auth.js';
fetchUserKeysApi,
flushUserKeysApi,
loginApi,
logoutApi,
removeUserKeyApi,
setUserApi,
signupApi,
} from './auth.js';
import consentHandler from './consent.js'; import consentHandler from './consent.js';
import { fetchEntitiesApi } from './entities.js'; import { fetchEntitiesApi } from './entities.js';
@@ -91,8 +83,6 @@ export default async function initServer() {
store: redisStore, store: redisStore,
})); }));
router.use(setUserApi);
// Vite integration // Vite integration
if (isProduction) { if (isProduction) {
app.enable('trust proxy'); app.enable('trust proxy');
@@ -118,6 +108,7 @@ export default async function initServer() {
router.use(viteDevMiddleware); router.use(viteDevMiddleware);
} }
router.use(setUserApi); // we need to set the IP before the restriction handler
router.use(restrictionHandler); router.use(restrictionHandler);
router.get('/consent', (_req, res) => { router.get('/consent', (_req, res) => {
@@ -134,22 +125,10 @@ export default async function initServer() {
next(); 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(userRouter);
router.use(stashesRouter); router.use(stashesRouter);
router.use(alertsRouter); router.use(alertsRouter);
router.use(authRouter);
router.use(scenesRouter); router.use(scenesRouter);
router.use(actorsRouter); router.use(actorsRouter);
router.use(syncRouter); router.use(syncRouter);

View File

@@ -4,7 +4,7 @@ export async function fetchTagsApi(req, res) {
const tags = await fetchTags({ const tags = await fetchTags({
query: req.query.query, query: req.query.query,
}, { }, {
restriction: req.restriction, restriction: req.geo.restriction,
}); });
res.send(tags); res.send(tags);

View File

@@ -6,7 +6,6 @@ import {
fetchUser, fetchUser,
fetchUserTemplates, fetchUserTemplates,
removeTemplate, removeTemplate,
updateSettings,
} from '../users.js'; } from '../users.js';
async function fetchUserApi(req, res) { async function fetchUserApi(req, res) {
@@ -15,14 +14,6 @@ async function fetchUserApi(req, res) {
res.send(user); res.send(user);
} }
async function updateSettingsApi(req, res) {
const updatedSettings = await updateSettings(req.body, req.user);
req.session.user.settings = updatedSettings;
res.send(updatedSettings);
}
async function fetchUserTemplatesApi(req, res) { async function fetchUserTemplatesApi(req, res) {
const templates = await fetchUserTemplates(req.user); const templates = await fetchUserTemplates(req.user);
@@ -52,8 +43,6 @@ export const router = Router();
router.get('/api/users/:userId', fetchUserApi); router.get('/api/users/:userId', fetchUserApi);
router.get('/api/users/:userId/templates', fetchUserTemplatesApi); router.get('/api/users/:userId/templates', fetchUserTemplatesApi);
router.patch('/api/me/settings', updateSettingsApi);
router.post('/api/templates', createTemplateApi); router.post('/api/templates', createTemplateApi);
router.delete('/api/templates/:templateId', removeTemplateApi); router.delete('/api/templates/:templateId', removeTemplateApi);

View File

@@ -1,6 +1,6 @@
import { MerkleJson } from 'merkle-json'; import { MerkleJson } from 'merkle-json';
import knex from '../knex.js'; import knex from '../src/knex.js';
const mj = new MerkleJson(); const mj = new MerkleJson();

3
utils/runtime-id.js Normal file
View File

@@ -0,0 +1,3 @@
import crypto from 'node:crypto';
export default crypto.randomUUID();