Compare commits
67 Commits
cb84ed219b
...
d973c6c2ee
| Author | SHA1 | Date | |
|---|---|---|---|
| d973c6c2ee | |||
| ec60cfc24d | |||
|
|
da09fbcaf5 | ||
|
|
7b15b8f0a2 | ||
|
|
413882f650 | ||
|
|
0bc6b33251 | ||
|
|
116652c2a5 | ||
|
|
a7d056a550 | ||
|
|
e96606eba3 | ||
|
|
1b8275d1b8 | ||
|
|
ec019eef72 | ||
|
|
0e74c1e9bd | ||
|
|
26853a83ca | ||
|
|
bed4014d4e | ||
|
|
1834742bb9 | ||
|
|
d3d6558913 | ||
|
|
cef5429cdc | ||
|
|
c3bcf30826 | ||
|
|
f6e9887ddb | ||
|
|
0f6afc2764 | ||
|
|
4fcbf7ba53 | ||
|
|
9751237316 | ||
|
|
b9d8ec6773 | ||
|
|
3445b183cd | ||
|
|
a943767f42 | ||
|
|
1fcf017359 | ||
|
|
71b19ae72b | ||
|
|
b2d71407ba | ||
|
|
80cca7b070 | ||
|
|
c09266cd17 | ||
|
|
9b3862de69 | ||
|
|
4064bd5c11 | ||
|
|
a6412968fe | ||
|
|
71c772d6c9 | ||
|
|
f76cfcc154 | ||
|
|
536f9d7fab | ||
|
|
ce4dc8a9e2 | ||
|
|
a5c9e78f5b | ||
|
|
4da3904b89 | ||
|
|
68e90344d6 | ||
|
|
91d3f4138c | ||
|
|
900054bab0 | ||
|
|
33db7b46cb | ||
|
|
8042c65ce3 | ||
|
|
f2cac5190a | ||
|
|
d985bbcf5c | ||
|
|
38f2ede5c0 | ||
|
|
7a6548cb2b | ||
|
|
24a267da50 | ||
|
|
42e8e7219e | ||
|
|
8304d99f15 | ||
|
|
0023af4311 | ||
|
|
5772e4089c | ||
|
|
d9aa2d1723 | ||
|
|
1a520f5792 | ||
|
|
0d9ffa6aa2 | ||
|
|
5240ccff2a | ||
|
|
b3e35dba12 | ||
|
|
c7d6b4291c | ||
|
|
6e86e086c3 | ||
|
|
271287e6f3 | ||
|
|
3208e7d25f | ||
|
|
5e2d126aea | ||
|
|
be4c83bfb0 | ||
|
|
c9b2e94ba9 | ||
|
|
1cf37e4e84 | ||
|
|
396322772f |
@ -26,12 +26,7 @@ enableRSS = true # set this to false to disable RSS feeds
|
||||
enableDebug = false # enable request logs and debug endpoints (/.accounts)
|
||||
proxy = "" # http/https url, SOCKS proxies are not supported
|
||||
proxyAuth = ""
|
||||
tokenCount = 10
|
||||
# minimum amount of usable tokens. tokens are used to authorize API requests,
|
||||
# but they expire after ~1 hour, and have a limit of 500 requests per endpoint.
|
||||
# the limits reset every 15 minutes, and the pool is filled up so there's
|
||||
# always at least `tokenCount` usable tokens. only increase this if you receive
|
||||
# major bursts all the time and don't have a rate limiting setup via e.g. nginx
|
||||
disableTid = false # enable this if cookie-based auth is failing
|
||||
|
||||
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
||||
[Preferences]
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.8 KiB |
122
public/css/fontello.css
vendored
122
public/css/fontello.css
vendored
@ -1,16 +1,27 @@
|
||||
@font-face {
|
||||
font-family: 'fontello';
|
||||
src: url('/fonts/fontello.eot?21002321');
|
||||
src: url('/fonts/fontello.eot?21002321#iefix') format('embedded-opentype'),
|
||||
url('/fonts/fontello.woff2?21002321') format('woff2'),
|
||||
url('/fonts/fontello.woff?21002321') format('woff'),
|
||||
url('/fonts/fontello.ttf?21002321') format('truetype'),
|
||||
url('/fonts/fontello.svg?21002321#fontello') format('svg');
|
||||
font-family: "fontello";
|
||||
src: url("/fonts/fontello.eot?76162212");
|
||||
src:
|
||||
url("/fonts/fontello.eot?76162212#iefix") format("embedded-opentype"),
|
||||
url("/fonts/fontello.woff2?76162212") format("woff2"),
|
||||
url("/fonts/fontello.woff?76162212") format("woff"),
|
||||
url("/fonts/fontello.ttf?76162212") format("truetype"),
|
||||
url("/fonts/fontello.svg?76162212#fontello") format("svg");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
[class^="icon-"]:before, [class*=" icon-"]:before {
|
||||
/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */
|
||||
/* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */
|
||||
/*
|
||||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||
@font-face {
|
||||
font-family: 'fontello';
|
||||
src: url('../font/fontello.svg?76162212#fontello') format('svg');
|
||||
}
|
||||
}
|
||||
*/
|
||||
[class^="icon-"]:before,
|
||||
[class*=" icon-"]:before {
|
||||
font-family: "fontello";
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
@ -19,7 +30,9 @@
|
||||
display: inline-block;
|
||||
text-decoration: inherit;
|
||||
width: 1em;
|
||||
margin-right: 0.2em;
|
||||
text-align: center;
|
||||
/* opacity: .8; */
|
||||
|
||||
/* For safety - reset parent styles, that can break glyph codes*/
|
||||
font-variant: normal;
|
||||
@ -28,26 +41,81 @@
|
||||
/* fix buttons height, for twitter bootstrap */
|
||||
line-height: 1em;
|
||||
|
||||
/* Animation center compensation - margins should be symmetric */
|
||||
/* remove if not needed */
|
||||
margin-left: 0.2em;
|
||||
|
||||
/* you can be more comfortable with increased icons size */
|
||||
/* font-size: 120%; */
|
||||
|
||||
/* Font smoothing. That was taken from TWBS */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Uncomment for 3D effect */
|
||||
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
|
||||
}
|
||||
|
||||
.icon-heart:before { content: '\2665'; } /* '♥' */
|
||||
.icon-quote:before { content: '\275e'; } /* '❞' */
|
||||
.icon-comment:before { content: '\e802'; } /* '' */
|
||||
.icon-ok:before { content: '\e803'; } /* '' */
|
||||
.icon-play:before { content: '\e804'; } /* '' */
|
||||
.icon-link:before { content: '\e805'; } /* '' */
|
||||
.icon-calendar:before { content: '\e806'; } /* '' */
|
||||
.icon-location:before { content: '\e807'; } /* '' */
|
||||
.icon-picture:before { content: '\e809'; } /* '' */
|
||||
.icon-lock:before { content: '\e80a'; } /* '' */
|
||||
.icon-down:before { content: '\e80b'; } /* '' */
|
||||
.icon-retweet:before { content: '\e80d'; } /* '' */
|
||||
.icon-search:before { content: '\e80e'; } /* '' */
|
||||
.icon-pin:before { content: '\e80f'; } /* '' */
|
||||
.icon-cog:before { content: '\e812'; } /* '' */
|
||||
.icon-rss-feed:before { content: '\e813'; } /* '' */
|
||||
.icon-info:before { content: '\f128'; } /* '' */
|
||||
.icon-bird:before { content: '\f309'; } /* '' */
|
||||
.icon-heart:before {
|
||||
content: "\2665";
|
||||
} /* '♥' */
|
||||
.icon-quote:before {
|
||||
content: "\275e";
|
||||
} /* '❞' */
|
||||
.icon-ok:before {
|
||||
content: "\e800";
|
||||
} /* '' */
|
||||
.icon-play:before {
|
||||
content: "\e801";
|
||||
} /* '' */
|
||||
.icon-comment:before {
|
||||
content: "\e802";
|
||||
} /* '' */
|
||||
.icon-link:before {
|
||||
content: "\e803";
|
||||
} /* '' */
|
||||
.icon-calendar:before {
|
||||
content: "\e804";
|
||||
} /* '' */
|
||||
.icon-picture:before {
|
||||
content: "\e805";
|
||||
} /* '' */
|
||||
.icon-lock:before {
|
||||
content: "\e806";
|
||||
} /* '' */
|
||||
.icon-down:before {
|
||||
content: "\e807";
|
||||
} /* '' */
|
||||
.icon-retweet:before {
|
||||
content: "\e808";
|
||||
} /* '' */
|
||||
.icon-search:before {
|
||||
content: "\e809";
|
||||
} /* '' */
|
||||
.icon-pin:before {
|
||||
content: "\e80a";
|
||||
} /* '' */
|
||||
.icon-cog:before {
|
||||
content: "\e80b";
|
||||
} /* '' */
|
||||
.icon-info:before {
|
||||
content: "\e80c";
|
||||
} /* '' */
|
||||
.icon-bookmark:before {
|
||||
content: "\e80d";
|
||||
} /* '' */
|
||||
.icon-eye:before {
|
||||
content: "\e80e";
|
||||
} /* '' */
|
||||
.icon-pcf:before {
|
||||
content: "\e83a";
|
||||
} /* '' */
|
||||
.icon-location:before {
|
||||
content: "\f031";
|
||||
} /* '' */
|
||||
.icon-bird:before {
|
||||
content: "\f099";
|
||||
} /* '' */
|
||||
.icon-rss-feed:before {
|
||||
content: "\f09e";
|
||||
} /* '' */
|
||||
|
||||
BIN
public/css/fonts/Hack-Bold.ttf
Executable file
BIN
public/css/fonts/Hack-Bold.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/Hack-BoldItalic.ttf
Executable file
BIN
public/css/fonts/Hack-BoldItalic.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/Hack-Italic.ttf
Executable file
BIN
public/css/fonts/Hack-Italic.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/Hack-Regular.ttf
Executable file
BIN
public/css/fonts/Hack-Regular.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/TerminessNerdFontMono-Bold.ttf
Executable file
BIN
public/css/fonts/TerminessNerdFontMono-Bold.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/TerminessNerdFontMono-BoldItalic.ttf
Executable file
BIN
public/css/fonts/TerminessNerdFontMono-BoldItalic.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/TerminessNerdFontMono-Italic.ttf
Executable file
BIN
public/css/fonts/TerminessNerdFontMono-Italic.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/TerminessNerdFontMono-Regular.ttf
Executable file
BIN
public/css/fonts/TerminessNerdFontMono-Regular.ttf
Executable file
Binary file not shown.
98
public/css/themes/eir.css
Executable file
98
public/css/themes/eir.css
Executable file
@ -0,0 +1,98 @@
|
||||
@font-face {
|
||||
font-family: hack;
|
||||
src: url("../fonts/Hack-Regular.ttf");
|
||||
}
|
||||
@font-face {
|
||||
font-family: hack;
|
||||
src: url("../fonts/Hack-Bold.ttf");
|
||||
font-weight: bold;
|
||||
}
|
||||
@font-face {
|
||||
font-family: hack;
|
||||
src: url("../fonts/Hack-Italic.ttf");
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: hack;
|
||||
src: url("../fonts/Hack-BoldItalic.ttf");
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Terminess;
|
||||
src: url("../fonts/TerminessNerdFontMono-Regular.ttf");
|
||||
}
|
||||
@font-face {
|
||||
font-family: Terminess;
|
||||
src: url("../fonts/TerminessNerdFontMono-Bold.ttf");
|
||||
font-weight: bold;
|
||||
}
|
||||
@font-face {
|
||||
font-family: Terminess;
|
||||
src: url("../fonts/TerminessNerdFontMono-Italic.ttf");
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: Terminess;
|
||||
src: url("../fonts/TerminessNerdFontMono-BoldItalic.ttf");
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
body {
|
||||
background-image: url("https://eir-nya.gay/static/images/background/night_sky.png");
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
background-position: 50% 0%;
|
||||
background-attachment: fixed;
|
||||
|
||||
font-family: Terminess, hack, 'Courier New', courier, monospace;
|
||||
}
|
||||
.tweet-content {
|
||||
font-family: hack, 'Courier New', courier, monospace;
|
||||
}
|
||||
.show-more {
|
||||
background-color: transparent;
|
||||
}
|
||||
body {
|
||||
--bg_color: transparent; /*#282a36;*/
|
||||
--fg_color: #f8f8f2;
|
||||
--fg_faded: #818eb6;
|
||||
--fg_dark: var(--fg_faded);
|
||||
--fg_nav: var(--accent);
|
||||
|
||||
--bg_panel: rgba(0.9803921568627451, 0.6666666666666666, 0.6705882352941176, 0.875); /*#343746;*/
|
||||
--bg_elements: #292b36;
|
||||
--bg_overlays: #20202080; /*#44475a;*/
|
||||
--bg_hover: #2f323f;
|
||||
|
||||
--grey: var(--fg_faded);
|
||||
--dark_grey: #44475a;
|
||||
--darker_grey: #3d4051;
|
||||
--darkest_grey: #363948;
|
||||
--border_grey: #44475a;
|
||||
|
||||
--accent: #faaaab;
|
||||
--accent_light: #facdce;
|
||||
--accent_dark: #ab7475;
|
||||
--accent_border: #e36f7196;
|
||||
|
||||
--play_button: #ffb86c;
|
||||
--play_button_hover: #ffc689;
|
||||
|
||||
--more_replies_dots: #bd93f9;
|
||||
--error_red: #ff5555;
|
||||
|
||||
--verified_blue: var(--accent);
|
||||
--icon_text: ##F8F8F2;
|
||||
|
||||
--tab: #6272a4;
|
||||
--tab_selected: var(--accent);
|
||||
|
||||
--profile_stat: #919cbf;
|
||||
}
|
||||
|
||||
.search-bar > form input::placeholder{
|
||||
color: var(--fg_faded);
|
||||
}
|
||||
Binary file not shown.
@ -1,46 +1,52 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata>Copyright (C) 2020 by original authors @ fontello.com</metadata>
|
||||
<metadata>Copyright (C) 2025 by original authors @ fontello.com</metadata>
|
||||
<defs>
|
||||
<font id="fontello" horiz-adv-x="1000" >
|
||||
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
|
||||
<missing-glyph horiz-adv-x="1000" />
|
||||
<glyph glyph-name="heart" unicode="♥" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" />
|
||||
<glyph glyph-name="heart" unicode="♥" d="M500-79q-14 0-25 10l-348 336q-5 5-15 15t-31 37-38 54-30 67-13 77q0 123 71 192t196 70q34 0 70-12t67-33 54-38 42-38q20 20 42 38t54 38 67 33 70 12q125 0 196-70t71-192q0-123-128-251l-347-335q-10-10-25-10z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="quote" unicode="❞" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" />
|
||||
|
||||
<glyph glyph-name="ok" unicode="" d="M249 0q-34 0-56 28l-180 236q-16 24-12 52t26 46 51 14 47-28l118-154 296 474q16 24 43 30t53-8q24-16 30-43t-8-53l-350-560q-20-32-56-32z" horiz-adv-x="667" />
|
||||
|
||||
<glyph glyph-name="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
|
||||
|
||||
<glyph glyph-name="comment" unicode="" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="ok" unicode="" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" />
|
||||
<glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
|
||||
|
||||
<glyph glyph-name="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
|
||||
<glyph glyph-name="calendar" unicode="" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
|
||||
|
||||
<glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
|
||||
<glyph glyph-name="picture" unicode="" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
|
||||
|
||||
<glyph glyph-name="calendar" unicode="" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
|
||||
<glyph glyph-name="lock" unicode="" d="M0 350q0 207 147 354t353 146 354-146 146-354-146-354-354-146-353 146-147 354z m252-271l496 0 0 314-82 0 0 59q0 33-14 66-19 45-62 74t-94 30-92-29-62-75q-16-35-14-125l-76 0 0-314z m176 314l0 59q2 31 19 49t45 21l4 0q29-2 49-22t21-48l0-59-138 0z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="location" unicode="" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" />
|
||||
<glyph glyph-name="down" unicode="" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="picture" unicode="" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
|
||||
<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
|
||||
|
||||
<glyph glyph-name="lock" unicode="" d="M0 350q0 207 147 354t353 146 354-146 146-354-146-354-354-146-353 146-147 354z m252-271l496 0 0 314-82 0 0 59q0 33-14 66-19 45-62 74t-94 30-92-29-62-75q-16-35-14-125l-76 0 0-314z m176 314l0 59q2 31 19 49t45 21l4 0q29-2 49-22t21-48l0-59-138 0z" horiz-adv-x="1000" />
|
||||
<glyph glyph-name="search" unicode="" d="M643 386q0 103-73 176t-177 74-177-74-73-176 73-177 177-73 177 73 73 177z m286-465q0-29-22-50t-50-21q-30 0-50 21l-191 191q-100-69-223-69-80 0-153 31t-125 84-84 125-31 153 31 152 84 126 125 84 153 31 153-31 125-84 84-126 31-152q0-123-69-223l191-191q21-21 21-51z" horiz-adv-x="928.6" />
|
||||
|
||||
<glyph glyph-name="down" unicode="" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
|
||||
<glyph glyph-name="pin" unicode="" d="M573 37q0-23-15-38t-37-15q-21 0-37 16l-169 169-315-236 236 315-168 169q-24 23-12 56 14 32 48 32 157 0 270 57 90 45 151 171 9 24 36 32t50-13l208-209q21-23 14-50t-32-36q-127-63-172-152-56-110-56-268z" horiz-adv-x="834" />
|
||||
|
||||
<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
|
||||
<glyph glyph-name="cog" unicode="" d="M571 350q0 59-41 101t-101 42-101-42-42-101 42-101 101-42 101 42 41 101z m286 61v-124q0-7-4-13t-11-7l-104-16q-10-30-21-51 19-27 59-77 6-6 6-13t-5-13q-15-21-55-61t-53-39q-7 0-14 5l-77 60q-25-13-51-21-9-76-16-104-4-16-20-16h-124q-8 0-14 5t-6 12l-16 103q-27 9-50 21l-79-60q-6-5-14-5-8 0-14 6-70 64-92 94-4 5-4 13 0 6 5 12 8 12 28 37t30 40q-15 28-23 55l-102 15q-7 1-11 7t-5 13v124q0 7 5 13t10 7l104 16q8 25 22 51-23 32-60 77-6 7-6 14 0 5 5 12 15 20 55 60t53 40q7 0 15-5l77-60q24 13 50 21 9 76 17 104 3 16 20 16h124q7 0 13-5t7-12l15-103q28-9 51-20l79 59q5 5 13 5 7 0 14-5 72-67 92-95 4-5 4-12 0-7-4-13-9-12-29-37t-30-40q15-28 23-54l102-16q7-1 12-7t4-13z" horiz-adv-x="857.1" />
|
||||
|
||||
<glyph glyph-name="search" unicode="" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" />
|
||||
<glyph glyph-name="info" unicode="" d="M494 740q86-62 86-184 0-64-42-124-12-20-88-80l-46-30q-40-34-48-60-6-16-8-44 0-14-16-14l-128 0q-16 0-16 12 4 98 28 124 16 22 48 48t56 42l24 14q22 16 34 34 28 44 28 70 0 40-26 78-28 36-92 36-68 0-94-44-28-42-28-92l-166 0q6 162 114 232 70 42 166 42 130 0 214-60z m-216-636q44 0 73-30t27-74q-2-46-32-73t-74-25q-44 0-73 29t-27 75 32 73 74 25z" horiz-adv-x="580" />
|
||||
|
||||
<glyph glyph-name="pin" unicode="" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" />
|
||||
<glyph glyph-name="bookmark" unicode="" d="M650 779q12 0 24-5 19-8 29-23t11-35v-719q0-19-11-35t-29-23q-10-4-24-4-27 0-47 18l-246 236-246-236q-20-19-46-19-13 0-25 5-18 7-29 23t-11 35v719q0 19 11 35t29 23q12 5 25 5h585z" horiz-adv-x="714.3" />
|
||||
|
||||
<glyph glyph-name="cog" unicode="" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" />
|
||||
<glyph glyph-name="eye" unicode="" d="M0 350q6 49 64 110 79 80 176 129 129 60 260 60 137-2 260-60 103-53 176-129 64-73 64-110-6-49-64-109-79-80-176-129-129-61-260-61-137 2-260 61-103 53-176 129-64 72-64 109z m264 0q0-94 69-159t167-65 167 65 69 159-69 159-167 66-167-66-69-159z m86 1q0 60 44 102t106 42 106-42 44-102-44-102-106-43-106 43-44 102z" horiz-adv-x="1000" />
|
||||
|
||||
<glyph glyph-name="rss-feed" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" />
|
||||
<glyph glyph-name="pcf" unicode="" d="M50 800c-24 0-50-25-50-50l0-350c0-111 50-250 250-250 18 0 34 1 50 3l0 147-200 0s25 100 150 100c25 0 35-4 50-9l0 109c0 46 18 84 50 100 50 25 70 15 100 0 50-25 50-25 50-25l0 175c0 25-26 50-50 50-50 0-100-50-200-50-100 0-150 50-200 50z m88-175c34 0 62-28 62-62 0-35-28-63-62-63-35 0-63 28-63 63 0 34 28 62 63 62z m262-75c-24 0-50-25-50-50l0-350c0-111 50-250 250-250 200 0 250 139 250 250l0 350c0 25-26 50-50 50-50 0-100-50-200-50-100 0-150 50-200 50z m88-175c34 0 62-28 62-62 0-35-28-63-62-63-35 0-63 28-63 63 0 34 28 62 63 62z m225 0c34 0 62-28 62-62 0-35-28-63-62-63-35 0-63 28-63 63 0 34 28 62 63 62z m-263-275l300 0s-25-100-150-100-150 100-150 100z" horiz-adv-x="850" />
|
||||
|
||||
<glyph glyph-name="info" unicode="" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" />
|
||||
<glyph glyph-name="location" unicode="" d="M0 473q0 70 27 134 25 61 74 111 47 47 110 73 63 27 134 27 72 0 135-27 62-26 110-73 48-49 74-111 27-64 27-134t-27-134q-18-40-36-65l-229-347q-22-35-53-35t-55 35l-228 347q-22 31-36 65-27 64-27 134z m217 0q0-53 37-91 38-38 91-38 54 0 91 38 38 37 38 91 0 53-38 91-37 38-91 38-53 0-91-38-37-38-37-91z" horiz-adv-x="691.4" />
|
||||
|
||||
<glyph glyph-name="bird" unicode="" d="M920 636q-36-54-94-98l0-24q0-130-60-250t-186-203-290-83q-160 0-290 84 14-2 46-2 132 0 234 80-62 2-110 38t-66 94q10-4 34-4 26 0 50 6-66 14-108 66t-42 120l0 2q36-20 84-24-84 58-84 158 0 48 26 94 154-188 390-196-6 18-6 42 0 78 55 133t135 55q82 0 136-58 60 12 120 44-20-66-82-104 56 8 108 30z" horiz-adv-x="920" />
|
||||
<glyph glyph-name="bird" unicode="" d="M904 622q-37-54-90-93 0-8 0-23 0-73-21-145t-64-139-103-117-144-82-181-30q-151 0-276 81 19-2 43-2 126 0 224 77-59 1-105 36t-64 89q19-3 34-3 24 0 48 6-63 13-104 62t-41 115v2q38-21 82-23-37 25-59 64t-22 86q0 49 25 91 68-83 164-133t208-55q-5 21-5 41 0 75 53 127t127 53q79 0 132-57 61 12 115 44-21-64-80-100 52 6 104 28z" horiz-adv-x="928.6" />
|
||||
|
||||
<glyph glyph-name="rss-feed" unicode="" d="M214 100q0-45-31-76t-76-31-76 31-31 76 31 76 76 31 76-31 31-76z m286-69q1-15-9-26-10-12-27-12h-75q-14 0-24 9t-11 23q-12 128-103 219t-219 103q-14 1-23 11t-9 24v75q0 16 12 26 9 10 24 10h3q89-7 170-45t145-101q63-63 101-145t45-171z m286-1q1-15-10-26-10-11-26-11h-80q-14 0-25 10t-10 23q-7 120-57 228t-129 188-188 129-227 57q-14 1-24 11t-10 24v80q0 16 11 26 10 10 25 10h1q147-8 280-67t238-164q104-104 164-238t67-280z" horiz-adv-x="785.7" />
|
||||
</font>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 8.0 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
7
public/js/eirResources.js
Executable file
7
public/js/eirResources.js
Executable file
@ -0,0 +1,7 @@
|
||||
let eirTheme = document.querySelector("link[href='/css/themes/eir.css']");
|
||||
if (eirTheme != null) {
|
||||
let cursorScr = document.createElement("script");
|
||||
cursorScr.src = "/res/js/cursors.js";
|
||||
cursorScr.defer = "";
|
||||
document.getElementsByTagName("head")[0].appendChild(cursorScr);
|
||||
}
|
||||
0
public/js/hls.min.js → public/js/hls.light.min.js
vendored
Normal file → Executable file
0
public/js/hls.min.js → public/js/hls.light.min.js
vendored
Normal file → Executable file
@ -1,54 +1,89 @@
|
||||
# About
|
||||
# My instance
|
||||
|
||||
Nitter is a free and open source alternative Twitter front-end focused on
|
||||
privacy and performance. The source is available on GitHub at
|
||||
<https://github.com/zedeus/nitter>
|
||||
**This instance is running a fork, whose source can be found at**
|
||||
<https://git.eir-nya.gay/eir/nitter>.
|
||||
|
||||
* No JavaScript or ads
|
||||
* All requests go through the backend, client never talks to Twitter
|
||||
* Prevents Twitter from tracking your IP or JavaScript fingerprint
|
||||
* Uses Twitter's unofficial API (no rate limits or developer account required)
|
||||
* Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
|
||||
* RSS feeds
|
||||
* Themes
|
||||
* Mobile support (responsive design)
|
||||
* AGPLv3 licensed, no proprietary instances permitted
|
||||
My fork is based on [Cynthia Foxwell's fork](https://gitlab.com/Cynosphere/nitter).
|
||||
Nitter is created by Zedeus, whose source can be found at <https://github.com/zedeus/nitter>.
|
||||
|
||||
Nitter's GitHub wiki contains
|
||||
[instances](https://github.com/zedeus/nitter/wiki/Instances) and
|
||||
[browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
|
||||
maintained by the community.
|
||||
The rest of this page is copied from Cynthia's fork:
|
||||
|
||||
## Why use Nitter?
|
||||
|
||||
It's impossible to use Twitter without JavaScript enabled. For privacy-minded
|
||||
folks, preventing JavaScript analytics and IP-based tracking is important, but
|
||||
apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
|
||||
a VPN and using heavy-duty adblockers, you can get accurately tracked with your
|
||||
[browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
|
||||
[no JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||
particularly important after Twitter [removed the
|
||||
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
||||
for users to control whether their data gets sent to advertisers.
|
||||
|
||||
Using an instance of Nitter (hosted on a VPS for example), you can browse
|
||||
Twitter without JavaScript while retaining your privacy. In addition to
|
||||
respecting your privacy, Nitter is on average around 15 times lighter than
|
||||
Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster).
|
||||
|
||||
In the future a simple account system will be added that lets you follow Twitter
|
||||
users, allowing you to have a clean chronological timeline without needing a
|
||||
Twitter account.
|
||||
|
||||
## Donating
|
||||
|
||||
Liberapay: <https://liberapay.com/zedeus> \
|
||||
Patreon: <https://patreon.com/nitter> \
|
||||
BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
|
||||
ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
|
||||
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
|
||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
|
||||
|
||||
## Contact
|
||||
|
||||
Feel free to join our [Matrix channel](https://matrix.to/#/#nitter:matrix.org).
|
||||
> # About
|
||||
>
|
||||
> Nitter is a free and open source alternative Twitter front-end focused on
|
||||
> privacy and performance.
|
||||
>
|
||||
> * No JavaScript or ads
|
||||
> * All requests go through the backend, client never talks to Twitter
|
||||
> * Prevents Twitter from tracking your IP or JavaScript fingerprint
|
||||
> * Uses Twitter's unofficial API (no rate limits or developer account required)
|
||||
> * Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
|
||||
> * RSS feeds
|
||||
> * Themes
|
||||
> * Mobile support (responsive design)
|
||||
> * AGPLv3 licensed, no proprietary instances permitted (source code below)
|
||||
>
|
||||
> Nitter's GitHub wiki contains
|
||||
> [instances](https://github.com/zedeus/nitter/wiki/Instances) and
|
||||
> [browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
|
||||
> maintained by the community.
|
||||
>
|
||||
> ### Fork features by Cynthia Foxwell
|
||||
>
|
||||
> * Localized following via cookies (list exportable and editable in preferences)
|
||||
> * Image zooming/carousel (requires JavaScript)
|
||||
> * Up to date Twitter features, e.g. Community Notes
|
||||
> * Embeds for chat services on-par with services like [FxTwitter](https://github.com/FixTweet/FxTwitter) and [vxTwitter](https://github.com/dylanpdx/BetterTwitFix)
|
||||
>
|
||||
> ## Why use Nitter?
|
||||
>
|
||||
> It's impossible to use Twitter without JavaScript enabled. For privacy-minded
|
||||
> folks, preventing JavaScript analytics and IP-based tracking is important, but
|
||||
> apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
|
||||
> a VPN and using heavy-duty adblockers, you can get accurately tracked with your
|
||||
> [browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
|
||||
> [no JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||
> particularly important after Twitter [removed the
|
||||
> ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
||||
> for users to control whether their data gets sent to advertisers.
|
||||
>
|
||||
> Using an instance of Nitter (hosted on a VPS for example), you can browse
|
||||
> Twitter without JavaScript while retaining your privacy. In addition to
|
||||
> respecting your privacy, Nitter is on average around 15 times lighter than
|
||||
> Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster).
|
||||
>
|
||||
> ## Donating
|
||||
>
|
||||
> Even though I could be selfish and point people to donate to me instead of
|
||||
> Zedeus, it would be disrespectful.
|
||||
>
|
||||
> GitHub Sponsors: <https://github.com/sponsors/zedeus> \
|
||||
> Donations go to zedeus, original creator of Nitter.
|
||||
>
|
||||
> Liberapay: <https://liberapay.com/zedeus> \
|
||||
> Patreon: <https://patreon.com/nitter> \
|
||||
> BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
|
||||
> ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
|
||||
> LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
|
||||
> XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
|
||||
>
|
||||
> ## Credits
|
||||
>
|
||||
> * Zedeus for this project
|
||||
> * PrivacyDevel, cmj, and taskylizard for keeping this project alive with forks after the main repo went inactive
|
||||
> * Every other contributors who've committed to the main repo in the past
|
||||
>
|
||||
> ## To any law enforcement agencies and copyright holders
|
||||
>
|
||||
> **All illegal content should be reported to Twitter directly.** This service is
|
||||
> merely a proxy of Twitter and no content is hosted on this server. Do not waste
|
||||
> your time contacting internet service providers, hosting providers and/or domain
|
||||
> registrars.
|
||||
>
|
||||
> If you would like more context, you can read about this exact issue happening to
|
||||
> [PussTheCat.org's instance](https://pussthecat.org/nitter/).
|
||||
>
|
||||
> I emplore all Nitter instance hosts to not enable media proxying, even if it
|
||||
> "phones home" to Twitter's CDN (which doesn't really pose a tracking risk and
|
||||
> breaks videos anyways), as it [has been used as an attack vector to take down
|
||||
> nitter.net](https://github.com/zedeus/nitter/issues/1150#issuecomment-1890855255).
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Nitter",
|
||||
"short_name": "Nitter",
|
||||
"name": "Kitter",
|
||||
"short_name": "Kitter",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
@ -18,7 +18,7 @@
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#333333",
|
||||
"background_color": "#333333",
|
||||
"theme_color": "#faaaab",
|
||||
"background_color": "#faaaab",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
202
src/api.nim
202
src/api.nim
@ -1,59 +1,114 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar
|
||||
import asyncdispatch, httpclient, strutils, sequtils, sugar
|
||||
import packedjson
|
||||
import types, query, formatters, consts, apiutils, parser
|
||||
import experimental/parser as newParser
|
||||
|
||||
# Helper to generate params object for GraphQL requests
|
||||
proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
|
||||
result.add ("variables", variables)
|
||||
result.add ("features", gqlFeatures)
|
||||
if fieldToggles.len > 0:
|
||||
result.add ("fieldToggles", fieldToggles)
|
||||
|
||||
proc apiUrl*(endpoint, variables: string; fieldToggles = ""): ApiUrl =
|
||||
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles))
|
||||
|
||||
proc apiReq*(endpoint, variables: string; fieldToggles = ""): ApiReq =
|
||||
let url = apiUrl(endpoint, variables, fieldToggles)
|
||||
return ApiReq(cookie: url, oauth: url)
|
||||
|
||||
proc mediaUrl*(id: string; cursor: string): ApiReq =
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor], """{"withArticlePlainText":false}"""),
|
||||
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor])
|
||||
)
|
||||
|
||||
proc userTweetsUrl*(id: string; cursor: string): ApiReq =
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
|
||||
oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor])
|
||||
)
|
||||
|
||||
proc userTweetsAndRepliesUrl*(id: string; cursor: string): ApiReq =
|
||||
let cookieVars = userTweetsAndRepliesVars % [id, cursor]
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
|
||||
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor])
|
||||
)
|
||||
|
||||
proc tweetDetailUrl*(id: string; cursor: string): ApiReq =
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphTweetDetail, tweetDetailVars % [id, cursor], tweetDetailFieldToggles),
|
||||
oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
|
||||
)
|
||||
|
||||
proc userUrl*(username: string): ApiReq =
|
||||
let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
|
||||
result = ApiReq(
|
||||
cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
|
||||
oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username)
|
||||
)
|
||||
|
||||
proc getGraphUser*(username: string): Future[User] {.async.} =
|
||||
if username.len == 0: return
|
||||
let
|
||||
variables = """{"screen_name": "$1"}""" % username
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetchRaw(graphUser ? params, Api.userScreenName)
|
||||
|
||||
let headers = newHttpHeaders()
|
||||
headers.add("Referer", """https://x.com/$1""" % username)
|
||||
|
||||
let js = await fetchRaw(userUrl(username), headers)
|
||||
result = parseGraphUser(js)
|
||||
|
||||
proc getGraphUserById*(id: string): Future[User] {.async.} =
|
||||
if id.len == 0 or id.any(c => not c.isDigit): return
|
||||
let headers = newHttpHeaders()
|
||||
headers.add("Referer", """https://x.com/i/user/$1""" % id)
|
||||
|
||||
let
|
||||
variables = """{"rest_id": "$1"}""" % id
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetchRaw(graphUserById ? params, Api.userRestId)
|
||||
url = apiReq(graphUserById, """{"userId":"$1"}""" % id)
|
||||
js = await fetchRaw(url, headers)
|
||||
result = parseGraphUser(js)
|
||||
|
||||
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
||||
if id.len == 0: return
|
||||
|
||||
let
|
||||
endpoint = case kind
|
||||
of TimelineKind.tweets: ""
|
||||
of TimelineKind.replies: "/with_replies"
|
||||
of TimelineKind.media: "/media"
|
||||
headers = newHttpHeaders()
|
||||
headers.add("Referer", """https://x.com/$1$2""" % [id, endpoint])
|
||||
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = if kind == TimelineKind.media: userMediaVariables % [id, cursor] else: userTweetsVariables % [id, cursor]
|
||||
fieldToggles = """{"withArticlePlainText":true}"""
|
||||
params = {"variables": variables, "features": gqlFeatures, "fieldToggles": fieldToggles}
|
||||
(url, apiId) = case kind
|
||||
of TimelineKind.tweets: (graphUserTweets, Api.userTweets)
|
||||
of TimelineKind.replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies)
|
||||
of TimelineKind.media: (graphUserMedia, Api.userMedia)
|
||||
js = await fetch(url ? params, apiId)
|
||||
url = case kind
|
||||
of TimelineKind.tweets: userTweetsUrl(id, cursor)
|
||||
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
|
||||
of TimelineKind.media: mediaUrl(id, cursor)
|
||||
js = await fetch(url, headers)
|
||||
result = parseGraphTimeline(js, if kind == TimelineKind.media: "" else: "user", after)
|
||||
|
||||
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = listTweetsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphListTweets ? params, Api.listTweets)
|
||||
url = apiReq(graphListTweets, restIdVars % [id, cursor])
|
||||
js = await fetch(url)
|
||||
result = parseGraphTimeline(js, "list", after).tweets
|
||||
|
||||
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
||||
let
|
||||
variables = %*{"screenName": name, "listSlug": list}
|
||||
params = {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphList(await fetch(graphListBySlug ? params, Api.listBySlug))
|
||||
url = apiReq(graphListBySlug, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphList(js)
|
||||
|
||||
proc getGraphList*(id: string): Future[List] {.async.} =
|
||||
let
|
||||
variables = """{"listId": "$1"}""" % id
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
result = parseGraphList(await fetch(graphListById ? params, Api.list))
|
||||
url = apiReq(graphListById, """{"listId": "$1"}""" % id)
|
||||
js = await fetch(url)
|
||||
result = parseGraphList(js)
|
||||
|
||||
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
||||
if list.id.len == 0: return
|
||||
@ -67,77 +122,45 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
|
||||
}
|
||||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
|
||||
|
||||
proc getFavorites*(id: string; cfg: Config; after=""): Future[Profile] {.async.} =
|
||||
if id.len == 0: return
|
||||
var
|
||||
variables = %*{
|
||||
"userId": id,
|
||||
"includePromotedContent":false,
|
||||
"withClientEventToken":false,
|
||||
"withBirdwatchNotes":false,
|
||||
"withVoice":true,
|
||||
"withV2Timeline":false
|
||||
}
|
||||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
let
|
||||
url = consts.favorites ? {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphTimeline(await fetch(url, Api.favorites), after)
|
||||
url = apiReq(graphListMembers, $variables)
|
||||
js = await fetchRaw(url)
|
||||
result = parseGraphListMembers(js, after)
|
||||
|
||||
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
||||
if id.len == 0: return
|
||||
|
||||
let headers = newHttpHeaders()
|
||||
headers.add("Referer", """https://x.com/i/status/$1""" % id)
|
||||
|
||||
let
|
||||
variables = """{"rest_id": "$1"}""" % id
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphTweetResult ? params, Api.tweetResult)
|
||||
url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
|
||||
js = await fetch(url, headers)
|
||||
result = parseGraphTweetResult(js)
|
||||
|
||||
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
|
||||
proc getGraphTweet*(id: string; after=""): Future[Conversation] {.async.} =
|
||||
if id.len == 0: return
|
||||
|
||||
let headers = newHttpHeaders()
|
||||
headers.add("Referer", """https://x.com/i/status/$1""" % id)
|
||||
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = tweetVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphTweet ? params, Api.tweetDetail)
|
||||
js = await fetch(tweetDetailUrl(id, cursor), headers)
|
||||
result = parseGraphConversation(js, id)
|
||||
|
||||
proc getGraphFavoriters*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = reactorsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphFavoriters ? params, Api.favoriters)
|
||||
result = parseGraphFavoritersTimeline(js, id)
|
||||
|
||||
proc getGraphRetweeters*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = reactorsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphRetweeters ? params, Api.retweeters)
|
||||
result = parseGraphRetweetersTimeline(js, id)
|
||||
|
||||
proc getGraphFollowing*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = followVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphFollowing ? params, Api.following)
|
||||
js = await fetch(apiReq(graphFollowing, followVars % [id, cursor]))
|
||||
result = parseGraphFollowTimeline(js, id)
|
||||
|
||||
proc getGraphFollowers*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = followVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphFollowers ? params, Api.followers)
|
||||
js = await fetch(apiReq(graphFollowers, followVars % [id, cursor]))
|
||||
result = parseGraphFollowTimeline(js, id)
|
||||
|
||||
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
||||
@ -158,15 +181,16 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
|
||||
variables = %*{
|
||||
"rawQuery": q,
|
||||
"count": 20,
|
||||
"query_source": "typed_query",
|
||||
"product": "Latest",
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false
|
||||
"withGrokTranslatedBio": false
|
||||
}
|
||||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after)
|
||||
let
|
||||
url = apiReq(graphSearchTimeline, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphSearch[Tweets](js, after)
|
||||
result.query = query
|
||||
|
||||
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
|
||||
@ -177,26 +201,24 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
|
||||
variables = %*{
|
||||
"rawQuery": query.text,
|
||||
"count": 20,
|
||||
"query_source": "typed_query",
|
||||
"product": "People",
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false
|
||||
"withGrokTranslatedBio": false
|
||||
}
|
||||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
result.beginning = false
|
||||
|
||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
||||
result = parseGraphSearch[User](await fetch(url, Api.search), after)
|
||||
let
|
||||
url = apiReq(graphSearchTimeline, $variables)
|
||||
js = await fetch(url)
|
||||
result = parseGraphSearch[User](js, after)
|
||||
result.query = query
|
||||
|
||||
proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} =
|
||||
if name.len == 0: return
|
||||
let
|
||||
ps = genParams({"screen_name": name, "trim_user": "true"},
|
||||
count="18", ext=false)
|
||||
url = photoRail ? ps
|
||||
result = parsePhotoRail(await fetch(url, Api.photoRail))
|
||||
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||
if id.len == 0: return
|
||||
let js = await fetch(mediaUrl(id, ""))
|
||||
result = parseGraphPhotoRail(js)
|
||||
|
||||
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
||||
let client = newAsyncHttpClient(maxRedirects=0)
|
||||
|
||||
130
src/apiutils.nim
130
src/apiutils.nim
@ -1,7 +1,7 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import httpclient, asyncdispatch, options, strutils, uri, times, tables
|
||||
import httpclient, asyncdispatch, options, strutils, uri, times, tables, math
|
||||
import jsony, packedjson, zippy
|
||||
import types, tokens, consts, parserutils, http_pool
|
||||
import types, consts, parserutils, http_pool, tid
|
||||
import experimental/types/common
|
||||
import config
|
||||
|
||||
@ -9,66 +9,63 @@ const
|
||||
rlRemaining = "x-rate-limit-remaining"
|
||||
rlReset = "x-rate-limit-reset"
|
||||
|
||||
var pool: HttpPool
|
||||
var
|
||||
pool: HttpPool
|
||||
disableTid: bool
|
||||
|
||||
proc genParams*(pars: openArray[(string, string)] = @[]; cursor="";
|
||||
count="20"; ext=true): seq[(string, string)] =
|
||||
result = timelineParams
|
||||
for p in pars:
|
||||
result &= p
|
||||
if ext:
|
||||
result &= ("include_ext_alt_text", "1")
|
||||
result &= ("include_ext_media_stats", "1")
|
||||
result &= ("include_ext_media_availability", "1")
|
||||
if count.len > 0:
|
||||
result &= ("count", count)
|
||||
if cursor.len > 0:
|
||||
# The raw cursor often has plus signs, which sometimes get turned into spaces,
|
||||
# so we need to turn them back into a plus
|
||||
if " " in cursor:
|
||||
result &= ("cursor", cursor.replace(" ", "+"))
|
||||
else:
|
||||
result &= ("cursor", cursor)
|
||||
proc setDisableTid*(disable: bool) =
|
||||
disableTid = disable
|
||||
|
||||
proc genHeaders*(token: Token = nil): HttpHeaders =
|
||||
result = newHttpHeaders({
|
||||
"connection": "keep-alive",
|
||||
"authorization": auth,
|
||||
"content-type": "application/json",
|
||||
"x-guest-token": if token == nil: "" else: token.tok,
|
||||
"x-twitter-active-user": "yes",
|
||||
"authority": "api.twitter.com",
|
||||
"accept-encoding": "gzip",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
"accept": "*/*",
|
||||
"DNT": "1"
|
||||
})
|
||||
proc toUrl(req: ApiReq): Uri =
|
||||
let c = req.cookie
|
||||
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params
|
||||
|
||||
template updateToken() =
|
||||
if resp.headers.hasKey(rlRemaining):
|
||||
proc rateLimitError*(): ref RateLimitError =
|
||||
newException(RateLimitError, "rate limited")
|
||||
|
||||
proc genHeaders*(): HttpHeaders =
|
||||
let
|
||||
remaining = parseInt(resp.headers[rlRemaining])
|
||||
reset = parseInt(resp.headers[rlReset])
|
||||
token.setRateLimit(api, remaining, reset)
|
||||
t = getTime()
|
||||
ffVersion = floor(124 + (t.toUnix() - 1710892800) / 2419200)
|
||||
result = newHttpHeaders({
|
||||
"Connection": "keep-alive",
|
||||
"Authorization": bearerToken,
|
||||
"Content-Type": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept": "*/*",
|
||||
"DNT": "1",
|
||||
"Host": "x.com",
|
||||
"Origin": "https://x.com",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Sec-GPC": "1",
|
||||
"TE": "trailers",
|
||||
"User-Agent": """Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:$1.0) Gecko/20100101 Firefox/$1.0""" % $ffVersion,
|
||||
"x-twitter-active-user": "yes",
|
||||
"x-twitter-auth-type": "OAuth2Session",
|
||||
"x-twitter-client-language": "en"
|
||||
}, true)
|
||||
|
||||
template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
|
||||
once:
|
||||
pool = HttpPool()
|
||||
|
||||
var token = await getToken(api)
|
||||
if token.tok.len == 0:
|
||||
raise rateLimitError()
|
||||
|
||||
if len(cfg.cookieHeader) != 0:
|
||||
additional_headers.add("Cookie", cfg.cookieHeader)
|
||||
if not disableTid:
|
||||
additional_headers.add("x-client-transaction-id", await genTid(url.path))
|
||||
|
||||
if len(cfg.xCsrfToken) != 0:
|
||||
additional_headers.add("x-csrf-token", cfg.xCsrfToken)
|
||||
|
||||
try:
|
||||
var resp: AsyncResponse
|
||||
var headers = genHeaders(token)
|
||||
var headers = genHeaders()
|
||||
for key, value in additional_headers.pairs():
|
||||
headers.add(key, value)
|
||||
|
||||
pool.use(headers):
|
||||
template getContent =
|
||||
resp = await c.get($url)
|
||||
@ -87,7 +84,6 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
|
||||
let
|
||||
remaining = parseInt(resp.headers[rlRemaining])
|
||||
reset = parseInt(resp.headers[rlReset])
|
||||
token.setRateLimit(api, remaining, reset)
|
||||
|
||||
if result.len > 0:
|
||||
if resp.headers.getOrDefault("content-encoding") == "gzip":
|
||||
@ -96,75 +92,55 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
|
||||
if result.startsWith("{\"errors"):
|
||||
let errors = result.fromJson(Errors)
|
||||
if errors in {expiredToken, badToken, authorizationError}:
|
||||
echo "fetch error: ", errors
|
||||
release(token, invalid=true)
|
||||
raise rateLimitError()
|
||||
elif errors in {rateLimited}:
|
||||
# rate limit hit, resets after 24 hours
|
||||
#setLimited(account, api)
|
||||
raise rateLimitError()
|
||||
elif result.startsWith("429 Too Many Requests"):
|
||||
echo "[accounts] 429 error, API: ", api, ", token: ", token[]
|
||||
#account.apis[api].remaining = 0
|
||||
# rate limit hit, resets after the 15 minute window
|
||||
raise rateLimitError()
|
||||
|
||||
fetchBody
|
||||
|
||||
release(token, used=true)
|
||||
|
||||
if resp.status == $Http400:
|
||||
echo "ERROR 400, ", url.path, ": ", result
|
||||
raise newException(InternalError, $url)
|
||||
except InternalError as e:
|
||||
raise e
|
||||
except BadClientError as e:
|
||||
release(token, used=true)
|
||||
raise e
|
||||
except OSError as e:
|
||||
raise e
|
||||
except ProtocolError as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url
|
||||
if "length" notin e.msg and "descriptor" notin e.msg:
|
||||
release(token, invalid=true)
|
||||
echo "error: ", e.name, ", msg: ", e.msg, ", url: ", url.path
|
||||
raise rateLimitError()
|
||||
|
||||
template retry(bod) =
|
||||
try:
|
||||
bod
|
||||
except RateLimitError:
|
||||
echo "[accounts] Rate limited, retrying ", api, " request..."
|
||||
except ProtocolError:
|
||||
bod
|
||||
|
||||
proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} =
|
||||
proc fetch*(req: ApiReq; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} =
|
||||
retry:
|
||||
var body: string
|
||||
let url = req.toUrl()
|
||||
fetchImpl(body, additional_headers):
|
||||
if body.startsWith('{') or body.startsWith('['):
|
||||
result = parseJson(body)
|
||||
else:
|
||||
echo resp.status, ": ", body, " --- url: ", url
|
||||
echo resp.status, " - non-json for: ", url, ", body: ", result
|
||||
result = newJNull()
|
||||
|
||||
updateToken()
|
||||
|
||||
let error = result.getError
|
||||
if error in {expiredToken, badToken}:
|
||||
echo "fetch error: ", result.getError
|
||||
release(token, invalid=true)
|
||||
echo "Fetch error, API: ", url.path, ", error: ", result.getError
|
||||
raise rateLimitError()
|
||||
|
||||
proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
|
||||
proc fetchRaw*(req: ApiReq; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
|
||||
retry:
|
||||
let url = req.toUrl()
|
||||
fetchImpl(result, additional_headers):
|
||||
if not (result.startsWith('{') or result.startsWith('[')):
|
||||
echo resp.status, ": ", result, " --- url: ", url
|
||||
echo resp.status, " - non-json for: ", url, ", body: ", result
|
||||
result.setLen(0)
|
||||
|
||||
updateToken()
|
||||
|
||||
if result.startsWith("{\"errors"):
|
||||
let errors = result.fromJson(Errors)
|
||||
if errors in {expiredToken, badToken}:
|
||||
echo "fetch error: ", errors
|
||||
release(token, invalid=true)
|
||||
raise rateLimitError()
|
||||
@ -126,7 +126,7 @@ proc getAccountPoolDebug*(): JsonNode =
|
||||
proc rateLimitError*(): ref RateLimitError =
|
||||
newException(RateLimitError, "rate limited")
|
||||
|
||||
proc isLimited(account: GuestAccount; api: Api): bool =
|
||||
proc isLimited(account: GuestAccount; req: ApiReq): bool =
|
||||
if account.isNil:
|
||||
return true
|
||||
|
||||
@ -157,9 +157,9 @@ proc release*(account: GuestAccount) =
|
||||
if account.isNil: return
|
||||
dec account.pending
|
||||
|
||||
proc getGuestAccount*(api: Api): Future[GuestAccount] {.async.} =
|
||||
proc getGuestAccount*(req: ApiReq): Future[GuestAccount] {.async.} =
|
||||
for i in 0 ..< accountPool.len:
|
||||
if result.isReady(api): break
|
||||
if result.isReady(req): break
|
||||
result = accountPool.sample()
|
||||
|
||||
if not result.isNil and result.isReady(api):
|
||||
|
||||
@ -22,6 +22,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
||||
httpMaxConns: cfg.get("Server", "httpMaxConnections", 100),
|
||||
staticDir: cfg.get("Server", "staticDir", "./public"),
|
||||
title: cfg.get("Server", "title", "Nitter"),
|
||||
oembedColor: cfg.get("Server", "oembedColor", "#1F1F1F"),
|
||||
hostname: cfg.get("Server", "hostname", "nitter.net"),
|
||||
|
||||
# Cache
|
||||
@ -42,6 +43,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
||||
enableDebug: cfg.get("Config", "enableDebug", false),
|
||||
proxy: cfg.get("Config", "proxy", ""),
|
||||
proxyAuth: cfg.get("Config", "proxyAuth", ""),
|
||||
disableTid: cfg.get("Config", "disableTid", false),
|
||||
cookieHeader: cfg.get("Config", "cookieHeader", ""),
|
||||
xCsrfToken: cfg.get("Config", "xCsrfToken", "")
|
||||
)
|
||||
|
||||
224
src/consts.nim
224
src/consts.nim
@ -1,105 +1,77 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import uri, sequtils, strutils
|
||||
import strutils
|
||||
|
||||
const
|
||||
auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
|
||||
|
||||
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
|
||||
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
|
||||
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
||||
|
||||
api = parseUri("https://api.twitter.com")
|
||||
activate* = $(api / "1.1/guest/activate.json")
|
||||
|
||||
photoRail* = api / "1.1/statuses/media_timeline.json"
|
||||
|
||||
timelineApi = api / "2/timeline"
|
||||
|
||||
graphql = api / "graphql"
|
||||
graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
|
||||
graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
|
||||
graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2"
|
||||
graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2"
|
||||
graphUserMedia* = graphql / "dexO_2tohK86JDudXXG3Yw/UserMedia"
|
||||
graphTweet* = graphql / "q94uRCEn65LZThakYcPT6g/TweetDetail"
|
||||
graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery"
|
||||
graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
|
||||
graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
|
||||
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
|
||||
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
|
||||
graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline"
|
||||
graphFavoriters* = graphql / "mDc_nU8xGv0cLRWtTaIEug/Favoriters"
|
||||
graphRetweeters* = graphql / "RCR9gqwYD1NEgi9FWzA50A/Retweeters"
|
||||
graphFollowers* = graphql / "EAqBhgcGr_qPOzhS4Q3scQ/Followers"
|
||||
graphFollowing* = graphql / "JPZiqKjET7_M1r5Tlr8pyA/Following"
|
||||
favorites* = graphql / "eSSNbhECHHWWALkkQq-YTA/Likes"
|
||||
|
||||
timelineParams* = {
|
||||
"include_can_media_tag": "1",
|
||||
"include_cards": "1",
|
||||
"include_entities": "1",
|
||||
"include_profile_interstitial_type": "0",
|
||||
"include_quote_count": "0",
|
||||
"include_reply_count": "0",
|
||||
"include_user_entities": "0",
|
||||
"include_ext_reply_count": "0",
|
||||
"include_ext_media_color": "0",
|
||||
"cards_platform": "Web-13",
|
||||
"tweet_mode": "extended",
|
||||
"send_error_codes": "1",
|
||||
"simple_quoted_tweet": "1"
|
||||
}.toSeq
|
||||
graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"
|
||||
graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
|
||||
graphUserById* = "Bbaot8ySMtJD7K2t01gW7A/UserByRestId"
|
||||
graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
|
||||
graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
|
||||
graphUserTweets* = "lZRf8IC-GTuGxDwcsHW8aw/UserTweets"
|
||||
graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
|
||||
graphUserMedia* = "1D04dx9H2pseMQAbMjXTvQ/UserMedia"
|
||||
graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
|
||||
graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
|
||||
graphTweetDetail* = "6QzqakNMdh_YzBAR9SYPkQ/TweetDetail"
|
||||
graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
|
||||
graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
|
||||
graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
|
||||
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
|
||||
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
|
||||
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
|
||||
graphFollowers* = "Efm7xwLreAw77q2Fq7rX-Q/Followers"
|
||||
graphFollowing* = "e0UtTAwQqgLKBllQxMgVxQ/Following"
|
||||
|
||||
gqlFeatures* = """{
|
||||
"android_graphql_skip_api_media_color_palette": false,
|
||||
"articles_preview_enabled": false,
|
||||
"blue_business_profile_image_shape_enabled": false,
|
||||
"c9s_tweet_anatomy_moderator_badge_enabled": false,
|
||||
"communities_web_enable_tweet_community_results_fetch": false,
|
||||
"creator_subscriptions_quote_tweet_preview_enabled": false,
|
||||
"creator_subscriptions_subscription_count_enabled": false,
|
||||
"creator_subscriptions_tweet_preview_api_enabled": true,
|
||||
"freedom_of_speech_not_reach_fetch_enabled": false,
|
||||
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
|
||||
"hidden_profile_likes_enabled": false,
|
||||
"highlights_tweets_tab_ui_enabled": false,
|
||||
"interactive_text_enabled": false,
|
||||
"longform_notetweets_consumption_enabled": true,
|
||||
"longform_notetweets_inline_media_enabled": false,
|
||||
"longform_notetweets_richtext_consumption_enabled": true,
|
||||
"longform_notetweets_rich_text_read_enabled": false,
|
||||
"responsive_web_edit_tweet_api_enabled": false,
|
||||
"responsive_web_enhance_cards_enabled": false,
|
||||
"responsive_web_graphql_exclude_directive_enabled": true,
|
||||
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
|
||||
"responsive_web_graphql_timeline_navigation_enabled": false,
|
||||
"responsive_web_media_download_video_enabled": false,
|
||||
"responsive_web_text_conversations_enabled": false,
|
||||
"responsive_web_twitter_article_tweet_consumption_enabled": false,
|
||||
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
|
||||
"rweb_lists_timeline_redesign_enabled": true,
|
||||
"rweb_tipjar_consumption_enabled": false,
|
||||
"rweb_video_timestamps_enabled": true,
|
||||
"spaces_2022_h2_clipping": true,
|
||||
"spaces_2022_h2_spaces_communities": true,
|
||||
"standardized_nudges_misinfo": false,
|
||||
"subscriptions_verification_info_enabled": true,
|
||||
"subscriptions_verification_info_reason_enabled": true,
|
||||
"subscriptions_verification_info_verified_since_enabled": true,
|
||||
"super_follow_badge_privacy_enabled": false,
|
||||
"super_follow_exclusive_tweet_notifications_enabled": false,
|
||||
"super_follow_tweet_api_enabled": false,
|
||||
"super_follow_user_api_enabled": false,
|
||||
"tweet_awards_web_tipping_enabled": false,
|
||||
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
|
||||
"tweetypie_unmention_optimization_enabled": false,
|
||||
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
|
||||
"rweb_video_screen_enabled": false,
|
||||
"profile_label_improvements_pcf_label_in_post_enabled": true,
|
||||
"responsive_web_profile_redirect_enabled": false,
|
||||
"rweb_tipjar_consumption_enabled": true,
|
||||
"verified_phone_label_enabled": false,
|
||||
"vibe_api_enabled": false,
|
||||
"view_counts_everywhere_api_enabled": false
|
||||
"creator_subscriptions_tweet_preview_api_enabled": true,
|
||||
"responsive_web_graphql_timeline_navigation_enabled": true,
|
||||
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
|
||||
"premium_content_api_read_enabled": false,
|
||||
"communities_web_enable_tweet_community_results_fetch": true,
|
||||
"c9s_tweet_anatomy_moderator_badge_enabled": true,
|
||||
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
|
||||
"responsive_web_grok_analyze_post_followups_enabled": true,
|
||||
"responsive_web_jetfuel_frame": true,
|
||||
"responsive_web_grok_share_attachment_enabled": true,
|
||||
"articles_preview_enabled": true,
|
||||
"responsive_web_edit_tweet_api_enabled": true,
|
||||
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
|
||||
"view_counts_everywhere_api_enabled": true,
|
||||
"longform_notetweets_consumption_enabled": true,
|
||||
"responsive_web_twitter_article_tweet_consumption_enabled": true,
|
||||
"tweet_awards_web_tipping_enabled": false,
|
||||
"responsive_web_grok_show_grok_translated_post": true,
|
||||
"responsive_web_grok_analysis_button_from_backend": true,
|
||||
"creator_subscriptions_quote_tweet_preview_enabled": false,
|
||||
"freedom_of_speech_not_reach_fetch_enabled": true,
|
||||
"standardized_nudges_misinfo": true,
|
||||
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
|
||||
"longform_notetweets_rich_text_read_enabled": true,
|
||||
"longform_notetweets_inline_media_enabled": true,
|
||||
"responsive_web_grok_image_annotation_enabled": true,
|
||||
"responsive_web_grok_imagine_annotation_enabled": true,
|
||||
"responsive_web_grok_community_note_auto_translation_is_enabled": false,
|
||||
"responsive_web_enhance_cards_enabled": false,
|
||||
"payments_enabled": false,
|
||||
"responsive_web_twitter_article_notes_tab_enabled": false,
|
||||
"hidden_profile_subscriptions_enabled": false,
|
||||
"subscriptions_verification_info_verified_since_enabled": false,
|
||||
"subscriptions_verification_info_is_identity_verified_enabled": false,
|
||||
"highlights_tweets_tab_ui_enabled": false,
|
||||
"subscriptions_feature_can_gift_premium": false
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
tweetVariables* = """{
|
||||
"focalTweetId": "$1",
|
||||
tweetVars* = """{
|
||||
"postId": "$1",
|
||||
$2
|
||||
"includeHasBirdwatchNotes": false,
|
||||
"includePromotedContent": false,
|
||||
@ -108,47 +80,57 @@ const
|
||||
"withV2Timeline": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
# oldUserTweetsVariables* = """{
|
||||
# "userId": "$1", $2
|
||||
# "count": 20,
|
||||
# "includePromotedContent": false,
|
||||
# "withDownvotePerspective": false,
|
||||
# "withReactionsMetadata": false,
|
||||
# "withReactionsPerspective": false,
|
||||
# "withVoice": false,
|
||||
# "withV2Timeline": true
|
||||
# }
|
||||
# """.replace(" ", "").replace("\n", "")
|
||||
|
||||
userTweetsVariables* = """{
|
||||
"rest_id": "$1",
|
||||
tweetDetailVars* = """{
|
||||
"focalTweetId": "$1",
|
||||
$2
|
||||
"count": 20
|
||||
"referrer": "profile",
|
||||
"with_rux_injections": false,
|
||||
"rankingMode": "Relevance",
|
||||
"includePromotedContent": true,
|
||||
"withCommunity": true,
|
||||
"withQuickPromoteEligibilityTweetFields": true,
|
||||
"withBirdwatchNotes": true,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
listTweetsVariables* = """{
|
||||
"rest_id": "$1",
|
||||
$2
|
||||
restIdVars* = """{
|
||||
"rest_id": "$1", $2
|
||||
"count": 20
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
}"""
|
||||
|
||||
reactorsVariables* = """{
|
||||
"tweetId": "$1",
|
||||
$2
|
||||
userMediaVars* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false
|
||||
"includePromotedContent": false,
|
||||
"withClientEventToken": false,
|
||||
"withBirdwatchNotes": false,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
followVariables* = """{
|
||||
userTweetsVars* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
"withQuickPromoteEligibilityTweetFields": true,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userTweetsAndRepliesVars* = """{
|
||||
"userId": "$1", $2
|
||||
"count": 20,
|
||||
"includePromotedContent": false,
|
||||
"withCommunity": true,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
followVars* = """{
|
||||
"userId": "$1",
|
||||
$2
|
||||
"count": 20,
|
||||
"includePromotedContent": false
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userMediaVariables* = """{
|
||||
"userId": "$1",
|
||||
$2
|
||||
"count": 20,
|
||||
"includePromotedContent": false
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
|
||||
userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
|
||||
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
|
||||
|
||||
@ -1,21 +1,53 @@
|
||||
import options
|
||||
import options, strutils
|
||||
import jsony
|
||||
import user, ../types/[graphuser, graphlistmembers]
|
||||
import user, utils, ../types/[graphuser, graphlistmembers]
|
||||
from ../../types import User, VerifiedType, Result, Query, QueryKind
|
||||
|
||||
proc parseUserResult*(userResult: UserResult): User =
|
||||
result = userResult.legacy
|
||||
|
||||
if result.verifiedType == none and userResult.isBlueVerified:
|
||||
result.verifiedType = blue
|
||||
|
||||
if result.username.len == 0 and userResult.core.screenName.len > 0:
|
||||
result.id = userResult.restId
|
||||
result.username = userResult.core.screenName
|
||||
result.fullname = userResult.core.name
|
||||
result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
|
||||
|
||||
if userResult.privacy.isSome:
|
||||
result.protected = userResult.privacy.get.protected
|
||||
|
||||
if userResult.location.isSome:
|
||||
result.location = userResult.location.get.location
|
||||
|
||||
if userResult.core.createdAt.len > 0:
|
||||
result.joinDate = parseTwitterDate(userResult.core.createdAt)
|
||||
|
||||
if userResult.verification.isSome:
|
||||
let v = userResult.verification.get
|
||||
if v.verifiedType != VerifiedType.none:
|
||||
result.verifiedType = v.verifiedType
|
||||
|
||||
if userResult.profileBio.isSome and result.bio.len == 0:
|
||||
result.bio = userResult.profileBio.get.description
|
||||
|
||||
proc parseGraphUser*(json: string): User =
|
||||
if json.len == 0 or json[0] != '{':
|
||||
return
|
||||
|
||||
let raw = json.fromJson(GraphUser)
|
||||
let
|
||||
raw = json.fromJson(GraphUser)
|
||||
userResult =
|
||||
if raw.data.userResult.isSome: raw.data.userResult.get.result
|
||||
elif raw.data.user.isSome: raw.data.user.get.result
|
||||
else: UserResult()
|
||||
|
||||
if raw.data.userResult.result.unavailableReason.get("") == "Suspended":
|
||||
if userResult.unavailableReason.get("") == "Suspended" or
|
||||
userResult.reason.get("") == "Suspended":
|
||||
return User(suspended: true)
|
||||
|
||||
result = raw.data.userResult.result.legacy
|
||||
result.id = raw.data.userResult.result.restId
|
||||
if result.verifiedType == VerifiedType.none and raw.data.userResult.result.isBlueVerified:
|
||||
result.verifiedType = blue
|
||||
result = parseUserResult(userResult)
|
||||
|
||||
proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||
result = Result[User](
|
||||
@ -31,7 +63,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||
of TimelineTimelineItem:
|
||||
let userResult = entry.content.itemContent.userResults.result
|
||||
if userResult.restId.len > 0:
|
||||
result.content.add userResult.legacy
|
||||
result.content.add parseUserResult(userResult)
|
||||
of TimelineTimelineCursor:
|
||||
if entry.content.cursorType == "Bottom":
|
||||
result.bottom = entry.content.value
|
||||
|
||||
8
src/experimental/parser/tid.nim
Normal file
8
src/experimental/parser/tid.nim
Normal file
@ -0,0 +1,8 @@
|
||||
import jsony
|
||||
import ../types/tid
|
||||
export TidPair
|
||||
|
||||
proc parseTidPairs*(raw: string): seq[TidPair] =
|
||||
result = raw.fromJson(seq[TidPair])
|
||||
if result.len == 0:
|
||||
raise newException(ValueError, "Parsing pairs failed: " & raw)
|
||||
@ -64,7 +64,7 @@ proc toUser*(raw: RawUser): User =
|
||||
)
|
||||
|
||||
if raw.pinnedTweetIdsStr.len > 0:
|
||||
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
|
||||
result.pinnedTweet = raw.pinnedTweetIdsStr[0]
|
||||
|
||||
result.expandUserEntities(raw)
|
||||
|
||||
|
||||
@ -1,15 +1,48 @@
|
||||
import options
|
||||
from ../../types import User
|
||||
import options, strutils
|
||||
from ../../types import User, VerifiedType
|
||||
|
||||
type
|
||||
GraphUser* = object
|
||||
data*: tuple[userResult: UserData]
|
||||
data*: tuple[userResult: Option[UserData], user: Option[UserData]]
|
||||
|
||||
UserData* = object
|
||||
result*: UserResult
|
||||
|
||||
UserResult = object
|
||||
UserCore* = object
|
||||
name*: string
|
||||
screenName*: string
|
||||
createdAt*: string
|
||||
|
||||
UserBio* = object
|
||||
description*: string
|
||||
|
||||
UserAvatar* = object
|
||||
imageUrl*: string
|
||||
|
||||
Verification* = object
|
||||
verifiedType*: VerifiedType
|
||||
|
||||
Location* = object
|
||||
location*: string
|
||||
|
||||
Privacy* = object
|
||||
protected*: bool
|
||||
|
||||
UserResult* = object
|
||||
legacy*: User
|
||||
restId*: string
|
||||
isBlueVerified*: bool
|
||||
core*: UserCore
|
||||
avatar*: UserAvatar
|
||||
unavailableReason*: Option[string]
|
||||
reason*: Option[string]
|
||||
privacy*: Option[Privacy]
|
||||
profileBio*: Option[UserBio]
|
||||
verification*: Option[Verification]
|
||||
location*: Option[Location]
|
||||
|
||||
proc enumHook*(s: string; v: var VerifiedType) =
|
||||
v = try:
|
||||
parseEnum[VerifiedType](s)
|
||||
except:
|
||||
VerifiedType.none
|
||||
|
||||
4
src/experimental/types/tid.nim
Normal file
4
src/experimental/types/tid.nim
Normal file
@ -0,0 +1,4 @@
|
||||
type
|
||||
TidPair* = object
|
||||
animationKey*: string
|
||||
verification*: string
|
||||
@ -7,10 +7,11 @@ const
|
||||
cards = "cards.twitter.com/cards"
|
||||
tco = "https://t.co"
|
||||
twitter = parseUri("https://twitter.com")
|
||||
sameProto = "//"
|
||||
|
||||
let
|
||||
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
|
||||
twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>"""
|
||||
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?(twitter|x)\.com"
|
||||
twLinkRegex = re"""<a href="https:\/\/(twitter|x).com([^"]+)">(twitter|x)\.com(\S+)</a>"""
|
||||
|
||||
ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase})
|
||||
|
||||
@ -31,8 +32,10 @@ let
|
||||
illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]"
|
||||
|
||||
proc getUrlPrefix*(cfg: Config): string =
|
||||
if cfg.useHttps: https & cfg.hostname
|
||||
else: "http://" & cfg.hostname
|
||||
var proto = "http"
|
||||
if cfg.useHttps: proto &= "s"
|
||||
proto &= "://"
|
||||
result = proto & cfg.hostname
|
||||
|
||||
proc shortLink*(text: string; length=28): string =
|
||||
result = text.replace(wwwRegex, "")
|
||||
@ -59,12 +62,12 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
|
||||
if prefs.replaceYouTube.len > 0 and "youtu" in result:
|
||||
result = result.replace(ytRegex, prefs.replaceYouTube)
|
||||
|
||||
if prefs.replaceTwitter.len > 0 and ("twitter.com" in body or tco in body):
|
||||
if prefs.replaceTwitter.len > 0 and ("twitter.com" in result or "/x.com" in result or tco in result):
|
||||
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
|
||||
result = result.replace(cards, prefs.replaceTwitter & "/cards")
|
||||
result = result.replace(twRegex, prefs.replaceTwitter)
|
||||
result = result.replacef(twLinkRegex, a(
|
||||
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
|
||||
prefs.replaceTwitter & "$4", href = prefs.replaceTwitter & "$2"))
|
||||
|
||||
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
|
||||
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
|
||||
@ -150,7 +153,7 @@ proc getShortTime*(tweet: Tweet): string =
|
||||
result = "now"
|
||||
|
||||
proc getLink*(tweet: Tweet; focus=true): string =
|
||||
if tweet.id == 0: return
|
||||
if tweet.id.len == 0: return
|
||||
var username = tweet.user.username
|
||||
if username.len == 0:
|
||||
username = "i"
|
||||
|
||||
@ -6,14 +6,15 @@ from htmlgen import a
|
||||
|
||||
import jester
|
||||
|
||||
import types, config, prefs, formatters, redis_cache, http_pool
|
||||
import types, config, prefs, formatters, redis_cache, http_pool, apiutils
|
||||
import views/[general, about]
|
||||
import routes/[
|
||||
preferences, timeline, status, media, search, rss, list, #debug,
|
||||
unsupported, embed, resolver, router_utils, home, follow, twitter_api]
|
||||
preferences, timeline, status, media, search, list, rss, #debug,
|
||||
unsupported, embed, resolver, router_utils, home, follow, twitter_api,
|
||||
activityspoof]
|
||||
|
||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||
const issuesUrl = "https://git.eir-nya.gay/eir/nitter/issues"
|
||||
|
||||
#let accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json")
|
||||
|
||||
@ -33,6 +34,7 @@ setHmacKey(cfg.hmacKey)
|
||||
setProxyEncoding(cfg.base64Media)
|
||||
setMaxHttpConns(cfg.httpMaxConns)
|
||||
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
||||
setDisableTid(cfg.disableTid)
|
||||
initAboutPage(cfg.staticDir)
|
||||
|
||||
waitFor initRedisPool(cfg)
|
||||
@ -51,6 +53,7 @@ createEmbedRouter(cfg)
|
||||
createRssRouter(cfg)
|
||||
#createDebugRouter(cfg)
|
||||
createTwitterApiRouter(cfg)
|
||||
createActivityPubRouter(cfg)
|
||||
|
||||
settings:
|
||||
port = Port(cfg.port)
|
||||
@ -78,7 +81,7 @@ routes:
|
||||
|
||||
error InternalError:
|
||||
echo error.exc.name, ": ", error.exc.msg
|
||||
const link = a("open a GitHub issue", href = issuesUrl)
|
||||
const link = a("open an issue", href = issuesUrl)
|
||||
resp Http500, showError(
|
||||
&"An error occurred, please {link} with the URL you tried to visit.", cfg)
|
||||
|
||||
@ -102,6 +105,6 @@ routes:
|
||||
extend preferences, ""
|
||||
extend resolver, ""
|
||||
extend embed, ""
|
||||
#extend debug, ""
|
||||
extend activityspoof, ""
|
||||
extend api, ""
|
||||
extend unsupported, ""
|
||||
|
||||
191
src/parser.nim
191
src/parser.nim
@ -2,7 +2,7 @@
|
||||
import strutils, options, times, math
|
||||
import packedjson, packedjson/deserialiser
|
||||
import types, parserutils, utils
|
||||
import experimental/parser/unifiedcard
|
||||
import experimental/parser/[unifiedcard, utils]
|
||||
import std/tables
|
||||
|
||||
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
|
||||
@ -24,17 +24,52 @@ proc parseUser(js: JsonNode; id=""): User =
|
||||
media: js{"media_count"}.getInt,
|
||||
verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")),
|
||||
protected: js{"protected"}.getBool,
|
||||
joinDate: js{"created_at"}.getTime
|
||||
joinDate: parseTwitterDate(js{"created_at"}.getStr)
|
||||
)
|
||||
|
||||
result.expandUserEntities(js)
|
||||
|
||||
proc parseGraphUser(js: JsonNode): User =
|
||||
var user = js{"user_result", "result"}
|
||||
proc parseGraphUser*(js: JsonNode): User =
|
||||
var user = js{"data", "user", "result"}
|
||||
if user.isNull:
|
||||
user = ? js{"user_results", "result"}
|
||||
user = js{"user_results", "result"}
|
||||
if user.isNull:
|
||||
user = js{"user_result", "result"}
|
||||
|
||||
result = parseUser(user{"legacy"})
|
||||
if user{"__typename"}.getStr == "UserUnavailable" and user{"reason"}.getStr == "Suspended":
|
||||
return User(suspended: true)
|
||||
|
||||
result = parseUser(user{"legacy"}, user{"rest_id"}.getStr)
|
||||
|
||||
result.username = user{"core", "screen_name"}.getStr
|
||||
result.fullname = user{"core", "name"}.getStr
|
||||
result.joinDate = parseTwitterDate(user{"core", "created_at"}.getStr)
|
||||
result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")
|
||||
result.protected = user{"privacy", "protected"}.getBool
|
||||
|
||||
let label = user{"affiliates_highlighted_label", "label"}
|
||||
if not label.isNull:
|
||||
let labelType = label{"userLabelType"}.getStr
|
||||
if labelType == "AutomatedLabel":
|
||||
result.bot = true
|
||||
let entities = label{"longDescription", "entities"}
|
||||
if not entities.isNull:
|
||||
for ent in entities:
|
||||
if ent{"ref", "type"}.getStr != "TimelineRichTextMention": continue
|
||||
result.botOwner = ent{"ref", "screen_name"}.getStr
|
||||
break
|
||||
# TODO: are there other types than the two
|
||||
# TODO: find profile with "userLabelDisplayType" not equal to "Badge"
|
||||
elif labelType == "BusinessLabel":
|
||||
result.badge = Badge(
|
||||
name: label{"description"}.getStr,
|
||||
icon: label{"badge", "url"}.getStr,
|
||||
url: label{"url", "url"}.getStr
|
||||
)
|
||||
|
||||
let pcf = user{"parody_commentary_fan_label"}.getStr
|
||||
if pcf.len > 0 and pcf != "None":
|
||||
result.pcf = pcf
|
||||
|
||||
if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false):
|
||||
result.verifiedType = blue
|
||||
@ -205,9 +240,10 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
||||
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
if js.isNull: return
|
||||
result = Tweet(
|
||||
id: js{"id_str"}.getId,
|
||||
threadId: js{"conversation_id_str"}.getId,
|
||||
replyId: js{"in_reply_to_status_id_str"}.getId,
|
||||
id: js{"id_str"}.getStr,
|
||||
threadId: js{"conversation_id_str"}.getStr,
|
||||
replyId: js{"in_reply_to_status_id_str"}.getStr,
|
||||
replyHandle: js{"in_reply_to_screen_name"}.getStr,
|
||||
text: js{"full_text"}.getStr,
|
||||
time: js{"created_at"}.getTime,
|
||||
hasThread: js{"self_thread"}.notNull,
|
||||
@ -217,22 +253,23 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
replies: js{"reply_count"}.getInt,
|
||||
retweets: js{"retweet_count"}.getInt,
|
||||
likes: js{"favorite_count"}.getInt,
|
||||
quotes: js{"quote_count"}.getInt
|
||||
quotes: js{"quote_count"}.getInt,
|
||||
bookmarks: js{"bookmark_count"}.getInt
|
||||
)
|
||||
)
|
||||
|
||||
# fix for pinned threads
|
||||
if result.hasThread and result.threadId == 0:
|
||||
result.threadId = js{"self_thread", "id_str"}.getId
|
||||
if result.hasThread and result.threadId.len == 0:
|
||||
result.threadId = js{"self_thread", "id_str"}.getStr
|
||||
|
||||
if "retweeted_status" in js:
|
||||
result.retweet = some Tweet()
|
||||
elif js{"is_quote_status"}.getBool:
|
||||
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
|
||||
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getStr)
|
||||
|
||||
# legacy
|
||||
with rt, js{"retweeted_status_id_str"}:
|
||||
result.retweet = some Tweet(id: rt.getId)
|
||||
result.retweet = some Tweet(id: rt.getStr)
|
||||
return
|
||||
|
||||
# graphql
|
||||
@ -249,7 +286,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
let name = jsCard{"name"}.getStr
|
||||
if "poll" in name:
|
||||
if "image" in name:
|
||||
result.photos.add jsCard{"binding_values", "image_large"}.getImageVal
|
||||
result.photos.add Image(
|
||||
url: jsCard{"binding_values", "image_large"}.getImageVal
|
||||
)
|
||||
|
||||
result.poll = some parsePoll(jsCard)
|
||||
elif name == "amplify":
|
||||
@ -263,7 +302,10 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
for m in jsMedia:
|
||||
case m{"type"}.getStr
|
||||
of "photo":
|
||||
result.photos.add m{"media_url_https"}.getImageStr
|
||||
result.photos.add Image(
|
||||
url: m{"media_url_https"}.getImageStr,
|
||||
description: m{"ext_alt_text"}.getStr,
|
||||
)
|
||||
of "video":
|
||||
result.video = some(parseVideo(m))
|
||||
with user, m{"additional_media_info", "source_user"}:
|
||||
@ -318,11 +360,10 @@ proc parseTweetSearch*(js: JsonNode; after=""): Timeline =
|
||||
result.content.add @[parsed]
|
||||
|
||||
if result.content.len > 0:
|
||||
result.bottom = $(result.content[^1][0].id - 1)
|
||||
result.bottom = $(result.content[^1][0].id.parseBiggestInt() - 1)
|
||||
|
||||
proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
|
||||
let intId = if id.len > 0: parseBiggestInt(id) else: 0
|
||||
result = global.tweets.getOrDefault(id, Tweet(id: intId))
|
||||
result = global.tweets.getOrDefault(id, Tweet(id: id))
|
||||
|
||||
if result.quote.isSome:
|
||||
let quote = get(result.quote).id
|
||||
@ -409,23 +450,6 @@ proc parseTimeline*(js: JsonNode; after=""): Profile =
|
||||
else:
|
||||
result.tweets.top = cursor{"value"}.getStr
|
||||
|
||||
proc parsePhotoRail*(js: JsonNode): PhotoRail =
|
||||
with error, js{"error"}:
|
||||
if error.getStr == "Not authorized.":
|
||||
return
|
||||
|
||||
for tweet in js:
|
||||
let
|
||||
t = parseTweet(tweet, js{"tweet_card"})
|
||||
url = if t.photos.len > 0: t.photos[0]
|
||||
elif t.video.isSome: get(t.video).thumb
|
||||
elif t.gif.isSome: get(t.gif).thumb
|
||||
elif t.card.isSome: get(t.card).image
|
||||
else: ""
|
||||
|
||||
if url.len == 0: continue
|
||||
result.add GalleryPhoto(url: url, tweetId: $t.id)
|
||||
|
||||
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
|
||||
if js.kind == JNull:
|
||||
return Tweet()
|
||||
@ -442,7 +466,17 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
|
||||
of "TweetPreviewDisplay":
|
||||
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
|
||||
of "TweetWithVisibilityResults":
|
||||
return parseGraphTweet(js{"tweet"}, isLegacy)
|
||||
result = parseGraphTweet(js{"tweet"}, isLegacy)
|
||||
if js.hasKey("limitedActionResults"):
|
||||
for actionRes in js{"limitedActionResults", "limited_actions"}:
|
||||
let action = LimitedActions(
|
||||
title: actionRes{"prompt" , "headline", "text"}.getStr,
|
||||
text: actionRes{"prompt", "subtext", "text"}.getStr
|
||||
)
|
||||
result.limitedActions = some(action)
|
||||
break
|
||||
|
||||
return result
|
||||
else:
|
||||
discard
|
||||
|
||||
@ -457,7 +491,7 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
|
||||
jsCard["binding_values"] = values
|
||||
|
||||
result = parseTweet(js{"legacy"}, jsCard)
|
||||
result.id = js{"rest_id"}.getId
|
||||
result.id = js{"rest_id"}.getStr
|
||||
result.user = parseGraphUser(js{"core"})
|
||||
|
||||
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
|
||||
@ -467,13 +501,48 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
|
||||
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy))
|
||||
|
||||
with communityNote, js{"birdwatch_pivot"}:
|
||||
let title = communityNote{"title"}.getStr
|
||||
let note = BirdwatchNote(
|
||||
id: communityNote{"note", "rest_id"}.getId,
|
||||
title: communityNote{"title"}.getStr,
|
||||
id: communityNote{"note", "rest_id"}.getStr,
|
||||
title: title,
|
||||
)
|
||||
note.expandBirdwatchEntities(communityNote{"subtitle"})
|
||||
if title != "Rate proposed Community Notes":
|
||||
result.birdwatch = some(note)
|
||||
|
||||
if not js{"views", "count"}.isNull:
|
||||
result.stats.views = parseInt(js{"views", "count"}.getStr)
|
||||
else:
|
||||
result.stats.views = -1
|
||||
|
||||
proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
|
||||
result = @[]
|
||||
|
||||
let instructions =
|
||||
? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||
|
||||
for i in instructions:
|
||||
if i{"__typename"}.getStr == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "content", "tweetResult", "result"}:
|
||||
let t = parseGraphTweet(tweetResult, false)
|
||||
if not t.available:
|
||||
t.id = $parseBiggestInt(entryId.getId())
|
||||
|
||||
let url =
|
||||
if t.photos.len > 0: t.photos[0].url
|
||||
elif t.video.isSome: get(t.video).thumb
|
||||
elif t.gif.isSome: get(t.gif).thumb
|
||||
elif t.card.isSome: get(t.card).image
|
||||
else: ""
|
||||
|
||||
result.add GalleryPhoto(url: url, tweetId: t.id)
|
||||
|
||||
if result.len == 16:
|
||||
break
|
||||
|
||||
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
||||
for t in js{"content", "items"}:
|
||||
let entryId = t{"entryId"}.getStr
|
||||
@ -481,7 +550,7 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
||||
let cursor = t{"item", "content", "value"}
|
||||
result.thread.cursor = cursor.getStr
|
||||
result.thread.hasMore = true
|
||||
elif "tweet" in entryId:
|
||||
elif "tweet" in entryId and "promoted-" notin entryId:
|
||||
let
|
||||
isLegacy = t{"item"}.hasKey("itemContent")
|
||||
(contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results")
|
||||
@ -504,23 +573,25 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for e in instructions[0]{"entries"}:
|
||||
for i in instructions:
|
||||
if i{"type"}.getStr == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult, true)
|
||||
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
tweet.id = entryId.getId()
|
||||
|
||||
if $tweet.id == tweetId:
|
||||
if tweet.id == tweetId:
|
||||
result.tweet = tweet
|
||||
else:
|
||||
result.before.content.add tweet
|
||||
elif entryId.startsWith("tombstone"):
|
||||
let id = entryId.getId()
|
||||
let tweet = Tweet(
|
||||
id: parseBiggestInt(id),
|
||||
id: id,
|
||||
available: false,
|
||||
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
|
||||
)
|
||||
@ -529,42 +600,26 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
||||
result.tweet = tweet
|
||||
else:
|
||||
result.before.content.add tweet
|
||||
elif entryId.startsWith("conversationthread"):
|
||||
elif (entryId.startsWith("conversationthread") or entryId.startswith("reply-mixer-conversation")) and "promoted-tweet" notin entryId:
|
||||
let (thread, self) = parseGraphThread(e)
|
||||
if self:
|
||||
result.after = thread
|
||||
else:
|
||||
result.replies.content.add thread
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.replies.bottom = e{"content", "itemContent", "value"}.getStr
|
||||
result.replies.bottom = e.getCursor
|
||||
|
||||
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
||||
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
||||
|
||||
let instructions =
|
||||
if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"}
|
||||
elif root == "user": ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||
else: ? js{"data", "user", "result", "timeline", "timeline", "instructions"}
|
||||
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for i in instructions:
|
||||
if i{"__typename"}.getStr == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "content", "tweetResult", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult, false)
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
result.tweets.content.add tweet
|
||||
elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
|
||||
let (thread, self) = parseGraphThread(e)
|
||||
result.tweets.content.add thread.content
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.tweets.bottom = e{"content", "value"}.getStr
|
||||
# TODO cleanup
|
||||
if i{"type"}.getStr == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
@ -572,21 +627,21 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult, false)
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
tweet.id = entryId.getId()
|
||||
result.tweets.content.add tweet
|
||||
elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
|
||||
let (thread, self) = parseGraphThread(e)
|
||||
result.tweets.content.add thread.content
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.tweets.bottom = e{"content", "value"}.getStr
|
||||
if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry":
|
||||
with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}:
|
||||
if after.len == 0 and i{"type"}.getStr == "TimelinePinEntry":
|
||||
with tweetResult, i{"entry", "content", "itemContent", "tweet_results", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult, false)
|
||||
tweet.pinned = true
|
||||
if not tweet.available and tweet.tombstone.len == 0:
|
||||
let entryId = i{"entry", "entryId"}.getEntryId
|
||||
if entryId.len > 0:
|
||||
tweet.id = parseBiggestInt(entryId)
|
||||
tweet.id = entryId
|
||||
result.pinned = some tweet
|
||||
|
||||
proc parseGraphUsersTimeline(timeline: JsonNode; after=""): UsersTimeline =
|
||||
@ -636,7 +691,7 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
|
||||
with tweetRes, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
let tweet = parseGraphTweet(tweetRes)
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
tweet.id = entryId.getId()
|
||||
result.content.add tweet
|
||||
elif T is User:
|
||||
if entryId.startsWith("user"):
|
||||
|
||||
@ -48,7 +48,13 @@ template with*(ident; value: JsonNode; body): untyped =
|
||||
if notNull(value): body
|
||||
|
||||
template getCursor*(js: JsonNode): string =
|
||||
js{"content", "operation", "cursor", "value"}.getStr
|
||||
var cursor = js{"content", "operation", "cursor", "value"}
|
||||
if cursor.isNull:
|
||||
cursor = js{"content", "value"}
|
||||
if cursor.isNull:
|
||||
cursor = js{"content", "itemContent", "value"}
|
||||
|
||||
cursor.getStr
|
||||
|
||||
template getError*(js: JsonNode): Error =
|
||||
if js.kind != JArray or js.len == 0: null
|
||||
@ -286,7 +292,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
|
||||
url: "/" & name, display: mention["name"].getStr)
|
||||
if idx > -1 and name != replyTo:
|
||||
tweet.reply.delete idx
|
||||
elif idx == -1 and tweet.replyId != 0:
|
||||
elif idx == -1 and tweet.replyId.len != 0:
|
||||
tweet.reply.add name
|
||||
|
||||
replacements.deduplicate
|
||||
@ -303,7 +309,7 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
||||
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
|
||||
|
||||
var replyTo = ""
|
||||
if tweet.replyId != 0:
|
||||
if tweet.replyId.len != 0:
|
||||
with reply, js{"in_reply_to_screen_name"}:
|
||||
replyTo = reply.getStr
|
||||
tweet.reply.add replyTo
|
||||
|
||||
@ -59,6 +59,9 @@ genPrefs:
|
||||
theme(select, "Nitter"):
|
||||
"Theme"
|
||||
|
||||
eirResources(checkbox, true):
|
||||
"Some extra silly js I added, like cursors :3"
|
||||
|
||||
infiniteScroll(checkbox, false):
|
||||
"Infinite scrolling (experimental, requires JavaScript)"
|
||||
|
||||
|
||||
@ -66,7 +66,8 @@ proc initRedisPool*(cfg: Config) {.async.} =
|
||||
template uidKey(name: string): string = "pid:" & $(hash(name) div 1_000_000)
|
||||
template userKey(name: string): string = "p:" & name
|
||||
template listKey(l: List): string = "l:" & l.id
|
||||
template tweetKey(id: int64): string = "t:" & $id
|
||||
template tweetKey(id: string): string = "t:" & id
|
||||
template convKey(id: string): string = "c:" & id
|
||||
|
||||
proc get(query: string): Future[string] {.async.} =
|
||||
pool.withAcquire(r):
|
||||
@ -86,7 +87,7 @@ proc cache*(data: List) {.async.} =
|
||||
await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))
|
||||
|
||||
proc cache*(data: PhotoRail; name: string) {.async.} =
|
||||
await setEx("pr:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data)))
|
||||
await setEx("pr2:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data)))
|
||||
|
||||
proc cache*(data: User) {.async.} =
|
||||
if data.username.len == 0: return
|
||||
@ -96,10 +97,15 @@ proc cache*(data: User) {.async.} =
|
||||
dawait r.setEx(name.userKey, baseCacheTime, compress(toFlatty(data)))
|
||||
|
||||
proc cache*(data: Tweet) {.async.} =
|
||||
if data.isNil or data.id == 0: return
|
||||
if data.isNil or data.id.len == 0: return
|
||||
pool.withAcquire(r):
|
||||
dawait r.setEx(data.id.tweetKey, baseCacheTime, compress(toFlatty(data)))
|
||||
|
||||
proc cache*(data: Conversation) {.async.} =
|
||||
if data.isNil or data.tweet.isNil or data.tweet.id.len == 0: return
|
||||
pool.withAcquire(r):
|
||||
dawait r.setEx(data.tweet.id.convKey, baseCacheTime, compress(toFlatty(data)))
|
||||
|
||||
proc cacheRss*(query: string; rss: Rss) {.async.} =
|
||||
let key = "rss:" & query
|
||||
pool.withAcquire(r):
|
||||
@ -114,7 +120,13 @@ template deserialize(data, T) =
|
||||
except:
|
||||
echo "Decompression failed($#): '$#'" % [astToStr(T), data]
|
||||
|
||||
proc getUserId*(username: string): Future[string] {.async.} =
|
||||
proc deserializeConversation(data: string): Conversation =
|
||||
try:
|
||||
result = fromFlatty(uncompress(data), Conversation)
|
||||
except:
|
||||
echo "Decompression failed(Conversation): '$#'" % [data]
|
||||
|
||||
proc getCachedUserId*(username: string): Future[string] {.async.} =
|
||||
let name = toLower(username)
|
||||
pool.withAcquire(r):
|
||||
result = await r.hGet(name.uidKey, name)
|
||||
@ -133,13 +145,16 @@ proc getCachedUser*(username: string; fetch=true): Future[User] {.async.} =
|
||||
elif fetch:
|
||||
result = await getGraphUser(username)
|
||||
await cache(result)
|
||||
if result.id.len > 0:
|
||||
await setEx("i:" & result.id, baseCacheTime, result.username)
|
||||
await cacheUserId(result.username, result.id)
|
||||
|
||||
proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
||||
let
|
||||
key = "i:" & userId
|
||||
username = await get(key)
|
||||
|
||||
if username != redisNil:
|
||||
if username != redisNil and username.len > 0:
|
||||
result = username
|
||||
else:
|
||||
let user = await getGraphUserById(userId)
|
||||
@ -148,24 +163,28 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
||||
if result.len > 0 and user.id.len > 0:
|
||||
await all(cacheUserId(result, user.id), cache(user))
|
||||
|
||||
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
|
||||
# if id == 0: return
|
||||
# let tweet = await get(id.tweetKey)
|
||||
# if tweet != redisNil:
|
||||
# tweet.deserialize(Tweet)
|
||||
# else:
|
||||
# result = await getGraphTweetResult($id)
|
||||
# if not result.isNil:
|
||||
# await cache(result)
|
||||
proc getCachedTweet*(id: string; after=""): Future[Conversation] {.async.} =
|
||||
if id.len == 0: return
|
||||
let tweet = await get(id.tweetKey)
|
||||
|
||||
proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} =
|
||||
if name.len == 0: return
|
||||
let rail = await get("pr:" & toLower(name))
|
||||
if tweet != redisNil:
|
||||
result = deserializeConversation(tweet)
|
||||
else:
|
||||
result = await getGraphTweet(id)
|
||||
if not result.isNil:
|
||||
await cache(result)
|
||||
|
||||
if not result.isNil and after.len > 0:
|
||||
result.replies = await getReplies(id, after)
|
||||
|
||||
proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||
if id.len == 0: return
|
||||
let rail = await get("pr2:" & toLower(id))
|
||||
if rail != redisNil:
|
||||
rail.deserialize(PhotoRail)
|
||||
else:
|
||||
result = await getPhotoRail(name)
|
||||
await cache(result, name)
|
||||
result = await getPhotoRail(id)
|
||||
await cache(result, id)
|
||||
|
||||
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
||||
let list = if id.len == 0: redisNil
|
||||
|
||||
350
src/routes/activityspoof.nim
Normal file
350
src/routes/activityspoof.nim
Normal file
@ -0,0 +1,350 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times
|
||||
|
||||
import jester
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, formatters, api, redis_cache]
|
||||
import ../views/[mastoapi]
|
||||
|
||||
export json, uri, sequtils, options, sugar, times
|
||||
export router_utils
|
||||
export api, formatters
|
||||
export mastoapi
|
||||
|
||||
proc createActivityPubRouter*(cfg: Config) =
|
||||
router activityspoof:
|
||||
get "/api/v1/accounts/?":
|
||||
resp Http200, {"Content-Type": "application/json"}, """[]"""
|
||||
|
||||
get "/api/v1/accounts/@id":
|
||||
let id = @"id"
|
||||
#if id.len > 19 or id.any(c => not c.isDigit):
|
||||
if not id.allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}):
|
||||
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid account ID"}"""
|
||||
|
||||
#var username = await getCachedUsername(id)
|
||||
#if username.len == 0:
|
||||
#resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
|
||||
|
||||
let user = await getCachedUser(id)
|
||||
if user.suspended or user.id.len == 0:
|
||||
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
|
||||
|
||||
resp Http200, {"Content-Type": "application/json"}, $getMastoAPIUser(user, cfg)
|
||||
|
||||
get "/api/v1/statuses/@id":
|
||||
var
|
||||
id = @"id"
|
||||
query = ""
|
||||
|
||||
# stupid hack to trick discord lmao
|
||||
if id.startsWith("422209040515"):
|
||||
query = "video"
|
||||
id.removePrefix("422209040515")
|
||||
elif id.startsWith("421608152015"):
|
||||
query = "photo:"
|
||||
id.removePrefix("421608152015")
|
||||
query &= id[0]
|
||||
id = id[1..^1]
|
||||
|
||||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
|
||||
|
||||
let prefs = cookiePrefs()
|
||||
|
||||
let conv = await getCachedTweet(id)
|
||||
if conv == nil:
|
||||
echo "nil conv"
|
||||
|
||||
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
|
||||
var error = "Record not found"
|
||||
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
|
||||
error = conv.tweet.tombstone
|
||||
|
||||
var errJson = newJObject()
|
||||
errJson["error"] = %error
|
||||
|
||||
resp Http404, {"Content-Type": "application/json"}, $errJson
|
||||
|
||||
var
|
||||
mediaType = ""
|
||||
mediaIndex = ""
|
||||
if query.len > 0:
|
||||
let parts = query.split(":")
|
||||
mediaType = parts[0]
|
||||
if parts.len == 2:
|
||||
mediaIndex = parts[1]
|
||||
|
||||
let
|
||||
tweet = conv.tweet
|
||||
tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{id}"
|
||||
var media: seq[JsonNode] = @[]
|
||||
|
||||
if mediaType.len > 0:
|
||||
if mediaType == "video" and tweet.video.isSome or tweet.gif.isSome:
|
||||
tweet.photos = @[]
|
||||
elif mediaType == "photo" and tweet.photos.len > 0:
|
||||
if mediaIndex.len > 0:
|
||||
var index = parseInt(mediaIndex)
|
||||
var useVideo = false
|
||||
if index > tweet.photos.len:
|
||||
if tweet.video.isSome or tweet.gif.isSome:
|
||||
useVideo = true
|
||||
else:
|
||||
index = tweet.photos.len
|
||||
elif index < 1:
|
||||
index = 1
|
||||
|
||||
if useVideo:
|
||||
tweet.photos = @[]
|
||||
else:
|
||||
index -= 1
|
||||
tweet.video = none(Video)
|
||||
let image = tweet.photos[index]
|
||||
tweet.photos = @[]
|
||||
tweet.photos.add(image)
|
||||
|
||||
if tweet.photos.len > 0:
|
||||
for imageObj in tweet.photos:
|
||||
let image = getUrlPrefix(cfg) & getPicUrl(imageObj.url)
|
||||
var mediaObj = newJObject()
|
||||
|
||||
mediaObj["id"] = %"138733266285887488" # idk if discord even parses this snowflake, but its my user id why not
|
||||
mediaObj["type"] = %"image"
|
||||
mediaObj["url"] = %image
|
||||
mediaObj["preview_url"] = %image
|
||||
mediaObj["remote_url"] = %image
|
||||
mediaObj["preview_remote_url"] = %image
|
||||
mediaObj["text_url"] = newJNull()
|
||||
mediaObj["description"] = %imageObj.description
|
||||
# FIXME but this probably isnt used by discord
|
||||
mediaObj["meta"] = newJObject()
|
||||
|
||||
media.add(mediaObj)
|
||||
|
||||
if tweet.video.isSome():
|
||||
let
|
||||
videoObj = get(tweet.video)
|
||||
vars = videoObj.variants.filterIt(it.contentType == mp4)
|
||||
videoUrl = vars[^1].url.replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", "")
|
||||
videoPreview = getUrlPrefix(cfg) & getPicUrl(videoObj.thumb)
|
||||
var mediaObj = newJObject()
|
||||
var description = videoObj.title
|
||||
if videoObj.description.len > 0:
|
||||
description = videoObj.description
|
||||
|
||||
mediaObj["id"] = %"138733266285887488"
|
||||
mediaObj["type"] = %"video"
|
||||
mediaObj["url"] = %videoUrl
|
||||
mediaObj["preview_url"] = %videoPreview
|
||||
mediaObj["remote_url"] = %videoUrl
|
||||
mediaObj["preview_remote_url"] = %videoPreview
|
||||
mediaObj["text_url"] = newJNull()
|
||||
mediaObj["description"] = %description
|
||||
# FIXME but this probably isnt used by discord
|
||||
mediaObj["meta"] = newJObject()
|
||||
|
||||
media.add(mediaObj)
|
||||
elif tweet.gif.isSome():
|
||||
let
|
||||
gif = get(tweet.gif)
|
||||
gifUrl = (https & gif.url).replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", "")
|
||||
gifPreview = getUrlPrefix(cfg) & getPicUrl(gif.thumb)
|
||||
var mediaObj = newJObject()
|
||||
|
||||
mediaObj["id"] = %"138733266285887488"
|
||||
mediaObj["type"] = %"video"
|
||||
mediaObj["url"] = %gifUrl
|
||||
mediaObj["preview_url"] = %gifPreview
|
||||
mediaObj["remote_url"] = %gifUrl
|
||||
mediaObj["preview_remote_url"] = %gifPreview
|
||||
mediaObj["text_url"] = newJNull()
|
||||
mediaObj["description"] = newJNull() # FIXME this requires refactoring gifs
|
||||
# FIXME but this probably isnt used by discord
|
||||
mediaObj["meta"] = newJObject()
|
||||
|
||||
media.add(mediaObj)
|
||||
|
||||
var fields: seq[JsonNode] = @[]
|
||||
|
||||
if tweet.user.location.len > 0:
|
||||
var location = newJObject()
|
||||
location["name"] = %"Location"
|
||||
location["value"] = %tweet.user.location
|
||||
location["verified_at"] = newJNull()
|
||||
fields.add(location)
|
||||
|
||||
if tweet.user.website.len > 0:
|
||||
var website = newJObject()
|
||||
website["name"] = %"Website"
|
||||
website["value"] = %(&"<a href=\"{tweet.user.website}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{tweet.user.website}</a>")
|
||||
website["verified_at"] = newJNull()
|
||||
fields.add(website)
|
||||
|
||||
var postJson = newJObject()
|
||||
postJson["id"] = %tweet.id
|
||||
postJson["url"] = %tweetUrl
|
||||
postJson["uri"] = %tweetUrl
|
||||
postJson["created_at"] = %($tweet.time)
|
||||
postJson["edited_at"] = newJNull()
|
||||
postJson["reblog"] = newJNull()
|
||||
if tweet.replyId.len != 0:
|
||||
postJson["in_reply_to_id"] = %(&"{tweet.replyId}")
|
||||
postJson["in_reply_to_account_id"] = %tweet.replyHandle
|
||||
else:
|
||||
postJson["in_reply_to_id"] = newJNull()
|
||||
postJson["in_reply_to_account_id"] = newJNull()
|
||||
postJson["language"] = %"en" # FIXME
|
||||
postJson["content"] = %formatTweetForMastoAPI(tweet, cfg, prefs)
|
||||
postJson["spoiler_text"] = %""
|
||||
postJson["visibility"] = %"public"
|
||||
postJson["application"] = %*{
|
||||
"name": "Nitter",
|
||||
"website": getUrlPrefix(cfg)
|
||||
}
|
||||
postJson["media_attachments"] = %media
|
||||
postJson["account"] = getMastoAPIUser(tweet.user, cfg)
|
||||
postJson["mentions"] = newJArray() # TODO: parse?
|
||||
postJson["tags"] = newJArray() # TODO: parse?
|
||||
postJson["emojis"] = newJArray()
|
||||
postJson["card"] = newJNull()
|
||||
postJson["poll"] = newJNull() # TODO: parse?
|
||||
|
||||
resp Http200, {"Content-Type": "application/json"}, $postJson
|
||||
|
||||
get "/users/@name/statuses/@id":
|
||||
var
|
||||
id = @"id"
|
||||
query = ""
|
||||
|
||||
# stupid hack to trick discord lmao
|
||||
if id.startsWith("422209040515"):
|
||||
query = "video"
|
||||
id.removePrefix("422209040515")
|
||||
elif id.startsWith("421608152015"):
|
||||
query = "photo:"
|
||||
id.removePrefix("421608152015")
|
||||
query &= id[0]
|
||||
id = id[1..^1]
|
||||
|
||||
var
|
||||
mediaType = ""
|
||||
mediaIndex = ""
|
||||
if query.len > 0:
|
||||
let parts = query.split(":")
|
||||
mediaType = parts[0]
|
||||
if parts.len == 2:
|
||||
mediaIndex = parts[1]
|
||||
|
||||
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
|
||||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
|
||||
|
||||
let prefs = cookiePrefs()
|
||||
|
||||
let conv = await getCachedTweet(id)
|
||||
if conv == nil:
|
||||
echo "nil conv"
|
||||
|
||||
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
|
||||
var error = "Record not found"
|
||||
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
|
||||
error = conv.tweet.tombstone
|
||||
|
||||
var errJson = newJObject()
|
||||
errJson["error"] = %error
|
||||
|
||||
resp Http404, {"Content-Type": "application/json"}, $errJson
|
||||
|
||||
let tweet = conv.tweet
|
||||
|
||||
if mediaType.len > 0:
|
||||
if mediaType == "video" and tweet.video.isSome or tweet.gif.isSome:
|
||||
tweet.photos = @[]
|
||||
elif mediaType == "photo" and tweet.photos.len > 0:
|
||||
if mediaIndex.len > 0:
|
||||
var index = parseInt(mediaIndex)
|
||||
var useVideo = false
|
||||
if index > tweet.photos.len:
|
||||
if tweet.video.isSome or tweet.gif.isSome:
|
||||
useVideo = true
|
||||
else:
|
||||
index = tweet.photos.len
|
||||
elif index < 1:
|
||||
index = 1
|
||||
|
||||
if useVideo:
|
||||
tweet.photos = @[]
|
||||
else:
|
||||
index -= 1
|
||||
tweet.video = none(Video)
|
||||
let image = tweet.photos[index]
|
||||
tweet.photos = @[]
|
||||
tweet.photos.add(image)
|
||||
|
||||
let postJson = getActivityStream(tweet, cfg, prefs)
|
||||
|
||||
resp Http200, {"Content-Type": "application/json"}, $postJson
|
||||
|
||||
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||
|
||||
get "/users/@name":
|
||||
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
|
||||
let user = await getCachedUser(@"name")
|
||||
if user.suspended or user.id.len == 0:
|
||||
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
|
||||
|
||||
let prefs = cookiePrefs()
|
||||
|
||||
let userJson = getActivityStream(user, cfg, prefs)
|
||||
|
||||
resp Http200, {"Content-Type": "application/json"}, $userJson
|
||||
|
||||
redirect("/" & @"name")
|
||||
|
||||
# might as well
|
||||
get "/.well-known/nodeinfo":
|
||||
var nodeinfo = newJObject()
|
||||
let link: JsonNode = %*{
|
||||
"href": &"{getUrlPrefix(cfg)}/nodeinfo/2.1.json",
|
||||
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.1"
|
||||
}
|
||||
var links: seq[JsonNode] = @[]
|
||||
links.add(link)
|
||||
|
||||
nodeinfo["links"] = %links
|
||||
|
||||
resp Http200, {"Content-Type": "application/json"}, $nodeinfo
|
||||
|
||||
get "/nodeinfo/2.1.json":
|
||||
var nodeinfo = newJObject()
|
||||
nodeinfo["version"] = %"2.1"
|
||||
nodeinfo["software"] = %*{
|
||||
"name": cfg.title,
|
||||
"repository": "https://git.eir-nya.gay/eir/nitter"
|
||||
}
|
||||
|
||||
var metadata = newJObject()
|
||||
metadata["features"] = newJArray()
|
||||
metadata["federation"] = newJObject()
|
||||
metadata["nodeDescription"] = %"Alternative Twitter front-end (ActivityPub support added for Discord)"
|
||||
metadata["nodeName"] = %cfg.title
|
||||
metadata["private"] = %true
|
||||
metadata["maintainer"] = %*{
|
||||
"name": "Eir",
|
||||
"email": "eir@eir-nya.gay"
|
||||
}
|
||||
|
||||
nodeinfo["metadata"] = metadata
|
||||
nodeinfo["openRegistrations"] = %false
|
||||
nodeinfo["protocols"] = newJArray()
|
||||
|
||||
var services = newJObject()
|
||||
services["inbound"] = newJArray()
|
||||
services["outbound"] = newJArray()
|
||||
|
||||
nodeinfo["services"] = services
|
||||
nodeinfo["usage"] = newJObject()
|
||||
|
||||
resp Http200, {"Content-Type": "application/json"}, $nodeinfo
|
||||
@ -1,17 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import jester
|
||||
import router_utils
|
||||
import ".."/[tokens, types]
|
||||
|
||||
proc createDebugRouter*(cfg: Config) =
|
||||
router debug:
|
||||
get "/.tokens":
|
||||
cond cfg.enableDebug
|
||||
respJson getPoolJson()
|
||||
|
||||
#get "/.health":
|
||||
#respJson getAccountPoolHealth()
|
||||
|
||||
#get "/.accounts":
|
||||
#cond cfg.enableDebug
|
||||
#respJson getAccountPoolDebug()
|
||||
@ -4,7 +4,7 @@ import strutils, strformat, uri
|
||||
import jester
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, redis_cache, api]
|
||||
import ".."/[types, api, redis_cache]
|
||||
import ../views/[general, timeline, list]
|
||||
|
||||
template respList*(list, timeline, title, vnode: typed) =
|
||||
@ -20,6 +20,14 @@ template respList*(list, timeline, title, vnode: typed) =
|
||||
proc title*(list: List): string =
|
||||
&"@{list.username}/{list.name}"
|
||||
|
||||
|
||||
proc getList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
||||
if id.len > 0:
|
||||
result = await getGraphList(id)
|
||||
else:
|
||||
result = await getGraphListBySlug(username, slug)
|
||||
|
||||
|
||||
proc createListRouter*(cfg: Config) =
|
||||
router list:
|
||||
get "/@name/lists/@slug/?":
|
||||
|
||||
@ -141,3 +141,6 @@ proc createMediaRouter*(cfg: Config) =
|
||||
content = proxifyVideo(vid, cookiePref(proxyVideos))
|
||||
|
||||
resp content, m3u8Mime
|
||||
|
||||
get re"^\/tvid\/(.+)$":
|
||||
redirect("https://video.twimg.com/" & request.matches[0] & ".mp4")
|
||||
|
||||
@ -4,22 +4,26 @@ import strutils
|
||||
import jester
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, api]
|
||||
import ".."/[types, api, formatters]
|
||||
import ../views/general
|
||||
|
||||
template respResolved*(url, kind: string): untyped =
|
||||
template respResolved*(url, kind: string; prefs: Prefs): untyped =
|
||||
let u = url
|
||||
if u.len == 0:
|
||||
resp showError("Invalid $1 link" % kind, cfg)
|
||||
else:
|
||||
redirect(u)
|
||||
redirect(replaceUrls(u, prefs))
|
||||
|
||||
proc createResolverRouter*(cfg: Config) =
|
||||
router resolver:
|
||||
get "/cards/@card/@id":
|
||||
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
|
||||
respResolved(await resolve(url, cookiePrefs()), "card")
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
|
||||
respResolved(await resolve(url, prefs), "card", prefs)
|
||||
|
||||
get "/t.co/@url":
|
||||
let url = "https://t.co/" & @"url"
|
||||
respResolved(await resolve(url, cookiePrefs()), "t.co")
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
url = "https://t.co/" & @"url"
|
||||
respResolved(await resolve(url, prefs), "t.co", prefs)
|
||||
|
||||
@ -1,16 +1,29 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, strutils, sequtils, uri, options, sugar
|
||||
import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times
|
||||
|
||||
import jester, karax/vdom
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, formatters, api]
|
||||
import ../views/[general, status, search]
|
||||
import ".."/[types, formatters, api, redis_cache]
|
||||
import ../views/[general, status, mastoapi]
|
||||
|
||||
export uri, sequtils, options, sugar
|
||||
export json, uri, sequtils, options, sugar, times
|
||||
export router_utils
|
||||
export api, formatters
|
||||
export status
|
||||
export status, mastoapi
|
||||
|
||||
proc formatStat*(stat: int): string =
|
||||
#if stat > 1000000000000:
|
||||
# result = formatBiggestFloat(stat / 1000000000000, ffDecimal, precision = 1) & "T"
|
||||
#el
|
||||
if stat > 1000000000:
|
||||
result = formatBiggestFloat(stat / 1000000000, ffDecimal, precision = 1) & "B"
|
||||
elif stat > 1000000:
|
||||
result = formatBiggestFloat(stat / 1000000, ffDecimal, precision = 1) & "M"
|
||||
elif stat > 1000:
|
||||
result = formatBiggestFloat(stat / 1000, ffDecimal, precision = 1) & "K"
|
||||
else:
|
||||
result = $stat
|
||||
|
||||
proc createStatusRouter*(cfg: Config) =
|
||||
router status:
|
||||
@ -30,16 +43,86 @@ proc createStatusRouter*(cfg: Config) =
|
||||
resp Http404, ""
|
||||
resp $renderReplies(replies, prefs, getPath())
|
||||
|
||||
if @"reactors" == "favoriters":
|
||||
resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs),
|
||||
request, cfg, prefs)
|
||||
elif @"reactors" == "retweeters":
|
||||
resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs),
|
||||
request, cfg, prefs)
|
||||
#if @"reactors" == "favoriters":
|
||||
# resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs),
|
||||
# request, cfg, prefs)
|
||||
#elif @"reactors" == "retweeters":
|
||||
# resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs),
|
||||
# request, cfg, prefs)
|
||||
|
||||
get "/@name/status/@id/?":
|
||||
get "/@name/status/@id/?@m?/?@i?/?":
|
||||
cond '.' notin @"name"
|
||||
let id = @"id"
|
||||
var
|
||||
id = @"id"
|
||||
media = @"m"
|
||||
mediaIndex = @"i"
|
||||
|
||||
let url = $request.getNativeReq().url
|
||||
var
|
||||
rawVideo = false
|
||||
rawImage = false
|
||||
if url.endsWith(".mp4") or url.endsWith(".gif"):
|
||||
rawVideo = true
|
||||
elif url.endsWith(".png") or url.endsWith(".jpg"):
|
||||
rawImage = true
|
||||
|
||||
for ext in @[".mp4", ".gif", ".png", ".jpg"]:
|
||||
if id.endsWith(ext):
|
||||
id.removeSuffix(ext)
|
||||
if media.endsWith(ext):
|
||||
media.removeSuffix(ext)
|
||||
if mediaIndex.endsWith(ext):
|
||||
mediaIndex.removeSuffix(ext)
|
||||
|
||||
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
|
||||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
|
||||
|
||||
let prefs = cookiePrefs()
|
||||
|
||||
let conv = await getCachedTweet(id)
|
||||
if conv == nil:
|
||||
echo "nil conv"
|
||||
|
||||
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
|
||||
var error = "Record not found"
|
||||
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
|
||||
error = conv.tweet.tombstone
|
||||
|
||||
var errJson = newJObject()
|
||||
errJson["error"] = %error
|
||||
|
||||
resp Http404, {"Content-Type": "application/json"}, $errJson
|
||||
|
||||
let tweet = conv.tweet
|
||||
|
||||
if media.len > 0:
|
||||
if media == "video" and tweet.video.isSome or tweet.gif.isSome:
|
||||
tweet.photos = @[]
|
||||
elif media == "photo" and tweet.photos.len > 0:
|
||||
if mediaIndex.len > 0:
|
||||
var index = parseInt(mediaIndex)
|
||||
var useVideo = false
|
||||
if index > tweet.photos.len:
|
||||
if tweet.video.isSome or tweet.gif.isSome:
|
||||
useVideo = true
|
||||
else:
|
||||
index = tweet.photos.len
|
||||
elif index < 1:
|
||||
index = 1
|
||||
|
||||
if useVideo:
|
||||
tweet.photos = @[]
|
||||
else:
|
||||
index -= 1
|
||||
tweet.video = none(Video)
|
||||
let image = tweet.photos[index]
|
||||
tweet.photos = @[]
|
||||
tweet.photos.add(image)
|
||||
|
||||
let postJson = getActivityStream(tweet, cfg, prefs)
|
||||
|
||||
resp Http200, {"Content-Type": "application/json"}, $postJson
|
||||
|
||||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, showError("Invalid tweet ID", cfg)
|
||||
@ -53,56 +136,151 @@ proc createStatusRouter*(cfg: Config) =
|
||||
resp Http404, ""
|
||||
resp $renderReplies(replies, prefs, getPath())
|
||||
|
||||
let conv = await getTweet(id, getCursor())
|
||||
let conv = await getCachedTweet(id, getCursor())
|
||||
if conv == nil:
|
||||
echo "nil conv"
|
||||
|
||||
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
||||
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
|
||||
var error = "Tweet not found"
|
||||
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
|
||||
error = conv.tweet.tombstone
|
||||
resp Http404, showError(error, cfg)
|
||||
|
||||
let
|
||||
title = pageTitle(conv.tweet)
|
||||
ogTitle = pageTitle(conv.tweet.user)
|
||||
desc = conv.tweet.text
|
||||
avatar = conv.tweet.user.userPic
|
||||
time = some(conv.tweet.time)
|
||||
tweet = conv.tweet
|
||||
title = pageTitle(tweet)
|
||||
ogTitle = pageTitle(tweet.user)
|
||||
desc = tweet.text
|
||||
avatar = tweet.user.userPic
|
||||
time = some(tweet.time)
|
||||
|
||||
let
|
||||
ua = request.headers.getOrDefault("User-Agent").toString()
|
||||
isChatEmbedder = ua.contains("Discordbot") or ua.contains("TelegramBot")
|
||||
var
|
||||
realMediaIndex = mediaIndex
|
||||
realUseVideo = false
|
||||
if isChatEmbedder and media.len > 0:
|
||||
if media == "video" and tweet.video.isSome or tweet.gif.isSome:
|
||||
tweet.photos = @[]
|
||||
elif media == "photo" and tweet.photos.len > 0:
|
||||
if mediaIndex.len > 0:
|
||||
var index = parseInt(mediaIndex)
|
||||
var useVideo = false
|
||||
if index > tweet.photos.len:
|
||||
if tweet.video.isSome or tweet.gif.isSome:
|
||||
useVideo = true
|
||||
realUseVideo = true
|
||||
else:
|
||||
index = tweet.photos.len
|
||||
elif index < 1:
|
||||
index = 1
|
||||
|
||||
if useVideo:
|
||||
tweet.photos = @[]
|
||||
else:
|
||||
realMediaIndex = $index
|
||||
index -= 1
|
||||
tweet.video = none(Video)
|
||||
let image = tweet.photos[index]
|
||||
tweet.photos = @[]
|
||||
tweet.photos.add(image)
|
||||
|
||||
var
|
||||
images = conv.tweet.photos
|
||||
images = tweet.photos
|
||||
video = ""
|
||||
context = ""
|
||||
contextUrl = ""
|
||||
|
||||
if conv.tweet.video.isSome():
|
||||
let videoObj = get(conv.tweet.video)
|
||||
images = @[videoObj.thumb]
|
||||
if tweet.quote.isSome():
|
||||
let
|
||||
quote = get(tweet.quote)
|
||||
quoteUser = quote.user
|
||||
if tweet.replyId.len != 0:
|
||||
let replyUser = await getCachedUser(tweet.replyHandle)
|
||||
context = &"↩ {replyUser.fullname} (@{tweet.replyHandle})\n↘ {quoteUser.fullname} (@{quoteUser.username})"
|
||||
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
|
||||
else:
|
||||
context = &"↘ {quoteUser.fullname} (@{quoteUser.username})"
|
||||
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}"
|
||||
elif tweet.replyId.len != 0:
|
||||
let replyUser = await getCachedUser(tweet.replyHandle)
|
||||
context = &"↩ {replyUser.fullname} (@{tweet.replyHandle})"
|
||||
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
|
||||
|
||||
if tweet.video.isSome():
|
||||
let videoObj = get(tweet.video)
|
||||
images.add(Image(url:videoObj.thumb))
|
||||
|
||||
let vars = videoObj.variants.filterIt(it.contentType == mp4)
|
||||
# idk why this wont sort when it sorts everywhere else
|
||||
#video = vars.sortedByIt(it.bitrate)[^1].url
|
||||
video = vars[^1].url
|
||||
elif conv.tweet.gif.isSome():
|
||||
let gif = get(conv.tweet.gif)
|
||||
images = @[gif.thumb]
|
||||
video = getPicUrl(gif.url)
|
||||
#elif conv.tweet.card.isSome():
|
||||
# let card = conv.tweet.card.get()
|
||||
elif tweet.gif.isSome():
|
||||
let gif = get(tweet.gif)
|
||||
images.add(Image(url:gif.thumb))
|
||||
video = getUrlPrefix(cfg) & getPicUrl(gif.url)
|
||||
#elif tweet.card.isSome():
|
||||
# let card = tweet.card.get()
|
||||
# if card.image.len > 0:
|
||||
# images = @[card.image]
|
||||
# elif card.video.isSome():
|
||||
# images = @[card.video.get().thumb]
|
||||
|
||||
if rawVideo and video != "":
|
||||
redirect(video)
|
||||
elif rawImage and images.len > 0:
|
||||
if media == "photo" and mediaIndex.len > 0:
|
||||
var index = parseInt(mediaIndex)
|
||||
var useVideo = false
|
||||
if index > tweet.photos.len:
|
||||
if video != "":
|
||||
useVideo = true
|
||||
else:
|
||||
index = tweet.photos.len
|
||||
elif index < 1:
|
||||
index = 1
|
||||
|
||||
if useVideo:
|
||||
redirect(video)
|
||||
else:
|
||||
index -= 1
|
||||
redirect(getPicUrl(images[index].url))
|
||||
else:
|
||||
redirect(getPicUrl(images[0].url))
|
||||
|
||||
var query = ""
|
||||
if media == "video":
|
||||
query = "video"
|
||||
elif media == "photo" and mediaIndex.len > 0:
|
||||
if realUseVideo and video != "":
|
||||
query = "video"
|
||||
else:
|
||||
query = &"photo:{realMediaIndex}"
|
||||
|
||||
var stats: seq[string] = @[]
|
||||
if tweet.stats.replies > 0:
|
||||
stats.add("↩ " & formatStat(tweet.stats.replies))
|
||||
if tweet.stats.retweets > 0:
|
||||
stats.add("🔁 " & formatStat(tweet.stats.retweets))
|
||||
if tweet.stats.quotes > 0:
|
||||
stats.add("↘ " & formatStat(tweet.stats.quotes))
|
||||
if tweet.stats.likes > 0:
|
||||
stats.add("♥ " & formatStat(tweet.stats.likes))
|
||||
if tweet.stats.bookmarks > 0:
|
||||
stats.add("🔖 " & formatStat(tweet.stats.bookmarks))
|
||||
if tweet.stats.views > 0:
|
||||
stats.add("👁️ " & formatStat(tweet.stats.views))
|
||||
|
||||
let statsStr = stats.join(" ")
|
||||
|
||||
let html = renderConversation(conv, prefs, getPath() & "#m")
|
||||
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
|
||||
images=images, video=video, avatar=avatar, time=time)
|
||||
images=images, video=video, avatar=avatar, time=time,
|
||||
context=context, contextUrl=contextUrl, id=id,
|
||||
media=query, stats=statsStr)
|
||||
|
||||
get "/@name/@s/@id/@m/?@i?":
|
||||
cond @"s" in ["status", "statuses"]
|
||||
cond @"m" in ["video", "photo"]
|
||||
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||
|
||||
get "/@name/statuses/@id/?":
|
||||
get "/@name/statuses/@id/?@m?/?@i?":
|
||||
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||
|
||||
get "/i/web/status/@id":
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, strutils, sequtils, uri, options, times
|
||||
import asyncdispatch, strutils, sequtils, uri, options, times, json
|
||||
import jester, karax/vdom
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, redis_cache, formatters, query, api]
|
||||
import ../views/[general, profile, timeline, status, search]
|
||||
import ".."/[types, formatters, query, api, redis_cache]
|
||||
import ../views/[general, profile, timeline, status, search, mastoapi]
|
||||
|
||||
export vdom
|
||||
export uri, sequtils
|
||||
export uri, sequtils, json
|
||||
export router_utils
|
||||
export redis_cache, formatters, query, api
|
||||
export profile, timeline, status
|
||||
export formatters, query, api, redis_cache
|
||||
export profile, timeline, status, mastoapi
|
||||
|
||||
proc getQuery*(request: Request; tab, name: string): Query =
|
||||
case tab
|
||||
@ -32,7 +32,7 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
|
||||
skipPinned=false): Future[Profile] {.async.} =
|
||||
let
|
||||
name = query.fromUser[0]
|
||||
userId = await getUserId(name)
|
||||
userId = await getCachedUserId(name)
|
||||
|
||||
if userId.len == 0:
|
||||
return Profile(user: User(username: name))
|
||||
@ -48,7 +48,7 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
|
||||
let
|
||||
rail =
|
||||
skipIf(skipRail or query.kind == media, @[]):
|
||||
getCachedPhotoRail(name)
|
||||
getCachedPhotoRail(userId)
|
||||
|
||||
user = getCachedUser(name)
|
||||
|
||||
@ -83,7 +83,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
|
||||
|
||||
let pHtml = renderProfile(profile, cfg, prefs, getPath())
|
||||
result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u),
|
||||
rss=rss, images = @[u.getUserPic("_400x400")],
|
||||
rss=rss, images = @[Image(url: u.getUserPic("_400x400"))],
|
||||
banner=u.banner)
|
||||
|
||||
template respTimeline*(timeline: typed) =
|
||||
@ -111,6 +111,7 @@ proc createTimelineRouter*(cfg: Config) =
|
||||
get "/@name/?@tab?/?":
|
||||
cond '.' notin @"name"
|
||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
|
||||
cond @"tab" in ["with_replies", "media", "search", "following", "followers", ""]
|
||||
let
|
||||
prefs = cookiePrefs()
|
||||
@ -120,10 +121,22 @@ proc createTimelineRouter*(cfg: Config) =
|
||||
|
||||
case tab:
|
||||
of "followers":
|
||||
resp renderMain(renderUserList(await getGraphFollowers(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
|
||||
resp renderMain(renderUserList(await getGraphFollowers(await getCachedUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
|
||||
of "following":
|
||||
resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
|
||||
resp renderMain(renderUserList(await getGraphFollowing(await getCachedUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
|
||||
else:
|
||||
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
|
||||
let userId = await getCachedUserId(@"name")
|
||||
|
||||
if userId == "suspended" or userId.len == 0:
|
||||
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
|
||||
|
||||
let user = await getCachedUser(@"name")
|
||||
|
||||
let userJson = getActivityStream(user, cfg, prefs)
|
||||
|
||||
resp Http200, {"Content-Type": "application/json"}, $userJson
|
||||
|
||||
var query = request.getQuery(@"tab", @"name")
|
||||
if names.len != 1:
|
||||
query.fromUser = names
|
||||
|
||||
@ -4,7 +4,7 @@ import json, asyncdispatch, options, uri
|
||||
import times
|
||||
import jester
|
||||
import router_utils
|
||||
import ".."/[types, api, apiutils, query, consts]
|
||||
import ".."/[types, api, apiutils, redis_cache]
|
||||
import httpclient, strutils
|
||||
import sequtils
|
||||
|
||||
@ -52,7 +52,7 @@ proc tweetToJson*(t: Tweet): JsonNode =
|
||||
result["photos"] = %t.photos
|
||||
|
||||
proc getUserProfileJson*(username: string): Future[JsonNode] {.async.} =
|
||||
let user: User = await getGraphUser(username)
|
||||
let user: User = await getCachedUser(username)
|
||||
let response: JsonNode = %*{
|
||||
"id": user.id,
|
||||
"username": user.username
|
||||
@ -81,52 +81,49 @@ proc getUserTweetsJson*(id: string): Future[JsonNode] {.async.} =
|
||||
|
||||
result = response
|
||||
|
||||
proc searchTimeline*(query: Query; after=""): Future[string] {.async.} =
|
||||
let q = genQueryParam(query)
|
||||
var
|
||||
variables = %*{
|
||||
"rawQuery": q,
|
||||
"count": 20,
|
||||
"product": "Latest",
|
||||
"withDownvotePerspective": false,
|
||||
"withReactionsMetadata": false,
|
||||
"withReactionsPerspective": false
|
||||
}
|
||||
if after.len > 0:
|
||||
variables["cursor"] = % after
|
||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
||||
result = await fetchRaw(url, Api.search)
|
||||
|
||||
proc getUserTweets*(id: string; after=""): Future[string] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = userTweetsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
result = await fetchRaw(graphUserTweets ? params, Api.userTweets)
|
||||
|
||||
let headers = newHttpHeaders()
|
||||
headers.add("Referer", """https://x.com/$1""" % id)
|
||||
|
||||
let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
result = await fetchRaw(userTweetsUrl(id, cursor), headers)
|
||||
|
||||
proc getUserReplies*(id: string; after=""): Future[string] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = userTweetsVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
result = await fetchRaw(graphUserTweets ? params, Api.userTweetsAndReplies)
|
||||
|
||||
let headers = newHttpHeaders()
|
||||
headers.add("Referer", """https://x.com/$1/with_replies""" % id)
|
||||
|
||||
let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
result = await fetchRaw(userTweetsAndRepliesUrl(id, cursor), headers)
|
||||
|
||||
proc getUserMedia*(id: string; after=""): Future[string] {.async.} =
|
||||
if id.len == 0: return
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = userMediaVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
result = await fetchRaw(graphUserMedia ? params, Api.userMedia)
|
||||
|
||||
let headers = newHttpHeaders()
|
||||
headers.add("Referer", """https://x.com/$1/media""" % id)
|
||||
|
||||
let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
result = await fetchRaw(mediaUrl(id, cursor), headers)
|
||||
|
||||
proc getTweetById*(id: string; after=""): Future[string] {.async.} =
|
||||
let
|
||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
variables = tweetVariables % [id, cursor]
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
result = await fetchRaw(graphTweet ? params, Api.tweetDetail)
|
||||
if id.len == 0: return
|
||||
|
||||
let headers = newHttpHeaders()
|
||||
headers.add("Referer", """https://x.com/i/status/$1""" % id)
|
||||
|
||||
let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||
result = await fetchRaw(tweetDetailUrl(id, cursor), headers)
|
||||
|
||||
proc getUser*(username: string): Future[string] {.async.} =
|
||||
if username.len == 0: return
|
||||
|
||||
let headers = newHttpHeaders()
|
||||
headers.add("Referer", """https://x.com/$1""" % username)
|
||||
|
||||
result = await fetchRaw(userUrl(username), headers)
|
||||
|
||||
proc createTwitterApiRouter*(cfg: Config) =
|
||||
router api:
|
||||
@ -135,20 +132,15 @@ proc createTwitterApiRouter*(cfg: Config) =
|
||||
|
||||
get "/api/user/@username":
|
||||
let username = @"username"
|
||||
let response = await getUserProfileJson(username)
|
||||
respJson response
|
||||
|
||||
# get "/api/user/@id/tweets":
|
||||
# let id = @"id"
|
||||
# let response = await getUserTweetsJson(id)
|
||||
# respJson response
|
||||
|
||||
get "/api/user/@username/timeline":
|
||||
let username = @"username"
|
||||
let query = Query(fromUser: @[username])
|
||||
let response = await searchTimeline(query)
|
||||
let response = await getUser(username)
|
||||
resp Http200, { "Content-Type": "application/json" }, response
|
||||
|
||||
#get "/api/user/@username/timeline":
|
||||
# let username = @"username"
|
||||
# let query = Query(fromUser: @[username])
|
||||
# let response = await searchTimeline(query)
|
||||
# resp Http200, { "Content-Type": "application/json" }, response
|
||||
|
||||
get "/api/user/@id/tweets":
|
||||
let id = @"id"
|
||||
let after = getCursor()
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
.panel-container {
|
||||
margin: auto;
|
||||
@ -23,6 +23,7 @@
|
||||
font-weight: bold;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0px 5px 1px 8px;
|
||||
}
|
||||
|
||||
input {
|
||||
@ -37,3 +38,13 @@
|
||||
height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-badge {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.brand-badge-image {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--accent_border);
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
@import 'tweet/_base';
|
||||
@import 'profile/_base';
|
||||
@import 'general';
|
||||
@import 'navbar';
|
||||
@import 'inputs';
|
||||
@import 'timeline';
|
||||
@import 'search';
|
||||
@import "tweet/_base";
|
||||
@import "profile/_base";
|
||||
@import "general";
|
||||
@import "navbar";
|
||||
@import "inputs";
|
||||
@import "timeline";
|
||||
@import "search";
|
||||
|
||||
body {
|
||||
// colors
|
||||
@ -50,7 +50,7 @@ body {
|
||||
|
||||
background-color: var(--bg_color);
|
||||
color: var(--fg_color);
|
||||
font-family: $font_0, $font_1, $font_2, $font_3;
|
||||
font-family: $font_0, $font_1;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
@ -66,7 +66,8 @@ h1 {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
h2, h3 {
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@ -90,7 +91,7 @@ fieldset {
|
||||
|
||||
legend {
|
||||
width: 100%;
|
||||
padding: .6em 0 .3em 0;
|
||||
padding: 0.6em 0 0.3em 0;
|
||||
border: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
@ -142,39 +143,60 @@ ul {
|
||||
}
|
||||
|
||||
.verified-icon {
|
||||
color: var(--icon_text);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin: 2px 0 3px 3px;
|
||||
padding-top: 3px;
|
||||
height: 11px;
|
||||
width: 14px;
|
||||
font-size: 8px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: 2px;
|
||||
|
||||
.verified-icon-circle {
|
||||
position: absolute;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.verified-icon-check {
|
||||
position: absolute;
|
||||
font-size: 9px;
|
||||
margin: 5px 3px;
|
||||
}
|
||||
|
||||
&.blue {
|
||||
background-color: var(--verified_blue);
|
||||
.verified-icon-circle {
|
||||
color: var(--verified_blue);
|
||||
}
|
||||
|
||||
.verified-icon-check {
|
||||
color: var(--icon_text);
|
||||
}
|
||||
}
|
||||
|
||||
&.business {
|
||||
.verified-icon-circle {
|
||||
color: var(--verified_business);
|
||||
}
|
||||
|
||||
.verified-icon-check {
|
||||
color: var(--bg_panel);
|
||||
background-color: var(--verified_business);
|
||||
}
|
||||
}
|
||||
|
||||
&.government {
|
||||
.verified-icon-circle {
|
||||
color: var(--verified_government);
|
||||
}
|
||||
|
||||
.verified-icon-check {
|
||||
color: var(--bg_panel);
|
||||
background-color: var(--verified_government);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width: 600px) {
|
||||
@media (max-width: 600px) {
|
||||
.preferences-container {
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.nav-item, .nav-item .icon-container {
|
||||
.nav-item,
|
||||
.nav-item .icon-container {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
@import '_variables';
|
||||
@import "_variables";
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
@ -12,7 +12,8 @@ nav {
|
||||
z-index: 1000;
|
||||
font-size: 16px;
|
||||
|
||||
a, .icon-button button {
|
||||
a,
|
||||
.icon-button button {
|
||||
color: var(--fg_nav);
|
||||
}
|
||||
}
|
||||
@ -58,14 +59,10 @@ nav {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&.right a {
|
||||
padding-left: 4px;
|
||||
|
||||
&:hover {
|
||||
&.right a:hover {
|
||||
color: var(--accent_light);
|
||||
text-decoration: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lp {
|
||||
@ -80,10 +77,11 @@ nav {
|
||||
}
|
||||
}
|
||||
|
||||
.icon-info:before {
|
||||
.icon-info {
|
||||
margin: 0 -3px;
|
||||
}
|
||||
|
||||
.icon-cog {
|
||||
font-size: 15px;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
@ -82,7 +82,9 @@
|
||||
|
||||
.profile-joindate,
|
||||
.profile-location,
|
||||
.profile-website {
|
||||
.profile-website,
|
||||
.profile-automated,
|
||||
.profile-pcf {
|
||||
color: var(--fg_faded);
|
||||
margin: 1px 0;
|
||||
width: 100%;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
.search-title {
|
||||
font-weight: bold;
|
||||
@ -13,6 +13,7 @@
|
||||
|
||||
button {
|
||||
margin: 0 2px 0 0;
|
||||
padding: 0px 1px 1px 4px;
|
||||
height: 23px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -34,7 +35,7 @@
|
||||
background-color: var(--bg_elements);
|
||||
color: var(--fg_color);
|
||||
border: 1px solid var(--accent_border);
|
||||
padding: 1px 6px 2px 6px;
|
||||
padding: 1px 1px 2px 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
@ -62,7 +63,7 @@
|
||||
.checkbox-container {
|
||||
display: inline;
|
||||
padding-right: unset;
|
||||
margin-bottom: unset;
|
||||
margin-bottom: 5px;
|
||||
margin-left: 23px;
|
||||
}
|
||||
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import 'thread';
|
||||
@import 'media';
|
||||
@import 'video';
|
||||
@import 'embed';
|
||||
@import 'card';
|
||||
@import 'poll';
|
||||
@import 'quote';
|
||||
@import 'community_note';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
@import "thread";
|
||||
@import "media";
|
||||
@import "video";
|
||||
@import "embed";
|
||||
@import "card";
|
||||
@import "poll";
|
||||
@import "quote";
|
||||
@import "community_note";
|
||||
@import "limited_actions";
|
||||
|
||||
.tweet-body {
|
||||
flex: 1;
|
||||
@ -32,7 +33,7 @@
|
||||
padding: 0;
|
||||
vertical-align: bottom;
|
||||
flex-basis: 100%;
|
||||
margin-bottom: .2em;
|
||||
margin-bottom: 0.2em;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
@ -47,6 +48,11 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.tweet-label-row {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
.fullname-and-username {
|
||||
display: flex;
|
||||
@ -65,17 +71,26 @@
|
||||
.username {
|
||||
@include ellipsis;
|
||||
min-width: 1.6em;
|
||||
margin-left: .4em;
|
||||
margin-left: 0.4em;
|
||||
word-wrap: normal;
|
||||
}
|
||||
|
||||
.user-automated,
|
||||
.user-pcf {
|
||||
@include ellipsis;
|
||||
min-width: 1px;
|
||||
color: var(--fg_faded);
|
||||
}
|
||||
|
||||
.tweet-date {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.tweet-date a, .username, .show-more a {
|
||||
.tweet-date a,
|
||||
.username,
|
||||
.show-more a {
|
||||
color: var(--fg_dark);
|
||||
}
|
||||
|
||||
@ -158,7 +173,8 @@
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.media-tag, .icon-container {
|
||||
.media-tag,
|
||||
.icon-container {
|
||||
color: var(--fg_faded);
|
||||
}
|
||||
}
|
||||
@ -180,7 +196,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.retweet-header, .pinned, .tweet-stats {
|
||||
.retweet-header,
|
||||
.pinned,
|
||||
.tweet-stats {
|
||||
align-content: center;
|
||||
color: var(--grey);
|
||||
display: flex;
|
||||
|
||||
32
src/sass/tweet/limited_actions.scss
Normal file
32
src/sass/tweet/limited_actions.scss
Normal file
@ -0,0 +1,32 @@
|
||||
@import "_variables";
|
||||
|
||||
.limited-actions {
|
||||
margin-top: 10px;
|
||||
border: solid 1px var(--dark_grey);
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_overlays);
|
||||
overflow: hidden;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--grey);
|
||||
}
|
||||
|
||||
.limited-actions-title {
|
||||
font-weight: bold;
|
||||
padding: 6px 8px;
|
||||
padding-bottom: 2px;
|
||||
margin-top: 1px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.limited-actions-text {
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
padding: 8px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,19 @@
|
||||
margin: 0;
|
||||
max-height: 530px;
|
||||
}
|
||||
|
||||
.alt {
|
||||
position: relative;
|
||||
bottom: 15px;
|
||||
left: 4px;
|
||||
padding: 4px;
|
||||
background: #101010;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-gif video {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
@import '_variables';
|
||||
@import '_mixins';
|
||||
@import "_variables";
|
||||
@import "_mixins";
|
||||
|
||||
video {
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@ -13,14 +13,12 @@ video {
|
||||
|
||||
.gallery-video.card-container {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
max-height: 530px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
max-height: 100%;
|
||||
|
||||
62
src/tid.nim
Normal file
62
src/tid.nim
Normal file
@ -0,0 +1,62 @@
|
||||
import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]
|
||||
import nimcrypto
|
||||
import experimental/parser/tid
|
||||
|
||||
randomize()
|
||||
|
||||
const defaultKeyword = "obfiowerehiring";
|
||||
const pairsUrl =
|
||||
"https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json";
|
||||
|
||||
var
|
||||
cachedPairs: seq[TidPair] = @[]
|
||||
lastCached = 0
|
||||
# refresh every hour
|
||||
ttlSec = 60 * 60
|
||||
|
||||
proc getPair(): Future[TidPair] {.async.} =
|
||||
if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec:
|
||||
lastCached = int(epochTime())
|
||||
|
||||
let client = newAsyncHttpClient()
|
||||
defer: client.close()
|
||||
|
||||
let resp = await client.get(pairsUrl)
|
||||
if resp.status == $Http200:
|
||||
cachedPairs = parseTidPairs(await resp.body)
|
||||
|
||||
return sample(cachedPairs)
|
||||
|
||||
proc encodeSha256(text: string): array[32, byte] =
|
||||
let
|
||||
data = cast[ptr byte](addr text[0])
|
||||
dataLen = uint(len(text))
|
||||
digest = sha256.digest(data, dataLen)
|
||||
return digest.data
|
||||
|
||||
proc encodeBase64[T](data: T): string =
|
||||
return encode(data).replace("=", "")
|
||||
|
||||
proc decodeBase64(data: string): seq[byte] =
|
||||
return cast[seq[byte]](decode(data))
|
||||
|
||||
proc genTid*(path: string): Future[string] {.async.} =
|
||||
let
|
||||
pair = await getPair()
|
||||
|
||||
timeNow = int(epochTime() - 1682924400)
|
||||
timeNowBytes = @[
|
||||
byte(timeNow and 0xff),
|
||||
byte((timeNow shr 8) and 0xff),
|
||||
byte((timeNow shr 16) and 0xff),
|
||||
byte((timeNow shr 24) and 0xff)
|
||||
]
|
||||
|
||||
data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey
|
||||
hashBytes = encodeSha256(data)
|
||||
keyBytes = decodeBase64(pair.verification)
|
||||
bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8]
|
||||
randomNum = byte(rand(256))
|
||||
tid = @[randomNum] & bytesArr.mapIt(it xor randomNum)
|
||||
|
||||
return encodeBase64(tid)
|
||||
168
src/tokens.nim
168
src/tokens.nim
@ -1,168 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, httpclient, times, sequtils, json, random
|
||||
import strutils, tables
|
||||
import types, consts
|
||||
|
||||
const
|
||||
maxConcurrentReqs = 5 # max requests at a time per token, to avoid race conditions
|
||||
maxLastUse = 1.hours # if a token is unused for 60 minutes, it expires
|
||||
maxAge = 2.hours + 55.minutes # tokens expire after 3 hours
|
||||
failDelay = initDuration(minutes=30)
|
||||
|
||||
var
|
||||
tokenPool: seq[Token]
|
||||
lastFailed: Time
|
||||
enableLogging = false
|
||||
|
||||
let headers = newHttpHeaders({"authorization": auth})
|
||||
|
||||
template log(str) =
|
||||
if enableLogging: echo "[tokens] ", str
|
||||
|
||||
proc getPoolJson*(): JsonNode =
|
||||
var
|
||||
list = newJObject()
|
||||
totalReqs = 0
|
||||
totalPending = 0
|
||||
reqsPerApi: Table[string, int]
|
||||
|
||||
for token in tokenPool:
|
||||
totalPending.inc(token.pending)
|
||||
list[token.tok] = %*{
|
||||
"apis": newJObject(),
|
||||
"pending": token.pending,
|
||||
"init": $token.init,
|
||||
"lastUse": $token.lastUse
|
||||
}
|
||||
|
||||
for api in token.apis.keys:
|
||||
list[token.tok]["apis"][$api] = %token.apis[api]
|
||||
|
||||
let
|
||||
maxReqs =
|
||||
case api
|
||||
of Api.photoRail: 180
|
||||
#of Api.timeline: 187
|
||||
#of Api.userTweets, Api.userTimeline: 300
|
||||
of Api.userTweets: 300
|
||||
of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets,
|
||||
Api.userTweetsAndReplies, Api.userMedia,
|
||||
Api.userRestId, Api.userScreenName, Api.tweetDetail,
|
||||
Api.tweetResult, Api.search, Api.favorites,
|
||||
Api.retweeters, Api.favoriters, Api.following, Api.followers: 500
|
||||
#of Api.userSearch: 900
|
||||
reqs = maxReqs - token.apis[api].remaining
|
||||
|
||||
reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs
|
||||
totalReqs.inc(reqs)
|
||||
|
||||
return %*{
|
||||
"amount": tokenPool.len,
|
||||
"requests": totalReqs,
|
||||
"pending": totalPending,
|
||||
"apis": reqsPerApi,
|
||||
"tokens": list
|
||||
}
|
||||
|
||||
proc rateLimitError*(): ref RateLimitError =
|
||||
newException(RateLimitError, "rate limited")
|
||||
|
||||
proc fetchToken(): Future[Token] {.async.} =
|
||||
if getTime() - lastFailed < failDelay:
|
||||
raise rateLimitError()
|
||||
|
||||
let client = newAsyncHttpClient(headers=headers)
|
||||
|
||||
try:
|
||||
let
|
||||
resp = await client.postContent(activate)
|
||||
tokNode = parseJson(resp)["guest_token"]
|
||||
tok = tokNode.getStr($(tokNode.getInt))
|
||||
time = getTime()
|
||||
|
||||
return Token(tok: tok, init: time, lastUse: time)
|
||||
except Exception as e:
|
||||
echo "[tokens] fetching token failed: ", e.msg
|
||||
if "Try again" notin e.msg:
|
||||
echo "[tokens] fetching tokens paused, resuming in 30 minutes"
|
||||
lastFailed = getTime()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
proc expired(token: Token): bool =
|
||||
let time = getTime()
|
||||
token.init < time - maxAge or token.lastUse < time - maxLastUse
|
||||
|
||||
proc isLimited(token: Token; api: Api): bool =
|
||||
if token.isNil or token.expired:
|
||||
return true
|
||||
|
||||
if api in token.apis:
|
||||
let limit = token.apis[api]
|
||||
return (limit.remaining <= 10 and limit.reset > epochTime().int)
|
||||
else:
|
||||
return false
|
||||
|
||||
proc isReady(token: Token; api: Api): bool =
|
||||
not (token.isNil or token.pending > maxConcurrentReqs or token.isLimited(api))
|
||||
|
||||
proc release*(token: Token; used=false; invalid=false) =
|
||||
if token.isNil: return
|
||||
if invalid or token.expired:
|
||||
if invalid: log "discarding invalid token"
|
||||
elif token.expired: log "discarding expired token"
|
||||
|
||||
let idx = tokenPool.find(token)
|
||||
if idx > -1: tokenPool.delete(idx)
|
||||
elif used:
|
||||
dec token.pending
|
||||
token.lastUse = getTime()
|
||||
|
||||
proc getToken*(api: Api): Future[Token] {.async.} =
|
||||
for i in 0 ..< tokenPool.len:
|
||||
if result.isReady(api): break
|
||||
release(result)
|
||||
result = tokenPool.sample()
|
||||
|
||||
if not result.isReady(api):
|
||||
release(result)
|
||||
result = await fetchToken()
|
||||
log "added new token to pool"
|
||||
tokenPool.add result
|
||||
|
||||
if not result.isNil:
|
||||
inc result.pending
|
||||
else:
|
||||
raise rateLimitError()
|
||||
|
||||
proc setRateLimit*(token: Token; api: Api; remaining, reset: int) =
|
||||
# avoid undefined behavior in race conditions
|
||||
if api in token.apis:
|
||||
let limit = token.apis[api]
|
||||
if limit.reset >= reset and limit.remaining < remaining:
|
||||
return
|
||||
|
||||
token.apis[api] = RateLimit(remaining: remaining, reset: reset)
|
||||
|
||||
proc poolTokens*(amount: int) {.async.} =
|
||||
var futs: seq[Future[Token]]
|
||||
for i in 0 ..< amount:
|
||||
futs.add fetchToken()
|
||||
|
||||
for token in futs:
|
||||
var newToken: Token
|
||||
|
||||
try: newToken = await token
|
||||
except: discard
|
||||
|
||||
if not newToken.isNil:
|
||||
log "added new token to pool"
|
||||
tokenPool.add newToken
|
||||
|
||||
proc initTokenPool*(cfg: Config) {.async.} =
|
||||
enableLogging = cfg.enableDebug
|
||||
|
||||
while true:
|
||||
if tokenPool.countIt(not it.isLimited(Api.userTweets)) < cfg.minTokens:
|
||||
await poolTokens(min(4, cfg.minTokens - tokenPool.len))
|
||||
await sleepAsync(2000)
|
||||
@ -12,25 +12,13 @@ type
|
||||
TimelineKind* {.pure.} = enum
|
||||
tweets, replies, media
|
||||
|
||||
Api* {.pure.} = enum
|
||||
tweetDetail
|
||||
tweetResult
|
||||
photoRail
|
||||
search
|
||||
list
|
||||
listBySlug
|
||||
listMembers
|
||||
listTweets
|
||||
userRestId
|
||||
userScreenName
|
||||
favorites
|
||||
userTweets
|
||||
userTweetsAndReplies
|
||||
userMedia
|
||||
favoriters
|
||||
retweeters
|
||||
following
|
||||
followers
|
||||
ApiUrl* = object
|
||||
endpoint*: string
|
||||
params*: seq[(string, string)]
|
||||
|
||||
ApiReq* = object
|
||||
oauth*: ApiUrl
|
||||
cookie*: ApiUrl
|
||||
|
||||
RateLimit* = object
|
||||
remaining*: int
|
||||
@ -43,14 +31,14 @@ type
|
||||
init*: Time
|
||||
lastUse*: Time
|
||||
pending*: int
|
||||
apis*: Table[Api, RateLimit]
|
||||
apis*: Table[string, RateLimit]
|
||||
|
||||
GuestAccount* = ref object
|
||||
id*: int64
|
||||
oauthToken*: string
|
||||
oauthSecret*: string
|
||||
pending*: int
|
||||
apis*: Table[Api, RateLimit]
|
||||
apis*: Table[string, RateLimit]
|
||||
|
||||
Error* = enum
|
||||
null = 0
|
||||
@ -80,6 +68,11 @@ type
|
||||
business = "Business"
|
||||
government = "Government"
|
||||
|
||||
Badge* = object
|
||||
name*: string
|
||||
icon*: string
|
||||
url*: string
|
||||
|
||||
User* = object
|
||||
id*: string
|
||||
username*: string
|
||||
@ -89,7 +82,7 @@ type
|
||||
bio*: string
|
||||
userPic*: string
|
||||
banner*: string
|
||||
pinnedTweet*: int64
|
||||
pinnedTweet*: string
|
||||
following*: int
|
||||
followers*: int
|
||||
tweets*: int
|
||||
@ -99,6 +92,10 @@ type
|
||||
protected*: bool
|
||||
suspended*: bool
|
||||
joinDate*: DateTime
|
||||
bot*: bool
|
||||
botOwner*: string
|
||||
pcf*: string
|
||||
badge*: Badge
|
||||
|
||||
VideoType* = enum
|
||||
m3u8 = "application/x-mpegURL"
|
||||
@ -123,6 +120,10 @@ type
|
||||
playbackType*: VideoType
|
||||
variants*: seq[VideoVariant]
|
||||
|
||||
Image* = object
|
||||
url*: string
|
||||
description*: string
|
||||
|
||||
QueryKind* = enum
|
||||
posts, replies, media, users, tweets, userList, favorites
|
||||
|
||||
@ -197,20 +198,27 @@ type
|
||||
retweets*: int
|
||||
likes*: int
|
||||
quotes*: int
|
||||
bookmarks*: int
|
||||
views*: int
|
||||
|
||||
BirdwatchNote* = ref object
|
||||
id*: int64
|
||||
id*: string
|
||||
title*: string
|
||||
text*: string
|
||||
|
||||
LimitedActions* = ref object
|
||||
title*: string
|
||||
text*: string
|
||||
|
||||
Tweet* = ref object
|
||||
id*: int64
|
||||
threadId*: int64
|
||||
replyId*: int64
|
||||
id*: string
|
||||
threadId*: string
|
||||
replyId*: string
|
||||
user*: User
|
||||
text*: string
|
||||
time*: DateTime
|
||||
reply*: seq[string]
|
||||
replyHandle*: string
|
||||
pinned*: bool
|
||||
hasThread*: bool
|
||||
available*: bool
|
||||
@ -227,8 +235,9 @@ type
|
||||
poll*: Option[Poll]
|
||||
gif*: Option[Gif]
|
||||
video*: Option[Video]
|
||||
photos*: seq[string]
|
||||
photos*: seq[Image]
|
||||
birdwatch*: Option[BirdwatchNote]
|
||||
limitedActions*: Option[LimitedActions]
|
||||
|
||||
Tweets* = seq[Tweet]
|
||||
|
||||
@ -277,6 +286,7 @@ type
|
||||
useHttps*: bool
|
||||
httpMaxConns*: int
|
||||
title*: string
|
||||
oembedColor*: string
|
||||
hostname*: string
|
||||
staticDir*: string
|
||||
|
||||
@ -287,6 +297,7 @@ type
|
||||
enableDebug*: bool
|
||||
proxy*: string
|
||||
proxyAuth*: string
|
||||
disableTid*: bool
|
||||
cookieHeader*: string
|
||||
xCsrfToken*: string
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import karax/[karaxdsl, vdom]
|
||||
const
|
||||
date = staticExec("git show -s --format=\"%cd\" --date=format:\"%Y.%m.%d\"")
|
||||
hash = staticExec("git show -s --format=\"%h\"")
|
||||
link = "https://github.com/zedeus/nitter/commit/" & hash
|
||||
link = "https://git.eir-nya.gay/eir/nitter/commit/" & hash
|
||||
version = &"{date}-{hash}"
|
||||
|
||||
var aboutHtml: string
|
||||
|
||||
@ -15,7 +15,7 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
||||
let vidUrl = vars.sortedByIt(it.bitrate)[^1].url
|
||||
let prefs = Prefs(hlsPlayback: true, mp4Playback: true)
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
|
||||
renderHead(prefs, cfg, req, video=vidUrl, images=(@[Image(url:thumb)]))
|
||||
|
||||
body:
|
||||
tdiv(class="embed-video"):
|
||||
@ -23,11 +23,11 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
||||
|
||||
result = doctype & $node
|
||||
|
||||
proc generateOembed*(cfg: Config; typ, title, user, url, provider: string): JsonNode =
|
||||
proc generateOembed*(cfg: Config; typ, title, user, url: string, provider: string): JsonNode =
|
||||
%*{
|
||||
"type": typ,
|
||||
"version": "1.0",
|
||||
"provider_name": provider,
|
||||
"provider_name": provider, #cfg.title,
|
||||
"provider_url": getUrlPrefix(cfg),
|
||||
"title": title,
|
||||
"author_name": user,
|
||||
|
||||
@ -3,7 +3,7 @@ import uri, strutils, strformat, times, options
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import renderutils
|
||||
import ../utils, ../types, ../prefs, ../formatters
|
||||
import ".."/[utils, types, prefs, formatters]
|
||||
|
||||
import jester
|
||||
|
||||
@ -37,16 +37,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
||||
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
|
||||
|
||||
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
||||
rss=""; canonical=""; avatar="";
|
||||
time: Option[DateTime] = none(DateTime)): VNode =
|
||||
video=""; images: seq[Image] = @[]; banner=""; ogTitle="";
|
||||
rss=""; canonical=""; avatar=""; context=""; contextUrl="";
|
||||
id=""; time: Option[DateTime] = none(DateTime); media="";
|
||||
stats = ""): VNode =
|
||||
var theme = prefs.theme.toTheme
|
||||
if "theme" in req.params:
|
||||
theme = req.params["theme"].toTheme
|
||||
|
||||
let ogType =
|
||||
if video.len > 0: "video.other"
|
||||
elif rss.len > 0: "object"
|
||||
#elif rss.len > 0: "object"
|
||||
elif images.len > 0: "photo"
|
||||
else: "article"
|
||||
|
||||
@ -54,17 +55,19 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
|
||||
buildHtml(head):
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=20")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3")
|
||||
link(rel="stylesheet", href="/css/baguetteBox.min.css")
|
||||
script(src="/js/baguetteBox.min.js", `async`="")
|
||||
script(src="/js/zoom.js")
|
||||
|
||||
let isDiscord = req.headers.getOrDefault("User-Agent").toString().contains("Discordbot")
|
||||
|
||||
if theme.len > 0:
|
||||
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
||||
|
||||
link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png")
|
||||
link(rel="icon", type="image/png", sizes="32x32", href="/favicon-32x32.png")
|
||||
link(rel="icon", type="image/png", sizes="16x16", href="/favicon-16x16.png")
|
||||
link(rel="icon", type="image/png", sizes="32x32", href=(&"{getUrlPrefix(cfg)}/favicon-32x32.png"))
|
||||
link(rel="icon", type="image/png", sizes="16x16", href=(&"{getUrlPrefix(cfg)}/favicon-16x16.png"))
|
||||
link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png?v=2")
|
||||
link(rel="manifest", href="/site.webmanifest")
|
||||
link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60")
|
||||
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
||||
@ -83,6 +86,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
if prefs.infiniteScroll:
|
||||
script(src="/js/infiniteScroll.js", `defer`="")
|
||||
|
||||
# Eir: load custom js
|
||||
if prefs.eirResources:
|
||||
script(src="/js/eirResources.js", `defer`="")
|
||||
|
||||
title:
|
||||
if titleText.len > 0:
|
||||
text titleText & " | " & cfg.title
|
||||
@ -93,7 +100,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
let finalizedDesc = stripHtml(desc)
|
||||
|
||||
meta(name="viewport", content="width=device-width, initial-scale=1.0")
|
||||
meta(name="theme-color", content="#1F1F1F")
|
||||
meta(name="theme-color", content=cfg.oembedColor)
|
||||
meta(property="og:type", content=ogType)
|
||||
if video.len > 0 and len(finalizedDesc) <= 67:
|
||||
meta(property="og:title", content=finalizedDesc)
|
||||
@ -101,16 +108,22 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
meta(property="og:title", content=finalizedTitleText)
|
||||
meta(property="og:description", content=finalizedDesc)
|
||||
meta(property="og:locale", content="en_US")
|
||||
meta(name="referrer", content="no-referrer")
|
||||
|
||||
var siteName = "Nitter"
|
||||
|
||||
var siteName = cfg.title
|
||||
if time.isSome:
|
||||
let timeObj = time.get
|
||||
let timeStr = $timeObj
|
||||
meta(property="og:article:published_time", content=timeStr)
|
||||
|
||||
if not isDiscord:
|
||||
let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss")
|
||||
siteName = &"Nitter • {formattedTime}"
|
||||
siteName = &"{siteName} • {formattedTime}"
|
||||
if stats.len > 0:
|
||||
siteName &= "\n" & stats
|
||||
|
||||
if isDiscord and stats.len > 0:
|
||||
siteName &= " • " & stats
|
||||
|
||||
meta(property="og:site_name", content=siteName)
|
||||
|
||||
@ -119,19 +132,21 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
|
||||
|
||||
if images.len > 0:
|
||||
for url in images:
|
||||
let preloadUrl = if "400x400" in url: getPicUrl(url)
|
||||
for imageObj in images:
|
||||
let
|
||||
url = imageObj.url
|
||||
preloadUrl = if "400x400" in url: getPicUrl(url)
|
||||
else: getSmallPic(url)
|
||||
link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
|
||||
|
||||
let image = getUrlPrefix(cfg) & getPicUrl(url)
|
||||
meta(property="og:image", content=image)
|
||||
meta(property="og:image:alt", content=imageObj.description)
|
||||
if video.len == 0:
|
||||
meta(property="twitter:image:src", content=image)
|
||||
if rss.len > 0:
|
||||
meta(property="twitter:card", content="summary")
|
||||
elif video.len == 0:
|
||||
meta(property="twitter:card", content="summary_large_image")
|
||||
else:
|
||||
meta(property="twitter:card", content="summary")
|
||||
elif avatar.len > 0:
|
||||
let avatarUrl = getUrlPrefix(cfg) & getPicUrl(avatar)
|
||||
meta(property="og:image", content=avatarUrl)
|
||||
@ -140,32 +155,59 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
meta(property="og:video:url", content=video)
|
||||
meta(property="og:video:secure_url", content=video)
|
||||
meta(property="og:video:type", content="video/mp4")
|
||||
var title = encodeUrl(finalizedDesc)
|
||||
var author = encodeUrl(finalizedTitleText)
|
||||
|
||||
var
|
||||
title = encodeUrl(finalizedDesc)
|
||||
author = encodeUrl(finalizedTitleText)
|
||||
url = req.path
|
||||
|
||||
if len(finalizedDesc) > 67:
|
||||
title = author
|
||||
author = encodeUrl(finalizedDesc)
|
||||
|
||||
verbatim &"<link rel=\"alternate\" href=\"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(req.path)}\" type=\"application/json+oembed\" />"
|
||||
#link(rel="alternate",
|
||||
# href=&"{getUrlPrefix(cfg)}/oembed.json?type=video&title={encodeUrl(stripHtml(desc))}&user={encodeUrl(finalizedTitleText)}&url={encodeUrl(req.path)}",
|
||||
# `type`="application/json+oembed")
|
||||
if context != "":
|
||||
author = encodeUrl(context & "\n") & author
|
||||
|
||||
if contextUrl != "":
|
||||
url = contextUrl
|
||||
|
||||
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(url)}"), type="application/json+oembed")
|
||||
elif context != "" and contextUrl != "":
|
||||
var
|
||||
title = encodeUrl(finalizedTitleText)
|
||||
author = encodeUrl(context)
|
||||
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(contextUrl)}"), type="application/json+oembed")
|
||||
|
||||
var fediUrl = &"{getUrlPrefix(cfg)}/users/i/statuses/"
|
||||
if media.len > 0:
|
||||
if media == "video":
|
||||
fediUrl &= "422209040515" # 42 + "video"
|
||||
else:
|
||||
let parts = media.split(":")
|
||||
fediUrl &= "421608152015" # 42 + "photo"
|
||||
if parts.len == 2:
|
||||
fediUrl &= parts[1] # + index
|
||||
|
||||
fediUrl &= id
|
||||
link(rel="alternate", href=fediUrl, type="application/activity+json")
|
||||
|
||||
# this is last so images are also preloaded
|
||||
# if this is done earlier, Chrome only preloads one image for some reason
|
||||
link(rel="preload", type="font/woff2", `as`="font",
|
||||
href="/fonts/fontello.woff2?21002321", crossorigin="anonymous")
|
||||
href="/fonts/fontello.woff2?76162212", crossorigin="anonymous")
|
||||
|
||||
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
||||
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
||||
images: seq[string] = @[]; banner=""; avatar="";
|
||||
time: Option[DateTime] = none(DateTime)): string =
|
||||
images: seq[Image] = @[]; banner=""; avatar=""; context="";
|
||||
contextUrl=""; id=""; time: Option[DateTime] = none(DateTime);
|
||||
media=""; stats=""): string =
|
||||
|
||||
let canonical = getTwitterLink(req.path, req.params)
|
||||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||
rss, canonical, avatar, time)
|
||||
rss, canonical, avatar, context, contextUrl, id, time, media,
|
||||
stats)
|
||||
|
||||
body:
|
||||
renderNavbar(cfg, req, rss, canonical)
|
||||
|
||||
341
src/views/mastoapi.nim
Normal file
341
src/views/mastoapi.nim
Normal file
@ -0,0 +1,341 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, options, json, sequtils, times, math
|
||||
import ".."/[types, formatters, utils]
|
||||
|
||||
proc formatTweetForMastoAPI*(tweet: Tweet, cfg: Config, prefs: Prefs): string =
|
||||
var content = replaceUrls(tweet.text, prefs, absolute=getUrlPrefix(cfg))
|
||||
|
||||
if tweet.poll.isSome():
|
||||
let poll = get(tweet.poll)
|
||||
content &= "\n<blockquote>"
|
||||
for i in 0 ..< poll.options.len:
|
||||
let
|
||||
leader = if poll.leader == i: " leader" else: ""
|
||||
val = poll.values[i]
|
||||
perc = if val > 0: val / poll.votes * 100 else: 0
|
||||
percStr = (&"{perc:>3.0f}").strip(chars={'.'}) & '%'
|
||||
barLen = round((perc / 100) * 32).int
|
||||
bar = repeat("█", barLen)
|
||||
notBar = repeat(" ", 32 - barLen)
|
||||
content &= &"<b>{poll.options[i]}</b> ({insertSep($val, ',')}, {percStr})\n<code>{bar}{notBar}</code>\n"
|
||||
|
||||
content &= &"\n{insertSep($poll.votes, ',')} votes • {poll.status}</blockquote>"
|
||||
|
||||
if tweet.quote.isSome():
|
||||
let
|
||||
quote = get(tweet.quote)
|
||||
quoteContent = replaceUrls(quote.text, prefs, absolute=getUrlPrefix(cfg))
|
||||
quoteUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}"
|
||||
content &= &"\n\n<blockquote><b>↘ <a href=\"{quoteUrl}\">{quote.user.fullName} (@{quote.user.username})</a></b>\n{quoteContent}"
|
||||
|
||||
if quote.video.isSome() or quote.gif.isSome():
|
||||
content &= "\n📹"
|
||||
if quote.gif.isSome():
|
||||
content &= " (GIF)"
|
||||
elif quote.photos.len > 0:
|
||||
content &= "\n🖼️"
|
||||
if quote.photos.len > 1:
|
||||
content &= &" ({quote.photos.len})"
|
||||
|
||||
content &= "</blockquote>"
|
||||
|
||||
if tweet.birdwatch.isSome():
|
||||
let
|
||||
note = get(tweet.birdwatch)
|
||||
noteContent = replaceUrls(note.text, prefs, absolute=getUrlPrefix(cfg))
|
||||
content &= &"\n\n<blockquote><b>ⓘ {note.title}</b>\n{noteContent}</blockquote>"
|
||||
|
||||
result = content.replace("\n", "<br>")
|
||||
|
||||
proc getActivityStream*(tweet: Tweet, cfg: Config, prefs: Prefs): JsonNode =
|
||||
let
|
||||
tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.id}"
|
||||
tweetContent = formatTweetForMastoAPI(tweet, cfg, prefs)
|
||||
var media: seq[JsonNode] = @[]
|
||||
|
||||
if tweet.photos.len > 0:
|
||||
for imageObj in tweet.photos:
|
||||
let
|
||||
image = getUrlPrefix(cfg) & getPicUrl(imageObj.url)
|
||||
splitUrl = imageObj.url.split('.')
|
||||
var filetype = splitUrl[^1]
|
||||
if filetype == "jpg":
|
||||
filetype = "jpeg"
|
||||
|
||||
var mediaObj = newJObject()
|
||||
mediaObj["type"] = %"Image"
|
||||
mediaObj["mediaType"] = %("image/" & filetype)
|
||||
mediaObj["url"] = %image
|
||||
mediaObj["name"] = %imageObj.description
|
||||
media.add(mediaObj)
|
||||
|
||||
if tweet.video.isSome():
|
||||
let
|
||||
videoObj = get(tweet.video)
|
||||
vars = videoObj.variants.filterIt(it.contentType == mp4)
|
||||
var description = videoObj.title
|
||||
if videoObj.description.len > 0:
|
||||
description = videoObj.description
|
||||
|
||||
let splitUrl = videoObj.thumb.split('.')
|
||||
var filetype = splitUrl[^1]
|
||||
if filetype == "jpg":
|
||||
filetype = "jpeg"
|
||||
|
||||
var url: seq[JsonNode] = @[]
|
||||
|
||||
var thumb = newJObject()
|
||||
thumb["type"] = %"Link"
|
||||
thumb["mediaType"] = %("image/" & filetype)
|
||||
thumb["href"] = %(getUrlPrefix(cfg) & getPicUrl(videoObj.thumb))
|
||||
url.add(thumb)
|
||||
|
||||
var mediaObj = newJObject()
|
||||
mediaObj["type"] = %"Link"
|
||||
mediaObj["mediaType"] = %"video/mp4"
|
||||
mediaObj["href"] = %(vars[^1].url.replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", ""))
|
||||
url.add(mediaObj)
|
||||
|
||||
var wrapper = newJObject()
|
||||
wrapper["type"] = %"Video"
|
||||
wrapper["name"] = %description
|
||||
wrapper["url"] = %url
|
||||
media.add(wrapper)
|
||||
elif tweet.gif.isSome():
|
||||
let
|
||||
gif = get(tweet.gif)
|
||||
gifUrl = https & gif.url
|
||||
|
||||
let splitUrl = gif.thumb.split('.')
|
||||
var filetype = splitUrl[^1]
|
||||
if filetype == "jpg":
|
||||
filetype = "jpeg"
|
||||
|
||||
var url: seq[JsonNode] = @[]
|
||||
|
||||
var thumb = newJObject()
|
||||
thumb["type"] = %"Link"
|
||||
thumb["mediaType"] = %("image/" & filetype)
|
||||
thumb["href"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb))
|
||||
url.add(thumb)
|
||||
|
||||
var mediaObj = newJObject()
|
||||
mediaObj["type"] = %"Link"
|
||||
mediaObj["mediaType"] = %"video/mp4"
|
||||
mediaObj["href"] = %(gifUrl.replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", ""))
|
||||
url.add(mediaObj)
|
||||
|
||||
var wrapper = newJObject()
|
||||
wrapper["type"] = %"Video"
|
||||
wrapper["name"] = newJNull()
|
||||
wrapper["url"] = %url
|
||||
media.add(wrapper)
|
||||
|
||||
var context: seq[JsonNode] = @[]
|
||||
let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams"
|
||||
context.add(contextUrl)
|
||||
let asProps: JsonNode = %*{
|
||||
"ostatus": "http://ostatus.org#",
|
||||
"atomUri": "ostatus:atomUri",
|
||||
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
|
||||
"conversation": "ostatus:conversation",
|
||||
"sensitive": "as:sensitive",
|
||||
}
|
||||
context.add(asProps)
|
||||
|
||||
var postJson = newJObject()
|
||||
postJson["@context"] = %context
|
||||
postJson["id"] = %tweetUrl
|
||||
postJson["type"] = %"Note"
|
||||
postJson["summary"] = newJNull()
|
||||
if tweet.replyId.len != 0:
|
||||
let replyUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
|
||||
postJson["inReplyTo"] = %replyUrl
|
||||
postJson["inReplyToAtomUri"] = %replyUrl
|
||||
else:
|
||||
postJson["inReplyTo"] = newJNull()
|
||||
postJson["inReplyToAtomUri"] = newJNull()
|
||||
postJson["published"] = %($tweet.time)
|
||||
postJson["url"] = %tweetUrl
|
||||
postJson["attributedTo"] = %(&"{getUrlPrefix(cfg)}/users/{tweet.user.username}")
|
||||
postJson["to"] = newJArray()
|
||||
postJson["cc"] = %(@["https://www.w3.org/ns/activitystreams#Public"])
|
||||
postJson["sensitive"] = %false # FIXME
|
||||
postJson["atomUri"] = %tweetUrl
|
||||
postJson["conversation"] = %""
|
||||
postJson["content"] = %tweetContent
|
||||
postJson["contentMap"] = %*{
|
||||
"en": tweetContent
|
||||
}
|
||||
postJson["attachment"] = %media
|
||||
postJson["tag"] = newJArray() # TODO: parse?
|
||||
postJson["replies"] = newJObject()
|
||||
|
||||
result = postJson
|
||||
|
||||
proc getActivityStream*(user: User, cfg: Config, prefs: Prefs): JsonNode =
|
||||
let userUrl = &"{getUrlPrefix(cfg)}/{user.username}"
|
||||
|
||||
var context: seq[JsonNode] = @[]
|
||||
let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams"
|
||||
context.add(contextUrl)
|
||||
let contextUrl2: JsonNode = %"https://w3id.org/security/v1"
|
||||
context.add(contextUrl2)
|
||||
|
||||
let contextAka: JsonNode = %*{
|
||||
"@id": "as:alsoKnownAs",
|
||||
"@type": "@id"
|
||||
}
|
||||
let contextMovedTo = %*{
|
||||
"@id": "as:movedTo",
|
||||
"@type": "@id"
|
||||
}
|
||||
var asProps: JsonNode = %*{
|
||||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
||||
"schema": "http://schema.org#",
|
||||
"PropertyValue": "schema:PropertyValue",
|
||||
"value": "schema:value",
|
||||
}
|
||||
asProps["alsoKnownAs"] = contextAka
|
||||
asProps["movedTo"] = contextMovedTo
|
||||
context.add(asProps)
|
||||
|
||||
var fields: seq[JsonNode] = @[]
|
||||
|
||||
if user.location.len > 0:
|
||||
var location = newJObject()
|
||||
location["type"] = %"PropertyValue"
|
||||
location["name"] = %"Location"
|
||||
location["value"] = %user.location
|
||||
fields.add(location)
|
||||
|
||||
if user.website.len > 0:
|
||||
var website = newJObject()
|
||||
website["type"] = %"PropertyValue"
|
||||
website["name"] = %"Website"
|
||||
website["value"] = %(&"<a href=\"{user.website}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{user.website}</a>")
|
||||
fields.add(website)
|
||||
|
||||
if user.botOwner.len > 0:
|
||||
var botOwner = newJObject()
|
||||
botOwner["type"] = %"PropertyValue"
|
||||
botOwner["name"] = %"Automated by"
|
||||
botOwner["value"] = %(&"<a href=\"{getUrlPrefix(cfg)}/{user.botOwner}\" translate=\"no\">{user.botOwner}</a>")
|
||||
fields.add(botOwner)
|
||||
|
||||
if user.pcf != "" and user.pcf != "None":
|
||||
var pcf = newJObject()
|
||||
pcf["type"] = %"PropertyValue"
|
||||
pcf["name"] = %"PCF Label"
|
||||
pcf["value"] = %user.pcf
|
||||
fields.add(pcf)
|
||||
|
||||
if user.verifiedType != none:
|
||||
var verified = newJObject()
|
||||
verified["type"] = %"PropertyValue"
|
||||
verified["name"] = %"Verified Type"
|
||||
verified["value"] = %user.verifiedType
|
||||
fields.add(verified)
|
||||
|
||||
var userJson = newJObject()
|
||||
userJson["@context"] = %context
|
||||
userJson["id"] = %userUrl
|
||||
userJson["type"] = %"Person"
|
||||
userJson["following"] = %(userUrl & "/following")
|
||||
userJson["followers"] = %(userUrl & "/followers")
|
||||
userJson["inbox"] = newJNull()
|
||||
userJson["outbox"] = newJNull()
|
||||
userJson["featured"] = newJNull()
|
||||
userJson["featuredTags"] = newJNull()
|
||||
userJson["preferredUsername"] = %user.username
|
||||
userJson["name"] = %user.fullname
|
||||
userJson["summary"] = %user.bio
|
||||
userJson["url"] = %userUrl
|
||||
userJson["manuallyApprovesFollowers"] = %user.protected
|
||||
userJson["discoverable"] = %true
|
||||
userJson["indexable"] = %false
|
||||
userJson["published"] = %($user.joinDate)
|
||||
userJson["memorial"] = %false
|
||||
userJson["publicKey"] = newJNull()
|
||||
userJson["tag"] = newJArray()
|
||||
userJson["attachment"] = %fields
|
||||
userJson["endpoints"] = newJObject()
|
||||
userJson["icon"] = %*{
|
||||
"type": "Image",
|
||||
"mediaType": "image/jpeg",
|
||||
"url": getUrlPrefix(cfg) & getPicUrl(user.userPic)
|
||||
}
|
||||
userJson["image"] = %*{
|
||||
"type": "Image",
|
||||
"mediaType": "image/jpeg",
|
||||
"url": getUrlPrefix(cfg) & getPicUrl(user.banner)
|
||||
}
|
||||
|
||||
result = userJson
|
||||
|
||||
proc getMastoAPIUser*(user: User, cfg: Config): JsonNode =
|
||||
var fields: seq[JsonNode] = @[]
|
||||
|
||||
if user.location.len > 0:
|
||||
var location = newJObject()
|
||||
location["name"] = %"Location"
|
||||
location["value"] = %user.location
|
||||
location["verified_at"] = newJNull()
|
||||
fields.add(location)
|
||||
|
||||
if user.website.len > 0:
|
||||
var website = newJObject()
|
||||
website["name"] = %"Website"
|
||||
website["value"] = %(&"<a href=\"{user.website}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{user.website}</a>")
|
||||
website["verified_at"] = newJNull()
|
||||
fields.add(website)
|
||||
|
||||
if user.botOwner.len > 0:
|
||||
var botOwner = newJObject()
|
||||
botOwner["name"] = %"Automated by"
|
||||
botOwner["value"] = %(&"<a href=\"{getUrlPrefix(cfg)}/{user.botOwner}\" translate=\"no\">{user.botOwner}</a>")
|
||||
botOwner["verified_at"] = newJNull()
|
||||
fields.add(botOwner)
|
||||
|
||||
if user.pcf != "" and user.pcf != "None":
|
||||
var pcf = newJObject()
|
||||
pcf["name"] = %"PCF Label"
|
||||
pcf["value"] = %user.pcf
|
||||
pcf["verified_at"] = newJNull()
|
||||
fields.add(pcf)
|
||||
|
||||
if user.verifiedType != none:
|
||||
var verified = newJObject()
|
||||
verified["name"] = %"Verified Type"
|
||||
verified["value"] = %user.verifiedType
|
||||
verified["verified_at"] = newJNull()
|
||||
fields.add(verified)
|
||||
|
||||
var userJson = newJObject()
|
||||
userJson["id"] = %user.id
|
||||
userJson["username"] = %user.username
|
||||
userJson["acct"] = %user.username
|
||||
userJson["display_name"] = %user.fullname
|
||||
userJson["locked"] = %user.protected
|
||||
userJson["bot"] = %user.bot
|
||||
userJson["discoverable"] = %true
|
||||
userJson["indexable"] = %false
|
||||
userJson["group"] = %false
|
||||
userJson["created_at"] = %($user.joinDate)
|
||||
userJson["note"] = %user.bio
|
||||
userJson["url"] = %(&"{getUrlPrefix(cfg)}/{user.username}")
|
||||
userJson["uri"] = %(&"{getUrlPrefix(cfg)}/{user.username}")
|
||||
userJson["avatar"] = %(getUrlPrefix(cfg) & getPicUrl(user.userPic))
|
||||
userJson["avatar_static"] = %(getUrlPrefix(cfg) & getPicUrl(user.userPic))
|
||||
userJson["header"] = %(getUrlPrefix(cfg) & getPicUrl(user.banner))
|
||||
userJson["header_static"] = %(getUrlPrefix(cfg) & getPicUrl(user.banner))
|
||||
userJson["followers_count"] = %user.followers
|
||||
userJson["following_count"] = %user.following
|
||||
userJson["statuses_count"] = %user.tweets
|
||||
userJson["hide_collections"] = %false
|
||||
userJson["noindex"] = %false
|
||||
userJson["emojis"] = %(@[])
|
||||
userJson["roles"] = %(@[])
|
||||
userJson["fields"] = %fields
|
||||
|
||||
result = userJson
|
||||
@ -26,8 +26,8 @@ proc renderUserCard*(user: User; prefs: Prefs; path: string): VNode =
|
||||
|
||||
tdiv(class="profile-card-tabs-name-and-follow"):
|
||||
tdiv():
|
||||
linkUser(user, class="profile-card-fullname")
|
||||
linkUser(user, class="profile-card-username")
|
||||
linkUser(user, class="profile-card-fullname", prefs)
|
||||
linkUser(user, class="profile-card-username", prefs)
|
||||
let following = isFollowing(user.username, prefs.following)
|
||||
if not following:
|
||||
buttonReferer "/follow/" & user.username, "Follow", path, "profile-card-follow-button"
|
||||
@ -35,6 +35,20 @@ proc renderUserCard*(user: User; prefs: Prefs; path: string): VNode =
|
||||
buttonReferer "/unfollow/" & user.username, "Unfollow", path, "profile-card-follow-button"
|
||||
|
||||
tdiv(class="profile-card-extra"):
|
||||
if user.bot:
|
||||
tdiv(class="profile-automated"):
|
||||
span:
|
||||
if user.botOwner.len > 0:
|
||||
icon "cog", "Automated by "
|
||||
a(href=(&"/{user.botOwner}")): text &"@{user.botOwner}"
|
||||
else:
|
||||
icon "cog", "Automated"
|
||||
|
||||
if user.pcf.len > 0:
|
||||
tdiv(class="profile-pcf"):
|
||||
span:
|
||||
icon "pcf", &"{user.pcf} account"
|
||||
|
||||
if user.bio.len > 0:
|
||||
tdiv(class="profile-bio"):
|
||||
p(dir="auto"):
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat
|
||||
import karax/[karaxdsl, vdom, vstyles]
|
||||
import ".."/[types, utils]
|
||||
import ".."/[types, utils, formatters]
|
||||
|
||||
const smallWebp* = "?name=small&format=webp"
|
||||
|
||||
@ -26,11 +26,13 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
||||
template verifiedIcon*(user: User): untyped {.dirty.} =
|
||||
if user.verifiedType != VerifiedType.none:
|
||||
let lower = ($user.verifiedType).toLowerAscii()
|
||||
icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account")
|
||||
buildHtml(tdiv(class=(&"verified-icon {lower}"))):
|
||||
icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account")
|
||||
icon "ok", class="verified-icon-check", title=(&"Verified {lower} account")
|
||||
else:
|
||||
text ""
|
||||
|
||||
proc linkUser*(user: User, class=""): VNode =
|
||||
proc linkUser*(user: User, class="", prefs: Prefs): VNode =
|
||||
let
|
||||
isName = "username" notin class
|
||||
href = "/" & user.username
|
||||
@ -44,6 +46,10 @@ proc linkUser*(user: User, class=""): VNode =
|
||||
if user.protected:
|
||||
text " "
|
||||
icon "lock", title="Protected account"
|
||||
if user.badge.name.len > 0:
|
||||
span(class="brand-badge"):
|
||||
a(href=replaceUrls(user.badge.url, prefs), title=user.badge.name):
|
||||
img(class="brand-badge-image", src=getPicUrl(user.badge.icon), alt=user.badge.name)
|
||||
|
||||
proc linkText*(text: string; class=""): VNode =
|
||||
let url = if "http" notin text: https & text else: text
|
||||
@ -89,9 +95,9 @@ proc genDate*(pref, state: string): VNode =
|
||||
input(name=pref, `type`="date", value=state)
|
||||
icon "calendar"
|
||||
|
||||
proc genImg*(url: string; class=""): VNode =
|
||||
proc genImg*(url: string; alt=""; class=""): VNode =
|
||||
buildHtml():
|
||||
img(src=getPicUrl(url), class=class, alt="")
|
||||
img(src=getPicUrl(url), class=class, alt=alt)
|
||||
|
||||
proc getTabClass*(query: Query; tab: QueryKind): string =
|
||||
if query.kind == tab: "tab-item active"
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
## SPDX-License-Identifier: AGPL-3.0-only
|
||||
#import strutils, xmltree, strformat, options, unicode
|
||||
#import ../types, ../utils, ../formatters, ../prefs
|
||||
## Snowflake ID cutoff for RSS GUID format transition
|
||||
## Corresponds to approximately December 14, 2025 UTC
|
||||
#const guidCutoff = 2000000000000000000'i64
|
||||
#
|
||||
#proc getTitle(tweet: Tweet; retweet: string): string =
|
||||
#if tweet.pinned: result = "Pinned: "
|
||||
@ -25,7 +28,25 @@
|
||||
#end proc
|
||||
#
|
||||
#proc getDescription(desc: string; cfg: Config): string =
|
||||
Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||
Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
|
||||
#end proc
|
||||
#
|
||||
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
|
||||
#result = profile.tweets.content
|
||||
#if profile.pinned.isSome and result.len > 0:
|
||||
# let pinnedTweet = profile.pinned.get
|
||||
# var inserted = false
|
||||
# for threadIdx in 0 ..< result.len:
|
||||
# if not inserted:
|
||||
# for tweetIdx in 0 ..< result[threadIdx].len:
|
||||
# if result[threadIdx][tweetIdx].id < pinnedTweet.id:
|
||||
# result[threadIdx].insert(pinnedTweet, tweetIdx)
|
||||
# inserted = true
|
||||
# end if
|
||||
# end for
|
||||
# end if
|
||||
# end for
|
||||
#end if
|
||||
#end proc
|
||||
#
|
||||
#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
|
||||
@ -35,10 +56,13 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||
<p>${text.replace("\n", "<br>\n")}</p>
|
||||
#if tweet.photos.len > 0:
|
||||
# for photo in tweet.photos:
|
||||
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
|
||||
<img src="${urlPrefix}${getPicUrl(photo.url)}" style="max-width:250px;" />
|
||||
# end for
|
||||
#elif tweet.video.isSome:
|
||||
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
||||
<a href="${urlPrefix}${tweet.getLink}">
|
||||
<br>Video<br>
|
||||
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
||||
</a>
|
||||
#elif tweet.gif.isSome:
|
||||
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
|
||||
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
|
||||
@ -51,10 +75,18 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||
# end if
|
||||
#end if
|
||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||
# let quoteLink = getLink(get(tweet.quote))
|
||||
# let quoteTweet = get(tweet.quote)
|
||||
# let quoteLink = urlPrefix & getLink(quoteTweet)
|
||||
<hr/>
|
||||
<p>Quoting: <a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
|
||||
${renderRssTweet(get(tweet.quote), cfg)}
|
||||
<blockquote>
|
||||
<b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
|
||||
<p>
|
||||
${renderRssTweet(quoteTweet, cfg)}
|
||||
</p>
|
||||
<footer>
|
||||
— <cite><a href="${quoteLink}">${quoteLink}</a>
|
||||
</footer>
|
||||
</blockquote>
|
||||
#end if
|
||||
#end proc
|
||||
#
|
||||
@ -72,12 +104,17 @@ ${renderRssTweet(get(tweet.quote), cfg)}
|
||||
# if link in links: continue
|
||||
# end if
|
||||
# links.add link
|
||||
# let useGlobalGuid = parseBiggestInt(tweet.id) >= guidCutoff
|
||||
<item>
|
||||
<title>${getTitle(tweet, retweet)}</title>
|
||||
<dc:creator>@${tweet.user.username}</dc:creator>
|
||||
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
||||
#if useGlobalGuid:
|
||||
<guid isPermaLink="false">${tweet.id}</guid>
|
||||
#else:
|
||||
<guid>${urlPrefix & link}</guid>
|
||||
#end if
|
||||
<link>${urlPrefix & link}</link>
|
||||
</item>
|
||||
# end for
|
||||
@ -108,8 +145,9 @@ ${renderRssTweet(get(tweet.quote), cfg)}
|
||||
<width>128</width>
|
||||
<height>128</height>
|
||||
</image>
|
||||
#if profile.tweets.content.len > 0:
|
||||
${renderRssTweets(profile.tweets.content, cfg, userId=profile.user.id)}
|
||||
#let tweetsList = getTweetsWithPinned(profile)
|
||||
#if tweetsList.len > 0:
|
||||
${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
|
||||
#end if
|
||||
</channel>
|
||||
</rss>
|
||||
|
||||
@ -24,9 +24,9 @@ proc renderSearch*(): VNode =
|
||||
buildHtml(tdiv(class="panel-container")):
|
||||
tdiv(class="search-bar"):
|
||||
form(`method`="get", action="/search", autocomplete="off"):
|
||||
hiddenField("f", "users")
|
||||
hiddenField("f", "tweets")
|
||||
input(`type`="text", name="q", autofocus="",
|
||||
placeholder="Enter username...", dir="auto")
|
||||
placeholder="Search...", dir="auto")
|
||||
button(`type`="submit"): icon "search"
|
||||
|
||||
proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode =
|
||||
|
||||
@ -28,13 +28,18 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
|
||||
if thread.hasMore:
|
||||
renderMoreReplies(thread)
|
||||
|
||||
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
|
||||
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
|
||||
buildHtml(tdiv(class="replies", id="r")):
|
||||
var hasReplies = false
|
||||
var replyCount = 0
|
||||
for thread in replies.content:
|
||||
if thread.content.len == 0: continue
|
||||
hasReplies = true
|
||||
replyCount += thread.content.len
|
||||
renderReplyThread(thread, prefs, path)
|
||||
|
||||
if replies.bottom.len > 0:
|
||||
if hasReplies and replies.bottom.len > 0:
|
||||
if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
|
||||
renderMore(Query(), replies.bottom, focus="#r")
|
||||
|
||||
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
|
||||
@ -45,7 +50,7 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
|
||||
if conv.before.content.len > 0:
|
||||
tdiv(class="before-tweet thread-line"):
|
||||
let first = conv.before.content[0]
|
||||
if threadId != first.id and (first.replyId > 0 or not first.available):
|
||||
if threadId != first.id and (first.replyId.len > 0 or not first.available):
|
||||
renderEarlier(conv.before)
|
||||
for i, tweet in conv.before.content:
|
||||
renderTweet(tweet, prefs, path, index=i)
|
||||
@ -70,6 +75,6 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
|
||||
if not conv.replies.beginning:
|
||||
renderNewer(Query(), getLink(conv.tweet), focus="#r")
|
||||
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
|
||||
renderReplies(conv.replies, prefs, path)
|
||||
renderReplies(conv.replies, prefs, path, conv.tweet)
|
||||
|
||||
renderToTop(focus="#m")
|
||||
|
||||
@ -55,7 +55,7 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
|
||||
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
||||
index=i, last=(i == thread.high), showThread=show)
|
||||
|
||||
proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] =
|
||||
proc threadFilter(tweets: openArray[Tweet]; threads: openArray[string]; it: Tweet): seq[Tweet] =
|
||||
result = @[it]
|
||||
if it.retweet.isSome or it.replyId in threads: return
|
||||
for t in tweets:
|
||||
@ -74,8 +74,8 @@ proc renderUser*(user: User; prefs: Prefs): VNode =
|
||||
|
||||
tdiv(class="tweet-name-row"):
|
||||
tdiv(class="fullname-and-username"):
|
||||
linkUser(user, class="fullname")
|
||||
linkUser(user, class="username")
|
||||
linkUser(user, class="fullname", prefs)
|
||||
linkUser(user, class="username", prefs)
|
||||
|
||||
tdiv(class="tweet-content media-body", dir="auto"):
|
||||
verbatim replaceUrls(user.bio, prefs)
|
||||
@ -112,20 +112,20 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
|
||||
else:
|
||||
renderNoneFound()
|
||||
else:
|
||||
var retweets: seq[int64]
|
||||
var retweets: seq[string]
|
||||
|
||||
for thread in results.content:
|
||||
if thread.len == 1:
|
||||
let
|
||||
tweet = thread[0]
|
||||
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
|
||||
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: ""
|
||||
|
||||
if retweetId in retweets or tweet.id in retweets or
|
||||
tweet.pinned and prefs.hidePins:
|
||||
continue
|
||||
|
||||
var hasThread = tweet.hasThread
|
||||
if retweetId != 0 and tweet.retweet.isSome:
|
||||
if retweetId.len != 0 and tweet.retweet.isSome:
|
||||
retweets &= retweetId
|
||||
hasThread = get(tweet.retweet).hasThread
|
||||
renderTweet(tweet, prefs, path, showThread=hasThread)
|
||||
|
||||
@ -15,30 +15,40 @@ proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
|
||||
img(class=(prefs.getAvatarClass & " mini"), src=url)
|
||||
|
||||
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
|
||||
let user = tweet.user
|
||||
|
||||
buildHtml(tdiv):
|
||||
if pinned:
|
||||
tdiv(class="pinned"):
|
||||
span: icon "pin", "Pinned Tweet"
|
||||
span: icon "pin", "Pinned"
|
||||
elif retweet.len > 0:
|
||||
tdiv(class="retweet-header"):
|
||||
span: icon "retweet", retweet & " retweeted"
|
||||
|
||||
tdiv(class="tweet-header"):
|
||||
a(class="tweet-avatar", href=("/" & tweet.user.username)):
|
||||
a(class="tweet-avatar", href=("/" & user.username)):
|
||||
var size = "_bigger"
|
||||
if not prefs.autoplayGifs and tweet.user.userPic.endsWith("gif"):
|
||||
if not prefs.autoplayGifs and user.userPic.endsWith("gif"):
|
||||
size = "_400x400"
|
||||
genImg(tweet.user.getUserPic(size), class=prefs.getAvatarClass)
|
||||
genImg(user.getUserPic(size), class=prefs.getAvatarClass)
|
||||
|
||||
tdiv(class="tweet-name-row"):
|
||||
tdiv(class="fullname-and-username"):
|
||||
linkUser(tweet.user, class="fullname")
|
||||
linkUser(tweet.user, class="username")
|
||||
linkUser(user, class="fullname", prefs)
|
||||
linkUser(user, class="username", prefs)
|
||||
|
||||
span(class="tweet-date"):
|
||||
a(href=getLink(tweet), title=tweet.getTime):
|
||||
text tweet.getShortTime
|
||||
|
||||
if user.pcf.len > 0 or user.bot:
|
||||
tdiv(class="tweet-label-row"):
|
||||
if user.bot:
|
||||
tdiv(class="user-automated"): icon "cog", "Automated"
|
||||
|
||||
if user.pcf.len > 0:
|
||||
tdiv(class="user-pcf"): icon "pcf", &"{user.pcf} account"
|
||||
|
||||
proc renderAlbum(tweet: Tweet): VNode =
|
||||
let
|
||||
groups = if tweet.photos.len < 3: @[tweet.photos]
|
||||
@ -49,12 +59,15 @@ proc renderAlbum(tweet: Tweet): VNode =
|
||||
let margin = if i > 0: ".25em" else: ""
|
||||
tdiv(class="gallery-row", style={marginTop: margin}):
|
||||
for photo in photos:
|
||||
tdiv(class="attachment image"):
|
||||
tdiv(class="attachment image", title=photo.description):
|
||||
let
|
||||
named = "name=" in photo
|
||||
small = if named: photo else: photo & smallWebp
|
||||
a(href=getOrigPicUrl(photo), class="still-image", target="_blank"):
|
||||
genImg(small)
|
||||
url = photo.url
|
||||
named = "name=" in url
|
||||
small = if named: url else: url & smallWebp
|
||||
a(href=getOrigPicUrl(url), class="still-image", target="_blank", data-caption=photo.description):
|
||||
genImg(small, alt=photo.description)
|
||||
if photo.description.len > 0:
|
||||
span(class="alt"): text "ALT"
|
||||
|
||||
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
||||
case playbackType
|
||||
@ -180,19 +193,16 @@ func formatStat(stat: int): string =
|
||||
if stat > 0: insertSep($stat, ',')
|
||||
else: ""
|
||||
|
||||
proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode =
|
||||
proc renderStats(stats: TweetStats; tweet: Tweet): VNode =
|
||||
buildHtml(tdiv(class="tweet-stats")):
|
||||
a(href=getLink(tweet)):
|
||||
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
||||
a(href=getLink(tweet, false) & "/retweeters"):
|
||||
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
||||
a(href="/search?q=quoted_tweet_id:" & $tweet.id):
|
||||
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
|
||||
a():
|
||||
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
||||
a(href=getLink(tweet)):
|
||||
if views.len > 0:
|
||||
span(class="tweet-stat"): icon "play", insertSep(views, ',')
|
||||
span(class="tweet-stat", title="Replies", "aria-label"="Replies"): icon "comment", formatStat(stats.replies)
|
||||
span(class="tweet-stat", title="Reposts", "aria-label"="Reposts"): icon "retweet", formatStat(stats.retweets)
|
||||
span(class="tweet-stat"):
|
||||
a(href="/search?q=quoted_tweet_id:" & $tweet.id, title="Quotes", "aria-label"="Quotes"): icon "quote", formatStat(stats.quotes)
|
||||
span(class="tweet-stat", title="Likes", "aria-label"="Likes"): icon "heart", formatStat(stats.likes)
|
||||
span(class="tweet-stat", title="Bookmarks", "aria-label"="Bookmarks"): icon "bookmark", formatStat(stats.bookmarks)
|
||||
if stats.views > -1:
|
||||
span(class="tweet-stat", title="Views", "aria-label"="Views"): icon "eye", formatStat(stats.views)
|
||||
|
||||
proc renderReply(tweet: Tweet): VNode =
|
||||
buildHtml(tdiv(class="replying-to")):
|
||||
@ -220,7 +230,8 @@ proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="quote-media-container")):
|
||||
if quote.photos.len > 0:
|
||||
renderAlbum(quote)
|
||||
elif quote.video.isSome:
|
||||
|
||||
if quote.video.isSome:
|
||||
renderVideo(quote.video.get(), prefs, path)
|
||||
elif quote.gif.isSome:
|
||||
renderGif(quote.gif.get(), prefs)
|
||||
@ -242,8 +253,8 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||||
tdiv(class="tweet-name-row"):
|
||||
tdiv(class="fullname-and-username"):
|
||||
renderMiniAvatar(quote.user, prefs)
|
||||
linkUser(quote.user, class="fullname")
|
||||
linkUser(quote.user, class="username")
|
||||
linkUser(quote.user, class="fullname", prefs)
|
||||
linkUser(quote.user, class="username", prefs)
|
||||
|
||||
span(class="tweet-date"):
|
||||
a(href=getLink(quote), title=quote.getTime):
|
||||
@ -271,6 +282,11 @@ proc renderCommunityNote(note: BirdwatchNote; prefs: Prefs): VNode =
|
||||
tdiv(class="community-note-text", dir="auto"):
|
||||
verbatim replaceUrls(note.text, prefs)
|
||||
|
||||
proc renderLimitedActions(action: LimitedActions): VNode =
|
||||
buildHtml(tdiv(class="limited-actions")):
|
||||
tdiv(class="limited-actions-title"): text action.title
|
||||
tdiv(class="limited-actions-text"): text action.text
|
||||
|
||||
proc renderLocation*(tweet: Tweet): string =
|
||||
let (place, url) = tweet.getLocation()
|
||||
if place.len == 0: return
|
||||
@ -316,7 +332,6 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
a(class="tweet-link", href=getLink(tweet))
|
||||
|
||||
tdiv(class="tweet-body"):
|
||||
var views = ""
|
||||
renderHeader(tweet, retweet, pinned, prefs)
|
||||
|
||||
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
||||
@ -338,12 +353,11 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
|
||||
if tweet.photos.len > 0:
|
||||
renderAlbum(tweet)
|
||||
elif tweet.video.isSome:
|
||||
|
||||
if tweet.video.isSome:
|
||||
renderVideo(tweet.video.get(), prefs, path)
|
||||
views = tweet.video.get().views
|
||||
elif tweet.gif.isSome:
|
||||
renderGif(tweet.gif.get(), prefs)
|
||||
views = "GIF"
|
||||
|
||||
if tweet.poll.isSome:
|
||||
renderPoll(tweet.poll.get())
|
||||
@ -351,7 +365,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
if tweet.quote.isSome:
|
||||
renderQuote(tweet.quote.get(), prefs, path)
|
||||
|
||||
if mainTweet and tweet.birdwatch.isSome:
|
||||
if tweet.birdwatch.isSome:
|
||||
renderCommunityNote(tweet.birdwatch.get(), prefs)
|
||||
|
||||
if mainTweet:
|
||||
@ -361,12 +375,15 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
renderMediaTags(tweet.mediaTags)
|
||||
|
||||
if not prefs.hideTweetStats:
|
||||
renderStats(tweet.stats, views, tweet)
|
||||
renderStats(tweet.stats, tweet)
|
||||
|
||||
if showThread:
|
||||
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
||||
text "Show this thread"
|
||||
|
||||
if mainTweet and tweet.limitedActions.isSome:
|
||||
renderLimitedActions(tweet.limitedActions.get())
|
||||
|
||||
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user