Compare commits

..
60 Commits
Author SHA1 Message Date
DebaucheryLibrarian 10ac366e87 0.57.25 2026-09-22 02:22:21 +02:00
DebaucheryLibrarian a5e06b51ea Fixed scene serie poster curation and tile display. 2026-09-22 02:22:19 +02:00
DebaucheryLibrarian b3dcb65871 0.57.24 2026-09-22 00:54:23 +02:00
DebaucheryLibrarian 4527bf2495 Fixed alerts cron never initializing due missing config. 2026-09-22 00:54:22 +02:00
DebaucheryLibrarian 6c0e4d5456 0.57.23 2026-09-20 19:11:32 +02:00
DebaucheryLibrarian 8dd136446d Changed Chapter GraphQL type to accommodate video-type chapters without a starting time. 2026-09-20 19:11:30 +02:00
DebaucheryLibrarian 1cc94d665a 0.57.22 2026-09-18 23:25:51 +02:00
DebaucheryLibrarian a9d9b7623d Added actor date of birth and death precision. 2026-09-18 23:25:49 +02:00
DebaucheryLibrarian 83c0f1e4c6 0.57.21 2026-09-18 16:11:29 +02:00
DebaucheryLibrarian 64b92536d5 Fixed dark theme text color for revision count. 2026-09-18 16:11:27 +02:00
DebaucheryLibrarian cea5b98bda 0.57.20 2026-09-18 03:37:06 +02:00
DebaucheryLibrarian 411cde7cf9 Extended revision pagination to scene and actor pages, fixes 'revisions' label without count. 2026-09-18 03:37:03 +02:00
DebaucheryLibrarian 73d65ae873 0.57.19 2026-09-18 03:31:32 +02:00
DebaucheryLibrarian b5e9886b9b Added pagination and total to revisions. 2026-09-18 03:31:28 +02:00
DebaucheryLibrarian cd10f14285 0.57.18 2026-09-17 02:17:34 +02:00
DebaucheryLibrarian 514ed235a4 Added a default og:description. 2026-09-17 02:17:32 +02:00
DebaucheryLibrarian 00c6d17aaa 0.57.17 2026-09-17 01:17:06 +02:00
DebaucheryLibrarian de0b1c2baf Added scene chapters to GraphQL. Verifying API credential types. 2026-09-17 01:17:03 +02:00
DebaucheryLibrarian e9b7bb03d0 0.57.16 2026-08-17 00:06:56 +02:00
DebaucheryLibrarian 1e7207160c Fixed alert stash not syncing to manticore. 2026-08-17 00:06:53 +02:00
DebaucheryLibrarian 37a919770a 0.57.15 2026-07-28 23:46:12 +02:00
DebaucheryLibrarian 4e59994806 Fixed notify stash manticoresearch call still using obsolete 'index' key. 2026-07-28 23:46:10 +02:00
DebaucheryLibrarian 699f7f0944 0.57.14 2026-07-26 03:40:37 +02:00
DebaucheryLibrarian bf220f6b4e Added stash archiving. 2026-07-26 03:40:32 +02:00
DebaucheryLibrarian 02ee99d52f 0.57.13 2026-07-26 03:15:52 +02:00
DebaucheryLibrarian e50dedca66 Fixed profile page breaking when logged out. 2026-07-26 03:15:50 +02:00
DebaucheryLibrarian 760025f414 0.57.12 2026-07-26 00:26:51 +02:00
DebaucheryLibrarian 07d9dfaaaa Added stash sorting to stash tooltip menu and profile page. 2026-07-26 00:26:49 +02:00
DebaucheryLibrarian d3f549c92f 0.57.11 2026-07-25 03:11:39 +02:00
DebaucheryLibrarian bf64a3d30e Only listing social links from known platforms in bio. 2026-07-25 03:11:36 +02:00
DebaucheryLibrarian b27c4811da 0.57.10 2026-07-25 03:02:42 +02:00
DebaucheryLibrarian c462c2b11a Tweaked merge and alert search result tooltips. 2026-07-25 03:02:40 +02:00
DebaucheryLibrarian a774254e71 0.57.9 2026-07-25 02:28:09 +02:00
DebaucheryLibrarian 0a665ab553 Ordering stashes by last stashed item. 2026-07-25 02:28:06 +02:00
DebaucheryLibrarian c2d8cc3a40 0.57.8 2026-07-25 01:17:15 +02:00
DebaucheryLibrarian eed07ba84b Improved tooltip positioning and sizing, in particular for stashing (heart). 2026-07-25 01:17:12 +02:00
DebaucheryLibrarian e8f1dca2a0 0.57.7 2026-07-24 02:06:47 +02:00
DebaucheryLibrarian 4037098927 Fixed scene banner pushing grid row spacing. 2026-07-24 02:06:45 +02:00
DebaucheryLibrarian e007dd2b83 0.57.6 2026-07-24 00:33:58 +02:00
DebaucheryLibrarian b86cf8e30f Added grid rule to prevent row gaps on large screens. 2026-07-24 00:33:56 +02:00
DebaucheryLibrarian e46f174a1e 0.57.5 2026-07-23 23:36:21 +02:00
DebaucheryLibrarian 32b8f00308 Removed inop actor merge avatar conflict ignore. 2026-07-23 23:36:18 +02:00
DebaucheryLibrarian c9e698c906 0.57.4 2026-07-23 23:06:21 +02:00
DebaucheryLibrarian 286248e403 Widened actor merge avatar conflict ignore scope. 2026-07-23 23:06:18 +02:00
DebaucheryLibrarian f9e03f47ba 0.57.3 2026-07-23 22:54:39 +02:00
DebaucheryLibrarian b54174eb5e Ignoring actor merge avatar conflicts, put actor merge source IDs in post body. 2026-07-23 22:54:35 +02:00
DebaucheryLibrarian 62d19d1a04 0.57.2 2026-07-23 22:12:34 +02:00
DebaucheryLibrarian 6b3866a99f Changed sign-up flow to require e-mail verification up front, preventing account existence leakage. 2026-07-23 22:12:33 +02:00
DebaucheryLibrarian 18c30b6f98 0.57.1 2026-07-23 05:34:32 +02:00
DebaucheryLibrarian 77b27a56f7 Determining location with restrictions disabled, cleaned up. 2026-07-23 05:34:28 +02:00
DebaucheryLibrarian 4c48700086 0.57.0 2026-07-23 04:40:27 +02:00
DebaucheryLibrarian 6f68a50eb7 Added password recovery and login attempt guards. 2026-07-23 04:40:25 +02:00
DebaucheryLibrarian ef9be8ef9a 0.56.4 2026-07-22 17:07:26 +02:00
DebaucheryLibrarian a81343c519 Improved mobile scaling of preferences and account settings. 2026-07-22 17:07:23 +02:00
DebaucheryLibrarian 60ad068939 0.56.3 2026-07-22 06:06:46 +02:00
DebaucheryLibrarian df6965cb3a Fixed stray headings on account page, fixed summary editor not full-width. 2026-07-22 06:06:43 +02:00
DebaucheryLibrarian 9e95bec659 0.56.2 2026-07-22 06:02:44 +02:00
DebaucheryLibrarian 88b866c96a Setting e-mail web address in email module. 2026-07-22 06:02:42 +02:00
DebaucheryLibrarian 5784c08e1b 0.56.1 2026-07-22 05:59:13 +02:00
DebaucheryLibrarian 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
90 changed files with 2353 additions and 377 deletions
+2
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);
+15 -3
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,
+5
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

+18
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

+18
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

+16
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

+38
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

