Compare commits

...

205 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
5ac7cfbc9a 0.42.23 2026-01-23 03:03:47 +01:00
bf802771de Improved affiliate entity logic. 2026-01-23 03:03:45 +01:00
559dc21189 0.42.22 2026-01-23 02:59:22 +01:00
7fdb915921 Not using parent affiliate URL for networks. 2026-01-23 02:59:20 +01:00
19f0752b0f 0.42.21 2026-01-23 02:13:54 +01:00
471ee42c0e Showing networks first in child entity list. 2026-01-23 02:13:53 +01:00
1e089f731a 0.42.20 2026-01-22 19:42:15 +01:00
c026988a7b Filter Woodman photos from actor page. 2026-01-22 19:42:12 +01:00
6281842a14 0.42.19 2026-01-22 05:58:07 +01:00
2380342328 Added improved affiliate URL logic for entities. 2026-01-22 05:58:05 +01:00
e28904b791 0.42.18 2026-01-22 03:48:54 +01:00
ad7f1ce1fa Using new object affiliate parameters. 2026-01-22 03:48:53 +01:00
327c7ab1db 0.42.17 2026-01-22 01:15:38 +01:00
30303a80d3 Not using NATS redirect URL for independent channels. Showing independent channels first in list. 2026-01-22 01:15:35 +01:00
66c1cbab6a 0.42.16 2026-01-20 03:34:47 +01:00
34348890ec Replaced double left join with lateral join in scene affiliate SQL. 2026-01-20 03:34:44 +01:00
edb4be379f 0.42.15 2026-01-20 02:43:30 +01:00
363a6b4084 Changed affiliate query to ensure channel priority. 2026-01-20 02:43:28 +01:00
fc5a0d209c 0.42.14 2026-01-19 06:05:09 +01:00
63bee8f5e0 Stripping /trial from affiliate URL. 2026-01-19 06:05:07 +01:00
166f4ee7ce Fixed scenes breaking if no affiliate is available. 2026-01-19 04:52:11 +01:00
7702839b7a 0.42.13 2026-01-19 04:46:42 +01:00
cb1c884503 Fixed NATS URL composition. 2026-01-19 04:46:40 +01:00
7bc9a90b81 0.42.12 2026-01-11 02:44:46 +01:00
b2ca5a6713 Fixed scene tile actor alignment so it's less likely to cut-off letters. 2026-01-11 02:44:44 +01:00
a4325f6ff6 0.42.11 2026-01-11 00:08:02 +01:00
03f57c1ef4 Fixed movie header filterbar clearance. 2026-01-11 00:07:59 +01:00
378e0edc75 0.42.10 2026-01-10 04:36:18 +01:00
a2622aa536 Fixed slugify behavior. 2026-01-10 04:36:15 +01:00
602765bfbb 0.42.9 2026-01-10 03:00:34 +01:00
e32dd55220 Fixed slugify not handling slugs as input. 2026-01-10 03:00:31 +01:00
4e181d5ff7 0.42.8 2026-01-09 06:35:32 +01:00
ba971035a0 Improved actor bio collapse behavior. 2026-01-09 06:35:30 +01:00
c214ccf201 0.42.7 2026-01-09 03:50:02 +01:00
414499636e Ignoring actor photos with unknown entropy. 2026-01-09 03:50:00 +01:00
d9bbd95fa1 0.42.6 2026-01-09 03:08:09 +01:00
b63037ef74 Hiding low-entropy photos from actor page. Fixed actors filter tab spacing. Fixed entity logo stretching on compact scene page. 2026-01-09 03:08:08 +01:00
37ccc3c3dd 0.42.5 2026-01-09 02:09:44 +01:00
a8bacaf083 Using local slugify. 2026-01-09 02:09:41 +01:00
0e23115cfe 0.42.4 2026-01-09 01:54:12 +01:00
e67ec5eca4 Using common slugify. 2026-01-09 01:54:10 +01:00
7a452db2f8 0.42.3 2026-01-06 01:54:51 +01:00
8b3e9d32d6 Added 5K and 8K to scene page quality map. 2026-01-06 01:54:49 +01:00
d8d2ee6785 0.42.2 2026-01-05 21:40:05 +01:00
77b9acea32 Improved search, linking results to scenes page, updating on input clear. 2026-01-05 21:40:03 +01:00
68f15d4f74 0.42.1 2025-12-30 04:55:08 +01:00
42a83b2126 Fixed header responsiveness. 2025-12-30 04:55:05 +01:00
a19c39d8eb 0.42.0 2025-12-30 04:49:34 +01:00
f06df01e70 Added easily accessible global scenes page with filters. 2025-12-30 04:49:29 +01:00
0527674333 0.41.25 2025-12-28 06:00:49 +01:00
8d6da08519 Using common for socials definitions. 2025-12-28 06:00:46 +01:00
2b338e32eb 0.41.24 2025-12-20 23:29:03 +01:00
aa8412863b Fixed tag tile breaking page if no poster is available. 2025-12-20 23:29:01 +01:00
d4a486d2ae 0.41.23 2025-12-19 23:23:24 +01:00
ceac4ecc56 Styled API key HTTP header instructions to clarify it is your literal API user ID 2025-12-19 23:23:21 +01:00
233829223e 0.41.22 2025-12-15 01:54:43 +01:00
456b69f1ca Fixed fallback affiliate query causing duplicate results in channel aggregate. 2025-12-15 01:54:41 +01:00
83efdf59d4 0.41.21 2025-12-13 04:24:50 +01:00
02f2629f6b Fixed width and height attributes on tag photos, fixes lazy loading. 2025-12-13 04:24:47 +01:00
6796f7f258 0.41.20 2025-12-13 04:00:17 +01:00
84b9bbd1b6 Showing tag poster and photos on tag page. Improved campaign fallback logic, fixes wrong ratio selected. 2025-12-13 04:00:14 +01:00
bc26e07812 0.41.19 2025-12-13 01:46:06 +01:00
e05ca80f7c Skipping seemingly redundant tags page scrolling logic that causes jittering in Firefox. Added hash exclude property to umami script attributes. 2025-12-13 01:46:00 +01:00
79eacee3f0 0.41.18 2025-11-23 03:10:20 +01:00
1fc77587ed Added 'compilation' to global tag filter. 2025-11-23 03:10:18 +01:00
77d37ce6b5 0.41.17 2025-11-23 03:07:53 +01:00
5dc829674a Showing boobs enhancement in bio if bust field is missing. 2025-11-23 03:07:47 +01:00
5ffc865c00 0.41.16 2025-11-21 06:17:49 +01:00
ae085ad5ec Limited ratio on scenes nav ad. 2025-11-21 06:17:46 +01:00
05a93293fe 0.41.15 2025-11-21 04:40:12 +01:00
117923ff1d Linked stash tile header to largest stash. 2025-11-21 04:40:10 +01:00
47a748c623 0.41.14 2025-11-18 02:17:57 +01:00
e9e0cf3600 Filtering missing items in summary actor details. 2025-11-18 02:17:54 +01:00
c530751a70 0.41.13 2025-11-18 00:41:43 +01:00
f4acee53c4 Added details prop to summary actors. 2025-11-18 00:41:41 +01:00
14bca958fd 0.41.12 2025-11-17 23:42:25 +01:00
0b5ce620d6 Added format option to actors, exposing age and gender. 2025-11-17 23:42:24 +01:00
0435472489 0.41.11 2025-11-17 17:36:04 +01:00
253052f75a Added markdown to built-in scene summary list. 2025-11-17 17:36:02 +01:00
27acb09ced 0.41.10 2025-11-14 22:52:18 +01:00
8f145e926e Added origin config to use in summaries. Added link property to summaries for local traxxx URL. 2025-11-14 22:52:16 +01:00
0dd4bcc7fe 0.41.9 2025-11-14 03:00:13 +01:00
b9c1c9914d Added custom text property to summary templates. Added URL to summary mock-up. 2025-11-14 03:00:10 +01:00
90da4b592a 0.41.8 2025-11-02 18:27:00 +01:00
ac1e44f427 Checking value before initializing date in revision curation. 2025-11-02 18:26:54 +01:00
880d6369e4 0.41.7 2025-11-02 18:19:23 +01:00
aad922ac30 Fixed scene duration getting lost during edit. 2025-11-02 18:19:21 +01:00
9c99e464aa Added Kelly Madison as popular network. 2025-10-06 05:43:18 +02:00
ebc2895d7e 0.41.6 2025-10-06 05:40:12 +02:00
45ed3be747 Fixed affiliate banner ratio on tags page. Added affiliate banners to actor page. 2025-10-06 05:40:09 +02:00
2afcdd6050 0.41.5 2025-10-06 05:20:19 +02:00
b355ef4bf5 Improved affiliate selection. 2025-10-06 05:20:17 +02:00
50280692e8 0.41.4 2025-10-05 17:53:38 +02:00
35699becf5 Re-added enhanced wand icon in bio, hiding enhancements bio section if no details are known. 2025-10-05 17:53:14 +02:00
6237aa0c03 0.41.3 2025-09-15 05:30:05 +02:00
b6398197ea Linking entities in entity health overview. 2025-09-15 05:30:03 +02:00
ea398a51aa 0.41.2 2025-09-15 05:23:33 +02:00
87a800edc9 Changed dead site alert threshold to weeks. 2025-09-15 05:23:28 +02:00
50d280a3c9 0.41.1 2025-09-15 05:19:44 +02:00
1de174a8c4 Added network to dead site overview. 2025-09-15 05:19:42 +02:00
c90d0c3f3c 0.41.0 2025-09-15 05:07:07 +02:00
32202d8ab5 Added rudimentary entity health overview. 2025-09-15 05:07:02 +02:00
37b40f1744 0.40.11 2025-08-27 05:13:09 +02:00
bd9a794e34 Added search icon to filter search inputs. 2025-08-27 05:13:07 +02:00
63ee4cae31 0.40.10 2025-08-27 04:51:30 +02:00
ddaf5c3b42 Added box cover filter to movies. 2025-08-27 04:51:27 +02:00
7b78724bb4 0.40.9 2025-08-27 04:42:38 +02:00
e4f410f293 Fixed actor countries filter disappearing if no countries are found. 2025-08-27 04:42:35 +02:00
55680b5150 0.40.8 2025-08-21 05:56:10 +02:00
1f7ad45393 Removed line clamping from scene and movie titles. 2025-08-21 05:56:07 +02:00
99 changed files with 2762 additions and 489 deletions

View File

