Compare commits
50 Commits
04abff06ec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 37a919770a | |||
| 4e59994806 | |||
| 699f7f0944 | |||
| bf220f6b4e | |||
| 02ee99d52f | |||
| e50dedca66 | |||
| 760025f414 | |||
| 07d9dfaaaa | |||
| d3f549c92f | |||
| bf64a3d30e | |||
| b27c4811da | |||
| c462c2b11a | |||
| a774254e71 | |||
| 0a665ab553 | |||
| c2d8cc3a40 | |||
| eed07ba84b | |||
| e8f1dca2a0 | |||
| 4037098927 | |||
| e007dd2b83 | |||
| b86cf8e30f | |||
| e46f174a1e | |||
| 32b8f00308 | |||
| c9e698c906 | |||
| 286248e403 | |||
| f9e03f47ba | |||
| b54174eb5e | |||
| 62d19d1a04 | |||
| 6b3866a99f | |||
| 18c30b6f98 | |||
| 77b27a56f7 | |||
| 4c48700086 | |||
| 6f68a50eb7 | |||
| ef9be8ef9a | |||
| a81343c519 | |||
| 60ad068939 | |||
| df6965cb3a | |||
| 9e95bec659 | |||
| 88b866c96a | |||
| 5784c08e1b | |||
| 753840f4f1 | |||
| dbe27d68d3 | |||
| 9b058fd44b | |||
| 17d34f6a80 | |||
| 17fd285d67 | |||
| 097baed957 | |||
| b983fbaa1f | |||
| afe337d44a | |||
| 6fe8c84ef4 | |||
| e71a6987fc | |||
| 938878d860 |
@@ -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);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.input {
|
.input {
|
||||||
|
width: 15rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: .5rem .75rem;
|
padding: .5rem .75rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|||||||
@@ -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
assets/img/icons/drawer-out.svg
Executable file
5
assets/img/icons/drawer-out.svg
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- Generated by IcoMoon.io -->
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M15.89 10.188l-4-5c-0.172-0.216-0.487-0.251-0.703-0.078s-0.251 0.487-0.078 0.703l3.89 4.863v0.325h-3.5l-2 2h-3l-2-2h-3.5v-0.325l3.89-4.863c0.173-0.216 0.138-0.53-0.078-0.703s-0.53-0.138-0.703 0.078l-4 5c-0.071 0.089-0.11 0.199-0.11 0.312v4.5c0 0.552 0.448 1 1 1h14c0.552 0 1-0.448 1-1v-4.5c0-0.114-0.039-0.224-0.11-0.312z"></path>
|
||||||
|
<path d="M8 0.5l-3.5 3.5h2.5v5h2v-5h2.5z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 528 B |
18
assets/img/icons/sort-add-asc.svg
Normal file
18
assets/img/icons/sort-add-asc.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
id="svg3"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs3" /><path
|
||||||
|
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||||
|
id="path1" /><path
|
||||||
|
id="path1-3"
|
||||||
|
style="stroke-width:0.348033"
|
||||||
|
d="M 10.443359 2.2773438 C 10.354073 2.2861032 10.283203 2.3587981 10.283203 2.4472656 L 10.283203 4.328125 L 8.3339844 4.328125 C 8.2362054 4.328125 8.1582031 4.4056346 8.1582031 4.5 L 8.1582031 5.5253906 C 8.1582031 5.6197556 8.2362047 5.6953125 8.3339844 5.6953125 L 10.283203 5.6953125 L 10.283203 7.5292969 L 11.556641 6.46875 A 0.14617623 0.14617623 0 0 0 11.556641 6.4667969 C 11.601003 6.4299705 11.650097 6.3991617 11.701172 6.375 L 11.701172 5.6953125 L 13.648438 5.6953125 C 13.746218 5.6953125 13.826172 5.619755 13.826172 5.5253906 L 13.826172 4.5 C 13.826172 4.405635 13.746225 4.328125 13.648438 4.328125 L 11.701172 4.328125 L 11.701172 2.4472656 C 11.701172 2.3529006 11.621224 2.2773437 11.523438 2.2773438 L 10.460938 2.2773438 C 10.454826 2.2773438 10.449312 2.2767598 10.443359 2.2773438 z " /><path
|
||||||
|
d="M 16,5 C 16,2.243 13.757,0 11,0 8.243,0 6,2.243 6,5 V 5.5 C 6,5.776 6.224,6 6.5,6 6.776,6 7,5.776 7,5.5 V 5 c 0,-2.206 1.794,-4 4,-4 2.206,0 4,1.794 4,4 0,1.86 -1.277,3.428 -3,3.873 V 7 L 9,9.5 12,12 V 9.899 C 14.279,9.434 16,7.414 16,5 Z"
|
||||||
|
id="path3" /></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
18
assets/img/icons/sort-add-desc.svg
Normal file
18
assets/img/icons/sort-add-desc.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
id="svg3"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs3" /><path
|
||||||
|
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||||
|
id="path1" /><path
|
||||||
|
id="path1-3"
|
||||||
|
style="stroke-width:0.348033"
|
||||||
|
d="M 10.443359 2.2773438 C 10.354073 2.2861032 10.283203 2.3587981 10.283203 2.4472656 L 10.283203 4.328125 L 8.3339844 4.328125 C 8.2362054 4.328125 8.1582031 4.4056346 8.1582031 4.5 L 8.1582031 5.5253906 C 8.1582031 5.6197556 8.2362047 5.6953125 8.3339844 5.6953125 L 10.283203 5.6953125 L 10.283203 6.3671875 C 10.340222 6.3923929 10.394355 6.4261166 10.443359 6.4667969 A 0.14617623 0.14617623 0 0 1 10.443359 6.46875 L 11.701172 7.5175781 L 11.701172 5.6953125 L 13.648438 5.6953125 C 13.746218 5.6953125 13.826172 5.619755 13.826172 5.5253906 L 13.826172 4.5 C 13.826172 4.405635 13.746225 4.328125 13.648438 4.328125 L 11.701172 4.328125 L 11.701172 2.4472656 C 11.701172 2.3529006 11.621224 2.2773437 11.523438 2.2773438 L 10.460938 2.2773438 C 10.454826 2.2773438 10.449312 2.2767598 10.443359 2.2773438 z " /><path
|
||||||
|
d="m 6,5 c 0,-2.757 2.243,-5 5,-5 2.757,0 5,2.243 5,5 V 5.5 C 16,5.776 15.776,6 15.5,6 15.224,6 15,5.776 15,5.5 V 5 C 15,2.794 13.206,1 11,1 8.794,1 7,2.794 7,5 7,6.86 8.277,8.428 10,8.873 V 7 L 13,9.5 10,12 V 9.899 C 7.721,9.434 6,7.414 6,5 Z"
|
||||||
|
id="path3" /></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
16
assets/img/icons/sort-add.svg
Normal file
16
assets/img/icons/sort-add.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
id="svg3"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs3" /><path
|
||||||
|
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||||
|
id="path1" /><path
|
||||||
|
d="M 14.334849,4.1517112 H 11.883382 V 1.785868 c 0,-0.1187227 -0.09983,-0.2150766 -0.222859,-0.2150766 h -1.337165 c -0.12302,0 -0.222861,0.096353 -0.222861,0.2150766 V 4.1517112 H 7.6490291 c -0.1230181,0 -0.2228598,0.096353 -0.2228598,0.2150768 v 1.2904597 c 0,0.1187229 0.099841,0.2150767 0.2228598,0.2150767 h 2.4514679 v 2.365843 c 0,0.1187227 0.09983,0.2150768 0.222861,0.2150768 h 1.337165 c 0.123019,0 0.222859,-0.096353 0.222859,-0.2150768 v -2.365843 h 2.451467 c 0.12302,0 0.22286,-0.096355 0.22286,-0.2150767 V 4.366788 c 0,-0.1187229 -0.09983,-0.2150768 -0.22286,-0.2150768 z"
|
||||||
|
id="path1-3"
|
||||||
|
style="stroke-width:0.437868" /></svg>
|
||||||
|
After Width: | Height: | Size: 1010 B |
38
assets/img/icons/sort-heart.svg
Normal file
38
assets/img/icons/sort-heart.svg
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
id="svg3"
|
||||||
|
sodipodi:docname="sort-heart.svg"
|
||||||
|
xml:space="preserve"
|
||||||
|
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs3" /><sodipodi:namedview
|
||||||
|
id="namedview3"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="26.077075"
|
||||||
|
inkscape:cx="4.0265253"
|
||||||
|
inkscape:cy="4.640091"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1020"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="32"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg3"
|
||||||
|
showguides="true" /><path
|
||||||
|
d="M5 12v-12h-2v12h-2.5l3.5 3.5 3.5-3.5h-2.5z"
|
||||||
|
id="path1" /><path
|
||||||
|
d="M 13.78723,0 C 12.941692,0 12.207573,0.47431375 11.834786,1.1709458 11.461999,0.47431375 10.72788,0 9.8823418,0 c -1.2219697,0 -2.21277,0.99080032 -2.21277,2.2127699 0,2.9937477 4.1652142,5.0763547 4.1652142,5.0763547 0,0 4.165214,-2.082607 4.165214,-5.0763547 C 16,0.99080032 15.0092,0 13.78723,0 Z"
|
||||||
|
id="path1-5"
|
||||||
|
style="stroke-width:0.520651" /></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -303,6 +303,7 @@ function updateFilter(prop, value, reload = true) {
|
|||||||
display: grid;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
.filter((social) => social.platform)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((social) => ({
|
||||||
...social,
|
...social,
|
||||||
handle: social.url
|
handle: social.url
|
||||||
? new URL(social.url).hostname
|
? new URL(social.url).hostname
|
||||||
: social.handle,
|
: social.handle,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const aliases = Object
|
const aliases = Object
|
||||||
.entries(props.actor.aliases.reduce((acc, alias) => {
|
.entries(props.actor.aliases.reduce((acc, alias) => {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
106
components/emails/password.vue
Normal file
106
components/emails/password.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Preview,
|
||||||
|
Text,
|
||||||
|
} from '@vue-email/components';
|
||||||
|
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
default: () => mockUser,
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: String,
|
||||||
|
default: 'http://localhost:5100',
|
||||||
|
},
|
||||||
|
ip: {
|
||||||
|
type: String,
|
||||||
|
default: '127.0.0.1',
|
||||||
|
},
|
||||||
|
country: {
|
||||||
|
type: String,
|
||||||
|
default: 'Unknown',
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
type: Date,
|
||||||
|
default: () => new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||||
|
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||||
|
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'DebuggingLibrarian',
|
||||||
|
email: 'debug@alerts.traxxx.me',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Html>
|
||||||
|
<Body style="font-family: sans-serif;">
|
||||||
|
<Preview>Your traxxx password has been changed</Preview>
|
||||||
|
|
||||||
|
<Container>
|
||||||
|
<Img
|
||||||
|
:src="`${address}/img/logo.png`"
|
||||||
|
width="150"
|
||||||
|
alt="traxxx"
|
||||||
|
style="margin: 15px auto 25px auto;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Heading
|
||||||
|
as="h3"
|
||||||
|
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||||
|
>Hi, {{ user.username }}</Heading>
|
||||||
|
|
||||||
|
<Heading
|
||||||
|
as="h3"
|
||||||
|
style="margin-top: 30px;"
|
||||||
|
>Your traxxx password was changed.</Heading>
|
||||||
|
|
||||||
|
<Text>Device used to change your password:</Text>
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center"
|
||||||
|
width="100%"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
role="presentation"
|
||||||
|
style="font-size: 14px;"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 8px 4px 0;">IP:</td>
|
||||||
|
<td style="padding: 4px 0;">{{ ip }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 8px 4px 0;">Country:</td>
|
||||||
|
<td style="padding: 4px 0;">{{ country }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 8px 4px 0;">Timestamp:</td>
|
||||||
|
<td style="padding: 4px 0;">{{ format(timestamp, 'yyyy-MM-dd hh:mm:ss') }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<Text style="font-size: 16px; margin: 20px 0;">If this change was made by you, you can ignore this e-mail. If this was not you, please <a
|
||||||
|
href="mailto:account@traxxx.me"
|
||||||
|
style="color: #f65596; text-decoration: none;"
|
||||||
|
>contact us</a> immediately.</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
</template>
|
||||||
79
components/emails/recovery.vue
Normal file
79
components/emails/recovery.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Preview,
|
||||||
|
Text,
|
||||||
|
} from '@vue-email/components';
|
||||||
|
|
||||||
|
import config from 'config';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
default: () => mockUser,
|
||||||
|
},
|
||||||
|
verifyUrl: {
|
||||||
|
type: String,
|
||||||
|
default: 'http://localhost:5100/recovery?userId=0&token=00000000000000000000000000000000',
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: String,
|
||||||
|
default: 'http://localhost:5100',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||||
|
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||||
|
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'DebuggingLibrarian',
|
||||||
|
email: 'debug@alerts.traxxx.me',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Html>
|
||||||
|
<Body style="font-family: sans-serif;">
|
||||||
|
<Preview>Reset your traxxx password</Preview>
|
||||||
|
|
||||||
|
<Container>
|
||||||
|
<Img
|
||||||
|
:src="`${address}/img/logo.png`"
|
||||||
|
width="150"
|
||||||
|
alt="traxxx"
|
||||||
|
style="margin: 15px auto 25px auto;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Heading
|
||||||
|
as="h3"
|
||||||
|
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||||
|
>Hi, {{ user.username }}</Heading>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
<strong>You requested to reset the password for your traxxx account.</strong>
|
||||||
|
<br>
|
||||||
|
Please set a new password by clicking the button below:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
:href="verifyUrl"
|
||||||
|
target="_blank"
|
||||||
|
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
|
||||||
|
><strong>Reset password</strong></Button>
|
||||||
|
|
||||||
|
<Text style="font-size: 16px; margin: 20px 0;">
|
||||||
|
The link will expire in {{ config.auth.passwordTokenExpiry }} minutes, or when you request another password reset.
|
||||||
|
If you did not request a password reset, you can safely ignore this e-mail.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
</template>
|
||||||
80
components/emails/signup.vue
Normal file
80
components/emails/signup.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Preview,
|
||||||
|
Text,
|
||||||
|
} from '@vue-email/components';
|
||||||
|
|
||||||
|
import config from 'config';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
default: () => mockUser,
|
||||||
|
},
|
||||||
|
verifyUrl: {
|
||||||
|
type: String,
|
||||||
|
default: 'http://localhost:5100/verify?userId=0&token=00000000000000000000000000000000',
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
type: String,
|
||||||
|
default: 'change',
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: String,
|
||||||
|
default: 'http://localhost:5100',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||||
|
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||||
|
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'DebuggingLibrarian',
|
||||||
|
email: 'debug@alerts.traxxx.me',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Html>
|
||||||
|
<Body style="font-family: sans-serif;">
|
||||||
|
<Preview>Sign up for traxxx</Preview>
|
||||||
|
|
||||||
|
<Container>
|
||||||
|
<Img
|
||||||
|
:src="`${address}/img/logo.png`"
|
||||||
|
width="150"
|
||||||
|
alt="traxxx"
|
||||||
|
style="margin: 15px auto 25px auto;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Heading
|
||||||
|
as="h3"
|
||||||
|
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||||
|
>Hi,</Heading>
|
||||||
|
|
||||||
|
<Text style="font-size: 16px;">
|
||||||
|
<strong>Thank you for signing up for traxxx!</strong>
|
||||||
|
<br>
|
||||||
|
Please complete your registration by clicking the button below:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
:href="verifyUrl"
|
||||||
|
target="_blank"
|
||||||
|
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
|
||||||
|
><strong>Create my account</strong></Button>
|
||||||
|
|
||||||
|
<Text style="font-size: 16px; margin: 20px 0;">The link will expire in {{ config.auth.signupTokenExpiry }} minutes. If you did not sign up, you can safely ignore this e-mail.</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
</template>
|
||||||
62
components/emails/unavailable.vue
Normal file
62
components/emails/unavailable.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Preview,
|
||||||
|
Text,
|
||||||
|
} from '@vue-email/components';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
default: () => mockUser,
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: String,
|
||||||
|
default: 'http://localhost:5100',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||||
|
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||||
|
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'DebuggingLibrarian',
|
||||||
|
email: 'debug@alerts.traxxx.me',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Html>
|
||||||
|
<Body style="font-family: sans-serif;">
|
||||||
|
<Preview>Someone tried to use this e-mail address for traxxx</Preview>
|
||||||
|
|
||||||
|
<Container>
|
||||||
|
<Img
|
||||||
|
:src="`${address}/img/logo.png`"
|
||||||
|
width="150"
|
||||||
|
alt="traxxx"
|
||||||
|
style="margin: 15px auto 25px auto;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Heading
|
||||||
|
as="h3"
|
||||||
|
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||||
|
>Hi, {{ user.username }}</Heading>
|
||||||
|
|
||||||
|
<Heading
|
||||||
|
as="h3"
|
||||||
|
style="margin-top: 30px;"
|
||||||
|
>Someone tried to assign this e-mail address to a traxxx account.</Heading>
|
||||||
|
|
||||||
|
<Text style="font-size: 16px; margin: 20px 0;">If this was you with this account ({{ user.username }}), your e-mail address is already set correctly. If this was you with another account, you will need to use another e-mail address. If this was not you, you can safely ignore this e-mail.</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
</template>
|
||||||
81
components/emails/verify.vue
Normal file
81
components/emails/verify.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Preview,
|
||||||
|
Text,
|
||||||
|
} from '@vue-email/components';
|
||||||
|
|
||||||
|
import config from 'config';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
default: () => mockUser,
|
||||||
|
},
|
||||||
|
verifyUrl: {
|
||||||
|
type: String,
|
||||||
|
default: 'http://localhost:5100/verify?userId=0&token=00000000000000000000000000000000',
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
type: String,
|
||||||
|
default: 'change',
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: String,
|
||||||
|
default: 'http://localhost:5100',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||||
|
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||||
|
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'DebuggingLibrarian',
|
||||||
|
email: 'debug@alerts.traxxx.me',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Html>
|
||||||
|
<Body style="font-family: sans-serif;">
|
||||||
|
<Preview>Please verify your traxxx e-mail address</Preview>
|
||||||
|
|
||||||
|
<Container>
|
||||||
|
<Img
|
||||||
|
:src="`${address}/img/logo.png`"
|
||||||
|
width="150"
|
||||||
|
alt="traxxx"
|
||||||
|
style="margin: 15px auto 25px auto;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Heading
|
||||||
|
as="h3"
|
||||||
|
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||||
|
>Hi, {{ user.username }}</Heading>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
<strong>You requested to {{ trigger === 'verify' ? 'verify' : 'change' }} the e-mail address for your traxxx account.</strong>
|
||||||
|
<br>
|
||||||
|
Please confirm your e-mail address by clicking the button below:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
:href="verifyUrl"
|
||||||
|
target="_blank"
|
||||||
|
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
|
||||||
|
><strong>Verify e-mail address</strong></Button>
|
||||||
|
|
||||||
|
<Text style="font-size: 16px; margin: 20px 0;">
|
||||||
|
The link will expire in {{ config.auth.emailTokenExpiry }} minutes, or when you request an(other) e-mail change or verification. If you did not request this {{ trigger === 'verify' ? 'verification' : 'change' }}, you can safely ignore this e-mail.</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
</template>
|
||||||
92
components/form/password.vue
Normal file
92
components/form/password.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
password: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: 'Password',
|
||||||
|
},
|
||||||
|
autocomplete: {
|
||||||
|
type: String,
|
||||||
|
default: 'current-password',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['change', 'input']);
|
||||||
|
|
||||||
|
const currentPassword = ref(props.password);
|
||||||
|
const showPassword = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="password-container">
|
||||||
|
<input
|
||||||
|
v-model="currentPassword"
|
||||||
|
:type="showPassword ? 'input' : 'password'"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
minlength="3"
|
||||||
|
class="password input"
|
||||||
|
@change="emit('change', currentPassword)"
|
||||||
|
@input="emit('input', currentPassword)"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="password-show"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
v-show="!showPassword"
|
||||||
|
icon="eye"
|
||||||
|
class="password-show"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
v-show="showPassword"
|
||||||
|
icon="eye-blocked"
|
||||||
|
class="password-show"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.input {
|
||||||
|
background: var(--background);
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-container {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-show {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
padding: 0 1rem 0 .5rem;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
fill: var(--glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
fill: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
|
width: 100%;
|
||||||
resize: none;
|
resize: none;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ async function reloadStashes(newStash) {
|
|||||||
v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))"
|
v-if="itemStashes && (showSecondary || (hasSecondaryStash && pageStash?.user.id !== user.id))"
|
||||||
:aria-id="stashMenuDropdownId"
|
:aria-id="stashMenuDropdownId"
|
||||||
:shown="showStashes"
|
:shown="showStashes"
|
||||||
|
placement="auto"
|
||||||
|
popper-class="no-outer-scroll"
|
||||||
@hide="showStashes = false"
|
@hide="showStashes = false"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
@@ -259,6 +261,7 @@ async function reloadStashes(newStash) {
|
|||||||
<StashMenu
|
<StashMenu
|
||||||
:stashes="stashes"
|
:stashes="stashes"
|
||||||
:item-stashes="itemStashes"
|
:item-stashes="itemStashes"
|
||||||
|
:domain="domain"
|
||||||
@stash="(stash) => stashItem(stash)"
|
@stash="(stash) => stashItem(stash)"
|
||||||
@unstash="(stash) => unstashItem(stash)"
|
@unstash="(stash) => unstashItem(stash)"
|
||||||
@create="showStashDialog = true"
|
@create="showStashDialog = true"
|
||||||
@@ -272,6 +275,8 @@ async function reloadStashes(newStash) {
|
|||||||
:triggers="[]"
|
:triggers="[]"
|
||||||
:shown="showStashes"
|
:shown="showStashes"
|
||||||
:disabled="showSecondary"
|
:disabled="showSecondary"
|
||||||
|
placement="auto"
|
||||||
|
popper-class="no-outer-scroll"
|
||||||
@hide="showStashes = false"
|
@hide="showStashes = false"
|
||||||
>
|
>
|
||||||
<template v-if="pageStash?.user.id === user.id">
|
<template v-if="pageStash?.user.id === user.id">
|
||||||
@@ -314,6 +319,7 @@ async function reloadStashes(newStash) {
|
|||||||
<StashMenu
|
<StashMenu
|
||||||
:stashes="stashes"
|
:stashes="stashes"
|
||||||
:item-stashes="itemStashes"
|
:item-stashes="itemStashes"
|
||||||
|
:domain="domain"
|
||||||
@stash="(stash) => stashItem(stash)"
|
@stash="(stash) => stashItem(stash)"
|
||||||
@unstash="(stash) => unstashItem(stash)"
|
@unstash="(stash) => unstashItem(stash)"
|
||||||
@create="showStashDialog = true"
|
@create="showStashDialog = true"
|
||||||
|
|||||||
@@ -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,15 +12,64 @@ 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">
|
||||||
|
<ul class="stash-list nolist noselect">
|
||||||
<li
|
<li
|
||||||
v-for="userStash in stashes"
|
v-for="userStash in sortedStashes"
|
||||||
:key="`stash-${userStash.id}`"
|
:key="`stash-${userStash.id}`"
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
>
|
>
|
||||||
@@ -29,27 +80,73 @@ const emit = defineEmits(['stash', 'unstash', 'create']);
|
|||||||
/>{{ userStash.name }}
|
/>{{ userStash.name }}
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -91,6 +91,30 @@ async function rename() {
|
|||||||
|
|
||||||
showRenameDialog.value = false;
|
showRenameDialog.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function archive(isArchived = true) {
|
||||||
|
if (done.value === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
done.value = false;
|
||||||
|
|
||||||
|
await patch(`/stashes/${props.stash.id}`, { isArchived }, {
|
||||||
|
...isArchived
|
||||||
|
? {
|
||||||
|
undoFeedback: `Stash archived`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
successFeedback: `Stash revived`,
|
||||||
|
},
|
||||||
|
errorFeedback: `Failed to ${isArchived ? 'archive' : 'revive'} stash`,
|
||||||
|
appendErrorMessage: true,
|
||||||
|
}).finally(() => { done.value = true; });
|
||||||
|
|
||||||
|
emit('reload');
|
||||||
|
|
||||||
|
showRenameDialog.value = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -159,6 +183,28 @@ async function rename() {
|
|||||||
/>Rename
|
/>Rename
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li
|
||||||
|
v-if="stash.isArchived"
|
||||||
|
class="menu-item"
|
||||||
|
@click="archive(false)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="drawer-out"
|
||||||
|
class="noselect"
|
||||||
|
/>Revive
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li
|
||||||
|
v-else
|
||||||
|
class="menu-item"
|
||||||
|
@click="archive()"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="drawer-in"
|
||||||
|
class="noselect"
|
||||||
|
/>Archive
|
||||||
|
</li>
|
||||||
|
|
||||||
<li
|
<li
|
||||||
v-if="!stash.isPrimary"
|
v-if="!stash.isPrimary"
|
||||||
class="menu-item remove"
|
class="menu-item remove"
|
||||||
@@ -306,6 +352,7 @@ async function rename() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
margin-right: .75rem;
|
margin-right: .75rem;
|
||||||
fill: var(--glass);
|
fill: var(--glass);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.remove {
|
&.remove {
|
||||||
|
|||||||
300
components/user/account.vue
Normal file
300
components/user/account.vue
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<script setup>
|
||||||
|
import { inject, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
import Password from '#/components/form/password.vue';
|
||||||
|
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||||
|
|
||||||
|
// import Checkbox from '#/components/form/checkbox.vue';
|
||||||
|
|
||||||
|
import { patch, post } from '#/src/api.js';
|
||||||
|
|
||||||
|
const pageContext = inject('pageContext');
|
||||||
|
const { user } = pageContext;
|
||||||
|
|
||||||
|
const allowEmailUpdate = pageContext.env.emailEnabled;
|
||||||
|
|
||||||
|
const credentials = reactive({
|
||||||
|
email: null,
|
||||||
|
password: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentPassword = ref(null);
|
||||||
|
const saving = ref(false);
|
||||||
|
const submitted = ref(false);
|
||||||
|
const verifying = ref(false);
|
||||||
|
const verified = ref(false);
|
||||||
|
const feedback = ref(null);
|
||||||
|
|
||||||
|
function getFeedback() {
|
||||||
|
if (credentials.email && Object.entries(credentials).some(([key, value]) => key !== 'email' && value !== null)) {
|
||||||
|
return 'Account updated, please check your inbox to change e-mail.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.email) {
|
||||||
|
return 'Please check your inbox to change e-mail.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Account updated.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAccount() {
|
||||||
|
saving.value = true;
|
||||||
|
feedback.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const feedback = getFeedback();
|
||||||
|
|
||||||
|
await patch('/me', {
|
||||||
|
credentials,
|
||||||
|
password: currentPassword.value,
|
||||||
|
}, {
|
||||||
|
successFeedback: feedback,
|
||||||
|
errorFeedback: 'Failed to update account',
|
||||||
|
appendErrorMessage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentPassword.value = null;
|
||||||
|
credentials.email = null;
|
||||||
|
credentials.password = null;
|
||||||
|
|
||||||
|
submitted.value = true;
|
||||||
|
feedback.value = feedback;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyEmail() {
|
||||||
|
verifying.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await post('/me/email_verification', null, {
|
||||||
|
successFeedback: 'E-mail verification sent, please check your inbox',
|
||||||
|
errorFeedback: 'Failed to request e-mail verification',
|
||||||
|
appendErrorMessage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
verified.value = true;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
verifying.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form
|
||||||
|
class="profile-section"
|
||||||
|
@submit.prevent="updateAccount"
|
||||||
|
>
|
||||||
|
<div class="setting">
|
||||||
|
<span class="setting-label">
|
||||||
|
<strong class="setting-title">E-mail address</strong>
|
||||||
|
<span class="setting-description">Primarily used for alert notifications and password recovery</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="setting-value">
|
||||||
|
<span class="setting-group">
|
||||||
|
<VDropdown v-if="!user.isEmailVerified">
|
||||||
|
<Icon
|
||||||
|
icon="warning2"
|
||||||
|
class="unverified"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template #popper>
|
||||||
|
<div class="unverified-tooltip">
|
||||||
|
Your e-mail address is unverified.
|
||||||
|
|
||||||
|
<Ellipsis v-if="verifying" />
|
||||||
|
|
||||||
|
<strong
|
||||||
|
v-else-if="verified"
|
||||||
|
class="success"
|
||||||
|
>Verification e-mail sent</strong>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else-if="allowEmailUpdate"
|
||||||
|
type="button"
|
||||||
|
class="button button-primary"
|
||||||
|
@click="verifyEmail"
|
||||||
|
>Verify now</button>
|
||||||
|
|
||||||
|
<span v-else>Verification is currently unavailable.</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VDropdown>
|
||||||
|
|
||||||
|
<input
|
||||||
|
:value="user.email"
|
||||||
|
type="email"
|
||||||
|
class="input"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-if="allowEmailUpdate"
|
||||||
|
v-model="credentials.email"
|
||||||
|
type="email"
|
||||||
|
placeholder="New e-mail"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting">
|
||||||
|
<span class="setting-label">
|
||||||
|
<strong class="setting-title">New password</strong>
|
||||||
|
<span class="setting-description">Change the password you use for signing in</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="setting-value">
|
||||||
|
<Password
|
||||||
|
:password="credentials.password"
|
||||||
|
placeholder="New password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
@input="(password) => credentials.password = password"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting">
|
||||||
|
<span class="setting-label">
|
||||||
|
<strong class="setting-title">Current password</strong>
|
||||||
|
<span class="setting-description">Enter your current password to save your changes</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="setting-value">
|
||||||
|
<Password
|
||||||
|
:password="currentPassword"
|
||||||
|
placeholder="Current password"
|
||||||
|
@input="(password) => currentPassword = password"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-actions">
|
||||||
|
<Ellipsis v-if="saving" />
|
||||||
|
|
||||||
|
<strong
|
||||||
|
v-else-if="submitted"
|
||||||
|
class="success"
|
||||||
|
>{{ feedback }}</strong>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
v-tooltip="(credentials.email || credentials.password) && currentPassword ? null : 'Please supply your new credentials and current password'"
|
||||||
|
class="button button-primary"
|
||||||
|
:disabled="!currentPassword"
|
||||||
|
>Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.input {
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-basis: auto;
|
||||||
|
background: var(--background);
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
color: var(--glass);
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-section .heading {
|
||||||
|
margin: .5rem 1rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
border-bottom: solid 1px var(--glass-weak-30);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--glass-weak-20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-title {
|
||||||
|
color: var(--glass-strong-20);
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
color: var(--glass);
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
.button {
|
||||||
|
flex-basis: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: var(--success);
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unverified {
|
||||||
|
fill: var(--error);
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unverified-tooltip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media(--small-20) {
|
||||||
|
.setting {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -154,6 +154,14 @@ module.exports = {
|
|||||||
siteKey: '10000000-ffff-ffff-ffff-000000000001',
|
siteKey: '10000000-ffff-ffff-ffff-000000000001',
|
||||||
secretKey: '0x0000000000000000000000000000000000000000',
|
secretKey: '0x0000000000000000000000000000000000000000',
|
||||||
},
|
},
|
||||||
|
loginCredentialAttempts: 5,
|
||||||
|
loginCredentialCooldown: 10, // minutes,
|
||||||
|
loginIpAttempts: 20, // shared IPs might result in more non-malicious failed attempt
|
||||||
|
loginIpCooldown: 10, // minutes
|
||||||
|
tokenCooldown: 10, // minutes, how often a token can be requested
|
||||||
|
emailTokenExpiry: 120, // minutes
|
||||||
|
passwordTokenExpiry: 120, // minutes
|
||||||
|
signupTokenExpiry: 120, // minutes
|
||||||
},
|
},
|
||||||
bans: {
|
bans: {
|
||||||
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
|
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
|
||||||
@@ -206,6 +214,6 @@ module.exports = {
|
|||||||
// Resend
|
// Resend
|
||||||
enabled: false,
|
enabled: false,
|
||||||
apiKey: null,
|
apiKey: null,
|
||||||
from: 'noreply@alerts.traxxx.me',
|
from: 'traxxx <noreply@alerts.traxxx.me>',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import antfu from '@antfu/eslint-config';
|
import antfu from '@antfu/eslint-config';
|
||||||
|
import importX, { createNodeResolver } from 'eslint-plugin-import-x';
|
||||||
|
|
||||||
export default antfu(
|
export default antfu(
|
||||||
{
|
{
|
||||||
@@ -18,11 +19,21 @@ export default antfu(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
plugins: { 'import-x': importX },
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
CONFIG: 'readonly',
|
CONFIG: 'readonly',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
settings: {
|
||||||
|
// mirrors the '#'/'#root' aliases in vite.config.js, which is what actually resolves these at build/runtime
|
||||||
|
'import-x/resolver-next': [createNodeResolver({
|
||||||
|
alias: {
|
||||||
|
'#': [import.meta.dirname],
|
||||||
|
'#root': [import.meta.dirname],
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'default-param-last': 'off',
|
'default-param-last': 'off',
|
||||||
'no-underscore-dangle': 'off',
|
'no-underscore-dangle': 'off',
|
||||||
@@ -33,6 +44,7 @@ export default antfu(
|
|||||||
ignorePropertyModificationsFor: ['state', 'acc', 'req'],
|
ignorePropertyModificationsFor: ['state', 'acc', 'req'],
|
||||||
}],
|
}],
|
||||||
'prefer-destructuring': 'off',
|
'prefer-destructuring': 'off',
|
||||||
|
'import-x/no-unresolved': 'error',
|
||||||
'import/prefer-default-export': 'off',
|
'import/prefer-default-export': 'off',
|
||||||
'unused-imports/no-unused-vars': ['error', {
|
'unused-imports/no-unused-vars': ['error', {
|
||||||
args: 'after-used',
|
args: 'after-used',
|
||||||
|
|||||||
482
package-lock.json
generated
482
package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "traxxx-web",
|
"name": "traxxx-web",
|
||||||
"version": "0.55.0",
|
"version": "0.57.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"version": "0.55.0",
|
"version": "0.57.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@brillout/json-serializer": "^0.5.25",
|
"@brillout/json-serializer": "^0.5.25",
|
||||||
"@dicebear/collection": "7.0.5",
|
"@dicebear/collection": "7.0.5",
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"ejs": "^6.0.1",
|
"ejs": "^6.0.1",
|
||||||
"error-stack-parser": "^2.1.4",
|
"error-stack-parser": "^2.1.4",
|
||||||
"escape-string-regexp": "^5.0.0",
|
"escape-string-regexp": "^5.0.0",
|
||||||
|
"eslint-plugin-import-x": "^4.17.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
@@ -6113,7 +6114,7 @@
|
|||||||
"version": "8.63.0",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz",
|
||||||
"integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==",
|
"integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.63.0",
|
"@typescript-eslint/tsconfig-utils": "^8.63.0",
|
||||||
@@ -6339,7 +6340,7 @@
|
|||||||
"version": "8.63.0",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz",
|
||||||
"integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==",
|
"integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.63.0",
|
"@typescript-eslint/types": "8.63.0",
|
||||||
@@ -6357,7 +6358,7 @@
|
|||||||
"version": "8.63.0",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz",
|
||||||
"integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==",
|
"integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -6399,7 +6400,6 @@
|
|||||||
"version": "8.63.0",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
|
||||||
"integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
|
"integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -6413,7 +6413,7 @@
|
|||||||
"version": "8.63.0",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz",
|
||||||
"integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==",
|
"integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.63.0",
|
"@typescript-eslint/project-service": "8.63.0",
|
||||||
@@ -6441,7 +6441,7 @@
|
|||||||
"version": "7.8.5",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -6454,7 +6454,7 @@
|
|||||||
"version": "8.63.0",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz",
|
||||||
"integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==",
|
"integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
@@ -6478,7 +6478,7 @@
|
|||||||
"version": "8.63.0",
|
"version": "8.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz",
|
||||||
"integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==",
|
"integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.63.0",
|
"@typescript-eslint/types": "8.63.0",
|
||||||
@@ -6496,7 +6496,7 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
@@ -6663,6 +6663,328 @@
|
|||||||
"@universal-middleware/core": "^0.4.18"
|
"@universal-middleware/core": "^0.4.18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-android-arm64": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-darwin-arm64": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-darwin-x64": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-freebsd-x64": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-loong64-gnu": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-loong64-musl": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-x64-musl": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-openharmony-arm64": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "1.10.0",
|
||||||
|
"@emnapi/runtime": "1.10.0",
|
||||||
|
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@videojs/http-streaming": {
|
"node_modules/@videojs/http-streaming": {
|
||||||
"version": "3.17.5",
|
"version": "3.17.5",
|
||||||
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.5.tgz",
|
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.5.tgz",
|
||||||
@@ -8363,7 +8685,6 @@
|
|||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz",
|
||||||
"integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==",
|
"integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
@@ -9756,6 +10077,30 @@
|
|||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint-import-context": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz",
|
||||||
|
"integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-tsconfig": "^4.10.1",
|
||||||
|
"stable-hash-x": "^0.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint-import-context"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"unrs-resolver": "^1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"unrs-resolver": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint-import-resolver-node": {
|
"node_modules/eslint-import-resolver-node": {
|
||||||
"version": "0.3.10",
|
"version": "0.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz",
|
||||||
@@ -9932,6 +10277,54 @@
|
|||||||
"eslint": "^9.0.0 || ^10.0.0"
|
"eslint": "^9.0.0 || ^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint-plugin-import-x": {
|
||||||
|
"version": "4.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.17.1.tgz",
|
||||||
|
"integrity": "sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/types": "^8.56.0",
|
||||||
|
"comment-parser": "^1.4.1",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"eslint-import-context": "^0.1.9",
|
||||||
|
"is-glob": "^4.0.3",
|
||||||
|
"minimatch": "^9.0.3 || ^10.1.2",
|
||||||
|
"semver": "^7.7.2",
|
||||||
|
"stable-hash-x": "^0.2.0",
|
||||||
|
"unrs-resolver": "^1.9.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint-plugin-import-x"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@typescript-eslint/utils": "^8.56.0",
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"eslint-import-resolver-node": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@typescript-eslint/utils": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"eslint-import-resolver-node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint-plugin-import-x/node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint-plugin-jsdoc": {
|
"node_modules/eslint-plugin-jsdoc": {
|
||||||
"version": "63.0.13",
|
"version": "63.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-63.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-63.0.13.tgz",
|
||||||
@@ -11318,7 +11711,6 @@
|
|||||||
"version": "4.14.0",
|
"version": "4.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resolve-pkg-maps": "^1.0.0"
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
@@ -15871,6 +16263,21 @@
|
|||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/napi-postinstall": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"napi-postinstall": "lib/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/napi-postinstall"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/natural-compare": {
|
"node_modules/natural-compare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||||
@@ -17694,7 +18101,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
@@ -18478,6 +18884,15 @@
|
|||||||
"node": ">=20.16.0"
|
"node": ">=20.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stable-hash-x": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stack-trace": {
|
"node_modules/stack-trace": {
|
||||||
"version": "0.0.10",
|
"version": "0.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||||
@@ -19412,7 +19827,7 @@
|
|||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.12"
|
"node": ">=18.12"
|
||||||
@@ -20320,6 +20735,43 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unrs-resolver": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"napi-postinstall": "^0.3.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/unrs-resolver"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@unrs/resolver-binding-android-arm-eabi": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-android-arm64": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-darwin-arm64": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-darwin-x64": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-freebsd-x64": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-linux-arm64-gnu": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-linux-arm64-musl": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-linux-loong64-gnu": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-linux-loong64-musl": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-linux-riscv64-musl": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-linux-s390x-gnu": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-linux-x64-gnu": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-linux-x64-musl": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-openharmony-arm64": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-wasm32-wasi": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-win32-arm64-msvc": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-win32-ia32-msvc": "1.12.2",
|
||||||
|
"@unrs/resolver-binding-win32-x64-msvc": "1.12.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"ejs": "^6.0.1",
|
"ejs": "^6.0.1",
|
||||||
"error-stack-parser": "^2.1.4",
|
"error-stack-parser": "^2.1.4",
|
||||||
"escape-string-regexp": "^5.0.0",
|
"escape-string-regexp": "^5.0.0",
|
||||||
|
"eslint-plugin-import-x": "^4.17.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
@@ -91,7 +92,7 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "$vite"
|
"vite": "$vite"
|
||||||
},
|
},
|
||||||
"version": "0.55.0",
|
"version": "0.57.15",
|
||||||
"imports": {
|
"imports": {
|
||||||
"#/*": "./*.js"
|
"#/*": "./*.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
export default {
|
export default {
|
||||||
meta: {
|
meta: {
|
||||||
|
title: {
|
||||||
|
env: {
|
||||||
|
server: true,
|
||||||
|
client: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
env: {
|
env: {
|
||||||
server: true,
|
server: true,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export async function onBeforeRender(pageContext) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
pageContext: {
|
pageContext: {
|
||||||
title: 'actors',
|
|
||||||
pageProps: {
|
pageProps: {
|
||||||
actors,
|
actors,
|
||||||
countries,
|
countries,
|
||||||
|
|||||||
1
pages/actors/+title.js
Normal file
1
pages/actors/+title.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default 'Actors';
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { inject, onMounted, ref } from 'vue';
|
import { inject, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import Password from '#/components/form/password.vue';
|
||||||
|
|
||||||
import { post } from '#/src/api.js';
|
import { post } from '#/src/api.js';
|
||||||
import navigate from '#/src/navigate.js';
|
import navigate from '#/src/navigate.js';
|
||||||
|
|
||||||
const pageContext = inject('pageContext');
|
const pageContext = inject('pageContext');
|
||||||
const user = pageContext.user;
|
const user = pageContext.user;
|
||||||
const allowSignup = pageContext.env.allowSignup;
|
const allowSignup = pageContext.env.allowSignup;
|
||||||
|
const allowRecovery = pageContext.env.emailEnabled;
|
||||||
|
|
||||||
const username = ref('');
|
const username = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
@@ -14,7 +17,6 @@ const password = ref('');
|
|||||||
const submitted = ref(false);
|
const submitted = ref(false);
|
||||||
const errorMsg = ref(null);
|
const errorMsg = ref(null);
|
||||||
const userInput = ref(null);
|
const userInput = ref(null);
|
||||||
const showPassword = ref(false);
|
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
submitted.value = true;
|
submitted.value = true;
|
||||||
@@ -88,32 +90,11 @@ onMounted(() => {
|
|||||||
class="input"
|
class="input"
|
||||||
>
|
>
|
||||||
|
|
||||||
<div class="password-container">
|
<Password
|
||||||
<input
|
:password="password"
|
||||||
v-model="password"
|
@input="(newPassword) => password = newPassword"
|
||||||
:type="showPassword ? 'input' : 'password'"
|
|
||||||
placeholder="Password"
|
|
||||||
class="password input"
|
|
||||||
>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="password-show"
|
|
||||||
@click="showPassword = !showPassword"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
v-show="!showPassword"
|
|
||||||
icon="eye"
|
|
||||||
class="password-show"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Icon
|
|
||||||
v-show="showPassword"
|
|
||||||
icon="eye-blocked"
|
|
||||||
class="password-show"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="button button-submit"
|
class="button button-submit"
|
||||||
:disabled="submitted"
|
:disabled="submitted"
|
||||||
@@ -124,11 +105,21 @@ onMounted(() => {
|
|||||||
href="/signup"
|
href="/signup"
|
||||||
class="link"
|
class="link"
|
||||||
>Create an account</a>
|
>Create an account</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
v-if="allowRecovery"
|
||||||
|
href="/recovery"
|
||||||
|
class="link"
|
||||||
|
>I forgot my password</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.login-container {
|
.login-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
@@ -200,4 +191,8 @@ onMounted(() => {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-submit {
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
1
pages/auth/login/+title.js
Normal file
1
pages/auth/login/+title.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default 'Login';
|
||||||
218
pages/auth/recovery/+Page.vue
Normal file
218
pages/auth/recovery/+Page.vue
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<script setup>
|
||||||
|
import { inject, ref } from 'vue';
|
||||||
|
|
||||||
|
import Password from '#/components/form/password.vue';
|
||||||
|
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||||
|
|
||||||
|
import { post } from '#/src/api.js';
|
||||||
|
|
||||||
|
const pageContext = inject('pageContext');
|
||||||
|
const { userId, token } = pageContext.urlParsed.search;
|
||||||
|
const allowRecovery = pageContext.env.emailEnabled;
|
||||||
|
|
||||||
|
const credential = ref('');
|
||||||
|
|
||||||
|
const submitted = ref(false);
|
||||||
|
const success = ref(false);
|
||||||
|
const errorMsg = ref(null);
|
||||||
|
const password = ref(null);
|
||||||
|
|
||||||
|
async function request() {
|
||||||
|
submitted.value = true;
|
||||||
|
errorMsg.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await post('/passwords/request', {
|
||||||
|
credential: credential.value,
|
||||||
|
}, {
|
||||||
|
successFeedback: 'Password reset requested, please check your e-mail inbox',
|
||||||
|
appendErrorMessage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
success.value = true;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
errorMsg.value = error.message;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitted.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset() {
|
||||||
|
submitted.value = true;
|
||||||
|
errorMsg.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await post('/passwords/reset', {
|
||||||
|
userId,
|
||||||
|
token,
|
||||||
|
password: password.value,
|
||||||
|
}, {
|
||||||
|
successFeedback: 'Password reset successfully',
|
||||||
|
appendErrorMessage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
success.value = true;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
errorMsg.value = error.message;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitted.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="recovery-container">
|
||||||
|
<h3 class="heading">
|
||||||
|
<template v-if="userId && token">Password reset</template>
|
||||||
|
<template v-else>Account recovery</template>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<template v-if="allowRecovery">
|
||||||
|
<strong
|
||||||
|
v-if="errorMsg"
|
||||||
|
class="error"
|
||||||
|
>{{ errorMsg }}</strong>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-else-if="userId && token"
|
||||||
|
class="login-panel"
|
||||||
|
@submit.prevent="reset"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="success"
|
||||||
|
class="success"
|
||||||
|
>
|
||||||
|
<strong class="success-heading">Password reset successfully</strong>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="link"
|
||||||
|
>Go to login</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<Password
|
||||||
|
:password="password"
|
||||||
|
@input="(newPassword) => password = newPassword"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Ellipsis v-if="submitted" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="button button-submit"
|
||||||
|
>Reset password</button>
|
||||||
|
</template>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-else-if="allowRecovery"
|
||||||
|
class="login-panel"
|
||||||
|
@submit.prevent="request"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="success"
|
||||||
|
class="success"
|
||||||
|
>
|
||||||
|
<strong class="success-heading">Password reset requested</strong>
|
||||||
|
If the account exists, instructions have been sent to the associated e-mail address.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
v-model="credential"
|
||||||
|
placeholder="Username or e-mail"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
|
||||||
|
<Ellipsis v-if="submitted" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="button button-submit"
|
||||||
|
>Request password reset</button>
|
||||||
|
</template>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="error"
|
||||||
|
>Password recovery is currently unavailable</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
text-align: center;
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--background-base-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
width: 20rem;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0 1rem 1rem 1rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||||
|
background: var(--background-base);
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
.button {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
margin-top: .5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.link {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-heading {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-container {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem 1rem .5rem 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
pages/auth/recovery/+route.js
Normal file
1
pages/auth/recovery/+route.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default '/recovery';
|
||||||
1
pages/auth/recovery/+title.js
Normal file
1
pages/auth/recovery/+title.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default `Account recovery`;
|
||||||
@@ -2,32 +2,30 @@
|
|||||||
import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
|
import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||||
import { inject, onMounted, ref } from 'vue';
|
import { inject, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import Password from '#/components/form/password.vue';
|
||||||
|
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||||
|
|
||||||
import { post } from '#/src/api.js';
|
import { post } from '#/src/api.js';
|
||||||
import navigate from '#/src/navigate.js';
|
import navigate from '#/src/navigate.js';
|
||||||
|
|
||||||
const pageContext = inject('pageContext');
|
const pageContext = inject('pageContext');
|
||||||
const { user, env } = pageContext;
|
const { user, env } = pageContext;
|
||||||
|
const { token } = pageContext.urlParsed.search;
|
||||||
|
|
||||||
const username = ref('');
|
const username = ref('');
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
const passwordConfirm = ref('');
|
|
||||||
|
|
||||||
const errorMsg = ref(null);
|
const errorMsg = ref(null);
|
||||||
const submitted = ref(false);
|
const submitted = ref(false);
|
||||||
const userInput = ref(null);
|
const success = ref(false);
|
||||||
const showPassword = ref(false);
|
const mainInput = ref(null);
|
||||||
const captcha = ref(null);
|
const captcha = ref(null);
|
||||||
|
|
||||||
async function signup() {
|
async function signup() {
|
||||||
errorMsg.value = null;
|
errorMsg.value = null;
|
||||||
submitted.value = true;
|
submitted.value = true;
|
||||||
|
|
||||||
if (password.value !== passwordConfirm.value) {
|
|
||||||
errorMsg.value = 'Passwords do not match';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (env.captcha.enabled && !captcha.value) {
|
if (env.captcha.enabled && !captcha.value) {
|
||||||
errorMsg.value = 'Please complete the CAPTCHA';
|
errorMsg.value = 'Please complete the CAPTCHA';
|
||||||
return;
|
return;
|
||||||
@@ -35,11 +33,14 @@ async function signup() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const newUser = await post('/users', {
|
const newUser = await post('/users', {
|
||||||
|
token,
|
||||||
username: username.value,
|
username: username.value,
|
||||||
email: email.value,
|
|
||||||
password: password.value,
|
password: password.value,
|
||||||
redirect: pageContext.urlParsed.search.r,
|
redirect: pageContext.urlParsed.search.r,
|
||||||
captcha: captcha.value,
|
captcha: captcha.value,
|
||||||
|
}, {
|
||||||
|
successFeedback: 'Sign-up successful!',
|
||||||
|
appendErrorMessage: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
navigate(`/user/${newUser.username}`, null, { redirect: true });
|
navigate(`/user/${newUser.username}`, null, { redirect: true });
|
||||||
@@ -52,8 +53,30 @@ async function signup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function request() {
|
||||||
|
errorMsg.value = null;
|
||||||
|
submitted.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await post('/signups', {
|
||||||
|
email: email.value,
|
||||||
|
}, {
|
||||||
|
successFeedback: 'Sign-up requested, please check your inbox',
|
||||||
|
appendErrorMessage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
success.value = true;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
errorMsg.value = error.message;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitted.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
userInput.value.focus();
|
mainInput.value.focus();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -87,7 +110,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
v-else
|
v-else-if="token"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
class="login-panel"
|
class="login-panel"
|
||||||
@submit.prevent="signup"
|
@submit.prevent="signup"
|
||||||
@@ -98,79 +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
|
<Password
|
||||||
v-model="email"
|
:password="password"
|
||||||
type="email"
|
|
||||||
placeholder="E-mail"
|
|
||||||
class="input"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
|
|
||||||
<div class="password-container">
|
|
||||||
<input
|
|
||||||
v-model="password"
|
|
||||||
:type="showPassword ? 'input' : 'password'"
|
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
minlength="3"
|
@input="(newPassword) => password = newPassword"
|
||||||
placeholder="Password"
|
|
||||||
class="password input"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="password-show"
|
|
||||||
@click="showPassword = !showPassword"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
v-show="!showPassword"
|
|
||||||
icon="eye"
|
|
||||||
class="password-show"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Icon
|
|
||||||
v-show="showPassword"
|
|
||||||
icon="eye-blocked"
|
|
||||||
class="password-show"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="password-container">
|
|
||||||
<input
|
|
||||||
v-model="passwordConfirm"
|
|
||||||
:type="showPassword ? 'input' : 'password'"
|
|
||||||
autocomplete="new-password"
|
|
||||||
minlength="3"
|
|
||||||
placeholder="Confirm password"
|
|
||||||
class="password input"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="password-show"
|
|
||||||
@click="showPassword = !showPassword"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
v-show="!showPassword"
|
|
||||||
icon="eye"
|
|
||||||
class="password-show"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Icon
|
|
||||||
v-show="showPassword"
|
|
||||||
icon="eye-blocked"
|
|
||||||
class="password-show"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<VueHCaptcha
|
<VueHCaptcha
|
||||||
v-if="env.captcha.enabled"
|
v-if="env.captcha.enabled"
|
||||||
:sitekey="env.captcha.siteKey"
|
:sitekey="env.captcha.siteKey"
|
||||||
@@ -179,20 +142,62 @@ onMounted(() => {
|
|||||||
@expired="captcha = null"
|
@expired="captcha = null"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Ellipsis v-if="submitted" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
v-else
|
||||||
class="button button-submit"
|
class="button button-submit"
|
||||||
:disabled="submitted"
|
:disabled="submitted"
|
||||||
>Sign up</button>
|
>Sign up</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-else
|
||||||
|
autocomplete="off"
|
||||||
|
class="login-panel"
|
||||||
|
@submit.prevent="request"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="success"
|
||||||
|
class="success"
|
||||||
|
>
|
||||||
|
<strong class="success-heading">Thank you for signing up!</strong>
|
||||||
|
Please check your e-mail inbox to continue.
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
ref="mainInput"
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="E-mail"
|
||||||
|
class="input"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<Ellipsis v-if="submitted" />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
class="button button-submit"
|
||||||
|
:disabled="submitted"
|
||||||
|
>Send signup e-mail</button>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/login"
|
href="/login"
|
||||||
class="link"
|
class="link login"
|
||||||
>I already have an account</a>
|
>I already have an account</a>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.login-container {
|
.login-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
@@ -219,7 +224,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
margin-top: .5rem;
|
margin-top: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,6 +266,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.button-submit {
|
.button-submit {
|
||||||
|
padding: .75rem 1rem;
|
||||||
margin-top: .5rem;
|
margin-top: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,4 +280,18 @@ onMounted(() => {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-heading {
|
||||||
|
display: block;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-container {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
1
pages/auth/signup/+title.js
Normal file
1
pages/auth/signup/+title.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default 'Sign up';
|
||||||
@@ -4,7 +4,7 @@ export async function onBeforeRender(pageContext) {
|
|||||||
const networks = await fetchEntities(pageContext.urlParsed.search.q
|
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),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function getTitle(movie) {
|
|||||||
|
|
||||||
export async function onBeforeRender(pageContext) {
|
export async function onBeforeRender(pageContext) {
|
||||||
const [[movie], movieScenes] = await Promise.all([
|
const [[movie], movieScenes] = await Promise.all([
|
||||||
fetchMoviesById([Number(pageContext.routeParams.movieId)], pageContext.user, { restriction: pageContext.restriction }),
|
fetchMoviesById([Number(pageContext.routeParams.movieId)], pageContext.user, { restriction: pageContext.geo.restriction }),
|
||||||
fetchScenes(await curateScenesQuery({
|
fetchScenes(await curateScenesQuery({
|
||||||
...pageContext.urlQuery,
|
...pageContext.urlQuery,
|
||||||
scope: 'oldest',
|
scope: 'oldest',
|
||||||
@@ -28,7 +28,7 @@ export async function onBeforeRender(pageContext) {
|
|||||||
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
limit: Number(pageContext.urlParsed.search.limit) || 30,
|
||||||
aggregate: true,
|
aggregate: true,
|
||||||
}, pageContext.user, {
|
}, pageContext.user, {
|
||||||
restriction: pageContext.restriction,
|
restriction: pageContext.geo.restriction,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
1
pages/recovery/+title.js
Normal file
1
pages/recovery/+title.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default 'Account recovery';
|
||||||
@@ -18,7 +18,7 @@ export async function onBeforeRender(pageContext) {
|
|||||||
aggregate: true,
|
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 },
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Summaries from '#/components/scenes/summaries.vue';
|
|||||||
// filters are implemented as generic settings panel in case we want to extend it in the future
|
// filters are implemented as generic settings panel in case we want to extend it in the future
|
||||||
import Settings from '#/components/settings/settings.vue';
|
import Settings from '#/components/settings/settings.vue';
|
||||||
import Stashes from '#/components/stashes/stashes.vue';
|
import Stashes from '#/components/stashes/stashes.vue';
|
||||||
|
import Account from '#/components/user/account.vue';
|
||||||
import ApiKeys from '#/components/user/api-keys.vue';
|
import ApiKeys from '#/components/user/api-keys.vue';
|
||||||
import Preferences from '#/components/user/preferences.vue';
|
import Preferences from '#/components/user/preferences.vue';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ const domain = pageContext.routeParams.domain;
|
|||||||
|
|
||||||
const user = pageContext.user;
|
const user = pageContext.user;
|
||||||
const profile = ref(pageContext.pageProps.profile);
|
const profile = ref(pageContext.pageProps.profile);
|
||||||
|
const primaryStash = pageContext.assets?.primaryStash;
|
||||||
|
|
||||||
const showFilters = ref(false);
|
const showFilters = ref(false);
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ function scrollHorizontal(event) {
|
|||||||
<div class="profile-header">
|
<div class="profile-header">
|
||||||
<a
|
<a
|
||||||
:href="`/user/${profile.username}/`"
|
:href="`/user/${profile.username}/`"
|
||||||
|
:title="`#${profile.id}`"
|
||||||
class="user nolink"
|
class="user nolink"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
@@ -69,6 +72,12 @@ function scrollHorizontal(event) {
|
|||||||
class="domains nobar"
|
class="domains nobar"
|
||||||
@wheel.prevent="scrollHorizontal"
|
@wheel.prevent="scrollHorizontal"
|
||||||
>
|
>
|
||||||
|
<a
|
||||||
|
v-if="primaryStash"
|
||||||
|
:href="`/stash/${user.username}/${primaryStash.slug}`"
|
||||||
|
class="domain nolink"
|
||||||
|
><Icon :icon="primaryStash.slug === 'favorites' ? 'heart7' : 'folder-heart'" />{{ primaryStash.name }}</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
:href="`/user/${profile.username}/stashes`"
|
:href="`/user/${profile.username}/stashes`"
|
||||||
class="domain nolink"
|
class="domain nolink"
|
||||||
@@ -87,13 +96,6 @@ function scrollHorizontal(event) {
|
|||||||
:class="{ active: section === 'templates' }"
|
:class="{ active: section === 'templates' }"
|
||||||
><Icon icon="paste3" />Templates</a>
|
><Icon icon="paste3" />Templates</a>
|
||||||
|
|
||||||
<a
|
|
||||||
v-if="profile.isIdentityVerified"
|
|
||||||
:href="`/user/${profile.username}/api`"
|
|
||||||
class="domain nolink"
|
|
||||||
:class="{ active: section === 'api' }"
|
|
||||||
><Icon icon="key" />API Keys</a>
|
|
||||||
|
|
||||||
<a
|
<a
|
||||||
:href="`/user/${profile.username}/revisions/scenes`"
|
:href="`/user/${profile.username}/revisions/scenes`"
|
||||||
class="domain nolink"
|
class="domain nolink"
|
||||||
@@ -113,11 +115,24 @@ function scrollHorizontal(event) {
|
|||||||
@click="showFilters = true"
|
@click="showFilters = true"
|
||||||
><Icon icon="filter" />Filters</span>
|
><Icon icon="filter" />Filters</span>
|
||||||
|
|
||||||
|
<a
|
||||||
|
v-if="profile.isIdentityVerified"
|
||||||
|
:href="`/user/${profile.username}/api`"
|
||||||
|
class="domain nolink"
|
||||||
|
:class="{ active: section === 'api' }"
|
||||||
|
><Icon icon="key" />API Keys</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
:href="`/user/${profile.username}/preferences`"
|
:href="`/user/${profile.username}/preferences`"
|
||||||
class="domain nolink"
|
class="domain nolink"
|
||||||
:class="{ active: section === 'preferences' }"
|
:class="{ active: section === 'preferences' }"
|
||||||
><Icon icon="equalizer" />Preferences</a>
|
><Icon icon="equalizer" />Preferences</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
:href="`/user/${profile.username}/account`"
|
||||||
|
class="domain nolink"
|
||||||
|
:class="{ active: section === 'account' }"
|
||||||
|
><Icon icon="user-tie" />Account</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<Stashes v-if="section === 'stashes'" />
|
<Stashes v-if="section === 'stashes'" />
|
||||||
@@ -128,13 +143,9 @@ function scrollHorizontal(event) {
|
|||||||
:release="mockupRelease"
|
:release="mockupRelease"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ApiKeys
|
<ApiKeys v-if="section === 'api'" />
|
||||||
v-if="section === 'api'"
|
<Preferences v-if="section === 'preferences'" />
|
||||||
/>
|
<Account v-if="section === 'account'" />
|
||||||
|
|
||||||
<Preferences
|
|
||||||
v-if="section === 'preferences'"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -259,7 +270,7 @@ function scrollHorizontal(event) {
|
|||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: .5rem 0;
|
padding: .5rem 0;
|
||||||
margin-top: .5rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.domain {
|
.domain {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ async function fetchRevisions(pageContext) {
|
|||||||
|
|
||||||
export async function onBeforeRender(pageContext) {
|
export async function onBeforeRender(pageContext) {
|
||||||
const [profile, alerts, userRevisions, keys] = await Promise.all([
|
const [profile, alerts, userRevisions, keys] = await Promise.all([
|
||||||
fetchUser(pageContext.routeParams.username, {}, pageContext.user),
|
fetchUser(pageContext.routeParams.username, { throwError: false }, pageContext.user),
|
||||||
pageContext.routeParams.section === 'alerts' && pageContext.routeParams.username === pageContext.user?.username
|
pageContext.routeParams.section === 'alerts' && pageContext.routeParams.username === pageContext.user?.username
|
||||||
? fetchAlerts(pageContext.user)
|
? fetchAlerts(pageContext.user)
|
||||||
: [],
|
: [],
|
||||||
|
|||||||
85
pages/verify/email/+Page.vue
Normal file
85
pages/verify/email/+Page.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup>
|
||||||
|
import { inject, ref } from 'vue';
|
||||||
|
|
||||||
|
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||||
|
|
||||||
|
import { post } from '#/src/api.js';
|
||||||
|
|
||||||
|
const { urlParsed } = inject('pageContext');
|
||||||
|
const { userId, token } = urlParsed.search;
|
||||||
|
|
||||||
|
const errorMsg = ref(null);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const success = ref(false);
|
||||||
|
|
||||||
|
async function verify() {
|
||||||
|
errorMsg.value = null;
|
||||||
|
submitting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await post('/emails/verify', {
|
||||||
|
userId,
|
||||||
|
token,
|
||||||
|
}, {
|
||||||
|
appendErrorMessage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
success.value = true;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
errorMsg.value = error.message;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="content">
|
||||||
|
<Ellipsis v-if="submitting" />
|
||||||
|
|
||||||
|
<strong
|
||||||
|
v-else-if="errorMsg"
|
||||||
|
class="feedback error"
|
||||||
|
>{{ errorMsg }}</strong>
|
||||||
|
|
||||||
|
<strong
|
||||||
|
v-else-if="success"
|
||||||
|
class="feedback success"
|
||||||
|
>Verification successful</strong>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="button button-primary"
|
||||||
|
@click="verify"
|
||||||
|
>Click here to verify your e-mail address</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,6 +9,6 @@ export default {
|
|||||||
'assets',
|
'assets',
|
||||||
'campaigns',
|
'campaigns',
|
||||||
'meta',
|
'meta',
|
||||||
'restriction',
|
'geo',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -304,18 +304,18 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
|
|||||||
.orderBy(knex.raw('max(actors_avatars.created_at)'), 'desc'),
|
.orderBy(knex.raw('max(actors_avatars.created_at)'), 'desc'),
|
||||||
knex('actors_socials')
|
knex('actors_socials')
|
||||||
.whereIn('actor_id', actorIds),
|
.whereIn('actor_id', actorIds),
|
||||||
reqUser
|
reqUser && !options.shallow
|
||||||
? knex('stashes_actors')
|
? knex('stashes_actors')
|
||||||
.leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id')
|
.leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id')
|
||||||
.where('stashes.user_id', reqUser.id)
|
.where('stashes.user_id', reqUser.id)
|
||||||
.whereIn('stashes_actors.actor_id', actorIds)
|
.whereIn('stashes_actors.actor_id', actorIds)
|
||||||
: [],
|
: [],
|
||||||
reqUser
|
reqUser && !options.shallow
|
||||||
? knex('alerts_users_actors')
|
? knex('alerts_users_actors')
|
||||||
.where('user_id', reqUser.id)
|
.where('user_id', reqUser.id)
|
||||||
.whereIn('actor_id', actorIds)
|
.whereIn('actor_id', actorIds)
|
||||||
: [],
|
: [],
|
||||||
].slice(0, options.shallow ? 1 : -1));
|
].slice(0, options.shallow ? 1 : undefined));
|
||||||
|
|
||||||
if (options.order) {
|
if (options.order) {
|
||||||
return actors.map((actorEntry) => curateActor(actorEntry, {
|
return actors.map((actorEntry) => curateActor(actorEntry, {
|
||||||
@@ -413,7 +413,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!filters.includeAliased) {
|
if (!filters.includeAliased && !filters.stashId) {
|
||||||
builder.where('alias_for', 0);
|
builder.where('alias_for', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,7 +867,6 @@ export async function mergeActors(targetActorId, sourceActorIds, comment, reqUse
|
|||||||
.update('alias_for', targetActorId)
|
.update('alias_for', targetActorId)
|
||||||
.whereIn('id', sourceActorIds)
|
.whereIn('id', sourceActorIds)
|
||||||
.returning(['id', 'alias_for']),
|
.returning(['id', 'alias_for']),
|
||||||
// some avatars are not matched to a profile, need to investigate why this happens and the avatar table needs a dedicated actor field
|
|
||||||
trx('actors_avatars')
|
trx('actors_avatars')
|
||||||
.update('actor_id', targetActorId)
|
.update('actor_id', targetActorId)
|
||||||
.whereIn('actor_id', sourceActorIds),
|
.whereIn('actor_id', sourceActorIds),
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import { CronJob } from 'cron';
|
|||||||
import { add } from 'date-fns';
|
import { add } from 'date-fns';
|
||||||
import escapeRegexp from 'escape-string-regexp';
|
import escapeRegexp from 'escape-string-regexp';
|
||||||
import markdownIt from 'markdown-it';
|
import markdownIt from 'markdown-it';
|
||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
import promiseProps from '../utils/promise-props.js';
|
import promiseProps from '../utils/promise-props.js';
|
||||||
import { getIdsBySlug } from './cache.js';
|
import { getIdsBySlug } from './cache.js';
|
||||||
import { emailTemplates } from './email-templates.js';
|
import { sendEmails } from './email.js';
|
||||||
import { HttpError } from './errors.js';
|
import { HttpError } from './errors.js';
|
||||||
import { knexOwner as knex } from './knex.js';
|
import { knexOwner as knex } from './knex.js';
|
||||||
import initLogger from './logger.js';
|
import initLogger from './logger.js';
|
||||||
@@ -15,18 +14,6 @@ import { indexApi } from './manticore.js';
|
|||||||
import { fetchScenesById } from './scenes.js';
|
import { fetchScenesById } from './scenes.js';
|
||||||
|
|
||||||
const logger = initLogger();
|
const logger = initLogger();
|
||||||
const resend = (() => {
|
|
||||||
if (config.email.enabled) {
|
|
||||||
try {
|
|
||||||
return new Resend(config.email.apiKey);
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
logger.error(`Failed to initialize Resend: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
function getImagePath(media) {
|
function getImagePath(media) {
|
||||||
if (!media) {
|
if (!media) {
|
||||||
@@ -262,7 +249,7 @@ export async function createAlert(alert, reqUser) {
|
|||||||
entityIds?.length > 0 && knex('alerts_entities').insert(entityIds.map((entityId) => ({
|
entityIds?.length > 0 && knex('alerts_entities').insert(entityIds.map((entityId) => ({
|
||||||
alert_id: alertId,
|
alert_id: alertId,
|
||||||
entity_id: entityId,
|
entity_id: entityId,
|
||||||
})).slice(0, alert.allEntities ? 1 : Infinity)), // one scene can never match multiple entities in AND mode
|
})).slice(0, alert.allEntities ? 1 : undefined)), // one scene can never match multiple entities in AND mode
|
||||||
sceneIds?.length > 0 && knex('alerts_scenes').insert(sceneIds.map((sceneId) => ({
|
sceneIds?.length > 0 && knex('alerts_scenes').insert(sceneIds.map((sceneId) => ({
|
||||||
alert_id: alertId,
|
alert_id: alertId,
|
||||||
scene_id: sceneId,
|
scene_id: sceneId,
|
||||||
@@ -452,11 +439,6 @@ async function sendEmailAlerts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resend) {
|
|
||||||
logger.warn('E-mails are enabled, but Resend is not initialized');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const qualifyingUsers = await knex('notifications')
|
const qualifyingUsers = await knex('notifications')
|
||||||
.select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email'))
|
.select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email'))
|
||||||
.where('notifications.email', true)
|
.where('notifications.email', true)
|
||||||
@@ -546,7 +528,7 @@ async function sendEmailAlerts() {
|
|||||||
&& !!notification.scene
|
&& !!notification.scene
|
||||||
&& notification.user.settings?.alertEmailsEnabled !== false); // keep the e-mails marked as 'sent', if emails get re-enabled there shouldn't be a barrage of old notifications
|
&& notification.user.settings?.alertEmailsEnabled !== false); // keep the e-mails marked as 'sent', if emails get re-enabled there shouldn't be a barrage of old notifications
|
||||||
|
|
||||||
const emails = await Promise.all(Object.values(usersByUserId).map(async (user) => {
|
const emailItems = Object.values(usersByUserId).map((user) => {
|
||||||
const userTriggers = Object.values(Object.fromEntries(triggers
|
const userTriggers = Object.values(Object.fromEntries(triggers
|
||||||
.filter((trigger) => trigger.user.id === user.id)
|
.filter((trigger) => trigger.user.id === user.id)
|
||||||
.map((trigger) => [trigger.scene.id, trigger])))
|
.map((trigger) => [trigger.scene.id, trigger])))
|
||||||
@@ -556,41 +538,23 @@ async function sendEmailAlerts() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return {
|
||||||
const { html, text } = await emailTemplates.render('alert.vue', {
|
template: 'alert',
|
||||||
|
to: user.email,
|
||||||
|
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
|
||||||
props: {
|
props: {
|
||||||
user,
|
user,
|
||||||
triggers: userTriggers,
|
triggers: userTriggers,
|
||||||
},
|
},
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
from: config.email.from,
|
|
||||||
to: user.email,
|
|
||||||
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
|
|
||||||
html,
|
|
||||||
text,
|
|
||||||
};
|
};
|
||||||
}
|
}).filter(Boolean);
|
||||||
catch (error) {
|
|
||||||
logger.warn(`Failed to render e-mail for '${user.username}' with scenes ${userTriggers.map((trigger) => `${trigger.notificationId}: ${trigger.scene.id}`)}: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})).then((allEmails) => allEmails.filter(Boolean));
|
|
||||||
|
|
||||||
if (emails.length === 0) {
|
if (emailItems.length === 0) {
|
||||||
logger.info(`No alert e-mails sent`);
|
logger.info(`No alert e-mails sent`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await resend.batch.send(emails);
|
await sendEmails(emailItems);
|
||||||
|
|
||||||
if (error) {
|
|
||||||
logger.error(`Failed to send ${emails.length} alert e-mails (${error.statusCode} ${error.name}): ${error.message}`);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
logger.info(`Sent ${data.data.length}/${emails.length} alert e-mails`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function notify(sceneIds, options = {}) {
|
export async function notify(sceneIds, options = {}) {
|
||||||
@@ -790,7 +754,7 @@ export async function notify(sceneIds, options = {}) {
|
|||||||
|
|
||||||
const docs = stashedEntries.map((stash) => ({
|
const docs = stashedEntries.map((stash) => ({
|
||||||
replace: {
|
replace: {
|
||||||
index: 'scenes_stashed',
|
table: 'scenes_stashed',
|
||||||
id: stash.id,
|
id: stash.id,
|
||||||
doc: {
|
doc: {
|
||||||
scene_id: stash.scene_id,
|
scene_id: stash.scene_id,
|
||||||
@@ -846,7 +810,13 @@ async function alertSceneReleases() {
|
|||||||
.update('is_expired', true);
|
.update('is_expired', true);
|
||||||
|
|
||||||
await trx.commit();
|
await trx.commit();
|
||||||
await knex.schema.refreshMaterializedView('alerts_users_scenes');
|
|
||||||
|
await Promise.all([
|
||||||
|
knex.schema.refreshMaterializedView('alerts_users_actors'),
|
||||||
|
knex.schema.refreshMaterializedView('alerts_users_tags'),
|
||||||
|
knex.schema.refreshMaterializedView('alerts_users_entities'),
|
||||||
|
knex.schema.refreshMaterializedView('alerts_users_scenes'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
await trx.rollback();
|
await trx.rollback();
|
||||||
@@ -876,7 +846,12 @@ async function purgeExpiredAlerts() {
|
|||||||
)
|
)
|
||||||
.delete();
|
.delete();
|
||||||
|
|
||||||
await knex.schema.refreshMaterializedView('alerts_users_scenes');
|
await Promise.all([
|
||||||
|
knex.schema.refreshMaterializedView('alerts_users_actors'),
|
||||||
|
knex.schema.refreshMaterializedView('alerts_users_tags'),
|
||||||
|
knex.schema.refreshMaterializedView('alerts_users_entities'),
|
||||||
|
knex.schema.refreshMaterializedView('alerts_users_scenes'),
|
||||||
|
]);
|
||||||
|
|
||||||
logger.info(`Purged ${purgedNotifications} notifications and ${purgedAlerts} alerts`);
|
logger.info(`Purged ${purgedNotifications} notifications and ${purgedAlerts} alerts`);
|
||||||
}
|
}
|
||||||
|
|||||||
496
src/auth.js
496
src/auth.js
@@ -1,3 +1,4 @@
|
|||||||
|
import { Buffer } from 'node:buffer';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import util from 'node:util';
|
import util from 'node:util';
|
||||||
@@ -5,9 +6,10 @@ import { shapes } from '@dicebear/collection';
|
|||||||
import { createAvatar } from '@dicebear/core';
|
import { createAvatar } from '@dicebear/core';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import config from 'config';
|
import config from 'config';
|
||||||
import { verify } from 'hcaptcha';
|
import { verify as verifyCaptcha } from 'hcaptcha';
|
||||||
|
|
||||||
import slugify from '../utils/slugify.js';
|
import slugify from '../utils/slugify.js';
|
||||||
|
import { sendEmails } from './email.js';
|
||||||
import { HttpError } from './errors.js';
|
import { HttpError } from './errors.js';
|
||||||
import { knexOwner as knex } from './knex.js';
|
import { knexOwner as knex } from './knex.js';
|
||||||
import initLogger, { initAccessLogger } from './logger.js';
|
import initLogger, { initAccessLogger } from './logger.js';
|
||||||
@@ -44,19 +46,71 @@ async function generateAvatar(user) {
|
|||||||
logger.verbose(`Generated avatar for '${user.username}' (${user.id})`);
|
logger.verbose(`Generated avatar for '${user.username}' (${user.id})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkLoginCooldown(identifier, attemptThreshold) {
|
||||||
|
if (!identifier) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
|
||||||
|
const attempts = JSON.parse(await redis.get(cooldownKey) || '0');
|
||||||
|
|
||||||
|
if (attempts >= attemptThreshold) {
|
||||||
|
throw new HttpError(`Please wait a few minutes before attempting another login`, 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setLoginCooldown(identifier, attemptCooldown) {
|
||||||
|
if (!identifier) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
|
||||||
|
|
||||||
|
await redis.incr(cooldownKey);
|
||||||
|
await redis.expire(cooldownKey, attemptCooldown * 60);
|
||||||
|
}
|
||||||
|
|
||||||
export async function login(credentials, userIp) {
|
export async function login(credentials, userIp) {
|
||||||
if (!config.auth.login) {
|
if (!config.auth.login) {
|
||||||
throw new HttpError('Logins are currently disabled', 405);
|
throw new HttpError('Logins are currently disabled', 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!credentials.username) {
|
||||||
|
throw new HttpError('Please provide a username or e-mail', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!credentials.password) {
|
||||||
|
throw new HttpError('Please provide a password', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
checkLoginCooldown(credentials.username, config.auth.loginCredentialAttempts),
|
||||||
|
checkLoginCooldown(userIp, config.auth.loginIpAttempts),
|
||||||
|
]);
|
||||||
|
|
||||||
const { user } = await fetchUser(credentials.username.trim(), {
|
const { user } = await fetchUser(credentials.username.trim(), {
|
||||||
email: true,
|
email: true,
|
||||||
raw: true,
|
raw: true,
|
||||||
}).catch(() => {
|
}).catch(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
|
||||||
|
setLoginCooldown(userIp, config.auth.loginIpCooldown),
|
||||||
|
]);
|
||||||
|
|
||||||
throw new HttpError('Username or password incorrect', 401);
|
throw new HttpError('Username or password incorrect', 401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
await verifyPassword(credentials.password, user.password);
|
await verifyPassword(credentials.password, user.password);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
await Promise.all([
|
||||||
|
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
|
||||||
|
setLoginCooldown(userIp, config.auth.loginIpCooldown),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
await knex('users')
|
await knex('users')
|
||||||
.update('last_login', 'NOW()')
|
.update('last_login', 'NOW()')
|
||||||
@@ -76,9 +130,278 @@ export async function login(credentials, userIp) {
|
|||||||
return curateUser(user, user);
|
return curateUser(user, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hashPassword(password) {
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
const hashedPassword = (await scrypt(password, salt, 64)).toString('hex');
|
||||||
|
const storedPassword = `${salt}/${hashedPassword}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
storedPassword,
|
||||||
|
hashedPassword,
|
||||||
|
salt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tokenCooldown(identifier) {
|
||||||
|
const cooldownKey = `traxxx:token_cooldown:${slugify(identifier, '_')}`;
|
||||||
|
|
||||||
|
if (await redis.exists(cooldownKey)) {
|
||||||
|
throw new HttpError(`Please wait a few minutes before requesting another token`, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.set(cooldownKey, JSON.stringify(true));
|
||||||
|
await redis.expire(cooldownKey, config.auth.tokenCooldown * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmailVerification(user, customEmail, trigger) {
|
||||||
|
if (!config.email.enabled) {
|
||||||
|
throw new HttpError('E-mail verification is disabled', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new HttpError('No user provided', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = customEmail || user.email;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new HttpError('No e-mail address provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tokenCooldown(email);
|
||||||
|
|
||||||
|
const tokenKey = `traxxx:token_email:${user.id}`;
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
const tokenData = {
|
||||||
|
email,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
|
||||||
|
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||||
|
await redis.expire(tokenKey, config.auth.emailTokenExpiry * 60);
|
||||||
|
|
||||||
|
const verifyUrl = `${config.web.address}/verify/email?${new URLSearchParams({
|
||||||
|
userId: user.id,
|
||||||
|
token,
|
||||||
|
}).toString()}`;
|
||||||
|
|
||||||
|
logger.info(`E-mail verification sent to ${user.username} (${user.id}): ${verifyUrl}`);
|
||||||
|
|
||||||
|
await sendEmails([{
|
||||||
|
template: 'verify',
|
||||||
|
to: email,
|
||||||
|
subject: 'Please verify your traxxx e-mail address',
|
||||||
|
props: {
|
||||||
|
user,
|
||||||
|
verifyUrl,
|
||||||
|
trigger,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyEmail(userId, emailToken) {
|
||||||
|
if (!userId) {
|
||||||
|
throw new HttpError('User ID not provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emailToken) {
|
||||||
|
throw new HttpError('Token not provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenKey = `traxxx:token_email:${userId}`;
|
||||||
|
const tokenEntry = await redis.getDel(tokenKey);
|
||||||
|
|
||||||
|
if (!tokenEntry) {
|
||||||
|
throw new HttpError('Invalid verification link', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, email } = JSON.parse(tokenEntry);
|
||||||
|
|
||||||
|
if (!token
|
||||||
|
|| !email
|
||||||
|
|| emailToken.length !== token.length
|
||||||
|
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
|
||||||
|
throw new HttpError('Invalid verification link', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await knex('users')
|
||||||
|
.where('id', userId)
|
||||||
|
.update({
|
||||||
|
email,
|
||||||
|
email_verified: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updated === 0) {
|
||||||
|
throw new HttpError('E-mail verification failed because the user could not be found', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPasswordReset(credential) {
|
||||||
|
if (!config.email.enabled) {
|
||||||
|
throw new HttpError('Password reset is disabled', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
throw new HttpError('No credential provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tokenCooldown(credential);
|
||||||
|
|
||||||
|
const user = await fetchUser(credential, {
|
||||||
|
email: true,
|
||||||
|
includePrivate: true,
|
||||||
|
throwError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// we don't throw errors because the requesting user shouldn't know whether the account exists or not
|
||||||
|
if (!user) {
|
||||||
|
logger.info(`Password reset requested for unknown user '${credential}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.email) {
|
||||||
|
logger.info(`Password reset requested for '${credential}', but user '${user.username}' (${user.id}) has no e-mail address known`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenKey = `traxxx:token_password:${user.id}`;
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
const tokenData = {
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
|
||||||
|
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||||
|
await redis.expire(tokenKey, config.auth.passwordTokenExpiry * 60);
|
||||||
|
|
||||||
|
const verifyUrl = `${config.web.address}/recovery?${new URLSearchParams({
|
||||||
|
userId: user.id,
|
||||||
|
token,
|
||||||
|
}).toString()}`;
|
||||||
|
|
||||||
|
logger.info(`Password reset sent to ${user.username} (${user.id}): ${verifyUrl}`);
|
||||||
|
|
||||||
|
await sendEmails([{
|
||||||
|
template: 'recovery',
|
||||||
|
to: user.email,
|
||||||
|
subject: 'Reset your traxxx password',
|
||||||
|
props: {
|
||||||
|
user,
|
||||||
|
verifyUrl,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPassword(userId, emailToken, newPassword) {
|
||||||
|
if (!userId) {
|
||||||
|
throw new HttpError('User ID not provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emailToken) {
|
||||||
|
throw new HttpError('Token not provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newPassword) {
|
||||||
|
throw new HttpError('New password not provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenKey = `traxxx:token_password:${userId}`;
|
||||||
|
const tokenEntry = await redis.getDel(tokenKey);
|
||||||
|
|
||||||
|
if (!tokenEntry) {
|
||||||
|
throw new HttpError('Invalid recovery link', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = JSON.parse(tokenEntry);
|
||||||
|
|
||||||
|
if (!token
|
||||||
|
|| emailToken.length !== token.length
|
||||||
|
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
|
||||||
|
throw new HttpError('Invalid recovery link', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storedPassword } = await hashPassword(newPassword);
|
||||||
|
|
||||||
|
const updated = await knex('users')
|
||||||
|
.where('id', userId)
|
||||||
|
.update('password', storedPassword);
|
||||||
|
|
||||||
|
if (updated === 0) {
|
||||||
|
throw new HttpError('Password reset failed because the user could not be found', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendSignup(email) {
|
||||||
|
if (!config.auth.signup || !config.email.enabled) {
|
||||||
|
throw new HttpError('Sign-ups are disabled', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new HttpError('No e-mail address provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tokenCooldown(email);
|
||||||
|
|
||||||
|
const user = await fetchUser(email, {
|
||||||
|
email: true,
|
||||||
|
includePrivate: true,
|
||||||
|
throwError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await sendEmails([{
|
||||||
|
template: 'unavailable',
|
||||||
|
to: email,
|
||||||
|
subject: 'Someone tried to use your e-mail address for traxxx',
|
||||||
|
props: {
|
||||||
|
user,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
logger.info(`Duplicate signup with '${email}' attempted, user e-mailed`);
|
||||||
|
|
||||||
|
// we don't throw an error because the requesting user shouldn't know whether the account exists or not
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const tokenKey = `traxxx:token_signup:${token}`;
|
||||||
|
|
||||||
|
const tokenData = {
|
||||||
|
email,
|
||||||
|
};
|
||||||
|
|
||||||
|
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||||
|
await redis.expire(tokenKey, config.auth.signupTokenExpiry * 60);
|
||||||
|
|
||||||
|
const verifyUrl = `${config.web.address}/signup?${new URLSearchParams({
|
||||||
|
token,
|
||||||
|
}).toString()}`;
|
||||||
|
|
||||||
|
logger.info(`Signup e-mail sent to ${email}: ${verifyUrl}`);
|
||||||
|
|
||||||
|
await sendEmails([{
|
||||||
|
template: 'signup',
|
||||||
|
to: email,
|
||||||
|
subject: 'Sign up for traxxx',
|
||||||
|
props: {
|
||||||
|
verifyUrl,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
export async function signup(credentials, userIp) {
|
export async function signup(credentials, userIp) {
|
||||||
if (!config.auth.signup) {
|
if (!config.auth.signup) {
|
||||||
throw new HttpError('Sign-ups are currently disabled', 405);
|
throw new HttpError('Sign-ups are currently disabled', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!credentials.token) {
|
||||||
|
throw new HttpError('Token required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.password?.length < 3) {
|
||||||
|
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const curatedUsername = credentials.username.trim();
|
const curatedUsername = credentials.username.trim();
|
||||||
@@ -99,41 +422,44 @@ export async function signup(credentials, userIp) {
|
|||||||
throw new HttpError('Username contains invalid characters', 400);
|
throw new HttpError('Username contains invalid characters', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!credentials.email) {
|
const existingUser = await knex('users')
|
||||||
throw new HttpError('E-mail required', 400);
|
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
|
||||||
}
|
.first();
|
||||||
|
|
||||||
if (credentials.password?.length < 3) {
|
if (existingUser) {
|
||||||
throw new HttpError('Password must be 3 characters or longer', 400);
|
throw new HttpError('Username already in use', 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.auth.captcha.enabled) {
|
if (config.auth.captcha.enabled) {
|
||||||
const captchaVerification = await verify(config.auth.captcha.secretKey, credentials.captcha);
|
const captchaVerification = await verifyCaptcha(config.auth.captcha.secretKey, credentials.captcha);
|
||||||
|
|
||||||
if (!captchaVerification.success) {
|
if (!captchaVerification.success) {
|
||||||
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${credentials.email}, ${userIp})`);
|
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${userIp})`);
|
||||||
throw new HttpError('Invalid CAPTCHA', 400);
|
throw new HttpError('Invalid CAPTCHA', 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await knex('users')
|
const tokenKey = `traxxx:token_signup:${credentials.token}`;
|
||||||
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
|
const tokenEntry = await redis.getDel(tokenKey);
|
||||||
.orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase())
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existingUser) {
|
if (!tokenEntry) {
|
||||||
throw new HttpError('Username or e-mail already in use', 409);
|
throw new HttpError('Invalid sign-up link', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const salt = crypto.randomBytes(16).toString('hex');
|
const { email } = JSON.parse(tokenEntry);
|
||||||
const hashedPassword = (await scrypt(credentials.password, salt, 64)).toString('hex');
|
|
||||||
const storedPassword = `${salt}/${hashedPassword}`;
|
if (!email) {
|
||||||
|
throw new HttpError('Invalid sign-up link', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storedPassword } = await hashPassword(credentials.password);
|
||||||
|
|
||||||
const [{ id: userId }] = await knex('users')
|
const [{ id: userId }] = await knex('users')
|
||||||
.insert({
|
.insert({
|
||||||
username: curatedUsername,
|
username: curatedUsername,
|
||||||
email: credentials.email,
|
email,
|
||||||
password: storedPassword,
|
password: storedPassword,
|
||||||
|
email_verified: true,
|
||||||
})
|
})
|
||||||
.returning('id');
|
.returning('id');
|
||||||
|
|
||||||
@@ -145,14 +471,138 @@ export async function signup(credentials, userIp) {
|
|||||||
primary: true,
|
primary: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`);
|
logger.info(`Signup from '${curatedUsername}' (${userId}, ${email}, ${userIp})`);
|
||||||
|
|
||||||
await generateAvatar({
|
await generateAvatar({
|
||||||
id: userId,
|
id: userId,
|
||||||
username: curatedUsername,
|
username: curatedUsername,
|
||||||
});
|
});
|
||||||
|
|
||||||
return fetchUser(userId);
|
const user = await fetchUser(userId, {
|
||||||
|
includePrivate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// no longer needed with token-first signup flow
|
||||||
|
// await sendEmailVerification(user, null, 'signup');
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePassword(password, reqUser, reqSession) {
|
||||||
|
const now = new Date();
|
||||||
|
const { storedPassword } = await hashPassword(password);
|
||||||
|
|
||||||
|
await knex('users')
|
||||||
|
.where('id', reqUser.id)
|
||||||
|
.update('password', storedPassword);
|
||||||
|
|
||||||
|
const country = reqSession.geo?.country && await knex('countries').where('alpha2', reqSession.geo.country).first();
|
||||||
|
|
||||||
|
await sendEmails([{
|
||||||
|
template: 'password',
|
||||||
|
to: reqUser.email,
|
||||||
|
subject: 'Your traxxx password has been changed',
|
||||||
|
props: {
|
||||||
|
user: reqUser,
|
||||||
|
ip: reqUser.ip,
|
||||||
|
country: country?.name,
|
||||||
|
timestamp: now,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
logger.info(`Password changed by ${reqUser.username} (${reqUser.id}, ${reqUser.ip})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateEmail(email, reqUser) {
|
||||||
|
const user = await fetchUser(email, {
|
||||||
|
email: true,
|
||||||
|
throwError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await sendEmails([{
|
||||||
|
template: 'unavailable',
|
||||||
|
to: email,
|
||||||
|
subject: 'Someone tried to use your e-mail address for traxxx',
|
||||||
|
props: {
|
||||||
|
user, // not reqUser, we don't want to leak details about the requester
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEmailVerification(reqUser, email, 'change');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser({ credentials, password }, reqUser, reqSession) {
|
||||||
|
if (!reqUser) {
|
||||||
|
throw new HttpError('You are not logged in', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!credentials?.email && !credentials.password) {
|
||||||
|
throw new HttpError('No changeable credentials provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.email && !config.email.enabled) {
|
||||||
|
throw new HttpError('E-mail is currently disabled', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.password && credentials.password?.length < 3) {
|
||||||
|
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
throw new HttpError('No current password provided', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await fetchUser(reqUser.id, {
|
||||||
|
email: true,
|
||||||
|
raw: true,
|
||||||
|
}).catch(() => {
|
||||||
|
throw new HttpError('Username or password incorrect', 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
await verifyPassword(password, user.password);
|
||||||
|
|
||||||
|
if (credentials.password) {
|
||||||
|
await updatePassword(credentials.password, reqUser, reqSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.email) {
|
||||||
|
await updateEmail(credentials.email, reqUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
return curateUser(user, reqUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validSettingKeys = [
|
||||||
|
'alertNotificationsEnabled',
|
||||||
|
'alertEmailsEnabled',
|
||||||
|
'alertEmailsFrequency',
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function updateSettings(settings, reqUser) {
|
||||||
|
if (!reqUser) {
|
||||||
|
throw new HttpError('You need to be authenticated to change your settings', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidKeys = Object.keys(settings).filter((key) => !validSettingKeys.includes(key));
|
||||||
|
|
||||||
|
if (invalidKeys.length > 0) {
|
||||||
|
throw new HttpError(`Invalid settings: ${invalidKeys.join()}`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.alertEmailsFrequency && !['instant', 'daily', 'weekly', 'monthly'].includes(settings.alertEmailsFrequency)) {
|
||||||
|
throw new HttpError('Invalid email frequency setting', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ settings: updatedSettings }] = await knex('users')
|
||||||
|
.where('id', reqUser.id)
|
||||||
|
.update('settings', knex.raw('coalesce(settings, \'{}\'::jsonb) || ?::jsonb', [JSON.stringify(settings)]))
|
||||||
|
.returning('settings');
|
||||||
|
|
||||||
|
return updatedSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
function curateKey(key) {
|
function curateKey(key) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { readdirSync } from 'node:fs';
|
import { readdirSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import process from 'node:process';
|
||||||
|
|
||||||
import { config as configureEmailTemplates } from '@vue-email/compiler';
|
import { config as configureEmailTemplates } from '@vue-email/compiler';
|
||||||
|
|
||||||
export const emailsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../components/emails');
|
export const emailsDir = path.join(process.cwd(), 'components/emails');
|
||||||
|
|
||||||
export const emailTemplates = configureEmailTemplates(emailsDir, { verbose: false });
|
export const emailTemplates = configureEmailTemplates(emailsDir, { verbose: false });
|
||||||
|
|
||||||
|
|||||||
84
src/email.js
Normal file
84
src/email.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import config from 'config';
|
||||||
|
import { Resend } from 'resend';
|
||||||
|
|
||||||
|
import { emailTemplates } from './email-templates.js';
|
||||||
|
import initLogger from './logger.js';
|
||||||
|
|
||||||
|
const logger = initLogger();
|
||||||
|
|
||||||
|
const resend = (() => {
|
||||||
|
if (config.email.enabled) {
|
||||||
|
try {
|
||||||
|
return new Resend(config.email.apiKey);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger.error(`Failed to initialize Resend: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
export async function renderEmail(template, { to, subject, props }) {
|
||||||
|
const { html, text } = await emailTemplates.render(`${template}.vue`, {
|
||||||
|
props: {
|
||||||
|
...props,
|
||||||
|
address: config.web.address,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: config.email.from,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// always batches, even for a single item: Resend's batch endpoint accepts a batch of one,
|
||||||
|
// and passing batchValidation: 'permissive' means one bad recipient among many doesn't
|
||||||
|
// sink the rest of the batch (the default 'strict' mode fails the whole call instead)
|
||||||
|
export async function sendEmails(items) {
|
||||||
|
if (!config.email.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resend) {
|
||||||
|
logger.warn('E-mails are enabled, but Resend is not initialized');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rendered = await Promise.all(items.map(async (item) => {
|
||||||
|
try {
|
||||||
|
return await renderEmail(item.template, item);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger.error(`Failed to render '${item.template}' e-mail for '${item.to}': ${error.message} — ${JSON.stringify(item.props)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sentItems = items.filter((item, index) => rendered[index]);
|
||||||
|
const emails = rendered.filter(Boolean);
|
||||||
|
|
||||||
|
if (emails.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await resend.batch.send(emails, { batchValidation: 'permissive' });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error(`Failed to send ${emails.length} e-mail(s) (${error.statusCode} ${error.name}): ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.errors?.forEach(({ index, message }) => {
|
||||||
|
const item = sentItems[index];
|
||||||
|
logger.error(`Failed to send '${item.template}' e-mail to '${item.to}': ${message} — ${JSON.stringify(item.props)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Sent ${emails.length - (data.errors?.length || 0)}/${items.length} e-mail(s)`);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { pageContext } from '../renderer/usePageContext.js';
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }));
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/users.js
47
src/users.js
@@ -14,7 +14,7 @@ function curateTemplate(template) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function curateUser(user, reqUser) {
|
export function curateUser(user, reqUser, options = {}) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ export function curateUser(user, reqUser) {
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
abilities: [...user.role_abilities || [], ...user.abilities || []],
|
abilities: [...user.role_abilities || [], ...user.abilities || []],
|
||||||
createdAt: user.created_at,
|
createdAt: user.created_at,
|
||||||
...(reqUser?.id === user.id ? privateUser : null),
|
...(reqUser?.id === user.id || options.includePrivate ? privateUser : null),
|
||||||
};
|
};
|
||||||
|
|
||||||
return curatedUser;
|
return curatedUser;
|
||||||
@@ -56,6 +56,14 @@ function whereUser(builder, userId, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUser(userId, options = {}, reqUser) {
|
export async function fetchUser(userId, options = {}, reqUser) {
|
||||||
|
if (!userId) {
|
||||||
|
if (options.raw) {
|
||||||
|
return { user: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await knex('users')
|
const user = await knex('users')
|
||||||
.select(knex.raw('users.*, users_roles.abilities as role_abilities'))
|
.select(knex.raw('users.*, users_roles.abilities as role_abilities'))
|
||||||
.modify((builder) => whereUser(builder, userId, options))
|
.modify((builder) => whereUser(builder, userId, options))
|
||||||
@@ -63,7 +71,7 @@ export async function fetchUser(userId, options = {}, reqUser) {
|
|||||||
.groupBy('users.id', 'users_roles.role')
|
.groupBy('users.id', 'users_roles.role')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!user) {
|
if (!user && options.throwError !== false) {
|
||||||
throw new HttpError(`User '${userId}' not found`, 404);
|
throw new HttpError(`User '${userId}' not found`, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +79,7 @@ export async function fetchUser(userId, options = {}, reqUser) {
|
|||||||
return { user };
|
return { user };
|
||||||
}
|
}
|
||||||
|
|
||||||
return curateUser(user, reqUser);
|
return curateUser(user, reqUser, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUserTemplates(reqUser) {
|
export async function fetchUserTemplates(reqUser) {
|
||||||
@@ -119,8 +127,6 @@ export async function removeTemplate(templateId, reqUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createBan(ban, reqUser) {
|
export async function createBan(ban, reqUser) {
|
||||||
console.log(ban);
|
|
||||||
|
|
||||||
if (reqUser.role !== 'admin') {
|
if (reqUser.role !== 'admin') {
|
||||||
throw new HttpError('You do not have sufficient privileges to set a ban', 403);
|
throw new HttpError('You do not have sufficient privileges to set a ban', 403);
|
||||||
}
|
}
|
||||||
@@ -136,32 +142,3 @@ export async function createBan(ban, reqUser) {
|
|||||||
|
|
||||||
await knex('bans').insert(curatedBan);
|
await knex('bans').insert(curatedBan);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validSettingKeys = [
|
|
||||||
'alertNotificationsEnabled',
|
|
||||||
'alertEmailsEnabled',
|
|
||||||
'alertEmailsFrequency',
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function updateSettings(settings, reqUser) {
|
|
||||||
if (!reqUser) {
|
|
||||||
throw new HttpError('You need to be authenticated to change your settings', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const invalidKeys = Object.keys(settings).filter((key) => !validSettingKeys.includes(key));
|
|
||||||
|
|
||||||
if (invalidKeys.length > 0) {
|
|
||||||
throw new HttpError(`Invalid settings: ${invalidKeys.join()}`, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.alertEmailsFrequency && !['instant', 'daily', 'weekly', 'monthly'].includes(settings.alertEmailsFrequency)) {
|
|
||||||
throw new HttpError('Invalid email frequency setting', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [{ settings: updatedSettings }] = await knex('users')
|
|
||||||
.where('id', reqUser.id)
|
|
||||||
.update('settings', knex.raw('coalesce(settings, \'{}\'::jsonb) || ?::jsonb', [JSON.stringify(settings)]))
|
|
||||||
.returning('settings');
|
|
||||||
|
|
||||||
return updatedSettings;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -219,12 +219,12 @@ export async function updateNotificationGraphql(query, req) {
|
|||||||
return updatedNotification;
|
return updatedNotification;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const router = Router();
|
export const alertsRouter = Router();
|
||||||
|
|
||||||
router.get('/api/alerts', fetchAlertsApi);
|
alertsRouter.get('/api/alerts', fetchAlertsApi);
|
||||||
router.post('/api/alerts', createAlertApi);
|
alertsRouter.post('/api/alerts', createAlertApi);
|
||||||
router.delete('/api/alerts/:alertId', removeAlertApi);
|
alertsRouter.delete('/api/alerts/:alertId', removeAlertApi);
|
||||||
|
|
||||||
router.get('/api/users/:userId/notifications', fetchNotificationsApi);
|
alertsRouter.get('/api/users/:userId/notifications', fetchNotificationsApi);
|
||||||
router.patch('/api/users/:userId/notifications', updateNotificationsApi);
|
alertsRouter.patch('/api/users/:userId/notifications', updateNotificationsApi);
|
||||||
router.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);
|
alertsRouter.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);
|
||||||
|
|||||||
143
src/web/auth.js
143
src/web/auth.js
@@ -1,5 +1,7 @@
|
|||||||
import crypto from 'node:crypto';
|
import { Router } from 'express';
|
||||||
import IPCIDR from 'ip-cidr';
|
import IPCIDR from 'ip-cidr';
|
||||||
|
|
||||||
|
import runtimeId from '../../utils/runtime-id.js';
|
||||||
import argv from '../argv.js';
|
import argv from '../argv.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -8,31 +10,34 @@ import {
|
|||||||
flushUserKeys,
|
flushUserKeys,
|
||||||
login,
|
login,
|
||||||
removeUserKey,
|
removeUserKey,
|
||||||
|
resetPassword,
|
||||||
|
sendEmailVerification,
|
||||||
|
sendPasswordReset,
|
||||||
|
sendSignup,
|
||||||
signup,
|
signup,
|
||||||
|
updateSettings,
|
||||||
|
updateUser,
|
||||||
|
verifyEmail,
|
||||||
} from '../auth.js';
|
} from '../auth.js';
|
||||||
|
|
||||||
import { fetchUser } from '../users.js';
|
import {
|
||||||
|
fetchUser,
|
||||||
// changes every time the process restarts, so a deploy invalidates all cached session users
|
} from '../users.js';
|
||||||
// even if they're within the staleness window below
|
|
||||||
const runtimeId = crypto.randomUUID();
|
|
||||||
const sessionStaleThreshold = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
// spread into a new object rather than mutating the user we're about to hand back to the
|
// spread into a new object rather than mutating the user we're about to hand back to the
|
||||||
// client (loginApi/signupApi send this straight to the browser) — runtimeId/refreshedAt are
|
// client (loginApi/signupApi send this straight to the browser) — runtimeId/refreshedAt are
|
||||||
// session bookkeeping only, they shouldn't leak into the API response
|
// session bookkeeping only, they shouldn't leak into the API response
|
||||||
function toSession(user) {
|
function toSession(user) {
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
runtimeId,
|
runtimeId,
|
||||||
refreshedAt: Date.now(),
|
refreshedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSessionStale(sessionUser) {
|
|
||||||
return sessionUser.runtimeId !== runtimeId || Date.now() - sessionUser.refreshedAt > sessionStaleThreshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
// concurrent requests for the same user (e.g. the burst of asset/API requests a single page
|
// concurrent requests for the same user (e.g. the burst of asset/API requests a single page
|
||||||
// load fires) share one in-flight refresh instead of each independently hitting the DB
|
// load fires) share one in-flight refresh instead of each independently hitting the DB
|
||||||
const pendingSessionRefreshes = new Map();
|
const pendingSessionRefreshes = new Map();
|
||||||
@@ -81,16 +86,24 @@ function getIp(req) {
|
|||||||
return expandedIp.addressStart?.addressMinusSuffix || null;
|
return expandedIp.addressStart?.addressMinusSuffix || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// changes every time the process restarts, so a deploy invalidates all cached session users
|
||||||
|
// even if they're within the staleness window below
|
||||||
|
const sessionStaleThreshold = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function isSessionStale(sessionUser) {
|
||||||
|
return !!sessionUser && (sessionUser.runtimeId !== runtimeId || Date.now() - sessionUser.refreshedAt > sessionStaleThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
export async function setUserApi(req, res, next) {
|
export async function setUserApi(req, res, next) {
|
||||||
const ip = getIp(req);
|
const ip = getIp(req);
|
||||||
|
|
||||||
req.userIp = ip;
|
req.userIp = ip;
|
||||||
|
|
||||||
if (req.session.user) {
|
|
||||||
if (isSessionStale(req.session.user)) {
|
if (isSessionStale(req.session.user)) {
|
||||||
req.session.user = await refreshSessionUser(req.session.user);
|
req.session.user = await refreshSessionUser(req.session.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.session.user) {
|
||||||
req.user = req.session.user;
|
req.user = req.session.user;
|
||||||
req.user.ip = ip;
|
req.user.ip = ip;
|
||||||
}
|
}
|
||||||
@@ -101,10 +114,12 @@ export async function setUserApi(req, res, next) {
|
|||||||
export async function updateSessionUser(req) {
|
export async function updateSessionUser(req) {
|
||||||
const user = toSession(await fetchUser(req.session.user.id, {}, req.session.user));
|
const user = toSession(await fetchUser(req.session.user.id, {}, req.session.user));
|
||||||
|
|
||||||
|
if (user) {
|
||||||
req.session.user = user;
|
req.session.user = user;
|
||||||
|
|
||||||
req.user = user;
|
req.user = user;
|
||||||
req.user.ip = req.userIp;
|
req.user.ip = req.userIp;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginApi(req, res) {
|
export async function loginApi(req, res) {
|
||||||
@@ -124,6 +139,12 @@ export async function logoutApi(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendSignupApi(req, res) {
|
||||||
|
await sendSignup(req.body.email, req.userIp);
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
export async function signupApi(req, res) {
|
export async function signupApi(req, res) {
|
||||||
const user = await signup(req.body, req.userIp);
|
const user = await signup(req.body, req.userIp);
|
||||||
|
|
||||||
@@ -131,6 +152,74 @@ export async function signupApi(req, res) {
|
|||||||
res.send(user);
|
res.send(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateUserApi(req, res) {
|
||||||
|
const updatedUser = await updateUser(req.body, req.user, req.session);
|
||||||
|
const sessions = await req.sessionStore.all();
|
||||||
|
|
||||||
|
req.session.user.refreshedAt = 0;
|
||||||
|
|
||||||
|
await Promise.all(sessions.map(async (session) => {
|
||||||
|
if (session.user?.id === req.user.id && session.id !== req.sessionID) {
|
||||||
|
if (req.body.credentials?.password) {
|
||||||
|
// we changed the password, destroy all user sessions except our own (we already provided all the credentials)
|
||||||
|
await req.sessionStore.destroy(session.id);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// mark our sessions as stale
|
||||||
|
session.user.refreshedAt = 0; // eslint-disable-line no-param-reassign
|
||||||
|
await req.sessionStore.set(session.id, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.send(updatedUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmailVerificationApi(req, res) {
|
||||||
|
await sendEmailVerification(req.user, null, 'verify');
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyEmailApi(req, res) {
|
||||||
|
const userId = Number(req.body.userId) || null;
|
||||||
|
|
||||||
|
await verifyEmail(userId, req.body.token);
|
||||||
|
|
||||||
|
const sessions = await req.sessionStore.all();
|
||||||
|
|
||||||
|
await Promise.all(sessions.map(async (session) => {
|
||||||
|
if (session.user?.id === userId) {
|
||||||
|
session.user.refreshedAt = 0; // eslint-disable-line no-param-reassign
|
||||||
|
await req.sessionStore.set(session.id, session);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendPasswordResetApi(req, res) {
|
||||||
|
await sendPasswordReset(req.body.credential);
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPasswordApi(req, res) {
|
||||||
|
const userId = Number(req.body.userId) || null;
|
||||||
|
|
||||||
|
await resetPassword(userId, req.body.token, req.body.password);
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSettingsApi(req, res) {
|
||||||
|
const updatedSettings = await updateSettings(req.body, req.user);
|
||||||
|
|
||||||
|
req.session.user.settings = updatedSettings;
|
||||||
|
|
||||||
|
res.send(updatedSettings);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchUserKeysApi(req, res) {
|
export async function fetchUserKeysApi(req, res) {
|
||||||
const keys = await fetchUserKeys(req.user);
|
const keys = await fetchUserKeys(req.user);
|
||||||
|
|
||||||
@@ -154,3 +243,31 @@ export async function flushUserKeysApi(req, res) {
|
|||||||
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
// SESSION
|
||||||
|
// authRouter.use(setUserApi);
|
||||||
|
|
||||||
|
authRouter.post('/api/session', loginApi);
|
||||||
|
authRouter.delete('/api/session', logoutApi);
|
||||||
|
|
||||||
|
// USERS
|
||||||
|
authRouter.post('/api/signups', sendSignupApi);
|
||||||
|
authRouter.post('/api/users', signupApi);
|
||||||
|
|
||||||
|
// SETTINGS
|
||||||
|
authRouter.patch('/api/me', updateUserApi);
|
||||||
|
authRouter.patch('/api/me/settings', updateSettingsApi);
|
||||||
|
|
||||||
|
// EMAIL VERIFICATION
|
||||||
|
authRouter.post('/api/me/email_verification', sendEmailVerificationApi);
|
||||||
|
authRouter.post('/api/emails/verify', verifyEmailApi); // usable without session
|
||||||
|
authRouter.post('/api/passwords/request', sendPasswordResetApi);
|
||||||
|
authRouter.post('/api/passwords/reset', resetPasswordApi);
|
||||||
|
|
||||||
|
// API KEYS
|
||||||
|
authRouter.get('/api/me/keys', fetchUserKeysApi);
|
||||||
|
authRouter.post('/api/me/keys', createKeyApi);
|
||||||
|
authRouter.delete('/api/me/keys/:keyIdentifier', removeUserKeyApi);
|
||||||
|
authRouter.delete('/api/me/keys', flushUserKeysApi);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default async function mainHandler(req, res, next) {
|
|||||||
role: req.user.role,
|
role: req.user.role,
|
||||||
abilities: req.user.abilities,
|
abilities: req.user.abilities,
|
||||||
avatar: req.user.avatar,
|
avatar: req.user.avatar,
|
||||||
|
isEmailVerified: req.user.isEmailVerified,
|
||||||
},
|
},
|
||||||
assets: req.user
|
assets: req.user
|
||||||
? {
|
? {
|
||||||
@@ -59,7 +60,7 @@ export default async function mainHandler(req, res, next) {
|
|||||||
siteKey: config.auth.captcha.siteKey,
|
siteKey: config.auth.captcha.siteKey,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
restriction: req.restriction,
|
geo: req.geo,
|
||||||
meta: {
|
meta: {
|
||||||
now: new Date().toISOString(),
|
now: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
&& Object.hasOwn(req.session.geo, 'country')
|
||||||
|
&& req.session.geo.ip === req.userIp
|
||||||
|
&& req.session.geo.runtimeId === runtimeId) {
|
||||||
|
const { country, subdivision } = req.session.geo;
|
||||||
|
|
||||||
|
if (!config.restrictions.enabled) {
|
||||||
return {
|
return {
|
||||||
restriction: req.session.restriction,
|
restriction: 0,
|
||||||
country: req.session.country,
|
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();
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async function fetchScenesApi(req, res) {
|
|||||||
page: Number(req.query.page) || 1,
|
page: Number(req.query.page) || 1,
|
||||||
limit: Number(req.query.limit) || 30,
|
limit: Number(req.query.limit) || 30,
|
||||||
}, req.user, {
|
}, req.user, {
|
||||||
restriction: req.restriction,
|
restriction: req.geo.restriction,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.send({
|
res.send({
|
||||||
@@ -254,7 +254,7 @@ export async function fetchScenesGraphql(query, req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSceneApi(req, res) {
|
async function fetchSceneApi(req, res) {
|
||||||
const [scene] = await fetchScenesById([Number(req.params.sceneId)], { reqUser: req.user }, { restriction: req.restriction });
|
const [scene] = await fetchScenesById([Number(req.params.sceneId)], { reqUser: req.user }, { restriction: req.geo.restriction });
|
||||||
|
|
||||||
if (!scene) {
|
if (!scene) {
|
||||||
throw new HttpError(`No scene with ID ${req.params.sceneId} found`, 404);
|
throw new HttpError(`No scene with ID ${req.params.sceneId} found`, 404);
|
||||||
@@ -267,7 +267,7 @@ export async function fetchScenesByIdGraphql(query, req) {
|
|||||||
const scenes = await fetchScenesById([].concat(query.id, query.ids).filter(Boolean), {
|
const scenes = await fetchScenesById([].concat(query.id, query.ids).filter(Boolean), {
|
||||||
reqUser: req.user,
|
reqUser: req.user,
|
||||||
includePartOf: true,
|
includePartOf: true,
|
||||||
restriction: req.restriction,
|
restriction: req.geo.restriction,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (query.ids) {
|
if (query.ids) {
|
||||||
|
|||||||
@@ -12,19 +12,11 @@ import initLogger from '../logger.js';
|
|||||||
|
|
||||||
import redis from '../redis.js';
|
import redis from '../redis.js';
|
||||||
import queryBoolean from '../utils/query-boolean.js';
|
import queryBoolean from '../utils/query-boolean.js';
|
||||||
import { actorsRouter } from './actors.js';
|
|
||||||
|
|
||||||
import { router as alertsRouter } from './alerts.js';
|
import { actorsRouter } from './actors.js';
|
||||||
import {
|
import { alertsRouter } from './alerts.js';
|
||||||
createKeyApi,
|
import { authRouter, setUserApi } from './auth.js';
|
||||||
fetchUserKeysApi,
|
|
||||||
flushUserKeysApi,
|
|
||||||
loginApi,
|
|
||||||
logoutApi,
|
|
||||||
removeUserKeyApi,
|
|
||||||
setUserApi,
|
|
||||||
signupApi,
|
|
||||||
} from './auth.js';
|
|
||||||
import consentHandler from './consent.js';
|
import consentHandler from './consent.js';
|
||||||
|
|
||||||
import { fetchEntitiesApi } from './entities.js';
|
import { fetchEntitiesApi } from './entities.js';
|
||||||
@@ -91,8 +83,6 @@ export default async function initServer() {
|
|||||||
store: redisStore,
|
store: redisStore,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.use(setUserApi);
|
|
||||||
|
|
||||||
// Vite integration
|
// Vite integration
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
app.enable('trust proxy');
|
app.enable('trust proxy');
|
||||||
@@ -118,6 +108,7 @@ export default async function initServer() {
|
|||||||
router.use(viteDevMiddleware);
|
router.use(viteDevMiddleware);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.use(setUserApi); // we need to set the IP before the restriction handler
|
||||||
router.use(restrictionHandler);
|
router.use(restrictionHandler);
|
||||||
|
|
||||||
router.get('/consent', (_req, res) => {
|
router.get('/consent', (_req, res) => {
|
||||||
@@ -134,22 +125,10 @@ export default async function initServer() {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// SESSION
|
|
||||||
router.post('/api/session', loginApi);
|
|
||||||
router.delete('/api/session', logoutApi);
|
|
||||||
|
|
||||||
// USERS
|
|
||||||
router.post('/api/users', signupApi);
|
|
||||||
|
|
||||||
// API KEYS
|
|
||||||
router.get('/api/me/keys', fetchUserKeysApi);
|
|
||||||
router.post('/api/me/keys', createKeyApi);
|
|
||||||
router.delete('/api/me/keys/:keyIdentifier', removeUserKeyApi);
|
|
||||||
router.delete('/api/me/keys', flushUserKeysApi);
|
|
||||||
|
|
||||||
router.use(userRouter);
|
router.use(userRouter);
|
||||||
router.use(stashesRouter);
|
router.use(stashesRouter);
|
||||||
router.use(alertsRouter);
|
router.use(alertsRouter);
|
||||||
|
router.use(authRouter);
|
||||||
router.use(scenesRouter);
|
router.use(scenesRouter);
|
||||||
router.use(actorsRouter);
|
router.use(actorsRouter);
|
||||||
router.use(syncRouter);
|
router.use(syncRouter);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
fetchUser,
|
fetchUser,
|
||||||
fetchUserTemplates,
|
fetchUserTemplates,
|
||||||
removeTemplate,
|
removeTemplate,
|
||||||
updateSettings,
|
|
||||||
} from '../users.js';
|
} from '../users.js';
|
||||||
|
|
||||||
async function fetchUserApi(req, res) {
|
async function fetchUserApi(req, res) {
|
||||||
@@ -15,14 +14,6 @@ async function fetchUserApi(req, res) {
|
|||||||
res.send(user);
|
res.send(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateSettingsApi(req, res) {
|
|
||||||
const updatedSettings = await updateSettings(req.body, req.user);
|
|
||||||
|
|
||||||
req.session.user.settings = updatedSettings;
|
|
||||||
|
|
||||||
res.send(updatedSettings);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchUserTemplatesApi(req, res) {
|
async function fetchUserTemplatesApi(req, res) {
|
||||||
const templates = await fetchUserTemplates(req.user);
|
const templates = await fetchUserTemplates(req.user);
|
||||||
|
|
||||||
@@ -52,8 +43,6 @@ export const router = Router();
|
|||||||
router.get('/api/users/:userId', fetchUserApi);
|
router.get('/api/users/:userId', fetchUserApi);
|
||||||
router.get('/api/users/:userId/templates', fetchUserTemplatesApi);
|
router.get('/api/users/:userId/templates', fetchUserTemplatesApi);
|
||||||
|
|
||||||
router.patch('/api/me/settings', updateSettingsApi);
|
|
||||||
|
|
||||||
router.post('/api/templates', createTemplateApi);
|
router.post('/api/templates', createTemplateApi);
|
||||||
router.delete('/api/templates/:templateId', removeTemplateApi);
|
router.delete('/api/templates/:templateId', removeTemplateApi);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MerkleJson } from 'merkle-json';
|
import { MerkleJson } from 'merkle-json';
|
||||||
|
|
||||||
import knex from '../knex.js';
|
import knex from '../src/knex.js';
|
||||||
|
|
||||||
const mj = new MerkleJson();
|
const mj = new MerkleJson();
|
||||||
|
|
||||||
|
|||||||
3
utils/runtime-id.js
Normal file
3
utils/runtime-id.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
export default crypto.randomUUID();
|
||||||
Reference in New Issue
Block a user