Compare commits

..

95 Commits

Author SHA1 Message Date
a45fd152df 0.46.16 2026-02-12 21:07:57 +01:00
60f01d859e Restored compilation global filter position. 2026-02-12 21:07:55 +01:00
6d4e033fb7 0.46.15 2026-02-12 21:07:16 +01:00
bf635df863 Added extreme insertion global filter. 2026-02-12 21:07:14 +01:00
1732b4cf4d 0.46.14 2026-02-12 01:28:36 +01:00
a2e268913a Added Matrix link to footer. 2026-02-12 01:28:34 +01:00
dfb04e5e01 0.46.13 2026-02-08 21:41:22 +01:00
c0ce844169 Fixed scene tile banner styling disrupting other banner positions. 2026-02-08 21:41:20 +01:00
56acc42f17 0.46.12 2026-02-08 05:19:01 +01:00
7cee6639e7 Showing chapter posters before scene photos in scene album. 2026-02-08 05:18:58 +01:00
62753a4af0 0.46.11 2026-02-08 02:02:16 +01:00
2d4d2b1105 Fixed scene chapter photos not curated. 2026-02-08 02:02:14 +01:00
09ed130327 0.46.10 2026-02-07 17:47:11 +01:00
1414a846ec Added edit tooltips. 2026-02-07 17:47:10 +01:00
19dc029e28 Improved chapter date display. 2026-02-07 05:54:17 +01:00
67176db933 0.46.9 2026-02-07 05:10:06 +01:00
95e8982696 Showing heading and dates on chapters. 2026-02-07 05:10:02 +01:00
b8a03cd6fb 0.46.8 2026-02-07 02:03:17 +01:00
aad4ff8079 Centered banner blur background. 2026-02-07 02:03:15 +01:00
8821b3a00d 0.46.7 2026-02-07 01:11:55 +01:00
db43102487 Added blurred backdrop to scene tile banners to compensate for lack of upscaling. 2026-02-07 01:11:53 +01:00
a3072a4967 0.46.6 2026-02-07 00:56:05 +01:00
983e24835f Removed auto width from tile banners to prevent blurry upscaling. Fixed tag photo paths. 2026-02-07 00:56:02 +01:00
1c982124b0 0.46.5 2026-02-06 23:55:08 +01:00
6aaa3ad30c Allowing campaigns to be marked as non-global. 2026-02-06 23:55:06 +01:00
57dfa621df 0.46.4 2026-02-06 23:05:54 +01:00
e98254d444 Fixed tag photo paths. Fixed affiliate prefix slash logic. 2026-02-06 23:05:52 +01:00
56defe2c6f 0.46.3 2026-02-06 21:49:00 +01:00
a2f08c540c Switched scene tile URL back to watch URL. 2026-02-06 21:48:58 +01:00
217decee06 0.46.2 2026-02-06 21:43:29 +01:00
d93baf80ab Improved affiliate URL calculation. 2026-02-06 21:43:26 +01:00
e409f3c214 0.46.1 2026-02-04 06:37:44 +01:00
a1e080c20d Fixed search breaking due missing restrictions, added restrictions to API calls. 2026-02-04 06:37:41 +01:00
6c8fce49d6 0.46.0 2026-02-04 05:39:16 +01:00
1a84f899e7 Added georestriction with SFW mode. 2026-02-04 05:39:14 +01:00
ce107e6b65 0.45.11 2026-02-03 00:04:30 +01:00
515d3885c7 Fixed banner URL not resolving to affiliate definition. 2026-02-03 00:04:29 +01:00
5194c5e156 0.45.10 2026-02-02 23:48:16 +01:00
5b53f53fd3 Improved banner URL calculation. 2026-02-02 23:48:13 +01:00
750b30d896 0.45.9 2026-01-31 00:39:49 +01:00
b9afa61e01 Fixed query-based affiliate URL getting skipped. 2026-01-31 00:39:33 +01:00
490be8800a 0.45.8 2026-01-30 23:20:23 +01:00
49ee6b4eee Fixed unresolved affiliate scene URL breaking. 2026-01-30 23:20:21 +01:00
ada81340ef 0.45.7 2026-01-30 22:39:35 +01:00
5ae3b5d91c Ensuring affiliate URL is valid. 2026-01-30 22:39:34 +01:00
bff3bc6a0b 0.45.6 2026-01-30 22:19:23 +01:00
5496bced59 Optionally chaining user abilities until everyone's logged out and back in. 2026-01-30 22:19:21 +01:00
030d6dc835 0.45.5 2026-01-30 06:03:06 +01:00
fc46ae00f8 Added plain URL for privileged users. 2026-01-30 06:03:03 +01:00
e22978cbe4 0.45.4 2026-01-29 22:23:34 +01:00
70049ed495 Fixed entity affiliate URL generator breaking if no entity URL exists, falling back on parent URL. 2026-01-29 22:23:32 +01:00
9e20af925f 0.45.3 2026-01-29 21:51:26 +01:00
457afa5043 Allowing parent affiliate URL if channel uses the same URL as network. 2026-01-29 21:51:21 +01:00
c536a75f3d Added support for entryId in affiliate links. 2026-01-29 21:29:26 +01:00
a2d5828fda 0.45.2 2026-01-29 04:45:40 +01:00
52d041c591 Retired visitor database connection. Fixed empty traxxx breaking on missing batch IDs. 2026-01-29 04:45:38 +01:00
3bee1ac97d 0.45.1 2026-01-28 01:17:58 +01:00
428d86b1ee Fixed scene pages breaking if user is not logged in. 2026-01-28 01:17:56 +01:00
5facacd066 0.45.0 2026-01-28 00:58:14 +01:00
0bf0b716b2 Added dynamic affiliate URLs and video player restrictions. 2026-01-28 00:57:47 +01:00
31c62e01f9 0.44.6 2026-01-27 03:07:18 +01:00
a57b66cd95 Removed stray comment. 2026-01-27 03:07:16 +01:00
e4675e6e97 Removed showcase missing date filter, try disabling showcase on specific problematic channels. 2026-01-27 03:06:50 +01:00
bac0b768e2 0.44.5 2026-01-27 01:04:30 +01:00
74c69c698e PM2 ecosystem file calls src/app directly, should fix cluster problem. 2026-01-27 01:04:28 +01:00
87604ed848 0.44.4 2026-01-26 16:56:57 +01:00
b6ca08727f Darkening instead of lightening blurred scene banner background in dark theme. 2026-01-26 16:56:55 +01:00
af99491533 0.44.3 2026-01-26 16:52:37 +01:00
461f6cf8fd Fixed fingerprint row highlight color. 2026-01-26 16:52:34 +01:00
2ad17c2279 0.44.2 2026-01-26 16:50:08 +01:00
9f3bc6e8de Fixed fingerprint heading dark theme color. 2026-01-26 16:50:06 +01:00
6dedf10846 0.44.1 2026-01-26 02:44:34 +01:00
6b6e31a1bb Fixed auth page stuck on submitted on error. 2026-01-26 02:44:31 +01:00
f7016609a0 0.44.0 2026-01-26 02:02:40 +01:00
fde2d607b8 Displaying fingerprints on scene page. 2026-01-26 02:02:35 +01:00
54e9fd9f6a 0.43.4 2026-01-24 18:21:52 +01:00
886f02c5fc Further bio spacing improvements. 2026-01-24 18:21:50 +01:00
8d57dfd2d2 0.43.3 2026-01-24 18:15:15 +01:00
f9ba519dea Removed min-width from actor bio columns. 2026-01-24 18:15:13 +01:00
2eb4678afc 0.43.2 2026-01-24 18:08:43 +01:00
9558ce80b4 Improved actor bio scaling. 2026-01-24 18:08:41 +01:00
4569930a81 0.43.1 2026-01-24 17:59:23 +01:00
52b012402e Uncommented client-side CAPTCHA completion check. 2026-01-24 17:59:21 +01:00
82ff813225 0.43.0 2026-01-24 17:53:03 +01:00
b7bd0fac03 Integrated hCaptcha. 2026-01-24 17:53:01 +01:00
9933b4fbf0 0.42.28 2026-01-24 01:38:54 +01:00
c272e6c8b3 Fixed expand button showing behind bio bar. 2026-01-24 01:38:52 +01:00
302f6a0621 0.42.27 2026-01-24 01:36:17 +01:00
23155520d2 Hiding serie poster container if no poster is present. 2026-01-24 01:36:15 +01:00
b788d78aab 0.42.26 2026-01-23 05:25:39 +01:00
cd9e4a5e8d Fixed date display on serie page. 2026-01-23 05:25:36 +01:00
8ec48ec43e 0.42.25 2026-01-23 03:33:05 +01:00
a27bc2c815 Separated scene and entity affiliate replace. 2026-01-23 03:33:03 +01:00
16f43066a4 0.42.24 2026-01-23 03:26:34 +01:00
6191e17c4e Improved affiliate logic. 2026-01-23 03:26:29 +01:00
64 changed files with 1510 additions and 246 deletions

View File