@@ -21,7 +21,7 @@
"no-console": 0,
"no-param-reassign": ["error", {
"props": true,
"ignorePropertyModificationsFor": ["state", "acc"]
"ignorePropertyModificationsFor": ["state", "acc", "req"]
}],
"vue/multi-word-component-names": 0,
"vue/no-reserved-component-names": 0,
@@ -32,7 +32,8 @@
"vue/html-indent": ["error", "tab"],
"vue/multiline-html-element-content-newline": 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": {
"import/resolver": {

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ config/*
log/
/media
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

4
assets/markdown.yaml Normal file
View File

@@ -0,0 +1,4 @@
- wrap: ['[Scene details](', ')']
items:
- link
- text: 'on [traxxx](https://traxxx.me/).'

45
assets/mockup-release.ts Normal file
View File

@@ -0,0 +1,45 @@
const now = new Date();
export default {
id: 0,
shootId: 12345,
title: 'Nut For Human Consumption',
slug: 'nut-for-human-consumption',
link: 'https://traxxx.me/scene/0/nut-for-human-consumption',
url: 'https://example.com/video/12345/nut-for-human-consumption',
date: now,
effectiveDate: now,
createdAt: new Date(now.getFullYear(), 0, 1),
actors: [
{
name: 'Chanel Chakra',
gender: 'female',
ageThen: 26,
ageFromBirth: 31,
dateOfBirth: new Date(1999, 2, 2),
},
{
name: 'Mo The Fucker',
gender: 'male',
ageThen: 32,
ageFromBirth: 37,
dateOfBirth: new Date(1988, 5, 12),
},
],
tags: [
{ name: 'anal' },
{ name: 'facefucking' },
{ name: 'deepthroat' },
{ name: 'blowjob' },
{ name: 'facial' },
],
movies: [{
title: `Best Of Traxxx ${String(now.getFullYear()).slice(2)}`,
}],
channel: {
name: 'Traxxxed',
},
network: {
name: 'Traxxx',
},
};

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>

2
common

Submodule common updated: dc00c3d58a...ec4b15ce33

View File

@@ -8,7 +8,7 @@
type="search"
placeholder="Search actors"
class="input search"
@keydown.enter="search"
@search="search"
>
<Icon
@@ -281,7 +281,7 @@ function updateFilter(prop, value, reload = true) {
.actors-header {
display: flex;
align-items: center;
padding: .5rem 0 .25rem 2.25rem;
padding: .5rem 0 .5rem 3rem;
margin-bottom: .25rem;
}

View File

@@ -102,7 +102,7 @@
<li
v-if="actor.residence"
class="bio-item residence hideable"
class="bio-item residence"
:class="{ hideable: !!actor.origin }"
>
<dfn class="bio-label"><Icon icon="location" />Lives in</dfn>
@@ -142,14 +142,24 @@
class="bio-item figure"
>
<dfn class="bio-label"><Icon icon="ruler" />Figure</dfn>
<span class="bio-value">{{ actor.bust || '??' }}{{ actor.cup || '?' }}-{{ actor.waist || '??' }}-{{ actor.hip || '??' }}</span>
<span class="bio-value">
{{ actor.bust || '??' }}{{ actor.cup || '?' }}-{{ actor.waist || '??' }}-{{ actor.hip || '??' }}
<Icon
v-if="actor.naturalBoobs === false || actor.naturalButt === false"
v-tooltip="[actor.naturalBoobs === false ? 'Enhanced boobs' : null, actor.naturalButt === false ? 'Enhanced butt' : null].filter(Boolean).join(', ')"
icon="magic-wand2"
class="enhanced"
/>
</span>
</li>
<li
v-if="actor.naturalBoobs === false || actor.naturalButt === false"
v-if="actor.naturalLips === false || actor.naturalLabia === false
|| (actor.naturalBoobs === false && !actor.bust) || actor.boobsVolume || actor.boobsPlacement || actor.boobsIncision || actor.boobsImplant
|| actor.buttVolume || actor.buttImplant"
class="bio-item augmentations hideable"
>
<dfn class="bio-label"><Icon icon="magic-wand2" />Enhanced</dfn>
<dfn class="bio-label"><Icon icon="magic-wand2" />Enhancements</dfn>
<span class="bio-value">
<div
@@ -284,7 +294,7 @@
<li
v-if="actor.agency"
class="bio-item"
class="bio-item hideable"
>
<dfn class="bio-label"><Icon icon="user-tie" />Agency</dfn>
@@ -294,7 +304,10 @@
>{{ actor.agency }}</span>
</li>
<div class="bio-item bio-socials hideable">
<div
v-if="socials.length > 0"
class="bio-item bio-socials hideable"
>
<ul class="socials">
<a
v-for="social in socials"
@@ -436,6 +449,7 @@ const showExpand = [
'bust',
'cup',
'eyes',
'ethnicity',
'hairColor',
'hasPiercings',
'hasTattoos',
@@ -615,7 +629,9 @@ const socials = props.actor.socials.map((social) => ({
}
.bio-value {
display: inline-block;
margin: 0 0 0 2rem;
max-width: 20rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
@@ -838,7 +854,7 @@ const socials = props.actor.socials.map((social) => ({
display: none;
justify-content: center;
position: absolute;
z-index: 1;
z-index: 10;
bottom: -.25rem;
}
@@ -944,6 +960,10 @@ const socials = props.actor.socials.map((social) => ({
margin: 0;
}
.bio-value {
max-width: initial;
}
.expanded .bio-value {
white-space: normal;
}

View File

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

View File

@@ -17,6 +17,14 @@
:class="{ active: pageContext.routeParams.section === 'revisions' && pageContext.routeParams.domain === 'actors' }"
>Actor Revisions</a>
</li>
<li class="nav-item">
<a
href="/admin/entities"
class="nav-link nolink"
:class="{ active: pageContext.routeParams.section === 'entities' }"
>Entity Health</a>
</li>
</ul>
</nav>

View File

@@ -1,6 +1,17 @@
<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
v-if="campaign?.banner?.type === 'html'"
v-else-if="campaign?.banner?.type === 'html'"
ref="iframe"
:width="campaign.banner.width"
:height="campaign.banner.height"
@@ -18,27 +29,40 @@
:href="campaign.url || campaign.affiliate?.url"
target="_blank"
class="campaign"
:style="{ 'background-image': backdrop && `url(${bannerSrc})` }"
:class="{ backdrop }"
data-umami-event="campaign-click"
:data-umami-event-campaign-id="`${campaign.entity.slug}-${campaign.id}`"
>
<img
:src="bannerSrc"
:width="campaign.banner.width"
:height="campaign.banner.height"
class="campaign-banner"
>
<div class="campaign-overlay">
<img
:src="bannerSrc"
:width="campaign.banner.width"
:height="campaign.banner.height"
class="campaign-banner"
>
</div>
</a>
</template>
<script setup>
import { inject } from 'vue';
const pageContext = inject('pageContext');
const { restriction } = pageContext;
const props = defineProps({
campaign: {
type: Object,
default: null,
},
backdrop: {
type: Boolean,
default: false,
},
});
// console.log(props.campaign?.banner);
// console.log(props.campaign);
const bannerSrc = (() => {
if (props.campaign.banner) {
@@ -60,6 +84,10 @@ const bannerSrc = (() => {
height: 100%;
display: flex;
justify-content: center;
border-radius: .25rem;
overflow: hidden;
background-size: cover;
background-position: center;
}
.frame {
@@ -67,11 +95,45 @@ const bannerSrc = (() => {
overflow: hidden;
}
.campaign-overlay {
width: 100%;
height: 100%;
display: flex;
box-sizing: border-box;
justify-content: center;
align-items: center;
}
.campaign-banner {
width: auto;
height: auto;
max-height: 100%;
max-width: 100%;
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>

View File

@@ -300,7 +300,7 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
}))];
}
if (dateKeys.includes(key)) {
if (dateKeys.includes(key) && value) {
return [key, new Date(value)];
}
@@ -334,7 +334,7 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
};
}
if (dateKeys.includes(delta.key)) {
if (dateKeys.includes(delta.key) && delta.value) {
return {
...delta,
value: new Date(delta.value),

View File

@@ -6,12 +6,16 @@
>Some actors may not be listed, apply a filter or search to narrow down results.</div>
<div class="filters-sort">
<input
v-model="search"
type="search"
:placeholder="`Filter ${availableActors.length} actors`"
class="input input-inline filters-search"
>
<label class="filter-search">
<input
v-model="search"
type="search"
:placeholder="`Filter ${availableActors.length} actors`"
class="input input-inline filters-search"
>
<Icon icon="search" />
</label>
<div
class="filter-sort noselect"

View File

@@ -1,12 +1,16 @@
<template>
<div class="filter channels-container">
<div class="filters-sort">
<input
v-model="search"
type="search"
:placeholder="`Filter ${channels.length} channels`"
class="input input-inline filters-search"
>
<label class="filter-search">
<input
v-model="search"
type="search"
:placeholder="`Filter ${channels.length} channels`"
class="input input-inline filters-search"
>
<Icon icon="search" />
</label>
<div
v-show="order === 'name'"

View File

@@ -1,17 +1,25 @@
<template>
<div
v-if="filteredCountries.length > 0"
class="countries-container"
>
<input
<label
v-if="!filters.country"
v-model="countryQuery"
type="search"
placeholder="Filter countries"
class="input input-inline countries-search"
class="filter-search"
>
<input
v-model="countryQuery"
type="search"
placeholder="Filter countries"
class="input input-inline countries-search filters-search"
>
<div class="countries-list">
<Icon icon="search" />
</label>
<div
v-if="filteredCountries.length > 0"
class="countries-list"
>
<Countries
v-if="!countryQuery && !filters.country && topCountries.length < filteredCountries.length"
:countries="topCountries"
@@ -25,6 +33,13 @@
@country="(alpha2) => emit('update', 'country', alpha2, true)"
/>
</div>
<div
v-else
class="empty"
>
No matching countries
</div>
</div>
</template>
@@ -60,13 +75,12 @@ const filteredCountries = computed(() => allCountries.value.filter((country) =>
<style scoped>
.countries-container {
border-bottom: solid 1px var(--shadow-weak-30);
padding: .25rem 0;
padding-bottom: .25rem;
margin-bottom: .5rem;
}
.countries-search {
width: 100%;
margin-bottom: .25rem;
.filter-search {
border-bottom: solid 1px var(--shadow-weak-40);
}
@@ -75,6 +89,12 @@ const filteredCountries = computed(() => allCountries.value.filter((country) =>
overflow-y: auto;
}
.empty {
padding: .5rem;
color: var(--glass);
font-style: italic;
}
:deep(.country.selected) .country-name {
padding: .5rem;
}

View File

@@ -272,7 +272,6 @@ function toggleFilters(state) {
&.order {
padding: 0 .5rem 0 .25rem;
}
.icon {
fill: var(--glass);
}
@@ -286,6 +285,26 @@ function toggleFilters(state) {
}
}
.filter-search {
display: flex;
flex-direction: row-reverse;
cursor: pointer;
.icon {
padding: .5rem .25rem .5rem .75rem;
fill: var(--glass-weak-20);
&:hover {
cursor: pointer;
fill: var(--primary);
}
}
.input:focus + .icon {
fill: var(--primary);
}
}
.filter-details {
display: flex;
align-items: stretch;
@@ -366,15 +385,15 @@ function toggleFilters(state) {
}
.filters-toggle {
min-width: 2rem;
height: 2.5rem;
min-width: 2.75rem;
height: 3rem;
display: none;
justify-content: center;
align-items: center;
padding: 0 .25rem;
position: absolute;
top: .35rem;
right: -2.5rem;
right: -3.25rem;
border-radius: 0 .5rem .5rem 0;
background: var(--background);
color: var(--glass);

View File

@@ -1,12 +1,16 @@
<template>
<div class="filter tags-container">
<div class="filters-sort">
<input
v-model="search"
type="search"
:placeholder="`Filter ${groupedTags.available.length} tags`"
class="input input-inline filters-search"
>
<label class="filter-search">
<input
v-model="search"
type="search"
:placeholder="`Filter ${groupedTags.available.length} tags`"
class="input input-inline filters-search"
>
<Icon icon="search" />
</label>
<div
v-show="order === 'priority'"

View File

@@ -0,0 +1,50 @@
<template>
<div
class="filters-toggle open"
@click.stop="emit('toggle')"
><Icon icon="filter" /></div>
</template>
<script setup>
const emit = defineEmits(['toggle']);
</script>
<style scoped>
.filters-toggle {
min-width: 2.75rem;
height: 3rem;
justify-content: center;
align-items: center;
padding: 0 .25rem;
border-radius: 0 .5rem .5rem 0;
background: var(--background);
color: var(--glass);
font-weight: bold;
box-shadow: inset 0 0 3px var(--shadow-weak-30);
&.open {
left: 0;
right: auto;
}
&.show-full {
display: flex;
}
&.close .cross {
display: none;
}
.icon {
fill: var(--glass);
}
&:hover {
cursor: pointer;
.icon {
fill: var(--primary);
}
}
}
</style>

View File

@@ -2,6 +2,14 @@
<footer class="footer">
<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
v-if="env.links.discord"
:href="env.links.discord"
@@ -56,7 +64,8 @@ const { env } = pageContext;
}
}
.discord .icon {
.discord .icon,
.matrix .icon {
height: 1.25rem;
width: 3.5rem;
fill: var(--glass-strong-10);

View File

@@ -21,6 +21,14 @@
</li>
-->
<li class="nav-item">
<Link
class="link"
:class="{ active: activePage === 'scenes' }"
href="/scenes"
>Scenes</Link>
</li>
<li class="nav-item">
<Link
class="link"
@@ -563,7 +571,7 @@ function blurSearch(event) {
fill: var(--error);
}
@media(--small) {
@media(--compact) {
.header-section {
flex-grow: 1;
}

View File

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

View File

@@ -37,6 +37,14 @@
:actors="aggActors"
@update="updateFilter"
/>
<div class="filter">
<Checkbox
:checked="filters.requireCover"
label="Require box cover"
@change="(checked) => updateFilter('requireCover', checked, true)"
/>
</div>
</Filters>
<div class="movies-container">
@@ -100,6 +108,7 @@ import YearsFilter from '#/components/filters/years.vue';
import ActorsFilter from '#/components/filters/actors.vue';
import TagsFilter from '#/components/filters/tags.vue';
import ChannelsFilter from '#/components/filters/channels.vue';
import Checkbox from '#/components/form/checkbox.vue';
import Pagination from '#/components/pagination/pagination.vue';
const pageContext = inject('pageContext');
@@ -139,6 +148,7 @@ const filters = ref({
tags: urlParsed.search.tags?.split(',').filter(Boolean) || [],
entity: queryEntity,
actors: queryActors,
requireCover: !!urlParsed.search.cover,
});
function getPath(targetScope, preserveQuery) {
@@ -178,6 +188,7 @@ async function search(options = {}) {
const query = {
q: filters.value.search || undefined,
cover: filters.value.requireCover || undefined,
};
const entity = filters.value.entity || pageEntity;
@@ -241,7 +252,7 @@ function updateFilter(prop, value, reload = true) {
.movies-header {
display: flex;
align-items: center;
padding: .5rem 1rem .25rem 3rem;
padding: .5rem 1rem .25rem 4rem;
}
.meta {

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@
type="search"
placeholder="Search scenes"
class="search input"
@keydown.enter="search"
@search="search"
>
<Icon
@@ -65,6 +65,7 @@
<Campaign
v-if="campaigns?.meta"
:campaign="campaigns.meta"
class="campaign-meta"
/>
<div class="views">
@@ -155,6 +156,7 @@
<Campaign
v-if="campaigns?.scope"
:campaign="campaigns.scope"
class="campaign-scope"
/>
</nav>
@@ -165,7 +167,10 @@
v-if="item === 'campaign' && sceneCampaign"
:key="`campaign-${item.id}`"
>
<Campaign :campaign="sceneCampaign" />
<Campaign
:campaign="sceneCampaign"
:backdrop="true"
/>
</li>
<li
@@ -393,7 +398,7 @@ function setView(newView) {
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: .5rem 1rem .25rem 3rem;
padding: .5rem 1rem .25rem 4rem;
.campaign {
max-height: 6rem;
@@ -431,11 +436,11 @@ function setView(newView) {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: .5rem;
padding: .5rem 1rem 1rem 1rem;
}
:deep(.campaign) .campaign-banner {
border-radius: .25rem;
box-shadow: 0 0 3px var(--shadow-weak-20);
}
:deep(.campaign-meta) .campaign-banner,
:deep(.campaign-scope) .campaign-banner {
width: auto;
}
.scopes {

View File

@@ -37,6 +37,7 @@
class="input edit"
@input="update"
@blur="save(false)"
@keydown.tab.prevent="indent"
/>
<textarea
@@ -126,7 +127,7 @@ const changed = ref(false);
const templateName = ref(initialTemplate?.name || `custom_${Date.now()}`);
function getSummary() {
return processSummaryTemplate(template.value, props.release);
return processSummaryTemplate(template.value, props.release, pageContext.env);
}
const summary = ref(getSummary());
@@ -227,6 +228,11 @@ function reset() {
}
}
function indent() {
// YAML does not support tabs
input.value.setRangeText(' ', input.value.selectionStart, input.value.selectionEnd, 'end');
}
onMounted(() => {
window.addEventListener('beforeunload', (event) => {
if (changed.value) {

View File

@@ -38,6 +38,7 @@
<Meta
:scene="scene"
:user="user"
class="meta-full"
/>
@@ -129,7 +130,7 @@ const props = defineProps({
});
const pageContext = inject('pageContext');
const user = pageContext.user;
const { user } = pageContext;
const pageStash = pageContext.pageProps.stash;
const currentStash = pageStash || pageContext.assets?.primaryStash;
@@ -213,7 +214,7 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
.title {
display: block;
margin-top: .5rem;
margin-bottom: .4rem;
margin-bottom: .3rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@@ -238,7 +239,8 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
}
.actors {
height: 1rem;
height: 1.15rem;
margin-bottom: .15rem;
overflow: hidden;
white-space: pre-wrap;
}
@@ -301,12 +303,12 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
}
.row {
margin: 0 .5rem .5rem .5rem;
margin: 0 .5rem .45rem .5rem;
}
.title {
margin-top: .6rem;
margin-bottom: .6rem;
margin-bottom: .5rem;
}
}
}

View File

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

View File

@@ -41,10 +41,12 @@ const cookies = Cookies.withConverter({
const tags = {
anal: 'anal',
'anal-prolapse': 'anal prolapse',
'extreme-insertion': 'extreme insertion (oversized dildos)',
pissing: 'pissing',
gay: 'gay',
transsexual: 'transsexual',
bisexual: 'bisexual',
compilation: 'compilation',
bts: 'behind the scenes',
vr: 'virtual reality',
};
@@ -65,9 +67,10 @@ function toggleTag(tag, isChecked) {
<style scoped>
.dialog-body {
padding: 1rem;
width: 30rem;
max-width: 100%;
box-sizing: border-box;
padding: 1rem;
overflow-y: auto;
}

View File

@@ -20,12 +20,12 @@
<ul class="nolist menu">
<li
class="menu-item"
:class="{ active: activePage === 'updates' }"
:class="{ active: activePage === 'scenes' }"
>
<a
href="/updates"
href="/scenes"
class="menu-link nolink"
>Updates</a>
>Scenes</a>
</li>
<li

View File

@@ -2,7 +2,7 @@
<div class="stash">
<div class="stash-header">
<a
:href="`/stash/${profile.username}/${stash.slug}`"
:href="`/stash/${profile.username}/${stash.slug}/${primaryDomain}`"
class="stash-name ellipsis nolink"
>
<span class="ellipsis">{{ stash.name }}</span>
@@ -151,6 +151,14 @@ const stashNameInput = ref(null);
const showRenameDialog = ref(false);
const done = ref(true);
const domainCounts = {
scenes: props.stash.stashedScenes,
actors: props.stash.stashedActors,
movies: props.stash.stashedMovies,
};
const primaryDomain = Object.entries(domainCounts).toSorted((domainA, domainB) => domainB[1] - domainA[1])[0][0];
async function setPublic(isPublic) {
if (done.value === false) {
return;

64
components/tags/logo.vue Normal file
View File

@@ -0,0 +1,64 @@
<template>
<a
v-if="photo?.entity"
v-tooltip="photo.entity.name"
:to="`/${photo.entity.type}/${photo.entity.slug}`"
>
<img
v-if="favicon && photo.entity.type !== 'network' && !photo.entity.independent && photo.entity.parent"
:src="`/logos/${photo.entity.parent.slug}/favicon.png`"
class="album-logo favicon"
>
<img
v-else-if="favicon"
:src="`/logos/${photo.entity.slug}/favicon.png`"
class="album-logo favicon"
>
<img
v-else-if="photo.entity.type !== 'network' && !photo.entity.independent && photo.entity.parent"
:src="`/logos/${photo.entity.parent.slug}/${photo.entity.slug}.png`"
class="album-logo"
>
<img
v-else
:src="`/logos/${photo.entity.slug}/network.png`"
class="album-logo"
>
</a>
</template>
<script setup>
defineProps({
photo: {
type: Object,
default: null,
},
favicon: {
type: Boolean,
default: false,
},
});
</script>
<style scoped>
.album-logo {
max-height: .75rem;
max-width: 5rem;
position: absolute;
right: 0;
bottom: 0;
padding: .5rem;
transition: transform .25s ease, opacity .25s ease;
filter: drop-shadow(0 0 2px var(--shadow-weak));
}
@media(--small) {
.album-logo:not(.favicon) {
max-height: .5rem;
max-width: 3.5rem;
}
}
</style>

150
components/tags/photos.vue Normal file
View File

@@ -0,0 +1,150 @@
<template>
<div class="photos nobar">
<Campaign
v-if="campaigns?.photos"
:campaign="campaigns.photos"
/>
<div
v-for="photo in photos"
:key="`photo-${photo.id}`"
:title="photo.comment"
target="_blank"
rel="noopener noreferrer"
class="photo-container"
>
<img
:src="getPath(photo, 'thumbnail', { local: true })"
:style="{ 'background-image': `url(${getPath(photo, 'lazy', { local: true })})` }"
:alt="photo.comment"
:width="photo.width"
:height="photo.height"
class="photo"
loading="lazy"
@load="emit('load', $event)"
>
<Logo :photo="photo" />
<a
v-if="photo.comment && photo.entity"
:href="`/${photo.entity.type}/${photo.entity.slug}`"
class="photo-comment"
>{{ photo.comment }} for {{ photo.entity.name }}</a>
<span
v-else-if="photo.comment"
class="photo-comment"
>{{ photo.comment }}</span>
</div>
</div>
</template>
<script setup>
import { computed, inject } from 'vue';
import Logo from '#/components/tags/logo.vue';
import Campaign from '#/components/campaigns/campaign.vue';
import getPath from '#/src/get-path.js';
const props = defineProps({
tag: {
type: Object,
default: null,
},
});
const emit = defineEmits(['load', 'campaign']);
const { campaigns } = inject('pageContext');
const photos = computed(() => {
/* sfw not currently implemented
if (props.tag.poster && this.$store.state.ui.sfw) {
return [props.tag.poster].concat(props.tag.photos).map((photo) => photo.sfw);
}
if (this.$store.state.ui.sfw) {
return props.tag.photos.map((photo) => photo.sfw);
}
*/
if (props.tag.poster) {
return [props.tag.poster].concat(props.tag.photos);
}
return props.tag.photos;
});
</script>
<style scoped>
.photos {
display: flex;
flex-shrink: 0;
gap: 0.5rem;
padding: .5rem;
border-bottom: solid 1px var(--shadow-weak-40);
background: var(--background-base-10);
overflow-x: auto;
}
.photo-container {
position: relative;
flex-shrink: 0;
font-size: 0;
overflow: hidden;
&:hover {
.photo-comment {
transform: translateY(0);
}
::v-deep(.album-logo) {
opacity: 0;
}
}
}
.photo,
.campaign {
height: 14rem;
width: auto;
flex-shrink: 0;
}
.photo {
object-fit: cover;
object-position: 50% 0;
border-radius: .25rem;
background-size: cover;
background-position: center;
box-shadow: 0 0px 3px var(--shadow-weak-30);
}
.photo-comment {
width: 100%;
position: absolute;
bottom: -1px;
left: 0;
box-sizing: border-box;
padding: .5rem;
color: var(--text-light);
background: var(--shadow);
font-size: .9rem;
text-shadow: 0 0 3px var(--shadow);
text-decoration: none;
white-space: normal;
line-height: 1.25;
transform: translateY(100%);
transition: transform .25s ease;
border-radius: 0 0 .25rem .25rem;
}
@media(--small) {
.photo,
.campaign {
height: 11rem;
}
}
</style>