+1 -1
Submodule common updated: ec0812ad9d...7df69021ea
+1
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;
} }
+4
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;
+27 -13
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) => {
@@ -132,13 +135,18 @@ const aliases = Object
<dfn class="bio-label"><Icon icon="cake" />Date of birth</dfn> <dfn class="bio-label"><Icon icon="cake" />Date of birth</dfn>
<span class="birthdate"> <span class="birthdate">
<span class="birthdate-long">{{ formatDate(actor.dateOfBirth, actor.dateOfBirth.getFullYear() > 1 ? 'MMMM d, yyyy' : 'MMMM d') }}</span> <template v-if="actor.dateOfBirthPrecision === 'day'">
<span class="birthdate-short">{{ formatDate(actor.dateOfBirth, actor.dateOfBirth.getFullYear() > 1 ? 'MMM d, yyyy' : 'MMM d') }}</span> <span class="birthdate-long">{{ formatDate(actor.dateOfBirth, actor.dateOfBirth.getFullYear() > 1 ? 'MMMM d, yyyy' : 'MMMM d') }}</span>
<span class="birthdate-short">{{ formatDate(actor.dateOfBirth, actor.dateOfBirth.getFullYear() > 1 ? 'MMM d, yyyy' : 'MMM d') }}</span>
</template>
<span v-else-if="actor.dateOfBirthPrecision === 'month'">{{ formatDate(actor.dateOfBirth, 'MMMM, yyyy') }}</span>
<span v-else-if="actor.dateOfBirth.getFullYear() > 1">{{ formatDate(actor.dateOfBirth, 'yyyy') }}</span>
<span <span
v-if="!actor.dateOfDeath && actor.dateOfBirth.getFullYear() > 1" v-if="!actor.dateOfDeath && actor.dateOfBirth.getFullYear() > 1"
class="age" class="age"
>{{ actor.ageFromBirth }}</span> ><template v-if="actor.ageFromBirthIsApproximate">~</template>{{ actor.ageFromBirth }}</span>
</span> </span>
</li> </li>
@@ -160,10 +168,16 @@ const aliases = Object
> >
<dfn class="bio-label"><Icon icon="tombstone" />Date of death</dfn> <dfn class="bio-label"><Icon icon="tombstone" />Date of death</dfn>
<span class="birthdate">{{ formatDate(actor.dateOfDeath, 'MMMM d, yyyy') }}<span <span class="birthdate">
v-if="actor.ageAtDeath" <span v-if="actor.dateOfDeathPrecision === 'day'">{{ formatDate(actor.dateOfDeath, 'MMMM d, yyyy') }}</span>
class="age" <span v-else-if="actor.dateOfDeathPrecision === 'month'">{{ formatDate(actor.dateOfDeath, 'MMMM, yyyy') }}</span>
>{{ actor.ageAtDeath }}</span></span> <span v-else>{{ formatDate(actor.dateOfDeath, 'yyyy') }}</span>
<span
v-if="actor.ageAtDeath"
class="age"
><template v-if="actor.ageAtDeathIsApproximate">~</template>{{ actor.ageAtDeath }}</span>
</span>
</li> </li>
<li <li
+3 -1
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"
+24 -12
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"
+39 -6
View File
@@ -5,8 +5,10 @@ import { computed, inject, ref } from 'vue';
import Avatar from '#/components/edit/avatar.vue'; import Avatar from '#/components/edit/avatar.vue';
import Socials from '#/components/edit/revision-socials.vue'; import Socials from '#/components/edit/revision-socials.vue';
import Checkbox from '#/components/form/checkbox.vue'; import Checkbox from '#/components/form/checkbox.vue';
import Pagination from '#/components/pagination/pagination.vue';
import { get, post } from '#/src/api.js'; import { get, post } from '#/src/api.js';
import navigate from '#/src/navigate.js';
import { formatDuration } from '#/utils/format.js'; import { formatDuration } from '#/utils/format.js';
defineProps({ defineProps({
@@ -31,10 +33,14 @@ const moviesById = computed(() => Object.fromEntries(movies.value.map((movie) =>
const avatarsById = computed(() => Object.fromEntries(avatars.value.map((avatar) => [avatar.id, avatar]))); const avatarsById = computed(() => Object.fromEntries(avatars.value.map((avatar) => [avatar.id, avatar])));
const feedbacks = ref({}); const feedbacks = ref({});
const showReviewed = ref(false); const showReviewed = ref(Object.hasOwn(pageContext.urlParsed.search, 'final'));
const reviewedRevisions = ref(new Set()); const reviewedRevisions = ref(new Set());
const expanded = ref(new Set()); const expanded = ref(new Set());
const total = ref(pageContext.pageProps.revisionTotal);
const page = Number(pageContext.routeParams.page) || 1;
const limit = Number(pageContext.urlParsed.search.limit) || 100;
const mappedKeys = { const mappedKeys = {
actors: actorsById, actors: actorsById,
// tags: tagsById, // tags: tagsById,
@@ -163,7 +169,8 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
async function reloadRevisions() { async function reloadRevisions() {
const updatedRevisions = await get(`/revisions/${domain}`, { const updatedRevisions = await get(`/revisions/${domain}`, {
isFinalized: showReviewed.value ? undefined : false, isFinalized: showReviewed.value ? undefined : false,
limit: 50, page,
limit,
}); });
actors.value = updatedRevisions.actors; actors.value = updatedRevisions.actors;
@@ -171,6 +178,14 @@ async function reloadRevisions() {
movies.value = updatedRevisions.movies; movies.value = updatedRevisions.movies;
avatars.value = updatedRevisions.avatars; avatars.value = updatedRevisions.avatars;
revisions.value = updatedRevisions.revisions; revisions.value = updatedRevisions.revisions;
total.value = updatedRevisions.total;
navigate(pageContext.urlParsed.pathname, {
...pageContext.urlParsed.search,
final: showReviewed.value || undefined,
}, {
redirect: false,
});
} }
async function reviewRevision(revision, isApproved) { async function reviewRevision(revision, isApproved) {
@@ -208,10 +223,15 @@ async function banEditor(revision) {
<template> <template>
<div class="page"> <div class="page">
<div <div
v-if="context === 'admin'"
class="revs-header" class="revs-header"
> >
<span
v-if="typeof total === 'number'"
class="revs-total"
>{{ total }} revisions</span>
<Checkbox <Checkbox
v-if="context === 'admin'"
label="Show finalized" label="Show finalized"
:checked="showReviewed" :checked="showReviewed"
@change="(checked) => { showReviewed = checked; reloadRevisions(); }" @change="(checked) => { showReviewed = checked; reloadRevisions(); }"
@@ -388,13 +408,12 @@ async function banEditor(revision) {
class="rev-compact" class="rev-compact"
@click="expanded.add(rev.id)" @click="expanded.add(rev.id)"
> >
<span class="rev-scene nolink noshrink"><template v-if="context !== 'scene' && context !== 'actor'">{{ rev.sceneId || rev.actorId }}</template>@{{ rev.hash.slice(0, 6) }}</span> <span class="rev-scene nolink noshrink"><template v-if="context !== 'scene' && context !== 'actor'">{{ rev.sceneId || rev.actorId }}</template>@{{ rev.hash.slice(0, 6) }}</span>
<span <span
v-if="context !== 'scene'" v-if="context !== 'scene'"
class="rev-title nolink ellipsis" class="rev-title nolink ellipsis"
>{{ rev.base.title }}</span> >{{ rev.base.title || rev.base.name }}</span>
<span class="rev-summary"> <span class="rev-summary">
<span class="summary-deltas ellipsis">{{ rev.deltas.map((delta) => delta.key).join(', ') }}</span> <span class="summary-deltas ellipsis">{{ rev.deltas.map((delta) => delta.key).join(', ') }}</span>
@@ -431,6 +450,13 @@ async function banEditor(revision) {
</div> </div>
</li> </li>
</ul> </ul>
<Pagination
v-if="total"
:total="total"
:page="page"
:limit="limit"
/>
</div> </div>
</template> </template>
@@ -443,13 +469,20 @@ async function banEditor(revision) {
.revs-header { .revs-header {
display: flex; display: flex;
margin-bottom: 1rem; justify-content: space-between;
box-sizing: border-box;
padding: .5rem .5rem .75rem .5rem;
width: 100%;
.check-container { .check-container {
display: inline-flex; display: inline-flex;
} }
} }
.revs-total {
color: var(--glass-strong-10);
}
.revs { .revs {
width: 100%; width: 100%;
flex-grow: 1; flex-grow: 1;
+1 -1
View File
@@ -49,7 +49,7 @@ const mockUser = {
<template> <template>
<Html> <Html>
<Body style="font-family: sans-serif;"> <Body style="font-family: sans-serif;">
<Preview>Your traxxx password has been changed.</Preview> <Preview>Your traxxx password has been changed</Preview>
<Container> <Container>
<Img <Img
+79
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>
+80
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>
+62
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>
+3 -7
View File
@@ -46,7 +46,7 @@ const mockUser = {
<template> <template>
<Html> <Html>
<Body style="font-family: sans-serif;"> <Body style="font-family: sans-serif;">
<Preview>Please verify your traxxx e-mail address.</Preview> <Preview>Please verify your traxxx e-mail address</Preview>
<Container> <Container>
<Img <Img
@@ -62,8 +62,7 @@ const mockUser = {
>Hi, {{ user.username }}</Heading> >Hi, {{ user.username }}</Heading>
<Text> <Text>
<strong v-if="trigger === 'signup'">Thank you for creating your traxxx account!</strong> <strong>You requested to {{ trigger === 'verify' ? 'verify' : 'change' }} the e-mail address for your traxxx account.</strong>
<strong v-else>You requested to {{ trigger === 'verify' ? 'verify' : 'change' }} the e-mail address for your traxxx account.</strong>
<br> <br>
Please confirm your e-mail address by clicking the button below: Please confirm your e-mail address by clicking the button below:
</Text> </Text>
@@ -75,10 +74,7 @@ const mockUser = {
><strong>Verify e-mail address</strong></Button> ><strong>Verify e-mail address</strong></Button>
<Text style="font-size: 16px; margin: 20px 0;"> <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. 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>
<template v-if="trigger === 'signup'">If you did not sign up, you can safely ignore this e-mail.</template>
<template v-else>If you did not request this {{ trigger === 'verify' ? 'verification' : 'change' }}, you can safely ignore this e-mail.</template>
</Text>
</Container> </Container>
</Body> </Body>
</Html> </Html>
+1 -1
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;
} }
+4
View File
@@ -13,6 +13,10 @@ const props = defineProps({
type: Number, type: Number,
default: null, default: null,
}, },
limit: {
type: Number,
default: null,
},
redirect: { redirect: {
type: Boolean, type: Boolean,
default: true, default: true,
+7
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;
+1
View File
@@ -253,6 +253,7 @@ onMounted(() => {
} }
.input { .input {
width: 100%;
resize: none; resize: none;
background: var(--background); background: var(--background);
} }
+18 -2
View File
@@ -10,7 +10,10 @@ defineProps({
</script> </script>
<template> <template>
<div class="serie-tile"> <div
class="serie-tile"
:class="{ noposter: !serie.poster }"
>
<a <a
v-if="serie.poster" v-if="serie.poster"
:href="`/serie/${serie.id}/${serie.slug}`" :href="`/serie/${serie.id}/${serie.slug}`"
@@ -39,8 +42,8 @@ defineProps({
flex-direction: column; flex-direction: column;
box-shadow: 0 0 3px var(--shadow-weak-30); box-shadow: 0 0 3px var(--shadow-weak-30);
border-radius: .25rem; border-radius: .25rem;
overflow: hidden;
background: var(--background-base); background: var(--background-base);
overflow: hidden;
&:hover { &:hover {
box-shadow: 0 0 3px var(--shadow-weak-20); box-shadow: 0 0 3px var(--shadow-weak-20);
@@ -53,6 +56,19 @@ defineProps({
&.unstashed { &.unstashed {
opacity: .5; opacity: .5;
} }
&.noposter {
background: none;
box-shadow: none;
.title {
padding: 0;
&:hover {
color: var(--primary);
}
}
}
} }
.poster-container { .poster-container {
-2
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>
+6 -2
View File
@@ -41,8 +41,6 @@ const itemAlerts = ref(props.item.alerts);
const itemAlerted = ref(props.item.alerts?.only.length > 0); const itemAlerted = ref(props.item.alerts?.only.length > 0);
const hasSecondaryStash = computed(() => itemStashes.value.some((itemStash) => !itemStash.isPrimary)); const hasSecondaryStash = computed(() => itemStashes.value.some((itemStash) => !itemStash.isPrimary));
// console.log(props.item);
const done = ref(true); const done = ref(true);
const stashMenuDropdownId = useId(); const stashMenuDropdownId = useId();
const stashTogglesDropdownId = useId(); const stashTogglesDropdownId = useId();
@@ -248,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
@@ -261,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"
@@ -274,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">
@@ -316,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"
+135 -20
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>
+121 -3
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;
+47
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 {
+35 -12
View File
@@ -11,6 +11,8 @@ import { patch, post } from '#/src/api.js';
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const { user } = pageContext; const { user } = pageContext;
const allowEmailUpdate = pageContext.env.emailEnabled;
const credentials = reactive({ const credentials = reactive({
email: null, email: null,
password: null, password: null,
@@ -21,6 +23,7 @@ const saving = ref(false);
const submitted = ref(false); const submitted = ref(false);
const verifying = ref(false); const verifying = ref(false);
const verified = ref(false); const verified = ref(false);
const feedback = ref(null);
function getFeedback() { function getFeedback() {
if (credentials.email && Object.entries(credentials).some(([key, value]) => key !== 'email' && value !== null)) { if (credentials.email && Object.entries(credentials).some(([key, value]) => key !== 'email' && value !== null)) {
@@ -36,18 +39,26 @@ function getFeedback() {
async function updateAccount() { async function updateAccount() {
saving.value = true; saving.value = true;
feedback.value = null;
try { try {
const feedback = getFeedback();
await patch('/me', { await patch('/me', {
credentials, credentials,
password: currentPassword.value, password: currentPassword.value,
}, { }, {
successFeedback: getFeedback(), successFeedback: feedback,
errorFeedback: 'Failed to update account', errorFeedback: 'Failed to update account',
appendErrorMessage: true, appendErrorMessage: true,
}); });
currentPassword.value = null;
credentials.email = null;
credentials.password = null;
submitted.value = true; submitted.value = true;
feedback.value = feedback;
} }
finally { finally {
saving.value = false; saving.value = false;
@@ -77,9 +88,7 @@ async function verifyEmail() {
class="profile-section" class="profile-section"
@submit.prevent="updateAccount" @submit.prevent="updateAccount"
> >
<h3 class="heading settings-heading">Alerts</h3> <div class="setting">
<label class="setting">
<span class="setting-label"> <span class="setting-label">
<strong class="setting-title">E-mail address</strong> <strong class="setting-title">E-mail address</strong>
<span class="setting-description">Primarily used for alert notifications and password recovery</span> <span class="setting-description">Primarily used for alert notifications and password recovery</span>
@@ -95,7 +104,7 @@ async function verifyEmail() {
<template #popper> <template #popper>
<div class="unverified-tooltip"> <div class="unverified-tooltip">
Your e-mail address is unverified Your e-mail address is unverified.
<Ellipsis v-if="verifying" /> <Ellipsis v-if="verifying" />
@@ -105,11 +114,13 @@ async function verifyEmail() {
>Verification e-mail sent</strong> >Verification e-mail sent</strong>
<button <button
v-else v-else-if="allowEmailUpdate"
type="button" type="button"
class="button button-primary" class="button button-primary"
@click="verifyEmail" @click="verifyEmail"
>Verify now</button> >Verify now</button>
<span v-else>Verification is currently unavailable.</span>
</div> </div>
</template> </template>
</VDropdown> </VDropdown>
@@ -123,15 +134,16 @@ async function verifyEmail() {
</span> </span>
<input <input
v-if="allowEmailUpdate"
v-model="credentials.email" v-model="credentials.email"
type="email" type="email"
placeholder="New e-mail" placeholder="New e-mail"
class="input" class="input"
> >
</span> </span>
</label> </div>
<label class="setting"> <div class="setting">
<span class="setting-label"> <span class="setting-label">
<strong class="setting-title">New password</strong> <strong class="setting-title">New password</strong>
<span class="setting-description">Change the password you use for signing in</span> <span class="setting-description">Change the password you use for signing in</span>
@@ -145,9 +157,9 @@ async function verifyEmail() {
@input="(password) => credentials.password = password" @input="(password) => credentials.password = password"
/> />
</span> </span>
</label> </div>
<label class="setting"> <div class="setting">
<span class="setting-label"> <span class="setting-label">
<strong class="setting-title">Current password</strong> <strong class="setting-title">Current password</strong>
<span class="setting-description">Enter your current password to save your changes</span> <span class="setting-description">Enter your current password to save your changes</span>
@@ -160,7 +172,7 @@ async function verifyEmail() {
@input="(password) => currentPassword = password" @input="(password) => currentPassword = password"
/> />
</span> </span>
</label> </div>
<div class="settings-actions"> <div class="settings-actions">
<Ellipsis v-if="saving" /> <Ellipsis v-if="saving" />
@@ -168,7 +180,7 @@ async function verifyEmail() {
<strong <strong
v-else-if="submitted" v-else-if="submitted"
class="success" class="success"
>{{ getFeedback() }}</strong> >{{ feedback }}</strong>
<button <button
v-else v-else
@@ -274,4 +286,15 @@ async function verifyEmail() {
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
@media(--small-20) {
.setting {
flex-direction: column;
align-items: flex-start;
}
.setting-label {
margin-bottom: .75rem;
}
}
</style> </style>
+15 -6
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>
+8
View File
@@ -154,7 +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 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
@@ -199,6 +206,7 @@ module.exports = {
crontab: '*/10 * * * *', // every 10 minutes crontab: '*/10 * * * *', // every 10 minutes
}, },
alerts: { alerts: {
enabled: true,
crontab: '*/10 * * * *', // every 10 minutes crontab: '*/10 * * * *', // every 10 minutes
seenExpiry: 30, // days seenExpiry: 30, // days
finalExpiry: 365, // days finalExpiry: 365, // days
+12
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',
+467 -15
View File
@@ -1,11 +1,11 @@
{ {
"name": "traxxx-web", "name": "traxxx-web",
"version": "0.56.0", "version": "0.57.25",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"version": "0.56.0", "version": "0.57.25",
"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",
+2 -1
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.56.0", "version": "0.57.25",
"imports": { "imports": {
"#/*": "./*.js" "#/*": "./*.js"
}, },
+6
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,
-1
View File
@@ -16,7 +16,6 @@ export async function onBeforeRender(pageContext) {
return { return {
pageContext: { pageContext: {
title: 'actors',
pageProps: { pageProps: {
actors, actors,
countries, countries,
+1
View File
@@ -0,0 +1 @@
export default 'Actors';
@@ -5,11 +5,13 @@ export async function onBeforeRender(pageContext) {
const { const {
revisions, revisions,
total: revisionTotal,
avatars, avatars,
} = await fetchActorRevisions(null, { } = await fetchActorRevisions(null, {
actorId: actor.id, actorId: actor.id,
isFinalized: true, isFinalized: true,
limit: 100, limit: Number(pageContext.urlParsed.search.limit) || 100,
page: Number(pageContext.routeParams.page) || 1,
}, pageContext.user); }, pageContext.user);
return { return {
@@ -18,6 +20,7 @@ export async function onBeforeRender(pageContext) {
pageProps: { pageProps: {
actor, actor,
revisions, revisions,
revisionTotal,
avatars, avatars,
}, },
}, },
+45 -6
View File
@@ -52,9 +52,12 @@ const fields = computed(() => [
key: 'dateOfBirth', key: 'dateOfBirth',
label: 'date of birth', label: 'date of birth',
type: 'date', type: 'date',
value: actor.value?.dateOfBirth value: {
? format(actor.value.dateOfBirth, 'yyyy-MM-dd') date: actor.value?.dateOfBirth
: null, ? format(actor.value.dateOfBirth, 'yyyy-MM-dd')
: null,
precision: actor.value.dateOfBirthPrecision,
},
inline: true, inline: true,
}, },
{ {
@@ -212,9 +215,12 @@ const fields = computed(() => [
key: 'dateOfDeath', key: 'dateOfDeath',
label: 'date of death', label: 'date of death',
type: 'date', type: 'date',
value: actor.value?.dateOfDeath value: {
? format(actor.value.dateOfDeath, 'yyyy-MM-dd') date: actor.value?.dateOfDeath
: null, ? format(actor.value.dateOfDeath, 'yyyy-MM-dd')
: null,
precision: actor.value.dateOfDeathPrecision,
},
}, },
{ {
key: 'allowGlobalMatch', key: 'allowGlobalMatch',
@@ -270,6 +276,14 @@ const keyMap = {
country: 'residenceCountry', country: 'residenceCountry',
place: 'residencePlace', place: 'residencePlace',
}, },
dateOfBirth: {
date: 'dateOfBirth',
precision: 'dateOfBirthPrecision',
},
dateOfDeath: {
date: 'dateOfDeath',
precision: 'dateOfDeathPrecision',
},
tattoos: { tattoos: {
has: 'hasTattoos', has: 'hasTattoos',
description: 'tattoos', description: 'tattoos',
@@ -501,6 +515,7 @@ async function submit() {
>{{ item.unit }} >{{ item.unit }}
</template> </template>
<!--
<input <input
v-if="item.type === 'date'" v-if="item.type === 'date'"
v-model="edits[item.key]" v-model="edits[item.key]"
@@ -508,6 +523,30 @@ async function submit() {
class="date input" class="date input"
:disabled="!editing.has(item.key)" :disabled="!editing.has(item.key)"
> >
-->
<div
v-if="item.type === 'date'"
class="date"
>
<input
v-model="edits[item.key].date"
type="date"
class="date input"
:disabled="!editing.has(item.key)"
>
<select
v-if="item.value.precision"
v-model="edits[item.key].precision"
class="input"
:disabled="!editing.has(item.key)"
>
<option value="day">Day</option>
<option value="month">Month</option>
<option value="year">Year</option>
</select>
</div>
<select <select
v-if="item.type === 'select'" v-if="item.type === 'select'"
@@ -10,10 +10,12 @@ export async function onBeforeRender(pageContext) {
const { const {
revisions, revisions,
total: revisionTotal,
avatars, avatars,
} = await fetchActorRevisions(null, { } = await fetchActorRevisions(null, {
isFinalized: false, isFinalized: Object.hasOwn(pageContext.urlParsed.search, 'final'),
limit: 50, limit: Number(pageContext.urlParsed.search.limit) || 100,
page: Number(pageContext.routeParams.page) || 1,
}, pageContext.user); }, pageContext.user);
return { return {
@@ -21,6 +23,7 @@ export async function onBeforeRender(pageContext) {
title: pageContext.routeParams.section, title: pageContext.routeParams.section,
pageProps: { pageProps: {
revisions, revisions,
revisionTotal,
avatars, avatars,
}, },
}, },
+3 -1
View File
@@ -1,6 +1,6 @@
import { match } from 'path-to-regexp'; import { match } from 'path-to-regexp';
const path = '/admin/:section/:domain(actors)'; const path = '/admin/:section/:domain(actors)/:page?';
const urlMatch = match(path, { decode: decodeURIComponent }); const urlMatch = match(path, { decode: decodeURIComponent });
export default (pageContext) => { export default (pageContext) => {
@@ -11,6 +11,8 @@ export default (pageContext) => {
routeParams: { routeParams: {
section: matched.params.section, section: matched.params.section,
domain: matched.params.domain, domain: matched.params.domain,
page: matched.params.page || '1',
path,
}, },
}; };
} }
@@ -10,12 +10,14 @@ export async function onBeforeRender(pageContext) {
const { const {
revisions, revisions,
total: revisionTotal,
actors, actors,
tags, tags,
movies, movies,
} = await fetchSceneRevisions(null, { } = await fetchSceneRevisions(null, {
isFinalized: false, isFinalized: Object.hasOwn(pageContext.urlParsed.search, 'final'),
limit: 50, limit: Number(pageContext.urlParsed.search.limit) || 100,
page: Number(pageContext.routeParams.page) || 1,
}, pageContext.user); }, pageContext.user);
return { return {
@@ -23,6 +25,7 @@ export async function onBeforeRender(pageContext) {
title: pageContext.routeParams.section, title: pageContext.routeParams.section,
pageProps: { pageProps: {
revisions, revisions,
revisionTotal,
actors, actors,
tags, tags,
movies, movies,
+3 -1
View File
@@ -1,6 +1,6 @@
import { match } from 'path-to-regexp'; import { match } from 'path-to-regexp';
const path = '/admin/:section/:domain(scenes)'; const path = '/admin/:section/:domain(scenes)/:page?';
const urlMatch = match(path, { decode: decodeURIComponent }); const urlMatch = match(path, { decode: decodeURIComponent });
export default (pageContext) => { export default (pageContext) => {
@@ -11,6 +11,8 @@ export default (pageContext) => {
routeParams: { routeParams: {
section: matched.params.section, section: matched.params.section,
domain: matched.params.domain, domain: matched.params.domain,
page: matched.params.page || '1',
path,
}, },
}; };
} }
+11
View File
@@ -9,6 +9,7 @@ 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('');
@@ -104,6 +105,12 @@ 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>
@@ -184,4 +191,8 @@ onMounted(() => {
font-weight: bold; font-weight: bold;
text-align: center; text-align: center;
} }
.button-submit {
padding: .75rem 1rem;
}
</style> </style>
+1
View File
@@ -0,0 +1 @@
export default 'Login';
+218
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>
+1
View File
@@ -0,0 +1 @@
export default '/recovery';
+1
View File
@@ -0,0 +1 @@
export default `Account recovery`;
+91 -31
View File
@@ -3,32 +3,29 @@ 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 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 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;
@@ -36,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 });
@@ -53,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>
@@ -88,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"
@@ -99,34 +121,19 @@ 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
v-model="email"
type="email"
placeholder="E-mail"
class="input"
required
>
<Password <Password
:password="password" :password="password"
autocomplete="new-password" autocomplete="new-password"
@input="(newPassword) => password = newPassword" @input="(newPassword) => password = newPassword"
/> />
<Password
:password="passwordConfirm"
autocomplete="new-password"
placeholder="Confirm password"
@input="(newPassword) => passwordConfirm = newPassword"
/>
<VueHCaptcha <VueHCaptcha
v-if="env.captcha.enabled" v-if="env.captcha.enabled"
:sitekey="env.captcha.siteKey" :sitekey="env.captcha.siteKey"
@@ -135,15 +142,53 @@ 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>
@@ -179,7 +224,7 @@ onMounted(() => {
} }
.link { .link {
margin-top: .5rem; margin-top: 1rem;
text-align: center; text-align: center;
} }
} }
@@ -221,6 +266,7 @@ onMounted(() => {
} }
.button-submit { .button-submit {
padding: .75rem 1rem;
margin-top: .5rem; margin-top: .5rem;
} }
@@ -234,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>
+1
View File
@@ -0,0 +1 @@
export default 'Sign up';
+1 -1
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 {
@@ -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),
]); ]);
+1 -1
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 {
+2 -2
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
View File
@@ -0,0 +1 @@
export default 'Account recovery';
+1 -1
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 },
+1 -1
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) {
@@ -10,13 +10,15 @@ export async function onBeforeRender(pageContext) {
const { const {
revisions, revisions,
total: revisionTotal,
actors, actors,
tags, tags,
movies, movies,
} = await fetchSceneRevisions(null, { } = await fetchSceneRevisions(null, {
sceneId: scene.id, sceneId: scene.id,
isFinalized: true, isFinalized: true,
limit: 100, limit: Number(pageContext.urlParsed.search.limit) || 100,
page: Number(pageContext.routeParams.page) || 1,
}, pageContext.user); }, pageContext.user);
return { return {
@@ -25,6 +27,7 @@ export async function onBeforeRender(pageContext) {
pageProps: { pageProps: {
scene, scene,
revisions, revisions,
revisionTotal,
actors, actors,
tags, tags,
movies, movies,
+4 -4
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,
}), }),
]); ]);
+1
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;
} }
+3 -3
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),
])); ]));
+3 -3
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}`);
+1 -1
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 },
+2 -2
View File
@@ -20,7 +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 primaryStash = pageContext.assets?.primaryStash;
const showFilters = ref(false); const showFilters = ref(false);
@@ -270,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 {
+7 -3
View File
@@ -15,14 +15,16 @@ async function fetchRevisions(pageContext) {
if (pageContext.routeParams.section === 'revisions' && pageContext.routeParams.domain === 'scenes') { if (pageContext.routeParams.section === 'revisions' && pageContext.routeParams.domain === 'scenes') {
return fetchSceneRevisions(null, { return fetchSceneRevisions(null, {
userId: pageContext.user.id, userId: pageContext.user.id,
limit: 100, limit: Number(pageContext.urlParsed.search.limit) || 100,
page: Number(pageContext.routeParams.page) || 1,
}, pageContext.user); }, pageContext.user);
} }
if (pageContext.routeParams.section === 'revisions' && pageContext.routeParams.domain === 'actors') { if (pageContext.routeParams.section === 'revisions' && pageContext.routeParams.domain === 'actors') {
return fetchActorRevisions(null, { return fetchActorRevisions(null, {
userId: pageContext.user.id, userId: pageContext.user.id,
limit: 100, limit: Number(pageContext.urlParsed.search.limit) || 100,
page: Number(pageContext.routeParams.page) || 1,
}, pageContext.user); }, pageContext.user);
} }
@@ -31,7 +33,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)
: [], : [],
@@ -47,6 +49,7 @@ export async function onBeforeRender(pageContext) {
const { const {
revisions, revisions,
total: revisionTotal,
actors, actors,
tags, tags,
movies, movies,
@@ -63,6 +66,7 @@ export async function onBeforeRender(pageContext) {
stashes, stashes,
alerts, alerts,
revisions, revisions,
revisionTotal,
keys, keys,
actors, actors,
tags, tags,
+3 -1
View File
@@ -1,7 +1,7 @@
import { match } from 'path-to-regexp'; import { match } from 'path-to-regexp';
import { redirect } from 'vike/abort'; import { redirect } from 'vike/abort';
const path = '/user/:username/:section?/:domain?'; const path = '/user/:username/:section?/:domain?/:page?';
const urlMatch = match(path, { decode: decodeURIComponent }); const urlMatch = match(path, { decode: decodeURIComponent });
export default (pageContext) => { export default (pageContext) => {
@@ -18,6 +18,8 @@ export default (pageContext) => {
username: matched.params.username, username: matched.params.username,
section: matched.params.section || '', section: matched.params.section || '',
domain: matched.params.domain || '', domain: matched.params.domain || '',
page: matched.params.page || '1',
path,
}, },
}; };
} }
+1 -1
View File
@@ -9,6 +9,6 @@ export default {
'assets', 'assets',
'campaigns', 'campaigns',
'meta', 'meta',
'restriction', 'geo',
], ],
}; };
+7 -4
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">
@@ -64,7 +67,7 @@ async function onRenderHtml(pageContext) {
<meta name="theme-color" content="#f65596"> <meta name="theme-color" content="#f65596">
<meta property="og:title" content="traxxx" /> <meta property="og:title" content="traxxx" />
<meta property="og:image" content="https://traxxx.me/img/og_logo.png" /> <meta property="og:description" content="${pageContext.description || 'Never miss new adult videos with traxxx'}" />
<meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0,interactive-widget=resizes-content" /> <meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0,interactive-widget=resizes-content" />
+8 -1
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);
+1 -1
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;
} }
+31 -2
View File
@@ -32,7 +32,9 @@ const mj = new MerkleJson();
const keyMap = { const keyMap = {
avatar: 'avatar_media_id', avatar: 'avatar_media_id',
dateOfBirth: 'date_of_birth', dateOfBirth: 'date_of_birth',
dateOfBirthPrecision: 'date_of_birth_precision',
dateOfDeath: 'date_of_death', dateOfDeath: 'date_of_death',
dateOfDeathPrecision: 'date_of_death_precision',
originCountry: 'birth_country_alpha2', originCountry: 'birth_country_alpha2',
originState: 'birth_state', originState: 'birth_state',
originCity: 'birth_city', originCity: 'birth_city',
@@ -62,6 +64,24 @@ const keyMap = {
const socialsOrder = ['onlyfans', 'fansly', 'twitter', 'instagram', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null]; const socialsOrder = ['onlyfans', 'fansly', 'twitter', 'instagram', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null];
// with month (but not day) precision on either side, the age is still certain unless both dates fall in the same month,
// since only then could the unknown day(s) put the birthday on either side of the reference date
function isAgeApproximate(baseDate, basePrecision, referenceDate, referencePrecision = 'day') {
if (!baseDate || !referenceDate) {
return false;
}
if (basePrecision === 'year' || referencePrecision === 'year') {
return true;
}
if (basePrecision === 'day' && referencePrecision === 'day') {
return false;
}
return baseDate.getMonth() === referenceDate.getMonth();
}
export function curateActor(actor, context = {}) { export function curateActor(actor, context = {}) {
if (!actor) { if (!actor) {
return null; return null;
@@ -97,14 +117,20 @@ export function curateActor(actor, context = {}) {
ageFromBirth: actor.date_of_birth && actor.date_of_birth.getFullYear() > 1 ageFromBirth: actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
? differenceInYears(Date.now(), actor.date_of_birth) ? differenceInYears(Date.now(), actor.date_of_birth)
: null, : null,
ageFromBirthIsApproximate: actor.date_of_birth_precision !== 'day'
&& isAgeApproximate(actor.date_of_birth, actor.date_of_birth_precision, new Date()),
ageAtDeath: actor.date_of_birth && actor.date_of_death && actor.date_of_birth.getFullYear() > 1 ageAtDeath: actor.date_of_birth && actor.date_of_death && actor.date_of_birth.getFullYear() > 1
? differenceInYears(actor.date_of_death, actor.date_of_birth) ? differenceInYears(actor.date_of_death, actor.date_of_birth)
: null, : null,
ageAtDeathIsApproximate: (actor.date_of_birth_precision !== 'day' || actor.date_of_death_precision !== 'day')
&& isAgeApproximate(actor.date_of_birth, actor.date_of_birth_precision, actor.date_of_death, actor.date_of_death_precision),
ageThen: context.sceneDate && actor.date_of_birth && actor.date_of_birth.getFullYear() > 1 ageThen: context.sceneDate && actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
? differenceInYears(context.sceneDate, actor.date_of_birth) ? differenceInYears(context.sceneDate, actor.date_of_birth)
: null, : null,
dateOfBirth: actor.date_of_birth, dateOfBirth: actor.date_of_birth,
dateOfBirthPrecision: actor.date_of_birth_precision,
dateOfDeath: actor.date_of_death, dateOfDeath: actor.date_of_death,
dateOfDeathPrecision: actor.date_of_death_precision,
bust: actor.bust, bust: actor.bust,
cup: actor.cup, cup: actor.cup,
waist: actor.waist, waist: actor.waist,
@@ -413,7 +439,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 +893,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),
@@ -1060,6 +1085,7 @@ export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
'actors_revisions.*', 'actors_revisions.*',
'users.username as username', 'users.username as username',
'reviewers.username as reviewer_username', 'reviewers.username as reviewer_username',
knex.raw('count(*) over() as total'),
) )
.leftJoin('users', 'users.id', 'actors_revisions.user_id') .leftJoin('users', 'users.id', 'actors_revisions.user_id')
.leftJoin('users as reviewers', 'reviewers.id', 'actors_revisions.reviewed_by') .leftJoin('users as reviewers', 'reviewers.id', 'actors_revisions.reviewed_by')
@@ -1106,6 +1132,7 @@ export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
return { return {
revisions: curatedRevisions, revisions: curatedRevisions,
revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId), revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId),
total: Number(revisions[0]?.total) || 0,
avatars, avatars,
}; };
} }
@@ -1184,7 +1211,9 @@ async function applyActorRevision(revisionIds, reqUser) {
'gender', 'gender',
'avatar', 'avatar',
'dateOfBirth', 'dateOfBirth',
'dateOfBirthPrecision',
'dateOfDeath', 'dateOfDeath',
'dateOfDeathPrecision',
'originCountry', 'originCountry',
'originState', 'originState',
'originCity', 'originCity',
+2 -2
View File
@@ -545,7 +545,6 @@ async function sendEmailAlerts() {
props: { props: {
user, user,
triggers: userTriggers, triggers: userTriggers,
address: config.web.address,
}, },
}; };
}).filter(Boolean); }).filter(Boolean);
@@ -739,6 +738,7 @@ export async function notify(sceneIds, options = {}) {
.insert(stashEntries) .insert(stashEntries)
.onConflict(['stash_id', 'scene_id']) .onConflict(['stash_id', 'scene_id'])
.ignore() .ignore()
.returning('id')
: [], : [],
notifications.length > 0 notifications.length > 0
? knex('notifications') ? knex('notifications')
@@ -755,7 +755,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,
+378 -125
View File
@@ -6,7 +6,7 @@ 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 { sendEmails } from './email.js';
@@ -46,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', 503); 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()')
@@ -90,7 +142,22 @@ async function hashPassword(password) {
}; };
} }
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) { export async function sendEmailVerification(user, customEmail, trigger) {
if (!config.email.enabled) {
throw new HttpError('E-mail verification is disabled', 503);
}
if (!user) { if (!user) {
throw new HttpError('No user provided', 401); throw new HttpError('No user provided', 401);
} }
@@ -101,6 +168,8 @@ export async function sendEmailVerification(user, customEmail, trigger) {
throw new HttpError('No e-mail address provided', 400); throw new HttpError('No e-mail address provided', 400);
} }
await tokenCooldown(email);
const tokenKey = `traxxx:token_email:${user.id}`; const tokenKey = `traxxx:token_email:${user.id}`;
const token = crypto.randomBytes(32).toString('hex'); const token = crypto.randomBytes(32).toString('hex');
@@ -131,128 +200,6 @@ export async function sendEmailVerification(user, customEmail, trigger) {
}]); }]);
} }
export async function signup(credentials, userIp) {
if (!config.auth.signup) {
throw new HttpError('Sign-ups are currently disabled', 503);
}
const curatedUsername = credentials.username.trim();
if (!curatedUsername) {
throw new HttpError('Username required', 400);
}
if (curatedUsername.length < config.auth.usernameLength[0]) {
throw new HttpError('Username is too short', 400);
}
if (curatedUsername.length > config.auth.usernameLength[1]) {
throw new HttpError('Username is too long', 400);
}
if (!config.auth.usernamePattern.test(curatedUsername)) {
throw new HttpError('Username contains invalid characters', 400);
}
if (!credentials.email) {
throw new HttpError('E-mail required', 400);
}
if (credentials.password?.length < 3) {
throw new HttpError('Password must be 3 characters or longer', 400);
}
if (config.auth.captcha.enabled) {
const captchaVerification = await verify(config.auth.captcha.secretKey, credentials.captcha);
if (!captchaVerification.success) {
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${credentials.email}, ${userIp})`);
throw new HttpError('Invalid CAPTCHA', 400);
}
}
const existingUser = await knex('users')
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
.orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase())
.first();
if (existingUser) {
throw new HttpError('Username or e-mail already in use', 409);
}
const { storedPassword } = await hashPassword(credentials.password);
const [{ id: userId }] = await knex('users')
.insert({
username: curatedUsername,
email: credentials.email,
password: storedPassword,
})
.returning('id');
await knex('stashes').insert({
user_id: userId,
name: 'Favorites',
slug: 'favorites',
public: false,
primary: true,
});
logger.info(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`);
await generateAvatar({
id: userId,
username: curatedUsername,
});
const user = await fetchUser(userId, {
includePrivate: true,
});
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.country && await knex('countries').where('alpha2', reqSession.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,
address: config.web.address,
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) {
throw new HttpError('This e-mail address is already in use', 409);
}
await sendEmailVerification(reqUser, email, 'change');
}
export async function verifyEmail(userId, emailToken) { export async function verifyEmail(userId, emailToken) {
if (!userId) { if (!userId) {
throw new HttpError('User ID not provided', 400); throw new HttpError('User ID not provided', 400);
@@ -290,6 +237,304 @@ export async function verifyEmail(userId, emailToken) {
} }
} }
export async function sendPasswordReset(credential) {
if (!config.email.enabled) {
throw new HttpError('Password reset is disabled', 503);
}
if (!credential) {
throw new HttpError('No credential provided', 400);
}
await tokenCooldown(credential);
const user = await fetchUser(credential, {
email: true,
includePrivate: true,
throwError: false,
});
// we don't throw errors because the requesting user shouldn't know whether the account exists or not
if (!user) {
logger.info(`Password reset requested for unknown user '${credential}'`);
return;
}
if (!user.email) {
logger.info(`Password reset requested for '${credential}', but user '${user.username}' (${user.id}) has no e-mail address known`);
return;
}
const tokenKey = `traxxx:token_password:${user.id}`;
const token = crypto.randomBytes(32).toString('hex');
const tokenData = {
token,
};
await redis.set(tokenKey, JSON.stringify(tokenData));
await redis.expire(tokenKey, config.auth.passwordTokenExpiry * 60);
const verifyUrl = `${config.web.address}/recovery?${new URLSearchParams({
userId: user.id,
token,
}).toString()}`;
logger.info(`Password reset sent to ${user.username} (${user.id}): ${verifyUrl}`);
await sendEmails([{
template: 'recovery',
to: user.email,
subject: 'Reset your traxxx password',
props: {
user,
verifyUrl,
},
}]);
}
export async function resetPassword(userId, emailToken, newPassword) {
if (!userId) {
throw new HttpError('User ID not provided', 400);
}
if (!emailToken) {
throw new HttpError('Token not provided', 400);
}
if (!newPassword) {
throw new HttpError('New password not provided', 400);
}
const tokenKey = `traxxx:token_password:${userId}`;
const tokenEntry = await redis.getDel(tokenKey);
if (!tokenEntry) {
throw new HttpError('Invalid recovery link', 401);
}
const { token } = JSON.parse(tokenEntry);
if (!token
|| emailToken.length !== token.length
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
throw new HttpError('Invalid recovery link', 401);
}
const { storedPassword } = await hashPassword(newPassword);
const updated = await knex('users')
.where('id', userId)
.update('password', storedPassword);
if (updated === 0) {
throw new HttpError('Password reset failed because the user could not be found', 404);
}
}
export async function sendSignup(email) {
if (!config.auth.signup || !config.email.enabled) {
throw new HttpError('Sign-ups are disabled', 503);
}
if (!email) {
throw new HttpError('No e-mail address provided', 400);
}
await tokenCooldown(email);
const user = await fetchUser(email, {
email: true,
includePrivate: true,
throwError: false,
});
if (user) {
await sendEmails([{
template: 'unavailable',
to: email,
subject: 'Someone tried to use your e-mail address for traxxx',
props: {
user,
},
}]);
logger.info(`Duplicate signup with '${email}' attempted, user e-mailed`);
// we don't throw an error because the requesting user shouldn't know whether the account exists or not
return;
}
const token = crypto.randomBytes(32).toString('hex');
const tokenKey = `traxxx:token_signup:${token}`;
const tokenData = {
email,
};
await redis.set(tokenKey, JSON.stringify(tokenData));
await redis.expire(tokenKey, config.auth.signupTokenExpiry * 60);
const verifyUrl = `${config.web.address}/signup?${new URLSearchParams({
token,
}).toString()}`;
logger.info(`Signup e-mail sent to ${email}: ${verifyUrl}`);
await sendEmails([{
template: 'signup',
to: email,
subject: 'Sign up for traxxx',
props: {
verifyUrl,
},
}]);
}
export async function signup(credentials, userIp) {
if (!config.auth.signup) {
throw new HttpError('Sign-ups are currently disabled', 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();
if (!curatedUsername) {
throw new HttpError('Username required', 400);
}
if (curatedUsername.length < config.auth.usernameLength[0]) {
throw new HttpError('Username is too short', 400);
}
if (curatedUsername.length > config.auth.usernameLength[1]) {
throw new HttpError('Username is too long', 400);
}
if (!config.auth.usernamePattern.test(curatedUsername)) {
throw new HttpError('Username contains invalid characters', 400);
}
const existingUser = await knex('users')
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
.first();
if (existingUser) {
throw new HttpError('Username already in use', 409);
}
if (config.auth.captcha.enabled) {
const captchaVerification = await verifyCaptcha(config.auth.captcha.secretKey, credentials.captcha);
if (!captchaVerification.success) {
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${userIp})`);
throw new HttpError('Invalid CAPTCHA', 400);
}
}
const tokenKey = `traxxx:token_signup:${credentials.token}`;
const tokenEntry = await redis.getDel(tokenKey);
if (!tokenEntry) {
throw new HttpError('Invalid sign-up link', 401);
}
const { email } = JSON.parse(tokenEntry);
if (!email) {
throw new HttpError('Invalid sign-up link', 401);
}
const { storedPassword } = await hashPassword(credentials.password);
const [{ id: userId }] = await knex('users')
.insert({
username: curatedUsername,
email,
password: storedPassword,
email_verified: true,
})
.returning('id');
await knex('stashes').insert({
user_id: userId,
name: 'Favorites',
slug: 'favorites',
public: false,
primary: true,
});
logger.info(`Signup from '${curatedUsername}' (${userId}, ${email}, ${userIp})`);
await generateAvatar({
id: userId,
username: curatedUsername,
});
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) { export async function updateUser({ credentials, password }, reqUser, reqSession) {
if (!reqUser) { if (!reqUser) {
throw new HttpError('You are not logged in', 401); throw new HttpError('You are not logged in', 401);
@@ -383,6 +628,14 @@ export async function verifyKey(userId, key, req) {
throw new HttpError('The API credentials are not provided.', 401); throw new HttpError('The API credentials are not provided.', 401);
} }
if (typeof userId !== 'number') {
throw new HttpError('User ID is not a number', 400);
}
if (typeof key !== 'string') {
throw new HttpError('Key is not a string', 400);
}
const hashedKey = (await scrypt(key, '', 64)).toString('hex'); // salt redundant for randomly generated key const hashedKey = (await scrypt(key, '', 64)).toString('hex'); // salt redundant for randomly generated key
const storedKey = await knex('users_keys') const storedKey = await knex('users_keys')
+6 -1
View File
@@ -20,7 +20,12 @@ const resend = (() => {
})(); })();
export async function renderEmail(template, { to, subject, props }) { export async function renderEmail(template, { to, subject, props }) {
const { html, text } = await emailTemplates.render(`${template}.vue`, { props }); const { html, text } = await emailTemplates.render(`${template}.vue`, {
props: {
...props,
address: config.web.address,
},
});
return { return {
from: config.email.from, from: config.email.from,
+3 -3
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;
} }
+3 -1
View File
@@ -127,7 +127,7 @@ function curateScene(rawScene, assets, reqUser, context) {
title: serie.title, title: serie.title,
poster: context.restriction poster: context.restriction
? null ? null
: (serie.serie_poster, { type: 'poster' }), : curateMedia(serie.serie_poster, { type: 'poster' }),
})), })),
poster: curateMedia(assets.poster, { type: 'poster' }), poster: curateMedia(assets.poster, { type: 'poster' }),
photos: assets.photos?.map((photo) => curateMedia(photo, { type: 'photo' })) || [], photos: assets.photos?.map((photo) => curateMedia(photo, { type: 'photo' })) || [],
@@ -766,6 +766,7 @@ export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
'scenes_revisions.*', 'scenes_revisions.*',
'users.username as username', 'users.username as username',
'reviewers.username as reviewer_username', 'reviewers.username as reviewer_username',
knexOwner.raw('count(*) over() as total'),
) )
.leftJoin('users', 'users.id', 'scenes_revisions.user_id') .leftJoin('users', 'users.id', 'scenes_revisions.user_id')
.leftJoin('users as reviewers', 'reviewers.id', 'scenes_revisions.reviewed_by') .leftJoin('users as reviewers', 'reviewers.id', 'scenes_revisions.reviewed_by')
@@ -824,6 +825,7 @@ export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
return { return {
revisions: curatedRevisions, revisions: curatedRevisions,
revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId), revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId),
total: Number(revisions[0]?.total) || 0,
actors, actors,
tags, tags,
movies, movies,
+15 -3
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 }));
} }
+8 -2
View File
@@ -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))
@@ -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);
} }
+2 -2
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);
+49 -18
View File
@@ -1,6 +1,7 @@
import crypto from 'node:crypto';
import { Router } from 'express'; 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 {
@@ -9,7 +10,10 @@ import {
flushUserKeys, flushUserKeys,
login, login,
removeUserKey, removeUserKey,
resetPassword,
sendEmailVerification, sendEmailVerification,
sendPasswordReset,
sendSignup,
signup, signup,
updateSettings, updateSettings,
updateUser, updateUser,
@@ -20,26 +24,20 @@ import {
fetchUser, fetchUser,
} from '../users.js'; } from '../users.js';
// changes every time the process restarts, so a deploy invalidates all cached session users
// 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();
@@ -88,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;
} }
@@ -108,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) {
@@ -131,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);
@@ -184,6 +198,20 @@ async function verifyEmailApi(req, res) {
res.status(204).send(); 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) { async function updateSettingsApi(req, res) {
const updatedSettings = await updateSettings(req.body, req.user); const updatedSettings = await updateSettings(req.body, req.user);
@@ -225,6 +253,7 @@ authRouter.post('/api/session', loginApi);
authRouter.delete('/api/session', logoutApi); authRouter.delete('/api/session', logoutApi);
// USERS // USERS
authRouter.post('/api/signups', sendSignupApi);
authRouter.post('/api/users', signupApi); authRouter.post('/api/users', signupApi);
// SETTINGS // SETTINGS
@@ -234,6 +263,8 @@ authRouter.patch('/api/me/settings', updateSettingsApi);
// EMAIL VERIFICATION // EMAIL VERIFICATION
authRouter.post('/api/me/email_verification', sendEmailVerificationApi); authRouter.post('/api/me/email_verification', sendEmailVerificationApi);
authRouter.post('/api/emails/verify', verifyEmailApi); // usable without session authRouter.post('/api/emails/verify', verifyEmailApi); // usable without session
authRouter.post('/api/passwords/request', sendPasswordResetApi);
authRouter.post('/api/passwords/reset', resetPasswordApi);
// API KEYS // API KEYS
authRouter.get('/api/me/keys', fetchUserKeysApi); authRouter.get('/api/me/keys', fetchUserKeysApi);
+1 -1
View File
@@ -112,7 +112,7 @@ export async function graphqlApi(req, res) {
return; return;
} }
await verifyKey(req.headers['api-user'], req.headers['api-key'], req); await verifyKey(Number(req.headers['api-user']), req.headers['api-key'], req);
req.user = { req.user = {
id: Number(req.headers['api-user']), id: Number(req.headers['api-user']),
+1 -1
View File
@@ -60,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(),
}, },
+3 -3
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,
+64 -20
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();
+15 -3
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({
@@ -131,6 +131,7 @@ export const scenesSchema = `
network: Entity network: Entity
actors: [Actor!]! actors: [Actor!]!
tags: [Tag!]! tags: [Tag!]!
chapters: [Chapter!]!
poster: Media poster: Media
trailer: Media trailer: Media
photos: [Media!]! photos: [Media!]!
@@ -160,6 +161,17 @@ export const scenesSchema = `
size: Int size: Int
createdAt: Int createdAt: Int
} }
type Chapter {
id: Int!
time: Int
tags: [Tag!]!
date: Date
duration: Int
description: String
title: String
poster: Media
}
`; `;
function getScope(query) { function getScope(query) {
@@ -254,7 +266,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 +279,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) {
+1 -1
View File
@@ -119,7 +119,7 @@ export default async function initServer() {
router.use('/api/{*splat}', async (req, _res, next) => { router.use('/api/{*splat}', async (req, _res, next) => {
if (req.headers['api-user']) { if (req.headers['api-user']) {
req.user = await verifyKey(req.headers['api-user'], req.headers['api-key'], req); req.user = await verifyKey(Number(req.headers['api-user']), req.headers['api-key'], req);
} }
next(); next();
+1 -1
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);
+1 -1
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
View File
@@ -0,0 +1,3 @@
import crypto from 'node:crypto';
export default crypto.randomUUID();