@@ -21,7 +21,7 @@
"no-console": 0, "no-console": 0,
"no-param-reassign": ["error", { "no-param-reassign": ["error", {
"props": true, "props": true,
"ignorePropertyModificationsFor": ["state", "acc"] "ignorePropertyModificationsFor": ["state", "acc", "req"]
}], }],
"vue/multi-word-component-names": 0, "vue/multi-word-component-names": 0,
"vue/no-reserved-component-names": 0, "vue/no-reserved-component-names": 0,
@@ -32,7 +32,8 @@
"vue/html-indent": ["error", "tab"], "vue/html-indent": ["error", "tab"],
"vue/multiline-html-element-content-newline": 0, "vue/multiline-html-element-content-newline": 0,
"vue/no-v-html": 0, "vue/no-v-html": 0,
"vue/singleline-html-element-content-newline": 0 "vue/singleline-html-element-content-newline": 0,
"vue/comment-directive": 0,
}, },
"settings": { "settings": {
"import/resolver": { "import/resolver": {

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ config/*
log/ log/
/media /media
data/ data/
assets/*.mmdb
assets/.geoipupdate.lock

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
viewBox="0 0 75 32"
id="svg1"
sodipodi:docname="matrix-full.svg"
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"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="12.000629"
inkscape:cx="27.79021"
inkscape:cy="22.165505"
inkscape:window-width="1920"
inkscape:window-height="1020"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<title
id="title1">Matrix (protocol) logo</title>
<g
id="g1">
<path
d="m0.936 0.732v30.52h2.194v0.732h-3.035v-31.98h3.034v0.732zm8.45 9.675v1.544h0.044a4.461 4.461 0 0 1 1.487-1.368c0.58-0.323 1.245-0.485 1.993-0.485 0.72 0 1.377 0.14 1.972 0.42 0.595 0.279 1.047 0.771 1.355 1.477 0.338-0.5 0.796-0.941 1.377-1.323 0.58-0.383 1.266-0.574 2.06-0.574 0.602 0 1.16 0.074 1.674 0.22 0.514 0.148 0.954 0.383 1.322 0.707 0.366 0.323 0.653 0.746 0.859 1.268 0.205 0.522 0.308 1.15 0.308 1.887v7.633h-3.127v-6.464c0-0.383-0.015-0.743-0.044-1.082a2.305 2.305 0 0 0-0.242-0.882 1.473 1.473 0 0 0-0.584-0.596c-0.257-0.146-0.606-0.22-1.047-0.22-0.44 0-0.796 0.085-1.068 0.253-0.272 0.17-0.485 0.39-0.639 0.662a2.654 2.654 0 0 0-0.308 0.927 7.074 7.074 0 0 0-0.078 1.048v6.354h-3.128v-6.398c0-0.338-7e-3 -0.673-0.021-1.004a2.825 2.825 0 0 0-0.188-0.916 1.411 1.411 0 0 0-0.55-0.673c-0.258-0.168-0.636-0.253-1.135-0.253a2.33 2.33 0 0 0-0.584 0.1 1.94 1.94 0 0 0-0.705 0.374c-0.228 0.184-0.422 0.449-0.584 0.794-0.161 0.346-0.242 0.798-0.242 1.357v6.619h-3.129v-11.41zm16.46 1.677a3.751 3.751 0 0 1 1.233-1.17 5.37 5.37 0 0 1 1.685-0.629 9.579 9.579 0 0 1 1.884-0.187c0.573 0 1.153 0.04 1.74 0.121 0.588 0.081 1.124 0.24 1.609 0.475 0.484 0.235 0.88 0.562 1.19 0.981 0.308 0.42 0.462 0.975 0.462 1.666v5.934c0 0.516 0.03 1.008 0.088 1.478 0.058 0.471 0.161 0.824 0.308 1.06h-3.171a4.435 4.435 0 0 1-0.22-1.104c-0.5 0.515-1.087 0.876-1.762 1.081a7.084 7.084 0 0 1-2.071 0.31c-0.544 0-1.05-0.067-1.52-0.2a3.472 3.472 0 0 1-1.234-0.617 2.87 2.87 0 0 1-0.826-1.059c-0.199-0.426-0.298-0.934-0.298-1.522 0-0.647 0.114-1.18 0.342-1.6 0.227-0.419 0.52-0.753 0.881-1.004 0.36-0.25 0.771-0.437 1.234-0.562 0.462-0.125 0.929-0.224 1.399-0.298 0.47-0.073 0.932-0.132 1.387-0.176 0.456-0.044 0.86-0.11 1.212-0.199 0.353-0.088 0.631-0.217 0.837-0.386s0.301-0.415 0.287-0.74c0-0.337-0.055-0.606-0.166-0.804a1.217 1.217 0 0 0-0.44-0.464 1.737 1.737 0 0 0-0.639-0.22 5.292 5.292 0 0 0-0.782-0.055c-0.617 0-1.101 0.132-1.454 0.397-0.352 0.264-0.558 0.706-0.617 1.323h-3.128c0.044-0.735 0.227-1.345 0.55-1.83zm6.179 4.423a5.095 5.095 0 0 1-0.639 0.165 9.68 9.68 0 0 1-0.716 0.11c-0.25 0.03-0.5 0.067-0.749 0.11a5.616 5.616 0 0 0-0.694 0.177 2.057 2.057 0 0 0-0.594 0.298c-0.17 0.125-0.305 0.284-0.408 0.474-0.103 0.192-0.154 0.434-0.154 0.728 0 0.28 0.051 0.515 0.154 0.706 0.103 0.192 0.242 0.342 0.419 0.453 0.176 0.11 0.381 0.187 0.617 0.231 0.234 0.044 0.477 0.066 0.726 0.066 0.617 0 1.094-0.102 1.432-0.309 0.338-0.205 0.587-0.452 0.75-0.739 0.16-0.286 0.26-0.576 0.297-0.87 0.036-0.295 0.055-0.53 0.055-0.707v-1.17a1.4 1.4 0 0 1-0.496 0.277zm11.86-6.1v2.096h-2.291v5.647c0 0.53 0.088 0.883 0.264 1.059 0.176 0.177 0.529 0.265 1.057 0.265 0.177 0 0.345-7e-3 0.507-0.022 0.161-0.015 0.316-0.037 0.463-0.066v2.426a7.49 7.49 0 0 1-0.882 0.089 21.67 21.67 0 0 1-0.947 0.022c-0.484 0-0.944-0.034-1.377-0.1a3.233 3.233 0 0 1-1.145-0.386 2.04 2.04 0 0 1-0.782-0.816c-0.191-0.353-0.287-0.816-0.287-1.39v-6.728h-1.894v-2.096h1.894v-3.42h3.129v3.42h2.29zm4.471 0v2.118h0.044a3.907 3.907 0 0 1 1.454-1.754 4.213 4.213 0 0 1 1.036-0.497 3.734 3.734 0 0 1 1.145-0.176c0.206 0 0.433 0.037 0.683 0.11v2.912a5.862 5.862 0 0 0-0.528-0.077 5.566 5.566 0 0 0-0.595-0.033c-0.573 0-1.058 0.096-1.454 0.287a2.52 2.52 0 0 0-0.958 0.783 3.143 3.143 0 0 0-0.518 1.158 6.32 6.32 0 0 0-0.154 1.434v5.14h-3.128v-11.4zm5.684-1.765v-2.582h3.128v2.582h-3.127zm3.128 1.765v11.4h-3.127v-11.4h3.128zm1.63 0h3.569l2.005 2.978 1.982-2.978h3.459l-3.745 5.339 4.208 6.067h-3.57l-2.378-3.596-2.38 3.596h-3.502l4.097-6.001zm15.3 20.84v-30.52h-2.194v-0.732h3.035v31.98h-3.035v-0.732z"
id="path1" />
</g>
<metadata
id="metadata1">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>Matrix (protocol) logo</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
width="800px"
height="800px"
viewBox="0 0 32 32"
version="1.1"
id="svg1"
sodipodi:docname="matrix.svg"
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="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.23142407"
inkscape:cx="412.66234"
inkscape:cy="557.41825"
inkscape:window-width="1920"
inkscape:window-height="1020"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
d="M0.844 0.735v30.531h2.197v0.735h-3.041v-32h3.041v0.735zM10.235 10.412v1.547h0.041c0.412-0.595 0.912-1.047 1.489-1.371 0.579-0.323 1.251-0.484 2-0.484 0.719 0 1.38 0.141 1.975 0.417 0.599 0.281 1.047 0.776 1.359 1.479 0.339-0.5 0.803-0.943 1.38-1.323 0.579-0.38 1.267-0.573 2.063-0.573 0.604 0 1.161 0.073 1.677 0.224 0.521 0.145 0.959 0.38 1.328 0.703 0.365 0.329 0.651 0.751 0.86 1.272 0.203 0.52 0.307 1.151 0.307 1.891v7.635h-3.129v-6.468c0-0.381-0.016-0.745-0.048-1.084-0.020-0.307-0.099-0.604-0.239-0.88-0.131-0.251-0.333-0.459-0.584-0.593-0.255-0.152-0.609-0.224-1.047-0.224-0.443 0-0.797 0.083-1.068 0.249-0.265 0.167-0.489 0.396-0.64 0.667-0.161 0.287-0.265 0.604-0.308 0.927-0.052 0.349-0.077 0.699-0.083 1.048v6.359h-3.131v-6.401c0-0.339-0.005-0.672-0.025-1-0.011-0.317-0.073-0.624-0.193-0.916-0.104-0.281-0.301-0.516-0.552-0.672-0.255-0.167-0.636-0.255-1.136-0.255-0.151 0-0.348 0.031-0.588 0.099-0.24 0.067-0.479 0.192-0.703 0.375-0.229 0.188-0.428 0.453-0.589 0.797-0.161 0.343-0.239 0.796-0.239 1.359v6.62h-3.131v-11.421zM31.156 31.265v-30.531h-2.197v-0.735h3.041v32h-3.041v-0.735z"
id="path1"
style="fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

125
assets/sfw.ejs Normal file
View File

@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png">
<link rel="manifest" href="/img/favicon/site.webmanifest">
<link rel="mask-icon" href="/img/favicon/safari-pinned-tab.svg" color="#5bbad5">
<link rel="shortcut icon" href="/img/favicon/favicon.ico">
<meta name="msapplication-TileColor" content="#b91d47">
<meta name="msapplication-config" content="/img/favicon/browserconfig.xml">
<meta name="theme-color" content="#f65596">
<meta property="og:title" content="traxxx" />
<meta property="og:image" content="https://traxxx.me/img/og_logo.png" />
<meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0,interactive-widget=resizes-content" />
<title>traxxx - None shall pass</title>
<style>
:root {
--primary-dark-10: #e54485;
--primary: #f65596;
--primary-light-10: #f075a6;
--primary-light-20: #f2a6c4;
--primary-light-30: #f7c9dc;
}
html,
body {
height: 100%;
margin: 0;
}
.content {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
box-sizing: border-box;
padding: 1rem;
}
.explainer {
margin-bottom: 3rem;
font-size: 1.25rem;
text-align: justify;
line-height: 1.5;
}
.useful {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.links {
display: flex;
gap: 1rem;
list-style: none;
padding: 0;
margin: 1rem 0 0 0;
}
.links li {
padding: .25rem 0;
margin: 0;
font-size: 1.25rem;
}
.links a {
padding: .5rem 1rem;
border-radius: .5rem;
background: var(--primary);
color: white;
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
}
.links a:hover {
background: var(--primary-dark-10);
}
</style>
</head>
<body>
<div class="content">
<h2 class="heading">Not so fast, rascal.</h2>
<p class="explainer">The content offered by traxxx is restricted in your jurisdiction.</p>
<% if (!noVpn) { %>
<div class="useful">
Useful links:
<ul class="links">
<li>
<a
href="https://mullvad.net/"
target="_blank"
rel="noopener"
class="link"
>Mullvad VPN</a>
</li>
<li>
<a
href="https://protonvpn.com/"
target="_blank"
rel="noopener"
class="link"
>Proton VPN</a>
</li>
</ul>
</div>
<% } %>
</div>
</body>
</html>

View File

@@ -449,6 +449,7 @@ const showExpand = [
'bust', 'bust',
'cup', 'cup',
'eyes', 'eyes',
'ethnicity',
'hairColor', 'hairColor',
'hasPiercings', 'hasPiercings',
'hasTattoos', 'hasTattoos',
@@ -628,7 +629,9 @@ const socials = props.actor.socials.map((social) => ({
} }
.bio-value { .bio-value {
display: inline-block;
margin: 0 0 0 2rem; margin: 0 0 0 2rem;
max-width: 20rem;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
@@ -851,7 +854,7 @@ const socials = props.actor.socials.map((social) => ({
display: none; display: none;
justify-content: center; justify-content: center;
position: absolute; position: absolute;
z-index: 1; z-index: 10;
bottom: -.25rem; bottom: -.25rem;
} }
@@ -957,6 +960,10 @@ const socials = props.actor.socials.map((social) => ({
margin: 0; margin: 0;
} }
.bio-value {
max-width: initial;
}
.expanded .bio-value { .expanded .bio-value {
white-space: normal; white-space: normal;
} }

View File

@@ -11,7 +11,7 @@
class="avatar-link no-link" class="avatar-link no-link"
> >
<img <img
v-if="actor.avatar" v-if="actor.avatar && !restriction"
:src="getPath(actor.avatar, 'thumbnail')" :src="getPath(actor.avatar, 'thumbnail')"
:style="{ 'background-image': `url(${getPath(actor.avatar, 'lazy')})` }" :style="{ 'background-image': `url(${getPath(actor.avatar, 'lazy')})` }"
loading="lazy" loading="lazy"
@@ -103,7 +103,7 @@ const props = defineProps({
}); });
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const { user } = pageContext; const { user, restriction } = pageContext;
const pageStash = pageContext.pageProps.stash; const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash; const currentStash = pageStash || pageContext.assets?.primaryStash;

View File

@@ -1,6 +1,17 @@
<template> <template>
<div
v-if="restriction"
class="restricted"
>
<div>Traxxx is restricted in your region</div>
<a
href="/sfw"
class="link"
>Learn more</a>
</div>
<iframe <iframe
v-if="campaign?.banner?.type === 'html'" v-else-if="campaign?.banner?.type === 'html'"
ref="iframe" ref="iframe"
:width="campaign.banner.width" :width="campaign.banner.width"
:height="campaign.banner.height" :height="campaign.banner.height"
@@ -18,27 +29,40 @@
:href="campaign.url || campaign.affiliate?.url" :href="campaign.url || campaign.affiliate?.url"
target="_blank" target="_blank"
class="campaign" class="campaign"
:style="{ 'background-image': backdrop && `url(${bannerSrc})` }"
:class="{ backdrop }"
data-umami-event="campaign-click" data-umami-event="campaign-click"
:data-umami-event-campaign-id="`${campaign.entity.slug}-${campaign.id}`" :data-umami-event-campaign-id="`${campaign.entity.slug}-${campaign.id}`"
> >
<img <div class="campaign-overlay">
:src="bannerSrc" <img
:width="campaign.banner.width" :src="bannerSrc"
:height="campaign.banner.height" :width="campaign.banner.width"
class="campaign-banner" :height="campaign.banner.height"
> class="campaign-banner"
>
</div>
</a> </a>
</template> </template>
<script setup> <script setup>
import { inject } from 'vue';
const pageContext = inject('pageContext');
const { restriction } = pageContext;
const props = defineProps({ const props = defineProps({
campaign: { campaign: {
type: Object, type: Object,
default: null, default: null,
}, },
backdrop: {
type: Boolean,
default: false,
},
}); });
// console.log(props.campaign?.banner); // console.log(props.campaign);
const bannerSrc = (() => { const bannerSrc = (() => {
if (props.campaign.banner) { if (props.campaign.banner) {
@@ -60,7 +84,10 @@ const bannerSrc = (() => {
height: 100%; height: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
/* align-items: center; */ border-radius: .25rem;
overflow: hidden;
background-size: cover;
background-position: center;
} }
.frame { .frame {
@@ -68,11 +95,45 @@ const bannerSrc = (() => {
overflow: hidden; overflow: hidden;
} }
.campaign-overlay {
width: 100%;
height: 100%;
display: flex;
box-sizing: border-box;
justify-content: center;
align-items: center;
}
.campaign-banner { .campaign-banner {
width: auto;
height: auto; height: auto;
max-height: 100%; max-height: 100%;
max-width: 100%; max-width: 100%;
object-fit: contain; object-fit: contain;
} }
.backdrop {
.campaign-overlay {
padding: 4px; /* margin for drop shadow */
backdrop-filter: blur(1rem) saturate(70%) brightness(125%);
}
.dark .campaign-overlay {
backdrop-filter: blur(1rem) saturate(70%) brightness(75%);
}
.campaign-banner {
filter: drop-shadow(0 0 2px var(--shadow-weak-20));
}
}
.restricted {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: .5rem;
font-weight: bold;
padding: .5rem;
}
</style> </style>

View File

@@ -2,6 +2,14 @@
<footer class="footer"> <footer class="footer">
<span class="footer-segment">© traxxx</span> <span class="footer-segment">© traxxx</span>
<a
v-if="env.links.matrix"
:href="env.links.matrix"
target="_blank"
rel="noopener noreferrer"
class="footer-segment footer-link nolink matrix"
><Icon icon="matrix-full" /></a>
<a <a
v-if="env.links.discord" v-if="env.links.discord"
:href="env.links.discord" :href="env.links.discord"
@@ -56,7 +64,8 @@ const { env } = pageContext;
} }
} }
.discord .icon { .discord .icon,
.matrix .icon {
height: 1.25rem; height: 1.25rem;
width: 3.5rem; width: 3.5rem;
fill: var(--glass-strong-10); fill: var(--glass-strong-10);

View File

@@ -46,6 +46,13 @@
@play="playing = true; paused = false;" @play="playing = true; paused = false;"
@pause="playing = false; paused = true;" @pause="playing = false; paused = true;"
/> />
<Icon
v-if="(release.trailer || release.teaser).isRestricted"
v-tooltip="'Restricted video'"
icon="blocked"
class="restricted"
/>
</div> </div>
<div <div
@@ -71,9 +78,9 @@
<div <div
v-for="photo in [ v-for="photo in [
...(coversInAlbum ? release.covers : []), ...(coversInAlbum ? release.covers : []),
...(release.chapters?.map((chapter) => chapter.poster).filter(Boolean) || []),
...release.photos, ...release.photos,
...release.caps, ...release.caps,
...(release.chapters?.map((chapter) => chapter.poster).filter(Boolean) || []),
...(release.teaser?.mime.type === 'image' ? [release.poster] : []), ...(release.teaser?.mime.type === 'image' ? [release.poster] : []),
]" ]"
:key="`photo-${photo.id}`" :key="`photo-${photo.id}`"
@@ -149,6 +156,10 @@ const coversInAlbum = props.release.covers?.length > 0 && props.release.trailer;
overflow: hidden; overflow: hidden;
} }
.dark .banner {
backdrop-filter: brightness(70%) blur(1rem);
}
.poster-container { .poster-container {
flex-shrink: 0; flex-shrink: 0;
margin-right: .5rem; margin-right: .5rem;
@@ -188,6 +199,15 @@ const coversInAlbum = props.release.covers?.length > 0 && props.release.trailer;
width: calc(21/9 * 16rem); width: calc(21/9 * 16rem);
flex-shrink: 0; flex-shrink: 0;
aspect-ratio: 16/9; aspect-ratio: 16/9;
position: relative;
}
.restricted {
position: absolute;
top: 0;
left: 0;
padding: .5rem;
fill: var(--highlight-weak-10);
} }
:deep(.player) { :deep(.player) {

View File

@@ -3,6 +3,7 @@
<Campaign <Campaign
v-if="campaigns?.pagination" v-if="campaigns?.pagination"
:campaign="campaigns.pagination" :campaign="campaigns.pagination"
class="campaign-pagination"
/> />
<div <div
@@ -301,6 +302,10 @@ function getPath(page) {
padding: 0 1rem; padding: 0 1rem;
} }
:deep(.campaign-pagination) .campaign-banner {
width: auto;
}
@media(--small-10) { @media(--small-10) {
.campaign { .campaign {
padding: 0; padding: 0;

View File

@@ -52,6 +52,13 @@
v-tooltip="'Duration'" v-tooltip="'Duration'"
class="chapter-duration" class="chapter-duration"
><Icon icon="stopwatch" />{{ formatDuration(chapter.duration) }}</span> ><Icon icon="stopwatch" />{{ formatDuration(chapter.duration) }}</span>
<time
v-if="chapter.date"
v-tooltip="formatDate(chapter.date, 'yyyy-MM-dd hh:mm')"
:datetime="chapter.date"
class="chapter-date"
>{{ formatDate(chapter.date, 'MMM d') }}</time>
</span> </span>
<div class="chapter-info"> <div class="chapter-info">
@@ -87,7 +94,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import getPath from '#/src/get-path.js'; import getPath from '#/src/get-path.js';
import { formatDuration } from '#/utils/format.js'; import { formatDuration, formatDate } from '#/utils/format.js';
const props = defineProps({ const props = defineProps({
chapters: { chapters: {
@@ -138,9 +145,9 @@ const timeline = computed(() => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0 .5rem; padding: 0 .75rem;
border-radius: 0 0 .25rem .25rem; border-radius: 0 0 .25rem .25rem;
margin: 0 0 .5rem 0; margin: 0 0 .75rem 0;
color: var(--text-light); color: var(--text-light);
background: var(--grey-dark-40); background: var(--grey-dark-40);
font-size: .8rem; font-size: .8rem;
@@ -164,7 +171,7 @@ const timeline = computed(() => {
} }
.chapter-info { .chapter-info {
padding: 0 .5rem; padding: 0 .75rem;
font-size: 1rem; font-size: 1rem;
} }
@@ -181,6 +188,7 @@ const timeline = computed(() => {
} }
.chapter-description { .chapter-description {
text-align: justify;
line-height: 1.5; line-height: 1.5;
} }

View File

@@ -52,6 +52,10 @@ defineProps({
type: Object, type: Object,
default: null, default: null,
}, },
user: {
type: Object,
default: null,
},
}); });
</script> </script>

View File

@@ -65,6 +65,7 @@
<Campaign <Campaign
v-if="campaigns?.meta" v-if="campaigns?.meta"
:campaign="campaigns.meta" :campaign="campaigns.meta"
class="campaign-meta"
/> />
<div class="views"> <div class="views">
@@ -155,6 +156,7 @@
<Campaign <Campaign
v-if="campaigns?.scope" v-if="campaigns?.scope"
:campaign="campaigns.scope" :campaign="campaigns.scope"
class="campaign-scope"
/> />
</nav> </nav>
@@ -165,7 +167,10 @@
v-if="item === 'campaign' && sceneCampaign" v-if="item === 'campaign' && sceneCampaign"
:key="`campaign-${item.id}`" :key="`campaign-${item.id}`"
> >
<Campaign :campaign="sceneCampaign" /> <Campaign
:campaign="sceneCampaign"
:backdrop="true"
/>
</li> </li>
<li <li
@@ -431,11 +436,11 @@ function setView(newView) {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: .5rem; gap: .5rem;
padding: .5rem 1rem 1rem 1rem; padding: .5rem 1rem 1rem 1rem;
}
:deep(.campaign) .campaign-banner { :deep(.campaign-meta) .campaign-banner,
border-radius: .25rem; :deep(.campaign-scope) .campaign-banner {
box-shadow: 0 0 3px var(--shadow-weak-20); width: auto;
}
} }
.scopes { .scopes {

View File

@@ -38,6 +38,7 @@
<Meta <Meta
:scene="scene" :scene="scene"
:user="user"
class="meta-full" class="meta-full"
/> />
@@ -129,7 +130,7 @@ const props = defineProps({
}); });
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const user = pageContext.user; const { user } = pageContext;
const pageStash = pageContext.pageProps.stash; const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash; const currentStash = pageStash || pageContext.assets?.primaryStash;

View File

@@ -1,11 +1,11 @@
<template> <template>
<div class="serie-tile"> <div class="serie-tile">
<a <a
v-if="serie.poster"
:href="`/serie/${serie.id}/${serie.slug}`" :href="`/serie/${serie.id}/${serie.slug}`"
class="poster-container" class="poster-container"
> >
<img <img
v-if="serie.poster"
:src="getPath(serie.poster, 'thumbnail')" :src="getPath(serie.poster, 'thumbnail')"
:style="{ 'background-image': `url(${getPath(serie.poster, 'lazy')})` }" :style="{ 'background-image': `url(${getPath(serie.poster, 'lazy')})` }"
class="poster" class="poster"

View File

@@ -41,6 +41,7 @@ const cookies = Cookies.withConverter({
const tags = { const tags = {
anal: 'anal', anal: 'anal',
'anal-prolapse': 'anal prolapse', 'anal-prolapse': 'anal prolapse',
'extreme-insertion': 'extreme insertion (oversized dildos)',
pissing: 'pissing', pissing: 'pissing',
gay: 'gay', gay: 'gay',
transsexual: 'transsexual', transsexual: 'transsexual',

View File

@@ -9,14 +9,13 @@
v-for="photo in photos" v-for="photo in photos"
:key="`photo-${photo.id}`" :key="`photo-${photo.id}`"
:title="photo.comment" :title="photo.comment"
:href="`/img/${photo.path}`"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="photo-container" class="photo-container"
> >
<img <img
:src="`/${photo.thumbnail}`" :src="getPath(photo, 'thumbnail', { local: true })"
:style="{ 'background-image': `url(/${photo.lazy})` }" :style="{ 'background-image': `url(${getPath(photo, 'lazy', { local: true })})` }"
:alt="photo.comment" :alt="photo.comment"
:width="photo.width" :width="photo.width"
:height="photo.height" :height="photo.height"
@@ -47,6 +46,8 @@ import { computed, inject } from 'vue';
import Logo from '#/components/tags/logo.vue'; import Logo from '#/components/tags/logo.vue';
import Campaign from '#/components/campaigns/campaign.vue'; import Campaign from '#/components/campaigns/campaign.vue';
import getPath from '#/src/get-path.js';
const props = defineProps({ const props = defineProps({
tag: { tag: {
type: Object, type: Object,

View File

@@ -58,11 +58,101 @@ module.exports = {
address: 'http://localhost:3000/script.js', address: 'http://localhost:3000/script.js',
siteId: '1b28ac3b-d229-43bf-aec9-75cf0a72a466', siteId: '1b28ac3b-d229-43bf-aec9-75cf0a72a466',
}, },
restrictions: {
enabled: false,
modes: [
null, // easier for 0 to mean disabled
'block', // 1
'censor', // 2
],
regions: {
// Europe
DE: 1, // Germany
FR: 1, // France
GB: 1, // Great Britain / United Kingdom
IT: 1, // Italy
// Asia & Oceania
AU: 1, // Australia
CN: 1, // China
// Americas
US: {
AL: 1, // Alabama
AR: 1, // Arkansas
AZ: 1, // Arizona
FL: 1, // Florida
GA: 1, // Georgia
ID: 1, // Idaho
IN: 1, // Indiana
KS: 1, // Kansas
KY: 1, // Kentucky
LA: 1, // Louisiana
MO: 1, // Missouri
MS: 1, // Mississippi
MT: 1, // Montana
NC: 1, // North Carolina
ND: 1, // North Dakota
NE: 1, // Nebraska
OH: 1, // Ohio
OK: 1, // Oklahoma
SC: 1, // South Carolina
SD: 1, // South Dakota
TN: 1, // Tennessee
TX: 1, // Texas
UT: 1, // Utah
VA: 1, // Virginia
WY: 1, // Wyoming
}, // only Florida
},
noVpn: [
'AE', // United Arab Emirates
'BY', // Belarus
'CN', // China
'IQ', // Iraq
'IR', // Iran
'KP', // North Korea
'OM', // Oman
'RU', // Russia
'TM', // Turkmenistan
'TR', // Turkey
],
censors: [ // additional to default filter
'ball',
'bisexual',
'blow',
'blowbang',
'condom',
'cowgirl',
'creampie',
'doggy',
'facial',
'finger',
'gay',
'hole',
'horny',
'lesbian',
'masturbation',
'milf',
'missionary',
'prolapse',
'nymph',
'sex',
'swallowing',
'squirt',
'sucking',
'threesome',
'trans',
],
},
auth: { auth: {
login: true, login: true,
signup: true, signup: true,
usernameLength: [2, 24], usernameLength: [2, 24],
usernamePattern: /^[a-zA-Z0-9_-]+$/, usernamePattern: /^[a-zA-Z0-9_-]+$/,
captcha: {
enabled: false,
siteKey: '10000000-ffff-ffff-ffff-000000000001',
secretKey: '0x0000000000000000000000000000000000000000',
},
}, },
bans: { bans: {
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
@@ -84,6 +174,7 @@ module.exports = {
links: { links: {
content: 'mailto:content@traxxx.me', content: 'mailto:content@traxxx.me',
discord: 'https://discord.gg/gY6fnq6jJV', discord: 'https://discord.gg/gY6fnq6jJV',
matrix: 'https://matrix.to/#/#traxxx:matrix.unknown.name',
}, },
stashes: { stashes: {
nameLength: [2, 24], nameLength: [2, 24],
@@ -96,8 +187,9 @@ module.exports = {
}, },
media: { media: {
path: './media', path: './media',
assetPath: '/img', assetPath: '',
mediaPath: '/media', mediaPath: '/media',
s3Path: 'https://s3.wasabisys.com', s3Path: 'https://s3.wasabisys.com',
videoRestrictions: [], // entity slugs, _ prefix for networks, hides trailer and teaser videos
}, },
}; };

View File

@@ -4,8 +4,9 @@ module.exports = {
apps: [ apps: [
{ {
name: 'traxxx', name: 'traxxx',
script: 'npm', // script: 'npm',
args: 'run server:prod', // args: 'run server:prod',
script: './src/app.js',
exec_mode: 'cluster', exec_mode: 'cluster',
instances: 2, instances: 2,
restart_delay: 3000, restart_delay: 3000,

242
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "traxxx-web", "name": "traxxx-web",
"version": "0.42.23", "version": "0.46.16",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"version": "0.42.23", "version": "0.46.16",
"dependencies": { "dependencies": {
"@brillout/json-serializer": "^0.5.8", "@brillout/json-serializer": "^0.5.8",
"@dicebear/collection": "^7.0.5", "@dicebear/collection": "^7.0.5",
@@ -13,6 +13,8 @@
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@floating-ui/dom": "^1.5.3", "@floating-ui/dom": "^1.5.3",
"@floating-ui/vue": "^1.0.2", "@floating-ui/vue": "^1.0.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@maxmind/geoip2-node": "^6.3.4",
"@resvg/resvg-js": "^2.6.0", "@resvg/resvg-js": "^2.6.0",
"@toycode/markdown-it-class": "^1.2.4", "@toycode/markdown-it-class": "^1.2.4",
"@vitejs/plugin-vue": "^4.5.2", "@vitejs/plugin-vue": "^4.5.2",
@@ -28,6 +30,7 @@
"cron": "^3.1.6", "cron": "^3.1.6",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"date-fns": "^3.0.0", "date-fns": "^3.0.0",
"ejs": "^4.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",
"express": "^4.18.2", "express": "^4.18.2",
@@ -38,6 +41,7 @@
"graphql": "^16.9.0", "graphql": "^16.9.0",
"graphql-parse-resolve-info": "^4.13.0", "graphql-parse-resolve-info": "^4.13.0",
"graphql-scalars": "^1.24.2", "graphql-scalars": "^1.24.2",
"hcaptcha": "^0.2.0",
"ip-cidr": "^4.0.0", "ip-cidr": "^4.0.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"knex": "^3.1.0", "knex": "^3.1.0",
@@ -50,6 +54,7 @@
"mysql": "^2.18.1", "mysql": "^2.18.1",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"object.omit": "^3.0.0", "object.omit": "^3.0.0",
"obscenity": "^0.4.6",
"path-to-regexp": "^6.2.1", "path-to-regexp": "^6.2.1",
"pg": "^8.11.3", "pg": "^8.11.3",
"redis": "^4.6.12", "redis": "^4.6.12",
@@ -3023,6 +3028,18 @@
} }
} }
}, },
"node_modules/@hcaptcha/vue3-hcaptcha": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@hcaptcha/vue3-hcaptcha/-/vue3-hcaptcha-1.3.0.tgz",
"integrity": "sha512-IEonS6JiYdU7uy6aeib8cYtMO4nj8utwStbA9bWHyYbOvOvhpkV+AW8vfSKh6SntYxqle/TRwhv+kU9p92CfsA==",
"license": "MIT",
"dependencies": {
"vue": "^3.2.19"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.13", "version": "0.11.13",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
@@ -3210,6 +3227,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@maxmind/geoip2-node": {
"version": "6.3.4",
"resolved": "https://registry.npmjs.org/@maxmind/geoip2-node/-/geoip2-node-6.3.4.tgz",
"integrity": "sha512-BTRFHCX7Uie4wVSPXsWQfg0EVl4eGZgLCts0BTKAP+Eiyt1zmF2UPyuUZkaj0R59XSDYO+84o1THAtaenUoQYg==",
"license": "Apache-2.0",
"dependencies": {
"maxmind": "^5.0.0"
}
},
"node_modules/@modyfi/vite-plugin-yaml": { "node_modules/@modyfi/vite-plugin-yaml": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@modyfi/vite-plugin-yaml/-/vite-plugin-yaml-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@modyfi/vite-plugin-yaml/-/vite-plugin-yaml-1.1.0.tgz",
@@ -4513,9 +4539,10 @@
"peer": true "peer": true
}, },
"node_modules/async": { "node_modules/async": {
"version": "3.2.5", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
}, },
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
@@ -5563,6 +5590,21 @@
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
}, },
"node_modules/ejs": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-4.0.1.tgz",
"integrity": "sha512-krvQtxc0btwSm/nvnt1UpnaFDFVJpJ0fdckmALpCgShsr/iGYHTnJiUliZTgmzq/UxTX33TtOQVKaNigMQp/6Q==",
"license": "Apache-2.0",
"dependencies": {
"jake": "^10.9.1"
},
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
"node": ">=0.12.18"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.4.616", "version": "1.4.616",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz",
@@ -6776,6 +6818,36 @@
"moment": "^2.29.1" "moment": "^2.29.1"
} }
}, },
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -7319,6 +7391,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hcaptcha": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/hcaptcha/-/hcaptcha-0.2.0.tgz",
"integrity": "sha512-x25z3RoEa9oqfyuQsgk2olc+LCNVDAJaGKUP1qFhpAybB6qjqOf4qB2y1E3LJpXDvM229JWEywc6iWnzWvGjNw==",
"license": "MIT"
},
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
@@ -8025,6 +8103,23 @@
"@pkgjs/parseargs": "^0.11.0" "@pkgjs/parseargs": "^0.11.0"
} }
}, },
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.6",
"filelist": "^1.0.4",
"picocolors": "^1.1.1"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/javascript-natural-sort": { "node_modules/javascript-natural-sort": {
"version": "0.7.1", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
@@ -8490,6 +8585,20 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/maxmind": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.5.tgz",
"integrity": "sha512-1lcH2kMjbBpCFhuHaMU32vz8CuOsKttRcWMQyXvtlklopCzN7NNHSVR/h9RYa8JPuFTGmkn2vYARm+7cIGuqDw==",
"license": "MIT",
"dependencies": {
"mmdb-lib": "3.0.2",
"tiny-lru": "11.4.7"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/mdurl": { "node_modules/mdurl": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
@@ -8633,6 +8742,16 @@
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
}, },
"node_modules/mmdb-lib": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.2.tgz",
"integrity": "sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==",
"license": "MIT",
"engines": {
"node": ">=10",
"npm": ">=6"
}
},
"node_modules/moment": { "node_modules/moment": {
"version": "2.30.1", "version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -8971,6 +9090,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/obscenity": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/obscenity/-/obscenity-0.4.6.tgz",
"integrity": "sha512-pHk7kNN7j3L3zGhhGnwxjvXIGsPpLrcZl2r58fqWh/V/rH6b/dafscj2sMmAY+A/9/wPsocLmimgGk2DKeKsFQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -10714,6 +10842,15 @@
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
}, },
"node_modules/tiny-lru": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.2.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.2.tgz",
@@ -13761,6 +13898,14 @@
} }
} }
}, },
"@hcaptcha/vue3-hcaptcha": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@hcaptcha/vue3-hcaptcha/-/vue3-hcaptcha-1.3.0.tgz",
"integrity": "sha512-IEonS6JiYdU7uy6aeib8cYtMO4nj8utwStbA9bWHyYbOvOvhpkV+AW8vfSKh6SntYxqle/TRwhv+kU9p92CfsA==",
"requires": {
"vue": "^3.2.19"
}
},
"@humanwhocodes/config-array": { "@humanwhocodes/config-array": {
"version": "0.11.13", "version": "0.11.13",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
@@ -13892,6 +14037,14 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"@maxmind/geoip2-node": {
"version": "6.3.4",
"resolved": "https://registry.npmjs.org/@maxmind/geoip2-node/-/geoip2-node-6.3.4.tgz",
"integrity": "sha512-BTRFHCX7Uie4wVSPXsWQfg0EVl4eGZgLCts0BTKAP+Eiyt1zmF2UPyuUZkaj0R59XSDYO+84o1THAtaenUoQYg==",
"requires": {
"maxmind": "^5.0.0"
}
},
"@modyfi/vite-plugin-yaml": { "@modyfi/vite-plugin-yaml": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@modyfi/vite-plugin-yaml/-/vite-plugin-yaml-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@modyfi/vite-plugin-yaml/-/vite-plugin-yaml-1.1.0.tgz",
@@ -14731,9 +14884,9 @@
"peer": true "peer": true
}, },
"async": { "async": {
"version": "3.2.5", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="
}, },
"asynckit": { "asynckit": {
"version": "0.4.0", "version": "0.4.0",
@@ -15490,6 +15643,14 @@
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
}, },
"ejs": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-4.0.1.tgz",
"integrity": "sha512-krvQtxc0btwSm/nvnt1UpnaFDFVJpJ0fdckmALpCgShsr/iGYHTnJiUliZTgmzq/UxTX33TtOQVKaNigMQp/6Q==",
"requires": {
"jake": "^10.9.1"
}
},
"electron-to-chromium": { "electron-to-chromium": {
"version": "1.4.616", "version": "1.4.616",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz",
@@ -16406,6 +16567,32 @@
"moment": "^2.29.1" "moment": "^2.29.1"
} }
}, },
"filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
"requires": {
"minimatch": "^5.0.1"
},
"dependencies": {
"brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"requires": {
"balanced-match": "^1.0.0"
}
},
"minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"requires": {
"brace-expansion": "^2.0.1"
}
}
}
},
"fill-range": { "fill-range": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -16771,6 +16958,11 @@
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
} }
}, },
"hcaptcha": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/hcaptcha/-/hcaptcha-0.2.0.tgz",
"integrity": "sha512-x25z3RoEa9oqfyuQsgk2olc+LCNVDAJaGKUP1qFhpAybB6qjqOf4qB2y1E3LJpXDvM229JWEywc6iWnzWvGjNw=="
},
"html-encoding-sniffer": { "html-encoding-sniffer": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
@@ -17244,6 +17436,16 @@
"@pkgjs/parseargs": "^0.11.0" "@pkgjs/parseargs": "^0.11.0"
} }
}, },
"jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"requires": {
"async": "^3.2.6",
"filelist": "^1.0.4",
"picocolors": "^1.1.1"
}
},
"javascript-natural-sort": { "javascript-natural-sort": {
"version": "0.7.1", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
@@ -17604,6 +17806,15 @@
"typed-function": "^4.1.1" "typed-function": "^4.1.1"
} }
}, },
"maxmind": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.5.tgz",
"integrity": "sha512-1lcH2kMjbBpCFhuHaMU32vz8CuOsKttRcWMQyXvtlklopCzN7NNHSVR/h9RYa8JPuFTGmkn2vYARm+7cIGuqDw==",
"requires": {
"mmdb-lib": "3.0.2",
"tiny-lru": "11.4.7"
}
},
"mdurl": { "mdurl": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
@@ -17705,6 +17916,11 @@
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
}, },
"mmdb-lib": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.2.tgz",
"integrity": "sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg=="
},
"moment": { "moment": {
"version": "2.30.1", "version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -17956,6 +18172,11 @@
"es-object-atoms": "^1.0.0" "es-object-atoms": "^1.0.0"
} }
}, },
"obscenity": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/obscenity/-/obscenity-0.4.6.tgz",
"integrity": "sha512-pHk7kNN7j3L3zGhhGnwxjvXIGsPpLrcZl2r58fqWh/V/rH6b/dafscj2sMmAY+A/9/wPsocLmimgGk2DKeKsFQ=="
},
"on-finished": { "on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -19194,6 +19415,11 @@
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
}, },
"tiny-lru": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw=="
},
"tmp": { "tmp": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.2.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.2.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"scripts": { "scripts": {
"dev": "npm run server:dev", "dev": "node ./src/app",
"prod": "npm run build && npm run server:prod", "prod": "npm run build && npm run server:prod",
"build": "vite build", "build": "vite build",
"server:dev": "node ./src/app", "server:dev": "node ./src/app",
@@ -13,6 +13,8 @@
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@floating-ui/dom": "^1.5.3", "@floating-ui/dom": "^1.5.3",
"@floating-ui/vue": "^1.0.2", "@floating-ui/vue": "^1.0.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@maxmind/geoip2-node": "^6.3.4",
"@resvg/resvg-js": "^2.6.0", "@resvg/resvg-js": "^2.6.0",
"@toycode/markdown-it-class": "^1.2.4", "@toycode/markdown-it-class": "^1.2.4",
"@vitejs/plugin-vue": "^4.5.2", "@vitejs/plugin-vue": "^4.5.2",
@@ -28,6 +30,7 @@
"cron": "^3.1.6", "cron": "^3.1.6",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"date-fns": "^3.0.0", "date-fns": "^3.0.0",
"ejs": "^4.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",
"express": "^4.18.2", "express": "^4.18.2",
@@ -38,6 +41,7 @@
"graphql": "^16.9.0", "graphql": "^16.9.0",
"graphql-parse-resolve-info": "^4.13.0", "graphql-parse-resolve-info": "^4.13.0",
"graphql-scalars": "^1.24.2", "graphql-scalars": "^1.24.2",
"hcaptcha": "^0.2.0",
"ip-cidr": "^4.0.0", "ip-cidr": "^4.0.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"knex": "^3.1.0", "knex": "^3.1.0",
@@ -50,6 +54,7 @@
"mysql": "^2.18.1", "mysql": "^2.18.1",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"object.omit": "^3.0.0", "object.omit": "^3.0.0",
"obscenity": "^0.4.6",
"path-to-regexp": "^6.2.1", "path-to-regexp": "^6.2.1",
"pg": "^8.11.3", "pg": "^8.11.3",
"redis": "^4.6.12", "redis": "^4.6.12",
@@ -87,7 +92,7 @@
"overrides": { "overrides": {
"vite": "$vite" "vite": "$vite"
}, },
"version": "0.42.23", "version": "0.46.16",
"imports": { "imports": {
"#/*": "./*.js" "#/*": "./*.js"
} }

View File

@@ -66,7 +66,16 @@
class="row" class="row"
> >
<div class="item-header"> <div class="item-header">
<div class="key">{{ item.label || item.key }}</div> <div class="key">
{{ item.label || item.key }}
<Icon
v-if="item.note"
v-tooltip="item.note"
icon="info2"
class="item-note"
/>
</div>
<div class="item-actions noselect"> <div class="item-actions noselect">
<Icon <Icon
@@ -443,6 +452,7 @@ const fields = computed(() => [
{ {
key: 'augmentation', key: 'augmentation',
type: 'augmentation', type: 'augmentation',
note: 'Provide explicit evidence, such as social media posts, visible scarring, or before/after. Avoid "it\'s obvious".',
value: { value: {
naturalBoobs: actor.value.naturalBoobs, naturalBoobs: actor.value.naturalBoobs,
boobsVolume: actor.value.boobsVolume, boobsVolume: actor.value.boobsVolume,
@@ -503,6 +513,7 @@ const fields = computed(() => [
{ {
key: 'piercings', key: 'piercings',
type: 'has', type: 'has',
note: 'Excludes earrings',
value: { value: {
has: actor.value.hasPiercings, has: actor.value.hasPiercings,
description: actor.value.piercings, description: actor.value.piercings,
@@ -685,10 +696,22 @@ async function submit() {
.key { .key {
width: 10rem; width: 10rem;
display: inline-flex;
align-items: center;
text-transform: capitalize; text-transform: capitalize;
font-weight: bold; font-weight: bold;
} }
.item-note{
fill: var(--glass);
padding: .5rem .75rem;
cursor: help;
&:hover {
fill: var(--primary);
}
}
.input { .input {
background: var(--background); background: var(--background);
} }

View File

@@ -70,7 +70,10 @@
</div> </div>
</div> </div>
<button class="button button-submit">Log in</button> <button
class="button button-submit"
:disabled="submitted"
>Log in</button>
<a <a
v-if="allowSignup" v-if="allowSignup"
@@ -94,11 +97,13 @@ const allowSignup = pageContext.env.allowSignup;
const username = ref(''); const username = ref('');
const password = ref(''); const password = ref('');
const submitted = ref(false);
const errorMsg = ref(null); const errorMsg = ref(null);
const userInput = ref(null); const userInput = ref(null);
const showPassword = ref(false); const showPassword = ref(false);
async function login() { async function login() {
submitted.value = true;
errorMsg.value = null; errorMsg.value = null;
try { try {
@@ -111,6 +116,8 @@ async function login() {
navigate(pageContext.urlParsed.search.r ? decodeURIComponent(pageContext.urlParsed.search.r) : `/user/${loginUser.username}`, null, { redirect: true }); navigate(pageContext.urlParsed.search.r ? decodeURIComponent(pageContext.urlParsed.search.r) : `/user/${loginUser.username}`, null, { redirect: true });
} catch (error) { } catch (error) {
errorMsg.value = error.message; errorMsg.value = error.message;
} finally {
submitted.value = false;
} }
} }

View File

@@ -112,7 +112,18 @@
</div> </div>
</div> </div>
<button class="button button-submit">Sign up</button> <VueHCaptcha
v-if="env.captcha.enabled"
:sitekey="env.captcha.siteKey"
class="captcha"
@verify="(verification) => captcha = verification"
@expired="captcha = null"
/>
<button
class="button button-submit"
:disabled="submitted"
>Sign up</button>
<a <a
href="/login" href="/login"
@@ -124,12 +135,13 @@
<script setup> <script setup>
import { ref, onMounted, inject } from 'vue'; import { ref, onMounted, inject } from 'vue';
import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
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, env } = pageContext;
const username = ref(''); const username = ref('');
const email = ref(''); const email = ref('');
@@ -137,28 +149,39 @@ const password = ref('');
const passwordConfirm = ref(''); const passwordConfirm = ref('');
const errorMsg = ref(null); const errorMsg = ref(null);
const submitted = ref(false);
const userInput = ref(null); const userInput = ref(null);
const showPassword = ref(false); const showPassword = ref(false);
const captcha = ref(null);
async function signup() { async function signup() {
errorMsg.value = null; errorMsg.value = null;
submitted.value = true;
if (password.value !== passwordConfirm.value) { if (password.value !== passwordConfirm.value) {
errorMsg.value = 'Passwords do not match'; errorMsg.value = 'Passwords do not match';
return; return;
} }
if (env.captcha.enabled && !captcha.value) {
errorMsg.value = 'Please complete the CAPTCHA';
return;
}
try { try {
const newUser = await post('/users', { const newUser = await post('/users', {
username: username.value, username: username.value,
email: email.value, email: email.value,
password: password.value, password: password.value,
redirect: pageContext.urlParsed.search.r, redirect: pageContext.urlParsed.search.r,
captcha: captcha.value,
}); });
navigate(`/user/${newUser.username}`, null, { redirect: true }); navigate(`/user/${newUser.username}`, null, { redirect: true });
} catch (error) { } catch (error) {
errorMsg.value = error.message; errorMsg.value = error.message;
} finally {
submitted.value = false;
} }
} }
@@ -229,6 +252,16 @@ onMounted(() => {
} }
} }
.captcha {
display: flex;
justify-content: center;
margin-top: .5rem;
}
.button-submit {
margin-top: .5rem;
}
.error { .error {
background: var(--error); background: var(--error);
color: var(--text-light); color: var(--text-light);

View File

@@ -87,6 +87,7 @@ const popularNetworks = [
'elegantangel', 'elegantangel',
'evilangel', 'evilangel',
'fakehub', 'fakehub',
'hentaied',
'hookuphotshot', 'hookuphotshot',
'hussiepass', 'hussiepass',
'julesjordan', 'julesjordan',

View File

@@ -3,7 +3,9 @@ import { fetchEntities } from '#/src/entities.js';
export async function onBeforeRender(pageContext) { 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,
});
return { return {
pageContext: { pageContext: {

View File

@@ -43,6 +43,14 @@
>{{ entity.name }}</h2> >{{ entity.name }}</h2>
</a> </a>
<a
v-if="user?.abilities.some((ability) => ability.plainUrls)"
:href="entity.url"
target="_blank"
rel="noopener"
class="plainurl"
><Icon icon="link" /></a>
<Heart <Heart
domain="entities" domain="entities"
:item="entity" :item="entity"
@@ -154,7 +162,7 @@ import Movies from '#/components/movies/movies.vue';
import Domains from '#/components/domains/domains.vue'; import Domains from '#/components/domains/domains.vue';
import Heart from '#/components/stashes/heart.vue'; import Heart from '#/components/stashes/heart.vue';
const { pageProps, routeParams } = inject('pageContext'); const { pageProps, routeParams, user } = inject('pageContext');
const { entity } = pageProps; const { entity } = pageProps;
const children = ref(null); const children = ref(null);
@@ -163,13 +171,7 @@ const expanded = ref(false);
const scrollable = computed(() => children.value?.scrollWidth > children.value?.clientWidth); const scrollable = computed(() => children.value?.scrollWidth > children.value?.clientWidth);
const domain = routeParams.domain; const domain = routeParams.domain;
const entityUrl = (() => { const entityUrl = entity.affiliateUrl || entity.url || null;
if (!entity.url) {
return null;
}
return entity.affiliateUrl || entity.url;
})();
</script> </script>
<style scoped> <style scoped>
@@ -317,6 +319,25 @@ const entityUrl = (() => {
display: none; display: none;
} }
.plainurl {
height: 100%;
display: flex;
align-items: center;
padding: 0 .25rem;
margin-top: -.25rem;
.icon {
width: 1rem;
height: auto;
padding: .5rem;
fill: var(--highlight);
}
&:hover .icon {
fill: var(--primary);
}
}
@media(--small-20) { @media(--small-20) {
.logo { .logo {
padding: .5rem 1rem; padding: .5rem 1rem;

View File

@@ -20,7 +20,9 @@ async function fetchReleases(pageContext, entityId) {
page: Number(pageContext.routeParams.page) || 1, page: Number(pageContext.routeParams.page) || 1,
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,
});
} }
return fetchScenes(await curateScenesQuery({ return fetchScenes(await curateScenesQuery({
@@ -32,7 +34,9 @@ async function fetchReleases(pageContext, entityId) {
page: Number(pageContext.routeParams.page) || 1, page: Number(pageContext.routeParams.page) || 1,
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,
});
} }
export async function onBeforeRender(pageContext) { export async function onBeforeRender(pageContext) {
@@ -47,7 +51,9 @@ export async function onBeforeRender(pageContext) {
[entity], [entity],
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,
}),
fetchReleases(pageContext, entityId), fetchReleases(pageContext, entityId),
]); ]);

View File

@@ -9,7 +9,9 @@ export async function onBeforeRender(pageContext) {
page: Number(pageContext.routeParams.page) || 1, page: Number(pageContext.routeParams.page) || 1,
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,
});
return { return {
pageContext: { pageContext: {

View File

@@ -18,7 +18,7 @@ function getTitle(movie) {
export async function onBeforeRender(pageContext) { export async function onBeforeRender(pageContext) {
const [[movie], movieScenes] = await Promise.all([ const [[movie], movieScenes] = await Promise.all([
fetchMoviesById([Number(pageContext.routeParams.movieId)], pageContext.user), fetchMoviesById([Number(pageContext.routeParams.movieId)], pageContext.user, { restriction: pageContext.restriction }),
fetchScenes(await curateScenesQuery({ fetchScenes(await curateScenesQuery({
...pageContext.urlQuery, ...pageContext.urlQuery,
scope: 'oldest', scope: 'oldest',
@@ -27,7 +27,9 @@ export async function onBeforeRender(pageContext) {
page: Number(pageContext.routeParams.page) || 1, page: Number(pageContext.routeParams.page) || 1,
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,
}),
]); ]);
if (!movie) { if (!movie) {

View File

@@ -52,7 +52,7 @@
</div> </div>
<Link <Link
:href="scene.watchUrl" :href="user?.abilities?.some((ability) => ability.plainUrls) ? scene.url : scene.watchUrl"
:title="scene.date ? formatDate(scene.date.toISOString(), 'y-MM-dd hh:mm') : `Release date unknown, added ${formatDate(scene.createdAt, 'y-MM-dd')}`" :title="scene.date ? formatDate(scene.date.toISOString(), 'y-MM-dd hh:mm') : `Release date unknown, added ${formatDate(scene.createdAt, 'y-MM-dd')}`"
target="_blank" target="_blank"
class="date nolink" class="date nolink"
@@ -249,12 +249,6 @@
</div> </div>
</div> </div>
<Chapters
v-if="scene.chapters.length > 0"
:chapters="scene.chapters"
class="section"
/>
<div <div
v-if="scene.description" v-if="scene.description"
class="section" class="section"
@@ -264,6 +258,18 @@
<p class="description">{{ scene.description }}</p> <p class="description">{{ scene.description }}</p>
</div> </div>
<section
v-if="scene.chapters.length > 0"
class="section"
>
<h3 class="heading">Chapters</h3>
<Chapters
:chapters="scene.chapters"
class="section"
/>
</section>
<div <div
v-if="campaigns?.scene" v-if="campaigns?.scene"
class="section" class="section"
@@ -343,6 +349,43 @@
</ul> </ul>
</div> </div>
<div
v-if="user && scene.fingerprints.length > 0"
class="section fingerprints"
>
<h3 class="heading">Fingerprints</h3>
<div class="fingerprints-container">
<table class="fingerprints-table">
<thead class="fingerprints-head">
<tr class="fingerprints-header">
<th class="fingerprints-heading">Hash</th>
<th class="fingerprints-heading">Type</th>
<th class="fingerprints-heading">Duration</th>
<th class="fingerprints-heading">Submissions</th>
<th class="fingerprints-heading">Source</th>
<th class="fingerprints-heading">First added</th>
</tr>
</thead>
<tbody class="fingerprints-body">
<tr
v-for="fingerprint in scene.fingerprints"
:key="`fingerprint-${fingerprint.hash}`"
class="fingerprint"
>
<td class="fingerprint-field fingerprint-hash">{{ fingerprint.hash }}</td>
<td class="fingerprint-field fingerprint-type">{{ fingerprint.type.toUpperCase() }}</td>
<td class="fingerprint-field fingerprint-duration">{{ formatDuration(fingerprint.duration) }}</td>
<td class="fingerprint-field fingerprint-submission">{{ fingerprint.submissions }}</td>
<td class="fingerprint-field fingerprint-source">{{ fingerprint.source || 'traxxx' }}</td>
<td class="fingerprint-field fingerprint-date">{{ formatDate(fingerprint.createdAt, 'yyyy-MM-dd') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div <div
class="scene-actions section" class="scene-actions section"
> >
@@ -740,6 +783,55 @@ function copySummary() {
display: none; display: none;
} }
.fingerprints {
margin-top: 1.5rem;
}
.fingerprints-container {
max-height: 10rem;
overflow-y: auto;
resize: vertical;
&[style*="height"] {
max-height: unset;
}
}
.fingerprints-table {
width: 100%;
}
.fingerprints-head {
background: var(--background-base-10);
position: sticky;
top: 0;
}
.fingerprints-heading {
color: var(--glass);
font-weight: normal;
padding: .25rem;
text-align: left;
}
.fingerprint {
&:nth-child(2n + 1) {
background: var(--glass-weak-50);
}
&:hover {
background: var(--glass-weak-40);
}
}
.fingerprint-field {
padding: .25rem;
}
.fingerprint-hash {
user-select: all;
}
@media(--compact) { @media(--compact) {
.content { .content {
margin: 0; margin: 0;

View File

@@ -25,6 +25,7 @@ export async function onBeforeRender(pageContext) {
includeAssets: true, includeAssets: true,
includePartOf: true, includePartOf: true,
actorStashes: true, actorStashes: true,
restriction: pageContext.restriction,
}); });
const [campaigns, tagIds] = await Promise.all([ const [campaigns, tagIds] = await Promise.all([

View File

@@ -66,7 +66,16 @@
class="row" class="row"
> >
<div class="item-header"> <div class="item-header">
<div class="key">{{ item.label || item.key }}</div> <div class="key">
{{ item.label || item.key }}
<Icon
v-if="item.note"
v-tooltip="item.note"
icon="info2"
class="item-note"
/>
</div>
<div class="item-actions"> <div class="item-actions">
<Icon <Icon
@@ -262,11 +271,13 @@ const fields = computed(() => [
key: 'title', key: 'title',
type: 'string', type: 'string',
value: scene.value.title, value: scene.value.title,
note: 'Do not correct language errors unless source was updated.',
}, },
{ {
key: 'description', key: 'description',
type: 'text', type: 'text',
value: scene.value.description, value: scene.value.description,
note: 'Do not correct language errors unless source was updated.',
}, },
{ {
key: 'date', key: 'date',
@@ -408,6 +419,8 @@ async function submit() {
.key { .key {
width: 8rem; width: 8rem;
display: inline-flex;
align-items: center;
text-transform: capitalize; text-transform: capitalize;
font-weight: bold; font-weight: bold;
} }
@@ -473,6 +486,16 @@ async function submit() {
} }
} }
.item-note{
fill: var(--glass);
padding: .5rem .75rem;
cursor: help;
&:hover {
fill: var(--primary);
}
}
.editor-footer { .editor-footer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

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

View File

@@ -16,22 +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), }, pageContext.user, { restriction: pageContext.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), }, pageContext.user, { restriction: pageContext.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), }, pageContext.user, { restriction: pageContext.restriction }),
fetchEntities({ fetchEntities({
query: pageContext.urlParsed.search.q, query: pageContext.urlParsed.search.q,
limit: 5, limit: 5,
}, {
restriction: pageContext.restriction,
}), }),
]); ]);

View File

@@ -103,9 +103,22 @@
</div> </div>
<time <time
:datetime="serie.date.toISOString()" :datetime="serie.effectiveDate.toISOString()"
class="date ellipsis" class="date ellipsis compact-hide"
>{{ formatDate(serie.date, 'MMMM d, y') }}</time> :class="{ nodate: !serie.date }"
>{{ formatDate(serie.effectiveDate, {
month: 'MMMM y',
year: 'y',
}[serie.datePrecision] || 'MMMM d, y') }}</time>
<time
:datetime="serie.effectiveDate.toISOString()"
class="date ellipsis compact-show"
:class="{ nodate: !serie.date }"
>{{ formatDate(serie.effectiveDate, {
month: 'MMM y',
year: 'y',
}[serie.datePrecision] || 'MMM d, y') }}</time>
</div> </div>
<div class="header"> <div class="header">
@@ -408,6 +421,11 @@ const scenes = pageContext.pageProps.scenes;
font-weight: bold; font-weight: bold;
} }
.nodate {
color: var(--highlight);
font-weight: normal;
}
.info, .info,
.header { .header {
border-top: none; border-top: none;
@@ -537,6 +555,10 @@ const scenes = pageContext.pageProps.scenes;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.compact-show {
display: none;
}
@media(--small) { @media(--small) {
.content { .content {
margin: 0; margin: 0;
@@ -621,5 +643,13 @@ const scenes = pageContext.pageProps.scenes;
.actors { .actors {
grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
} }
.compact-show {
display: flex;
}
.compact-hide {
display: none;
}
} }
</style> </style>

View File

@@ -75,8 +75,8 @@
> >
<img <img
v-if="tag.poster" v-if="tag.poster"
:src="`/${tag.poster.thumbnail}`" :src="getPath(tag.poster, 'thumbnail', { local: true })"
:style="{ 'background-image': `url(/${tag.poster.lazy})` }" :style="{ 'background-image': `url(${getPath(tag.poster, 'lazy', { local: true })})` }"
:title="tag.poster.comment" :title="tag.poster.comment"
class="thumb" class="thumb"
loading="lazy" loading="lazy"
@@ -111,6 +111,7 @@ import { ref, onMounted, inject } from 'vue';
import navigate from '#/src/navigate.js'; import navigate from '#/src/navigate.js';
import events from '#/src/events.js'; import events from '#/src/events.js';
import getPath from '#/src/get-path.js';
import Logo from '#/components/tags/logo.vue'; import Logo from '#/components/tags/logo.vue';
@@ -324,6 +325,7 @@ onMounted(() => {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
aspect-ratio: 5/3;
border-radius: .25rem; border-radius: .25rem;
background-size: cover; background-size: cover;
background-position: center; background-position: center;

View File

@@ -1,4 +1,5 @@
import { fetchTags, fetchTagsById } from '#/src/tags.js'; import { fetchTags, fetchTagsById } from '#/src/tags.js';
import { censor } from '#/src/censor.js';
const tagSlugs = { const tagSlugs = {
popular: [ popular: [
@@ -117,7 +118,7 @@ const tagSlugs = {
}; };
async function searchTags(pageContext) { async function searchTags(pageContext) {
const tags = await fetchTags({ query: pageContext.urlParsed.search.q }); const tags = await fetchTags({ query: pageContext.urlParsed.search.q }, { restriction: pageContext.restriction });
return { return {
pageContext: { pageContext: {
@@ -136,13 +137,13 @@ export async function onBeforeRender(pageContext) {
return searchTags(pageContext); return searchTags(pageContext);
} }
const tags = await fetchTagsById(Object.values(tagSlugs).flat()); const tags = await fetchTagsById(Object.values(tagSlugs).flat(), {}, pageContext.user, { restriction: pageContext.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]) => [
category, censor(category, pageContext.restriction),
categorySlugs.map((slug) => tagsBySlug[slug]).filter(Boolean), categorySlugs.map((slug) => tagsBySlug[slug]).filter(Boolean),
])); ]));

View File

@@ -21,7 +21,9 @@ async function fetchReleases(pageContext) {
page: Number(pageContext.routeParams.page) || 1, page: Number(pageContext.routeParams.page) || 1,
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,
});
} }
return fetchScenes(await curateScenesQuery({ return fetchScenes(await curateScenesQuery({
@@ -33,14 +35,16 @@ async function fetchReleases(pageContext) {
page: Number(pageContext.routeParams.page) || 1, page: Number(pageContext.routeParams.page) || 1,
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,
});
} }
export async function onBeforeRender(pageContext) { export async function onBeforeRender(pageContext) {
const tagSlug = pageContext.routeParams.tagSlug; const tagSlug = pageContext.routeParams.tagSlug;
const [[tag], tagReleases, campaigns] = await Promise.all([ const [[tag], tagReleases, campaigns] = await Promise.all([
fetchTagsById([tagSlug], {}, pageContext.user), fetchTagsById([tagSlug], {}, pageContext.user, { restriction: pageContext.restriction }),
fetchReleases(pageContext), fetchReleases(pageContext),
getRandomCampaigns([ getRandomCampaigns([
{ tagSlugs: [tagSlug], minRatio: 0.75, maxRatio: 1.25 }, { tagSlugs: [tagSlug], minRatio: 0.75, maxRatio: 1.25 },

View File

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

View File

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

View File

@@ -122,7 +122,10 @@ export function curateActor(actor, context = {}) {
state: actor.residence_state, state: actor.residence_state,
}, },
agency: actor.agency, agency: actor.agency,
avatar: curateMedia(actor.avatar), avatar: actor.avatar && curateMedia({
...actor.avatar,
sfw_media: actor.sfw_avatar,
}),
socials: context.socials?.map((social) => ({ socials: context.socials?.map((social) => ({
id: social.id, id: social.id,
url: social.url, url: social.url,
@@ -214,18 +217,21 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
'residence_countries.alpha2 as residence_country_alpha2', 'residence_countries.alpha2 as residence_country_alpha2',
knex.raw('COALESCE(residence_countries.alias, residence_countries.name) as residence_country_name'), knex.raw('COALESCE(residence_countries.alias, residence_countries.name) as residence_country_name'),
knex.raw('row_to_json(entities) as entity'), knex.raw('row_to_json(entities) as entity'),
knex.raw('row_to_json(sfw_media) as sfw_avatar'),
) )
.leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id') .leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id')
.leftJoin('countries as birth_countries', 'birth_countries.alpha2', 'actors.birth_country_alpha2') .leftJoin('countries as birth_countries', 'birth_countries.alpha2', 'actors.birth_country_alpha2')
.leftJoin('countries as residence_countries', 'residence_countries.alpha2', 'actors.residence_country_alpha2') .leftJoin('countries as residence_countries', 'residence_countries.alpha2', 'actors.residence_country_alpha2')
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id') .leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
.leftJoin('media as sfw_media', 'sfw_media.id', 'avatars.sfw_media_id')
.leftJoin('entities', 'entities.id', 'actors.entity_id') .leftJoin('entities', 'entities.id', 'actors.entity_id')
.whereIn('actors.id', actorIds) .whereIn('actors.id', actorIds)
.modify((builder) => { .modify((builder) => {
if (options.order) { if (options.order) {
builder.orderBy(...options.order); builder.orderBy(...options.order);
} }
}), })
.groupBy('actors.id', 'avatars.id', 'sfw_media.id', 'entities.id', 'actors_meta.stashed', 'birth_countries.alpha2', 'residence_countries.alpha2'),
knex('actors_profiles') knex('actors_profiles')
.select( .select(
'actors_profiles.*', 'actors_profiles.*',
@@ -245,10 +251,12 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
'media.*', 'media.*',
'actors_avatars.actor_id', 'actors_avatars.actor_id',
knex.raw('json_agg(actors_avatars.profile_id) as profile_ids'), knex.raw('json_agg(actors_avatars.profile_id) as profile_ids'),
knex.raw('row_to_json(sfw_media) as sfw_media'),
) )
.whereIn('actor_id', actorIds) .whereIn('actor_id', actorIds)
.leftJoin('media', 'media.id', 'actors_avatars.media_id') .leftJoin('media', 'media.id', 'actors_avatars.media_id')
.groupBy('media.id', 'actors_avatars.actor_id') .leftJoin('media as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
.groupBy('media.id', 'sfw_media.id', 'actors_avatars.actor_id')
.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),

View File

@@ -1,14 +1,20 @@
import format from 'template-format';
function getWatchUrl(scene) { function getWatchUrl(scene) {
if (scene.url) { try {
return scene.url; if (scene.url) {
} return new URL(scene.url).href;
}
if (scene.channel && (scene.channel.isIndependent || scene.channel.type === 'network')) { if (scene.channel && (scene.channel.isIndependent || scene.channel.type === 'network')) {
return scene.channel.url; return new URL(scene.channel.url).href;
} }
if (scene.network) { if (scene.network) {
return scene.network.url; return new URL(scene.network.url).href;
}
} catch (_error) {
// invalid URL
} }
return null; return null;
@@ -21,16 +27,19 @@ export function getAffiliateSceneUrl(scene) {
return null; return null;
} }
if (!scene.affiliate) { if (!scene.affiliate || scene.affiliate.parameters.scene === false) {
return watchUrl; return watchUrl;
} }
if (scene.affiliate.url?.includes('/track') if (scene.affiliate.parameters.dynamicScene) {
&& scene.affiliate.parameters.scene !== false const scenePath = new URL(watchUrl).pathname;
&& (!scene.channel.isIndependent || scene.channel.id === scene.affiliate.entityId)) { // standard NATS redirect
const { pathname, search } = new URL(watchUrl);
return `${scene.affiliate.url}${pathname.replace(/^\/trial/, '')}${search}`; // replace needed for Jules Jordan, verify behavior on other sites return format(scene.affiliate.parameters.dynamicScene, {
scenePath: scene.affiliate.parameters.prefixSlash === false
? scenePath.replace(/^\//, '')
: scenePath,
entryId: scene.entryId,
});
} }
if (scene.affiliate.parameters.query) { // used by e.g. Bang if (scene.affiliate.parameters.query) { // used by e.g. Bang
@@ -42,41 +51,98 @@ export function getAffiliateSceneUrl(scene) {
return `${watchUrl}?${newParams.toString()}`; return `${watchUrl}?${newParams.toString()}`;
} }
const affiliateUrl = scene.affiliate.parameters.replaceScene?.hostname === new URL(watchUrl).hostname
? scene.affiliate.parameters.replaceScene.url
: scene.affiliate.url;
if (!affiliateUrl) {
return watchUrl;
}
// NATS deep URL
if (affiliateUrl.includes('/track')
&& scene.affiliate.parameters.scene !== false
&& (!scene.channel.isIndependent || scene.channel.id === scene.affiliate.entityId)) {
const { pathname, search } = new URL(watchUrl);
return `${affiliateUrl}${pathname.replace(/^\/trial/, '')}${search}`; // replace needed for Jules Jordan, verify behavior on other sites
}
const affiliateUrlComponents = new URL(affiliateUrl);
// NetFame / GammaE deep URL
if (affiliateUrlComponents.searchParams.has('pa') && affiliateUrlComponents.searchParams.has('ar')) {
affiliateUrlComponents.searchParams.set('pa', 'clip');
affiliateUrlComponents.searchParams.set('ar', scene.entryId);
return affiliateUrlComponents.href;
}
return watchUrl; return watchUrl;
} }
export function getAffiliateEntityUrl(entity) { function getEntityUrl(entity) {
if (!entity.affiliate) { try {
return entity.url; return new URL(entity.url || entity.parent?.url).href;
} catch (_error) {
return null;
}
}
export function getAffiliateEntityUrl(entity, affiliate) {
const entityUrl = getEntityUrl(entity);
const entityAffiliate = affiliate || entity.affiliate;
if (!entityUrl) {
return null;
} }
if (entity.id === entity.affiliate.entityId) { if (!entityAffiliate) {
return entity.affiliate.url; return entityUrl;
}
const affiliateUrl = entityAffiliate.parameters?.replaceEntity?.hostname === new URL(entityUrl).hostname
? entityAffiliate.parameters.replaceEntity.url
: entityAffiliate.url;
if (affiliateUrl && (entity.id === entityAffiliate.entityId || entityUrl === entity.parent?.url)) {
return affiliateUrl;
}
if (entityAffiliate.parameters?.query) {
const newParams = new URLSearchParams({
...Object.fromEntries(new URL(entityUrl).searchParams),
...Object.fromEntries(new URLSearchParams(entityAffiliate.parameters.query)),
});
return `${entityUrl}?${newParams.toString()}`;
} }
if (entity.type === 'network' || entity.isIndependent) { if (entity.type === 'network' || entity.isIndependent) {
return entity.url; return entityUrl;
} }
if (new URL(entity.url).hostname !== new URL(entity.affiliate.url).hostname) { // channel has its own domain
return entity.url; if (new URL(entityUrl).pathname === '/' && entityUrl !== entity.parent?.url) {
return entityUrl;
} }
if (entity.affiliate.url?.includes('/track') if (entityAffiliate.parameters.dynamicEntity) {
&& entity.affiliate.parameters.channel !== false) { const entityPath = new URL(entityUrl).pathname;
const { pathname, search } = new URL(entity.url);
return `${entity.affiliate.url}${pathname.replace(/^\/trial/, '')}${search}`; // replace needed for Jules Jordan, verify behavior on other sites return format(entityAffiliate.parameters.dynamicEntity, {
} entityPath: entityAffiliate.parameters.prefixSlash
? entityPath
if (entity.affiliate.parameters.query) { // used by e.g. Bang : entityPath.replace(/^\//, ''),
const newParams = new URLSearchParams({
...Object.fromEntries(new URL(entity.url).searchParams),
...Object.fromEntries(new URLSearchParams(entity.affiliate.parameters.query)),
}); });
return `${entity.url}?${newParams.toString()}`;
} }
return entity.url; if (affiliateUrl?.includes('/track')
&& entityAffiliate.parameters.channel !== false) {
const { pathname, search } = new URL(entityUrl);
return `${affiliateUrl}${pathname.replace(/^\/trial/, '')}${search}`; // replace needed for Jules Jordan, verify behavior on other sites
}
return entityUrl;
} }

View File

@@ -1,11 +1,15 @@
import yargs from 'yargs'; import yargs from 'yargs';
const { argv } = yargs() const { argv } = yargs(process.argv.slice(2))
.command('npm start')
.option('debug', { .option('debug', {
describe: 'Show error stack traces', describe: 'Show error stack traces and inputs',
type: 'boolean', type: 'boolean',
default: process.env.NODE_ENV === 'development', default: process.env.NODE_ENV === 'development',
})
.option('ip', {
describe: 'Mock IP address',
type: 'string',
default: null,
}); });
export default argv; export default argv;

View File

@@ -5,6 +5,7 @@ import fs from 'fs/promises';
import { createAvatar } from '@dicebear/core'; import { createAvatar } from '@dicebear/core';
import { shapes } from '@dicebear/collection'; import { shapes } from '@dicebear/collection';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { verify } from 'hcaptcha';
import { knexOwner as knex } from './knex.js'; import { knexOwner as knex } from './knex.js';
import redis from './redis.js'; import redis from './redis.js';
@@ -105,6 +106,15 @@ export async function signup(credentials, userIp) {
throw new HttpError('Password must be 3 characters or longer', 400); throw new HttpError('Password must be 3 characters or longer', 400);
} }
if (config.auth.captcha.enabled) {
const captchaVerification = await verify(config.auth.captcha.secretKey, credentials.captcha);
if (!captchaVerification.success) {
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${credentials.email}, ${userIp})`);
throw new HttpError('Invalid CAPTCHA', 400);
}
}
const existingUser = await knex('users') const existingUser = await knex('users')
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase()) .where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
.orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase()) .orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase())
@@ -134,7 +144,7 @@ export async function signup(credentials, userIp) {
primary: true, primary: true,
}); });
logger.verbose(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`); logger.info(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`);
await generateAvatar({ await generateAvatar({
id: userId, id: userId,

View File

@@ -4,18 +4,41 @@ import { knexOwner as knex } from './knex.js';
import { curateEntity } from './entities.js'; import { curateEntity } from './entities.js';
import redis from './redis.js'; import redis from './redis.js';
import initLogger from './logger.js'; import initLogger from './logger.js';
import { getAffiliateEntityUrl } from './affiliates.js';
const logger = initLogger(); const logger = initLogger();
function getCampaignUrl(campaign, entity) {
if (!campaign) {
return null;
}
if (campaign.url) {
return campaign.url;
}
if (campaign.affiliate?.url) {
return campaign.affiliate.url;
}
if (campaign.entity) {
// resolve e.g. parameter tracking
return getAffiliateEntityUrl(entity, campaign.affiliate);
}
return null;
}
function curateCampaign(campaign) { function curateCampaign(campaign) {
if (!campaign) { if (!campaign) {
return null; return null;
} }
return { const entity = campaign.entity && curateEntity({ ...campaign.entity, parent: campaign.parent_entity });
const curatedCampaign = {
id: campaign.id, id: campaign.id,
url: campaign.url, entity,
entity: campaign.entity && curateEntity({ ...campaign.entity, parent: campaign.parent_entity }),
banner: campaign.banner && { banner: campaign.banner && {
id: campaign.banner.id, id: campaign.banner.id,
type: campaign.banner.type, type: campaign.banner.type,
@@ -31,6 +54,10 @@ function curateCampaign(campaign) {
parameters: campaign.affiliate.parameters, parameters: campaign.affiliate.parameters,
}, },
}; };
curatedCampaign.url = getCampaignUrl(campaign, entity);
return curatedCampaign;
} }
function selectRandomCampaign(primaryCampaigns, entityCampaigns, preferredCampaigns) { function selectRandomCampaign(primaryCampaigns, entityCampaigns, preferredCampaigns) {
@@ -55,18 +82,27 @@ export async function getRandomCampaign(options = {}, context = {}, pass = 0) {
const validCampaigns = campaigns.filter((campaign) => { const validCampaigns = campaigns.filter((campaign) => {
if (options.minRatio && (!campaign.banner || campaign.banner.ratio < options.minRatio)) { if (options.minRatio && (!campaign.banner || campaign.banner.ratio < options.minRatio)) {
// too small
return false; return false;
} }
if (options.maxRatio && (!campaign.banner || campaign.banner.ratio > options.maxRatio)) { if (options.maxRatio && (!campaign.banner || campaign.banner.ratio > options.maxRatio)) {
// too big
return false; return false;
} }
if (options.entityIds && !options.entityIds.some((entityId) => campaign.entity.id === entityId || campaign.entity.parent?.id === entityId)) { if (options.entityIds && !options.entityIds.some((entityId) => campaign.entity.id === entityId || campaign.entity.parent?.id === entityId)) {
// this is an entity page, this campaign does not belong to this entity
return false;
}
if (campaign.affiliate?.parameters?.global === false && !options.entityIds) {
// this campaign should only show on entity page
return false; return false;
} }
if (context.tagFilter && campaign.banner && campaign.banner.tags.some((tag) => context.tagFilter.includes(tag) && !options.tagSlugs?.includes(tag))) { if (context.tagFilter && campaign.banner && campaign.banner.tags.some((tag) => context.tagFilter.includes(tag) && !options.tagSlugs?.includes(tag))) {
// wrong tag
return false; return false;
} }

55
src/censor.js Normal file
View File

@@ -0,0 +1,55 @@
import config from 'config';
import {
TextCensor,
RegExpMatcher,
englishDataset,
englishRecommendedTransformers,
DataSet,
pattern,
// asteriskCensorStrategy,
} from 'obscenity';
const textCensor = new TextCensor();
// built-in asterisk strategy replaces entire word
textCensor.setStrategy(({
input,
startIndex,
endIndex,
matchLength,
}) => {
if (matchLength <= 2) {
return '*'.repeat(matchLength);
}
return `${input.at(startIndex)}${'*'.repeat(matchLength - 2)}${input.at(endIndex)}`;
});
const dataset = new DataSet().addAll(englishDataset);
config.restrictions.censors.forEach((word) => {
dataset.addPhrase((phrase) => phrase
.setMetadata({ originalWord: word })
.addPattern(pattern`${word}`));
});
const matcher = new RegExpMatcher({
...dataset.build(),
...englishRecommendedTransformers,
});
export function censor(text, restriction) {
if (!text) {
return null;
}
if (!restriction) {
return text;
}
const censorMatches = matcher.getAllMatches(text);
const censoredText = textCensor.applyTo(text, censorMatches);
return censoredText;
}

View File

@@ -3,29 +3,36 @@ import redis from './redis.js';
import initLogger from './logger.js'; import initLogger from './logger.js';
import entityPrefixes from './entities-prefixes.js'; import entityPrefixes from './entities-prefixes.js';
import { getAffiliateEntityUrl } from './affiliates.js'; import { getAffiliateEntityUrl } from './affiliates.js';
import { censor } from './censor.js';
const logger = initLogger(); const logger = initLogger();
export function curateEntity(entity, context) { export function curateEntity(entity, context = {}) {
if (!entity) { if (!entity) {
return null; return null;
} }
const curatedEntity = { const curatedEntity = {
id: entity.id, id: entity.id,
name: entity.name, name: censor(entity.name, context.restriction),
slug: entity.slug, slug: entity.slug,
type: entity.type, type: entity.type,
url: entity.url, url: entity.url,
isIndependent: entity.independent, isIndependent: entity.independent,
hasLogo: entity.has_logo, hasLogo: context.restriction ? false : entity.has_logo,
parent: curateEntity(entity.parent, context), parent: curateEntity(entity.parent, context),
tags: context?.tags?.map((tag) => ({ tags: context?.tags?.map((tag) => ({
id: tag.id, id: tag.id,
name: tag.name, name: tag.name,
slug: tag.slug, slug: tag.slug,
})), })),
children: context?.children?.filter((child) => child.parent_id === entity.id).map((child) => curateEntity({ ...child, parent: entity }, { parent: entity })) || [], children: context?.children?.filter((child) => child.parent_id === entity.id).map((child) => curateEntity({
...child,
parent: entity,
}, {
parent: entity,
restriction: context.restriction,
})) || [],
affiliate: entity.affiliate ? { affiliate: entity.affiliate ? {
id: entity.affiliate.id, id: entity.affiliate.id,
entityId: entity.affiliate.entity_id, entityId: entity.affiliate.entity_id,
@@ -39,12 +46,12 @@ export function curateEntity(entity, context) {
}, },
}; };
curatedEntity.affiliateUrl = getAffiliateEntityUrl(curatedEntity, curatedEntity); curatedEntity.affiliateUrl = getAffiliateEntityUrl(curatedEntity);
return curatedEntity; return curatedEntity;
} }
export async function fetchEntities(options = {}) { export async function fetchEntities(options = {}, context = {}) {
const entities = await knex('entities') const entities = await knex('entities')
.select('entities.*', knex.raw('row_to_json(parents) as parent')) .select('entities.*', knex.raw('row_to_json(parents) as parent'))
.modify((builder) => { .modify((builder) => {
@@ -93,11 +100,12 @@ export async function fetchEntities(options = {}) {
.whereIn('entity_id', entities.map((entity) => entity.id)); .whereIn('entity_id', entities.map((entity) => entity.id));
return entities.map((entityEntry) => curateEntity(entityEntry, { return entities.map((entityEntry) => curateEntity(entityEntry, {
...context,
tags: entitiesTags.filter((tag) => tag.entity_id === entityEntry.id), tags: entitiesTags.filter((tag) => tag.entity_id === entityEntry.id),
})); }));
} }
export async function fetchEntitiesById(entityIds, options = {}, reqUser) { export async function fetchEntitiesById(entityIds, options = {}, reqUser, context) {
const [entities, children, tags, alerts] = await Promise.all([ const [entities, children, tags, alerts] = await Promise.all([
knex('entities') knex('entities')
.select( .select(
@@ -136,6 +144,7 @@ export async function fetchEntitiesById(entityIds, options = {}, reqUser) {
if (options.order) { if (options.order) {
return entities.map((entityEntry) => curateEntity(entityEntry, { return entities.map((entityEntry) => curateEntity(entityEntry, {
...context,
append: options.append, append: options.append,
children: children.filter((channel) => channel.parent_id === entityEntry.id), children: children.filter((channel) => channel.parent_id === entityEntry.id),
alerts: alerts.filter((alert) => alert.entity_id === entityEntry.id), alerts: alerts.filter((alert) => alert.entity_id === entityEntry.id),
@@ -151,6 +160,7 @@ export async function fetchEntitiesById(entityIds, options = {}, reqUser) {
} }
return curateEntity(entity, { return curateEntity(entity, {
...context,
append: options.append, append: options.append,
children: children.filter((channel) => channel.parent_id === entity.id), children: children.filter((channel) => channel.parent_id === entity.id),
tags: tags.filter((tag) => tag.entity_id === entity.id), tags: tags.filter((tag) => tag.entity_id === entity.id),

View File

@@ -1,12 +1,9 @@
// import config from 'config';
import { pageContext } from '../renderer/usePageContext.js'; import { pageContext } from '../renderer/usePageContext.js';
function getBasePath(media, type, options) { function getBasePath(media, options) {
/* if (pageContext.restriction) {
if (store.state.ui.sfw) { return pageContext.env.media.assetPath;
return config.media.assetPath;
} }
*/
if (media.isS3) { if (media.isS3) {
return options.s3Path; return options.s3Path;
@@ -20,15 +17,13 @@ function getBasePath(media, type, options) {
} }
function getFilename(media, type, options) { function getFilename(media, type, options) {
/* if (pageContext.restriction && type && !options?.original) {
if (store.state.ui.sfw && type && !options?.original) { return media.sfw?.[type];
return media.sfw[type];
} }
if (store.state.ui.sfw) { if (pageContext.restriction) {
return media.sfw.path; return media.sfw?.path;
} }
*/
if (type && !options?.original) { if (type && !options?.original) {
return media[type]; return media[type];
@@ -42,7 +37,7 @@ export default function getPath(media, type, options) {
return null; return null;
} }
const path = getBasePath(media, type, { ...pageContext.env.media, ...options }); const path = getBasePath(media, { ...pageContext.env.media, ...options });
const filename = getFilename(media, type, { ...pageContext.env.media, ...options }); const filename = getFilename(media, type, { ...pageContext.env.media, ...options });
return `${path}/${filename}`; return `${path}/${filename}`;

View File

@@ -1,15 +1,6 @@
import config from 'config'; import config from 'config';
import knex from 'knex'; import knex from 'knex';
export const knexQuery = knex({
client: 'pg',
connection: config.database.query,
pool: config.database.pool,
// performance overhead, don't use asyncStackTraces in production
asyncStackTraces: process.env.NODE_ENV === 'development',
// debug: process.env.NODE_ENV === 'development',
});
export const knexOwner = knex({ export const knexOwner = knex({
client: 'pg', client: 'pg',
connection: config.database.owner, connection: config.database.owner,
@@ -19,6 +10,8 @@ export const knexOwner = knex({
// debug: process.env.NODE_ENV === 'development', // debug: process.env.NODE_ENV === 'development',
}); });
export const knexQuery = knexOwner; // legacy
export const knexManticore = knex({ export const knexManticore = knex({
client: 'mysql', client: 'mysql',
connection: { connection: {

View File

@@ -30,5 +30,7 @@ export function curateMedia(media, context = {}) {
parent: media.entity_parent, parent: media.entity_parent,
}), }),
type: context.type || null, type: context.type || null,
sfw: curateMedia(media.sfw_media),
isRestricted: context.isRestricted,
}; };
} }

View File

@@ -8,29 +8,30 @@ import { curateMedia } from './media.js';
import { fetchTagsById } from './tags.js'; import { fetchTagsById } from './tags.js';
import { fetchEntitiesById } from './entities.js'; import { fetchEntitiesById } from './entities.js';
import { curateStash } from './stashes.js'; import { curateStash } from './stashes.js';
import { censor } from './censor.js';
import escape from '../utils/escape-manticore.js'; import escape from '../utils/escape-manticore.js';
import promiseProps from '../utils/promise-props.js'; import promiseProps from '../utils/promise-props.js';
function curateMovie(rawMovie, assets) { function curateMovie(rawMovie, assets, context = {}) {
if (!rawMovie) { if (!rawMovie) {
return null; return null;
} }
return { return {
id: rawMovie.id, id: rawMovie.id,
title: rawMovie.title, title: censor(rawMovie.title, context.restriction),
slug: rawMovie.slug, slug: rawMovie.slug,
url: rawMovie.url, url: rawMovie.url,
date: rawMovie.date, date: rawMovie.date,
datePrecision: rawMovie.date_precision, datePrecision: rawMovie.date_precision,
createdAt: rawMovie.created_at, createdAt: rawMovie.created_at,
effectiveDate: rawMovie.effective_date, effectiveDate: rawMovie.effective_date,
description: rawMovie.description, description: censor(rawMovie.description, context.restriction),
duration: rawMovie.duration, duration: rawMovie.duration,
channel: { channel: {
id: assets.channel.id, id: assets.channel.id,
slug: assets.channel.slug, slug: assets.channel.slug,
name: assets.channel.name, name: censor(assets.channel.name, context.restriction),
type: assets.channel.type, type: assets.channel.type,
isIndependent: assets.channel.independent, isIndependent: assets.channel.independent,
hasLogo: assets.channel.has_logo, hasLogo: assets.channel.has_logo,
@@ -38,7 +39,7 @@ function curateMovie(rawMovie, assets) {
network: assets.channel.network_id ? { network: assets.channel.network_id ? {
id: assets.channel.network_id, id: assets.channel.network_id,
slug: assets.channel.network_slug, slug: assets.channel.network_slug,
name: assets.channel.network_name, name: censor(assets.channel.network_name, context.restriction),
type: assets.channel.network_type, type: assets.channel.network_type,
hasLogo: assets.channel.has_logo, hasLogo: assets.channel.has_logo,
} : null, } : null,
@@ -51,7 +52,7 @@ function curateMovie(rawMovie, assets) {
tags: assets.tags.map((tag) => ({ tags: assets.tags.map((tag) => ({
id: tag.id, id: tag.id,
slug: tag.slug, slug: tag.slug,
name: tag.name, name: censor(tag.name, context.restriction),
})), })),
// poster: curateMedia(assets.poster), // poster: curateMedia(assets.poster),
covers: assets.covers.map((cover) => curateMedia(cover, { type: 'cover' })), covers: assets.covers.map((cover) => curateMedia(cover, { type: 'cover' })),
@@ -64,7 +65,7 @@ function curateMovie(rawMovie, assets) {
}; };
} }
export async function fetchMoviesById(movieIds, reqUser) { export async function fetchMoviesById(movieIds, reqUser, context = {}) {
const { const {
movies, movies,
channels, channels,
@@ -123,20 +124,25 @@ export async function fetchMoviesById(movieIds, reqUser) {
.leftJoin('tags', 'tags.id', 'releases_tags.tag_id') .leftJoin('tags', 'tags.id', 'releases_tags.tag_id')
.orderBy('priority', 'desc'), .orderBy('priority', 'desc'),
covers: knex('movies_covers') covers: knex('movies_covers')
.select('media.*', 'movies_covers.movie_id', knex.raw('row_to_json(sfw_media) as sfw_media'))
.whereIn('movie_id', movieIds) .whereIn('movie_id', movieIds)
.leftJoin('media', 'media.id', 'movies_covers.media_id') .leftJoin('media', 'media.id', 'movies_covers.media_id')
.orderBy('media.index'), .leftJoin('media as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
photos: knex.transaction(async (trx) => { .orderBy('media.index')
.groupBy('media.id', 'movies_covers.movie_id', 'sfw_media.id'),
photos: context.restriction ? [] : knex.transaction(async (trx) => {
if (reqUser) { if (reqUser) {
await trx.select(knex.raw('set_config(\'user.id\', :userId, true)', { userId: reqUser.id })); await trx.select(knex.raw('set_config(\'user.id\', :userId, true)', { userId: reqUser.id }));
} }
return trx('movies_scenes') return trx('movies_scenes')
.select('media.*', 'movies_scenes.movie_id') .select('media.*', 'movies_scenes.movie_id', knex.raw('row_to_json(sfw_media) as sfw_media'))
.whereIn('movies_scenes.movie_id', movieIds) .whereIn('movies_scenes.movie_id', movieIds)
.whereNotNull('media.id') .whereNotNull('media.id')
.leftJoin('releases_photos', 'releases_photos.release_id', 'movies_scenes.scene_id') .leftJoin('releases_photos', 'releases_photos.release_id', 'movies_scenes.scene_id')
.leftJoin('media', 'media.id', 'releases_photos.media_id'); .leftJoin('media', 'media.id', 'releases_photos.media_id')
.leftJoin('media as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
.groupBy('media.id', 'movies_scenes.movie_id', 'sfw_media.id');
}), }),
caps: knex.transaction(async (trx) => { caps: knex.transaction(async (trx) => {
if (reqUser) { if (reqUser) {
@@ -144,11 +150,13 @@ export async function fetchMoviesById(movieIds, reqUser) {
} }
return trx('movies_scenes') return trx('movies_scenes')
.select('media.*', 'movies_scenes.movie_id') .select('media.*', 'movies_scenes.movie_id', knex.raw('row_to_json(sfw_media) as sfw_media'))
.whereIn('movies_scenes.movie_id', movieIds) .whereIn('movies_scenes.movie_id', movieIds)
.whereNotNull('media.id') .whereNotNull('media.id')
.leftJoin('releases_caps', 'releases_caps.release_id', 'movies_scenes.scene_id') .leftJoin('releases_caps', 'releases_caps.release_id', 'movies_scenes.scene_id')
.leftJoin('media', 'media.id', 'releases_caps.media_id'); .leftJoin('media', 'media.id', 'releases_caps.media_id')
.leftJoin('media as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
.groupBy('media.id', 'movies_scenes.movie_id', 'sfw_media.id');
}), }),
trailers: knex('movies_trailers') trailers: knex('movies_trailers')
.whereIn('movie_id', movieIds) .whereIn('movie_id', movieIds)
@@ -192,7 +200,7 @@ export async function fetchMoviesById(movieIds, reqUser) {
caps: movieCaps, caps: movieCaps,
trailer: movieTrailer, trailer: movieTrailer,
stashes: movieStashes, stashes: movieStashes,
}); }, context);
}).filter(Boolean); }).filter(Boolean);
} }
@@ -398,7 +406,7 @@ function countAggregations(buckets) {
return Object.fromEntries(buckets.map((bucket) => [bucket.key, { count: bucket.doc_count }])); return Object.fromEntries(buckets.map((bucket) => [bucket.key, { count: bucket.doc_count }]));
} }
export async function fetchMovies(filters, rawOptions, reqUser) { export async function fetchMovies(filters, rawOptions, reqUser, context) {
const options = curateOptions(rawOptions); const options = curateOptions(rawOptions);
console.log(options); console.log(options);
@@ -413,13 +421,13 @@ export async function fetchMovies(filters, rawOptions, reqUser) {
const channelCounts = options.aggregateChannels && countAggregations(result.aggregations?.channelIds); const channelCounts = options.aggregateChannels && countAggregations(result.aggregations?.channelIds);
const [aggActors, aggTags, aggChannels] = await Promise.all([ const [aggActors, aggTags, aggChannels] = await Promise.all([
options.aggregateActors ? fetchActorsById(result.aggregations.actorIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: actorCounts }) : [], options.aggregateActors ? fetchActorsById(result.aggregations.actorIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: actorCounts }, reqUser) : [],
options.aggregateTags ? fetchTagsById(result.aggregations.tagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }) : [], options.aggregateTags ? fetchTagsById(result.aggregations.tagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }, reqUser, context) : [],
options.aggregateChannels ? fetchEntitiesById(result.aggregations.channelIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: channelCounts }) : [], options.aggregateChannels ? fetchEntitiesById(result.aggregations.channelIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: channelCounts }, reqUser, context) : [],
]); ]);
const movieIds = result.movies.map((movie) => Number(movie.id)); const movieIds = result.movies.map((movie) => Number(movie.id));
const movies = await fetchMoviesById(movieIds, reqUser); const movies = await fetchMoviesById(movieIds, reqUser, context);
return { return {
movies, movies,

View File

@@ -1,6 +1,7 @@
import config from 'config'; import config from 'config';
import { MerkleJson } from 'merkle-json'; import { MerkleJson } from 'merkle-json';
import argv from './argv.js';
import { knexQuery as knex, knexOwner, knexManticore } from './knex.js'; import { knexQuery as knex, knexOwner, knexManticore } from './knex.js';
import { utilsApi } from './manticore.js'; import { utilsApi } from './manticore.js';
import { HttpError } from './errors.js'; import { HttpError } from './errors.js';
@@ -15,32 +16,34 @@ import promiseProps from '../utils/promise-props.js';
import initLogger from './logger.js'; import initLogger from './logger.js';
import { curateRevision } from './revisions.js'; import { curateRevision } from './revisions.js';
import { getAffiliateSceneUrl } from './affiliates.js'; import { getAffiliateSceneUrl } from './affiliates.js';
import { censor } from './censor.js';
const logger = initLogger(); const logger = initLogger();
const mj = new MerkleJson(); const mj = new MerkleJson();
function curateScene(rawScene, assets) { function curateScene(rawScene, assets, reqUser, context) {
if (!rawScene) { if (!rawScene) {
return null; return null;
} }
const curatedScene = { const curatedScene = {
id: rawScene.id, id: rawScene.id,
title: rawScene.title, title: censor(rawScene.title, context.restriction),
slug: rawScene.slug, slug: rawScene.slug,
url: rawScene.url, url: rawScene.url,
entryId: rawScene.entry_id,
date: rawScene.date, date: rawScene.date,
datePrecision: rawScene.date_precision, datePrecision: rawScene.date_precision,
createdAt: rawScene.created_at, createdAt: rawScene.created_at,
effectiveDate: rawScene.effective_date, effectiveDate: rawScene.effective_date,
description: rawScene.description, description: censor(rawScene.description, context.restriction),
duration: rawScene.duration, duration: rawScene.duration,
shootId: rawScene.shoot_id, shootId: rawScene.shoot_id,
productionDate: rawScene.production_date, productionDate: rawScene.production_date,
channel: { channel: {
id: assets.channel.id, id: assets.channel.id,
slug: assets.channel.slug, slug: assets.channel.slug,
name: assets.channel.name, name: censor(assets.channel.name, context.restriction),
type: assets.channel.type, type: assets.channel.type,
isIndependent: assets.channel.independent, isIndependent: assets.channel.independent,
hasLogo: assets.channel.has_logo, hasLogo: assets.channel.has_logo,
@@ -48,7 +51,7 @@ function curateScene(rawScene, assets) {
network: assets.channel.network_id ? { network: assets.channel.network_id ? {
id: assets.channel.network_id, id: assets.channel.network_id,
slug: assets.channel.network_slug, slug: assets.channel.network_slug,
name: assets.channel.network_name, name: censor(assets.channel.network_name, context.restriction),
type: assets.channel.network_type, type: assets.channel.network_type,
hasLogo: assets.channel.network_has_logo, hasLogo: assets.channel.network_has_logo,
} : null, } : null,
@@ -77,15 +80,19 @@ function curateScene(rawScene, assets) {
tags: assets.tags.map((tag) => ({ tags: assets.tags.map((tag) => ({
id: tag.id, id: tag.id,
slug: tag.slug, slug: tag.slug,
name: tag.name, name: censor(tag.name, context.restriction),
priority: tag.priority, priority: tag.priority,
})), })),
chapters: assets.chapters.map((chapter) => ({ chapters: assets.chapters.map((chapter) => ({
id: chapter.id, id: chapter.id,
title: chapter.title, title: chapter.title,
description: chapter.description,
time: chapter.time, time: chapter.time,
date: chapter.date,
duration: chapter.duration, duration: chapter.duration,
poster: curateMedia(chapter.chapter_poster), poster: context.restriction
? null
: curateMedia(chapter.chapter_poster, { type: 'poster' }),
tags: chapter.chapter_tags.map((tag) => ({ tags: chapter.chapter_tags.map((tag) => ({
id: tag.id, id: tag.id,
name: tag.name, name: tag.name,
@@ -97,26 +104,43 @@ function curateScene(rawScene, assets) {
movies: assets.movies.map((movie) => ({ movies: assets.movies.map((movie) => ({
id: movie.id, id: movie.id,
slug: movie.slug, slug: movie.slug,
title: movie.title, title: censor(movie.title, context.restriction),
covers: movie.movie_covers?.map((cover) => curateMedia(cover, { type: 'cover' })).toSorted((coverA, coverB) => coverA.index - coverB.index) || [], covers: movie.movie_covers && !context.restriction
? movie.movie_covers.map((cover) => curateMedia(cover, { type: 'cover' })).toSorted((coverA, coverB) => coverA.index - coverB.index)
: [],
})), })),
series: assets.series.map((serie) => ({ series: assets.series.map((serie) => ({
id: serie.id, id: serie.id,
slug: serie.slug, slug: serie.slug,
title: serie.title, title: serie.title,
poster: curateMedia(serie.serie_poster, { type: 'poster' }), poster: context.restriction
? null
: (serie.serie_poster, { type: 'poster' }),
})), })),
poster: curateMedia(assets.poster, { type: 'poster' }), poster: curateMedia(assets.poster, { type: 'poster' }),
trailer: curateMedia(assets.trailer, { type: 'trailer' }),
teaser: curateMedia(assets.teaser, { type: 'teaser' }),
photos: assets.photos?.map((photo) => curateMedia(photo, { type: 'photo' })) || [], photos: assets.photos?.map((photo) => curateMedia(photo, { type: 'photo' })) || [],
caps: assets.caps?.map((cap) => curateMedia(cap, { type: 'cap' })) || [], caps: assets.caps?.map((cap) => curateMedia(cap, { type: 'cap' })) || [],
stashes: assets.stashes?.map((stash) => curateStash(stash)) || [], stashes: assets.stashes?.map((stash) => curateStash(stash)) || [],
fingerprints: assets.fingerprints?.map((fingerprint) => ({
hash: fingerprint.hash,
type: fingerprint.type,
duration: fingerprint.duration,
source: fingerprint.source,
submissions: fingerprint.source_submissions,
createdAt: fingerprint.created_at,
})) || [],
createdBatchId: rawScene.created_batch_id, createdBatchId: rawScene.created_batch_id,
updatedBatchId: rawScene.updated_batch_id, updatedBatchId: rawScene.updated_batch_id,
isNew: assets.lastBatchId === rawScene.created_batch_id, isNew: assets.lastBatchId === rawScene.created_batch_id,
}; };
const isVideoRestricted = config.media.videoRestrictions.includes(curatedScene.channel.slug) || config.media.videoRestrictions.includes(`_${curatedScene.network?.slug}`);
if (!isVideoRestricted || reqUser?.abilities?.some((ability) => ability.trailerAccess)) {
curatedScene.trailer = curateMedia(assets.trailer, { type: 'trailer', isRestricted: isVideoRestricted });
curatedScene.teaser = curateMedia(assets.teaser, { type: 'teaser', isRestricted: isVideoRestricted });
}
curatedScene.watchUrl = getAffiliateSceneUrl(curatedScene); curatedScene.watchUrl = getAffiliateSceneUrl(curatedScene);
return curatedScene; return curatedScene;
@@ -138,8 +162,9 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
caps, caps,
trailers, trailers,
teasers, teasers,
fingerprints,
stashes, stashes,
lastBatch: { id: lastBatchId }, lastBatch,
} = await promiseProps({ } = await promiseProps({
scenes: knex('releases').whereIn('releases.id', sceneIds), scenes: knex('releases').whereIn('releases.id', sceneIds),
channels: knex('releases') channels: knex('releases')
@@ -174,14 +199,17 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
.select( .select(
'actors.*', 'actors.*',
knex.raw('row_to_json(avatars) as avatar'), knex.raw('row_to_json(avatars) as avatar'),
knex.raw('row_to_json(sfw_media) as sfw_avatar'),
'countries.name as birth_country_name', 'countries.name as birth_country_name',
'countries.alias as birth_country_alias', 'countries.alias as birth_country_alias',
'releases_actors.release_id', 'releases_actors.release_id',
) )
.leftJoin('actors', 'actors.id', 'releases_actors.actor_id') .leftJoin('actors', 'actors.id', 'releases_actors.actor_id')
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id') .leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
.leftJoin('media as sfw_media', 'sfw_media.id', 'avatars.sfw_media_id')
.leftJoin('countries', 'countries.alpha2', 'actors.birth_country_alpha2') .leftJoin('countries', 'countries.alpha2', 'actors.birth_country_alpha2')
.whereIn('release_id', sceneIds), .whereIn('release_id', sceneIds)
.groupBy('actors.id', 'releases_actors.release_id', 'avatars.id', 'countries.name', 'countries.alias', 'sfw_media.id'),
directors: knex('releases_directors') directors: knex('releases_directors')
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'), .leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),
@@ -219,17 +247,23 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
.whereIn('scene_id', sceneIds) .whereIn('scene_id', sceneIds)
.groupBy('series.id', 'series_scenes.scene_id', 'media.*') : [], .groupBy('series.id', 'series_scenes.scene_id', 'media.*') : [],
posters: knex('releases_posters') posters: knex('releases_posters')
.select('media.*', 'releases_posters.release_id', knex.raw('row_to_json(sfw_media) as sfw_media'))
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
.leftJoin('media', 'media.id', 'releases_posters.media_id'), .leftJoin('media', 'media.id', 'releases_posters.media_id')
photos: context.includeAssets ? knex.transaction(async (trx) => { .leftJoin('media as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
.groupBy('media.id', 'releases_posters.release_id', 'sfw_media.id'),
photos: context.includeAssets && !context.restriction ? knex.transaction(async (trx) => {
if (reqUser) { if (reqUser) {
await trx.select(knex.raw('set_config(\'user.id\', :userId, true)', { userId: reqUser.id })); await trx.select(knex.raw('set_config(\'user.id\', :userId, true)', { userId: reqUser.id }));
} }
return trx('releases_photos') return trx('releases_photos')
.select('media.*', 'releases_photos.release_id', knex.raw('row_to_json(sfw_media) as sfw_media'))
.leftJoin('media', 'media.id', 'releases_photos.media_id') .leftJoin('media', 'media.id', 'releases_photos.media_id')
.leftJoin('media as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
.orderBy('index'); .orderBy('index')
.groupBy('media.id', 'releases_photos.release_id', 'sfw_media.id');
}) : [], }) : [],
caps: context.includeAssets ? knex.transaction(async (trx) => { caps: context.includeAssets ? knex.transaction(async (trx) => {
if (reqUser) { if (reqUser) {
@@ -237,9 +271,12 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
} }
return trx('releases_caps') return trx('releases_caps')
.select('media.*', 'releases_caps.release_id', knex.raw('row_to_json(sfw_media) as sfw_media'))
.leftJoin('media', 'media.id', 'releases_caps.media_id') .leftJoin('media', 'media.id', 'releases_caps.media_id')
.leftJoin('media as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
.orderBy('index'); .orderBy('index')
.groupBy('media.id', 'releases_caps.release_id', 'sfw_media.id');
}) : [], }) : [],
trailers: context.includeAssets ? knex.transaction(async (trx) => { trailers: context.includeAssets ? knex.transaction(async (trx) => {
if (reqUser) { if (reqUser) {
@@ -259,6 +296,20 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
.leftJoin('media', 'media.id', 'releases_teasers.media_id'); .leftJoin('media', 'media.id', 'releases_teasers.media_id');
}) : [], }) : [],
fingerprints: context.includeAssets ? knex.transaction(async (trx) => {
if (reqUser) {
await trx.select(knex.raw('set_config(\'user.id\', :userId, true)', { userId: reqUser.id }));
}
return trx('releases_fingerprints')
.select('scene_id', 'hash', 'type', 'duration', 'source', 'source_submissions', knex.raw('min(coalesce(source_created_at, created_at)) as created_at'))
.whereIn('scene_id', sceneIds)
.orderBy([
{ column: 'source_submissions', order: 'desc' },
{ column: knex.raw('min(coalesce(source_created_at, created_at))'), order: 'desc' },
])
.groupBy(['scene_id', 'hash', 'type', 'duration', 'source', 'source_submissions']);
}) : [],
lastBatch: knex('batches') lastBatch: knex('batches')
.select('id') .select('id')
.where('showcased', true) .where('showcased', true)
@@ -299,6 +350,7 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
const sceneCaps = caps.filter((cap) => cap.release_id === sceneId); const sceneCaps = caps.filter((cap) => cap.release_id === sceneId);
const sceneTrailers = trailers.find((trailer) => trailer.release_id === sceneId); const sceneTrailers = trailers.find((trailer) => trailer.release_id === sceneId);
const sceneTeasers = teasers.find((teaser) => teaser.release_id === sceneId); const sceneTeasers = teasers.find((teaser) => teaser.release_id === sceneId);
const sceneFingerprints = fingerprints.filter((fingerprint) => fingerprint.scene_id === sceneId);
const sceneStashes = stashes.filter((stash) => stash.scene_id === sceneId); const sceneStashes = stashes.filter((stash) => stash.scene_id === sceneId);
const sceneActorStashes = sceneActors.map((actor) => actorStashes.find((stash) => stash.actor_id === actor.id)).filter(Boolean); const sceneActorStashes = sceneActors.map((actor) => actorStashes.find((stash) => stash.actor_id === actor.id)).filter(Boolean);
@@ -316,10 +368,11 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
caps: sceneCaps, caps: sceneCaps,
trailer: sceneTrailers, trailer: sceneTrailers,
teaser: sceneTeasers, teaser: sceneTeasers,
fingerprints: sceneFingerprints,
stashes: sceneStashes, stashes: sceneStashes,
actorStashes: sceneActorStashes, actorStashes: sceneActorStashes,
lastBatchId, lastBatchId: lastBatch?.id,
}); }, reqUser, context);
}).filter(Boolean); }).filter(Boolean);
} }
@@ -452,9 +505,11 @@ async function queryManticoreSql(filters, options, _reqUser) {
builder.where('scenes.is_showcased', filters.isShowcased); builder.where('scenes.is_showcased', filters.isShowcased);
} }
/*
if (filters.isShowcased) { if (filters.isShowcased) {
builder.where('scenes.date', '>', 0); builder.where('scenes.date', '>', 0);
} }
*/
if (options.dedupe) { if (options.dedupe) {
builder.where('scenes.dupe_index', '<', 2); builder.where('scenes.dupe_index', '<', 2);
@@ -512,7 +567,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
? sqlQuery ? sqlQuery
: sqlQuery.replace(/scenes\./g, ''); : sqlQuery.replace(/scenes\./g, '');
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development' && argv.debug) {
console.log(curatedSqlQuery); console.log(curatedSqlQuery);
} }
@@ -570,14 +625,18 @@ function countAggregations(buckets) {
return Object.fromEntries(buckets.map((bucket) => [bucket.key, { count: bucket.doc_count }])); return Object.fromEntries(buckets.map((bucket) => [bucket.key, { count: bucket.doc_count }]));
} }
export async function fetchScenes(filters, rawOptions, reqUser) { export async function fetchScenes(filters, rawOptions, reqUser, context) {
const options = curateOptions(rawOptions); const options = curateOptions(rawOptions);
console.log('filters', filters); if (argv.debug) {
console.log('options', options); console.log('filters', filters);
console.log('options', options);
}
console.time('manticore sql'); console.time('manticore sql');
const result = await queryManticoreSql(filters, options, reqUser); const result = await queryManticoreSql(filters, options, reqUser);
console.timeEnd('manticore sql'); console.timeEnd('manticore sql');
const aggYears = options.aggregateYears && result.aggregations.years.map((bucket) => ({ year: bucket.key, count: bucket.doc_count })); const aggYears = options.aggregateYears && result.aggregations.years.map((bucket) => ({ year: bucket.key, count: bucket.doc_count }));
@@ -590,16 +649,16 @@ export async function fetchScenes(filters, rawOptions, reqUser) {
console.time('fetch aggregations'); console.time('fetch aggregations');
const [aggActors, aggTags, aggChannels] = await Promise.all([ const [aggActors, aggTags, aggChannels] = await Promise.all([
options.aggregateActors ? fetchActorsById(result.aggregations.actorIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: actorCounts }) : [], options.aggregateActors ? fetchActorsById(result.aggregations.actorIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: actorCounts }, reqUser) : [],
options.aggregateTags ? fetchTagsById(result.aggregations.tagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }) : [], options.aggregateTags ? fetchTagsById(result.aggregations.tagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }, reqUser, context) : [],
options.aggregateChannels ? fetchEntitiesById(entityIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: channelCounts }) : [], options.aggregateChannels ? fetchEntitiesById(entityIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: channelCounts }, reqUser, context) : [],
]); ]);
console.timeEnd('fetch aggregations'); console.timeEnd('fetch aggregations');
console.time('fetch full'); console.time('fetch full');
const sceneIds = result.scenes.map((scene) => Number(scene.id)); const sceneIds = result.scenes.map((scene) => Number(scene.id));
const scenes = await fetchScenesById(sceneIds, { reqUser }); const scenes = await fetchScenesById(sceneIds, { reqUser, ...context });
console.timeEnd('fetch full'); console.timeEnd('fetch full');
return { return {

View File

@@ -1,17 +1,18 @@
import knex from './knex.js'; import knex from './knex.js';
import redis from './redis.js'; import redis from './redis.js';
import initLogger from './logger.js'; import initLogger from './logger.js';
import { censor } from './censor.js';
import { curateMedia } from './media.js'; import { curateMedia } from './media.js';
const logger = initLogger(); const logger = initLogger();
function curateTag(tag, context) { function curateTag(tag, context = {}) {
return { return {
id: tag.id, id: tag.id,
name: tag.name, name: censor(tag.name, context.restriction),
slug: tag.slug, slug: tag.slug,
description: tag.description, description: context.restriction ? null : tag.description, // censor interferes with markdown
priority: tag.priority, priority: tag.priority,
poster: tag.poster && curateMedia(tag.poster), poster: tag.poster && curateMedia(tag.poster),
photos: tag.photos?.map((photo) => curateMedia(photo)) || [], photos: tag.photos?.map((photo) => curateMedia(photo)) || [],
@@ -23,7 +24,7 @@ function curateTag(tag, context) {
}; };
} }
export async function fetchTags(options = {}) { export async function fetchTags(options = {}, context = {}) {
const query = options.query?.trim(); const query = options.query?.trim();
const [tags, posters] = await Promise.all([ const [tags, posters] = await Promise.all([
@@ -55,10 +56,13 @@ export async function fetchTags(options = {}) {
} }
}), }),
knex('tags_posters') knex('tags_posters')
.select('media.*', 'tags_posters.tag_id', knex.raw('row_to_json(sfw_media) as sfw_media'))
.select('tags_posters.tag_id', 'media.*', knex.raw('row_to_json(entities) as entity'), knex.raw('row_to_json(parents) as entity_parent')) .select('tags_posters.tag_id', 'media.*', knex.raw('row_to_json(entities) as entity'), knex.raw('row_to_json(parents) as entity_parent'))
.leftJoin('media', 'media.id', 'tags_posters.media_id') .leftJoin('media', 'media.id', 'tags_posters.media_id')
.leftJoin('media as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
.leftJoin('entities', 'entities.id', 'media.entity_id') .leftJoin('entities', 'entities.id', 'media.entity_id')
.leftJoin('entities as parents', 'parents.id', 'entities.parent_id'), .leftJoin('entities as parents', 'parents.id', 'entities.parent_id')
.groupBy('media.id', 'tags_posters.tag_id', 'sfw_media.id', 'entities.id', 'parents.id'),
]); ]);
const postersByTagId = Object.fromEntries(posters.map((poster) => [poster.tag_id, poster])); const postersByTagId = Object.fromEntries(posters.map((poster) => [poster.tag_id, poster]));
@@ -66,10 +70,10 @@ export async function fetchTags(options = {}) {
return tags.map((tagEntry) => curateTag({ return tags.map((tagEntry) => curateTag({
...tagEntry, ...tagEntry,
poster: postersByTagId[tagEntry.id], poster: postersByTagId[tagEntry.id],
})); }, context));
} }
export async function fetchTagsById(tagIds, options = {}, reqUser) { export async function fetchTagsById(tagIds, options = {}, reqUser, context = {}) {
const [tags, posters, photos, alerts] = await Promise.all([ const [tags, posters, photos, alerts] = await Promise.all([
knex('tags') knex('tags')
.whereIn('tags.id', tagIds.filter((tagId) => typeof tagId === 'number')) .whereIn('tags.id', tagIds.filter((tagId) => typeof tagId === 'number'))
@@ -80,14 +84,17 @@ export async function fetchTagsById(tagIds, options = {}, reqUser) {
} }
}), }),
knex('tags_posters') knex('tags_posters')
.select('media.*', 'tags_posters.tag_id', knex.raw('row_to_json(sfw_media) as sfw_media'))
.select('tags_posters.tag_id', 'media.*', knex.raw('row_to_json(entities) as entity'), knex.raw('row_to_json(parents) as entity_parent')) .select('tags_posters.tag_id', 'media.*', knex.raw('row_to_json(entities) as entity'), knex.raw('row_to_json(parents) as entity_parent'))
.leftJoin('tags', 'tags.id', 'tags_posters.tag_id') .leftJoin('tags', 'tags.id', 'tags_posters.tag_id')
.leftJoin('media', 'media.id', 'tags_posters.media_id') .leftJoin('media', 'media.id', 'tags_posters.media_id')
.leftJoin('media as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
.leftJoin('entities', 'entities.id', 'media.entity_id') .leftJoin('entities', 'entities.id', 'media.entity_id')
.leftJoin('entities as parents', 'parents.id', 'entities.parent_id') .leftJoin('entities as parents', 'parents.id', 'entities.parent_id')
.whereIn('tags.id', tagIds.filter((tagId) => typeof tagId === 'number')) .whereIn('tags.id', tagIds.filter((tagId) => typeof tagId === 'number'))
.orWhereIn('tags.slug', tagIds.filter((tagId) => typeof tagId === 'string')), .orWhereIn('tags.slug', tagIds.filter((tagId) => typeof tagId === 'string'))
knex('tags_photos') .groupBy('media.id', 'tags_posters.tag_id', 'sfw_media.id', 'entities.id', 'parents.id'),
context.restriction ? [] : knex('tags_photos')
.select('tags_photos.tag_id', 'media.*', knex.raw('row_to_json(entities) as entity'), knex.raw('row_to_json(parents) as entity_parent')) .select('tags_photos.tag_id', 'media.*', knex.raw('row_to_json(entities) as entity'), knex.raw('row_to_json(parents) as entity_parent'))
.leftJoin('tags', 'tags.id', 'tags_photos.tag_id') .leftJoin('tags', 'tags.id', 'tags_photos.tag_id')
.leftJoin('media', 'media.id', 'tags_photos.media_id') .leftJoin('media', 'media.id', 'tags_photos.media_id')
@@ -118,6 +125,7 @@ export async function fetchTagsById(tagIds, options = {}, reqUser) {
}, { }, {
alerts: alerts.filter((alert) => alert.tag_id === tagEntry.id), alerts: alerts.filter((alert) => alert.tag_id === tagEntry.id),
append: options.append, append: options.append,
...context,
})); }));
} }
@@ -136,6 +144,7 @@ export async function fetchTagsById(tagIds, options = {}, reqUser) {
}, { }, {
alerts: alerts.filter((alert) => alert.tag_id === tag.id), alerts: alerts.filter((alert) => alert.tag_id === tag.id),
append: options.append, append: options.append,
...context,
}); });
}).filter(Boolean); }).filter(Boolean);

View File

@@ -29,6 +29,7 @@ export function curateUser(user, _assets = {}) {
isIdentityVerified: user.identity_verified, isIdentityVerified: user.identity_verified,
avatar: `/media/avatars/${user.id}_${user.username}.png`, avatar: `/media/avatars/${user.id}_${user.username}.png`,
role: user.role, role: user.role,
abilities: [...user.role_abilities || [], ...user.abilities || []],
createdAt: user.created_at, createdAt: user.created_at,
}; };
@@ -61,23 +62,6 @@ export async function fetchUser(userId, options = {}, _reqUser) {
throw new HttpError(`User '${userId}' not found`, 404); throw new HttpError(`User '${userId}' not found`, 404);
} }
/*
const [stashes, templates] = await Promise.all([
knex('stashes')
.select('stashes.*', 'stashes_meta.*')
.leftJoin('stashes_meta', 'stashes_meta.stash_id', 'stashes.id')
.where('user_id', user.id)
.modify((builder) => {
if (reqUser?.id !== user.id && !options.includeStashes) {
builder.where('public', true);
}
}),
options.includeTemplates
? knex('users_templates').where('user_id', user.id)
: null,
]);
*/
if (options.raw) { if (options.raw) {
// return { user, stashes, templates }; // return { user, stashes, templates };
return { user }; return { user };

View File

@@ -1,6 +1,7 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import { stringify } from '@brillout/json-serializer/stringify'; /* eslint-disable-line import/extensions */ import { stringify } from '@brillout/json-serializer/stringify'; /* eslint-disable-line import/extensions */
import IPCIDR from 'ip-cidr'; import IPCIDR from 'ip-cidr';
import argv from '../argv.js';
import { import {
login, login,
@@ -14,6 +15,10 @@ import {
import { fetchUser } from '../users.js'; import { fetchUser } from '../users.js';
function getIp(req) { function getIp(req) {
if (argv.ip) {
return argv.ip;
}
const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.connection.remoteAddress; const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.connection.remoteAddress;
const unmappedIp = ip?.includes('.') const unmappedIp = ip?.includes('.')

View File

@@ -24,6 +24,7 @@ export default async function mainHandler(req, res, next) {
username: req.user.username, username: req.user.username,
email: req.user.email, email: req.user.email,
role: req.user.role, role: req.user.role,
abilities: req.user.abilities,
avatar: req.user.avatar, avatar: req.user.avatar,
}, },
assets: req.user ? { assets: req.user ? {
@@ -45,7 +46,12 @@ export default async function mainHandler(req, res, next) {
psa: config.psa, psa: config.psa,
links: config.links, links: config.links,
socials, socials,
captcha: {
enabled: config.auth.captcha.enabled,
siteKey: config.auth.captcha.siteKey,
},
}, },
restriction: req.restriction,
meta: { meta: {
now: new Date().toISOString(), now: new Date().toISOString(),
}, },

View File

@@ -33,7 +33,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); }, req.user, { restriction: req.restriction });
res.send(stringify({ res.send(stringify({
movies, movies,
@@ -47,7 +47,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 }); const [movie] = await fetchMoviesById([Number(req.params.movieId)], { reqUser: req.user }, { restriction: req.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);
@@ -137,7 +137,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); }, req.user, { restriction: req.restriction });
return { return {
nodes: movies, nodes: movies,

80
src/web/restrictions.js Normal file
View File

@@ -0,0 +1,80 @@
import config from 'config';
import path from 'path';
import { Reader } from '@maxmind/geoip2-node';
import initLogger from '../logger.js';
const logger = initLogger();
const regions = config.restrictions.regions;
export default async function initRestrictionHandler() {
const reader = await Reader.open('assets/GeoLite2-City.mmdb');
function getRestriction(req) {
if (req.session.restriction && req.session.country && req.session.restrictionIp === req.userIp) {
return {
restriction: req.session.restriction,
country: req.session.country,
};
}
const location = reader.city(req.userIp);
const country = location.country.isoCode;
const subdivision = location.subdivisions?.[0]?.isoCode;
if (regions[country]?.[subdivision]) {
// state or province restriction
return {
restriction: config.restrictions.modes[regions[country][subdivision]],
country,
};
}
if (regions[country]) {
// country restriction
return {
restriction: config.restrictions.modes[regions[country]],
country,
};
}
return {
restriction: null,
country,
};
}
function restrictionHandler(req, res, next) {
if (!config.restrictions.enabled) {
next();
return;
}
try {
const { restriction, country } = getRestriction(req);
if (restriction === 'block' || req.path === '/sfw/') {
res.render(path.join(import.meta.dirname, '../../assets/sfw.ejs'), {
noVpn: config.restrictions.noVpn.includes(country),
});
return;
}
if (req.session.restriction !== restriction) {
req.session.restrictionIp = req.userIp;
req.session.restriction = restriction;
req.session.country = country;
}
req.restriction = restriction;
req.country = country;
} catch (error) {
logger.error(`Failed Maxmind IP lookup for ${req.ip}: ${error.message}`);
}
next();
}
return restrictionHandler;
}

View File

@@ -68,7 +68,9 @@ 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,
});
res.send(stringify({ res.send(stringify({
scenes, scenes,
@@ -250,7 +252,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 }); const [scene] = await fetchScenesById([Number(req.params.sceneId)], { reqUser: req.user }, { restriction: req.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);
@@ -263,6 +265,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,
}); });
if (query.ids) { if (query.ids) {

View File

@@ -12,6 +12,7 @@ import redis from '../redis.js';
import errorHandler from './error.js'; import errorHandler from './error.js';
import consentHandler from './consent.js'; import consentHandler from './consent.js';
import initRestrictionHandler from './restrictions.js';
import { scenesRouter } from './scenes.js'; import { scenesRouter } from './scenes.js';
import { actorsRouter } from './actors.js'; import { actorsRouter } from './actors.js';
@@ -48,9 +49,11 @@ const isProduction = process.env.NODE_ENV === 'production';
export default async function initServer() { export default async function initServer() {
const app = express(); const app = express();
const router = Router(); const router = Router();
const restrictionHandler = await initRestrictionHandler();
app.use(compression()); app.use(compression());
app.disable('x-powered-by'); app.disable('x-powered-by');
app.set('view engine', 'ejs');
router.use(boolParser()); router.use(boolParser());
@@ -58,7 +61,7 @@ export default async function initServer() {
router.use('/', express.static('static')); router.use('/', express.static('static'));
router.use('/media', express.static(config.media.path)); router.use('/media', express.static(config.media.path));
router.use((req, res, next) => { router.use((req, _res, next) => {
if (req.headers.cookie) { if (req.headers.cookie) {
const cookies = cookie.parse(req.headers.cookie); const cookies = cookie.parse(req.headers.cookie);
@@ -109,11 +112,13 @@ export default async function initServer() {
router.use(viteDevMiddleware); router.use(viteDevMiddleware);
} }
router.get('/consent', (req, res) => { router.use(restrictionHandler);
router.get('/consent', (_req, res) => {
res.sendFile(path.join(import.meta.dirname, '../../assets/consent.html')); res.sendFile(path.join(import.meta.dirname, '../../assets/consent.html'));
}); });
router.use('/api/*', async (req, res, next) => { router.use('/api/*', async (req, _res, next) => {
if (req.headers['api-user']) { if (req.headers['api-user']) {
await verifyKey(req.headers['api-user'], req.headers['api-key'], req); await verifyKey(req.headers['api-user'], req.headers['api-key'], req);
@@ -159,7 +164,7 @@ export default async function initServer() {
router.use(consentHandler); router.use(consentHandler);
router.use((req, res, next) => { router.use((_req, res, next) => {
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
res.set('Accept-CH', 'Sec-CH-Prefers-Color-Scheme'); res.set('Accept-CH', 'Sec-CH-Prefers-Color-Scheme');
res.set('Vary', 'Sec-CH-Prefers-Color-Scheme'); res.set('Vary', 'Sec-CH-Prefers-Color-Scheme');
@@ -175,7 +180,9 @@ export default async function initServer() {
app.use(router); app.use(router);
const port = process.env.PORT || config.web.port || 3000; const port = process.env.PORT || config.web.port || 3000;
app.listen(port); // const port = Math.round(Math.random() * 10000);
logger.info(`Server running at http://localhost:${port}`); app.listen(port, config.web.host);
logger.info(`Server running at http://${config.web.host}:${port}`);
} }

View File

@@ -3,6 +3,8 @@ import { fetchTags } from '../tags.js';
export async function fetchTagsApi(req, res) { 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,
}); });
res.send(tags); res.send(tags);

2
static

Submodule static updated: d1ce4d1258...bb5b4f01b4