View File

@@ -91,8 +91,8 @@
<h3 class="info-heading">HTTP headers</h3>
<code class="headers">
API-User: {{ user.id }}<br>
API-Key: YourSecurelyStoredApiKey12345678
API-User: <strong>{{ user.id }}</strong><br>
API-Key: <em>YourSecretKey</em>
</code>
</div>
</div>

View File

@@ -1,5 +1,6 @@
module.exports = {
title: 'traxxx',
origin: 'http://localhost:5100', // only used for absolute links
database: {
owner: {
host: '127.0.0.1',
@@ -57,34 +58,105 @@ module.exports = {
address: 'http://localhost:3000/script.js',
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: {
login: true,
signup: true,
usernameLength: [2, 24],
usernamePattern: /^[a-zA-Z0-9_-]+$/,
captcha: {
enabled: false,
siteKey: '10000000-ffff-ffff-ffff-000000000001',
secretKey: '0x0000000000000000000000000000000000000000',
},
},
bans: {
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
},
socials: {
urls: {
cashapp: 'https://cash.app/${handle}', // eslint-disable-line no-template-curly-in-string
fansly: 'https://fansly.com/{handle}',
instagram: 'https://www.instagram.com/{handle}',
linktree: 'https://linktr.ee/{handle}',
loyalfans: 'https://www.loyalfans.com/{handle}',
manyvids: 'https://{handle}.manyvids.com',
onlyfans: 'https://onlyfans.com/{handle}',
pornhub: 'https://www.pornhub.com/model/{handle}',
reddit: 'https://www.reddit.com/u/{handle}',
twitter: 'https://x.com/{handle}',
},
prefix: {
default: '@',
cashapp: '$',
reddit: 'u/',
},
},
apiAccess: {
graphqlEnabled: true,
keySize: 24, // bytes
@@ -102,6 +174,7 @@ module.exports = {
links: {
content: 'mailto:content@traxxx.me',
discord: 'https://discord.gg/gY6fnq6jJV',
matrix: 'https://matrix.to/#/#traxxx:matrix.unknown.name',
},
stashes: {
nameLength: [2, 24],
@@ -114,8 +187,9 @@ module.exports = {
},
media: {
path: './media',
assetPath: '/img',
assetPath: '',
mediaPath: '/media',
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: [
{
name: 'traxxx',
script: 'npm',
args: 'run server:prod',
// script: 'npm',
// args: 'run server:prod',
script: './src/app.js',
exec_mode: 'cluster',
instances: 2,
restart_delay: 3000,

242
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "traxxx-web",
"version": "0.40.7",
"version": "0.46.16",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"version": "0.40.7",
"version": "0.46.16",
"dependencies": {
"@brillout/json-serializer": "^0.5.8",
"@dicebear/collection": "^7.0.5",
@@ -13,6 +13,8 @@
"@faker-js/faker": "^8.4.1",
"@floating-ui/dom": "^1.5.3",
"@floating-ui/vue": "^1.0.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@maxmind/geoip2-node": "^6.3.4",
"@resvg/resvg-js": "^2.6.0",
"@toycode/markdown-it-class": "^1.2.4",
"@vitejs/plugin-vue": "^4.5.2",
@@ -28,6 +30,7 @@
"cron": "^3.1.6",
"cross-env": "^7.0.3",
"date-fns": "^3.0.0",
"ejs": "^4.0.1",
"error-stack-parser": "^2.1.4",
"escape-string-regexp": "^5.0.0",
"express": "^4.18.2",
@@ -38,6 +41,7 @@
"graphql": "^16.9.0",
"graphql-parse-resolve-info": "^4.13.0",
"graphql-scalars": "^1.24.2",
"hcaptcha": "^0.2.0",
"ip-cidr": "^4.0.0",
"js-cookie": "^3.0.5",
"knex": "^3.1.0",
@@ -50,6 +54,7 @@
"mysql": "^2.18.1",
"nanoid": "^5.0.4",
"object.omit": "^3.0.0",
"obscenity": "^0.4.6",
"path-to-regexp": "^6.2.1",
"pg": "^8.11.3",
"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": {
"version": "0.11.13",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
@@ -3210,6 +3227,15 @@
"@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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@modyfi/vite-plugin-yaml/-/vite-plugin-yaml-1.1.0.tgz",
@@ -4513,9 +4539,10 @@
"peer": true
},
"node_modules/async": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz",
"integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg=="
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
@@ -5563,6 +5590,21 @@
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"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": {
"version": "1.4.616",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz",
@@ -6776,6 +6818,36 @@
"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": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -7319,6 +7391,12 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
@@ -8025,6 +8103,23 @@
"@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": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
@@ -8490,6 +8585,20 @@
"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": {
"version": "2.0.0",
"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",
"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": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -8971,6 +9090,15 @@
"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": {
"version": "2.4.1",
"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",
"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": {
"version": "0.2.2",
"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": {
"version": "0.11.13",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
@@ -13892,6 +14037,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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@modyfi/vite-plugin-yaml/-/vite-plugin-yaml-1.1.0.tgz",
@@ -14731,9 +14884,9 @@
"peer": true
},
"async": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz",
"integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg=="
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="
},
"asynckit": {
"version": "0.4.0",
@@ -15490,6 +15643,14 @@
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"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": {
"version": "1.4.616",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz",
@@ -16406,6 +16567,32 @@
"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": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -16771,6 +16958,11 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
@@ -17244,6 +17436,16 @@
"@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": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
@@ -17604,6 +17806,15 @@
"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": {
"version": "2.0.0",
"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",
"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": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -17956,6 +18172,11 @@
"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": {
"version": "2.4.1",
"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",
"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": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.2.tgz",

View File

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

View File

@@ -37,10 +37,10 @@
<div
class="photos nobar"
:class="{ 'has-avatar': actor.avatar, 'has-photos': actor.avatar ? actor.photos.length > 1 : actor.photos.length > 0 }"
:class="{ 'has-avatar': actor.avatar, 'has-photos': actor.avatar ? photos.length > 1 : photos.length > 0 }"
>
<div
v-for="photo in actor.photos"
v-for="photo in photos"
:key="`photo-${photo.id}`"
class="photo-container"
:class="{ avatar: photo.isAvatar }"
@@ -92,6 +92,9 @@ const { pageProps, routeParams } = pageContext;
const { actor } = pageProps;
const domain = routeParams.domain;
const badCredits = ['Pierre Woodman']; // consistently horrible photos
const photos = actor.photos.filter((photo) => photo.entropy > 5.5 && !badCredits.includes(photo.credit));
</script>
<style scoped>

View File

@@ -6,6 +6,7 @@ import { fetchMovies } from '#/src/movies.js';
import { curateScenesQuery } from '#/src/web/scenes.js';
import { curateMoviesQuery } from '#/src/web/movies.js';
import { fetchCountries } from '#/src/countries.js';
import { getRandomCampaigns, getCampaignIndex } from '#/src/campaigns.js';
async function fetchReleases(pageContext) {
if (pageContext.routeParams.domain === 'movies') {
@@ -40,16 +41,27 @@ export async function onBeforeRender(pageContext) {
throw redirect(`/login?r=${encodeURIComponent(pageContext.urlOriginal)}`);
}
const [[actor], actorReleases, countries] = await Promise.all([
fetchActorsById([Number(pageContext.routeParams.actorId)], {}, pageContext.user),
fetchReleases(pageContext),
isEditing && fetchCountries(),
]);
const [actor] = await fetchActorsById([Number(pageContext.routeParams.actorId)], {}, pageContext.user);
if (!actor) {
throw render(404, `Cannot find actor '${pageContext.routeParams.actorId}'.`);
}
const [actorReleases, campaigns, countries] = await Promise.all([
fetchReleases(pageContext),
getRandomCampaigns([
// don't show meta campaign, too intrusive under actor bio
{ minRatio: 3 },
pageContext.routeParams.domain === 'scenes'
? { minRatio: 0.75, maxRatio: 1.25 }
: null,
].filter(Boolean), { tagFilter: pageContext.tagFilter }),
isEditing && fetchCountries(),
]);
const campaignIndex = getCampaignIndex(actorReleases.limit);
const [paginationCampaign, sceneCampaign] = campaigns;
return {
pageContext: {
title: isEditing
@@ -60,6 +72,11 @@ export async function onBeforeRender(pageContext) {
countries,
...actorReleases,
},
campaigns: {
index: campaignIndex,
scenes: actorReleases.limit > 5 && sceneCampaign,
pagination: paginationCampaign,
},
},
};
}

View File

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

View File

@@ -16,6 +16,13 @@
class="link"
>Actor Revisions</a>
</li>
<li>
<a
href="/admin/entities"
class="link"
>Entity Health</a>
</li>
</ul>
</Admin>
</template>

View File

@@ -0,0 +1,187 @@
<template>
<Admin class="page">
<div class="header">
<div class="params">
<label>
Alert: <input
v-model="alertThreshold"
type="number"
placeholder="Alert threshold"
class="input"
> weeks
</label>
<label>
Dead: <input
v-model="deadThreshold"
type="number"
placeholder="Alert threshold"
class="input"
> months
</label>
</div>
<span class="attention">{{ alertEntities.length }} entities might require your attention</span>
</div>
<table class="table">
<thead>
<tr>
<th class="table-header">Entity</th>
<th class="table-header">Network</th>
<th
class="table-header noselect"
@click="sort('releases')"
>Releases</th>
<th
class="table-header noselect"
@click="sort('latest')"
>Latest release</th>
</tr>
</thead>
<tbody>
<tr
v-for="entity in alertEntities"
:key="`entity-${entity.id}`"
>
<td
:title="entity.id"
class="table-cell table-name ellipsis"
>
<a
:href="`/${entity.type}/${entity.slug}`"
target="_blank"
class="link"
>{{ entity.name }}</a>
</td>
<td
v-if="entity.parent"
:title="entity.paren?.id"
class="table-cell table-name ellipsis"
>
<a
:href="`/network/${entity.parent.slug}`"
target="_blank"
class="link"
>{{ entity.parent.name }}</a>
</td>
<td v-else />
<td class="table-cell table-total">{{ entity.totalReleases }}</td>
<td
class="table-cell table-date"
:class="{ alert: entity.latestReleaseDate && entity.latestReleaseDate < alertDate }"
>{{ entity.latestReleaseDate && format(entity.latestReleaseDate, 'yyyy-MM-dd hh:mm') }}</td>
</tr>
</tbody>
</table>
</Admin>
</template>
<script setup>
import {
ref,
computed,
watch,
inject,
} from 'vue';
import { format, subMonths, subWeeks } from 'date-fns';
import navigate from '#/src/navigate.js';
import Admin from '#/components/admin/admin.vue';
const {
pageProps,
urlParsed,
meta,
} = inject('pageContext');
const { entities } = pageProps;
const alertThreshold = ref(Number(urlParsed.search.alert) || 12);
const deadThreshold = ref(Number(urlParsed.search.dead) || 36);
const order = urlParsed.search.order || 'desc';
const alertDate = computed(() => subWeeks(meta.now, alertThreshold.value));
const deadDate = computed(() => subMonths(meta.now, deadThreshold.value));
const alertEntities = computed(() => entities.filter((entity) => entity.latestReleaseDate > deadDate.value && entity.latestReleaseDate < alertDate.value));
function sort(sorting) {
navigate('/admin/entities', {
sort: sorting,
order: order === 'desc' ? 'asc' : 'desc',
alert: alertThreshold.value,
dead: deadThreshold.value,
}, {
redirect: true,
});
}
watch([alertThreshold, deadThreshold], () => {
navigate('/admin/entities', {
...urlParsed.search,
alert: alertThreshold.value,
dead: deadThreshold.value,
}, {
redirect: false,
});
});
</script>
<style scoped>
.page {
flex-grow: 1;
}
.header {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.params {
display: flex;
align-items: center;
gap: 2rem;
.input {
width: 5rem;
}
}
.attention {
margin-left: 2rem;
color: var(--warn);
font-weight: bold;
}
.table-header {
text-align: left;
}
.table-name {
width: 12rem;
}
.table-total {
width: 6rem;
}
.alert {
color: var(--warn);
font-weight: bold;
}
.link {
color: var(--text);
}
</style>

View File

@@ -0,0 +1,27 @@
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
import { fetchEntityHealths } from '#/src/entities.js';
export async function onBeforeRender(pageContext) {
if (!pageContext.user || pageContext.user.role === 'user') {
throw render(404);
}
const {
entities,
} = await fetchEntityHealths({
sort: pageContext.urlParsed.search.sort || 'releases',
order: pageContext.urlParsed.search.order || 'desc',
}, pageContext.user);
return {
pageContext: {
title: pageContext.routeParams.section,
pageProps: {
entities,
},
routeParams: {
section: 'entities',
},
},
};
}

View File

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

View File

@@ -112,7 +112,18 @@
</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
href="/login"
@@ -124,12 +135,13 @@
<script setup>
import { ref, onMounted, inject } from 'vue';
import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
import { post } from '#/src/api.js';
import navigate from '#/src/navigate.js';
const pageContext = inject('pageContext');
const user = pageContext.user;
const { user, env } = pageContext;
const username = ref('');
const email = ref('');
@@ -137,28 +149,39 @@ const password = ref('');
const passwordConfirm = ref('');
const errorMsg = ref(null);
const submitted = ref(false);
const userInput = ref(null);
const showPassword = ref(false);
const captcha = ref(null);
async function signup() {
errorMsg.value = null;
submitted.value = true;
if (password.value !== passwordConfirm.value) {
errorMsg.value = 'Passwords do not match';
return;
}
if (env.captcha.enabled && !captcha.value) {
errorMsg.value = 'Please complete the CAPTCHA';
return;
}
try {
const newUser = await post('/users', {
username: username.value,
email: email.value,
password: password.value,
redirect: pageContext.urlParsed.search.r,
captcha: captcha.value,
});
navigate(`/user/${newUser.username}`, null, { redirect: true });
} catch (error) {
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 {
background: var(--error);
color: var(--text-light);

View File

@@ -61,7 +61,7 @@
</template>
<script setup>
import { ref, inject, onMounted } from 'vue';
import { ref, inject } from 'vue';
import navigate from '#/src/navigate.js';
@@ -75,20 +75,19 @@ const networksBySlug = Object.fromEntries(networks.map((network) => [network.slu
const popularNetworks = [
'21sextury',
'adulttime',
'amateurallure',
'analvids',
'bamvisions',
'bang',
'bangbros',
'blowpass',
'brazzers',
'burningangel',
'digitalplayground',
'dogfartnetwork',
'dorcel',
'elegantangel',
'evilangel',
'fakehub',
'hentaied',
'hookuphotshot',
'hussiepass',
'julesjordan',
@@ -102,7 +101,10 @@ const popularNetworks = [
'pornworld',
'private',
'realitykings',
'rickysroom',
'score',
'teamskeet',
'kellymadison',
'vixen',
'xempire',
].map((slug) => networksBySlug[slug]).filter(Boolean);
@@ -120,15 +122,11 @@ const sections = [
},
].filter(Boolean);
// const tags = Object.values(Object.fromEntries(networks.flatMap((entity) => entity.tags).map((tag) => [tag.id, tag])));
async function search() {
navigate('/channels', { q: query.value || undefined }, { redirect: true });
}
onMounted(() => {
window.addEventListener('load', (event) => {
console.log(event);
});
});
</script>
<style scoped>

View File

@@ -3,7 +3,9 @@ import { fetchEntities } from '#/src/entities.js';
export async function onBeforeRender(pageContext) {
const networks = await fetchEntities(pageContext.urlParsed.search.q
? { query: pageContext.urlParsed.search.q }
: { type: 'primary' });
: { type: 'primary' }, {
restriction: pageContext.restriction,
});
return {
pageContext: {

View File

@@ -43,6 +43,14 @@
>{{ entity.name }}</h2>
</a>
<a
v-if="user?.abilities.some((ability) => ability.plainUrls)"
:href="entity.url"
target="_blank"
rel="noopener"
class="plainurl"
><Icon icon="link" /></a>
<Heart
domain="entities"
:item="entity"
@@ -154,7 +162,7 @@ import Movies from '#/components/movies/movies.vue';
import Domains from '#/components/domains/domains.vue';
import Heart from '#/components/stashes/heart.vue';
const { pageProps, routeParams } = inject('pageContext');
const { pageProps, routeParams, user } = inject('pageContext');
const { entity } = pageProps;
const children = ref(null);
@@ -163,22 +171,7 @@ const expanded = ref(false);
const scrollable = computed(() => children.value?.scrollWidth > children.value?.clientWidth);
const domain = routeParams.domain;
const entityUrl = (() => {
if (!entity.url) {
return null;
}
if (!entity.affiliate?.parameters) {
return entity.url;
}
const newParams = new URLSearchParams({
...Object.fromEntries(new URL(entity.url).searchParams),
...Object.fromEntries(new URLSearchParams(entity.affiliate.parameters)),
});
return `${entity.url}?${newParams}`;
})();
const entityUrl = entity.affiliateUrl || entity.url || null;
</script>
<style scoped>
@@ -326,6 +319,25 @@ const entityUrl = (() => {
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) {
.logo {
padding: .5rem 1rem;

View File

@@ -20,7 +20,9 @@ async function fetchReleases(pageContext, entityId) {
page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 30,
aggregate: true,
}, pageContext.user);
}, pageContext.user, {
restriction: pageContext.restriction,
});
}
return fetchScenes(await curateScenesQuery({
@@ -32,7 +34,9 @@ async function fetchReleases(pageContext, entityId) {
page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 30,
aggregate: true,
}, pageContext.user);
}, pageContext.user, {
restriction: pageContext.restriction,
});
}
export async function onBeforeRender(pageContext) {
@@ -47,19 +51,21 @@ export async function onBeforeRender(pageContext) {
[entity],
entityReleases,
] = await Promise.all([
fetchEntitiesById([Number(entityId)], { includeChildren: true }, pageContext.user),
fetchEntitiesById([Number(entityId)], { includeChildren: true }, pageContext.user, {
restriction: pageContext.restriction,
}),
fetchReleases(pageContext, entityId),
]);
const campaigns = await getRandomCampaigns([
{
entityIds: [entity.id, entity.parent?.id].filter(Boolean),
minRatio: 1.5,
minRatio: 3,
allowRandomFallback: false,
},
{
entityIds: [entity.id, entity.parent?.id].filter(Boolean),
minRatio: 1.5,
minRatio: 3,
allowRandomFallback: false,
},
pageContext.routeParams.domain === 'scenes' ? {

View File

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

View File

@@ -277,12 +277,15 @@ const scenes = pageContext.pageProps.scenes;
}
.title {
margin: 0 .5rem 1rem 0;
margin: 0 .5rem 0 0;
line-height: 1.25;
/*
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
*/
}
.notitle {

View File

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

View File

@@ -52,7 +52,7 @@
</div>
<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')}`"
target="_blank"
class="date nolink"
@@ -249,12 +249,6 @@
</div>
</div>
<Chapters
v-if="scene.chapters.length > 0"
:chapters="scene.chapters"
class="section"
/>
<div
v-if="scene.description"
class="section"
@@ -264,6 +258,18 @@
<p class="description">{{ scene.description }}</p>
</div>
<section
v-if="scene.chapters.length > 0"
class="section"
>
<h3 class="heading">Chapters</h3>
<Chapters
:chapters="scene.chapters"
class="section"
/>
</section>
<div
v-if="campaigns?.scene"
class="section"
@@ -328,7 +334,7 @@
</div>
<ul
v-if="user && assets.templates.length > 0"
v-if="templates.length > 0"
class="nolist templates"
>
<Icon icon="markup" />
@@ -343,6 +349,43 @@
</ul>
</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
class="scene-actions section"
>
@@ -381,6 +424,7 @@ import Heart from '#/components/stashes/heart.vue';
import Campaign from '#/components/campaigns/campaign.vue';
import defaultTemplate from '#/assets/summary.yaml?raw'; // eslint-disable-line import/no-unresolved
import markdownTemplate from '#/assets/markdown.yaml?raw'; // eslint-disable-line import/no-unresolved
const cookies = Cookies.withConverter({
write: (value) => value,
@@ -399,6 +443,8 @@ const { scene } = pageProps;
const showSummaryDialog = ref(false);
const qualities = {
4320: '8K',
2280: '5K',
2160: '4K',
1440: 'Quad HD',
1080: 'Full HD',
@@ -418,6 +464,11 @@ const templates = [
name: 'traxxx',
template: defaultTemplate,
},
{
id: 1,
name: 'markdown',
template: markdownTemplate,
},
...(assets?.templates || []),
];
@@ -431,7 +482,7 @@ function selectTemplate(templateId, allowFallback = true) {
const template = targetTemplate || templates[0];
summary.value = processSummaryTemplate(template.template, scene);
summary.value = processSummaryTemplate(template.template, scene, env);
selectedTemplate.value = template.id;
cookies.set('selectedTemplate', String(templateId));
@@ -538,6 +589,8 @@ function copySummary() {
.title {
margin: .25rem .5rem .5rem 0;
line-height: 1.25;
/*
display: -webkit-box;
&:not(:active) {
@@ -545,6 +598,7 @@ function copySummary() {
-webkit-line-clamp: 2;
overflow: hidden;
}
*/
}
.notitle {
@@ -729,6 +783,55 @@ function copySummary() {
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) {
.content {
margin: 0;
@@ -763,7 +866,7 @@ function copySummary() {
}
.entity-logo {
width: 7.5rem;
max-width: 7.5rem;
}
}

View File

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

View File

@@ -66,7 +66,16 @@
class="row"
>
<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">
<Icon
@@ -262,11 +271,13 @@ const fields = computed(() => [
key: 'title',
type: 'string',
value: scene.value.title,
note: 'Do not correct language errors unless source was updated.',
},
{
key: 'description',
type: 'text',
value: scene.value.description,
note: 'Do not correct language errors unless source was updated.',
},
{
key: 'date',
@@ -282,6 +293,7 @@ const fields = computed(() => [
key: 'duration',
type: 'duration',
value: [Math.floor(scene.value.duration / 3600), Math.floor((scene.value.duration % 3600) / 60), scene.value.duration % 60],
simplify: false,
},
{
key: 'productionDate',
@@ -304,16 +316,16 @@ const fields = computed(() => [
}]),
]);
function simplifyArray(value) {
if (Array.isArray(value)) {
return value.map((item) => item.hash || item.id);
function simplifyArray(field) {
if (Array.isArray(field.value) && field.simplify !== false) {
return field.value.map((item) => item.hash || item.id);
}
return value;
return field.value;
}
const editing = ref(new Set());
const edits = ref(Object.fromEntries(fields.value.map((field) => [field.key, simplifyArray(field.value)])));
const edits = ref(Object.fromEntries(fields.value.map((field) => [field.key, simplifyArray(field)])));
const comment = ref(null);
const apply = ref(user.role !== 'user');
const submitted = ref(false);
@@ -332,7 +344,6 @@ const keyMap = {
function toggleField(item) {
if (editing.value.has(item.key)) {
editing.value.delete(item.key);
// delete edits.value[item.key];
return;
}
@@ -358,7 +369,8 @@ async function submit() {
return [[key, edits.value[key]]];
})),
duration: edits.value.duration
// duration: edits.value.duration
duration: editing.value.has('duration') && edits.value.duration
? (((edits.value.duration[0] || 0) * 3600) + ((edits.value.duration[1] || 0) * 60) + (edits.value.duration[2] || 0)) || null
: undefined,
},
@@ -407,6 +419,8 @@ async function submit() {
.key {
width: 8rem;
display: inline-flex;
align-items: center;
text-transform: capitalize;
font-weight: bold;
}
@@ -472,6 +486,16 @@ async function submit() {
}
}
.item-note{
fill: var(--glass);
padding: .5rem .75rem;
cursor: help;
&:hover {
fill: var(--primary);
}
}
.editor-footer {
display: flex;
flex-direction: column;

24
pages/scenes/+Page.vue Normal file
View File

@@ -0,0 +1,24 @@
<template>
<div class="updates">
<Scenes
:show-filters="true"
:show-meta="true"
:show-scope-tabs="false"
/>
</div>
</template>
<script setup>
// import { inject } from 'vue';
import Scenes from '#/components/scenes/scenes.vue';
// const pageContext = inject('pageContext');
</script>
<style scoped>
.updates {
display: flex;
flex-grow: 1;
}
</style>

View File

@@ -0,0 +1,49 @@
import { fetchScenes } from '#/src/scenes.js';
import { curateScenesQuery } from '#/src/web/scenes.js';
import { getRandomCampaigns, getCampaignIndex } from '#/src/campaigns.js';
export async function onBeforeRender(pageContext) {
const [
sceneResults,
campaigns,
] = await Promise.all([
fetchScenes(await curateScenesQuery({
...pageContext.urlQuery,
scope: pageContext.routeParams.scope || 'latest',
isShowcased: null,
tagFilter: pageContext.tagFilter,
}), {
page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 29,
aggregate: true,
dedupe: true,
}, pageContext.user, {
restriction: pageContext.restriction,
}),
getRandomCampaigns([
{ minRatio: 0.75, maxRatio: 1.25 },
{ minRatio: 1.5 },
], { tagFilter: pageContext.tagFilter }),
]);
const {
scenes,
} = sceneResults;
const campaignIndex = getCampaignIndex(scenes.length);
const [sceneCampaign, paginationCampaign] = campaigns;
return {
pageContext: {
title: pageContext.routeParams.scope,
pageProps: {
...sceneResults,
},
campaigns: {
index: campaignIndex,
scenes: scenes.length > 5 && sceneCampaign,
pagination: paginationCampaign,
},
},
};
}

21
pages/scenes/+route.js Normal file
View File

@@ -0,0 +1,21 @@
import { match } from 'path-to-regexp';
// import { resolveRoute } from 'vike/routing'; // eslint-disable-line import/extensions
const path = '/scenes/:scope?/:page?';
const urlMatch = match(path, { decode: decodeURIComponent });
export default (pageContext) => {
const matched = urlMatch(pageContext.urlPathname);
if (matched) {
return {
routeParams: {
scope: matched.params.scope || 'latest',
page: matched.params.page || '1',
path,
},
};
}
return false;
};

View File

@@ -83,7 +83,7 @@
Found {{ sceneTotal }} {{ sceneTotal > 1 ? 'scenes' : 'scene' }}
<a
:href="`/updates/results/?q=${query}`"
:href="`/scenes/results/?q=${query}`"
class="link"
>Full scene results</a>
</span>

View File

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

View File

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

View File

@@ -75,8 +75,8 @@
>
<img
v-if="tag.poster"
:src="`/${tag.poster.thumbnail}`"
:style="{ 'background-image': `url(/${tag.poster.lazy})` }"
:src="getPath(tag.poster, 'thumbnail', { local: true })"
:style="{ 'background-image': `url(${getPath(tag.poster, 'lazy', { local: true })})` }"
:title="tag.poster.comment"
class="thumb"
loading="lazy"
@@ -89,18 +89,10 @@
>
</a>
<a
v-if="tag.poster?.entity"
:href="`/${tag.poster.entity.type}/${tag.poster.entity.slug}`"
class="favicon-link"
>
<img
:src="!tag.poster.entity.parent || tag.poster.entity.isIndependent ? `/logos/${tag.poster.entity.slug}/favicon.png` : `/logos/${tag.poster.entity.parent.slug}/favicon.png`"
:alt="tag.poster.entity.name"
:title="tag.poster.entity.name"
class="favicon"
>
</a>
<Logo
:photo="tag.poster"
:favicon="true"
/>
</div>
<a
@@ -119,6 +111,9 @@ import { ref, onMounted, inject } from 'vue';
import navigate from '#/src/navigate.js';
import events from '#/src/events.js';
import getPath from '#/src/get-path.js';
import Logo from '#/components/tags/logo.vue';
const pageContext = inject('pageContext');
const showcase = pageContext.pageProps.tagShowcase;
@@ -151,11 +146,13 @@ function calculateActiveCategory() {
activeCategory.value = newCategory;
/* this understandably causes jittering in Firefox, why the need to scroll into the category we're already in?
const activeLink = document.querySelector(`a[href="#${activeCategory.value}"]`);
activeLink.scrollIntoView({
inline: 'center',
});
*/
navigate(`#${activeCategory.value}`, null, { replace: true });
}
@@ -328,6 +325,7 @@ onMounted(() => {
width: 100%;
height: 100%;
object-fit: cover;
aspect-ratio: 5/3;
border-radius: .25rem;
background-size: cover;
background-position: center;

View File

@@ -1,4 +1,5 @@
import { fetchTags, fetchTagsById } from '#/src/tags.js';
import { censor } from '#/src/censor.js';
const tagSlugs = {
popular: [
@@ -117,7 +118,7 @@ const tagSlugs = {
};
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 {
pageContext: {
@@ -136,13 +137,13 @@ export async function onBeforeRender(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 tagsBySlug = Object.fromEntries(filteredTags.map((tag) => [tag.slug, tag]));
const tagShowcase = Object.fromEntries(Object.entries(tagSlugs).map(([category, categorySlugs]) => [
category,
censor(category, pageContext.restriction),
categorySlugs.map((slug) => tagsBySlug[slug]).filter(Boolean),
]));

View File

@@ -17,11 +17,17 @@
v-html="description"
/>
<Photos
v-if="tag.poster || tag.photos.length > 0"
:tag="tag"
/>
<Domains
:path="`/tag/${tag.slug}`"
:domains="['scenes', 'movies']"
:domain="domain"
class="domains-bar"
:class="{ light: tag.poster || tag.photos.length }"
/>
<Scenes v-if="domain === 'scenes'" />
@@ -33,6 +39,7 @@
<script setup>
import { inject } from 'vue';
import Photos from '#/components/tags/photos.vue';
import Scenes from '#/components/scenes/scenes.vue';
import Movies from '#/components/movies/movies.vue';
import Domains from '#/components/domains/domains.vue';
@@ -69,6 +76,7 @@ const domain = pageContext.routeParams.domain;
display: flex;
align-items: center;
padding: .25rem 1rem;
margin-bottom: -1px; /* for some reason there's a gap between description */
color: var(--text-light);
background: var(--grey-dark-40);
}
@@ -94,4 +102,9 @@ const domain = pageContext.routeParams.domain;
background: var(--grey-dark-40);
line-height: 1.5;
}
.domains-bar.light {
background: var(--background-base-10);
border-bottom: solid 1px var(--shadow-weak-40);
}
</style>

View File

@@ -21,7 +21,9 @@ async function fetchReleases(pageContext) {
page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 30,
aggregate: true,
}, pageContext.user);
}, pageContext.user, {
restriction: pageContext.restriction,
});
}
return fetchScenes(await curateScenesQuery({
@@ -33,18 +35,21 @@ async function fetchReleases(pageContext) {
page: Number(pageContext.routeParams.page) || 1,
limit: Number(pageContext.urlParsed.search.limit) || 30,
aggregate: true,
}, pageContext.user);
}, pageContext.user, {
restriction: pageContext.restriction,
});
}
export async function onBeforeRender(pageContext) {
const tagSlug = pageContext.routeParams.tagSlug;
const [[tag], tagReleases, campaigns] = await Promise.all([
fetchTagsById([tagSlug], {}, pageContext.user),
fetchTagsById([tagSlug], {}, pageContext.user, { restriction: pageContext.restriction }),
fetchReleases(pageContext),
getRandomCampaigns([
{ tagSlugs: [tagSlug], minRatio: 1.5 },
{ tagSlugs: [tagSlug], minRatio: 1.5 },
{ tagSlugs: [tagSlug], minRatio: 0.75, maxRatio: 1.25 },
{ tagSlugs: [tagSlug], minRatio: 3 },
{ tagSlugs: [tagSlug], minRatio: 3 },
pageContext.routeParams.domain === 'scenes'
? { tagSlugs: [tagSlug], minRatio: 0.75, maxRatio: 1.25 }
: null,
@@ -55,7 +60,7 @@ export async function onBeforeRender(pageContext) {
const description = tag.description && md.renderInline(tag.description);
const campaignIndex = getCampaignIndex(releases.length);
const [metaCampaign, paginationCampaign, sceneCampaign] = campaigns;
const [photosCampaign, metaCampaign, paginationCampaign, sceneCampaign] = campaigns;
return {
pageContext: {
@@ -67,6 +72,7 @@ export async function onBeforeRender(pageContext) {
},
campaigns: {
index: campaignIndex,
photos: photosCampaign,
meta: metaCampaign,
scenes: releases.length > 5 && sceneCampaign,
pagination: paginationCampaign,

View File

@@ -1,20 +1,20 @@
<template>
<div class="updates">
<Scenes
:show-filters="!!query"
:show-meta="!!query"
:show-scope-tabs="!query"
:show-filters="false"
:show-meta="false"
:show-scope-tabs="true"
/>
</div>
</template>
<script setup>
import { inject } from 'vue';
// import { inject } from 'vue';
import Scenes from '#/components/scenes/scenes.vue';
const pageContext = inject('pageContext');
const query = Object.hasOwn(pageContext.urlParsed.search, 'q');
// const pageContext = inject('pageContext');
// const query = Object.hasOwn(pageContext.urlParsed.search, 'q');
</script>
<style scoped>

View File

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

View File

@@ -91,6 +91,8 @@ import Summaries from '#/components/scenes/summaries.vue';
import Revisions from '#/components/edit/revisions.vue';
import ApiKeys from '#/components/user/api-keys.vue';
import mockupRelease from '#/assets/mockup-release.ts';
const pageContext = inject('pageContext');
const section = pageContext.routeParams.section;
@@ -99,42 +101,6 @@ const domain = pageContext.routeParams.domain;
const user = pageContext.user;
const profile = ref(pageContext.pageProps.profile);
const now = new Date();
const mockupRelease = {
id: 1,
title: 'Nut For Human Consumption',
date: now,
effectiveDate: now,
createdAt: new Date(2024, 0, 1),
actors: [
{
name: 'Chanel Chakra',
gender: 'female',
},
{
name: 'Mo The Fucker',
gender: 'male',
},
],
tags: [
{ name: 'anal' },
{ name: 'facefucking' },
{ name: 'deepthroat' },
{ name: 'blowjob' },
{ name: 'facial' },
],
movies: [{
title: 'Best Of Traxxx 23',
}],
channel: {
name: 'Traxxxed',
},
network: {
name: 'Traxxx',
},
};
function scrollHorizontal(event) {
event.currentTarget.scrollLeft += event.deltaY; // eslint-disable-line no-param-reassign
}

View File

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

View File

@@ -70,7 +70,7 @@ async function onRenderHtml(pageContext) {
<meta name="description" content="Keep track of new porn releases and re-discover classics from your favorite porn stars and sites" />
${config.analytics.enabled ? dangerouslySkipEscape(`<script src="${config.analytics.address}" data-website-id="${config.analytics.siteId}" async></script>`) : ''}
${config.analytics.enabled ? dangerouslySkipEscape(`<script src="${config.analytics.address}" data-website-id="${config.analytics.siteId}" data-exclude-hash="true" async></script>`) : ''}
<title>${title}</title>
</head>

View File

@@ -19,7 +19,7 @@ import { curateStash } from './stashes.js';
import escape from '../utils/escape-manticore.js';
import slugify from '../utils/slugify.js';
import { curateRevision } from './revisions.js';
import { interpolateProfiles } from '../common/actors.mjs'; // eslint-disable-line import/namespace
import { interpolateProfiles, platformsByHostname } from '../common/actors.mjs'; // eslint-disable-line import/namespace
import { resolvePlace } from '../common/geo.mjs'; // eslint-disable-line import/namespace
const logger = initLogger();
@@ -122,7 +122,10 @@ export function curateActor(actor, context = {}) {
state: actor.residence_state,
},
agency: actor.agency,
avatar: curateMedia(actor.avatar),
avatar: actor.avatar && curateMedia({
...actor.avatar,
sfw_media: actor.sfw_avatar,
}),
socials: context.socials?.map((social) => ({
id: social.id,
url: social.url,
@@ -183,6 +186,7 @@ export function sortActorsByGender(actors, context = {}) {
const genderActors = ['transsexual', 'female', undefined, null, 'male'].flatMap((gender) => alphaActors.filter((actor) => actor.gender === gender));
const titleSlug = slugify(context.title);
const titleActors = titleSlug ? genderActors.sort((actorA, actorB) => {
const actorASlug = actorA.slug.split('-')[0];
const actorBSlug = actorB.slug.split('-')[0];
@@ -213,18 +217,21 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
'residence_countries.alpha2 as residence_country_alpha2',
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(sfw_media) as sfw_avatar'),
)
.leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id')
.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('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')
.whereIn('actors.id', actorIds)
.modify((builder) => {
if (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')
.select(
'actors_profiles.*',
@@ -244,10 +251,12 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
'media.*',
'actors_avatars.actor_id',
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)
.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'),
knex('actors_socials')
.whereIn('actor_id', actorIds),
@@ -824,16 +833,6 @@ function convertWeight(weight, units) {
return Number(weight) || null;
}
const platformsByHostname = Object.fromEntries(Object.entries(config.socials.urls).map(([platform, url]) => {
const { hostname, pathname } = new URL(url);
return [hostname, {
platform,
pathname: decodeURIComponent(pathname),
url,
}];
}));
function curateSocials(socials) {
return socials.map((social) => {
if (!social.handle && !social.url) {

148
src/affiliates.js Normal file
View File

@@ -0,0 +1,148 @@
import format from 'template-format';
function getWatchUrl(scene) {
try {
if (scene.url) {
return new URL(scene.url).href;
}
if (scene.channel && (scene.channel.isIndependent || scene.channel.type === 'network')) {
return new URL(scene.channel.url).href;
}
if (scene.network) {
return new URL(scene.network.url).href;
}
} catch (_error) {
// invalid URL
}
return null;
}
export function getAffiliateSceneUrl(scene) {
const watchUrl = getWatchUrl(scene);
if (!watchUrl) {
return null;
}
if (!scene.affiliate || scene.affiliate.parameters.scene === false) {
return watchUrl;
}
if (scene.affiliate.parameters.dynamicScene) {
const scenePath = new URL(watchUrl).pathname;
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
const newParams = new URLSearchParams({
...Object.fromEntries(new URL(watchUrl).searchParams),
...Object.fromEntries(new URLSearchParams(scene.affiliate.parameters.query)),
});
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;
}
function getEntityUrl(entity) {
try {
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 (!entityAffiliate) {
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) {
return entityUrl;
}
// channel has its own domain
if (new URL(entityUrl).pathname === '/' && entityUrl !== entity.parent?.url) {
return entityUrl;
}
if (entityAffiliate.parameters.dynamicEntity) {
const entityPath = new URL(entityUrl).pathname;
return format(entityAffiliate.parameters.dynamicEntity, {
entityPath: entityAffiliate.parameters.prefixSlash
? entityPath
: entityPath.replace(/^\//, ''),
});
}
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';
const { argv } = yargs()
.command('npm start')
const { argv } = yargs(process.argv.slice(2))
.option('debug', {
describe: 'Show error stack traces',
describe: 'Show error stack traces and inputs',
type: 'boolean',
default: process.env.NODE_ENV === 'development',
})
.option('ip', {
describe: 'Mock IP address',
type: 'string',
default: null,
});
export default argv;

View File

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

View File

@@ -4,18 +4,41 @@ import { knexOwner as knex } from './knex.js';
import { curateEntity } from './entities.js';
import redis from './redis.js';
import initLogger from './logger.js';
import { getAffiliateEntityUrl } from './affiliates.js';
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) {
if (!campaign) {
return null;
}
return {
const entity = campaign.entity && curateEntity({ ...campaign.entity, parent: campaign.parent_entity });
const curatedCampaign = {
id: campaign.id,
url: campaign.url,
entity: campaign.entity && curateEntity({ ...campaign.entity, parent: campaign.parent_entity }),
entity,
banner: campaign.banner && {
id: campaign.banner.id,
type: campaign.banner.type,
@@ -31,9 +54,13 @@ function curateCampaign(campaign) {
parameters: campaign.affiliate.parameters,
},
};
curatedCampaign.url = getCampaignUrl(campaign, entity);
return curatedCampaign;
}
function selectRandomCampaign(primaryCampaigns, entityCampaigns, preferredCampaigns, allCampaigns, options) {
function selectRandomCampaign(primaryCampaigns, entityCampaigns, preferredCampaigns) {
if (primaryCampaigns.length > 0) {
return primaryCampaigns[crypto.randomInt(primaryCampaigns.length)];
}
@@ -46,31 +73,36 @@ function selectRandomCampaign(primaryCampaigns, entityCampaigns, preferredCampai
return preferredCampaigns[crypto.randomInt(preferredCampaigns.length)];
}
if (allCampaigns.length > 0 && options.allowRandomFallback !== false) {
return allCampaigns[crypto.randomInt(allCampaigns.length)];
}
return null;
}
export async function getRandomCampaign(options = {}, context = {}) {
export async function getRandomCampaign(options = {}, context = {}, pass = 0) {
const campaigns = options.campaigns
|| await redis.hGetAll('traxxx:campaigns').then((rawCampaigns) => Object.values(rawCampaigns).map((rawCampaign) => JSON.parse(rawCampaign)));
const validCampaigns = campaigns.filter((campaign) => {
if (options.minRatio && (!campaign.banner || campaign.banner.ratio < options.minRatio)) {
// too small
return false;
}
if (options.maxRatio && (!campaign.banner || campaign.banner.ratio > options.maxRatio)) {
// too big
return false;
}
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;
}
if (context.tagFilter && campaign.banner && campaign.banner.tags.some((tag) => context.tagFilter.includes(tag) && !options.tagSlugs?.includes(tag))) {
// wrong tag
return false;
}
@@ -82,8 +114,6 @@ export async function getRandomCampaign(options = {}, context = {}) {
return true;
});
// console.log(validCampaigns);
const campaignsByEntityId = validCampaigns.reduce((acc, campaign) => {
const entityId = campaign.entity.parent?.id || campaign.entity.id;
@@ -103,6 +133,18 @@ export async function getRandomCampaign(options = {}, context = {}) {
const randomCampaign = selectRandomCampaign(primaryCampaigns, randomEntityCampaigns, validCampaigns, campaigns, options);
// no campaign found, gradually widen scope
if (!randomCampaign && pass === 0 && options.allowRandomFallback !== false) {
return getRandomCampaign({
minRatio: options.minRatio,
maxRatio: options.maxRatio,
}, context, pass + 1);
}
if (!randomCampaign && pass === 1 && options.allowRandomFallback !== false && options.allowRandomRatio) {
return getRandomCampaign({}, context, pass + 1);
}
return randomCampaign;
}

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

@@ -2,28 +2,42 @@ import knex from './knex.js';
import redis from './redis.js';
import initLogger from './logger.js';
import entityPrefixes from './entities-prefixes.js';
import { getAffiliateEntityUrl } from './affiliates.js';
import { censor } from './censor.js';
const logger = initLogger();
export function curateEntity(entity, context) {
export function curateEntity(entity, context = {}) {
if (!entity) {
return null;
}
return {
const curatedEntity = {
id: entity.id,
name: entity.name,
name: censor(entity.name, context.restriction),
slug: entity.slug,
type: entity.type,
url: entity.url,
isIndependent: entity.independent,
hasLogo: entity.has_logo,
hasLogo: context.restriction ? false : entity.has_logo,
parent: curateEntity(entity.parent, context),
children: context?.children?.filter((child) => child.parent_id === entity.id).map((child) => curateEntity({ ...child, parent: entity }, { parent: entity })) || [],
tags: context?.tags?.map((tag) => ({
id: tag.id,
name: tag.name,
slug: tag.slug,
})),
children: context?.children?.filter((child) => child.parent_id === entity.id).map((child) => curateEntity({
...child,
parent: entity,
}, {
parent: entity,
restriction: context.restriction,
})) || [],
affiliate: entity.affiliate ? {
id: entity.affiliate.id,
entityId: entity.affiliate.entity_id,
url: entity.affiliate.url,
parameters: entity.affiliate.parameters,
parameters: entity.affiliate.parameters || {},
} : null,
...context?.append?.[entity.id],
alerts: {
@@ -31,9 +45,13 @@ export function curateEntity(entity, context) {
multi: context?.alerts?.filter((alert) => !alert.is_only).flatMap((alert) => alert.alert_ids) || [],
},
};
curatedEntity.affiliateUrl = getAffiliateEntityUrl(curatedEntity);
return curatedEntity;
}
export async function fetchEntities(options = {}) {
export async function fetchEntities(options = {}, context = {}) {
const entities = await knex('entities')
.select('entities.*', knex.raw('row_to_json(parents) as parent'))
.modify((builder) => {
@@ -43,7 +61,8 @@ export async function fetchEntities(options = {}) {
.where((subBuilder) => {
subBuilder
.whereILike('entities.name', `%${options.query}%`)
.orWhereILike('entities.slug', `%${options.query}%`);
.orWhereILike('entities.slug', `%${options.query}%`)
.orWhereILike(knex.raw('array_to_string(entities.alias, \',\', \'*\')'), `%${options.query}%`);
})
.whereNot('entities.type', 'info');
});
@@ -75,30 +94,47 @@ export async function fetchEntities(options = {}) {
.offset((options.page - 1) * options.limit)
.limit(options.limit || 1000);
return entities.map((entityEntry) => curateEntity(entityEntry));
const entitiesTags = await knex('entities_tags')
.select('entity_id', 'tags.*')
.leftJoin('tags', 'tags.id', 'tag_id')
.whereIn('entity_id', entities.map((entity) => entity.id));
return entities.map((entityEntry) => curateEntity(entityEntry, {
...context,
tags: entitiesTags.filter((tag) => tag.entity_id === entityEntry.id),
}));
}
export async function fetchEntitiesById(entityIds, options = {}, reqUser) {
const [entities, children, alerts] = await Promise.all([
export async function fetchEntitiesById(entityIds, options = {}, reqUser, context) {
const [entities, children, tags, alerts] = await Promise.all([
knex('entities')
.select(
'entities.*',
knex.raw('row_to_json(parents) as parent'),
knex.raw('row_to_json(affiliates) as affiliate'),
knex.raw('coalesce(row_to_json(affiliates), row_to_json(network_affiliates)) as affiliate'),
)
.whereIn('entities.id', entityIds)
.leftJoin('entities as parents', 'parents.id', 'entities.parent_id')
.leftJoin('affiliates', knex.raw('affiliates.entity_id in (entities.id, parents.id)'))
.leftJoin('affiliates', 'affiliates.entity_id', 'entities.id')
.leftJoin('affiliates as network_affiliates', 'network_affiliates.entity_id', 'parents.id')
.modify((builder) => {
if (options.order) {
builder.orderBy(...options.order);
}
})
.groupBy('entities.id', 'parents.id', 'affiliates.id'),
.groupBy('entities.id', 'parents.id', 'affiliates.id', 'affiliates.entity_id', 'network_affiliates.id', 'network_affiliates.entity_id'),
options.includeChildren ? knex('entities')
.whereIn('entities.parent_id', entityIds)
.whereNot('type', 'info')
.orderBy('slug') : [],
.orderBy([
{ column: knex.raw('array_position(array[\'network\', \'channel\']::varchar[], type)'), order: 'asc' },
{ column: 'independent', order: 'desc' },
{ column: 'slug', order: 'asc' },
]) : [],
knex('entities_tags')
.select('entity_id', 'tags.*')
.leftJoin('tags', 'tags.id', 'tag_id')
.whereIn('entity_id', entityIds),
reqUser
? knex('alerts_users_entities')
.where('user_id', reqUser.id)
@@ -108,6 +144,7 @@ export async function fetchEntitiesById(entityIds, options = {}, reqUser) {
if (options.order) {
return entities.map((entityEntry) => curateEntity(entityEntry, {
...context,
append: options.append,
children: children.filter((channel) => channel.parent_id === entityEntry.id),
alerts: alerts.filter((alert) => alert.entity_id === entityEntry.id),
@@ -123,8 +160,10 @@ export async function fetchEntitiesById(entityIds, options = {}, reqUser) {
}
return curateEntity(entity, {
...context,
append: options.append,
children: children.filter((channel) => channel.parent_id === entity.id),
tags: tags.filter((tag) => tag.entity_id === entity.id),
alerts: alerts.filter((alert) => alert.entity_id === entity.id),
});
}).filter(Boolean);
@@ -158,3 +197,32 @@ export async function cacheEntityIds() {
logger.info('Cached entity IDs by slug');
}
const sortMap = {
releases: knex.raw('count(releases.id)'),
latest: 'latest_release_date',
};
export async function fetchEntityHealths(options) {
const entities = await knex('entities')
.select(
'entities.*',
knex.raw('row_to_json(parents) as parent'),
knex.raw('max(effective_date) as latest_release_date'),
knex.raw('count(releases.id) as total_releases'),
)
.leftJoin('releases', 'releases.entity_id', 'entities.id')
.leftJoin('entities as parents', 'parents.id', 'entities.parent_id')
.orderBy(sortMap[options.sort] || options.sort || sortMap.releases, options.order || 'desc')
.groupBy('entities.id', 'parents.id');
const curatedEntities = entities.map((entity) => ({
...curateEntity(entity),
totalReleases: entity.total_releases,
latestReleaseDate: entity.latest_release_date,
}));
return {
entities: curatedEntities,
};
}

View File

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

View File

@@ -1,15 +1,6 @@
import config from 'config';
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({
client: 'pg',
connection: config.database.owner,
@@ -19,6 +10,8 @@ export const knexOwner = knex({
// debug: process.env.NODE_ENV === 'development',
});
export const knexQuery = knexOwner; // legacy
export const knexManticore = knex({
client: 'mysql',
connection: {

View File

@@ -1,3 +1,5 @@
import { curateEntity } from './entities.js';
export function curateMedia(media, context = {}) {
if (!media) {
return null;
@@ -16,11 +18,19 @@ export function curateMedia(media, context = {}) {
height: media.height,
index: media.index,
sharpness: media.sharpness,
entropy: media.entropy,
credit: media.credit,
mime: mime && {
type: mime[0],
subtype: mime[1],
},
comment: media.comment,
entity: media.entity && curateEntity({
...media.entity,
parent: media.entity_parent,
}),
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 { fetchEntitiesById } from './entities.js';
import { curateStash } from './stashes.js';
import { censor } from './censor.js';
import escape from '../utils/escape-manticore.js';
import promiseProps from '../utils/promise-props.js';
function curateMovie(rawMovie, assets) {
function curateMovie(rawMovie, assets, context = {}) {
if (!rawMovie) {
return null;
}
return {
id: rawMovie.id,
title: rawMovie.title,
title: censor(rawMovie.title, context.restriction),
slug: rawMovie.slug,
url: rawMovie.url,
date: rawMovie.date,
datePrecision: rawMovie.date_precision,
createdAt: rawMovie.created_at,
effectiveDate: rawMovie.effective_date,
description: rawMovie.description,
description: censor(rawMovie.description, context.restriction),
duration: rawMovie.duration,
channel: {
id: assets.channel.id,
slug: assets.channel.slug,
name: assets.channel.name,
name: censor(assets.channel.name, context.restriction),
type: assets.channel.type,
isIndependent: assets.channel.independent,
hasLogo: assets.channel.has_logo,
@@ -38,7 +39,7 @@ function curateMovie(rawMovie, assets) {
network: assets.channel.network_id ? {
id: assets.channel.network_id,
slug: assets.channel.network_slug,
name: assets.channel.network_name,
name: censor(assets.channel.network_name, context.restriction),
type: assets.channel.network_type,
hasLogo: assets.channel.has_logo,
} : null,
@@ -51,7 +52,7 @@ function curateMovie(rawMovie, assets) {
tags: assets.tags.map((tag) => ({
id: tag.id,
slug: tag.slug,
name: tag.name,
name: censor(tag.name, context.restriction),
})),
// poster: curateMedia(assets.poster),
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 {
movies,
channels,
@@ -123,20 +124,25 @@ export async function fetchMoviesById(movieIds, reqUser) {
.leftJoin('tags', 'tags.id', 'releases_tags.tag_id')
.orderBy('priority', 'desc'),
covers: knex('movies_covers')
.select('media.*', 'movies_covers.movie_id', knex.raw('row_to_json(sfw_media) as sfw_media'))
.whereIn('movie_id', movieIds)
.leftJoin('media', 'media.id', 'movies_covers.media_id')
.orderBy('media.index'),
photos: knex.transaction(async (trx) => {
.leftJoin('media as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
.orderBy('media.index')
.groupBy('media.id', 'movies_covers.movie_id', 'sfw_media.id'),
photos: context.restriction ? [] : knex.transaction(async (trx) => {
if (reqUser) {
await trx.select(knex.raw('set_config(\'user.id\', :userId, true)', { userId: reqUser.id }));
}
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)
.whereNotNull('media.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) => {
if (reqUser) {
@@ -144,11 +150,13 @@ export async function fetchMoviesById(movieIds, reqUser) {
}
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)
.whereNotNull('media.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')
.whereIn('movie_id', movieIds)
@@ -192,7 +200,7 @@ export async function fetchMoviesById(movieIds, reqUser) {
caps: movieCaps,
trailer: movieTrailer,
stashes: movieStashes,
});
}, context);
}).filter(Boolean);
}
@@ -287,6 +295,10 @@ async function queryManticoreSql(filters, options) {
builder.whereRaw('any(entity_ids) = ?', filters.entityId);
}
if (filters.requireCover) {
builder.where('has_cover', true);
}
if (typeof filters.isShowcased === 'boolean') {
builder.where('is_showcased', filters.isShowcased);
}
@@ -394,7 +406,7 @@ function countAggregations(buckets) {
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);
console.log(options);
@@ -409,13 +421,13 @@ export async function fetchMovies(filters, rawOptions, reqUser) {
const channelCounts = options.aggregateChannels && countAggregations(result.aggregations?.channelIds);
const [aggActors, aggTags, aggChannels] = await Promise.all([
options.aggregateActors ? fetchActorsById(result.aggregations.actorIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: actorCounts }) : [],
options.aggregateTags ? fetchTagsById(result.aggregations.tagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }) : [],
options.aggregateChannels ? fetchEntitiesById(result.aggregations.channelIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: channelCounts }) : [],
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 }, reqUser, context) : [],
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 movies = await fetchMoviesById(movieIds, reqUser);
const movies = await fetchMoviesById(movieIds, reqUser, context);
return {
movies,

View File

@@ -1,7 +1,7 @@
import config from 'config';
import util from 'util'; /* eslint-disable-line no-unused-vars */
import { MerkleJson } from 'merkle-json';
import argv from './argv.js';
import { knexQuery as knex, knexOwner, knexManticore } from './knex.js';
import { utilsApi } from './manticore.js';
import { HttpError } from './errors.js';
@@ -15,67 +15,35 @@ import escape from '../utils/escape-manticore.js';
import promiseProps from '../utils/promise-props.js';
import initLogger from './logger.js';
import { curateRevision } from './revisions.js';
import { getAffiliateSceneUrl } from './affiliates.js';
import { censor } from './censor.js';
const logger = initLogger();
const mj = new MerkleJson();
function getWatchUrl(scene) {
if (scene.url) {
return scene.url;
}
if (scene.channel && (scene.channel.isIndependent || scene.channel.type === 'network')) {
return scene.channel.url;
}
if (scene.network) {
return scene.network.url;
}
return null;
}
function getAffiliateUrl(scene) {
const watchUrl = getWatchUrl(scene);
if (!watchUrl) {
return null;
}
if (!scene.affiliate?.parameters) {
return scene.url;
}
const newParams = new URLSearchParams({
...Object.fromEntries(new URL(watchUrl).searchParams),
...Object.fromEntries(new URLSearchParams(scene.affiliate.parameters)),
});
return `${watchUrl}?${newParams.toString()}`;
}
function curateScene(rawScene, assets) {
function curateScene(rawScene, assets, reqUser, context) {
if (!rawScene) {
return null;
}
const curatedScene = {
id: rawScene.id,
title: rawScene.title,
title: censor(rawScene.title, context.restriction),
slug: rawScene.slug,
url: rawScene.url,
entryId: rawScene.entry_id,
date: rawScene.date,
datePrecision: rawScene.date_precision,
createdAt: rawScene.created_at,
effectiveDate: rawScene.effective_date,
description: rawScene.description,
description: censor(rawScene.description, context.restriction),
duration: rawScene.duration,
shootId: rawScene.shoot_id,
productionDate: rawScene.production_date,
channel: {
id: assets.channel.id,
slug: assets.channel.slug,
name: assets.channel.name,
name: censor(assets.channel.name, context.restriction),
type: assets.channel.type,
isIndependent: assets.channel.independent,
hasLogo: assets.channel.has_logo,
@@ -83,7 +51,7 @@ function curateScene(rawScene, assets) {
network: assets.channel.network_id ? {
id: assets.channel.network_id,
slug: assets.channel.network_slug,
name: assets.channel.network_name,
name: censor(assets.channel.network_name, context.restriction),
type: assets.channel.network_type,
hasLogo: assets.channel.network_has_logo,
} : null,
@@ -97,7 +65,8 @@ function curateScene(rawScene, assets) {
affiliate: assets.channel.affiliate ? {
id: assets.channel.affiliate.id,
url: assets.channel.affiliate.url,
parameters: assets.channel.affiliate.parameters,
parameters: assets.channel.affiliate.parameters || {},
entityId: assets.channel.affiliate.entity_id,
} : null,
actors: sortActorsByGender(assets.actors.map((actor) => curateActor(actor, {
sceneDate: rawScene.effective_date,
@@ -111,15 +80,19 @@ function curateScene(rawScene, assets) {
tags: assets.tags.map((tag) => ({
id: tag.id,
slug: tag.slug,
name: tag.name,
name: censor(tag.name, context.restriction),
priority: tag.priority,
})),
chapters: assets.chapters.map((chapter) => ({
id: chapter.id,
title: chapter.title,
description: chapter.description,
time: chapter.time,
date: chapter.date,
duration: chapter.duration,
poster: curateMedia(chapter.chapter_poster),
poster: context.restriction
? null
: curateMedia(chapter.chapter_poster, { type: 'poster' }),
tags: chapter.chapter_tags.map((tag) => ({
id: tag.id,
name: tag.name,
@@ -131,27 +104,44 @@ function curateScene(rawScene, assets) {
movies: assets.movies.map((movie) => ({
id: movie.id,
slug: movie.slug,
title: movie.title,
covers: movie.movie_covers?.map((cover) => curateMedia(cover, { type: 'cover' })).toSorted((coverA, coverB) => coverA.index - coverB.index) || [],
title: censor(movie.title, context.restriction),
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) => ({
id: serie.id,
slug: serie.slug,
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' }),
trailer: curateMedia(assets.trailer, { type: 'trailer' }),
teaser: curateMedia(assets.teaser, { type: 'teaser' }),
photos: assets.photos?.map((photo) => curateMedia(photo, { type: 'photo' })) || [],
caps: assets.caps?.map((cap) => curateMedia(cap, { type: 'cap' })) || [],
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,
updatedBatchId: rawScene.updated_batch_id,
isNew: assets.lastBatchId === rawScene.created_batch_id,
};
curatedScene.watchUrl = getAffiliateUrl(curatedScene);
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);
return curatedScene;
}
@@ -172,8 +162,9 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
caps,
trailers,
teasers,
fingerprints,
stashes,
lastBatch: { id: lastBatchId },
lastBatch,
} = await promiseProps({
scenes: knex('releases').whereIn('releases.id', sceneIds),
channels: knex('releases')
@@ -189,8 +180,17 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
.whereIn('releases.id', sceneIds)
.leftJoin('entities as channels', 'channels.id', 'releases.entity_id')
.leftJoin('entities as networks', 'networks.id', 'channels.parent_id')
.leftJoin('affiliates', knex.raw('affiliates.entity_id in (channels.id, networks.id)'))
.groupBy('channels.id', 'networks.id', 'affiliates.id'),
// .leftJoin('affiliates as channel_affiliates', 'channel_affiliates.entity_id', 'channels.id')
// .leftJoin('affiliates as network_affiliates', 'network_affiliates.entity_id', 'networks.id')
.joinRaw(`
left join lateral (
select *
from affiliates
where affiliates.entity_id in (channels.id, networks.id)
order by (affiliates.entity_id = channels.id) desc
limit 1
) affiliates ON TRUE
`),
studios: knex('releases')
.whereIn('releases.id', sceneIds)
.leftJoin('entities as studios', 'studios.id', 'releases.studio_id'),
@@ -199,14 +199,17 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
.select(
'actors.*',
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.alias as birth_country_alias',
'releases_actors.release_id',
)
.leftJoin('actors', 'actors.id', 'releases_actors.actor_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')
.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')
.whereIn('release_id', sceneIds)
.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),
@@ -244,17 +247,23 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
.whereIn('scene_id', sceneIds)
.groupBy('series.id', 'series_scenes.scene_id', 'media.*') : [],
posters: knex('releases_posters')
.select('media.*', 'releases_posters.release_id', knex.raw('row_to_json(sfw_media) as sfw_media'))
.whereIn('release_id', sceneIds)
.leftJoin('media', 'media.id', 'releases_posters.media_id'),
photos: context.includeAssets ? knex.transaction(async (trx) => {
.leftJoin('media', 'media.id', 'releases_posters.media_id')
.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) {
await trx.select(knex.raw('set_config(\'user.id\', :userId, true)', { userId: reqUser.id }));
}
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 as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
.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) => {
if (reqUser) {
@@ -262,9 +271,12 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
}
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 as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
.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) => {
if (reqUser) {
@@ -284,6 +296,20 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
.whereIn('release_id', sceneIds)
.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')
.select('id')
.where('showcased', true)
@@ -324,6 +350,7 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
const sceneCaps = caps.filter((cap) => cap.release_id === sceneId);
const sceneTrailers = trailers.find((trailer) => trailer.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 sceneActorStashes = sceneActors.map((actor) => actorStashes.find((stash) => stash.actor_id === actor.id)).filter(Boolean);
@@ -341,10 +368,11 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
caps: sceneCaps,
trailer: sceneTrailers,
teaser: sceneTeasers,
fingerprints: sceneFingerprints,
stashes: sceneStashes,
actorStashes: sceneActorStashes,
lastBatchId,
});
lastBatchId: lastBatch?.id,
}, reqUser, context);
}).filter(Boolean);
}
@@ -477,9 +505,11 @@ async function queryManticoreSql(filters, options, _reqUser) {
builder.where('scenes.is_showcased', filters.isShowcased);
}
/*
if (filters.isShowcased) {
builder.where('scenes.date', '>', 0);
}
*/
if (options.dedupe) {
builder.where('scenes.dupe_index', '<', 2);
@@ -537,7 +567,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
? sqlQuery
: sqlQuery.replace(/scenes\./g, '');
if (process.env.NODE_ENV === 'development') {
if (process.env.NODE_ENV === 'development' && argv.debug) {
console.log(curatedSqlQuery);
}
@@ -595,14 +625,18 @@ function countAggregations(buckets) {
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);
console.log('filters', filters);
console.log('options', options);
if (argv.debug) {
console.log('filters', filters);
console.log('options', options);
}
console.time('manticore sql');
const result = await queryManticoreSql(filters, options, reqUser);
console.timeEnd('manticore sql');
const aggYears = options.aggregateYears && result.aggregations.years.map((bucket) => ({ year: bucket.key, count: bucket.doc_count }));
@@ -615,16 +649,16 @@ export async function fetchScenes(filters, rawOptions, reqUser) {
console.time('fetch aggregations');
const [aggActors, aggTags, aggChannels] = await Promise.all([
options.aggregateActors ? fetchActorsById(result.aggregations.actorIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: actorCounts }) : [],
options.aggregateTags ? fetchTagsById(result.aggregations.tagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }) : [],
options.aggregateChannels ? fetchEntitiesById(entityIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: channelCounts }) : [],
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 }, reqUser, context) : [],
options.aggregateChannels ? fetchEntitiesById(entityIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: channelCounts }, reqUser, context) : [],
]);
console.timeEnd('fetch aggregations');
console.time('fetch full');
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');
return {

View File

@@ -1,30 +1,21 @@
import knex from './knex.js';
import redis from './redis.js';
import initLogger from './logger.js';
import { censor } from './censor.js';
import { curateEntity } from './entities.js';
import { curateMedia } from './media.js';
const logger = initLogger();
function curateTag(tag, context) {
function curateTag(tag, context = {}) {
return {
id: tag.id,
name: tag.name,
name: censor(tag.name, context.restriction),
slug: tag.slug,
description: tag.description,
description: context.restriction ? null : tag.description, // censor interferes with markdown
priority: tag.priority,
poster: tag.poster && {
id: tag.poster.id,
path: tag.poster.path,
thumbnail: tag.poster.thumbnail,
lazy: tag.poster.lazy,
isS3: tag.poster.is_s3,
comment: tag.poster.comment,
entity: tag.poster.entity && curateEntity({
...tag.poster.entity,
parent: tag.poster.entity_parent,
}),
},
poster: tag.poster && curateMedia(tag.poster),
photos: tag.photos?.map((photo) => curateMedia(photo)) || [],
alerts: {
only: context?.alerts?.filter((alert) => alert.is_only).flatMap((alert) => alert.alert_ids) || [],
multi: context?.alerts?.filter((alert) => !alert.is_only).flatMap((alert) => alert.alert_ids) || [],
@@ -33,7 +24,7 @@ function curateTag(tag, context) {
};
}
export async function fetchTags(options = {}) {
export async function fetchTags(options = {}, context = {}) {
const query = options.query?.trim();
const [tags, posters] = await Promise.all([
@@ -65,10 +56,13 @@ export async function fetchTags(options = {}) {
}
}),
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'))
.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 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]));
@@ -76,11 +70,11 @@ export async function fetchTags(options = {}) {
return tags.map((tagEntry) => curateTag({
...tagEntry,
poster: postersByTagId[tagEntry.id],
}));
}, context));
}
export async function fetchTagsById(tagIds, options = {}, reqUser) {
const [tags, posters, alerts] = await Promise.all([
export async function fetchTagsById(tagIds, options = {}, reqUser, context = {}) {
const [tags, posters, photos, alerts] = await Promise.all([
knex('tags')
.whereIn('tags.id', tagIds.filter((tagId) => typeof tagId === 'number'))
.orWhereIn('tags.slug', tagIds.filter((tagId) => typeof tagId === 'string'))
@@ -90,9 +84,20 @@ export async function fetchTagsById(tagIds, options = {}, reqUser) {
}
}),
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'))
.leftJoin('tags', 'tags.id', 'tags_posters.tag_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 as parents', 'parents.id', 'entities.parent_id')
.whereIn('tags.id', tagIds.filter((tagId) => typeof tagId === 'number'))
.orWhereIn('tags.slug', tagIds.filter((tagId) => typeof tagId === 'string'))
.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'))
.leftJoin('tags', 'tags.id', 'tags_photos.tag_id')
.leftJoin('media', 'media.id', 'tags_photos.media_id')
.leftJoin('entities', 'entities.id', 'media.entity_id')
.leftJoin('entities as parents', 'parents.id', 'entities.parent_id')
.whereIn('tags.id', tagIds.filter((tagId) => typeof tagId === 'number'))
@@ -116,9 +121,11 @@ export async function fetchTagsById(tagIds, options = {}, reqUser) {
return tags.map((tagEntry) => curateTag({
...tagEntry,
poster: postersByTagId[tagEntry.id],
photos: photos.filter((photo) => photo.tag_id === tagEntry.id),
}, {
alerts: alerts.filter((alert) => alert.tag_id === tagEntry.id),
append: options.append,
...context,
}));
}
@@ -133,9 +140,11 @@ export async function fetchTagsById(tagIds, options = {}, reqUser) {
return curateTag({
...tag,
poster: postersByTagId[tag.id],
photos: photos.filter((photo) => photo.tag_id === tag.id),
}, {
alerts: alerts.filter((alert) => alert.tag_id === tag.id),
append: options.append,
...context,
});
}).filter(Boolean);

32
src/tools/slugify-test.js Normal file
View File

@@ -0,0 +1,32 @@
import slugify from '../utils/slugify.js';
function init() {
const cases = [
'Brave, New World',
'Jœrgenbahn Straße',
'Partêrre',
'Ápres ski.',
'very 😀 true 😃',
'a véééry long piece of text that should not result in a very long slug, even for $100',
'don\'t you, forget about me',
'Pneumonoultramicroscopicsilicovolcanoconiosis',
'this (old) spicemen[sic]',
'contact@example.com',
'!@#$%',
'',
' ',
['this is', '2026-01-01', 'an array', '', ' ', 'test'],
];
cases.forEach((item) => console.log(item, '-->', slugify(item, '-', { limit: 20 })));
cases.forEach((item) => console.log(item, '-->', slugify(item, '-', {
lower: true,
accents: false,
punctuation: false,
symbols: 'split',
limit: 50,
})));
}
init();

View File

@@ -29,6 +29,7 @@ export function curateUser(user, _assets = {}) {
isIdentityVerified: user.identity_verified,
avatar: `/media/avatars/${user.id}_${user.username}.png`,
role: user.role,
abilities: [...user.role_abilities || [], ...user.abilities || []],
createdAt: user.created_at,
};
@@ -61,23 +62,6 @@ export async function fetchUser(userId, options = {}, _reqUser) {
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) {
// return { user, stashes, templates };
return { user };

View File

@@ -1,19 +1,22 @@
const substitutes = {
const accentMap = {
à: 'a',
á: 'a',
ä: 'a',
å: 'a',
ã: 'a',
â: 'a',
æ: 'ae',
ç: 'c',
è: 'e',
é: 'e',
ë: 'e',
: 'e',
ê: 'e',
ì: 'i',
í: 'i',
ï: 'i',
ĩ: 'i',
î: 'i',
ǹ: 'n',
ń: 'n',
ñ: 'n',
@@ -21,6 +24,7 @@ const substitutes = {
ó: 'o',
ö: 'o',
õ: 'o',
ô: 'o',
ø: 'o',
œ: 'oe',
ß: 'ss',
@@ -28,48 +32,106 @@ const substitutes = {
ú: 'u',
ü: 'u',
ũ: 'u',
û: 'u',
: 'y',
ý: 'y',
ÿ: 'y',
: 'y',
};
const plainCharRegex = /[a-zA-Z0-9]/;
const defaultPunctuationRegex = /[.,?!:;&'"“”…()[\]{}<>/*—]/;
const defaultSymbolRegex = /[@$€£#%^+=\\~]/;
export default function slugify(strings, delimiter = '-', {
encode = false,
removeAccents = true,
removePunctuation = false,
limit = 1000,
lower = true,
encode = false,
accents: keepAccents = false,
punctuation: keepPunctuation = 'split',
punctuationRegex = defaultPunctuationRegex,
symbols: keepSymbols = false,
symbolRegex = defaultSymbolRegex,
} = {}) {
if (!strings || (typeof strings !== 'string' && !Array.isArray(strings))) {
return strings;
}
const slugComponents = []
.concat(strings)
.filter(Boolean)
.flatMap((string) => string
.trim()
.toLowerCase()
.replace(removePunctuation && /[.,:;'"_-]/g, '')
.match(/[A-Za-zÀ-ÖØ-öø-ÿ0-9]+/g));
const string = [].concat(strings).join(' ');
if (!slugComponents) {
const casedString = lower
? string.toLowerCase()
: string;
const normalized = casedString
.replace(/[_-]+/g, ' ')
.split('')
.map((char) => {
if (char === ' ') {
return char;
}
const lowChar = char.toLowerCase();
if (accentMap[lowChar]) {
if (keepAccents) {
return char;
}
// match original case after mapping
if (char === lowChar) {
return accentMap[lowChar];
}
return accentMap[lowChar].toUpperCase();
}
if (plainCharRegex.test(char)) {
return char;
}
if (punctuationRegex.test(char)) {
if (keepPunctuation === 'split') {
return ' ';
}
if (keepPunctuation) {
return char;
}
}
if (symbolRegex.test(char)) {
if (keepSymbols === 'split') {
return ' ';
}
if (keepSymbols) {
return char;
}
}
return '';
}).join('');
const components = normalized.trim().split(/\s+/).filter(Boolean);
if (components.length === 0) {
return '';
}
const slug = slugComponents.reduce((acc, component, index) => {
const slug = components.reduce((acc, component, index) => {
const accSlug = `${acc}${index > 0 ? delimiter : ''}${component}`;
if (accSlug.length < limit) {
if (removeAccents) {
return accSlug.replace(/[à-ÿ]/g, (match) => substitutes[match] || '');
}
return accSlug;
}
return acc;
}, '');
}).slice(0, limit); // in case first word exceeds limit
return encode ? encodeURI(slug) : slug;
if (encode) {
return encodeURI(slug);
}
return slug;
}

View File

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

View File

@@ -4,6 +4,7 @@ import { renderPage } from 'vike/server'; // eslint-disable-line import/extensio
import { fetchUserStashes } from '../stashes.js';
import { fetchUserTemplates } from '../users.js';
import { fetchUnseenNotificationsCount } from '../alerts.js';
import { socials } from '../../common/actors.mjs'; // eslint-disable-line import/namespace
export default async function mainHandler(req, res, next) {
const [stashes, templates, unseenNotifications] = req.user ? await Promise.all([
@@ -23,6 +24,7 @@ export default async function mainHandler(req, res, next) {
username: req.user.username,
email: req.user.email,
role: req.user.role,
abilities: req.user.abilities,
avatar: req.user.avatar,
},
assets: req.user ? {
@@ -39,11 +41,17 @@ export default async function mainHandler(req, res, next) {
allowSignup: config.auth.signup,
maxMatches: config.database.manticore.maxMatches,
maxAggregateSize: config.database.manticore.maxAggregateSize,
origin: config.origin,
media: config.media,
psa: config.psa,
links: config.links,
socials: config.socials,
socials,
captcha: {
enabled: config.auth.captcha.enabled,
siteKey: config.auth.captcha.siteKey,
},
},
restriction: req.restriction,
meta: {
now: new Date().toISOString(),
},

View File

@@ -33,7 +33,7 @@ export async function fetchMoviesApi(req, res) {
} = await fetchMovies(await curateMoviesQuery(req.query), {
page: Number(req.query.page) || 1,
limit: Number(req.query.limit) || 30,
}, req.user);
}, req.user, { restriction: req.restriction });
res.send(stringify({
movies,
@@ -47,7 +47,7 @@ export async function fetchMoviesApi(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) {
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,
limit: query.limit || 30,
aggregate: false,
}, req.user);
}, req.user, { restriction: req.restriction });
return {
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,
limit: Number(req.query.limit) || 30,
}, req.user);
}, req.user, {
restriction: req.restriction,
});
res.send(stringify({
scenes,
@@ -250,7 +252,7 @@ export async function fetchScenesGraphql(query, req) {
}
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) {
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), {
reqUser: req.user,
includePartOf: true,
restriction: req.restriction,
});
if (query.ids) {

View File

@@ -12,6 +12,7 @@ import redis from '../redis.js';
import errorHandler from './error.js';
import consentHandler from './consent.js';
import initRestrictionHandler from './restrictions.js';
import { scenesRouter } from './scenes.js';
import { actorsRouter } from './actors.js';
@@ -48,9 +49,11 @@ const isProduction = process.env.NODE_ENV === 'production';
export default async function initServer() {
const app = express();
const router = Router();
const restrictionHandler = await initRestrictionHandler();
app.use(compression());
app.disable('x-powered-by');
app.set('view engine', 'ejs');
router.use(boolParser());
@@ -58,7 +61,7 @@ export default async function initServer() {
router.use('/', express.static('static'));
router.use('/media', express.static(config.media.path));
router.use((req, res, next) => {
router.use((req, _res, next) => {
if (req.headers.cookie) {
const cookies = cookie.parse(req.headers.cookie);
@@ -109,11 +112,13 @@ export default async function initServer() {
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'));
});
router.use('/api/*', async (req, res, next) => {
router.use('/api/*', async (req, _res, next) => {
if (req.headers['api-user']) {
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((req, res, next) => {
router.use((_req, res, next) => {
/* eslint-disable no-param-reassign */
res.set('Accept-CH', '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);
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) {
const tags = await fetchTags({
query: req.query.query,
}, {
restriction: req.restriction,
});
res.send(tags);

2
static

Submodule static updated: 4982084fd8...bb5b4f01b4

View File

@@ -1,5 +1,6 @@
import { format } from 'date-fns';
import { parse } from 'yaml';
import formatTemplate from 'template-format';
import slugify from '#/utils/slugify.js';
import ellipsis from '#/utils/ellipsis.js';
@@ -13,6 +14,11 @@ const genderMap = {
};
const propProcessors = {
...Object.fromEntries(Object.entries({
// aliases
shoot: 'shootId',
}).map(([key, alias]) => [key, (sceneInfo) => sceneInfo[alias]])),
link: (sceneInfo, _options, env) => `${env.origin}/scene/${sceneInfo.id}/${sceneInfo.slug}`,
channel: (sceneInfo) => sceneInfo.channel?.name || sceneInfo.network?.name,
network: (sceneInfo) => sceneInfo.network?.name || sceneInfo.channel?.name,
actors: (sceneInfo, options) => {
@@ -20,7 +26,70 @@ const propProcessors = {
return sceneInfo.actors
.filter((actor) => genders.includes(actor.gender))
.map((actor) => actor.name);
.map((actor) => {
const curatedActor = {
...actor,
age: actor.ageThen,
ageNow: actor.age,
g: actor.gender?.charAt(0),
G: actor.gender?.charAt(0).toUpperCase(),
dateOfBirth: actor.dateOfBirth && format(actor.dateOfBirth, 'yyyy-MM-dd'),
};
if (options.format) {
return formatTemplate(options.format, Object.fromEntries(Object.entries(curatedActor).map(([key, value]) => [key, value ?? '']))); // don't render `null`
}
if (options.details) {
return options.details
.map((parentDetail) => {
if (parentDetail.details) { // already nested
return {
...parentDetail,
details: parentDetail.details.map((detail) => {
if (detail.key) {
return detail;
}
return { key: detail };
}),
};
}
if (parentDetail.key) {
return {
details: [parentDetail],
};
}
return {
details: [{ key: parentDetail }],
};
})
.map((parentDetail) => {
const curatedDetails = parentDetail.details
.filter((detail) => !!curatedActor[detail.key])
.map((detail) => {
if (detail.wrap) {
return `${detail.wrap[0]}${curatedActor[detail.key]}${detail.wrap[1]}`;
}
return curatedActor[detail.key];
})
.join(parentDetail.delimiter ?? ' ');
if (curatedDetails && parentDetail.wrap) {
return `${parentDetail.wrap[0]}${curatedDetails}${parentDetail.wrap[1]}`;
}
return curatedDetails;
})
.filter((parentDetail) => !!parentDetail)
.join(options.detailDelimiter ?? ' ');
}
return actor.name;
});
},
tags: (sceneInfo, options) => sceneInfo.tags
?.filter((tag) => {
@@ -66,7 +135,7 @@ function curateValue(value, item) {
.join(item.delimit || ', ');
}
function traverseTemplate(chain, release, { delimit = ' ' } = {}) {
function traverseTemplate(chain, release, env, { delimit = ' ' } = {}) {
const results = chain.reduce((result, item) => {
const keys = typeof item === 'string' ? item : item.key;
@@ -78,16 +147,20 @@ function traverseTemplate(chain, release, { delimit = ' ' } = {}) {
return result;
}
if (item.text) {
return result.concat(curateValue(item.text, item));
}
if (keys) {
const value = keys.split('|').reduce((acc, key) => acc
|| propProcessors[key]?.(release, typeof item === 'string' ? { key } : item)
|| propProcessors[key]?.(release, typeof item === 'string' ? { key } : item, env)
|| release[key], null);
return result.concat(curateValue(value, item));
}
if (item.items) {
const group = traverseTemplate(item.items, release, {
const group = traverseTemplate(item.items, release, env, {
delimit: item.delimit,
});
@@ -105,8 +178,8 @@ function traverseTemplate(chain, release, { delimit = ' ' } = {}) {
return '';
}
export default function processSummaryTemplate(template, release) {
export default function processSummaryTemplate(template, release, env) {
const chain = parse(template);
return traverseTemplate(chain, release);
return traverseTemplate(chain, release, env);
}