Compare commits

..

3 Commits

Author SHA1 Message Date
cb84ed219b pull updates from cynthia foxwell's fork
Some checks failed
Build and Publish Docker / build (push) Has been cancelled
2025-08-07 20:25:37 -05:00
775f04eeaa update eir theme - bg
Some checks failed
Build and Publish Docker / build (push) Has been cancelled
2025-02-17 17:38:20 -06:00
2a9c98fd0c Initial commit, my theme + changes
Some checks failed
Build and Publish Docker / build (push) Has been cancelled
2024-12-17 21:21:34 -06:00
57 changed files with 1555 additions and 2237 deletions

View File

@ -1,2 +0,0 @@
pnpm-lock.yaml
public/fonts/

View File

@ -26,7 +26,12 @@ 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 = ""
disableTid = false # enable this if cookie-based auth is failing
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
# Change default preferences here, see src/prefs_impl.nim for a complete list
[Preferences]

View File

@ -1,27 +1,16 @@
@font-face {
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-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-weight: normal;
font-style: normal;
}
/* 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 {
[class^="icon-"]:before, [class*=" icon-"]:before {
font-family: "fontello";
font-style: normal;
font-weight: normal;
@ -30,9 +19,7 @@
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;
@ -41,81 +28,26 @@
/* 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-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";
} /* '' */
.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'; } /* '' */

Binary file not shown.

View File

@ -1,52 +1,46 @@
<?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) 2025 by original authors @ fontello.com</metadata>
<metadata>Copyright (C) 2020 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="&#x2665;" 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="heart" unicode="&#x2665;" 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="quote" unicode="&#x275e;" 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="&#xe800;" 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="&#xe801;" 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="&#xe802;" 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="link" unicode="&#xe803;" 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="ok" unicode="&#xe803;" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" />
<glyph glyph-name="calendar" unicode="&#xe804;" 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="play" unicode="&#xe804;" 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="picture" unicode="&#xe805;" 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="link" unicode="&#xe805;" 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="lock" unicode="&#xe806;" 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="calendar" unicode="&#xe806;" 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="down" unicode="&#xe807;" 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="location" unicode="&#xe807;" 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="retweet" unicode="&#xe808;" 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="picture" unicode="&#xe809;" 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="search" unicode="&#xe809;" 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="lock" unicode="&#xe80a;" 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="pin" unicode="&#xe80a;" 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="down" unicode="&#xe80b;" 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="cog" unicode="&#xe80b;" 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="retweet" unicode="&#xe80d;" 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="info" unicode="&#xe80c;" 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="search" unicode="&#xe80e;" 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="bookmark" unicode="&#xe80d;" 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="pin" unicode="&#xe80f;" 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="eye" unicode="&#xe80e;" 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="cog" unicode="&#xe812;" 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="pcf" unicode="&#xe83a;" 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="rss-feed" unicode="&#xe813;" 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="location" unicode="&#xf031;" 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="info" unicode="&#xf128;" 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="bird" unicode="&#xf099;" 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="&#xf09e;" 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" />
<glyph glyph-name="bird" unicode="&#xf309;" 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" />
</font>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,89 +1,82 @@
# My instance
# About
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>.
<https://git.eir-nya.gay/eir/nitter>
Additionally, I am copying changes from <https://gitlab.com/Cynosphere/nitter> ([her instance](https://tw.counter-strike.gay))
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>.
* 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)
The rest of this page is copied from Cynthia's fork:
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.
> # 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).
### Fork features (from [Cynthia Foxwell's fork](<https://gitlab.com/Cynosphere/nitter>))
* 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)
* No dependency on Redis, as it has caused ratelimiting issues, but also forcably disables RSS
## 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
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
* Cynthia Foxwell for her fork of this project
* 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 (from [Cynthia Foxwell's fork](<https://gitlab.com/Cynosphere/nitter>))
**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).

View File

@ -1,114 +1,59 @@
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, httpclient, strutils, sequtils, sugar
import asyncdispatch, httpclient, uri, 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 headers = newHttpHeaders()
headers.add("Referer", """https://x.com/$1""" % username)
let js = await fetchRaw(userUrl(username), headers)
let
variables = """{"screen_name": "$1"}""" % username
params = {"variables": variables, "features": gqlFeatures}
js = await fetchRaw(graphUser ? params, Api.userScreenName)
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
url = apiReq(graphUserById, """{"userId":"$1"}""" % id)
js = await fetchRaw(url, headers)
variables = """{"rest_id": "$1"}""" % id
params = {"variables": variables, "features": gqlFeatures}
js = await fetchRaw(graphUserById ? params, Api.userRestId)
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: ""
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)
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)
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: ""
url = apiReq(graphListTweets, restIdVars % [id, cursor])
js = await fetch(url)
variables = listTweetsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphListTweets ? params, Api.listTweets)
result = parseGraphTimeline(js, "list", after).tweets
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
let
variables = %*{"screenName": name, "listSlug": list}
url = apiReq(graphListBySlug, $variables)
js = await fetch(url)
result = parseGraphList(js)
params = {"variables": $variables, "features": gqlFeatures}
result = parseGraphList(await fetch(graphListBySlug ? params, Api.listBySlug))
proc getGraphList*(id: string): Future[List] {.async.} =
let
url = apiReq(graphListById, """{"listId": "$1"}""" % id)
js = await fetch(url)
result = parseGraphList(js)
variables = """{"listId": "$1"}""" % id
params = {"variables": variables, "features": gqlFeatures}
result = parseGraphList(await fetch(graphListById ? params, Api.list))
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
if list.id.len == 0: return
@ -122,45 +67,77 @@ 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 = apiReq(graphListMembers, $variables)
js = await fetchRaw(url)
result = parseGraphListMembers(js, after)
url = consts.favorites ? {"variables": $variables, "features": gqlFeatures}
result = parseGraphTimeline(await fetch(url, Api.favorites), 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
url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
js = await fetch(url, headers)
variables = """{"rest_id": "$1"}""" % id
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphTweetResult ? params, Api.tweetResult)
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: ""
js = await fetch(tweetDetailUrl(id, cursor), headers)
variables = tweetVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures, "fieldToggles": tweetFieldToggles}
js = await fetch(graphTweet ? params, Api.tweetDetail)
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: ""
js = await fetch(apiReq(graphFollowing, followVars % [id, cursor]))
variables = followVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFollowing ? params, Api.following)
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: ""
js = await fetch(apiReq(graphFollowers, followVars % [id, cursor]))
variables = followVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFollowers ? params, Api.followers)
result = parseGraphFollowTimeline(js, id)
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
@ -181,16 +158,15 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
variables = %*{
"rawQuery": q,
"count": 20,
"query_source": "typed_query",
"product": "Latest",
"withGrokTranslatedBio": false
"withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false
}
if after.len > 0:
variables["cursor"] = % after
let
url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[Tweets](js, after)
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after)
result.query = query
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
@ -201,24 +177,26 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
variables = %*{
"rawQuery": query.text,
"count": 20,
"query_source": "typed_query",
"product": "People",
"withGrokTranslatedBio": false
"withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false
}
if after.len > 0:
variables["cursor"] = % after
result.beginning = false
let
url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[User](js, after)
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
result = parseGraphSearch[User](await fetch(url, Api.search), after)
result.query = query
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
if id.len == 0: return
let js = await fetch(mediaUrl(id, ""))
result = parseGraphPhotoRail(js)
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 resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
let client = newAsyncHttpClient(maxRedirects=0)

View File

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
import httpclient, asyncdispatch, options, strutils, uri, times, tables, math
import httpclient, asyncdispatch, options, strutils, uri, times, tables
import jsony, packedjson, zippy
import types, consts, parserutils, http_pool, tid
import types, tokens, consts, parserutils, http_pool
import experimental/types/common
import config
@ -9,63 +9,68 @@ const
rlRemaining = "x-rate-limit-remaining"
rlReset = "x-rate-limit-reset"
var
pool: HttpPool
disableTid: bool
var pool: HttpPool
proc setDisableTid*(disable: bool) =
disableTid = disable
proc toUrl(req: ApiReq): Uri =
let c = req.cookie
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params
proc rateLimitError*(): ref RateLimitError =
newException(RateLimitError, "rate limited")
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 genHeaders*(token: Token = nil): HttpHeaders =
proc genHeaders*(): HttpHeaders =
let
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,
"connection": "keep-alive",
"authorization": auth,
"content-type": "application/json",
#"x-guest-token": if token == nil: "" else: token.tok,
"x-twitter-active-user": "yes",
"x-twitter-auth-type": "OAuth2Session",
"x-twitter-client-language": "en"
}, true)
"authority": "api.twitter.com",
"accept-encoding": "gzip",
"accept-language": "en-US,en;q=0.9",
"accept": "*/*",
"DNT": "1"
})
#template updateToken() =
# if resp.headers.hasKey(rlRemaining):
# let
# remaining = parseInt(resp.headers[rlRemaining])
# reset = parseInt(resp.headers[rlReset])
# token.setRateLimit(api, remaining, reset)
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)
@ -84,6 +89,7 @@ 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":
@ -92,55 +98,76 @@ 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)
let errText = "body: '" & result & "' url: " & $url
raise newException(InternalError, errText)
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, ", url: ", url.path
echo "error: ", e.name, ", msg: ", e.msg, ", url: ", url
#if "length" notin e.msg and "descriptor" notin e.msg:
#release(token, invalid=true)
raise rateLimitError()
template retry(bod) =
try:
bod
except ProtocolError:
except RateLimitError:
echo "[accounts] Rate limited, retrying ", api, " request..."
bod
proc fetch*(req: ApiReq; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} =
retry:
proc fetch*(url: Uri; api: Api; 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, " - non-json for: ", url, ", body: ", result
echo resp.status, ": ", body, " --- url: ", url
result = newJNull()
#updateToken()
let error = result.getError
if error in {expiredToken, badToken}:
echo "Fetch error, API: ", url.path, ", error: ", result.getError
echo "fetch error: ", result.getError
#release(token, invalid=true)
raise rateLimitError()
proc fetchRaw*(req: ApiReq; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
retry:
let url = req.toUrl()
proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
#retry:
fetchImpl(result, additional_headers):
if not (result.startsWith('{') or result.startsWith('[')):
echo resp.status, " - non-json for: ", url, ", body: ", result
echo resp.status, ": ", result, " --- url: ", url
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()

View File

@ -126,7 +126,7 @@ proc getAccountPoolDebug*(): JsonNode =
proc rateLimitError*(): ref RateLimitError =
newException(RateLimitError, "rate limited")
proc isLimited(account: GuestAccount; req: ApiReq): bool =
proc isLimited(account: GuestAccount; api: Api): bool =
if account.isNil:
return true
@ -157,9 +157,9 @@ proc release*(account: GuestAccount) =
if account.isNil: return
dec account.pending
proc getGuestAccount*(req: ApiReq): Future[GuestAccount] {.async.} =
proc getGuestAccount*(api: Api): Future[GuestAccount] {.async.} =
for i in 0 ..< accountPool.len:
if result.isReady(req): break
if result.isReady(api): break
result = accountPool.sample()
if not result.isNil and result.isReady(api):

View File

@ -43,7 +43,6 @@ 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", "")
)

View File

@ -1,136 +1,160 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils
import uri, sequtils, strutils
const
auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
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"
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 / "y90SwUGBZ3yz0yNUmCHgTw/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
gqlFeatures* = """{
"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,
"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,
"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,
"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,
"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,
"subscriptions_feature_can_gift_premium": 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,
"verified_phone_label_enabled": false,
"vibe_api_enabled": false,
"view_counts_everywhere_api_enabled": false,
"premium_content_api_read_enabled": false,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_analysis_button_from_backend": false,
"responsive_web_grok_analyze_post_followups_enabled": false,
"responsive_web_jetfuel_frame": false,
"profile_label_improvements_pcf_label_in_post_enabled": true,
"responsive_web_grok_image_annotation_enabled": false,
"responsive_web_grok_share_attachment_enabled": false,
"rweb_video_screen_enabled": false
}""".replace(" ", "").replace("\n", "")
tweetVars* = """{
"postId": "$1",
$2
"includeHasBirdwatchNotes": false,
"includePromotedContent": false,
"withBirdwatchNotes": false,
"withVoice": false,
"withV2Timeline": true
}""".replace(" ", "").replace("\n", "")
tweetDetailVars* = """{
tweetVariables* = """{
"focalTweetId": "$1",
$2
"referrer": "profile",
"with_rux_injections": false,
"rankingMode": "Relevance",
"includePromotedContent": true,
"includePromotedContent": false,
"withCommunity": true,
"withQuickPromoteEligibilityTweetFields": true,
"withQuickPromoteEligibilityTweetFields": false,
"withBirdwatchNotes": true,
"withVoice": true
}""".replace(" ", "").replace("\n", "")
restIdVars* = """{
"rest_id": "$1", $2
tweetFieldToggles* = """{
"withArticleRichContentState": false,
"withArticlePlainText": true,
"withGrokAnalyze": false,
"withDisallowedReplyControls": false
}""".replace(" ", "").replace("\n", "")
userTweetsVariables* = """{
"rest_id": "$1",
$2
"count": 20
}"""
userMediaVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
"withClientEventToken": false,
"withBirdwatchNotes": false,
"withVoice": true
}""".replace(" ", "").replace("\n", "")
userTweetsVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
"withQuickPromoteEligibilityTweetFields": true,
"withVoice": true
listTweetsVariables* = """{
"rest_id": "$1",
$2
"count": 20
}""".replace(" ", "").replace("\n", "")
userTweetsAndRepliesVars* = """{
"userId": "$1", $2
reactorsVariables* = """{
"tweetId": "$1",
$2
"count": 20,
"includePromotedContent": false,
"withCommunity": true,
"withVoice": true
"includePromotedContent": false
}""".replace(" ", "").replace("\n", "")
followVars* = """{
followVariables* = """{
"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}"""
userMediaVariables* = """{
"userId": "$1",
$2
"count": 20,
"includePromotedContent": false
}""".replace(" ", "").replace("\n", "")

View File

@ -1,53 +1,21 @@
import options, strutils
import options
import jsony
import user, utils, ../types/[graphuser, graphlistmembers]
import user, ../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)
userResult =
if raw.data.userResult.isSome: raw.data.userResult.get.result
elif raw.data.user.isSome: raw.data.user.get.result
else: UserResult()
let raw = json.fromJson(GraphUser)
if userResult.unavailableReason.get("") == "Suspended" or
userResult.reason.get("") == "Suspended":
if raw.data.userResult.result.unavailableReason.get("") == "Suspended":
return User(suspended: true)
result = parseUserResult(userResult)
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
proc parseGraphListMembers*(json, cursor: string): Result[User] =
result = Result[User](
@ -63,7 +31,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 parseUserResult(userResult)
result.content.add userResult.legacy
of TimelineTimelineCursor:
if entry.content.cursorType == "Bottom":
result.bottom = entry.content.value

View File

@ -1,8 +0,0 @@
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)

View File

@ -64,7 +64,7 @@ proc toUser*(raw: RawUser): User =
)
if raw.pinnedTweetIdsStr.len > 0:
result.pinnedTweet = raw.pinnedTweetIdsStr[0]
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
result.expandUserEntities(raw)

View File

@ -1,48 +1,15 @@
import options, strutils
from ../../types import User, VerifiedType
import options
from ../../types import User
type
GraphUser* = object
data*: tuple[userResult: Option[UserData], user: Option[UserData]]
data*: tuple[userResult: UserData]
UserData* = object
result*: UserResult
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
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

View File

@ -1,4 +0,0 @@
type
TidPair* = object
animationKey*: string
verification*: string

View File

@ -7,11 +7,10 @@ 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|x)\.com"
twLinkRegex = re"""<a href="https:\/\/(twitter|x).com([^"]+)">(twitter|x)\.com(\S+)</a>"""
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>"""
ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase})
@ -32,10 +31,7 @@ let
illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]"
proc getUrlPrefix*(cfg: Config): string =
var proto = "http"
if cfg.useHttps: proto &= "s"
proto &= "://"
result = proto & cfg.hostname
"https://" & cfg.hostname
proc shortLink*(text: string; length=28): string =
result = text.replace(wwwRegex, "")
@ -62,12 +58,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 result or "/x.com" in result or tco in result):
if prefs.replaceTwitter.len > 0 and ("twitter.com" in body or tco in body):
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 & "$4", href = prefs.replaceTwitter & "$2"))
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
@ -153,7 +149,7 @@ proc getShortTime*(tweet: Tweet): string =
result = "now"
proc getLink*(tweet: Tweet; focus=true): string =
if tweet.id.len == 0: return
if tweet.id == 0: return
var username = tweet.user.username
if username.len == 0:
username = "i"

View File

@ -6,15 +6,15 @@ from htmlgen import a
import jester
import types, config, prefs, formatters, redis_cache, http_pool, apiutils
import types, config, prefs, formatters, redis_cache, http_pool
import views/[general, about]
import routes/[
preferences, timeline, status, media, search, list, rss, #debug,
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://git.eir-nya.gay/eir/nitter/issues"
const issuesUrl = "https://github.com/zedeus/nitter/issues"
#let accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json")
@ -34,12 +34,11 @@ setHmacKey(cfg.hmacKey)
setProxyEncoding(cfg.base64Media)
setMaxHttpConns(cfg.httpMaxConns)
setHttpProxy(cfg.proxy, cfg.proxyAuth)
setDisableTid(cfg.disableTid)
initAboutPage(cfg.staticDir)
waitFor initRedisPool(cfg)
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
stdout.flushFile
#waitFor initRedisPool(cfg)
#stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
#stdout.flushFile
createUnsupportedRouter(cfg)
createResolverRouter(cfg)
@ -50,7 +49,7 @@ createStatusRouter(cfg)
createSearchRouter(cfg)
createMediaRouter(cfg)
createEmbedRouter(cfg)
createRssRouter(cfg)
#createRssRouter(cfg)
#createDebugRouter(cfg)
createTwitterApiRouter(cfg)
createActivityPubRouter(cfg)
@ -81,7 +80,7 @@ routes:
error InternalError:
echo error.exc.name, ": ", error.exc.msg
const link = a("open an issue", href = issuesUrl)
const link = a("open a GitHub issue", href = issuesUrl)
resp Http500, showError(
&"An error occurred, please {link} with the URL you tried to visit.", cfg)
@ -96,7 +95,7 @@ routes:
extend home, ""
extend follow, ""
extend rss, ""
#extend rss, ""
extend status, ""
extend search, ""
extend timeline, ""
@ -105,6 +104,7 @@ routes:
extend preferences, ""
extend resolver, ""
extend embed, ""
#extend debug, ""
extend activityspoof, ""
extend api, ""
extend unsupported, ""

View File

@ -2,7 +2,7 @@
import strutils, options, times, math
import packedjson, packedjson/deserialiser
import types, parserutils, utils
import experimental/parser/[unifiedcard, utils]
import experimental/parser/unifiedcard
import std/tables
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
@ -24,52 +24,17 @@ proc parseUser(js: JsonNode; id=""): User =
media: js{"media_count"}.getInt,
verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")),
protected: js{"protected"}.getBool,
joinDate: parseTwitterDate(js{"created_at"}.getStr)
joinDate: js{"created_at"}.getTime
)
result.expandUserEntities(js)
proc parseGraphUser*(js: JsonNode): User =
var user = js{"data", "user", "result"}
proc parseGraphUser(js: JsonNode): User =
var user = js{"user_result", "result"}
if user.isNull:
user = js{"user_results", "result"}
if user.isNull:
user = js{"user_result", "result"}
user = ? js{"user_results", "result"}
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
result = parseUser(user{"legacy"})
if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false):
result.verifiedType = blue
@ -240,9 +205,9 @@ 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"}.getStr,
threadId: js{"conversation_id_str"}.getStr,
replyId: js{"in_reply_to_status_id_str"}.getStr,
id: js{"id_str"}.getId,
threadId: js{"conversation_id_str"}.getId,
replyId: js{"in_reply_to_status_id_str"}.getId,
replyHandle: js{"in_reply_to_screen_name"}.getStr,
text: js{"full_text"}.getStr,
time: js{"created_at"}.getTime,
@ -253,23 +218,22 @@ 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,
bookmarks: js{"bookmark_count"}.getInt
quotes: js{"quote_count"}.getInt
)
)
# fix for pinned threads
if result.hasThread and result.threadId.len == 0:
result.threadId = js{"self_thread", "id_str"}.getStr
if result.hasThread and result.threadId == 0:
result.threadId = js{"self_thread", "id_str"}.getId
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"}.getStr)
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
# legacy
with rt, js{"retweeted_status_id_str"}:
result.retweet = some Tweet(id: rt.getStr)
result.retweet = some Tweet(id: rt.getId)
return
# graphql
@ -286,9 +250,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
let name = jsCard{"name"}.getStr
if "poll" in name:
if "image" in name:
result.photos.add Image(
url: jsCard{"binding_values", "image_large"}.getImageVal
)
result.photos.add jsCard{"binding_values", "image_large"}.getImageVal
result.poll = some parsePoll(jsCard)
elif name == "amplify":
@ -302,10 +264,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
for m in jsMedia:
case m{"type"}.getStr
of "photo":
result.photos.add Image(
url: m{"media_url_https"}.getImageStr,
description: m{"ext_alt_text"}.getStr,
)
result.photos.add m{"media_url_https"}.getImageStr
of "video":
result.video = some(parseVideo(m))
with user, m{"additional_media_info", "source_user"}:
@ -360,10 +319,11 @@ proc parseTweetSearch*(js: JsonNode; after=""): Timeline =
result.content.add @[parsed]
if result.content.len > 0:
result.bottom = $(result.content[^1][0].id.parseBiggestInt() - 1)
result.bottom = $(result.content[^1][0].id - 1)
proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
result = global.tweets.getOrDefault(id, Tweet(id: id))
let intId = if id.len > 0: parseBiggestInt(id) else: 0
result = global.tweets.getOrDefault(id, Tweet(id: intId))
if result.quote.isSome:
let quote = get(result.quote).id
@ -450,6 +410,23 @@ 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()
@ -466,17 +443,7 @@ 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":
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
return parseGraphTweet(js{"tweet"}, isLegacy)
else:
discard
@ -491,7 +458,7 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
jsCard["binding_values"] = values
result = parseTweet(js{"legacy"}, jsCard)
result.id = js{"rest_id"}.getStr
result.id = js{"rest_id"}.getId
result.user = parseGraphUser(js{"core"})
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
@ -501,48 +468,13 @@ 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"}.getStr,
title: title,
id: communityNote{"note", "rest_id"}.getId,
title: communityNote{"title"}.getStr,
)
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
@ -550,7 +482,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 and "promoted-" notin entryId:
elif "tweet" in entryId:
let
isLegacy = t{"item"}.hasKey("itemContent")
(contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results")
@ -582,16 +514,16 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
let tweet = parseGraphTweet(tweetResult, true)
if not tweet.available:
tweet.id = entryId.getId()
tweet.id = parseBiggestInt(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: id,
id: parseBiggestInt(id),
available: false,
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
)
@ -600,26 +532,42 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
result.tweet = tweet
else:
result.before.content.add tweet
elif (entryId.startsWith("conversationthread") or entryId.startswith("reply-mixer-conversation")) and "promoted-tweet" notin entryId:
elif entryId.startsWith("conversationthread") or entryId.startswith("reply-mixer-conversation"):
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.getCursor
result.replies.bottom = e{"content", "value"}.getStr
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
@ -627,21 +575,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 = entryId.getId()
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
if after.len == 0 and i{"type"}.getStr == "TimelinePinEntry":
with tweetResult, i{"entry", "content", "itemContent", "tweet_results", "result"}:
if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry":
with tweetResult, i{"entry", "content", "content", "tweetResult", "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 = entryId
tweet.id = parseBiggestInt(entryId)
result.pinned = some tweet
proc parseGraphUsersTimeline(timeline: JsonNode; after=""): UsersTimeline =
@ -691,7 +639,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 = entryId.getId()
tweet.id = parseBiggestInt(entryId.getId())
result.content.add tweet
elif T is User:
if entryId.startsWith("user"):

View File

@ -48,13 +48,7 @@ template with*(ident; value: JsonNode; body): untyped =
if notNull(value): body
template getCursor*(js: JsonNode): string =
var cursor = js{"content", "operation", "cursor", "value"}
if cursor.isNull:
cursor = js{"content", "value"}
if cursor.isNull:
cursor = js{"content", "itemContent", "value"}
cursor.getStr
js{"content", "operation", "cursor", "value"}.getStr
template getError*(js: JsonNode): Error =
if js.kind != JArray or js.len == 0: null
@ -292,7 +286,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.len != 0:
elif idx == -1 and tweet.replyId != 0:
tweet.reply.add name
replacements.deduplicate
@ -309,7 +303,7 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
var replyTo = ""
if tweet.replyId.len != 0:
if tweet.replyId != 0:
with reply, js{"in_reply_to_screen_name"}:
replyTo = reply.getStr
tweet.reply.add replyTo

View File

@ -59,9 +59,6 @@ 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)"

View File

@ -66,8 +66,7 @@ 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: string): string = "t:" & id
template convKey(id: string): string = "c:" & id
template tweetKey(id: int64): string = "t:" & $id
proc get(query: string): Future[string] {.async.} =
pool.withAcquire(r):
@ -87,7 +86,7 @@ proc cache*(data: List) {.async.} =
await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))
proc cache*(data: PhotoRail; name: string) {.async.} =
await setEx("pr2:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data)))
await setEx("pr:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data)))
proc cache*(data: User) {.async.} =
if data.username.len == 0: return
@ -97,15 +96,10 @@ 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.len == 0: return
if data.isNil or data.id == 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):
@ -120,13 +114,7 @@ template deserialize(data, T) =
except:
echo "Decompression failed($#): '$#'" % [astToStr(T), data]
proc deserializeConversation(data: string): Conversation =
try:
result = fromFlatty(uncompress(data), Conversation)
except:
echo "Decompression failed(Conversation): '$#'" % [data]
proc getCachedUserId*(username: string): Future[string] {.async.} =
proc getUserId*(username: string): Future[string] {.async.} =
let name = toLower(username)
pool.withAcquire(r):
result = await r.hGet(name.uidKey, name)
@ -145,16 +133,13 @@ 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 and username.len > 0:
if username != redisNil:
result = username
else:
let user = await getGraphUserById(userId)
@ -163,28 +148,24 @@ 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: string; after=""): Future[Conversation] {.async.} =
if id.len == 0: return
let tweet = await get(id.tweetKey)
# 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)
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))
proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} =
if name.len == 0: return
let rail = await get("pr:" & toLower(name))
if rail != redisNil:
rail.deserialize(PhotoRail)
else:
result = await getPhotoRail(id)
await cache(result, id)
result = await getPhotoRail(name)
await cache(result, name)
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
let list = if id.len == 0: redisNil

232
src/routes/activityspoof.nim Normal file → Executable file
View File

@ -4,7 +4,7 @@ import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat,
import jester
import router_utils
import ".."/[types, formatters, api, redis_cache]
import ".."/[types, formatters, api]
import ../views/[mastoapi]
export json, uri, sequtils, options, sugar, times
@ -17,47 +17,19 @@ proc createActivityPubRouter*(cfg: Config) =
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]
let id = @"id"
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)
let conv = await getTweet(id)
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Record not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone
@ -67,57 +39,24 @@ proc createActivityPubRouter*(cfg: Config) =
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)
for url in tweet.photos:
let image = getUrlPrefix(cfg) & getPicUrl(url)
var mediaObj = newJObject()
mediaObj["id"] = %"138733266285887488" # idk if discord even parses this snowflake, but its my user id why not
mediaObj["id"] = %"150745989836308480" # 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["remote_url"] = newJNull()
mediaObj["preview_remote_url"] = newJNull()
mediaObj["text_url"] = newJNull()
mediaObj["description"] = %imageObj.description
mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y)
# FIXME but this probably isnt used by discord
mediaObj["meta"] = newJObject()
@ -127,71 +66,47 @@ proc createActivityPubRouter*(cfg: Config) =
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["id"] = %"150745989836308480"
mediaObj["type"] = %"video"
mediaObj["url"] = %videoUrl
mediaObj["preview_url"] = %videoPreview
mediaObj["remote_url"] = %videoUrl
mediaObj["preview_remote_url"] = %videoPreview
mediaObj["url"] = %vars[^1].url
mediaObj["preview_url"] = %(getUrlPrefix(cfg) & getPicUrl(videoObj.thumb))
mediaObj["remote_url"] = newJNull()
mediaObj["preview_remote_url"] = newJNull()
mediaObj["text_url"] = newJNull()
mediaObj["description"] = %description
mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y)
# 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)
let gif = get(tweet.gif)
var mediaObj = newJObject()
mediaObj["id"] = %"138733266285887488"
mediaObj["id"] = %"150745989836308480"
mediaObj["type"] = %"video"
mediaObj["url"] = %gifUrl
mediaObj["preview_url"] = %gifPreview
mediaObj["remote_url"] = %gifUrl
mediaObj["preview_remote_url"] = %gifPreview
mediaObj["url"] = %(&"https://{gif.url}")
mediaObj["preview_url"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb))
mediaObj["remote_url"] = newJNull()
mediaObj["preview_remote_url"] = newJNull()
mediaObj["text_url"] = newJNull()
mediaObj["description"] = newJNull() # FIXME this requires refactoring gifs
mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y)
# 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["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:
if tweet.replyId != 0:
postJson["in_reply_to_id"] = %(&"{tweet.replyId}")
postJson["in_reply_to_account_id"] = %tweet.replyHandle
postJson["in_reply_to_account_id"] = %""
else:
postJson["in_reply_to_id"] = newJNull()
postJson["in_reply_to_account_id"] = newJNull()
@ -204,7 +119,32 @@ proc createActivityPubRouter*(cfg: Config) =
"website": getUrlPrefix(cfg)
}
postJson["media_attachments"] = %media
postJson["account"] = getMastoAPIUser(tweet.user, cfg)
postJson["account"] = %*{
"id": &"{tweet.user.id}",
"display_name": tweet.user.fullname,
"username": tweet.user.username,
"acct": tweet.user.username,
"url": &"{getUrlPrefix(cfg)}/{tweet.user.username}",
"uri": &"{getUrlPrefix(cfg)}/{tweet.user.username}",
"created_at": $tweet.user.joinDate,
"locked": tweet.user.protected,
"bot": false, # TODO?
"discoverable": true,
"indexable": false,
"group": false,
"avatar": getUrlPrefix(cfg) & getPicUrl(tweet.user.userPic),
"avatar_static": getUrlPrefix(cfg) & getPicUrl(tweet.user.userPic),
"header": getUrlPrefix(cfg) & getPicUrl(tweet.user.banner),
"header_static": getUrlPrefix(cfg) & getPicUrl(tweet.user.banner),
"followers_count": tweet.user.followers,
"following_count": tweet.user.following,
"statuses_count": tweet.user.tweets,
"hide_collections": false,
"noindex": false,
"emojis": @[],
"roles": @[],
"fields": @[],
}
postJson["mentions"] = newJArray() # TODO: parse?
postJson["tags"] = newJArray() # TODO: parse?
postJson["emojis"] = newJArray()
@ -214,40 +154,18 @@ proc createActivityPubRouter*(cfg: Config) =
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]
let id = @"id"
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)
let conv = await getTweet(id)
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Record not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone
@ -257,33 +175,7 @@ proc createActivityPubRouter*(cfg: Config) =
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)
let postJson = getActivityStream(conv.tweet, cfg, prefs)
resp Http200, {"Content-Type": "application/json"}, $postJson
@ -291,7 +183,7 @@ proc createActivityPubRouter*(cfg: Config) =
get "/users/@name":
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
let user = await getCachedUser(@"name")
let user = await getGraphUser(@"name")
if user.suspended or user.id.len == 0:
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
@ -321,19 +213,19 @@ proc createActivityPubRouter*(cfg: Config) =
var nodeinfo = newJObject()
nodeinfo["version"] = %"2.1"
nodeinfo["software"] = %*{
"name": cfg.title,
"repository": "https://git.eir-nya.gay/eir/nitter"
"name": "Nitter",
"repository": "https://gitlab.com/Cynosphere/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["nodeName"] = %"Nitter"
metadata["private"] = %true
metadata["maintainer"] = %*{
"name": "Eir",
"email": "eir@eir-nya.gay"
"name": "Cynthia",
"email": "gamers@riseup.net"
}
nodeinfo["metadata"] = metadata

17
src/routes/debug.nim Normal file
View File

@ -0,0 +1,17 @@
# 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()

View File

@ -36,4 +36,4 @@ proc createEmbedRouter*(cfg: Config) =
resp Http404
get "/oembed.json":
respJson generateOembed(cfg, @"type", @"title", @"user", @"url", @"provider")
respJson generateOembed(cfg, @"type", @"title", @"user", @"url")

View File

@ -1,6 +1,6 @@
import jester
import asyncdispatch, strutils, options, router_utils, timeline
import ".."/[prefs, types, utils, redis_cache]
import ".."/[prefs, types, utils]
import ../views/[general, home, search]
export home
@ -43,7 +43,7 @@ proc createHomeRouter*(cfg: Config) =
query.kind = userList
for name in names:
let prof = await getCachedUser(name)
let prof = await getGraphUser(name)
profs &= @[prof]
resp renderMain(renderFollowing(query, profs, prefs), request, cfg, prefs)

View File

@ -4,7 +4,7 @@ import strutils, strformat, uri
import jester
import router_utils
import ".."/[types, api, redis_cache]
import ".."/[types, api]
import ../views/[general, timeline, list]
template respList*(list, timeline, title, vnode: typed) =
@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) =
cond @"slug" != "memberships"
let
slug = decodeUrl(@"slug")
list = await getCachedList(@"name", slug)
list = await getList(@"name", slug)
if list.id.len == 0:
resp Http404, showError(&"""List "{@"slug"}" not found""", cfg)
redirect(&"/i/lists/{list.id}")
@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) =
cond '.' notin @"id"
let
prefs = cookiePrefs()
list = await getCachedList(id=(@"id"))
list = await getList(id=(@"id"))
timeline = await getGraphListTweets(list.id, getCursor())
vnode = renderTimelineTweets(timeline, prefs, request.path)
respList(list, timeline, list.title, vnode)
@ -54,6 +54,6 @@ proc createListRouter*(cfg: Config) =
cond '.' notin @"id"
let
prefs = cookiePrefs()
list = await getCachedList(id=(@"id"))
list = await getList(id=(@"id"))
members = await getGraphListMembers(list, getCursor())
respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))

View File

@ -141,6 +141,3 @@ proc createMediaRouter*(cfg: Config) =
content = proxifyVideo(vid, cookiePref(proxyVideos))
resp content, m3u8Mime
get re"^\/tvid\/(.+)$":
redirect("https://video.twimg.com/" & request.matches[0] & ".mp4")

View File

@ -4,26 +4,22 @@ import strutils
import jester
import router_utils
import ".."/[types, api, formatters]
import ".."/[types, api]
import ../views/general
template respResolved*(url, kind: string; prefs: Prefs): untyped =
template respResolved*(url, kind: string): untyped =
let u = url
if u.len == 0:
resp showError("Invalid $1 link" % kind, cfg)
else:
redirect(replaceUrls(u, prefs))
redirect(u)
proc createResolverRouter*(cfg: Config) =
router resolver:
get "/cards/@card/@id":
let
prefs = cookiePrefs()
url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
respResolved(await resolve(url, prefs), "card", prefs)
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
respResolved(await resolve(url, cookiePrefs()), "card")
get "/t.co/@url":
let
prefs = cookiePrefs()
url = "https://t.co/" & @"url"
respResolved(await resolve(url, prefs), "t.co", prefs)
let url = "https://t.co/" & @"url"
respResolved(await resolve(url, cookiePrefs()), "t.co")

View File

@ -4,27 +4,14 @@ import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat,
import jester, karax/vdom
import router_utils
import ".."/[types, formatters, api, redis_cache]
import ../views/[general, status, mastoapi]
import ".."/[types, formatters, api]
import ../views/[general, status, search, mastoapi]
export json, uri, sequtils, options, sugar, times
export router_utils
export api, formatters
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:
get "/@name/status/@id/@reactors":
@ -43,36 +30,20 @@ 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/?@m?/?@i?/?":
get "/@name/status/@id/?":
cond '.' notin @"name"
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)
var id = @"id"
var rawFile = false
if id.endsWith(".mp4"):
rawFile = true
id.removeSuffix(".mp4")
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
if id.len > 19 or id.any(c => not c.isDigit):
@ -80,11 +51,11 @@ proc createStatusRouter*(cfg: Config) =
let prefs = cookiePrefs()
let conv = await getCachedTweet(id)
let conv = await getTweet(id)
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Record not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone
@ -94,33 +65,7 @@ proc createStatusRouter*(cfg: Config) =
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)
let postJson = getActivityStream(conv.tweet, cfg, prefs)
resp Http200, {"Content-Type": "application/json"}, $postJson
@ -136,11 +81,11 @@ proc createStatusRouter*(cfg: Config) =
resp Http404, ""
resp $renderReplies(replies, prefs, getPath())
let conv = await getCachedTweet(id, getCursor())
let conv = await getTweet(id, getCursor())
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Tweet not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone
@ -154,38 +99,6 @@ proc createStatusRouter*(cfg: Config) =
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 = tweet.photos
video = ""
@ -196,21 +109,19 @@ proc createStatusRouter*(cfg: Config) =
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})"
if tweet.replyId != 0:
context = &"↩ Replying to: @{tweet.replyHandle}\n↘ Quoting: {quoteUser.fullname} (@{quoteUser.username})"
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
else:
context = &"{quoteUser.fullname} (@{quoteUser.username})"
context = &"Quoting: {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})"
elif tweet.replyId != 0:
context = &"↩ Replying to: @{tweet.replyHandle}"
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
if tweet.video.isSome():
let videoObj = get(tweet.video)
images.add(Image(url:videoObj.thumb))
images = @[videoObj.thumb]
let vars = videoObj.variants.filterIt(it.contentType == mp4)
# idk why this wont sort when it sorts everywhere else
@ -218,7 +129,7 @@ proc createStatusRouter*(cfg: Config) =
video = vars[^1].url
elif tweet.gif.isSome():
let gif = get(tweet.gif)
images.add(Image(url:gif.thumb))
images = @[gif.thumb]
video = getUrlPrefix(cfg) & getPicUrl(gif.url)
#elif tweet.card.isSome():
# let card = tweet.card.get()
@ -227,60 +138,20 @@ proc createStatusRouter*(cfg: Config) =
# elif card.video.isSome():
# images = @[card.video.get().thumb]
if rawVideo and video != "":
if rawFile 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,
context=context, contextUrl=contextUrl, id=id,
media=query, stats=statsStr)
context=context, contextUrl=contextUrl, id=id)
get "/@name/statuses/@id/?@m?/?@i?":
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/?":
redirect("/$1/status/$2" % [@"name", @"id"])
get "/i/web/status/@id":

View File

@ -3,13 +3,13 @@ import asyncdispatch, strutils, sequtils, uri, options, times, json
import jester, karax/vdom
import router_utils
import ".."/[types, formatters, query, api, redis_cache]
import ".."/[types, formatters, query, api]
import ../views/[general, profile, timeline, status, search, mastoapi]
export vdom
export uri, sequtils, json
export router_utils
export formatters, query, api, redis_cache
export formatters, query, api
export profile, timeline, status, mastoapi
proc getQuery*(request: Request; tab, name: string): Query =
@ -28,11 +28,24 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
else:
body
proc getUserId(username: string): Future[string] {.async.} =
let user = await getGraphUser(username)
if user.suspended:
return "suspended"
else:
return user.id
proc getUsername*(userId: string): Future[string] {.async.} =
let user = await getGraphUserById(userId)
result = user.username
proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
skipPinned=false): Future[Profile] {.async.} =
let
name = query.fromUser[0]
userId = await getCachedUserId(name)
userId = await getUserId(name)
if userId.len == 0:
return Profile(user: User(username: name))
@ -48,9 +61,9 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
let
rail =
skipIf(skipRail or query.kind == media, @[]):
getCachedPhotoRail(userId)
getPhotoRail(name)
user = getCachedUser(name)
user = getGraphUser(name)
result =
case query.kind
@ -83,7 +96,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 = @[Image(url: u.getUserPic("_400x400"))],
rss=rss, images = @[u.getUserPic("_400x400")],
banner=u.banner)
template respTimeline*(timeline: typed) =
@ -94,7 +107,7 @@ template respTimeline*(timeline: typed) =
template respUserId*() =
cond @"user_id".len > 0
let username = await getCachedUsername(@"user_id")
let username = await getUsername(@"user_id")
if username.len > 0:
redirect("/" & username)
else:
@ -111,7 +124,6 @@ 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()
@ -121,17 +133,17 @@ proc createTimelineRouter*(cfg: Config) =
case tab:
of "followers":
resp renderMain(renderUserList(await getGraphFollowers(await getCachedUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
resp renderMain(renderUserList(await getGraphFollowers(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
of "following":
resp renderMain(renderUserList(await getGraphFollowing(await getCachedUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
else:
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
let userId = await getCachedUserId(@"name")
let userId = await getUserId(@"name")
if userId == "suspended" or userId.len == 0:
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
let user = await getCachedUser(@"name")
let user = await getGraphUser(@"name")
let userJson = getActivityStream(user, cfg, prefs)

View File

@ -4,7 +4,7 @@ import json, asyncdispatch, options, uri
import times
import jester
import router_utils
import ".."/[types, api, apiutils, redis_cache]
import ".."/[types, api, apiutils, query, consts]
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 getCachedUser(username)
let user: User = await getGraphUser(username)
let response: JsonNode = %*{
"id": user.id,
"username": user.username
@ -81,49 +81,52 @@ 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 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)
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)
proc getUserReplies*(id: string; after=""): Future[string] {.async.} =
if id.len == 0: return
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)
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)
proc getUserMedia*(id: string; after=""): Future[string] {.async.} =
if id.len == 0: return
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)
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)
proc getTweetById*(id: string; after=""): Future[string] {.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: ""
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)
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)
proc createTwitterApiRouter*(cfg: Config) =
router api:
@ -132,14 +135,19 @@ proc createTwitterApiRouter*(cfg: Config) =
get "/api/user/@username":
let username = @"username"
let response = await getUser(username)
resp Http200, { "Content-Type": "application/json" }, response
let response = await getUserProfileJson(username)
respJson 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 response = await getUserTweetsJson(id)
# respJson 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"

View File

@ -1,5 +1,5 @@
@import "_variables";
@import "_mixins";
@import '_variables';
@import '_mixins';
.panel-container {
margin: auto;
@ -23,7 +23,6 @@
font-weight: bold;
width: 30px;
height: 30px;
padding: 0px 5px 1px 8px;
}
input {
@ -38,13 +37,3 @@
height: unset;
}
}
.brand-badge {
margin-left: 4px;
}
.brand-badge-image {
width: 16px;
height: 16px;
border: 1px solid var(--accent_border);
margin-bottom: -4px;
}

View File

@ -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-family: $font_0, $font_1, $font_2, $font_3;
font-size: 14px;
line-height: 1.3;
margin: 0;
@ -66,8 +66,7 @@ h1 {
display: inline;
}
h2,
h3 {
h2, h3 {
font-weight: normal;
}
@ -91,7 +90,7 @@ fieldset {
legend {
width: 100%;
padding: 0.6em 0 0.3em 0;
padding: .6em 0 .3em 0;
border: 0;
font-size: 16px;
font-weight: 600;
@ -143,60 +142,39 @@ ul {
}
.verified-icon {
display: inline-block;
color: var(--icon_text);
border-radius: 50%;
flex-shrink: 0;
margin: 2px 0 3px 3px;
padding-top: 3px;
height: 11px;
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;
}
font-size: 8px;
display: inline-block;
text-align: center;
vertical-align: middle;
&.blue {
.verified-icon-circle {
color: var(--verified_blue);
}
.verified-icon-check {
color: var(--icon_text);
}
background-color: var(--verified_blue);
}
&.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;
}
}

View File

@ -1,4 +1,4 @@
@import "_variables";
@import '_variables';
nav {
display: flex;
@ -12,8 +12,7 @@ nav {
z-index: 1000;
font-size: 16px;
a,
.icon-button button {
a, .icon-button button {
color: var(--fg_nav);
}
}
@ -59,10 +58,14 @@ nav {
justify-content: flex-end;
}
&.right a:hover {
&.right a {
padding-left: 4px;
&:hover {
color: var(--accent_light);
text-decoration: unset;
}
}
}
.lp {
@ -77,11 +80,10 @@ nav {
}
}
.icon-info {
.icon-info:before {
margin: 0 -3px;
}
.icon-cog {
font-size: 15px;
padding-left: 0 !important;
}

View File

@ -82,9 +82,7 @@
.profile-joindate,
.profile-location,
.profile-website,
.profile-automated,
.profile-pcf {
.profile-website {
color: var(--fg_faded);
margin: 1px 0;
width: 100%;

View File

@ -1,5 +1,5 @@
@import "_variables";
@import "_mixins";
@import '_variables';
@import '_mixins';
.search-title {
font-weight: bold;
@ -13,7 +13,6 @@
button {
margin: 0 2px 0 0;
padding: 0px 1px 1px 4px;
height: 23px;
display: flex;
align-items: center;
@ -35,7 +34,7 @@
background-color: var(--bg_elements);
color: var(--fg_color);
border: 1px solid var(--accent_border);
padding: 1px 1px 2px 4px;
padding: 1px 6px 2px 6px;
font-size: 14px;
cursor: pointer;
margin-bottom: 2px;
@ -63,7 +62,7 @@
.checkbox-container {
display: inline;
padding-right: unset;
margin-bottom: 5px;
margin-bottom: unset;
margin-left: 23px;
}

View File

@ -1,14 +1,13 @@
@import "_variables";
@import "_mixins";
@import "thread";
@import "media";
@import "video";
@import "embed";
@import "card";
@import "poll";
@import "quote";
@import "community_note";
@import "limited_actions";
@import '_variables';
@import '_mixins';
@import 'thread';
@import 'media';
@import 'video';
@import 'embed';
@import 'card';
@import 'poll';
@import 'quote';
@import 'community_note';
.tweet-body {
flex: 1;
@ -33,7 +32,7 @@
padding: 0;
vertical-align: bottom;
flex-basis: 100%;
margin-bottom: 0.2em;
margin-bottom: .2em;
a {
display: inline-block;
@ -48,11 +47,6 @@
display: flex;
justify-content: space-between;
}
.tweet-label-row {
padding: 0;
display: flex;
gap: 0.4em;
}
.fullname-and-username {
display: flex;
@ -71,26 +65,17 @@
.username {
@include ellipsis;
min-width: 1.6em;
margin-left: 0.4em;
margin-left: .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);
}
@ -173,8 +158,7 @@
padding-right: 2px;
}
.media-tag,
.icon-container {
.media-tag, .icon-container {
color: var(--fg_faded);
}
}
@ -196,9 +180,7 @@
}
}
.retweet-header,
.pinned,
.tweet-stats {
.retweet-header, .pinned, .tweet-stats {
align-content: center;
color: var(--grey);
display: flex;

View File

@ -1,32 +0,0 @@
@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;
}
}

View File

@ -48,19 +48,6 @@
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 {

View File

@ -1,8 +1,8 @@
@import "_variables";
@import "_mixins";
@import '_variables';
@import '_mixins';
video {
height: 100%;
max-height: 100%;
width: 100%;
}
@ -13,12 +13,14 @@ 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%;

View File

@ -1,62 +0,0 @@
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 Normal file
View File

@ -0,0 +1,168 @@
# 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)

View File

@ -12,13 +12,25 @@ type
TimelineKind* {.pure.} = enum
tweets, replies, media
ApiUrl* = object
endpoint*: string
params*: seq[(string, string)]
ApiReq* = object
oauth*: ApiUrl
cookie*: ApiUrl
Api* {.pure.} = enum
tweetDetail
tweetResult
photoRail
search
list
listBySlug
listMembers
listTweets
userRestId
userScreenName
favorites
userTweets
userTweetsAndReplies
userMedia
favoriters
retweeters
following
followers
RateLimit* = object
remaining*: int
@ -31,14 +43,14 @@ type
init*: Time
lastUse*: Time
pending*: int
apis*: Table[string, RateLimit]
apis*: Table[Api, RateLimit]
GuestAccount* = ref object
id*: int64
oauthToken*: string
oauthSecret*: string
pending*: int
apis*: Table[string, RateLimit]
apis*: Table[Api, RateLimit]
Error* = enum
null = 0
@ -68,11 +80,6 @@ type
business = "Business"
government = "Government"
Badge* = object
name*: string
icon*: string
url*: string
User* = object
id*: string
username*: string
@ -82,7 +89,7 @@ type
bio*: string
userPic*: string
banner*: string
pinnedTweet*: string
pinnedTweet*: int64
following*: int
followers*: int
tweets*: int
@ -92,10 +99,6 @@ type
protected*: bool
suspended*: bool
joinDate*: DateTime
bot*: bool
botOwner*: string
pcf*: string
badge*: Badge
VideoType* = enum
m3u8 = "application/x-mpegURL"
@ -120,10 +123,6 @@ type
playbackType*: VideoType
variants*: seq[VideoVariant]
Image* = object
url*: string
description*: string
QueryKind* = enum
posts, replies, media, users, tweets, userList, favorites
@ -198,22 +197,16 @@ type
retweets*: int
likes*: int
quotes*: int
bookmarks*: int
views*: int
BirdwatchNote* = ref object
id*: string
title*: string
text*: string
LimitedActions* = ref object
id*: int64
title*: string
text*: string
Tweet* = ref object
id*: string
threadId*: string
replyId*: string
id*: int64
threadId*: int64
replyId*: int64
user*: User
text*: string
time*: DateTime
@ -235,9 +228,8 @@ type
poll*: Option[Poll]
gif*: Option[Gif]
video*: Option[Video]
photos*: seq[Image]
photos*: seq[string]
birdwatch*: Option[BirdwatchNote]
limitedActions*: Option[LimitedActions]
Tweets* = seq[Tweet]
@ -297,7 +289,6 @@ type
enableDebug*: bool
proxy*: string
proxyAuth*: string
disableTid*: bool
cookieHeader*: string
xCsrfToken*: string

View File

@ -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://git.eir-nya.gay/eir/nitter/commit/" & hash
link = "https://git.eir-nya.gay/nitter/commit/" & hash
version = &"{date}-{hash}"
var aboutHtml: string

View File

@ -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=(@[Image(url:thumb)]))
renderHead(prefs, cfg, req, video=vidUrl, images=(@[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: string, provider: string): JsonNode =
proc generateOembed*(cfg: Config; typ, title, user, url: string): JsonNode =
%*{
"type": typ,
"version": "1.0",
"provider_name": provider, #cfg.title,
"provider_name": cfg.title,
"provider_url": getUrlPrefix(cfg),
"title": title,
"author_name": user,

View File

@ -29,18 +29,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
tdiv(class="nav-item right"):
icon "search", title="Search", href="/search"
if cfg.enableRss and rss.len > 0:
icon "rss-feed", title="RSS Feed", href=rss
#if cfg.enableRss and rss.len > 0:
#icon "rss-feed", title="RSS Feed", href=rss
icon "bird", title="Open in Twitter", href=canonical
a(href="https://liberapay.com/zedeus"): verbatim lp
icon "info", title="About", href="/about"
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[Image] = @[]; banner=""; ogTitle="";
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
rss=""; canonical=""; avatar=""; context=""; contextUrl="";
id=""; time: Option[DateTime] = none(DateTime); media="";
stats = ""): VNode =
id=""; time: Option[DateTime] = none(DateTime)): VNode =
var theme = prefs.theme.toTheme
if "theme" in req.params:
theme = req.params["theme"].toTheme
@ -55,19 +54,17 @@ 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=3")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
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="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="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png")
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,
@ -76,8 +73,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
if canonical.len > 0:
link(rel="canonical", href=canonical)
if cfg.enableRss and rss.len > 0:
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
#if cfg.enableRss and rss.len > 0:
#link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
if prefs.hlsPlayback:
script(src="/js/hls.light.min.js", `defer`="")
@ -86,8 +83,7 @@ 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:
# load custom js
script(src="/js/eirResources.js", `defer`="")
title:
@ -108,22 +104,18 @@ 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 = cfg.title
if time.isSome:
let isDiscord = req.headers.getOrDefault("User-Agent").toString().contains("Discordbot")
if time.isSome and not isDiscord:
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 = &"{siteName} • {formattedTime}"
if stats.len > 0:
siteName &= "\n" & stats
if isDiscord and stats.len > 0:
siteName &= "" & stats
siteName = &"{cfg.title} • {formattedTime}"
meta(property="og:site_name", content=siteName)
@ -132,16 +124,13 @@ 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 imageObj in images:
let
url = imageObj.url
preloadUrl = if "400x400" in url: getPicUrl(url)
for url in images:
let 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)
meta(property="twitter:card", content="summary_large_image")
@ -178,36 +167,24 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
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")
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/users/i/statuses/{id}"), 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?76162212", crossorigin="anonymous")
href="/fonts/fontello.woff2?21002321", crossorigin="anonymous")
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[Image] = @[]; banner=""; avatar=""; context="";
contextUrl=""; id=""; time: Option[DateTime] = none(DateTime);
media=""; stats=""): string =
images: seq[string] = @[]; banner=""; avatar=""; context="";
contextUrl=""; id=""; time: Option[DateTime] = none(DateTime)
): 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, context, contextUrl, id, time, media,
stats)
rss, canonical, avatar, context, contextUrl, id, time)
body:
renderNavbar(cfg, req, rss, canonical)

217
src/views/mastoapi.nim Normal file → Executable file
View File

@ -1,30 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, options, json, sequtils, times, math
import strutils, strformat, options, json, sequtils, times
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("&nbsp;", 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>"
var content = replaceUrls(tweet.text, prefs)
if tweet.quote.isSome():
let
quote = get(tweet.quote)
quoteContent = replaceUrls(quote.text, prefs, absolute=getUrlPrefix(cfg))
quoteContent = replaceUrls(quote.text, prefs)
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}"
@ -42,8 +26,8 @@ proc formatTweetForMastoAPI*(tweet: Tweet, cfg: Config, prefs: Prefs): string =
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>"
noteContent = replaceUrls(note.text, prefs)
content &= &"\n<blockquote><b>ⓘ {note.title}</b>\n{noteContent}</blockquote>"
result = content.replace("\n", "<br>")
@ -54,82 +38,39 @@ proc getActivityStream*(tweet: Tweet, cfg: Config, prefs: Prefs): JsonNode =
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"
for url in tweet.photos:
let image = getUrlPrefix(cfg) & getPicUrl(url)
var mediaObj = newJObject()
mediaObj["type"] = %"Image"
mediaObj["mediaType"] = %("image/" & filetype)
mediaObj["type"] = %"Document"
mediaObj["mediaType"] = %"image/png"
mediaObj["url"] = %image
mediaObj["name"] = %imageObj.description
mediaObj["name"] = newJNull() # FIXME a11y
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)
mediaObj["type"] = %"Document"
mediaObj["mediaType"] = %"video/mp4"
mediaObj["url"] = %vars[^1].url
mediaObj["name"] = newJNull() # FIXME a11y
media.add(mediaObj)
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)
let gif = get(tweet.gif)
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)
mediaObj["type"] = %"Document"
mediaObj["mediaType"] = %"video/mp4"
mediaObj["url"] = %(&"https://{gif.url}")
mediaObj["name"] = newJNull() # FIXME a11y
media.add(mediaObj)
var context: seq[JsonNode] = @[]
let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams"
@ -148,7 +89,7 @@ proc getActivityStream*(tweet: Tweet, cfg: Config, prefs: Prefs): JsonNode =
postJson["id"] = %tweetUrl
postJson["type"] = %"Note"
postJson["summary"] = newJNull()
if tweet.replyId.len != 0:
if tweet.replyId != 0:
let replyUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
postJson["inReplyTo"] = %replyUrl
postJson["inReplyToAtomUri"] = %replyUrl
@ -200,43 +141,6 @@ proc getActivityStream*(user: User, cfg: Config, prefs: Prefs): JsonNode =
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
@ -258,7 +162,7 @@ proc getActivityStream*(user: User, cfg: Config, prefs: Prefs): JsonNode =
userJson["memorial"] = %false
userJson["publicKey"] = newJNull()
userJson["tag"] = newJArray()
userJson["attachment"] = %fields
userJson["attachment"] = newJArray()
userJson["endpoints"] = newJObject()
userJson["icon"] = %*{
"type": "Image",
@ -272,70 +176,3 @@ proc getActivityStream*(user: User, cfg: Config, prefs: Prefs): JsonNode =
}
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

View File

@ -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", prefs)
linkUser(user, class="profile-card-username", prefs)
linkUser(user, class="profile-card-fullname")
linkUser(user, class="profile-card-username")
let following = isFollowing(user.username, prefs.following)
if not following:
buttonReferer "/follow/" & user.username, "Follow", path, "profile-card-follow-button"
@ -35,20 +35,6 @@ 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"):

View File

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat
import karax/[karaxdsl, vdom, vstyles]
import ".."/[types, utils, formatters]
import ".."/[types, utils]
const smallWebp* = "?name=small&format=webp"
@ -26,13 +26,11 @@ 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()
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")
icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account")
else:
text ""
proc linkUser*(user: User, class="", prefs: Prefs): VNode =
proc linkUser*(user: User, class=""): VNode =
let
isName = "username" notin class
href = "/" & user.username
@ -46,10 +44,6 @@ proc linkUser*(user: User, class="", prefs: Prefs): 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
@ -95,9 +89,9 @@ proc genDate*(pref, state: string): VNode =
input(name=pref, `type`="date", value=state)
icon "calendar"
proc genImg*(url: string; alt=""; class=""): VNode =
proc genImg*(url: string; class=""): VNode =
buildHtml():
img(src=getPicUrl(url), class=class, alt=alt)
img(src=getPicUrl(url), class=class, alt="")
proc getTabClass*(query: Query; tab: QueryKind): string =
if query.kind == tab: "tab-item active"

View File

@ -2,9 +2,6 @@
## 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: "
@ -28,25 +25,7 @@
#end proc
#
#proc getDescription(desc: string; cfg: Config): string =
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
Twitter feed for: ${desc}. Generated by ${cfg.hostname}
#end proc
#
#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
@ -56,13 +35,10 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
<p>${text.replace("\n", "<br>\n")}</p>
#if tweet.photos.len > 0:
# for photo in tweet.photos:
<img src="${urlPrefix}${getPicUrl(photo.url)}" style="max-width:250px;" />
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
# end for
#elif tweet.video.isSome:
<a href="${urlPrefix}${tweet.getLink}">
<br>Video<br>
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
</a>
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
#elif tweet.gif.isSome:
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
@ -75,18 +51,10 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
# end if
#end if
#if tweet.quote.isSome and get(tweet.quote).available:
# let quoteTweet = get(tweet.quote)
# let quoteLink = urlPrefix & getLink(quoteTweet)
# let quoteLink = getLink(get(tweet.quote))
<hr/>
<blockquote>
<b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
<p>
${renderRssTweet(quoteTweet, cfg)}
</p>
<footer>
— <cite><a href="${quoteLink}">${quoteLink}</a>
</footer>
</blockquote>
<p>Quoting: <a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
${renderRssTweet(get(tweet.quote), cfg)}
#end if
#end proc
#
@ -104,17 +72,12 @@ ${renderRssTweet(quoteTweet, 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
@ -145,9 +108,8 @@ ${renderRssTweet(quoteTweet, cfg)}
<width>128</width>
<height>128</height>
</image>
#let tweetsList = getTweetsWithPinned(profile)
#if tweetsList.len > 0:
${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
#if profile.tweets.content.len > 0:
${renderRssTweets(profile.tweets.content, cfg, userId=profile.user.id)}
#end if
</channel>
</rss>

View File

@ -28,18 +28,13 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
if thread.hasMore:
renderMoreReplies(thread)
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): 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 hasReplies and replies.bottom.len > 0:
if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
if replies.bottom.len > 0:
renderMore(Query(), replies.bottom, focus="#r")
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
@ -50,7 +45,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.len > 0 or not first.available):
if threadId != first.id and (first.replyId > 0 or not first.available):
renderEarlier(conv.before)
for i, tweet in conv.before.content:
renderTweet(tweet, prefs, path, index=i)
@ -75,6 +70,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, conv.tweet)
renderReplies(conv.replies, prefs, path)
renderToTop(focus="#m")

View File

@ -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[string]; it: Tweet): seq[Tweet] =
proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; 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", prefs)
linkUser(user, class="username", prefs)
linkUser(user, class="fullname")
linkUser(user, class="username")
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[string]
var retweets: seq[int64]
for thread in results.content:
if thread.len == 1:
let
tweet = thread[0]
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: ""
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
if retweetId in retweets or tweet.id in retweets or
tweet.pinned and prefs.hidePins:
continue
var hasThread = tweet.hasThread
if retweetId.len != 0 and tweet.retweet.isSome:
if retweetId != 0 and tweet.retweet.isSome:
retweets &= retweetId
hasThread = get(tweet.retweet).hasThread
renderTweet(tweet, prefs, path, showThread=hasThread)

View File

@ -15,40 +15,30 @@ 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"
span: icon "pin", "Pinned Tweet"
elif retweet.len > 0:
tdiv(class="retweet-header"):
span: icon "retweet", retweet & " retweeted"
tdiv(class="tweet-header"):
a(class="tweet-avatar", href=("/" & user.username)):
a(class="tweet-avatar", href=("/" & tweet.user.username)):
var size = "_bigger"
if not prefs.autoplayGifs and user.userPic.endsWith("gif"):
if not prefs.autoplayGifs and tweet.user.userPic.endsWith("gif"):
size = "_400x400"
genImg(user.getUserPic(size), class=prefs.getAvatarClass)
genImg(tweet.user.getUserPic(size), class=prefs.getAvatarClass)
tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"):
linkUser(user, class="fullname", prefs)
linkUser(user, class="username", prefs)
linkUser(tweet.user, class="fullname")
linkUser(tweet.user, class="username")
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]
@ -59,15 +49,12 @@ 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", title=photo.description):
tdiv(class="attachment image"):
let
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"
named = "name=" in photo
small = if named: photo else: photo & smallWebp
a(href=getOrigPicUrl(photo), class="still-image", target="_blank"):
genImg(small)
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
case playbackType
@ -193,16 +180,19 @@ func formatStat(stat: int): string =
if stat > 0: insertSep($stat, ',')
else: ""
proc renderStats(stats: TweetStats; tweet: Tweet): VNode =
proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode =
buildHtml(tdiv(class="tweet-stats")):
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)
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, ',')
proc renderReply(tweet: Tweet): VNode =
buildHtml(tdiv(class="replying-to")):
@ -230,8 +220,7 @@ proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="quote-media-container")):
if quote.photos.len > 0:
renderAlbum(quote)
if quote.video.isSome:
elif quote.video.isSome:
renderVideo(quote.video.get(), prefs, path)
elif quote.gif.isSome:
renderGif(quote.gif.get(), prefs)
@ -253,8 +242,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", prefs)
linkUser(quote.user, class="username", prefs)
linkUser(quote.user, class="fullname")
linkUser(quote.user, class="username")
span(class="tweet-date"):
a(href=getLink(quote), title=quote.getTime):
@ -282,11 +271,6 @@ 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
@ -332,6 +316,7 @@ 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
@ -353,11 +338,12 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if tweet.photos.len > 0:
renderAlbum(tweet)
if tweet.video.isSome:
elif 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())
@ -375,15 +361,12 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
renderMediaTags(tweet.mediaTags)
if not prefs.hideTweetStats:
renderStats(tweet.stats, tweet)
renderStats(tweet.stats, views, 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)