1
0
mirror of https://github.com/zedeus/nitter.git synced 2026-05-15 08:42:51 -04:00

44 Commits

Author SHA1 Message Date
Zed a15d1ce16b Add full support for tweet edit history
Fixes #700
2026-02-16 00:52:17 +01:00
Zed f257ce53ae Bump style version 2026-02-14 02:38:10 +01:00
Zed d45545cd53 Fix "Replying to" parsing 2026-02-14 02:24:24 +01:00
Zed 90b664ffb7 Make "Tweet unavailable" clickable and consistent 2026-02-14 02:19:53 +01:00
Zed cbce620692 Add dynamic-range-limit to prevent HDR jumpscares
Fixes #1345
2026-02-12 20:16:50 +01:00
Zed 05b6dd2a43 Add config options to enable subset of RSS feeds
Fixes #1363
2026-02-11 23:49:50 +01:00
Zed dcec1eb458 Fix invalid search link formatting 2026-02-10 22:53:54 +01:00
Zed 1c06a67afd Support image alt text
Fixes #559
2026-02-10 22:43:10 +01:00
Zed 40b1ba4e4e Bump css version 2026-02-09 22:08:09 +01:00
Zed b85e8c5d7d Support preference overrides using URL params
Fixes #186
2026-02-09 21:54:57 +01:00
Zed db36f75519 Support restoring preferences via new prefs param
Fixes #1352
Fixes #553
Fixes #249
2026-02-09 20:23:31 +01:00
Zed 5d28bd18c6 Add preference for configuring sticky navbar
Fixes #1354
2026-02-09 17:38:14 +01:00
Zed 0a6e79e626 Add bulk script create_sessions_browser.py 2026-02-09 02:55:07 +01:00
Zed 33dd9b6668 Fix /pic/ exploit 2026-02-06 20:44:37 +01:00
cmj a45227b883 Add user-agent to guest_token request (#1359) 2026-01-29 17:27:41 +01:00
yav a92e79ebc3 Fix the checkmark position (#1347)
Co-authored-by: yav <796176@protonmail.com>
2025-12-24 02:22:20 -05:00
jackyzy823 baeaf685d3 Make maxConcurrentReqs configurable (#1341) 2025-12-08 04:05:08 -05:00
Zed 51b54852dc Add preliminary support for nitter-proxy 2025-12-06 05:15:01 +01:00
Zed 663f5a52e1 Improve headers 2025-12-06 05:00:34 +01:00
Zed 17fc2628f9 Minor fix 2025-11-30 18:07:27 +01:00
Zed e741385828 Allow , in username to support multiple users
Fixes #1329
2025-11-30 18:06:22 +01:00
Zed 693a189462 Add heuristics to detect when to show "Load more"
Fixes #1328
2025-11-30 05:43:17 +01:00
Zed 7734d976f7 Add username validation
Fixes #1317
2025-11-30 04:12:38 +01:00
Zed a62ec9cbb4 Normalize headers 2025-11-30 03:58:43 +01:00
Zed 4b9aec6fde Use graphTweet for cookie sessions for now 2025-11-30 02:57:34 +01:00
Zed 064ec88080 Transition to ID-only RSS GUIDs on Dec 14, 2025
Fixes #447
2025-11-30 02:56:19 +01:00
Zed 71e65c84d7 Round video duration properly 2025-11-29 04:34:04 +01:00
Zed 436a873e4b Improve verified checkmark icon, css improvements 2025-11-29 04:33:49 +01:00
Zed 96ec75fc7f Add video duration to overlay
Fixes #498
2025-11-29 03:38:40 +01:00
Zed 7a08a9e132 Format css 2025-11-29 03:36:21 +01:00
Zed 31d210ca47 Add experimental x-client-transaction-id support (#1324)
* Add experimental x-client-transaction-id support

* Remove broken test
2025-11-29 01:13:08 +01:00
Zed dae68b4f13 Ignore null errors, they're internal API errors 2025-11-29 01:05:57 +01:00
Zed 8516ebe2b7 Fix 'key not found in object: expanded_url' error
Fixes #1318
2025-11-29 00:37:45 +01:00
Zed b83227aaf5 Implement temp fix for cookie sessions
Fixes #1319
2025-11-26 01:03:27 +01:00
Zed 404b06b5f3 Include "Video" and link for video tweets in RSS (#1315)
Fixes #836
2025-11-25 01:03:45 +01:00
Zed 2b922c049a Embed quote tweet in RSS (#1316)
Fixes #132
Closes #820
2025-11-25 01:02:45 +01:00
Zed 78101df2cc Style number input field 2025-11-24 23:04:25 +01:00
Zed 12bbddf204 Update search panel grid layout and animation 2025-11-24 23:04:25 +01:00
Zed 4979d07f2e Add spaces filter, remove broken filters 2025-11-24 23:04:25 +01:00
Zed f038b53fa2 Fix body font size to match x.com
Fixes #711
2025-11-24 23:04:25 +01:00
Zed 4748311f8d Fix intent/follow URL redirect
Fixes #629
2025-11-24 23:04:25 +01:00
Zed d47eb8f0eb Fix double slashes in url replacements
Fixes #520
2025-11-24 23:04:25 +01:00
Zed 1657eeb769 Fix canonical link causing redirects to Twitter
Fixes #526
2025-11-24 23:04:25 +01:00
Zed 25df682094 Expose username as HTML attribute
Fixes #551
2025-11-24 23:04:25 +01:00
69 changed files with 2497 additions and 1570 deletions
+19 -11
View File
@@ -1,31 +1,39 @@
[Server] [Server]
hostname = "nitter.net" # for generating links, change this to your own domain/ip hostname = "nitter.net" # for generating links, change this to your own domain/ip
title = "nitter" title = "nitter"
address = "0.0.0.0" address = "0.0.0.0"
port = 8080 port = 8080
https = false # disable to enable cookies when not using https https = false # disable to enable cookies when not using https
httpMaxConnections = 100 httpMaxConnections = 100
staticDir = "./public" staticDir = "./public"
[Cache] [Cache]
listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) listMinutes = 240 # how long to cache list info (not the tweets, so keep it high)
rssMinutes = 10 # how long to cache rss queries rssMinutes = 10 # how long to cache rss queries
redisHost = "localhost" # Change to "nitter-redis" if using docker-compose redisHost = "localhost" # Change to "nitter-redis" if using docker-compose
redisPort = 6379 redisPort = 6379
redisPassword = "" redisPassword = ""
redisConnections = 20 # minimum open connections in pool redisConnections = 20 # minimum open connections in pool
redisMaxConnections = 30 redisMaxConnections = 30
# new connections are opened when none are available, but if the pool size # new connections are opened when none are available, but if the pool size
# goes above this, they're closed when released. don't worry about this unless # goes above this, they're closed when released. don't worry about this unless
# you receive tons of requests per second # you receive tons of requests per second
[Config] [Config]
hmacKey = "secretkey" # random key for cryptographic signing of video urls hmacKey = "secretkey" # random key for cryptographic signing of video urls
base64Media = false # use base64 encoding for proxied media urls base64Media = false # use base64 encoding for proxied media urls
enableRSS = true # set this to false to disable RSS feeds enableRSS = true # master switch, set to false to disable all RSS feeds
enableDebug = false # enable request logs and debug endpoints (/.sessions) enableRSSUserTweets = true # /@user/rss
proxy = "" # http/https url, SOCKS proxies are not supported enableRSSUserReplies = true # /@user/with_replies/rss
enableRSSUserMedia = true # /@user/media/rss
enableRSSSearch = true # /search/rss and /@user/search/rss
enableRSSList = true # list RSS feeds
enableDebug = false # enable request logs and debug endpoints (/.sessions)
proxy = "" # http/https url, SOCKS proxies are not supported
proxyAuth = "" proxyAuth = ""
apiProxy = "" # nitter-proxy host, e.g. localhost:7000
disableTid = false # enable this if cookie-based auth is failing
maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions
# Change default preferences here, see src/prefs_impl.nim for a complete list # Change default preferences here, see src/prefs_impl.nim for a complete list
[Preferences] [Preferences]
+115 -30
View File
@@ -1,53 +1,138 @@
@font-face { @font-face {
font-family: 'fontello'; font-family: "fontello";
src: url('/fonts/fontello.eot?61663884'); src: url("/fonts/fontello.eot?77185648");
src: url('/fonts/fontello.eot?61663884#iefix') format('embedded-opentype'), src:
url('/fonts/fontello.woff2?61663884') format('woff2'), url("/fonts/fontello.eot?77185648#iefix") format("embedded-opentype"),
url('/fonts/fontello.woff?61663884') format('woff'), url("/fonts/fontello.woff2?77185648") format("woff2"),
url('/fonts/fontello.ttf?61663884') format('truetype'), url("/fonts/fontello.woff?77185648") format("woff"),
url('/fonts/fontello.svg?61663884#fontello') format('svg'); url("/fonts/fontello.ttf?77185648") format("truetype"),
url("/fonts/fontello.svg?77185648#fontello") format("svg");
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
[class^="icon-"]:before, [class*=" icon-"]:before {
[class^="icon-"]:before,
[class*=" icon-"]:before {
font-family: "fontello"; font-family: "fontello";
font-style: normal; font-style: normal;
font-weight: normal; font-weight: normal;
speak: never; speak: never;
display: inline-block; display: inline-block;
text-decoration: inherit; text-decoration: inherit;
width: 1em; width: 1em;
margin-right: 0.2em;
text-align: center; text-align: center;
/* For safety - reset parent styles, that can break glyph codes*/ /* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal; font-variant: normal;
text-transform: none; text-transform: none;
/* fix buttons height, for twitter bootstrap */ /* fix buttons height, for twitter bootstrap */
line-height: 1em; line-height: 1em;
/* Font smoothing. That was taken from TWBS */ /* Font smoothing. That was taken from TWBS */
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.icon-views:before { content: '\e800'; } /* '' */ .icon-views:before {
.icon-heart:before { content: '\e801'; } /* '' */ content: "\e800";
.icon-quote:before { content: '\e802'; } /* '' */ }
.icon-comment:before { content: '\e803'; } /* '' */
.icon-ok:before { content: '\e804'; } /* '' */ /* '' */
.icon-play:before { content: '\e805'; } /* '' */ .icon-heart:before {
.icon-link:before { content: '\e806'; } /* '' */ content: "\e801";
.icon-calendar:before { content: '\e807'; } /* '' */ }
.icon-location:before { content: '\e808'; } /* '' */
.icon-picture:before { content: '\e809'; } /* '' */ /* '' */
.icon-lock:before { content: '\e80a'; } /* '' */ .icon-quote:before {
.icon-down:before { content: '\e80b'; } /* '' */ content: "\e802";
.icon-retweet:before { content: '\e80c'; } /* '' */ }
.icon-search:before { content: '\e80d'; } /* '' */
.icon-pin:before { content: '\e80e'; } /* '' */ /* '' */
.icon-cog:before { content: '\e80f'; } /* '' */ .icon-comment:before {
.icon-rss:before { content: '\e810'; } /* '' */ content: "\e803";
.icon-info:before { content: '\f128'; } /* '' */ }
.icon-bird:before { content: '\f309'; } /* '' */
/* '' */
.icon-play:before {
content: "\e805";
}
/* '' */
.icon-link:before {
content: "\e806";
}
/* '' */
.icon-calendar:before {
content: "\e807";
}
/* '' */
.icon-location:before {
content: "\e808";
}
/* '' */
.icon-picture:before {
content: "\e809";
}
/* '' */
.icon-lock:before {
content: "\e80a";
}
/* '' */
.icon-down:before {
content: "\e80b";
}
/* '' */
.icon-retweet:before {
content: "\e80c";
}
/* '' */
.icon-search:before {
content: "\e80d";
}
/* '' */
.icon-pin:before {
content: "\e80e";
}
/* '' */
.icon-cog:before {
content: "\e80f";
}
/* '' */
.icon-rss:before {
content: "\e810";
}
/* '' */
.icon-ok:before {
content: "\e811";
}
/* '' */
.icon-circle:before {
content: "\f111";
}
/* '' */
.icon-info:before {
content: "\f128";
}
/* '' */
.icon-bird:before {
content: "\f309";
}
/* '' */
Binary file not shown.
+4 -2
View File
@@ -14,8 +14,6 @@
<glyph glyph-name="comment" unicode="&#xe803;" 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="comment" unicode="&#xe803;" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
<glyph glyph-name="ok" unicode="&#xe804;" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" />
<glyph glyph-name="play" unicode="&#xe805;" 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="play" unicode="&#xe805;" 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="link" unicode="&#xe806;" 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="link" unicode="&#xe806;" 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" />
@@ -40,6 +38,10 @@
<glyph glyph-name="rss" unicode="&#xe810;" 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="rss" unicode="&#xe810;" 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="ok" unicode="&#xe811;" d="M933 534q0-22-16-38l-404-404-76-76q-16-15-38-15t-38 15l-76 76-202 202q-15 16-15 38t15 38l76 76q16 16 38 16t38-16l164-165 366 367q16 16 38 16t38-16l76-76q16-15 16-38z" horiz-adv-x="1000" />
<glyph glyph-name="circle" unicode="&#xf111;" d="M857 350q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
<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="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="&#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" /> <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" />

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+76 -60
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar, tables import asyncdispatch, httpclient, strutils, sequtils, sugar
import packedjson import packedjson
import types, query, formatters, consts, apiutils, parser import types, query, formatters, consts, apiutils, parser
import experimental/parser as newParser import experimental/parser as newParser
@@ -11,88 +11,92 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
if fieldToggles.len > 0: if fieldToggles.len > 0:
result.add ("fieldToggles", fieldToggles) result.add ("fieldToggles", fieldToggles)
proc mediaUrl(id: string; cursor: string): SessionAwareUrl = proc apiUrl(endpoint, variables: string; fieldToggles = ""): ApiUrl =
let return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles))
cookieVariables = userMediaVariables % [id, cursor]
oauthVariables = restIdVariables % [id, cursor] proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq =
result = SessionAwareUrl( let url = apiUrl(endpoint, variables, fieldToggles)
cookieUrl: graphUserMedia ? genParams(cookieVariables), return ApiReq(cookie: url, oauth: url)
oauthUrl: graphUserMediaV2 ? genParams(oauthVariables)
proc mediaUrl(id: string; cursor: string): ApiReq =
result = ApiReq(
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor]),
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor])
) )
proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = proc userTweetsUrl(id: string; cursor: string): ApiReq =
let result = ApiReq(
cookieVariables = userTweetsVariables % [id, cursor] # cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
oauthVariables = restIdVariables % [id, cursor] oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor])
result = SessionAwareUrl(
# cookieUrl: graphUserTweets ? genParams(cookieVariables, fieldToggles),
oauthUrl: graphUserTweetsV2 ? genParams(oauthVariables)
) )
# might change this in the future pending testing # might change this in the future pending testing
result.cookieUrl = result.oauthUrl result.cookie = result.oauth
proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
let let cookieVars = userTweetsAndRepliesVars % [id, cursor]
cookieVariables = userTweetsAndRepliesVariables % [id, cursor] result = ApiReq(
oauthVariables = restIdVariables % [id, cursor] cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
result = SessionAwareUrl( oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor])
cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVariables, fieldToggles),
oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVariables)
) )
proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = proc tweetDetailUrl(id: string; cursor: string): ApiReq =
let let cookieVars = tweetDetailVars % [id, cursor]
cookieVariables = tweetDetailVariables % [id, cursor] result = ApiReq(
oauthVariables = tweetVariables % [id, cursor] # cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
result = SessionAwareUrl( cookie: apiUrl(graphTweet, tweetVars % [id, cursor]),
cookieUrl: graphTweetDetail ? genParams(cookieVariables, tweetDetailFieldToggles), oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
oauthUrl: graphTweet ? genParams(oauthVariables) )
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.} = proc getGraphUser*(username: string): Future[User] {.async.} =
if username.len == 0: return if username.len == 0: return
let let js = await fetchRaw(userUrl(username))
url = graphUser ? genParams("""{"screen_name": "$1"}""" % username)
js = await fetchRaw(url, Api.userScreenName)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserById*(id: string): Future[User] {.async.} = proc getGraphUserById*(id: string): Future[User] {.async.} =
if id.len == 0 or id.any(c => not c.isDigit): return if id.len == 0 or id.any(c => not c.isDigit): return
let let
url = graphUserById ? genParams("""{"rest_id": "$1"}""" % id) url = apiReq(graphUserById, """{"rest_id": "$1"}""" % id)
js = await fetchRaw(url, Api.userRestId) js = await fetchRaw(url)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
js = case kind url = case kind
of TimelineKind.tweets: of TimelineKind.tweets: userTweetsUrl(id, cursor)
await fetch(userTweetsUrl(id, cursor), Api.userTweets) of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
of TimelineKind.replies: of TimelineKind.media: mediaUrl(id, cursor)
await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies) js = await fetch(url)
of TimelineKind.media:
await fetch(mediaUrl(id, cursor), Api.userMedia)
result = parseGraphTimeline(js, after) result = parseGraphTimeline(js, after)
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
url = graphListTweets ? genParams(restIdVariables % [id, cursor]) url = apiReq(graphListTweets, restIdVars % [id, cursor])
result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets js = await fetch(url)
result = parseGraphTimeline(js, after).tweets
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
let let
variables = %*{"screenName": name, "listSlug": list} variables = %*{"screenName": name, "listSlug": list}
url = graphListBySlug ? genParams($variables) url = apiReq(graphListBySlug, $variables)
result = parseGraphList(await fetch(url, Api.listBySlug)) js = await fetch(url)
result = parseGraphList(js)
proc getGraphList*(id: string): Future[List] {.async.} = proc getGraphList*(id: string): Future[List] {.async.} =
let let
url = graphListById ? genParams("""{"listId": "$1"}""" % id) url = apiReq(graphListById, """{"listId": "$1"}""" % id)
result = parseGraphList(await fetch(url, Api.list)) js = await fetch(url)
result = parseGraphList(js)
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
if list.id.len == 0: return if list.id.len == 0: return
@@ -106,22 +110,23 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
} }
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
let url = graphListMembers ? genParams($variables) let
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) url = apiReq(graphListMembers, $variables)
js = await fetchRaw(url)
result = parseGraphListMembers(js, after)
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
variables = """{"rest_id": "$1"}""" % id url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
params = {"variables": variables, "features": gqlFeatures} js = await fetch(url)
js = await fetch(graphTweetResult ? params, Api.tweetResult)
result = parseGraphTweetResult(js) result = parseGraphTweetResult(js)
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail) js = await fetch(tweetDetailUrl(id, cursor))
result = parseGraphConversation(js, id) result = parseGraphConversation(js, id)
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
@@ -133,6 +138,13 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
if after.len > 0: if after.len > 0:
result.replies = await getReplies(id, after) result.replies = await getReplies(id, after)
proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} =
if id.len == 0: return
let
url = apiReq(graphTweetEditHistory, tweetEditHistoryVars % id)
js = await fetch(url)
result = parseGraphEditHistory(js, id)
proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
let q = genQueryParam(query) let q = genQueryParam(query)
if q.len == 0 or q == emptyQuery: if q.len == 0 or q == emptyQuery:
@@ -150,8 +162,10 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
} }
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
let url = graphSearchTimeline ? genParams($variables) let
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after) url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[Tweets](js, after)
result.query = query result.query = query
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
@@ -172,13 +186,15 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
variables["cursor"] = % after variables["cursor"] = % after
result.beginning = false result.beginning = false
let url = graphSearchTimeline ? genParams($variables) let
result = parseGraphSearch[User](await fetch(url, Api.search), after) url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[User](js, after)
result.query = query result.query = query
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
if id.len == 0: return if id.len == 0: return
let js = await fetch(mediaUrl(id, ""), Api.userMedia) let js = await fetch(mediaUrl(id, ""))
result = parseGraphPhotoRail(js) result = parseGraphPhotoRail(js)
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
+66 -40
View File
@@ -1,16 +1,37 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
import jsony, packedjson, zippy, oauth1 import jsony, packedjson, zippy, oauth1
import types, auth, consts, parserutils, http_pool import types, auth, consts, parserutils, http_pool, tid
import experimental/types/common import experimental/types/common
const const
rlRemaining = "x-rate-limit-remaining" rlRemaining = "x-rate-limit-remaining"
rlReset = "x-rate-limit-reset" rlReset = "x-rate-limit-reset"
rlLimit = "x-rate-limit-limit" rlLimit = "x-rate-limit-limit"
errorsToSkip = {doesntExist, tweetNotFound, timeout, unauthorized, badRequest} errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
var pool: HttpPool var
pool: HttpPool
disableTid: bool
apiProxy: string
proc setDisableTid*(disable: bool) =
disableTid = disable
proc setApiProxy*(url: string) =
if url.len > 0:
apiProxy = url.strip(chars={'/'}) & "/"
if "http" notin apiProxy:
apiProxy = "http://" & apiProxy
proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri =
case sessionKind
of oauth:
let o = req.oauth
parseUri("https://api.x.com/graphql") / o.endpoint ? o.params
of cookie:
let c = req.cookie
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params
proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
let let
@@ -32,31 +53,41 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
proc getCookieHeader(authToken, ct0: string): string = proc getCookieHeader(authToken, ct0: string): string =
"auth_token=" & authToken & "; ct0=" & ct0 "auth_token=" & authToken & "; ct0=" & ct0
proc genHeaders*(session: Session, url: string): HttpHeaders = proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
result = newHttpHeaders({ result = newHttpHeaders({
"connection": "keep-alive", "accept": "*/*",
"content-type": "application/json",
"x-twitter-active-user": "yes",
"x-twitter-client-language": "en",
"authority": "api.x.com",
"accept-encoding": "gzip", "accept-encoding": "gzip",
"accept-language": "en-US,en;q=0.9", "accept-language": "en-US,en;q=0.9",
"accept": "*/*", "connection": "keep-alive",
"DNT": "1", "content-type": "application/json",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" "origin": "https://x.com",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
"x-twitter-active-user": "yes",
"x-twitter-client-language": "en",
"priority": "u=1, i"
}) })
case session.kind case session.kind
of SessionKind.oauth: of SessionKind.oauth:
result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
of SessionKind.cookie: of SessionKind.cookie:
result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
result["x-twitter-auth-type"] = "OAuth2Session" result["x-twitter-auth-type"] = "OAuth2Session"
result["x-csrf-token"] = session.ct0 result["x-csrf-token"] = session.ct0
result["cookie"] = getCookieHeader(session.authToken, session.ct0) result["cookie"] = getCookieHeader(session.authToken, session.ct0)
result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24""""
result["sec-ch-ua-mobile"] = "?0"
result["sec-ch-ua-platform"] = "Windows"
result["sec-fetch-dest"] = "empty"
result["sec-fetch-mode"] = "cors"
result["sec-fetch-site"] = "same-site"
if disableTid:
result["authorization"] = bearerToken2
else:
result["authorization"] = bearerToken
result["x-client-transaction-id"] = await genTid(url.path)
proc getAndValidateSession*(api: Api): Future[Session] {.async.} = proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} =
result = await getSession(api) result = await getSession(req)
case result.kind case result.kind
of SessionKind.oauth: of SessionKind.oauth:
if result.oauthToken.len == 0: if result.oauthToken.len == 0:
@@ -73,9 +104,13 @@ template fetchImpl(result, fetchBody) {.dirty.} =
try: try:
var resp: AsyncResponse var resp: AsyncResponse
pool.use(genHeaders(session, $url)): pool.use(await genHeaders(session, url)):
template getContent = template getContent =
resp = await c.get($url) # TODO: this is a temporary simple implementation
if apiProxy.len > 0:
resp = await c.get(($url).replace("https://", apiProxy))
else:
resp = await c.get($url)
result = await resp.body result = await resp.body
getContent() getContent()
@@ -89,7 +124,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
remaining = parseInt(resp.headers[rlRemaining]) remaining = parseInt(resp.headers[rlRemaining])
reset = parseInt(resp.headers[rlReset]) reset = parseInt(resp.headers[rlReset])
limit = parseInt(resp.headers[rlLimit]) limit = parseInt(resp.headers[rlLimit])
session.setRateLimit(api, remaining, reset, limit) session.setRateLimit(req, remaining, reset, limit)
if result.len > 0: if result.len > 0:
if resp.headers.getOrDefault("content-encoding") == "gzip": if resp.headers.getOrDefault("content-encoding") == "gzip":
@@ -98,24 +133,22 @@ template fetchImpl(result, fetchBody) {.dirty.} =
if result.startsWith("{\"errors"): if result.startsWith("{\"errors"):
let errors = result.fromJson(Errors) let errors = result.fromJson(Errors)
if errors notin errorsToSkip: if errors notin errorsToSkip:
echo "Fetch error, API: ", api, ", errors: ", errors echo "Fetch error, API: ", url.path, ", errors: ", errors
if errors in {expiredToken, badToken, locked}: if errors in {expiredToken, badToken, locked}:
invalidate(session) invalidate(session)
raise rateLimitError() raise rateLimitError()
elif errors in {rateLimited}: elif errors in {rateLimited}:
# rate limit hit, resets after 24 hours # rate limit hit, resets after 24 hours
setLimited(session, api) setLimited(session, req)
raise rateLimitError() raise rateLimitError()
elif result.startsWith("429 Too Many Requests"): elif result.startsWith("429 Too Many Requests"):
echo "[sessions] 429 error, API: ", api, ", session: ", session.pretty echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty
session.apis[api].remaining = 0
# rate limit hit, resets after the 15 minute window
raise rateLimitError() raise rateLimitError()
fetchBody fetchBody
if resp.status == $Http400: if resp.status == $Http400:
echo "ERROR 400, ", api, ": ", result echo "ERROR 400, ", url.path, ": ", result
raise newException(InternalError, $url) raise newException(InternalError, $url)
except InternalError as e: except InternalError as e:
raise e raise e
@@ -134,19 +167,16 @@ template retry(bod) =
try: try:
bod bod
except RateLimitError: except RateLimitError:
echo "[sessions] Rate limited, retrying ", api, " request..." echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, " request..."
bod bod
proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} = proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
retry: retry:
var var
body: string body: string
session = await getAndValidateSession(api) session = await getAndValidateSession(req)
when url is SessionAwareUrl: let url = req.toUrl(session.kind)
let url = case session.kind
of SessionKind.oauth: url.oauthUrl
of SessionKind.cookie: url.cookieUrl
fetchImpl body: fetchImpl body:
if body.startsWith('{') or body.startsWith('['): if body.startsWith('{') or body.startsWith('['):
@@ -157,19 +187,15 @@ proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} =
let error = result.getError let error = result.getError
if error != null and error notin errorsToSkip: if error != null and error notin errorsToSkip:
echo "Fetch error, API: ", api, ", error: ", error echo "Fetch error, API: ", url.path, ", error: ", error
if error in {expiredToken, badToken, locked}: if error in {expiredToken, badToken, locked}:
invalidate(session) invalidate(session)
raise rateLimitError() raise rateLimitError()
proc fetchRaw*(url: Uri | SessionAwareUrl; api: Api): Future[string] {.async.} = proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
retry: retry:
var session = await getAndValidateSession(api) var session = await getAndValidateSession(req)
let url = req.toUrl(session.kind)
when url is SessionAwareUrl:
let url = case session.kind
of SessionKind.oauth: url.oauthUrl
of SessionKind.cookie: url.cookieUrl
fetchImpl result: fetchImpl result:
if not (result.startsWith('{') or result.startsWith('[')): if not (result.startsWith('{') or result.startsWith('[')):
+27 -16
View File
@@ -1,20 +1,28 @@
#SPDX-License-Identifier: AGPL-3.0-only #SPDX-License-Identifier: AGPL-3.0-only
import std/[asyncdispatch, times, json, random, sequtils, strutils, tables, packedsets, os] import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os]
import types import types, consts
import experimental/parser/session import experimental/parser/session
# max requests at a time per session to avoid race conditions const hourInSeconds = 60 * 60
const
maxConcurrentReqs = 2
hourInSeconds = 60 * 60
var var
sessionPool: seq[Session] sessionPool: seq[Session]
enableLogging = false enableLogging = false
# max requests at a time per session to avoid race conditions
maxConcurrentReqs = 2
proc setMaxConcurrentReqs*(reqs: int) =
if reqs > 0:
maxConcurrentReqs = reqs
template log(str: varargs[string, `$`]) = template log(str: varargs[string, `$`]) =
echo "[sessions] ", str.join("") echo "[sessions] ", str.join("")
proc endpoint(req: ApiReq; session: Session): string =
case session.kind
of oauth: req.oauth.endpoint
of cookie: req.cookie.endpoint
proc pretty*(session: Session): string = proc pretty*(session: Session): string =
if session.isNil: if session.isNil:
return "<null>" return "<null>"
@@ -122,11 +130,12 @@ proc rateLimitError*(): ref RateLimitError =
proc noSessionsError*(): ref NoSessionsError = proc noSessionsError*(): ref NoSessionsError =
newException(NoSessionsError, "no sessions available") newException(NoSessionsError, "no sessions available")
proc isLimited(session: Session; api: Api): bool = proc isLimited(session: Session; req: ApiReq): bool =
if session.isNil: if session.isNil:
return true return true
if session.limited and api != Api.userTweets: let api = req.endpoint(session)
if session.limited and api != graphUserTweetsV2:
if (epochTime().int - session.limitedAt) > hourInSeconds: if (epochTime().int - session.limitedAt) > hourInSeconds:
session.limited = false session.limited = false
log "resetting limit: ", session.pretty log "resetting limit: ", session.pretty
@@ -140,8 +149,8 @@ proc isLimited(session: Session; api: Api): bool =
else: else:
return false return false
proc isReady(session: Session; api: Api): bool = proc isReady(session: Session; req: ApiReq): bool =
not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api)) not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req))
proc invalidate*(session: var Session) = proc invalidate*(session: var Session) =
if session.isNil: return if session.isNil: return
@@ -156,24 +165,26 @@ proc release*(session: Session) =
if session.isNil: return if session.isNil: return
dec session.pending dec session.pending
proc getSession*(api: Api): Future[Session] {.async.} = proc getSession*(req: ApiReq): Future[Session] {.async.} =
for i in 0 ..< sessionPool.len: for i in 0 ..< sessionPool.len:
if result.isReady(api): break if result.isReady(req): break
result = sessionPool.sample() result = sessionPool.sample()
if not result.isNil and result.isReady(api): if not result.isNil and result.isReady(req):
inc result.pending inc result.pending
else: else:
log "no sessions available for API: ", api log "no sessions available for API: ", req.cookie.endpoint
raise noSessionsError() raise noSessionsError()
proc setLimited*(session: Session; api: Api) = proc setLimited*(session: Session; req: ApiReq) =
let api = req.endpoint(session)
session.limited = true session.limited = true
session.limitedAt = epochTime().int session.limitedAt = epochTime().int
log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty
proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) = proc setRateLimit*(session: Session; req: ApiReq; remaining, reset, limit: int) =
# avoid undefined behavior in race conditions # avoid undefined behavior in race conditions
let api = req.endpoint(session)
if api in session.apis: if api in session.apis:
let rateLimit = session.apis[api] let rateLimit = session.apis[api]
if rateLimit.reset >= reset and rateLimit.remaining < remaining: if rateLimit.reset >= reset and rateLimit.remaining < remaining:
+11 -2
View File
@@ -13,6 +13,8 @@ proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
proc getConfig*(path: string): (Config, parseCfg.Config) = proc getConfig*(path: string): (Config, parseCfg.Config) =
var cfg = loadConfig(path) var cfg = loadConfig(path)
let masterRss = cfg.get("Config", "enableRSS", true)
let conf = Config( let conf = Config(
# Server # Server
address: cfg.get("Server", "address", "0.0.0.0"), address: cfg.get("Server", "address", "0.0.0.0"),
@@ -37,10 +39,17 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
hmacKey: cfg.get("Config", "hmacKey", "secretkey"), hmacKey: cfg.get("Config", "hmacKey", "secretkey"),
base64Media: cfg.get("Config", "base64Media", false), base64Media: cfg.get("Config", "base64Media", false),
minTokens: cfg.get("Config", "tokenCount", 10), minTokens: cfg.get("Config", "tokenCount", 10),
enableRss: cfg.get("Config", "enableRSS", true), enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true),
enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true),
enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true),
enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true),
enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true),
enableDebug: cfg.get("Config", "enableDebug", false), enableDebug: cfg.get("Config", "enableDebug", false),
proxy: cfg.get("Config", "proxy", ""), proxy: cfg.get("Config", "proxy", ""),
proxyAuth: cfg.get("Config", "proxyAuth", "") proxyAuth: cfg.get("Config", "proxyAuth", ""),
apiProxy: cfg.get("Config", "apiProxy", ""),
disableTid: cfg.get("Config", "disableTid", false),
maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2)
) )
return (conf, cfg) return (conf, cfg)
+70 -56
View File
@@ -1,62 +1,96 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import uri, strutils import strutils
const const
consumerKey* = "3nVuSoBZnx6U4vzUxf5w" consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys" consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
gql = parseUri("https://api.x.com") / "graphql" graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"
graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
graphUser* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" graphUserById* = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"
graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" graphUserTweets* = "oRJs8SLCRNRbQzuZG93_oA/UserTweets"
graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets" graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia" graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
graphUserMediaV2* = gql / "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
graphTweet* = gql / "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
graphTweetResult* = gql / "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" graphTweetEditHistory* = "upS9teTSG45aljmP9oTuXA/TweetEditHistory"
graphSearchTimeline* = gql / "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
graphListTweets* = gql / "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
gqlFeatures* = """{ gqlFeatures* = """{
"android_ad_formats_media_component_render_overlay_enabled": false, "android_ad_formats_media_component_render_overlay_enabled": false,
"android_graphql_skip_api_media_color_palette": false, "android_graphql_skip_api_media_color_palette": false,
"android_professional_link_spotlight_display_enabled": false, "android_professional_link_spotlight_display_enabled": false,
"articles_api_enabled": false,
"articles_preview_enabled": true,
"blue_business_profile_image_shape_enabled": false, "blue_business_profile_image_shape_enabled": false,
"c9s_tweet_anatomy_moderator_badge_enabled": true,
"commerce_android_shop_module_enabled": false, "commerce_android_shop_module_enabled": false,
"communities_web_enable_tweet_community_results_fetch": true,
"creator_subscriptions_quote_tweet_preview_enabled": false,
"creator_subscriptions_subscription_count_enabled": false, "creator_subscriptions_subscription_count_enabled": false,
"creator_subscriptions_tweet_preview_api_enabled": true, "creator_subscriptions_tweet_preview_api_enabled": true,
"freedom_of_speech_not_reach_fetch_enabled": true, "freedom_of_speech_not_reach_fetch_enabled": true,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true, "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
"grok_android_analyze_trend_fetch_enabled": false,
"grok_translations_community_note_auto_translation_is_enabled": false,
"grok_translations_community_note_translation_is_enabled": false,
"grok_translations_post_auto_translation_is_enabled": false,
"grok_translations_timeline_user_bio_auto_translation_is_enabled": false,
"hidden_profile_likes_enabled": false, "hidden_profile_likes_enabled": false,
"highlights_tweets_tab_ui_enabled": false, "highlights_tweets_tab_ui_enabled": false,
"immersive_video_status_linkable_timestamps": false,
"interactive_text_enabled": false, "interactive_text_enabled": false,
"longform_notetweets_consumption_enabled": true, "longform_notetweets_consumption_enabled": true,
"longform_notetweets_inline_media_enabled": true, "longform_notetweets_inline_media_enabled": true,
"longform_notetweets_rich_text_read_enabled": true,
"longform_notetweets_richtext_consumption_enabled": true, "longform_notetweets_richtext_consumption_enabled": true,
"longform_notetweets_rich_text_read_enabled": true,
"mobile_app_spotlight_module_enabled": false, "mobile_app_spotlight_module_enabled": false,
"payments_enabled": false,
"post_ctas_fetch_enabled": true,
"premium_content_api_read_enabled": false,
"profile_label_improvements_pcf_label_in_post_enabled": true,
"profile_label_improvements_pcf_label_in_profile_enabled": false,
"responsive_web_edit_tweet_api_enabled": true, "responsive_web_edit_tweet_api_enabled": true,
"responsive_web_enhance_cards_enabled": false, "responsive_web_enhance_cards_enabled": false,
"responsive_web_graphql_exclude_directive_enabled": true, "responsive_web_graphql_exclude_directive_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"responsive_web_graphql_timeline_navigation_enabled": true, "responsive_web_graphql_timeline_navigation_enabled": true,
"responsive_web_grok_analysis_button_from_backend": true,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_analyze_post_followups_enabled": true,
"responsive_web_grok_annotations_enabled": true,
"responsive_web_grok_community_note_auto_translation_is_enabled": false,
"responsive_web_grok_image_annotation_enabled": true,
"responsive_web_grok_imagine_annotation_enabled": true,
"responsive_web_grok_share_attachment_enabled": true,
"responsive_web_grok_show_grok_translated_post": false,
"responsive_web_jetfuel_frame": true,
"responsive_web_media_download_video_enabled": false, "responsive_web_media_download_video_enabled": false,
"responsive_web_profile_redirect_enabled": false,
"responsive_web_text_conversations_enabled": false, "responsive_web_text_conversations_enabled": false,
"responsive_web_twitter_article_notes_tab_enabled": false,
"responsive_web_twitter_article_tweet_consumption_enabled": true, "responsive_web_twitter_article_tweet_consumption_enabled": true,
"unified_cards_destination_url_params_enabled": false,
"responsive_web_twitter_blue_verified_badge_is_enabled": true, "responsive_web_twitter_blue_verified_badge_is_enabled": true,
"rweb_lists_timeline_redesign_enabled": true, "rweb_lists_timeline_redesign_enabled": true,
"rweb_tipjar_consumption_enabled": true,
"rweb_video_screen_enabled": false,
"rweb_video_timestamps_enabled": false,
"spaces_2022_h2_clipping": true, "spaces_2022_h2_clipping": true,
"spaces_2022_h2_spaces_communities": true, "spaces_2022_h2_spaces_communities": true,
"standardized_nudges_misinfo": true, "standardized_nudges_misinfo": true,
"subscriptions_feature_can_gift_premium": false,
"subscriptions_verification_info_enabled": true, "subscriptions_verification_info_enabled": true,
"subscriptions_verification_info_is_identity_verified_enabled": false,
"subscriptions_verification_info_reason_enabled": true, "subscriptions_verification_info_reason_enabled": true,
"subscriptions_verification_info_verified_since_enabled": true, "subscriptions_verification_info_verified_since_enabled": true,
"super_follow_badge_privacy_enabled": false, "super_follow_badge_privacy_enabled": false,
@@ -67,40 +101,14 @@ const
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true, "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
"tweetypie_unmention_optimization_enabled": false, "tweetypie_unmention_optimization_enabled": false,
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false, "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
"unified_cards_destination_url_params_enabled": false,
"verified_phone_label_enabled": false, "verified_phone_label_enabled": false,
"vibe_api_enabled": false, "vibe_api_enabled": false,
"view_counts_everywhere_api_enabled": true, "view_counts_everywhere_api_enabled": true,
"premium_content_api_read_enabled": false, "hidden_profile_subscriptions_enabled": false
"communities_web_enable_tweet_community_results_fetch": true,
"responsive_web_jetfuel_frame": true,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_image_annotation_enabled": true,
"responsive_web_grok_imagine_annotation_enabled": true,
"rweb_tipjar_consumption_enabled": true,
"profile_label_improvements_pcf_label_in_post_enabled": true,
"creator_subscriptions_quote_tweet_preview_enabled": false,
"c9s_tweet_anatomy_moderator_badge_enabled": true,
"responsive_web_grok_analyze_post_followups_enabled": true,
"rweb_video_timestamps_enabled": false,
"responsive_web_grok_share_attachment_enabled": true,
"articles_preview_enabled": true,
"immersive_video_status_linkable_timestamps": false,
"articles_api_enabled": false,
"responsive_web_grok_analysis_button_from_backend": true,
"rweb_video_screen_enabled": false,
"payments_enabled": false,
"responsive_web_profile_redirect_enabled": false,
"responsive_web_grok_show_grok_translated_post": false,
"responsive_web_grok_community_note_auto_translation_is_enabled": false,
"profile_label_improvements_pcf_label_in_profile_enabled": false,
"grok_android_analyze_trend_fetch_enabled": false,
"grok_translations_community_note_auto_translation_is_enabled": false,
"grok_translations_post_auto_translation_is_enabled": false,
"grok_translations_community_note_translation_is_enabled": false,
"grok_translations_timeline_user_bio_auto_translation_is_enabled": false
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
tweetVariables* = """{ tweetVars* = """{
"postId": "$1", "postId": "$1",
$2 $2
"includeHasBirdwatchNotes": false, "includeHasBirdwatchNotes": false,
@@ -110,7 +118,7 @@ const
"withV2Timeline": true "withV2Timeline": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
tweetDetailVariables* = """{ tweetDetailVars* = """{
"focalTweetId": "$1", "focalTweetId": "$1",
$2 $2
"referrer": "profile", "referrer": "profile",
@@ -123,12 +131,17 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
restIdVariables* = """{ tweetEditHistoryVars* = """{
"tweetId": "$1",
"withQuickPromoteEligibilityTweetFields": true
}""".replace(" ", "").replace("\n", "")
restIdVars* = """{
"rest_id": "$1", $2 "rest_id": "$1", $2
"count": 20 "count": 20
}""" }"""
userMediaVariables* = """{ userMediaVars* = """{
"userId": "$1", $2 "userId": "$1", $2
"count": 20, "count": 20,
"includePromotedContent": false, "includePromotedContent": false,
@@ -137,7 +150,7 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
userTweetsVariables* = """{ userTweetsVars* = """{
"userId": "$1", $2 "userId": "$1", $2
"count": 20, "count": 20,
"includePromotedContent": false, "includePromotedContent": false,
@@ -145,7 +158,7 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
userTweetsAndRepliesVariables* = """{ userTweetsAndRepliesVars* = """{
"userId": "$1", $2 "userId": "$1", $2
"count": 20, "count": 20,
"includePromotedContent": false, "includePromotedContent": false,
@@ -153,5 +166,6 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
fieldToggles* = """{"withArticlePlainText":false}""" userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}""" tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
+19 -5
View File
@@ -1,6 +1,6 @@
import options, strutils import options, strutils
import jsony import jsony
import user, ../types/[graphuser, graphlistmembers] import user, utils, ../types/[graphuser, graphlistmembers]
from ../../types import User, VerifiedType, Result, Query, QueryKind from ../../types import User, VerifiedType, Result, Query, QueryKind
proc parseUserResult*(userResult: UserResult): User = proc parseUserResult*(userResult: UserResult): User =
@@ -15,22 +15,36 @@ proc parseUserResult*(userResult: UserResult): User =
result.fullname = userResult.core.name result.fullname = userResult.core.name
result.userPic = userResult.avatar.imageUrl.replace("_normal", "") 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: if userResult.verification.isSome:
let v = userResult.verification.get let v = userResult.verification.get
if v.verifiedType != VerifiedType.none: if v.verifiedType != VerifiedType.none:
result.verifiedType = v.verifiedType result.verifiedType = v.verifiedType
if userResult.profileBio.isSome: if userResult.profileBio.isSome and result.bio.len == 0:
result.bio = userResult.profileBio.get.description result.bio = userResult.profileBio.get.description
proc parseGraphUser*(json: string): User = proc parseGraphUser*(json: string): User =
if json.len == 0 or json[0] != '{': if json.len == 0 or json[0] != '{':
return return
let raw = json.fromJson(GraphUser) let
let userResult = raw.data.userResult.result raw = json.fromJson(GraphUser)
userResult =
if raw.data.userResult.isSome: raw.data.userResult.get.result
elif raw.data.user.isSome: raw.data.user.get.result
else: UserResult()
if userResult.unavailableReason.get("") == "Suspended": if userResult.unavailableReason.get("") == "Suspended" or
userResult.reason.get("") == "Suspended":
return User(suspended: true) return User(suspended: true)
result = parseUserResult(userResult) result = parseUserResult(userResult)
+1 -1
View File
@@ -54,7 +54,7 @@ proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice];
let let
name = $runes[rep.slice.a.succ .. rep.slice.b] name = $runes[rep.slice.a.succ .. rep.slice.b]
symbol = $runes[rep.slice.a] symbol = $runes[rep.slice.a]
result.add a(symbol & name, href = "/search?q=%23" & name) result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
of rkMention: of rkMention:
result.add a($runes[rep.slice], href = rep.url, title = rep.display) result.add a($runes[rep.slice], href = rep.url, title = rep.display)
of rkUrl: of rkUrl:
+8
View File
@@ -0,0 +1,8 @@
import jsony
import ../types/tid
export TidPair
proc parseTidPairs*(raw: string): seq[TidPair] =
result = raw.fromJson(seq[TidPair])
if result.len == 0:
raise newException(ValueError, "Parsing pairs failed: " & raw)
+4 -2
View File
@@ -9,7 +9,7 @@ let
unReplace = "$1<a href=\"/$2\">@$2</a>" unReplace = "$1<a href=\"/$2\">@$2</a>"
htRegex = nre.re"""(*U)(^|[^\w-_.?])([#$])([\w_]*+)(?!</a>|">|#)""" htRegex = nre.re"""(*U)(^|[^\w-_.?])([#$])([\w_]*+)(?!</a>|">|#)"""
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>" htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
proc expandUserEntities(user: var User; raw: RawUser) = proc expandUserEntities(user: var User; raw: RawUser) =
let let
@@ -58,11 +58,13 @@ proc toUser*(raw: RawUser): User =
media: raw.mediaCount, media: raw.mediaCount,
verifiedType: raw.verifiedType, verifiedType: raw.verifiedType,
protected: raw.protected, protected: raw.protected,
joinDate: parseTwitterDate(raw.createdAt),
banner: getBanner(raw), banner: getBanner(raw),
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "") userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
) )
if raw.createdAt.len > 0:
result.joinDate = parseTwitterDate(raw.createdAt)
if raw.pinnedTweetIdsStr.len > 0: if raw.pinnedTweetIdsStr.len > 0:
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0]) result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
+11 -2
View File
@@ -3,7 +3,7 @@ from ../../types import User, VerifiedType
type type
GraphUser* = object GraphUser* = object
data*: tuple[userResult: UserData] data*: tuple[userResult: Option[UserData], user: Option[UserData]]
UserData* = object UserData* = object
result*: UserResult result*: UserResult
@@ -22,15 +22,24 @@ type
Verification* = object Verification* = object
verifiedType*: VerifiedType verifiedType*: VerifiedType
Location* = object
location*: string
Privacy* = object
protected*: bool
UserResult* = object UserResult* = object
legacy*: User legacy*: User
restId*: string restId*: string
isBlueVerified*: bool isBlueVerified*: bool
unavailableReason*: Option[string]
core*: UserCore core*: UserCore
avatar*: UserAvatar avatar*: UserAvatar
unavailableReason*: Option[string]
reason*: Option[string]
privacy*: Option[Privacy]
profileBio*: Option[UserBio] profileBio*: Option[UserBio]
verification*: Option[Verification] verification*: Option[Verification]
location*: Option[Location]
proc enumHook*(s: string; v: var VerifiedType) = proc enumHook*(s: string; v: var VerifiedType) =
v = try: v = try:
+4
View File
@@ -0,0 +1,4 @@
type
TidPair* = object
animationKey*: string
verification*: string
+35 -17
View File
@@ -1,12 +1,12 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math
import std/[enumerate, re] import std/[enumerate, re]
import types, utils, query import types, utils, query
const const
cards = "cards.twitter.com/cards" cards = "cards.twitter.com/cards"
tco = "https://t.co" tco = "https://t.co"
twitter = parseUri("https://twitter.com") twitter = parseUri("https://x.com")
let let
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com" twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
@@ -59,25 +59,28 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
result = body result = body
if prefs.replaceYouTube.len > 0 and "youtu" in result: if prefs.replaceYouTube.len > 0 and "youtu" in result:
result = result.replace(ytRegex, prefs.replaceYouTube) let youtubeHost = strip(prefs.replaceYouTube, chars={'/'})
result = result.replace(ytRegex, youtubeHost)
if prefs.replaceTwitter.len > 0: if prefs.replaceTwitter.len > 0:
let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
if tco in result: if tco in result:
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co") result = result.replace(tco, https & twitterHost & "/t.co")
if "x.com" in result: if "x.com" in result:
result = result.replace(xRegex, prefs.replaceTwitter) result = result.replace(xRegex, twitterHost)
result = result.replacef(xLinkRegex, a( result = result.replacef(xLinkRegex, a(
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) twitterHost & "$2", href = https & twitterHost & "$1"))
if "twitter.com" in result: if "twitter.com" in result:
result = result.replace(cards, prefs.replaceTwitter & "/cards") result = result.replace(cards, twitterHost & "/cards")
result = result.replace(twRegex, prefs.replaceTwitter) result = result.replace(twRegex, twitterHost)
result = result.replacef(twLinkRegex, a( result = result.replacef(twLinkRegex, a(
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) twitterHost & "$2", href = https & twitterHost & "$1"))
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result): if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/") let redditHost = strip(prefs.replaceReddit, chars={'/'})
result = result.replace(rdRegex, prefs.replaceReddit) result = result.replace(rdShortRegex, redditHost & "/comments/")
if prefs.replaceReddit in result and "/gallery/" in result: result = result.replace(rdRegex, redditHost)
if redditHost in result and "/gallery/" in result:
result = result.replace("/gallery/", "/comments/") result = result.replace("/gallery/", "/comments/")
if absolute.len > 0 and "href" in result: if absolute.len > 0 and "href" in result:
@@ -151,13 +154,28 @@ proc getShortTime*(tweet: Tweet): string =
else: else:
result = "now" result = "now"
proc getDuration*(video: Video): string =
let
ms = video.durationMs
sec = int(round(ms / 1000))
min = floorDiv(sec, 60)
hour = floorDiv(min, 60)
if hour > 0:
return &"{hour}:{min mod 60}:{sec mod 60:02}"
else:
return &"{min mod 60}:{sec mod 60:02}"
proc getLink*(id: int64; username="i"; focus=true): string =
var username = username
if username.len == 0:
username = "i"
result = &"/{username}/status/{id}"
if focus: result &= "#m"
proc getLink*(tweet: Tweet; focus=true): string = proc getLink*(tweet: Tweet; focus=true): string =
if tweet.id == 0: return if tweet.id == 0: return
var username = tweet.user.username var username = tweet.user.username
if username.len == 0: return getLink(tweet.id, username, focus)
username = "i"
result = &"/{username}/status/{tweet.id}"
if focus: result &= "#m"
proc getTwitterLink*(path: string; params: Table[string, string]): string = proc getTwitterLink*(path: string; params: Table[string, string]): string =
var var
@@ -185,7 +203,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string =
proc getLocation*(u: User | Tweet): (string, string) = proc getLocation*(u: User | Tweet): (string, string) =
if "://" in u.location: return (u.location, "") if "://" in u.location: return (u.location, "")
let loc = u.location.split(":") let loc = u.location.split(":")
let url = if loc.len > 1: "/search?q=place:" & loc[1] else: "" let url = if loc.len > 1: "/search?f=tweets&q=place:" & loc[1] else: ""
(loc[0], url) (loc[0], url)
proc getSuspended*(username: string): string = proc getSuspended*(username: string): string =
+12 -4
View File
@@ -6,7 +6,7 @@ from os import getEnv
import jester import jester
import types, config, prefs, formatters, redis_cache, http_pool, auth import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
import views/[general, about] import views/[general, about]
import routes/[ import routes/[
preferences, timeline, status, media, search, rss, list, debug, preferences, timeline, status, media, search, rss, list, debug,
@@ -37,6 +37,9 @@ setHmacKey(cfg.hmacKey)
setProxyEncoding(cfg.base64Media) setProxyEncoding(cfg.base64Media)
setMaxHttpConns(cfg.httpMaxConns) setMaxHttpConns(cfg.httpMaxConns)
setHttpProxy(cfg.proxy, cfg.proxyAuth) setHttpProxy(cfg.proxy, cfg.proxyAuth)
setApiProxy(cfg.apiProxy)
setDisableTid(cfg.disableTid)
setMaxConcurrentReqs(cfg.maxConcurrentReqs)
initAboutPage(cfg.staticDir) initAboutPage(cfg.staticDir)
waitFor initRedisPool(cfg) waitFor initRedisPool(cfg)
@@ -62,11 +65,16 @@ settings:
reusePort = true reusePort = true
routes: routes:
before:
# skip all file URLs
cond "." notin request.path
applyUrlPrefs()
get "/": get "/":
resp renderMain(renderSearch(), request, cfg, themePrefs()) resp renderMain(renderSearch(), request, cfg, requestPrefs())
get "/about": get "/about":
resp renderMain(renderAbout(), request, cfg, themePrefs()) resp renderMain(renderAbout(), request, cfg, requestPrefs())
get "/explore": get "/explore":
redirect("/about") redirect("/about")
@@ -77,7 +85,7 @@ routes:
get "/i/redirect": get "/i/redirect":
let url = decodeUrl(@"url") let url = decodeUrl(@"url")
if url.len == 0: resp Http404 if url.len == 0: resp Http404
redirect(replaceUrls(url, cookiePrefs())) redirect(replaceUrls(url, requestPrefs()))
error Http404: error Http404:
resp Http404, showError("Page not found", cfg) resp Http404, showError("Page not found", cfg)
+75 -23
View File
@@ -21,7 +21,7 @@ proc parseUser(js: JsonNode; id=""): User =
tweets: js{"statuses_count"}.getInt, tweets: js{"statuses_count"}.getInt,
likes: js{"favourites_count"}.getInt, likes: js{"favourites_count"}.getInt,
media: js{"media_count"}.getInt, media: js{"media_count"}.getInt,
protected: js{"protected"}.getBool, protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
joinDate: js{"created_at"}.getTime joinDate: js{"created_at"}.getTime
) )
@@ -139,7 +139,10 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
for m in jsMedia: for m in jsMedia:
case m.getTypeName: case m.getTypeName:
of "photo": of "photo":
result.photos.add m{"media_url_https"}.getImageStr result.photos.add Photo(
url: m{"media_url_https"}.getImageStr,
altText: m{"ext_alt_text"}.getStr
)
of "video": of "video":
result.video = some(parseVideo(m)) result.video = some(parseVideo(m))
with user, m{"additional_media_info", "source_user"}: with user, m{"additional_media_info", "source_user"}:
@@ -165,7 +168,10 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) =
with mediaInfo, mediaEntity{"media_results", "result", "media_info"}: with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
case mediaInfo.getTypeName case mediaInfo.getTypeName
of "ApiImage": of "ApiImage":
result.photos.add mediaInfo{"original_img_url"}.getImageStr result.photos.add Photo(
url: mediaInfo{"original_img_url"}.getImageStr,
altText: mediaInfo{"alt_text"}.getStr
)
of "ApiVideo": of "ApiVideo":
let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"} let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
result.video = some Video( result.video = some Video(
@@ -184,7 +190,7 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) =
# Remove media URLs from text # Remove media URLs from text
with mediaList, js{"legacy", "entities", "media"}: with mediaList, js{"legacy", "entities", "media"}:
for url in mediaList: for url in mediaList:
let expandedUrl = url{"expanded_url"}.getStr let expandedUrl = url.getExpandedUrl
if result.text.endsWith(expandedUrl): if result.text.endsWith(expandedUrl):
result.text.removeSuffix(expandedUrl) result.text.removeSuffix(expandedUrl)
result.text = result.text.strip() result.text = result.text.strip()
@@ -267,7 +273,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
for u in ? urls: for u in ? urls:
if u{"url"}.getStr == result.url: if u{"url"}.getStr == result.url:
result.url = u{"expanded_url"}.getStr result.url = u.getExpandedUrl(result.url)
break break
if kind in {videoDirectMessage, imageDirectMessage}: if kind in {videoDirectMessage, imageDirectMessage}:
@@ -277,7 +283,8 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
result.url.len == 0 or result.url.startsWith("card://"): result.url.len == 0 or result.url.startsWith("card://"):
result.url = getPicUrl(result.image) result.url = getPicUrl(result.image)
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
replyId: int64 = 0): Tweet =
if js.isNull: return if js.isNull: return
let time = let time =
@@ -301,6 +308,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
) )
) )
if result.replyId == 0:
result.replyId = replyId
# fix for pinned threads # fix for pinned threads
if result.hasThread and result.threadId == 0: if result.hasThread and result.threadId == 0:
result.threadId = js{"self_thread", "id_str"}.getId result.threadId = js{"self_thread", "id_str"}.getId
@@ -332,11 +342,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
let name = jsCard{"name"}.getStr let name = jsCard{"name"}.getStr
if "poll" in name: if "poll" in name:
if "image" in name: if "image" in name:
result.photos.add jsCard{"binding_values", "image_large"}.getImageVal result.photos.add Photo(
url: jsCard{"binding_values", "image_large"}.getImageVal
)
result.poll = some parsePoll(jsCard) result.poll = some parsePoll(jsCard)
elif name == "amplify": elif name == "amplify":
result.video = some(parsePromoVideo(jsCard{"binding_values"})) result.video = some parsePromoVideo(jsCard{"binding_values"})
else: else:
result.card = some parseCard(jsCard, js{"entities", "urls"}) result.card = some parseCard(jsCard, js{"entities", "urls"})
@@ -394,12 +406,17 @@ proc parseGraphTweet(js: JsonNode): Tweet =
"binding_values": %bindingObj "binding_values": %bindingObj
} }
result = parseTweet(js{"legacy"}, jsCard) var replyId = 0
with restId, js{"reply_to_results", "rest_id"}:
replyId = restId.getId
result = parseTweet(js{"legacy"}, jsCard, replyId)
result.id = js{"rest_id"}.getId result.id = js{"rest_id"}.getId
result.user = parseGraphUser(js{"core"}) result.user = parseGraphUser(js{"core"})
if result.replyId == 0: if result.reply.len == 0:
result.replyId = js{"reply_to_results", "rest_id"}.getId with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}:
result.reply = @[replyTo.getStr]
with count, js{"views", "count"}: with count, js{"views", "count"}:
result.stats.views = count.getStr("0").parseInt result.stats.views = count.getStr("0").parseInt
@@ -409,21 +426,25 @@ proc parseGraphTweet(js: JsonNode): Tweet =
parseMediaEntities(js, result) parseMediaEntities(js, result)
if result.quote.isSome: with quoted, js{"quoted_status_result", "result"}:
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}))
with quoted, js{"quotedPostResults", "result"}:
result.quote = some(parseGraphTweet(quoted)) result.quote = some(parseGraphTweet(quoted))
with quoted, js{"quotedPostResults"}:
if "result" in quoted:
result.quote = some(parseGraphTweet(quoted{"result"}))
else:
result.quote = some Tweet(id: js{"legacy", "quoted_status_id_str"}.getId)
with ids, js{"edit_control", "edit_control_initial", "edit_tweet_ids"}:
for id in ids:
result.history.add parseBiggestInt(id.getStr)
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
for t in ? js{"content", "items"}: for t in ? js{"content", "items"}:
let entryId = t.getEntryId let entryId = t.getEntryId
if "cursor-showmore" in entryId: if "tweet-" in entryId and "promoted" notin entryId:
let cursor = t{"item", "content", "value"} let tweet = t.getTweetResult("item")
result.thread.cursor = cursor.getStr if tweet.notNull:
result.thread.hasMore = true
elif "tweet" in entryId and "promoted" notin entryId:
with tweet, t.getTweetResult("item"):
result.thread.content.add parseGraphTweet(tweet) result.thread.content.add parseGraphTweet(tweet)
let tweetDisplayType = select( let tweetDisplayType = select(
@@ -432,6 +453,12 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
) )
if tweetDisplayType.getStr == "SelfThread": if tweetDisplayType.getStr == "SelfThread":
result.self = true result.self = true
else:
result.thread.content.add Tweet(id: entryId.getId)
elif "cursor-showmore" in entryId:
let cursor = t{"item", "content", "value"}
result.thread.cursor = cursor.getStr
result.thread.hasMore = true
proc parseGraphTweetResult*(js: JsonNode): Tweet = proc parseGraphTweetResult*(js: JsonNode): Tweet =
with tweet, js{"data", "tweet_result", "result"}: with tweet, js{"data", "tweet_result", "result"}:
@@ -452,7 +479,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if i.getTypeName == "TimelineAddEntries": if i.getTypeName == "TimelineAddEntries":
for e in i{"entries"}: for e in i{"entries"}:
let entryId = e.getEntryId let entryId = e.getEntryId
if entryId.startsWith("tweet"): if entryId.startsWith("tweet-"):
let tweetResult = getTweetResult(e) let tweetResult = getTweetResult(e)
if tweetResult.notNull: if tweetResult.notNull:
let tweet = parseGraphTweet(tweetResult) let tweet = parseGraphTweet(tweetResult)
@@ -460,10 +487,12 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if not tweet.available: if not tweet.available:
tweet.id = entryId.getId tweet.id = entryId.getId
if $tweet.id == tweetId: if entryId.endsWith(tweetId):
result.tweet = tweet result.tweet = tweet
else: else:
result.before.content.add tweet result.before.content.add tweet
elif not entryId.endsWith(tweetId):
result.before.content.add Tweet(id: entryId.getId)
elif entryId.startsWith("conversationthread"): elif entryId.startsWith("conversationthread"):
let (thread, self) = parseGraphThread(e) let (thread, self) = parseGraphThread(e)
if self: if self:
@@ -491,6 +520,29 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
) )
result.replies.bottom = cursorValue.getStr result.replies.bottom = cursorValue.getStr
proc parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory =
let instructions = ? js{
"data", "tweet_result_by_rest_id", "result",
"edit_history_timeline", "timeline", "instructions"
}
if instructions.len == 0:
return
for i in instructions:
if i.getTypeName == "TimelineAddEntries":
for e in i{"entries"}:
let entryId = e.getEntryId
if entryId == "latestTweet":
with item, e{"content", "items"}[0]:
let tweetResult = item.getTweetResult("item")
if tweetResult.notNull:
result.latest = parseGraphTweet(tweetResult)
elif entryId == "staleTweets":
for item in e{"content", "items"}:
let tweetResult = item.getTweetResult("item")
if tweetResult.notNull:
result.history.add parseGraphTweet(tweetResult)
proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
with tweetResult, getTweetResult(e): with tweetResult, getTweetResult(e):
var tweet = parseGraphTweet(tweetResult) var tweet = parseGraphTweet(tweetResult)
+9 -7
View File
@@ -17,7 +17,7 @@ let
unReplace = "$1<a href=\"/$2\">@$2</a>" unReplace = "$1<a href=\"/$2\">@$2</a>"
htRegex = re"(^|[^\w-_./?])([#$]|)([\w_]+)" htRegex = re"(^|[^\w-_./?])([#$]|)([\w_]+)"
htReplace = "$1<a href=\"/search?q=%23$3\">$2$3</a>" htReplace = "$1<a href=\"/search?f=tweets&q=%23$3\">$2$3</a>"
type type
ReplaceSliceKind = enum ReplaceSliceKind = enum
@@ -72,7 +72,6 @@ template getTypeName*(js: JsonNode): string =
template getEntryId*(e: JsonNode): string = template getEntryId*(e: JsonNode): string =
e{"entryId"}.getStr(e{"entry_id"}.getStr) e{"entryId"}.getStr(e{"entry_id"}.getStr)
template parseTime(time: string; f: static string; flen: int): DateTime = template parseTime(time: string; f: static string; flen: int): DateTime =
if time.len != flen: return if time.len != flen: return
parse(time, f, utc()) parse(time, f, utc())
@@ -112,6 +111,9 @@ proc getImageStr*(js: JsonNode): string =
template getImageVal*(js: JsonNode): string = template getImageVal*(js: JsonNode): string =
js{"image_value", "url"}.getImageStr js{"image_value", "url"}.getImageStr
template getExpandedUrl*(js: JsonNode; fallback=""): string =
js{"expanded_url"}.getStr(js{"url"}.getStr(fallback))
proc getCardUrl*(js: JsonNode; kind: CardKind): string = proc getCardUrl*(js: JsonNode; kind: CardKind): string =
result = js{"website_url"}.getStrVal result = js{"website_url"}.getStrVal
if kind == promoVideoConvo: if kind == promoVideoConvo:
@@ -177,7 +179,7 @@ proc extractSlice(js: JsonNode): Slice[int] =
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode; proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
textLen: int; hideTwitter = false) = textLen: int; hideTwitter = false) =
let let
url = js["expanded_url"].getStr url = js.getExpandedUrl
slice = js.extractSlice slice = js.extractSlice
if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl: if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
@@ -204,7 +206,7 @@ proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
let let
name = $runes[rep.slice.a.succ .. rep.slice.b] name = $runes[rep.slice.a.succ .. rep.slice.b]
symbol = $runes[rep.slice.a] symbol = $runes[rep.slice.a]
result.add a(symbol & name, href = "/search?q=%23" & name) result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
of rkMention: of rkMention:
result.add a($runes[rep.slice], href = rep.url, title = rep.display) result.add a($runes[rep.slice], href = rep.url, title = rep.display)
of rkUrl: of rkUrl:
@@ -238,7 +240,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
ent = ? js{"entities"} ent = ? js{"entities"}
with urls, ent{"url", "urls"}: with urls, ent{"url", "urls"}:
user.website = urls[0]{"expanded_url"}.getStr user.website = urls[0].getExpandedUrl
var replacements = newSeq[ReplaceSlice]() var replacements = newSeq[ReplaceSlice]()
@@ -268,7 +270,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink) replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
if hasCard and u{"url"}.getStr == get(tweet.card).url: if hasCard and u{"url"}.getStr == get(tweet.card).url:
get(tweet.card).url = u{"expanded_url"}.getStr get(tweet.card).url = u.getExpandedUrl
with media, entities{"media"}: with media, entities{"media"}:
for m in media: for m in media:
@@ -330,7 +332,7 @@ proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
proc extractGalleryPhoto*(t: Tweet): GalleryPhoto = proc extractGalleryPhoto*(t: Tweet): GalleryPhoto =
let url = let url =
if t.photos.len > 0: t.photos[0] if t.photos.len > 0: t.photos[0].url
elif t.video.isSome: get(t.video).thumb elif t.video.isSome: get(t.video).thumb
elif t.gif.isSome: get(t.gif).thumb elif t.gif.isSome: get(t.gif).thumb
elif t.card.isSome: get(t.card).image elif t.card.isSome: get(t.card).image
+9 -9
View File
@@ -1,22 +1,22 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import tables import tables, strutils
import types, prefs_impl import types, prefs_impl
from config import get from config import get
from parsecfg import nil from parsecfg import nil
export genUpdatePrefs, genResetPrefs export genUpdatePrefs, genResetPrefs, genApplyPrefs
var defaultPrefs*: Prefs var defaultPrefs*: Prefs
proc updateDefaultPrefs*(cfg: parsecfg.Config) = proc updateDefaultPrefs*(cfg: parsecfg.Config) =
genDefaultPrefs() genDefaultPrefs()
proc getPrefs*(cookies: Table[string, string]): Prefs = proc getPrefs*(cookies, params: Table[string, string]): Prefs =
result = defaultPrefs result = defaultPrefs
genCookiePrefs(cookies) genParsePrefs(cookies)
genParsePrefs(params)
template getPref*(cookies: Table[string, string], pref): untyped = proc encodePrefs*(prefs: Prefs): string =
bind genCookiePref var encPairs: seq[string]
var res = defaultPrefs.`pref` genEncodePrefs(prefs)
genCookiePref(cookies, pref, res) encPairs.join(",")
res
+40 -27
View File
@@ -60,6 +60,9 @@ genPrefs:
stickyProfile(checkbox, true): stickyProfile(checkbox, true):
"Make profile sidebar stick to top" "Make profile sidebar stick to top"
stickyNav(checkbox, true):
"Keep navbar fixed to top"
bidiSupport(checkbox, false): bidiSupport(checkbox, false):
"Support bidirectional text (makes clicking on tweets harder)" "Support bidirectional text (makes clicking on tweets harder)"
@@ -127,7 +130,7 @@ macro genDefaultPrefs*(): untyped =
result.add quote do: result.add quote do:
defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`) defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`)
macro genCookiePrefs*(cookies): untyped = macro genParsePrefs*(prefs): untyped =
result = nnkStmtList.newTree() result = nnkStmtList.newTree()
for pref in allPrefs(): for pref in allPrefs():
let let
@@ -137,37 +140,17 @@ macro genCookiePrefs*(cookies): untyped =
options = pref.options options = pref.options
result.add quote do: result.add quote do:
if `name` in `cookies`: if `name` in `prefs`:
when `kind` == input or `name` == "theme": when `kind` == input or `name` == "theme":
result.`ident` = `cookies`[`name`] result.`ident` = `prefs`[`name`]
elif `kind` == checkbox: elif `kind` == checkbox:
result.`ident` = `cookies`[`name`] == "on" result.`ident` = `prefs`[`name`] == "on" or
`prefs`[`name`] == "true" or
`prefs`[`name`] == "1"
else: else:
let value = `cookies`[`name`] let value = `prefs`[`name`]
if value in `options`: result.`ident` = value if value in `options`: result.`ident` = value
macro genCookiePref*(cookies, prefName, res): untyped =
result = nnkStmtList.newTree()
for pref in allPrefs():
let ident = ident(pref.name)
if ident != prefName:
continue
let
name = pref.name
kind = newLit(pref.kind)
options = pref.options
result.add quote do:
if `name` in `cookies`:
when `kind` == input or `name` == "theme":
`res` = `cookies`[`name`]
elif `kind` == checkbox:
`res` = `cookies`[`name`] == "on"
else:
let value = `cookies`[`name`]
if value in `options`: `res` = value
macro genUpdatePrefs*(): untyped = macro genUpdatePrefs*(): untyped =
result = nnkStmtList.newTree() result = nnkStmtList.newTree()
let req = ident("request") let req = ident("request")
@@ -202,6 +185,36 @@ macro genResetPrefs*(): untyped =
result.add quote do: result.add quote do:
savePref(`name`, "", `req`, expire=true) savePref(`name`, "", `req`, expire=true)
macro genEncodePrefs*(prefs): untyped =
result = nnkStmtList.newTree()
for pref in allPrefs():
let
name = newLit(pref.name)
ident = ident(pref.name)
kind = newLit(pref.kind)
defaultIdent = nnkDotExpr.newTree(ident("defaultPrefs"), ident(pref.name))
result.add quote do:
when `kind` == checkbox:
if `prefs`.`ident` != `defaultIdent`:
if `prefs`.`ident`:
encPairs.add `name` & "=on"
else:
encPairs.add `name` & "="
else:
if `prefs`.`ident` != `defaultIdent`:
encPairs.add `name` & "=" & `prefs`.`ident`
macro genApplyPrefs*(params, req): untyped =
result = nnkStmtList.newTree()
for pref in allPrefs():
let name = newLit(pref.name)
result.add quote do:
if `name` in `params`:
savePref(`name`, `params`[`name`], `req`)
else:
savePref(`name`, "", `req`, expire=true)
macro genPrefsType*(): untyped = macro genPrefsType*(): untyped =
let name = nnkPostfix.newTree(ident("*"), ident("Prefs")) let name = nnkPostfix.newTree(ident("*"), ident("Prefs"))
result = quote do: result = quote do:
+12 -8
View File
@@ -6,10 +6,9 @@ import types
const const
validFilters* = @[ validFilters* = @[
"media", "images", "twimg", "videos", "media", "images", "twimg", "videos",
"native_video", "consumer_video", "pro_video", "native_video", "consumer_video", "spaces",
"links", "news", "quote", "mentions", "links", "news", "quote", "mentions",
"replies", "retweets", "nativeretweets", "replies", "retweets", "nativeretweets"
"verified", "safe"
] ]
emptyQuery* = "include:nativeretweets" emptyQuery* = "include:nativeretweets"
@@ -18,6 +17,11 @@ template `@`(param: string): untyped =
if param in pms: pms[param] if param in pms: pms[param]
else: "" else: ""
proc validateNumber(value: string): string =
if value.anyIt(not it.isDigit):
return ""
return value
proc initQuery*(pms: Table[string, string]; name=""): Query = proc initQuery*(pms: Table[string, string]; name=""): Query =
result = Query( result = Query(
kind: parseEnum[QueryKind](@"f", tweets), kind: parseEnum[QueryKind](@"f", tweets),
@@ -26,7 +30,7 @@ proc initQuery*(pms: Table[string, string]; name=""): Query =
excludes: validFilters.filterIt("e-" & it in pms), excludes: validFilters.filterIt("e-" & it in pms),
since: @"since", since: @"since",
until: @"until", until: @"until",
near: @"near" minLikes: validateNumber(@"min_faves")
) )
if name.len > 0: if name.len > 0:
@@ -78,8 +82,8 @@ proc genQueryParam*(query: Query): string =
result &= " since:" & query.since result &= " since:" & query.since
if query.until.len > 0: if query.until.len > 0:
result &= " until:" & query.until result &= " until:" & query.until
if query.near.len > 0: if query.minLikes.len > 0:
result &= &" near:\"{query.near}\" within:15mi" result &= " min_faves:" & query.minLikes
if query.text.len > 0: if query.text.len > 0:
if result.len > 0: if result.len > 0:
result &= " " & query.text result &= " " & query.text
@@ -103,8 +107,8 @@ proc genQueryUrl*(query: Query): string =
params.add "since=" & query.since params.add "since=" & query.since
if query.until.len > 0: if query.until.len > 0:
params.add "until=" & query.until params.add "until=" & query.until
if query.near.len > 0: if query.minLikes.len > 0:
params.add "near=" & query.near params.add "min_faves=" & query.minLikes
if params.len > 0: if params.len > 0:
result &= params.join("&") result &= params.join("&")
+1 -1
View File
@@ -19,7 +19,7 @@ proc createEmbedRouter*(cfg: Config) =
get "/@user/status/@id/embed": get "/@user/status/@id/embed":
let let
tweet = await getGraphTweetResult(@"id") tweet = await getGraphTweetResult(@"id")
prefs = cookiePrefs() prefs = requestPrefs()
path = getPath() path = getPath()
if tweet == nil: if tweet == nil:
+3 -3
View File
@@ -13,7 +13,7 @@ template respList*(list, timeline, title, vnode: typed) =
let let
html = renderList(vnode, timeline.query, list) html = renderList(vnode, timeline.query, list)
rss = &"""/i/lists/{@"id"}/rss""" rss = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: ""
resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner) resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner)
@@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) =
get "/i/lists/@id/?": get "/i/lists/@id/?":
cond '.' notin @"id" cond '.' notin @"id"
let let
prefs = cookiePrefs() prefs = requestPrefs()
list = await getCachedList(id=(@"id")) list = await getCachedList(id=(@"id"))
timeline = await getGraphListTweets(list.id, getCursor()) timeline = await getGraphListTweets(list.id, getCursor())
vnode = renderTimelineTweets(timeline, prefs, request.path) vnode = renderTimelineTweets(timeline, prefs, request.path)
@@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) =
get "/i/lists/@id/members": get "/i/lists/@id/members":
cond '.' notin @"id" cond '.' notin @"id"
let let
prefs = cookiePrefs() prefs = requestPrefs()
list = await getCachedList(id=(@"id")) list = await getCachedList(id=(@"id"))
members = await getGraphListMembers(list, getCursor()) members = await getGraphListMembers(list, getCursor())
respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path)) respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))
+9 -5
View File
@@ -52,10 +52,10 @@ proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
"" ""
let headers = newHttpHeaders({ let headers = newHttpHeaders({
"Content-Type": res.headers["content-type", 0], "content-type": res.headers["content-type", 0],
"Content-Length": contentLength, "content-length": contentLength,
"Cache-Control": maxAge, "cache-control": maxAge,
"ETag": hashed "etag": hashed
}) })
respond(request, headers) respond(request, headers)
@@ -93,6 +93,8 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/orig\/(enc)?\/?(.+)": get re"^\/pic\/orig\/(enc)?\/?(.+)":
var url = decoded(request, 1) var url = decoded(request, 1)
cond "amplify_video" notin url
if "twimg.com" notin url: if "twimg.com" notin url:
url.insert(twimg) url.insert(twimg)
if not url.startsWith(https): if not url.startsWith(https):
@@ -107,6 +109,8 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/(enc)?\/?(.+)": get re"^\/pic\/(enc)?\/?(.+)":
var url = decoded(request, 1) var url = decoded(request, 1)
cond "amplify_video" notin url
if "twimg.com" notin url: if "twimg.com" notin url:
url.insert(twimg) url.insert(twimg)
if not url.startsWith(https): if not url.startsWith(https):
@@ -139,6 +143,6 @@ proc createMediaRouter*(cfg: Config) =
if ".m3u8" in url: if ".m3u8" in url:
let vid = await safeFetch(url) let vid = await safeFetch(url)
content = proxifyVideo(vid, cookiePref(proxyVideos)) content = proxifyVideo(vid, requestPrefs().proxyVideos)
resp content, m3u8Mime resp content, m3u8Mime
+4 -2
View File
@@ -19,8 +19,10 @@ proc createPrefRouter*(cfg: Config) =
router preferences: router preferences:
get "/settings": get "/settings":
let let
prefs = cookiePrefs() prefs = requestPrefs()
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir)) prefsCode = encodePrefs(prefs)
prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode
html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl)
resp renderMain(html, request, cfg, prefs, "Preferences") resp renderMain(html, request, cfg, prefs, "Preferences")
get "/settings/@i?": get "/settings/@i?":
+2 -2
View File
@@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) =
router resolver: router resolver:
get "/cards/@card/@id": get "/cards/@card/@id":
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"] let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
respResolved(await resolve(url, cookiePrefs()), "card") respResolved(await resolve(url, requestPrefs()), "card")
get "/t.co/@url": get "/t.co/@url":
let url = "https://t.co/" & @"url" let url = "https://t.co/" & @"url"
respResolved(await resolve(url, cookiePrefs()), "t.co") respResolved(await resolve(url, requestPrefs()), "t.co")
+27 -12
View File
@@ -9,21 +9,13 @@ export utils, prefs, types, uri
template savePref*(pref, value: string; req: Request; expire=false) = template savePref*(pref, value: string; req: Request; expire=false) =
if not expire or pref in cookies(req): if not expire or pref in cookies(req):
setCookie(pref, value, daysForward(when expire: -10 else: 360), setCookie(pref, value, daysForward(when expire: -10 else: 360),
httpOnly=true, secure=cfg.useHttps, sameSite=None) httpOnly=true, secure=cfg.useHttps, sameSite=None, path="/")
template cookiePrefs*(): untyped {.dirty.} = template requestPrefs*(): untyped {.dirty.} =
getPrefs(cookies(request)) getPrefs(cookies(request), params(request))
template cookiePref*(pref): untyped {.dirty.} =
getPref(cookies(request), pref)
template themePrefs*(): Prefs =
var res = defaultPrefs
res.theme = cookiePref(theme)
res
template showError*(error: string; cfg: Config): string = template showError*(error: string; cfg: Config): string =
renderMain(renderError(error), request, cfg, themePrefs(), "Error") renderMain(renderError(error), request, cfg, requestPrefs(), "Error")
template getPath*(): untyped {.dirty.} = template getPath*(): untyped {.dirty.} =
$(parseUri(request.path) ? filterParams(request.params)) $(parseUri(request.path) ? filterParams(request.params))
@@ -43,5 +35,28 @@ template getCursor*(req: Request): string =
proc getNames*(name: string): seq[string] = proc getNames*(name: string): seq[string] =
name.strip(chars={'/'}).split(",").filterIt(it.len > 0) name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
template applyUrlPrefs*() {.dirty.} =
if @"prefs".len > 0:
var prefParams = initTable[string, string]()
for pair in @"prefs".split(','):
let kv = pair.split('=', maxsplit=1)
if kv.len == 2:
prefParams[kv[0]] = kv[1]
elif kv.len == 1 and kv[0].len > 0:
prefParams[kv[0]] = ""
genApplyPrefs(prefParams, request)
# Rebuild URL without prefs param
var params: seq[(string, string)]
for k, v in request.params:
if k != "prefs":
params.add (k, v)
if params.len > 0:
let cleanUrl = request.getNativeReq.url ? params
redirect($cleanUrl)
else:
redirect(request.path)
template respJson*(node: JsonNode) = template respJson*(node: JsonNode) =
resp $node, "application/json" resp $node, "application/json"
+27 -12
View File
@@ -15,7 +15,7 @@ proc redisKey*(page, name, cursor: string): string =
if cursor.len > 0: if cursor.len > 0:
result &= ":" & cursor result &= ":" & cursor
proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} = proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future[Rss] {.async.} =
var profile: Profile var profile: Profile
let let
name = req.params.getOrDefault("name") name = req.params.getOrDefault("name")
@@ -39,7 +39,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
return Rss(feed: profile.user.username, cursor: "suspended") return Rss(feed: profile.user.username, cursor: "suspended")
if profile.user.fullname.len > 0: if profile.user.fullname.len > 0:
let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1)) let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1))
return Rss(feed: rss, cursor: profile.tweets.bottom) return Rss(feed: rss, cursor: profile.tweets.bottom)
template respRss*(rss, page) = template respRss*(rss, page) =
@@ -60,11 +60,14 @@ template respRss*(rss, page) =
proc createRssRouter*(cfg: Config) = proc createRssRouter*(cfg: Config) =
router rss: router rss:
get "/search/rss": get "/search/rss":
cond cfg.enableRss if not cfg.enableRSSSearch:
resp Http403, showError("RSS feed is disabled", cfg)
if @"q".len > 200: if @"q".len > 200:
resp Http400, showError("Search input too long.", cfg) resp Http400, showError("Search input too long.", cfg)
let query = initQuery(params(request)) let
prefs = requestPrefs()
query = initQuery(params(request))
if query.kind != tweets: if query.kind != tweets:
resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
@@ -78,15 +81,17 @@ proc createRssRouter*(cfg: Config) =
let tweets = await getGraphTweetSearch(query, cursor) let tweets = await getGraphTweetSearch(query, cursor)
rss.cursor = tweets.bottom rss.cursor = tweets.bottom
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs)
await cacheRss(key, rss) await cacheRss(key, rss)
respRss(rss, "Search") respRss(rss, "Search")
get "/@name/rss": get "/@name/rss":
cond cfg.enableRss
cond '.' notin @"name" cond '.' notin @"name"
if not cfg.enableRSSUserTweets:
resp Http403, showError("RSS feed is disabled", cfg)
let let
prefs = requestPrefs()
name = @"name" name = @"name"
key = redisKey("twitter", name, getCursor()) key = redisKey("twitter", name, getCursor())
@@ -94,16 +99,23 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0: if rss.cursor.len > 0:
respRss(rss, "User") respRss(rss, "User")
rss = await timelineRss(request, cfg, Query(fromUser: @[name])) rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs)
await cacheRss(key, rss) await cacheRss(key, rss)
respRss(rss, "User") respRss(rss, "User")
get "/@name/@tab/rss": get "/@name/@tab/rss":
cond cfg.enableRss
cond '.' notin @"name" cond '.' notin @"name"
cond @"tab" in ["with_replies", "media", "search"] cond @"tab" in ["with_replies", "media", "search"]
let rssEnabled = case @"tab"
of "with_replies": cfg.enableRSSUserReplies
of "media": cfg.enableRSSUserMedia
of "search": cfg.enableRSSSearch
else: false
if not rssEnabled:
resp Http403, showError("RSS feed is disabled", cfg)
let let
prefs = requestPrefs()
name = @"name" name = @"name"
tab = @"tab" tab = @"tab"
query = query =
@@ -122,14 +134,15 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0: if rss.cursor.len > 0:
respRss(rss, "User") respRss(rss, "User")
rss = await timelineRss(request, cfg, query) rss = await timelineRss(request, cfg, query, prefs)
await cacheRss(key, rss) await cacheRss(key, rss)
respRss(rss, "User") respRss(rss, "User")
get "/@name/lists/@slug/rss": get "/@name/lists/@slug/rss":
cond cfg.enableRss
cond @"name" != "i" cond @"name" != "i"
if not cfg.enableRSSList:
resp Http403, showError("RSS feed is disabled", cfg)
let let
slug = decodeUrl(@"slug") slug = decodeUrl(@"slug")
list = await getCachedList(@"name", slug) list = await getCachedList(@"name", slug)
@@ -145,8 +158,10 @@ proc createRssRouter*(cfg: Config) =
redirect(url) redirect(url)
get "/i/lists/@id/rss": get "/i/lists/@id/rss":
cond cfg.enableRss if not cfg.enableRSSList:
resp Http403, showError("RSS feed is disabled", cfg)
let let
prefs = requestPrefs()
id = @"id" id = @"id"
cursor = getCursor() cursor = getCursor()
key = redisKey("lists", id, cursor) key = redisKey("lists", id, cursor)
@@ -159,7 +174,7 @@ proc createRssRouter*(cfg: Config) =
list = await getCachedList(id=id) list = await getCachedList(id=id)
timeline = await getGraphListTweets(list.id, cursor) timeline = await getGraphListTweets(list.id, cursor)
rss.cursor = timeline.bottom rss.cursor = timeline.bottom
rss.feed = renderListRss(timeline.content, list, cfg) rss.feed = renderListRss(timeline.content, list, cfg, prefs)
await cacheRss(key, rss) await cacheRss(key, rss)
respRss(rss, "List") respRss(rss, "List")
+4 -4
View File
@@ -19,7 +19,7 @@ proc createSearchRouter*(cfg: Config) =
resp Http400, showError("Search input too long.", cfg) resp Http400, showError("Search input too long.", cfg)
let let
prefs = cookiePrefs() prefs = requestPrefs()
query = initQuery(params(request)) query = initQuery(params(request))
title = "Search" & (if q.len > 0: " (" & q & ")" else: "") title = "Search" & (if q.len > 0: " (" & q & ")" else: "")
@@ -36,16 +36,16 @@ proc createSearchRouter*(cfg: Config) =
of tweets: of tweets:
let let
tweets = await getGraphTweetSearch(query, getCursor()) tweets = await getGraphTweetSearch(query, getCursor())
rss = "/search/rss?" & genQueryUrl(query) rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: ""
resp renderMain(renderTweetSearch(tweets, prefs, getPath()), resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
request, cfg, prefs, title, rss=rss) request, cfg, prefs, title, rss=rss)
else: else:
resp Http404, showError("Invalid search", cfg) resp Http404, showError("Invalid search", cfg)
get "/hashtag/@hash": get "/hashtag/@hash":
redirect("/search?q=" & encodeUrl("#" & @"hash")) redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash"))
get "/opensearch": get "/opensearch":
let url = getUrlPrefix(cfg) & "/search?q=" let url = getUrlPrefix(cfg) & "/search?f=tweets&q="
resp Http200, {"Content-Type": "application/opensearchdescription+xml"}, resp Http200, {"Content-Type": "application/opensearchdescription+xml"},
generateOpenSearchXML(cfg.title, cfg.hostname, url) generateOpenSearchXML(cfg.title, cfg.hostname, url)
+23 -3
View File
@@ -21,7 +21,7 @@ proc createStatusRouter*(cfg: Config) =
if id.len > 19 or id.any(c => not c.isDigit): if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, showError("Invalid tweet ID", cfg) resp Http404, showError("Invalid tweet ID", cfg)
let prefs = cookiePrefs() let prefs = requestPrefs()
# used for the infinite scroll feature # used for the infinite scroll feature
if @"scroll".len > 0: if @"scroll".len > 0:
@@ -44,7 +44,7 @@ proc createStatusRouter*(cfg: Config) =
desc = conv.tweet.text desc = conv.tweet.text
var var
images = conv.tweet.photos images = conv.tweet.photos.mapIt(it.url)
video = "" video = ""
if conv.tweet.video.isSome(): if conv.tweet.video.isSome():
@@ -64,9 +64,29 @@ proc createStatusRouter*(cfg: Config) =
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
images=images, video=video) images=images, video=video)
get "/@name/status/@id/history/?":
cond '.' notin @"name"
let id = @"id"
if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, showError("Invalid tweet ID", cfg)
let edits = await getGraphEditHistory(id)
if edits.latest == nil or edits.latest.id == 0:
resp Http404, showError("Tweet history not found", cfg)
let
prefs = requestPrefs()
title = "History for " & pageTitle(edits.latest)
ogTitle = "Edit History for " & pageTitle(edits.latest.user)
desc = edits.latest.text
let html = renderEditHistory(edits, prefs, getPath())
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle)
get "/@name/@s/@id/@m/?@i?": get "/@name/@s/@id/@m/?@i?":
cond @"s" in ["status", "statuses"] cond @"s" in ["status", "statuses"]
cond @"m" in ["video", "photo", "history"] cond @"m" in ["video", "photo"]
redirect("/$1/status/$2" % [@"name", @"id"]) redirect("/$1/status/$2" % [@"name", @"id"])
get "/@name/statuses/@id/?": get "/@name/statuses/@id/?":
+18 -2
View File
@@ -105,12 +105,19 @@ proc createTimelineRouter*(cfg: Config) =
get "/intent/user": get "/intent/user":
respUserId() respUserId()
get "/intent/follow/?":
let username = request.params.getOrDefault("screen_name")
if username.len == 0:
resp Http400, showError("Missing screen_name parameter", cfg)
redirect("/" & username)
get "/@name/?@tab?/?": get "/@name/?@tab?/?":
cond '.' notin @"name" cond '.' notin @"name"
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] 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", ""] cond @"tab" in ["with_replies", "media", "search", ""]
let let
prefs = cookiePrefs() prefs = requestPrefs()
after = getCursor() after = getCursor()
names = getNames(@"name") names = getNames(@"name")
@@ -131,8 +138,17 @@ proc createTimelineRouter*(cfg: Config) =
profile.tweets.beginning = true profile.tweets.beginning = true
resp $renderTimelineTweets(profile.tweets, prefs, getPath()) resp $renderTimelineTweets(profile.tweets, prefs, getPath())
let rssEnabled =
if @"tab".len == 0: cfg.enableRSSUserTweets
elif @"tab" == "with_replies": cfg.enableRSSUserReplies
elif @"tab" == "media": cfg.enableRSSUserMedia
elif @"tab" == "search": cfg.enableRSSSearch
else: false
let rss = let rss =
if @"tab".len == 0: if not rssEnabled:
""
elif @"tab".len == 0:
"/$1/rss" % @"name" "/$1/rss" % @"name"
elif @"tab" == "search": elif @"tab" == "search":
"/$1/search/rss?$2" % [@"name", genQueryUrl(query)] "/$1/search/rss?$2" % [@"name", genQueryUrl(query)]
+2 -2
View File
@@ -10,14 +10,14 @@ export feature
proc createUnsupportedRouter*(cfg: Config) = proc createUnsupportedRouter*(cfg: Config) =
router unsupported: router unsupported:
template feature {.dirty.} = template feature {.dirty.} =
resp renderMain(renderFeature(), request, cfg, themePrefs()) resp renderMain(renderFeature(), request, cfg, requestPrefs())
get "/about/feature": feature() get "/about/feature": feature()
get "/login/?@i?": feature() get "/login/?@i?": feature()
get "/@name/lists/?": feature() get "/@name/lists/?": feature()
get "/intent/?@i?": get "/intent/?@i?":
cond @"i" notin ["user"] cond @"i" notin ["user", "follow"]
feature() feature()
get "/i/@i?/?@j?": get "/i/@i?/?@j?":
+29 -28
View File
@@ -1,39 +1,40 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
.panel-container { .panel-container {
margin: auto; margin: auto;
font-size: 130%; font-size: 130%;
} }
.error-panel { .error-panel {
@include center-panel(var(--error_red)); @include center-panel(var(--error_red));
text-align: center; text-align: center;
} }
.search-bar > form { .search-bar > form {
@include center-panel(var(--darkest_grey)); @include center-panel(var(--darkest_grey));
button { button {
background: var(--bg_elements); background: var(--bg_elements);
color: var(--fg_color); color: var(--fg_color);
border: 0; border: 0;
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
font-weight: bold; font-weight: bold;
width: 30px; width: 30px;
height: 30px; height: 30px;
} padding: 0px 5px 1px 8px;
}
input { input {
font-size: 16px; font-size: 16px;
width: 100%; width: 100%;
background: var(--bg_elements); background: var(--bg_elements);
color: var(--fg_color); color: var(--fg_color);
border: 0; border: 0;
border-radius: 4px; border-radius: 4px;
padding: 4px; padding: 4px;
margin-right: 8px; margin-right: 8px;
height: unset; height: unset;
} }
} }
+1 -12
View File
@@ -66,18 +66,7 @@
} }
#search-panel-toggle:checked ~ .search-panel { #search-panel-toggle:checked ~ .search-panel {
@if $rows == 6 { max-height: 380px !important;
max-height: 200px !important;
}
@if $rows == 5 {
max-height: 300px !important;
}
@if $rows == 4 {
max-height: 300px !important;
}
@if $rows == 3 {
max-height: 365px !important;
}
} }
} }
} }
+23 -26
View File
@@ -1,46 +1,43 @@
// colors // colors
$bg_color: #0F0F0F; $bg_color: #0f0f0f;
$fg_color: #F8F8F2; $fg_color: #f8f8f2;
$fg_faded: #F8F8F2CF; $fg_faded: #f8f8f2cf;
$fg_dark: #FF6C60; $fg_dark: #ff6c60;
$fg_nav: #FF6C60; $fg_nav: #ff6c60;
$bg_panel: #161616; $bg_panel: #161616;
$bg_elements: #121212; $bg_elements: #121212;
$bg_overlays: #1F1F1F; $bg_overlays: #1f1f1f;
$bg_hover: #1A1A1A; $bg_hover: #1a1a1a;
$grey: #888889; $grey: #888889;
$dark_grey: #404040; $dark_grey: #404040;
$darker_grey: #282828; $darker_grey: #282828;
$darkest_grey: #222222; $darkest_grey: #222222;
$border_grey: #3E3E35; $border_grey: #3e3e35;
$accent: #FF6C60; $accent: #ff6c60;
$accent_light: #FFACA0; $accent_light: #ffaca0;
$accent_dark: #8A3731; $accent_dark: #8a3731;
$accent_border: #FF6C6091; $accent_border: #ff6c6091;
$play_button: #D8574D; $play_button: #d8574d;
$play_button_hover: #FF6C60; $play_button_hover: #ff6c60;
$more_replies_dots: #AD433B; $more_replies_dots: #ad433b;
$error_red: #420A05; $error_red: #420a05;
$verified_blue: #1DA1F2; $verified_blue: #1da1f2;
$verified_business: #FAC82B; $verified_business: #fac82b;
$verified_government: #C1B6A4; $verified_government: #c1b6a4;
$icon_text: $fg_color; $icon_text: $fg_color;
$tab: $fg_color; $tab: $fg_color;
$tab_selected: $accent; $tab_selected: $accent;
$shadow: rgba(0,0,0,.6); $shadow: rgba(0, 0, 0, 0.6);
$shadow_dark: rgba(0,0,0,.2); $shadow_dark: rgba(0, 0, 0, 0.2);
//fonts //fonts
$font_0: Helvetica Neue; $font_0: sans-serif;
$font_1: Helvetica; $font_1: fontello;
$font_2: Arial;
$font_3: sans-serif;
$font_4: fontello;
+149 -113
View File
@@ -1,180 +1,216 @@
@import '_variables'; @import "_variables";
@import 'tweet/_base'; @import "tweet/_base";
@import 'profile/_base'; @import "profile/_base";
@import 'general'; @import "general";
@import 'navbar'; @import "navbar";
@import 'inputs'; @import "inputs";
@import 'timeline'; @import "timeline";
@import 'search'; @import "search";
body { body {
// colors // colors
--bg_color: #{$bg_color}; --bg_color: #{$bg_color};
--fg_color: #{$fg_color}; --fg_color: #{$fg_color};
--fg_faded: #{$fg_faded}; --fg_faded: #{$fg_faded};
--fg_dark: #{$fg_dark}; --fg_dark: #{$fg_dark};
--fg_nav: #{$fg_nav}; --fg_nav: #{$fg_nav};
--bg_panel: #{$bg_panel}; --bg_panel: #{$bg_panel};
--bg_elements: #{$bg_elements}; --bg_elements: #{$bg_elements};
--bg_overlays: #{$bg_overlays}; --bg_overlays: #{$bg_overlays};
--bg_hover: #{$bg_hover}; --bg_hover: #{$bg_hover};
--grey: #{$grey}; --grey: #{$grey};
--dark_grey: #{$dark_grey}; --dark_grey: #{$dark_grey};
--darker_grey: #{$darker_grey}; --darker_grey: #{$darker_grey};
--darkest_grey: #{$darkest_grey}; --darkest_grey: #{$darkest_grey};
--border_grey: #{$border_grey}; --border_grey: #{$border_grey};
--accent: #{$accent}; --accent: #{$accent};
--accent_light: #{$accent_light}; --accent_light: #{$accent_light};
--accent_dark: #{$accent_dark}; --accent_dark: #{$accent_dark};
--accent_border: #{$accent_border}; --accent_border: #{$accent_border};
--play_button: #{$play_button}; --play_button: #{$play_button};
--play_button_hover: #{$play_button_hover}; --play_button_hover: #{$play_button_hover};
--more_replies_dots: #{$more_replies_dots}; --more_replies_dots: #{$more_replies_dots};
--error_red: #{$error_red}; --error_red: #{$error_red};
--verified_blue: #{$verified_blue}; --verified_blue: #{$verified_blue};
--verified_business: #{$verified_business}; --verified_business: #{$verified_business};
--verified_government: #{$verified_government}; --verified_government: #{$verified_government};
--icon_text: #{$icon_text}; --icon_text: #{$icon_text};
--tab: #{$fg_color}; --tab: #{$fg_color};
--tab_selected: #{$accent}; --tab_selected: #{$accent};
--profile_stat: #{$fg_color}; --profile_stat: #{$fg_color};
background-color: var(--bg_color); background-color: var(--bg_color);
color: var(--fg_color); color: var(--fg_color);
font-family: $font_0, $font_1, $font_2, $font_3; font-family: $font_0, $font_1;
font-size: 14px; font-size: 15px;
line-height: 1.3; line-height: 1.3;
margin: 0; margin: 0;
} }
* { * {
outline: unset; outline: unset;
margin: 0; margin: 0;
text-decoration: none; text-decoration: none;
}
img {
dynamic-range-limit: standard;
} }
h1 { h1 {
display: inline; display: inline;
} }
h2, h3 { h2,
font-weight: normal; h3 {
font-weight: normal;
} }
p { p {
margin: 14px 0; margin: 14px 0;
} }
a { a {
color: var(--accent); color: var(--accent);
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} }
fieldset { fieldset {
border: 0; border: 0;
padding: 0; padding: 0;
margin-top: -0.6em; margin-top: -0.6em;
} }
legend { legend {
width: 100%; width: 100%;
padding: .6em 0 .3em 0; padding: 0.6em 0 0.3em 0;
border: 0; border: 0;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
border-bottom: 1px solid var(--border_grey); border-bottom: 1px solid var(--border_grey);
margin-bottom: 8px; margin-bottom: 8px;
} }
.preferences .note { .preferences {
.note {
border-top: 1px solid var(--border_grey); border-top: 1px solid var(--border_grey);
border-bottom: 1px solid var(--border_grey); border-bottom: 1px solid var(--border_grey);
padding: 6px 0 8px 0; padding: 6px 0 8px 0;
margin-bottom: 8px; margin-bottom: 8px;
margin-top: 16px; margin-top: 16px;
}
.bookmark-note {
margin: 0;
margin-bottom: 10px;
}
} }
ul { ul {
padding-left: 1.3em; padding-left: 1.3em;
} }
.container { .container {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
box-sizing: border-box; box-sizing: border-box;
padding-top: 50px; margin: auto;
margin: auto; min-height: 100vh;
min-height: 100vh; }
body.fixed-nav .container {
padding-top: 50px;
} }
.icon-container { .icon-container {
display: inline; display: inline;
} }
.overlay-panel { .overlay-panel {
max-width: 600px; max-width: 600px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
margin-top: 10px; margin-top: 10px;
background-color: var(--bg_overlays); background-color: var(--bg_overlays);
padding: 10px 15px; padding: 10px 15px;
align-self: start; align-self: start;
ul { ul {
margin-bottom: 14px; margin-bottom: 14px;
} }
p { p {
word-break: break-word; word-break: break-word;
} }
} }
.verified-icon { .verified-icon {
color: var(--icon_text); display: inline-block;
border-radius: 50%; width: 14px;
flex-shrink: 0; height: 14px;
margin: 2px 0 3px 3px; margin-left: 2px;
padding-top: 3px;
height: 11px;
width: 14px;
font-size: 8px;
display: inline-block;
text-align: center;
vertical-align: middle;
&.blue { .verified-icon-circle {
background-color: var(--verified_blue); position: absolute;
font-size: 15px;
}
.verified-icon-check {
position: absolute;
font-size: 9px;
margin: 5px 3px;
}
&.blue {
.verified-icon-circle {
color: var(--verified_blue);
} }
&.business { .verified-icon-check {
color: var(--bg_panel); color: var(--icon_text);
background-color: var(--verified_business); }
}
&.business {
.verified-icon-circle {
color: var(--verified_business);
} }
&.government { .verified-icon-check {
color: var(--bg_panel); color: var(--bg_panel);
background-color: var(--verified_government);
} }
}
&.government {
.verified-icon-circle {
color: var(--verified_government);
}
.verified-icon-check {
color: var(--bg_panel);
}
}
} }
@media(max-width: 600px) { @media (max-width: 600px) {
.preferences-container { .preferences-container {
max-width: 95vw; max-width: 95vw;
} }
.nav-item, .nav-item .icon-container { .nav-item,
font-size: 16px; .nav-item .icon-container {
} font-size: 16px;
}
} }
+154 -124
View File
@@ -1,185 +1,215 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
button { button {
@include input-colors; @include input-colors;
background-color: var(--bg_elements); background-color: var(--bg_elements);
color: var(--fg_color); color: var(--fg_color);
border: 1px solid var(--accent_border); border: 1px solid var(--accent_border);
padding: 3px 6px; padding: 3px 6px;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
float: right; float: right;
} }
input[type="text"], input[type="text"],
input[type="date"], input[type="date"],
input[type="number"],
select { select {
@include input-colors; @include input-colors;
background-color: var(--bg_elements); background-color: var(--bg_elements);
padding: 1px 4px; padding: 1px 4px;
color: var(--fg_color); color: var(--fg_color);
border: 1px solid var(--accent_border); border: 1px solid var(--accent_border);
border-radius: 0; border-radius: 0;
font-size: 14px; font-size: 14px;
} }
input[type="text"] { input[type="number"] {
height: 16px; -moz-appearance: textfield;
}
input[type="text"],
input[type="number"] {
height: 16px;
} }
select { select {
height: 20px; height: 20px;
padding: 0 2px; padding: 0 2px;
line-height: 1; line-height: 1;
} }
input[type="date"]::-webkit-inner-spin-button { input[type="date"]::-webkit-inner-spin-button {
display: none; display: none;
}
input[type="number"] {
-moz-appearance: textfield;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
display: none;
-webkit-appearance: none;
margin: 0;
} }
input[type="date"]::-webkit-clear-button { input[type="date"]::-webkit-clear-button {
margin-left: 17px; margin-left: 17px;
filter: grayscale(100%); filter: grayscale(100%);
filter: hue-rotate(120deg); filter: hue-rotate(120deg);
} }
input::-webkit-calendar-picker-indicator { input::-webkit-calendar-picker-indicator {
opacity: 0; opacity: 0;
} }
input::-webkit-datetime-edit-day-field:focus, input::-webkit-datetime-edit-day-field:focus,
input::-webkit-datetime-edit-month-field:focus, input::-webkit-datetime-edit-month-field:focus,
input::-webkit-datetime-edit-year-field:focus { input::-webkit-datetime-edit-year-field:focus {
background-color: var(--accent); background-color: var(--accent);
color: var(--fg_color); color: var(--fg_color);
outline: none; outline: none;
} }
.date-range { .date-range {
.date-input { .date-input {
display: inline-block; display: inline-block;
position: relative; position: relative;
} }
.icon-container { .icon-container {
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
top: 2px; top: 2px;
right: 5px; right: 5px;
} }
.search-title { .search-title {
margin: 0 2px; margin: 0 2px;
} }
} }
.icon-button button { .icon-button button {
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
background: none; background: none;
border: none; border: none;
float: none; float: none;
padding: unset; padding: unset;
padding-left: 4px; padding-left: 4px;
&:hover { &:hover {
color: var(--accent_light); color: var(--accent_light);
} }
} }
.checkbox { .checkbox {
position: absolute; position: absolute;
top: 1px; top: 1px;
right: 0; right: 0;
height: 17px; height: 17px;
width: 17px; width: 17px;
background-color: var(--bg_elements); background-color: var(--bg_elements);
border: 1px solid var(--accent_border); border: 1px solid var(--accent_border);
&:after { &:after {
content: ""; content: "";
position: absolute; position: absolute;
display: none; display: none;
} }
} }
.checkbox-container { .checkbox-container {
display: block; display: block;
position: relative; position: relative;
margin-bottom: 5px; margin-bottom: 5px;
cursor: pointer;
user-select: none;
padding-right: 22px;
input {
position: absolute;
opacity: 0;
cursor: pointer; cursor: pointer;
user-select: none; height: 0;
padding-right: 22px; width: 0;
input { &:checked ~ .checkbox:after {
position: absolute; display: block;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
&:checked ~ .checkbox:after {
display: block;
}
} }
}
&:hover input ~ .checkbox { &:hover input ~ .checkbox {
border-color: var(--accent); border-color: var(--accent);
} }
&:active input ~ .checkbox { &:active input ~ .checkbox {
border-color: var(--accent_light); border-color: var(--accent_light);
} }
.checkbox:after { .checkbox:after {
left: 2px; left: 2px;
bottom: 0; bottom: 0;
font-size: 13px; font-size: 13px;
font-family: $font_4; font-family: $font_1;
content: '\e803'; content: "\e811";
} }
} }
.pref-group { .pref-group {
display: inline; display: inline;
} }
.preferences { .preferences {
button { button {
margin: 6px 0 3px 0; margin: 6px 0 3px 0;
} }
label { label {
padding-right: 150px; padding-right: 150px;
} }
select { select {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
display: block; display: block;
-moz-appearance: none; -moz-appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
} }
input[type="text"] { input[type="text"],
position: absolute; input[type="number"] {
right: 0; position: absolute;
max-width: 140px; right: 0;
} max-width: 140px;
}
.pref-group { .pref-group {
display: block; display: block;
} }
.pref-input { .pref-input {
position: relative; position: relative;
margin-bottom: 6px; margin-bottom: 6px;
} }
.pref-reset { .pref-reset {
float: left; float: left;
} }
.prefs-code {
background-color: var(--bg_elements);
border: 1px solid var(--accent_border);
color: var(--fg_color);
font-size: 13px;
padding: 6px 8px;
margin: 4px 0;
word-break: break-all;
white-space: pre-wrap;
user-select: all;
}
} }
+62 -61
View File
@@ -1,89 +1,90 @@
@import '_variables'; @import "_variables";
nav { nav {
display: flex; display: flex;
align-items: center; align-items: center;
position: fixed; background-color: var(--bg_overlays);
background-color: var(--bg_overlays); box-shadow: 0 0 4px $shadow;
box-shadow: 0 0 4px $shadow; padding: 0;
padding: 0; width: 100%;
width: 100%; height: 50px;
height: 50px; z-index: 1000;
z-index: 1000; font-size: 16px;
font-size: 16px;
a, .icon-button button { a,
color: var(--fg_nav); .icon-button button {
} color: var(--fg_nav);
}
body.fixed-nav & {
position: fixed;
}
} }
.inner-nav { .inner-nav {
margin: auto; margin: auto;
box-sizing: border-box; box-sizing: border-box;
padding: 0 10px; padding: 0 10px;
display: flex; display: flex;
align-items: center; align-items: center;
flex-basis: 920px; flex-basis: 920px;
height: 50px; height: 50px;
} }
.site-name { .site-name {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
&:hover { &:hover {
color: var(--accent_light); color: var(--accent_light);
text-decoration: unset; text-decoration: unset;
} }
} }
.site-logo { .site-logo {
display: block; display: block;
width: 35px; width: 35px;
height: 35px; height: 35px;
} }
.nav-item { .nav-item {
display: flex; display: flex;
flex: 1; flex: 1;
line-height: 50px; line-height: 50px;
height: 50px; height: 50px;
overflow: hidden; overflow: hidden;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
&.right { &.right {
text-align: right; text-align: right;
justify-content: flex-end; justify-content: flex-end;
} }
&.right a { &.right a:hover {
padding-left: 4px; color: var(--accent_light);
text-decoration: unset;
&:hover { }
color: var(--accent_light);
text-decoration: unset;
}
}
} }
.lp { .lp {
height: 14px; height: 14px;
display: inline-block; display: inline-block;
position: relative; position: relative;
top: 2px; top: 2px;
fill: var(--fg_nav); fill: var(--fg_nav);
&:hover { &:hover {
fill: var(--accent_light); fill: var(--accent_light);
} }
} }
.icon-info:before { .icon-info {
margin: 0 -3px; margin: 0 -3px;
} }
.icon-cog { .icon-cog {
font-size: 15px; font-size: 15px;
padding-left: 0 !important;
} }
+5 -1
View File
@@ -39,7 +39,11 @@
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
max-width: 32%; max-width: 32%;
top: 50px; top: 0;
body.fixed-nav & {
top: 50px;
}
} }
.profile-result { .profile-result {
+86 -88
View File
@@ -1,122 +1,120 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
.search-title { .search-title {
font-weight: bold; font-weight: bold;
display: inline-block; display: inline-block;
margin-top: 4px; margin-top: 4px;
} }
.search-field { .search-field {
display: flex;
flex-wrap: wrap;
button {
margin: 0 2px 0 0;
padding: 0px 1px 1px 4px;
height: 23px;
display: flex; display: flex;
flex-wrap: wrap; align-items: center;
}
button { .pref-input {
margin: 0 2px 0 0; margin: 0 4px 0 0;
height: 23px; flex-grow: 1;
display: flex; height: 23px;
align-items: center; }
}
.pref-input { input[type="text"],
margin: 0 4px 0 0; input[type="number"] {
flex-grow: 1; height: calc(100% - 4px);
height: 23px; width: calc(100% - 8px);
} }
input[type="text"] { > label {
height: calc(100% - 4px); display: inline;
width: calc(100% - 8px); background-color: var(--bg_elements);
} color: var(--fg_color);
border: 1px solid var(--accent_border);
padding: 1px 1px 2px 4px;
font-size: 14px;
cursor: pointer;
margin-bottom: 2px;
> label { @include input-colors;
display: inline; }
background-color: var(--bg_elements);
color: var(--fg_color);
border: 1px solid var(--accent_border);
padding: 1px 6px 2px 6px;
font-size: 14px;
cursor: pointer;
margin-bottom: 2px;
@include input-colors; @include create-toggle(search-panel, 380px);
}
@include create-toggle(search-panel, 200px);
} }
.search-panel { .search-panel {
width: 100%; width: 100%;
max-height: 0; max-height: 0;
overflow: hidden; overflow: hidden;
transition: max-height 0.4s; transition: max-height 0.4s;
flex-grow: 1; flex-grow: 1;
font-weight: initial; font-weight: initial;
text-align: left; text-align: left;
> div { .checkbox-container {
line-height: 1.7em; display: inline;
} padding-right: unset;
margin-bottom: 5px;
margin-left: 23px;
}
.checkbox-container { .checkbox {
display: inline; right: unset;
padding-right: unset; left: -22px;
margin-bottom: unset; line-height: 1.6em;
margin-left: 23px; }
}
.checkbox { .checkbox-container .checkbox:after {
right: unset; top: -4px;
left: -22px; }
}
.checkbox-container .checkbox:after {
top: -4px;
}
} }
.search-row { .search-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
line-height: unset; line-height: unset;
> div { > div {
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;
} }
input {
height: 21px;
}
.pref-input {
display: block;
padding-bottom: 5px;
input { input {
height: 21px; height: 21px;
} margin-top: 1px;
.pref-input {
display: block;
padding-bottom: 5px;
input {
height: 21px;
margin-top: 1px;
}
} }
}
} }
.search-toggles { .search-toggles {
flex-grow: 1; flex-grow: 1;
display: grid; display: grid;
grid-template-columns: repeat(6, auto); grid-template-columns: repeat(5, auto);
grid-column-gap: 10px; grid-column-gap: 10px;
} }
.profile-tabs { .profile-tabs {
@include search-resize(820px, 5); @include search-resize(820px, 5);
@include search-resize(725px, 4); @include search-resize(715px, 4);
@include search-resize(600px, 6); @include search-resize(700px, 5);
@include search-resize(560px, 5); @include search-resize(485px, 4);
@include search-resize(480px, 4); @include search-resize(410px, 3);
@include search-resize(410px, 3);
} }
@include search-resize(560px, 5); @include search-resize(700px, 5);
@include search-resize(480px, 4); @include search-resize(485px, 4);
@include search-resize(410px, 3); @include search-resize(410px, 3);
+103 -106
View File
@@ -1,162 +1,159 @@
@import '_variables'; @import "_variables";
.timeline-container { .timeline-container {
@include panel(100%, 600px); @include panel(100%, 600px);
} }
.timeline { .timeline > div:not(:first-child) {
background-color: var(--bg_panel); border-top: 1px solid var(--border_grey);
> div:not(:first-child) {
border-top: 1px solid var(--border_grey);
}
} }
.timeline-header { .timeline-header {
width: 100%; width: 100%;
background-color: var(--bg_panel); background-color: var(--bg_panel);
text-align: center; text-align: center;
padding: 8px; padding: 8px;
display: block; display: block;
font-weight: bold; font-weight: bold;
margin-bottom: 5px; margin-bottom: 5px;
box-sizing: border-box; box-sizing: border-box;
button { button {
float: unset; float: unset;
} }
} }
.timeline-banner img { .timeline-banner img {
width: 100%; width: 100%;
} }
.timeline-description { .timeline-description {
font-weight: normal; font-weight: normal;
} }
.tab { .tab {
align-items: center; align-items: center;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
list-style: none; list-style: none;
margin: 0 0 5px 0; margin: 0 0 5px 0;
background-color: var(--bg_panel); background-color: var(--bg_panel);
padding: 0; padding: 0;
} }
.tab-item { .tab-item {
flex: 1 1 0; flex: 1 1 0;
text-align: center; text-align: center;
margin-top: 0; margin-top: 0;
a { a {
border-bottom: .1rem solid transparent; border-bottom: 0.1rem solid transparent;
color: var(--tab); color: var(--tab);
display: block; display: block;
padding: 8px 0; padding: 8px 0;
text-decoration: none; text-decoration: none;
font-weight: bold; font-weight: bold;
&:hover { &:hover {
text-decoration: none; text-decoration: none;
}
&.active {
border-bottom-color: var(--tab_selected);
color: var(--tab_selected);
}
} }
&.active a { &.active {
border-bottom-color: var(--tab_selected); border-bottom-color: var(--tab_selected);
color: var(--tab_selected); color: var(--tab_selected);
} }
}
&.wide { &.active a {
flex-grow: 1.2; border-bottom-color: var(--tab_selected);
flex-basis: 50px; color: var(--tab_selected);
} }
&.wide {
flex-grow: 1.2;
flex-basis: 50px;
}
} }
.timeline-footer { .timeline-footer {
background-color: var(--bg_panel); background-color: var(--bg_panel);
padding: 6px 0; padding: 6px 0;
} }
.timeline-protected { .timeline-protected {
text-align: center; text-align: center;
p { p {
margin: 8px 0; margin: 8px 0;
} }
h2 { h2 {
color: var(--accent);
font-size: 20px;
font-weight: 600;
}
}
.timeline-none {
color: var(--accent); color: var(--accent);
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
text-align: center; }
}
.timeline-none {
color: var(--accent);
font-size: 20px;
font-weight: 600;
text-align: center;
} }
.timeline-end { .timeline-end {
background-color: var(--bg_panel); background-color: var(--bg_panel);
color: var(--accent); color: var(--accent);
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
text-align: center; text-align: center;
} }
.show-more { .show-more {
background-color: var(--bg_panel); background-color: var(--bg_panel);
text-align: center; text-align: center;
padding: .75em 0; padding: 0.75em 0;
display: block !important; display: block !important;
a { a {
background-color: var(--darkest_grey); background-color: var(--darkest_grey);
display: inline-block; display: inline-block;
height: 2em; height: 2em;
padding: 0 2em; padding: 0 2em;
line-height: 2em; line-height: 2em;
&:hover { &:hover {
background-color: var(--darker_grey); background-color: var(--darker_grey);
}
} }
}
} }
.top-ref { .top-ref {
background-color: var(--bg_color); background-color: var(--bg_color);
border-top: none !important; border-top: none !important;
.icon-down { .icon-down {
font-size: 20px; font-size: 20px;
display: flex; display: flex;
justify-content: center; justify-content: center;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
color: var(--accent_light); color: var(--accent_light);
}
&::before {
transform: rotate(180deg) translateY(-1px);
}
} }
&::before {
transform: rotate(180deg) translateY(-1px);
}
}
} }
.timeline-item { .timeline-item {
overflow-wrap: break-word; overflow-wrap: break-word;
border-left-width: 0; border-left-width: 0;
min-width: 0; min-width: 0;
padding: .75em; padding: 0.75em;
display: flex; display: flex;
position: relative; position: relative;
background-color: var(--bg_panel);
} }
+169 -153
View File
@@ -1,240 +1,256 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
@import 'thread'; @import "thread";
@import 'media'; @import "media";
@import 'video'; @import "video";
@import 'embed'; @import "embed";
@import 'card'; @import "card";
@import 'poll'; @import "poll";
@import 'quote'; @import "quote";
.tweet-body { .tweet-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin-left: 58px; margin-left: 58px;
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 1;
} }
.tweet-content { .tweet-content {
font-family: $font_3; line-height: 1.3em;
line-height: 1.3em; pointer-events: all;
pointer-events: all; display: inline;
display: inline;
} }
.tweet-bidi { .tweet-bidi {
display: block !important; display: block !important;
} }
.tweet-header { .tweet-header {
padding: 0; padding: 0;
vertical-align: bottom; vertical-align: bottom;
flex-basis: 100%; flex-basis: 100%;
margin-bottom: .2em; margin-bottom: 0.2em;
a { a {
display: inline-block; display: inline-block;
word-break: break-all; word-break: break-all;
max-width: 100%; max-width: 100%;
pointer-events: all; pointer-events: all;
} }
} }
.tweet-name-row { .tweet-name-row {
padding: 0; padding: 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
.fullname-and-username { .fullname-and-username {
display: flex; display: flex;
min-width: 0; min-width: 0;
} }
.fullname { .fullname {
@include ellipsis; @include ellipsis;
flex-shrink: 2; flex-shrink: 2;
max-width: 80%; max-width: 80%;
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: var(--fg_color); color: var(--fg_color);
} }
.username { .username {
@include ellipsis; @include ellipsis;
min-width: 1.6em; min-width: 1.6em;
margin-left: .4em; margin-left: 0.4em;
word-wrap: normal; word-wrap: normal;
} }
.tweet-date { .tweet-date {
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
margin-left: 4px; margin-left: 4px;
} }
.tweet-date a, .username, .show-more a { .tweet-date a,
color: var(--fg_dark); .username,
.show-more a {
color: var(--fg_dark);
} }
.tweet-published { .tweet-published {
margin: 0; margin-top: 10px;
margin-top: 5px; margin-bottom: 3px;
color: var(--grey); color: var(--grey);
pointer-events: all; pointer-events: all;
} }
.tweet-avatar { .tweet-avatar {
display: contents !important; display: contents !important;
img { img {
float: left; float: left;
margin-top: 3px; margin-top: 3px;
margin-left: -58px; margin-left: -58px;
width: 48px; width: 48px;
height: 48px; height: 48px;
} }
} }
.avatar { .avatar {
&.round { &.round {
border-radius: 50%; border-radius: 50%;
-webkit-user-select: none; -webkit-user-select: none;
} }
&.mini { &.mini {
position: unset; position: unset;
margin-right: 5px; margin-right: 5px;
margin-top: -1px; margin-top: -1px;
width: 20px; width: 20px;
height: 20px; height: 20px;
} }
} }
.tweet-embed { .tweet-embed {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
background-color: var(--bg_panel);
.tweet-content {
font-size: 18px;
}
.tweet-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; max-height: calc(100vh - 0.75em * 2);
height: 100%; }
background-color: var(--bg_panel);
.tweet-content { .card-image img {
font-size: 18px; height: auto;
} }
.tweet-body {
display: flex;
flex-direction: column;
max-height: calc(100vh - 0.75em * 2);
}
.card-image img { .avatar {
height: auto; position: absolute;
} }
.avatar {
position: absolute;
}
} }
.attribution { .attribution {
display: flex; display: flex;
pointer-events: all; pointer-events: all;
margin: 5px 0; margin: 5px 0;
strong { strong {
color: var(--fg_color); color: var(--fg_color);
} }
} }
.media-tag-block { .media-tag-block {
padding-top: 5px; padding-top: 5px;
pointer-events: all; pointer-events: all;
color: var(--fg_faded);
.icon-container {
padding-right: 2px;
}
.media-tag,
.icon-container {
color: var(--fg_faded); color: var(--fg_faded);
}
.icon-container {
padding-right: 2px;
}
.media-tag, .icon-container {
color: var(--fg_faded);
}
} }
.timeline-container .media-tag-block { .timeline-container .media-tag-block {
font-size: 13px; font-size: 13px;
} }
.tweet-geo { .tweet-geo {
color: var(--fg_faded); color: var(--fg_faded);
} }
.replying-to { .replying-to {
color: var(--fg_faded); color: var(--fg_faded);
margin: -2px 0 4px; margin: -2px 0 4px;
a { a {
pointer-events: all; pointer-events: all;
} }
} }
.retweet-header, .pinned, .tweet-stats { .retweet-header,
align-content: center; .pinned,
color: var(--grey); .tweet-stats {
display: flex; align-content: center;
flex-shrink: 0; color: var(--grey);
flex-wrap: wrap; display: flex;
font-size: 14px; flex-shrink: 0;
font-weight: 600; flex-wrap: wrap;
line-height: 22px; font-size: 14px;
font-weight: 600;
line-height: 22px;
span { span {
@include ellipsis; @include ellipsis;
} }
} }
.retweet-header { .retweet-header {
margin-top: -5px !important; margin-top: -5px !important;
} }
.tweet-stats { .tweet-stats {
margin-bottom: -3px; margin-bottom: -3px;
-webkit-user-select: none; -webkit-user-select: none;
} }
.tweet-stat { .tweet-stat {
padding-top: 5px; padding-top: 5px;
min-width: 1em; min-width: 1em;
margin-right: 0.8em; margin-right: 0.8em;
} }
.show-thread { .show-thread {
display: block; display: block;
pointer-events: all; pointer-events: all;
padding-top: 2px; padding-top: 2px;
} }
.unavailable-box { .unavailable-box {
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 12px; padding: 12px;
border: solid 1px var(--dark_grey); border: solid 1px var(--dark_grey);
box-sizing: border-box; box-sizing: border-box;
border-radius: 10px; border-radius: 10px;
background-color: var(--bg_color); background-color: var(--bg_color);
z-index: 2; z-index: 2;
} }
.tweet-link { .tweet-link {
height: 100%; height: 100%;
width: 100%; width: 100%;
left: 0; left: 0;
top: 0; top: 0;
position: absolute; position: absolute;
-webkit-user-select: none; -webkit-user-select: none;
&:hover { &:hover {
background-color: var(--bg_hover); background-color: var(--bg_hover);
} }
}
.latest-post-version {
border-bottom: 1px solid var(--dark_grey);
border-top: 1px solid var(--dark_grey);
padding: 01ch 0px;
margin: 1ch 0px;
color: var(--grey);
a {
pointer-events: all;
}
} }
+13 -13
View File
@@ -1,17 +1,17 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
.embed-video { .embed-video {
.gallery-video { .gallery-video {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: absolute; position: absolute;
background-color: black; background-color: black;
top: 0%; top: 0%;
left: 0%; left: 0%;
} }
.video-container { .video-container {
max-height: unset; max-height: unset;
} }
} }
+100 -73
View File
@@ -1,76 +1,103 @@
@import '_variables'; @import "_variables";
.gallery-row { .gallery-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; overflow: hidden;
overflow: hidden; flex-grow: 1;
flex-grow: 1; max-height: 379.5px;
max-height: 379.5px; max-width: 533px;
max-width: 533px; pointer-events: all;
pointer-events: all;
.still-image { .still-image {
width: 100%; width: 100%;
display: flex; align-self: center;
} }
} }
.attachments { .attachments {
margin-top: .35em; margin-top: 0.35em;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
max-height: 600px; max-height: 600px;
border-radius: 7px; border-radius: 7px;
overflow: hidden; overflow: hidden;
flex-flow: column; flex-flow: column;
background-color: var(--bg_color); background-color: var(--bg_color);
align-items: center; align-items: center;
pointer-events: all; pointer-events: all;
.image-attachment { .image-attachment {
width: 100%; width: 100%;
} }
} }
.attachment { .attachment {
position: relative; position: relative;
line-height: 0; line-height: 0;
overflow: hidden; overflow: hidden;
margin: 0 .25em 0 0; margin: 0 0.25em 0 0;
flex-grow: 1; flex-grow: 1;
box-sizing: border-box; box-sizing: border-box;
min-width: 2em; min-width: 2em;
&:last-child { &:last-child {
margin: 0; margin: 0;
max-height: 530px; max-height: 530px;
} }
} }
.gallery-gif video { .gallery-gif video {
max-height: 530px; max-height: 530px;
background-color: #101010; background-color: #101010;
} }
.still-image { .still-image {
max-height: 379.5px; max-height: 379.5px;
max-width: 533px; max-width: 533px;
justify-content: center;
img { img {
object-fit: cover; object-fit: cover;
max-width: 100%; max-width: 100%;
max-height: 379.5px; max-height: 379.5px;
flex-basis: 300px; flex-basis: 300px;
flex-grow: 1; flex-grow: 1;
} }
}
.alt-text {
margin: 0px;
padding: 11px 7px;
box-sizing: border-box;
position: absolute;
bottom: 10px;
left: 10px;
width: 2.98em;
max-height: 25px;
white-space: pre;
overflow: hidden;
border-radius: 10px;
color: var(--fg_color);
font-size: 12px;
font-weight: bold;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(12px);
}
.alt-text:hover {
padding: 7px;
width: Min(230px, calc(100% - 10px * 2));
max-height: calc(100% - 10px);
line-height: 1.2em;
white-space: pre-wrap;
transition-duration: 0.4s;
transition-property: max-height;
} }
.image { .image {
display: inline-block; display: flex;
} }
// .single-image { // .single-image {
@@ -86,34 +113,34 @@
// } // }
.overlay-circle { .overlay-circle {
border-radius: 50%; border-radius: 50%;
background-color: var(--dark_grey); background-color: var(--dark_grey);
width: 40px; width: 40px;
height: 40px; height: 40px;
align-items: center; align-items: center;
display: flex; display: flex;
border-width: 5px; border-width: 5px;
border-color: var(--play_button); border-color: var(--play_button);
border-style: solid; border-style: solid;
} }
.overlay-triangle { .overlay-triangle {
width: 0; width: 0;
height: 0; height: 0;
border-style: solid; border-style: solid;
border-width: 12px 0 12px 17px; border-width: 12px 0 12px 17px;
border-color: transparent transparent transparent var(--play_button); border-color: transparent transparent transparent var(--play_button);
margin-left: 14px; margin-left: 14px;
} }
.media-gif { .media-gif {
display: table; display: table;
background-color: unset; background-color: unset;
width: unset; width: unset;
} }
.media-body { .media-body {
flex: 1; flex: 1;
padding: 0; padding: 0;
white-space: pre-wrap; white-space: pre-wrap;
} }
+24 -24
View File
@@ -1,42 +1,42 @@
@import '_variables'; @import "_variables";
.poll-meter { .poll-meter {
overflow: hidden; overflow: hidden;
position: relative; position: relative;
margin: 6px 0; margin: 6px 0;
height: 26px; height: 26px;
background: var(--bg_color); background: var(--bg_color);
border-radius: 5px; border-radius: 5px;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.poll-choice-bar { .poll-choice-bar {
height: 100%; height: 100%;
position: absolute; position: absolute;
background: var(--dark_grey); background: var(--dark_grey);
} }
.poll-choice-value { .poll-choice-value {
position: relative; position: relative;
font-weight: bold; font-weight: bold;
margin-left: 5px; margin-left: 5px;
margin-right: 6px; margin-right: 6px;
min-width: 30px; min-width: 30px;
text-align: right; text-align: right;
pointer-events: all; pointer-events: all;
} }
.poll-choice-option { .poll-choice-option {
position: relative; position: relative;
pointer-events: all; pointer-events: all;
} }
.poll-info { .poll-info {
color: var(--grey); color: var(--grey);
pointer-events: all; pointer-events: all;
} }
.leader .poll-choice-bar { .leader .poll-choice-bar {
background: var(--accent_dark); background: var(--accent_dark);
} }
+80 -73
View File
@@ -1,94 +1,101 @@
@import '_variables'; @import "_variables";
.quote { .quote {
margin-top: 10px; margin-top: 10px;
border: solid 1px var(--dark_grey); border: solid 1px var(--dark_grey);
border-radius: 10px; border-radius: 10px;
background-color: var(--bg_elements); background-color: var(--bg_elements);
overflow: hidden;
pointer-events: all;
position: relative;
width: 100%;
&:hover {
border-color: var(--grey);
}
&.unavailable:hover {
border-color: var(--dark_grey);
}
.tweet-name-row {
padding: 6px 8px;
margin-top: 1px;
}
.quote-text {
overflow: hidden; overflow: hidden;
pointer-events: all; white-space: pre-wrap;
position: relative; word-wrap: break-word;
width: 100%; padding: 0px 8px 8px 8px;
}
&:hover { .show-thread {
border-color: var(--grey); padding: 0px 8px 6px 8px;
} margin-top: -6px;
}
&.unavailable:hover { .quote-latest {
border-color: var(--dark_grey); padding: 0px 8px 6px 8px;
} color: var(--grey);
}
.tweet-name-row { .replying-to {
padding: 6px 8px; padding: 0px 8px;
margin-top: 1px; margin: unset;
} }
.quote-text {
overflow: hidden;
white-space: pre-wrap;
word-wrap: break-word;
padding: 0px 8px 8px 8px;
}
.show-thread {
padding: 0px 8px 6px 8px;
margin-top: -6px;
}
.replying-to {
padding: 0px 8px;
margin: unset;
}
} }
.unavailable-quote { .unavailable-quote {
padding: 12px; padding: 12px;
display: block;
} }
.quote-link { .quote-link {
width: 100%; width: 100%;
height: 100%; height: 100%;
left: 0; left: 0;
top: 0; top: 0;
position: absolute; position: absolute;
} }
.quote-media-container { .quote-media-container {
max-height: 300px; max-height: 300px;
display: flex;
.card {
margin: unset;
}
.attachments {
border-radius: 0;
}
.media-gif {
width: 100%;
display: flex; display: flex;
justify-content: center;
}
.card { .gallery-gif .attachment {
margin: unset; display: flex;
justify-content: center;
background-color: var(--bg_color);
video {
height: unset;
width: unset;
max-height: 100%;
max-width: 100%;
} }
}
.attachments { .gallery-video,
border-radius: 0; .gallery-gif {
} max-height: 300px;
}
.media-gif { .still-image img {
width: 100%; max-height: 250px;
display: flex; }
justify-content: center;
}
.gallery-gif .attachment {
display: flex;
justify-content: center;
background-color: var(--bg_color);
video {
height: unset;
width: unset;
max-height: 100%;
max-width: 100%;
}
}
.gallery-video, .gallery-gif {
max-height: 300px;
}
.still-image img {
max-height: 250px
}
} }
+122 -106
View File
@@ -1,138 +1,154 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
.conversation { .conversation,
@include panel(100%, 600px); .edit-history {
@include panel(100%, 600px);
.show-more { .show-more {
margin-bottom: 10px; margin-bottom: 10px;
} }
} }
.main-thread { .main-thread,
margin-bottom: 20px; .latest-edit {
background-color: var(--bg_panel); margin-bottom: 20px;
}
.main-tweet, .replies {
padding-top: 50px;
margin-top: -50px;
}
.main-tweet .tweet-content {
font-size: 18px;
}
@media(max-width: 600px) {
.main-tweet .tweet-content {
font-size: 16px;
}
} }
.reply { .reply {
background-color: var(--bg_panel); margin-bottom: 10px;
margin-bottom: 10px; }
.main-tweet,
.replies,
.edit-history > div {
body.fixed-nav & {
padding-top: 50px;
margin-top: -50px;
}
}
.edit-history-header {
padding: 10px;
margin-bottom: 5px;
font-size: 16px;
font-weight: bold;
background-color: var(--bg_panel);
}
.tweet-edit {
margin-bottom: 5px;
}
.main-tweet .tweet-content {
font-size: 18px;
}
@media (max-width: 600px) {
.main-tweet .tweet-content {
font-size: 16px;
}
} }
.thread-line { .thread-line {
.timeline-item::before, .timeline-item::before,
&.timeline-item::before { &.timeline-item::before {
background: var(--accent_dark); background: var(--accent_dark);
content: ''; content: "";
position: relative; position: relative;
min-width: 3px; min-width: 3px;
width: 3px; width: 3px;
left: 26px; left: 26px;
border-radius: 2px; border-radius: 2px;
margin-left: -3px; margin-left: -3px;
margin-bottom: 37px; margin-bottom: 37px;
top: 56px; top: 56px;
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
} }
.with-header:not(:first-child)::after { .with-header:not(:first-child)::after {
background: var(--accent_dark); background: var(--accent_dark);
content: ''; content: "";
position: relative; position: relative;
float: left; float: left;
min-width: 3px; min-width: 3px;
width: 3px; width: 3px;
right: calc(100% - 26px); right: calc(100% - 26px);
border-radius: 2px; border-radius: 2px;
margin-left: -3px; margin-left: -3px;
margin-bottom: 37px; margin-bottom: 37px;
bottom: 10px; bottom: 10px;
height: 30px; height: 30px;
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
} }
.unavailable::before { .unavailable::before {
top: 48px; top: 48px;
margin-bottom: 28px; margin-bottom: 28px;
} }
.more-replies::before { .more-replies::before {
content: '...'; content: "...";
background: unset; background: unset;
color: var(--more_replies_dots); color: var(--more_replies_dots);
font-weight: bold; font-weight: bold;
font-size: 20px; font-size: 20px;
line-height: 0.25em; line-height: 0.25em;
left: 1.2em; left: 1.2em;
width: 5px; width: 5px;
top: 2px; top: 2px;
margin-bottom: 0; margin-bottom: 0;
margin-left: -2.5px; margin-left: -2.5px;
} }
.earlier-replies { .earlier-replies {
padding-bottom: 0; padding-bottom: 0;
margin-bottom: -5px; margin-bottom: -5px;
} }
} }
.timeline-item.thread-last::before { .timeline-item.thread-last::before {
background: unset; background: unset;
min-width: unset; min-width: unset;
width: 0; width: 0;
margin: 0; margin: 0;
} }
.more-replies { .more-replies {
padding-top: 0.3em !important; padding-top: 0.3em !important;
} }
.more-replies-text { .more-replies-text {
@include ellipsis; @include ellipsis;
display: block; display: block;
margin-left: 58px; margin-left: 58px;
padding: 7px 0; padding: 7px 0;
} }
.timeline-item.thread.more-replies-thread { .timeline-item.thread.more-replies-thread {
padding: 0 0.75em; padding: 0 0.75em;
&::before {
top: 40px;
margin-bottom: 31px;
}
.more-replies {
display: flex;
padding-top: unset !important;
margin-top: 8px;
&::before { &::before {
top: 40px; display: inline-block;
margin-bottom: 31px; position: relative;
top: -1px;
line-height: 0.4em;
} }
.more-replies { .more-replies-text {
display: flex; display: inline;
padding-top: unset !important;
margin-top: 8px;
&::before {
display: inline-block;
position: relative;
top: -1px;
line-height: 0.4em;
}
.more-replies-text {
display: inline;
}
} }
}
} }
+57 -48
View File
@@ -1,68 +1,77 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
video { video {
max-height: 100%; height: 100%;
width: 100%; width: 100%;
} }
.gallery-video { .gallery-video {
display: flex; display: flex;
overflow: hidden; overflow: hidden;
} }
.gallery-video.card-container { .gallery-video.card-container {
flex-direction: column; flex-direction: column;
width: 100%;
} }
.video-container { .video-container {
min-height: 80px; min-height: 80px;
min-width: 200px; min-width: 200px;
max-height: 530px; max-height: 530px;
margin: 0; margin: 0;
display: flex;
align-items: center;
justify-content: center;
img { img {
max-height: 100%; max-height: 100%;
max-width: 100%; max-width: 100%;
} }
} }
.video-overlay { .video-overlay {
@include play-button; @include play-button;
background-color: $shadow; background-color: $shadow;
p { p {
position: relative; position: relative;
z-index: 0; z-index: 0;
text-align: center; text-align: center;
top: calc(50% - 20px); top: calc(50% - 20px);
font-size: 20px; font-size: 20px;
line-height: 1.3; line-height: 1.3;
margin: 0 20px; margin: 0 20px;
} }
div { .overlay-circle {
position: relative; position: relative;
z-index: 0; z-index: 0;
top: calc(50% - 20px); top: calc(50% - 20px);
margin: 0 auto; margin: 0 auto;
width: 40px; width: 40px;
height: 40px; height: 40px;
} }
form { .overlay-duration {
width: 100%; position: absolute;
height: 100%; bottom: 8px;
align-items: center; left: 8px;
justify-content: center; background-color: #0000007a;
display: flex; line-height: 1em;
} padding: 4px 6px 4px 6px;
border-radius: 5px;
font-weight: bold;
}
button { form {
padding: 5px 8px; width: 100%;
font-size: 16px; height: 100%;
} align-items: center;
justify-content: center;
display: flex;
}
button {
padding: 5px 8px;
font-size: 16px;
}
} }
+62
View File
@@ -0,0 +1,62 @@
import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]
import nimcrypto
import experimental/parser/tid
randomize()
const defaultKeyword = "obfiowerehiring";
const pairsUrl =
"https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json";
var
cachedPairs: seq[TidPair] = @[]
lastCached = 0
# refresh every hour
ttlSec = 60 * 60
proc getPair(): Future[TidPair] {.async.} =
if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec:
lastCached = int(epochTime())
let client = newAsyncHttpClient()
defer: client.close()
let resp = await client.get(pairsUrl)
if resp.status == $Http200:
cachedPairs = parseTidPairs(await resp.body)
return sample(cachedPairs)
proc encodeSha256(text: string): array[32, byte] =
let
data = cast[ptr byte](addr text[0])
dataLen = uint(len(text))
digest = sha256.digest(data, dataLen)
return digest.data
proc encodeBase64[T](data: T): string =
return encode(data).replace("=", "")
proc decodeBase64(data: string): seq[byte] =
return cast[seq[byte]](decode(data))
proc genTid*(path: string): Future[string] {.async.} =
let
pair = await getPair()
timeNow = int(epochTime() - 1682924400)
timeNowBytes = @[
byte(timeNow and 0xff),
byte((timeNow shr 8) and 0xff),
byte((timeNow shr 16) and 0xff),
byte((timeNow shr 24) and 0xff)
]
data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey
hashBytes = encodeSha256(data)
keyBytes = decodeBase64(pair.verification)
bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8]
randomNum = byte(rand(256))
tid = @[randomNum] & bytesArr.mapIt(it xor randomNum)
return encodeBase64(tid)
+28 -22
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import times, sequtils, options, tables, uri import times, sequtils, options, tables
import prefs_impl import prefs_impl
genPrefsType() genPrefsType()
@@ -13,19 +13,13 @@ type
TimelineKind* {.pure.} = enum TimelineKind* {.pure.} = enum
tweets, replies, media tweets, replies, media
Api* {.pure.} = enum ApiUrl* = object
tweetDetail endpoint*: string
tweetResult params*: seq[(string, string)]
search
list ApiReq* = object
listBySlug oauth*: ApiUrl
listMembers cookie*: ApiUrl
listTweets
userRestId
userScreenName
userTweets
userTweetsAndReplies
userMedia
RateLimit* = object RateLimit* = object
limit*: int limit*: int
@@ -42,7 +36,7 @@ type
pending*: int pending*: int
limited*: bool limited*: bool
limitedAt*: int limitedAt*: int
apis*: Table[Api, RateLimit] apis*: Table[string, RateLimit]
case kind*: SessionKind case kind*: SessionKind
of oauth: of oauth:
oauthToken*: string oauthToken*: string
@@ -51,10 +45,6 @@ type
authToken*: string authToken*: string
ct0*: string ct0*: string
SessionAwareUrl* = object
oauthUrl*: Uri
cookieUrl*: Uri
Error* = enum Error* = enum
null = 0 null = 0
noUserMatches = 17 noUserMatches = 17
@@ -140,13 +130,17 @@ type
fromUser*: seq[string] fromUser*: seq[string]
since*: string since*: string
until*: string until*: string
near*: string minLikes*: string
sep*: string sep*: string
Gif* = object Gif* = object
url*: string url*: string
thumb*: string thumb*: string
Photo* = object
url*: string
altText*: string
GalleryPhoto* = object GalleryPhoto* = object
url*: string url*: string
tweetId*: string tweetId*: string
@@ -227,7 +221,8 @@ type
poll*: Option[Poll] poll*: Option[Poll]
gif*: Option[Gif] gif*: Option[Gif]
video*: Option[Video] video*: Option[Video]
photos*: seq[string] photos*: seq[Photo]
history*: seq[int64]
Tweets* = seq[Tweet] Tweets* = seq[Tweet]
@@ -248,6 +243,10 @@ type
after*: Chain after*: Chain
replies*: Result[Chain] replies*: Result[Chain]
EditHistory* = object
latest*: Tweet
history*: Tweets
Timeline* = Result[Tweets] Timeline* = Result[Tweets]
Profile* = object Profile* = object
@@ -281,10 +280,17 @@ type
hmacKey*: string hmacKey*: string
base64Media*: bool base64Media*: bool
minTokens*: int minTokens*: int
enableRss*: bool enableRSSUserTweets*: bool
enableRSSUserReplies*: bool
enableRSSUserMedia*: bool
enableRSSSearch*: bool
enableRSSList*: bool
enableDebug*: bool enableDebug*: bool
proxy*: string proxy*: string
proxyAuth*: string proxyAuth*: string
apiProxy*: string
disableTid*: bool
maxConcurrentReqs*: int
rssCacheTime*: int rssCacheTime*: int
listCacheTime*: int listCacheTime*: int
+1 -1
View File
@@ -9,7 +9,7 @@ var
const const
https* = "https://" https* = "https://"
twimg* = "pbs.twimg.com/" twimg* = "pbs.twimg.com/"
nitterParams = ["name", "tab", "id", "list", "referer", "scroll"] nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"]
twitterDomains = @[ twitterDomains = @[
"twitter.com", "twitter.com",
"pic.twitter.com", "pic.twitter.com",
+14 -15
View File
@@ -29,19 +29,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
tdiv(class="nav-item right"): tdiv(class="nav-item right"):
icon "search", title="Search", href="/search" icon "search", title="Search", href="/search"
if cfg.enableRss and rss.len > 0: if rss.len > 0:
icon "rss", title="RSS Feed", href=rss icon "rss", title="RSS Feed", href=rss
icon "bird", title="Open in Twitter", href=canonical icon "bird", title="Open in X", href=canonical
a(href="https://liberapay.com/zedeus"): verbatim lp a(href="https://liberapay.com/zedeus"): verbatim lp
icon "info", title="About", href="/about" icon "info", title="About", href="/about"
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path)) icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[string] = @[]; banner=""; ogTitle=""; video=""; images: seq[string] = @[]; banner=""; ogTitle="";
rss=""; canonical=""): VNode = rss=""; alternate=""): VNode =
var theme = prefs.theme.toTheme let theme = prefs.theme.toTheme
if "theme" in req.params:
theme = req.params["theme"].toTheme
let ogType = let ogType =
if video.len > 0: "video" if video.len > 0: "video"
@@ -52,8 +50,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head): buildHtml(head):
link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") link(rel="stylesheet", type="text/css", href="/css/style.css?v=27")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4")
if theme.len > 0: if theme.len > 0:
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
@@ -66,10 +64,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
href=opensearchUrl) href=opensearchUrl)
if canonical.len > 0: if alternate.len > 0:
link(rel="canonical", href=canonical) link(rel="alternate", href=alternate, title="View on X")
if cfg.enableRss and rss.len > 0: if rss.len > 0:
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
if prefs.hlsPlayback: if prefs.hlsPlayback:
@@ -125,14 +123,15 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video=""; titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[string] = @[]; banner=""): string = images: seq[string] = @[]; banner=""): string =
let canonical = getTwitterLink(req.path, req.params) let twitterLink = getTwitterLink(req.path, req.params)
let node = buildHtml(html(lang="en")): let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
rss, canonical) rss, twitterLink)
body: let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
renderNavbar(cfg, req, rss, canonical) body(class=bodyClass):
renderNavbar(cfg, req, rss, twitterLink)
tdiv(class="container"): tdiv(class="container"):
body body
+10 -1
View File
@@ -32,7 +32,8 @@ macro renderPrefs*(): untyped =
result[2].add stmt result[2].add stmt
proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode = proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string];
prefsUrl: string): VNode =
buildHtml(tdiv(class="overlay-panel")): buildHtml(tdiv(class="overlay-panel")):
fieldset(class="preferences"): fieldset(class="preferences"):
form(`method`="post", action="/saveprefs", autocomplete="off"): form(`method`="post", action="/saveprefs", autocomplete="off"):
@@ -40,6 +41,14 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode
renderPrefs() renderPrefs()
legend: text "Bookmark"
p(class="bookmark-note"):
text "Save this URL to restore your preferences (?prefs works on all pages)"
pre(class="prefs-code"):
text prefsUrl
p(class="bookmark-note"):
verbatim "You can override preferences with query parameters (e.g. <code>?hlsPlayback=on</code>). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name."
h4(class="note"): h4(class="note"):
text "Preferences are stored client-side using cookies without any personal information." text "Preferences are stored client-side using cookies without any personal information."
+1
View File
@@ -26,6 +26,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
tdiv(class="profile-card-tabs-name"): tdiv(class="profile-card-tabs-name"):
linkUser(user, class="profile-card-fullname") linkUser(user, class="profile-card-fullname")
verifiedIcon(user)
linkUser(user, class="profile-card-username") linkUser(user, class="profile-card-username")
tdiv(class="profile-card-extra"): tdiv(class="profile-card-extra"):
+15 -7
View File
@@ -26,7 +26,9 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
template verifiedIcon*(user: User): untyped {.dirty.} = template verifiedIcon*(user: User): untyped {.dirty.} =
if user.verifiedType != VerifiedType.none: if user.verifiedType != VerifiedType.none:
let lower = ($user.verifiedType).toLowerAscii() let lower = ($user.verifiedType).toLowerAscii()
icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account") buildHtml(tdiv(class=(&"verified-icon {lower}"))):
icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account")
icon "ok", class="verified-icon-check", title=(&"Verified {lower} account")
else: else:
text "" text ""
@@ -40,7 +42,6 @@ proc linkUser*(user: User, class=""): VNode =
buildHtml(a(href=href, class=class, title=nameText)): buildHtml(a(href=href, class=class, title=nameText)):
text nameText text nameText
if isName: if isName:
verifiedIcon(user)
if user.protected: if user.protected:
text " " text " "
icon "lock", title="Protected account" icon "lock", title="Protected account"
@@ -64,20 +65,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod
text text text text
proc genCheckbox*(pref, label: string; state: bool): VNode = proc genCheckbox*(pref, label: string; state: bool): VNode =
buildHtml(label(class="pref-group checkbox-container")): buildHtml(label(class="pref-group checkbox-container", title=pref)):
text label text label
input(name=pref, `type`="checkbox", checked=state) input(name=pref, `type`="checkbox", checked=state)
span(class="checkbox") span(class="checkbox")
proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode = proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode =
let p = placeholder let p = placeholder
buildHtml(tdiv(class=("pref-group pref-input " & class))): buildHtml(tdiv(class=("pref-group pref-input " & class), title=pref)):
if label.len > 0: if label.len > 0:
label(`for`=pref): text label label(`for`=pref): text label
input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0)) input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0))
proc genSelect*(pref, label, state: string; options: seq[string]): VNode = proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
buildHtml(tdiv(class="pref-group pref-input")): buildHtml(tdiv(class="pref-group pref-input", title=pref)):
label(`for`=pref): text label label(`for`=pref): text label
select(name=pref): select(name=pref):
for opt in options: for opt in options:
@@ -89,9 +90,16 @@ proc genDate*(pref, state: string): VNode =
input(name=pref, `type`="date", value=state) input(name=pref, `type`="date", value=state)
icon "calendar" icon "calendar"
proc genImg*(url: string; class=""): VNode = proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode =
let p = placeholder
buildHtml(tdiv(class=("pref-group pref-input " & class))):
if label.len > 0:
label(`for`=pref): text label
input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1")
proc genImg*(url: string; class=""; alt=""): VNode =
buildHtml(): buildHtml():
img(src=getPicUrl(url), class=class, alt="", loading="lazy") img(src=getPicUrl(url), class=class, alt=alt, loading="lazy")
proc getTabClass*(query: Query; tab: QueryKind): string = proc getTabClass*(query: Query; tab: QueryKind): string =
if query.kind == tab: "tab-item active" if query.kind == tab: "tab-item active"
+38 -17
View File
@@ -2,6 +2,9 @@
## SPDX-License-Identifier: AGPL-3.0-only ## SPDX-License-Identifier: AGPL-3.0-only
#import strutils, xmltree, strformat, options, unicode #import strutils, xmltree, strformat, options, unicode
#import ../types, ../utils, ../formatters, ../prefs #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 = #proc getTitle(tweet: Tweet; retweet: string): string =
#if tweet.pinned: result = "Pinned: " #if tweet.pinned: result = "Pinned: "
@@ -25,7 +28,7 @@
#end proc #end proc
# #
#proc getDescription(desc: string; cfg: Config): string = #proc getDescription(desc: string; cfg: Config): string =
Twitter feed for: ${desc}. Generated by ${cfg.hostname} Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
#end proc #end proc
# #
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] = #proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
@@ -46,21 +49,20 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
#end if #end if
#end proc #end proc
# #
#proc renderRssTweet(tweet: Tweet; cfg: Config): string = #proc renderRssTweet(tweet: Tweet; cfg: Config; prefs: Prefs): string =
#let tweet = tweet.retweet.get(tweet) #let tweet = tweet.retweet.get(tweet)
#let urlPrefix = getUrlPrefix(cfg) #let urlPrefix = getUrlPrefix(cfg)
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix) #let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)
<p>${text.replace("\n", "<br>\n")}</p> <p>${text.replace("\n", "<br>\n")}</p>
#if tweet.quote.isSome and get(tweet.quote).available:
# let quoteLink = getLink(get(tweet.quote))
<p><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
#end if
#if tweet.photos.len > 0: #if tweet.photos.len > 0:
# for photo in tweet.photos: # for photo in tweet.photos:
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" /> <img src="${urlPrefix}${getPicUrl(photo.url)}" style="max-width:250px;" />
# end for # end for
#elif tweet.video.isSome: #elif tweet.video.isSome:
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" /> <a href="${urlPrefix}${tweet.getLink}">
<br>Video<br>
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
</a>
#elif tweet.gif.isSome: #elif tweet.gif.isSome:
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}" # let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}" # let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
@@ -72,9 +74,23 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" /> <img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
# end if # end if
#end if #end if
#if tweet.quote.isSome and get(tweet.quote).available:
# let quoteTweet = get(tweet.quote)
# let quoteLink = urlPrefix & getLink(quoteTweet)
<hr/>
<blockquote>
<b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
<p>
${renderRssTweet(quoteTweet, cfg, prefs)}
</p>
<footer>
— <cite><a href="${quoteLink}">${quoteLink}</a>
</footer>
</blockquote>
#end if
#end proc #end proc
# #
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string = #proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; prefs: Prefs; userId=""): string =
#let urlPrefix = getUrlPrefix(cfg) #let urlPrefix = getUrlPrefix(cfg)
#var links: seq[string] #var links: seq[string]
#for thread in tweets: #for thread in tweets:
@@ -88,19 +104,24 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
# if link in links: continue # if link in links: continue
# end if # end if
# links.add link # links.add link
# let useGlobalGuid = tweet.id >= guidCutoff
<item> <item>
<title>${getTitle(tweet, retweet)}</title> <title>${getTitle(tweet, retweet)}</title>
<dc:creator>@${tweet.user.username}</dc:creator> <dc:creator>@${tweet.user.username}</dc:creator>
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description> <description><![CDATA[${renderRssTweet(tweet, cfg, prefs).strip(chars={'\n'})}]]></description>
<pubDate>${getRfc822Time(tweet)}</pubDate> <pubDate>${getRfc822Time(tweet)}</pubDate>
#if useGlobalGuid:
<guid isPermaLink="false">${tweet.id}</guid>
#else:
<guid>${urlPrefix & link}</guid> <guid>${urlPrefix & link}</guid>
#end if
<link>${urlPrefix & link}</link> <link>${urlPrefix & link}</link>
</item> </item>
# end for # end for
#end for #end for
#end proc #end proc
# #
#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string = #proc renderTimelineRss*(profile: Profile; cfg: Config; prefs: Prefs; multi=false): string =
#let urlPrefix = getUrlPrefix(cfg) #let urlPrefix = getUrlPrefix(cfg)
#result = "" #result = ""
#let handle = (if multi: "" else: "@") & profile.user.username #let handle = (if multi: "" else: "@") & profile.user.username
@@ -126,13 +147,13 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
</image> </image>
#let tweetsList = getTweetsWithPinned(profile) #let tweetsList = getTweetsWithPinned(profile)
#if tweetsList.len > 0: #if tweetsList.len > 0:
${renderRssTweets(tweetsList, cfg, userId=profile.user.id)} ${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)}
#end if #end if
</channel> </channel>
</rss> </rss>
#end proc #end proc
# #
#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config): string = #proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config; prefs: Prefs): string =
#let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}" #let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
#result = "" #result = ""
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
@@ -144,12 +165,12 @@ ${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
<description>${getDescription(&"{list.name} by @{list.username}", cfg)}</description> <description>${getDescription(&"{list.name} by @{list.username}", cfg)}</description>
<language>en-us</language> <language>en-us</language>
<ttl>40</ttl> <ttl>40</ttl>
${renderRssTweets(tweets, cfg)} ${renderRssTweets(tweets, cfg, prefs)}
</channel> </channel>
</rss> </rss>
#end proc #end proc
# #
#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config): string = #proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config; prefs: Prefs): string =
#let link = &"{getUrlPrefix(cfg)}/search" #let link = &"{getUrlPrefix(cfg)}/search"
#let escName = xmltree.escape(name) #let escName = xmltree.escape(name)
#result = "" #result = ""
@@ -162,7 +183,7 @@ ${renderRssTweets(tweets, cfg)}
<description>${getDescription(&"Search \"{escName}\"", cfg)}</description> <description>${getDescription(&"Search \"{escName}\"", cfg)}</description>
<language>en-us</language> <language>en-us</language>
<ttl>40</ttl> <ttl>40</ttl>
${renderRssTweets(tweets, cfg)} ${renderRssTweets(tweets, cfg, prefs)}
</channel> </channel>
</rss> </rss>
#end proc #end proc
+4 -6
View File
@@ -10,14 +10,12 @@ const toggles = {
"media": "Media", "media": "Media",
"videos": "Videos", "videos": "Videos",
"news": "News", "news": "News",
"verified": "Verified",
"native_video": "Native videos", "native_video": "Native videos",
"replies": "Replies", "replies": "Replies",
"links": "Links", "links": "Links",
"images": "Images", "images": "Images",
"safe": "Safe",
"quote": "Quotes", "quote": "Quotes",
"pro_video": "Pro videos" "spaces": "Spaces"
}.toOrderedTable }.toOrderedTable
proc renderSearch*(): VNode = proc renderSearch*(): VNode =
@@ -53,7 +51,7 @@ proc renderSearchTabs*(query: Query): VNode =
proc isPanelOpen(q: Query): bool = proc isPanelOpen(q: Query): bool =
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
@[q.near, q.until, q.since].anyIt(it.len > 0)) @[q.minLikes, q.until, q.since].anyIt(it.len > 0))
proc renderSearchPanel*(query: Query): VNode = proc renderSearchPanel*(query: Query): VNode =
let user = query.fromUser.join(",") let user = query.fromUser.join(",")
@@ -85,8 +83,8 @@ proc renderSearchPanel*(query: Query): VNode =
span(class="search-title"): text "-" span(class="search-title"): text "-"
genDate("until", query.until) genDate("until", query.until)
tdiv: tdiv:
span(class="search-title"): text "Near" span(class="search-title"): text "Minimum likes"
genInput("near", "", query.near, "Location...", autofocus=false) genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false)
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode = pinned=none(Tweet)): VNode =
+23 -4
View File
@@ -28,14 +28,19 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
if thread.hasMore: if thread.hasMore:
renderMoreReplies(thread) renderMoreReplies(thread)
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode = proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
buildHtml(tdiv(class="replies", id="r")): buildHtml(tdiv(class="replies", id="r")):
var hasReplies = false
var replyCount = 0
for thread in replies.content: for thread in replies.content:
if thread.content.len == 0: continue if thread.content.len == 0: continue
hasReplies = true
replyCount += thread.content.len
renderReplyThread(thread, prefs, path) renderReplyThread(thread, prefs, path)
if replies.bottom.len > 0: if hasReplies and replies.bottom.len > 0:
renderMore(Query(), replies.bottom, focus="#r") if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
renderMore(Query(), replies.bottom, focus="#r")
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode = proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
let hasAfter = conv.after.content.len > 0 let hasAfter = conv.after.content.len > 0
@@ -70,6 +75,20 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
if not conv.replies.beginning: if not conv.replies.beginning:
renderNewer(Query(), getLink(conv.tweet), focus="#r") renderNewer(Query(), getLink(conv.tweet), focus="#r")
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0: if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
renderReplies(conv.replies, prefs, path) renderReplies(conv.replies, prefs, path, conv.tweet)
renderToTop(focus="#m") renderToTop(focus="#m")
proc renderEditHistory*(edits: EditHistory; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="edit-history")):
tdiv(class="latest-edit"):
tdiv(class="edit-history-header"):
text "Latest post"
renderTweet(edits.latest, prefs, path)
tdiv(class="previous-edits"):
tdiv(class="edit-history-header"):
text "Version history"
for tweet in edits.history:
tdiv(class="tweet-edit"):
renderTweet(tweet, prefs, path)
+5 -6
View File
@@ -53,10 +53,10 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
let show = i == thread.high and sortedThread[0].id != tweet.threadId let show = i == thread.high and sortedThread[0].id != tweet.threadId
let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: "" let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
renderTweet(tweet, prefs, path, class=(header & "thread"), renderTweet(tweet, prefs, path, class=(header & "thread"),
index=i, last=(i == thread.high), showThread=show) index=i, last=(i == thread.high))
proc renderUser(user: User; prefs: Prefs): VNode = proc renderUser(user: User; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-item")): buildHtml(tdiv(class="timeline-item", data-username=user.username)):
a(class="tweet-link", href=("/" & user.username)) a(class="tweet-link", href=("/" & user.username))
tdiv(class="tweet-body profile-result"): tdiv(class="tweet-body profile-result"):
tdiv(class="tweet-header"): tdiv(class="tweet-header"):
@@ -66,6 +66,7 @@ proc renderUser(user: User; prefs: Prefs): VNode =
tdiv(class="tweet-name-row"): tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"): tdiv(class="fullname-and-username"):
linkUser(user, class="fullname") linkUser(user, class="fullname")
verifiedIcon(user)
linkUser(user, class="username") linkUser(user, class="username")
tdiv(class="tweet-content media-body", dir="auto"): tdiv(class="tweet-content media-body", dir="auto"):
@@ -95,7 +96,7 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
if not prefs.hidePins and pinned.isSome: if not prefs.hidePins and pinned.isSome:
let tweet = get pinned let tweet = get pinned
renderTweet(tweet, prefs, path, showThread=tweet.hasThread) renderTweet(tweet, prefs, path)
if results.content.len == 0: if results.content.len == 0:
if not results.beginning: if not results.beginning:
@@ -115,11 +116,9 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
tweet.pinned and prefs.hidePins: tweet.pinned and prefs.hidePins:
continue continue
var hasThread = tweet.hasThread
if retweetId != 0 and tweet.retweet.isSome: if retweetId != 0 and tweet.retweet.isSome:
retweets &= retweetId retweets &= retweetId
hasThread = get(tweet.retweet).hasThread renderTweet(tweet, prefs, path)
renderTweet(tweet, prefs, path, showThread=hasThread)
else: else:
renderThread(thread, prefs, path) renderThread(thread, prefs, path)
+40 -17
View File
@@ -31,6 +31,7 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VN
tdiv(class="tweet-name-row"): tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"): tdiv(class="fullname-and-username"):
linkUser(tweet.user, class="fullname") linkUser(tweet.user, class="fullname")
verifiedIcon(tweet.user)
linkUser(tweet.user, class="username") linkUser(tweet.user, class="username")
span(class="tweet-date"): span(class="tweet-date"):
@@ -49,10 +50,12 @@ proc renderAlbum(tweet: Tweet): VNode =
for photo in photos: for photo in photos:
tdiv(class="attachment image"): tdiv(class="attachment image"):
let let
named = "name=" in photo named = "name=" in photo.url
small = if named: photo else: photo & smallWebp small = if named: photo.url else: photo.url & smallWebp
a(href=getOrigPicUrl(photo), class="still-image", target="_blank"): a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"):
genImg(small) genImg(small, alt=photo.altText)
if photo.altText.len > 0:
p(class="alt-text"): text "ALT " & photo.altText
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool = proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
case playbackType case playbackType
@@ -109,6 +112,7 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode =
video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos) video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">" verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
tdiv(class="overlay-circle"): span(class="overlay-triangle") tdiv(class="overlay-circle"): span(class="overlay-triangle")
tdiv(class="overlay-duration"): text getDuration(video)
verbatim "</div>" verbatim "</div>"
if container.len > 0: if container.len > 0:
tdiv(class="card-content"): tdiv(class="card-content"):
@@ -207,6 +211,12 @@ proc renderMediaTags(tags: seq[User]): VNode =
if i < tags.high: if i < tags.high:
text ", " text ", "
proc renderLatestPost(username: string; id: int64): VNode =
buildHtml(tdiv(class="latest-post-version")):
text "There's a new version of this post. "
a(href=getLink(id, username)):
text "See the latest post"
proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode = proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="quote-media-container")): buildHtml(tdiv(class="quote-media-container")):
if quote.photos.len > 0: if quote.photos.len > 0:
@@ -219,7 +229,7 @@ proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
if not quote.available: if not quote.available:
return buildHtml(tdiv(class="quote unavailable")): return buildHtml(tdiv(class="quote unavailable")):
tdiv(class="unavailable-quote"): a(class="unavailable-quote", href=getLink(quote, focus=false)):
if quote.tombstone.len > 0: if quote.tombstone.len > 0:
text quote.tombstone text quote.tombstone
elif quote.text.len > 0: elif quote.text.len > 0:
@@ -234,6 +244,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
tdiv(class="fullname-and-username"): tdiv(class="fullname-and-username"):
renderMiniAvatar(quote.user, prefs) renderMiniAvatar(quote.user, prefs)
linkUser(quote.user, class="fullname") linkUser(quote.user, class="fullname")
verifiedIcon(quote.user)
linkUser(quote.user, class="username") linkUser(quote.user, class="username")
span(class="tweet-date"): span(class="tweet-date"):
@@ -247,12 +258,16 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
tdiv(class="quote-text", dir="auto"): tdiv(class="quote-text", dir="auto"):
verbatim replaceUrls(quote.text, prefs) verbatim replaceUrls(quote.text, prefs)
if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome:
renderQuoteMedia(quote, prefs, path)
if quote.hasThread: if quote.hasThread:
a(class="show-thread", href=getLink(quote)): a(class="show-thread", href=getLink(quote)):
text "Show this thread" text "Show this thread"
if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome: if quote.history.len > 0 and quote.id != max(quote.history):
renderQuoteMedia(quote, prefs, path) tdiv(class="quote-latest"):
text "There's a new version of this post"
proc renderLocation*(tweet: Tweet): string = proc renderLocation*(tweet: Tweet): string =
let (place, url) = tweet.getLocation() let (place, url) = tweet.getLocation()
@@ -266,14 +281,14 @@ proc renderLocation*(tweet: Tweet): string =
return $node return $node
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
last=false; showThread=false; mainTweet=false; afterTweet=false): VNode = last=false; mainTweet=false; afterTweet=false): VNode =
var divClass = class var divClass = class
if index == -1 or last: if index == -1 or last:
divClass = "thread-last " & class divClass = "thread-last " & class
if not tweet.available: if not tweet.available:
return buildHtml(tdiv(class=divClass & "unavailable timeline-item")): return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)):
tdiv(class="unavailable-box"): a(class="unavailable-box", href=getLink(tweet)):
if tweet.tombstone.len > 0: if tweet.tombstone.len > 0:
text tweet.tombstone text tweet.tombstone
elif tweet.text.len > 0: elif tweet.text.len > 0:
@@ -294,7 +309,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
tweet = tweet.retweet.get tweet = tweet.retweet.get
retweet = fullTweet.user.fullname retweet = fullTweet.user.fullname
buildHtml(tdiv(class=("timeline-item " & divClass))): buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)):
if not mainTweet: if not mainTweet:
a(class="tweet-link", href=getLink(tweet)) a(class="tweet-link", href=getLink(tweet))
@@ -302,7 +317,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
renderHeader(tweet, retweet, pinned, prefs) renderHeader(tweet, retweet, pinned, prefs)
if not afterTweet and index == 0 and tweet.reply.len > 0 and if not afterTweet and index == 0 and tweet.reply.len > 0 and
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username): (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned):
renderReply(tweet) renderReply(tweet)
var tweetClass = "tweet-content media-body" var tweetClass = "tweet-content media-body"
@@ -331,8 +346,20 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if tweet.quote.isSome: if tweet.quote.isSome:
renderQuote(tweet.quote.get(), prefs, path) renderQuote(tweet.quote.get(), prefs, path)
let
hasEdits = tweet.history.len > 1
isLatest = hasEdits and tweet.id == max(tweet.history)
if mainTweet: if mainTweet:
p(class="tweet-published"): text &"{getTime(tweet)}" p(class="tweet-published"):
if hasEdits and isLatest:
a(href=(getLink(tweet, focus=false) & "/history")):
text &"Last edited {getTime(tweet)}"
else:
text &"{getTime(tweet)}"
if hasEdits and not isLatest:
renderLatestPost(tweet.user.username, max(tweet.history))
if tweet.mediaTags.len > 0: if tweet.mediaTags.len > 0:
renderMediaTags(tweet.mediaTags) renderMediaTags(tweet.mediaTags)
@@ -340,10 +367,6 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if not prefs.hideTweetStats: if not prefs.hideTweetStats:
renderStats(tweet.stats) renderStats(tweet.stats)
if showThread:
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
text "Show this thread"
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string = proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
let node = buildHtml(html(lang="en")): let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req) renderHead(prefs, cfg, req)
+1 -6
View File
@@ -11,12 +11,7 @@ card = [
['voidtarget/status/1094632512926605312', ['voidtarget/status/1094632512926605312',
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)', 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)',
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim', 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
'gist.github.com', True], 'gist.github.com', True]
['nim_lang/status/1082989146040340480',
'Nim in 2018: A short recap',
'There were several big news in the Nim world in 2018 two new major releases, partnership with Status, and much more. But let us go chronologically.',
'nim-lang.org', True]
] ]
no_thumb = [ no_thumb = [
+20 -1
View File
@@ -15,7 +15,19 @@ protected = [
['Poop', 'Randy', 'Social media fanatic.'] ['Poop', 'Randy', 'Social media fanatic.']
] ]
invalid = [['thisprofiledoesntexist'], ['%']] invalid = [['thisprofiledoesntexist']]
malformed = [
['${userId}'],
['$%7BuserId%7D'], # URL encoded version
['%'], # Percent sign is invalid
['user@name'],
['user.name'],
['user-name'],
['user$name'],
['user{name}'],
['user name'], # space
]
banner_image = [ banner_image = [
['mobile_test', 'profile_banners%2F82135242%2F1384108037%2F1500x500'] ['mobile_test', 'profile_banners%2F82135242%2F1384108037%2F1500x500']
@@ -65,6 +77,13 @@ class ProfileTest(BaseTestCase):
self.open_nitter(username) self.open_nitter(username)
self.assert_text(f'User "{username}" not found') self.assert_text(f'User "{username}" not found')
@parameterized.expand(malformed)
def test_malformed_username(self, username):
"""Test that malformed usernames (with invalid characters) return 404"""
self.open_nitter(username)
# Malformed usernames should return 404 page not found, not try to fetch from Twitter
self.assert_text('Page not found')
def test_suspended(self): def test_suspended(self):
self.open_nitter('suspendme') self.open_nitter('suspendme')
self.assert_text('User "suspendme" has been suspended') self.assert_text('User "suspendme" has been suspended')
+88 -47
View File
@@ -20,73 +20,112 @@ Output:
{"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."} {"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."}
""" """
import sys
import json
import asyncio import asyncio
import pyotp import json
import nodriver as uc
import os import os
import sys
import nodriver as uc
import pyotp
async def login_and_get_cookies(username, password, totp_seed=None, headless=False): async def login_and_get_cookies(username, password, totp_seed=None, headless=False):
"""Authenticate with X.com and extract session cookies""" """Authenticate with X.com and extract session cookies"""
# Note: headless mode may increase detection risk from bot-detection systems # Note: headless mode may increase detection risk from bot-detection systems
browser = await uc.start(headless=headless) browser = await uc.start(headless=headless)
tab = await browser.get('https://x.com/i/flow/login') tab = await browser.get("https://x.com/i/flow/login")
try: try:
# Enter username # Enter username
print('[*] Entering username...', file=sys.stderr) print(f"[*] Entering username {username}...", file=sys.stderr)
username_input = await tab.find('input[autocomplete="username"]', timeout=10)
await username_input.send_keys(username + '\n') retry = 0
await asyncio.sleep(1) while retry < 5:
username_input = await tab.find(
'input[autocomplete="username"]', timeout=10
)
pos = await username_input.get_position()
await tab.mouse_move(pos.x, pos.y, steps=50, flash=True)
await asyncio.sleep(0.1)
await username_input.click()
await asyncio.sleep(0.5)
await username_input.send_keys(username)
await asyncio.sleep(0.2)
await username_input.send_keys("\n")
await asyncio.sleep(2)
page_content = await tab.get_content()
if "Could not log you in" in page_content:
retry += 1
wait = retry * 10
print(f"Retrying in {wait} seconds...")
await asyncio.sleep(wait)
else:
break
# Enter password # Enter password
print('[*] Entering password...', file=sys.stderr) print("[*] Entering password...", file=sys.stderr)
password_input = await tab.find('input[autocomplete="current-password"]', timeout=15) pretry = 0
await password_input.send_keys(password + '\n') while pretry < 5:
await asyncio.sleep(2) password_input = await tab.find(
'input[autocomplete="current-password"]', timeout=15
)
await password_input.click()
await asyncio.sleep(0.5)
await password_input.send_keys(password)
await asyncio.sleep(0.2)
await password_input.send_keys("\n")
await asyncio.sleep(2)
page_content = await tab.get_content()
if "Could not log you in" in page_content:
pretry += 1
wait = pretry * 10
print(f"Retrying in {wait} seconds...")
await asyncio.sleep(wait)
else:
break
# Handle 2FA if needed # Handle 2FA if needed
page_content = await tab.get_content() page_content = await tab.get_content()
if 'verification code' in page_content or 'Enter code' in page_content: if "verification code" in page_content or "Enter code" in page_content:
if not totp_seed: if not totp_seed:
raise Exception('2FA required but no TOTP seed provided') raise Exception("2FA required but no TOTP seed provided")
print('[*] 2FA detected, entering code...', file=sys.stderr) print("[*] 2FA detected, entering code...", file=sys.stderr)
totp_code = pyotp.TOTP(totp_seed).now() totp_code = pyotp.TOTP(totp_seed).now()
code_input = await tab.select('input[type="text"]') code_input = await tab.select('input[type="text"]')
await code_input.send_keys(totp_code + '\n') await code_input.send_keys(totp_code + "\n")
await asyncio.sleep(3) await asyncio.sleep(3)
# Get cookies # Get cookies
print('[*] Retrieving cookies...', file=sys.stderr) print("[*] Retrieving cookies...", file=sys.stderr)
for _ in range(20): # 20 second timeout for _ in range(20): # 20 second timeout
cookies = await browser.cookies.get_all() cookies = await browser.cookies.get_all()
cookies_dict = {cookie.name: cookie.value for cookie in cookies} cookies_dict = {cookie.name: cookie.value for cookie in cookies}
if 'auth_token' in cookies_dict and 'ct0' in cookies_dict: if "auth_token" in cookies_dict and "ct0" in cookies_dict:
print('[*] Found both cookies', file=sys.stderr)
# Extract ID from twid cookie (may be URL-encoded) # Extract ID from twid cookie (may be URL-encoded)
user_id = None user_id = None
if 'twid' in cookies_dict: if "twid" in cookies_dict:
twid = cookies_dict['twid'] twid = cookies_dict["twid"]
# Try to extract the ID from twid (format: u%3D<id> or u=<id>) # Try to extract the ID from twid (format: u%3D<id> or u=<id>)
if 'u%3D' in twid: if "u%3D" in twid:
user_id = twid.split('u%3D')[1].split('&')[0].strip('"') user_id = twid.split("u%3D")[1].split("&")[0].strip('"')
elif 'u=' in twid: elif "u=" in twid:
user_id = twid.split('u=')[1].split('&')[0].strip('"') user_id = twid.split("u=")[1].split("&")[0].strip('"')
cookies_dict['username'] = username cookies_dict["username"] = username
if user_id: if user_id:
cookies_dict['id'] = user_id cookies_dict["id"] = user_id
return cookies_dict return cookies_dict
await asyncio.sleep(1) await asyncio.sleep(1)
raise Exception('Timeout waiting for cookies') raise Exception("Timeout waiting for cookies")
finally: finally:
browser.stop() browser.stop()
@@ -94,7 +133,9 @@ async def login_and_get_cookies(username, password, totp_seed=None, headless=Fal
async def main(): async def main():
if len(sys.argv) < 3: if len(sys.argv) < 3:
print('Usage: python3 twitter-auth.py username password [totp_seed] [--append sessions.jsonl] [--headless]') print(
"Usage: python3 create_session_browser.py username password [totp_seed] [--append file.jsonl] [--headless]"
)
sys.exit(1) sys.exit(1)
username = sys.argv[1] username = sys.argv[1]
@@ -107,49 +148,49 @@ async def main():
i = 3 i = 3
while i < len(sys.argv): while i < len(sys.argv):
arg = sys.argv[i] arg = sys.argv[i]
if arg == '--append': if arg == "--append":
if i + 1 < len(sys.argv): if i + 1 < len(sys.argv):
append_file = sys.argv[i + 1] append_file = sys.argv[i + 1]
i += 2 # Skip '--append' and filename i += 2 # Skip '--append' and filename
else: else:
print('[!] Error: --append requires a filename', file=sys.stderr) print("[!] Error: --append requires a filename", file=sys.stderr)
sys.exit(1) sys.exit(1)
elif arg == '--headless': elif arg == "--headless":
headless = True headless = True
i += 1 i += 1
elif not arg.startswith('--'): elif not arg.startswith("--"):
if totp_seed is None: if totp_seed is None:
totp_seed = arg totp_seed = arg
i += 1 i += 1
else: else:
# Unkown args # Unkown args
print(f'[!] Warning: Unknown argument: {arg}', file=sys.stderr) print(f"[!] Warning: Unknown argument: {arg}", file=sys.stderr)
i += 1 i += 1
try: try:
cookies = await login_and_get_cookies(username, password, totp_seed, headless) cookies = await login_and_get_cookies(username, password, totp_seed, headless)
session = { session = {
'kind': 'cookie', "kind": "cookie",
'username': cookies['username'], "username": cookies["username"],
'id': cookies.get('id'), "id": cookies.get("id"),
'auth_token': cookies['auth_token'], "auth_token": cookies["auth_token"],
'ct0': cookies['ct0'] "ct0": cookies["ct0"],
} }
output = json.dumps(session) output = json.dumps(session)
if append_file: if append_file:
with open(append_file, 'a') as f: with open(append_file, "a") as f:
f.write(output + '\n') f.write(output + "\n")
print(f'✓ Session appended to {append_file}', file=sys.stderr) print(f"✓ Session appended to {append_file}", file=sys.stderr)
else: else:
print(output) print(output)
os._exit(0) os._exit(0)
except Exception as error: except Exception as error:
print(f'[!] Error: {error}', file=sys.stderr) print(f"[!] Error: {error}", file=sys.stderr)
sys.exit(1) sys.exit(1)
if __name__ == '__main__': if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
Requirements:
pip install -r tools/requirements.txt
Usage:
python3 tools/create_sessions_browser.py <accounts_file> [--append sessions.jsonl] [--headless] [--delay]
Examples:
# Output to terminal
python3 tools/create_sessions_browser.py <accounts_file>
# Append to sessions.jsonl
python3 tools/create_sessions_browser.py <accounts_file> --append sessions.jsonl
# Add 5 second delay between sessions (default: 1)
python3 tools/create_sessions_browser.py <accounts_file> --delay 5
# Headless mode (may increase detection risk)
python3 tools/create_sessions_browser.py <accounts_file> --headless
Input (accounts_file):
[{"username": "user", "password": "pass", "totp": "totp_code"}, {...}, ...]
Output:
{"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."}
{"kind": "cookie", "username": "...", "id": "...", "auth_token": "...", "ct0": "..."}
...
"""
import asyncio
import json
import sys
from time import sleep
import nodriver as uc
import pyotp
async def login_and_get_cookies(account, headless=False):
"""Authenticate with X.com and extract session cookies"""
# Note: headless mode may increase detection risk from bot-detection systems
browser = await uc.start(headless=headless)
tab = await browser.get("https://x.com/i/flow/login")
username = account["username"]
password = account["password"]
totp_seed = account["totp"]
try:
# Enter username
print(f"[*] Entering username {username}...", file=sys.stderr)
retry = 0
while retry < 5:
username_input = await tab.find(
'input[autocomplete="username"]', timeout=10
)
pos = await username_input.get_position()
await tab.mouse_move(pos.x, pos.y, steps=50, flash=True)
await asyncio.sleep(0.1)
await username_input.click()
await asyncio.sleep(0.5)
await username_input.send_keys(username)
await asyncio.sleep(0.2)
await username_input.send_keys("\n")
await asyncio.sleep(2)
page_content = await tab.get_content()
if "Could not log you in" in page_content:
retry += 1
wait = retry * 10
print(f"Retrying in {wait} seconds...")
await asyncio.sleep(wait)
else:
break
# Enter password
print("[*] Entering password...", file=sys.stderr)
pretry = 0
while pretry < 5:
password_input = await tab.find(
'input[autocomplete="current-password"]', timeout=15
)
await password_input.click()
await asyncio.sleep(0.5)
await password_input.send_keys(password)
await asyncio.sleep(0.2)
await password_input.send_keys("\n")
await asyncio.sleep(2)
page_content = await tab.get_content()
if "Could not log you in" in page_content:
pretry += 1
wait = pretry * 10
print(f"Retrying in {wait} seconds...")
await asyncio.sleep(wait)
else:
break
# Handle 2FA if needed
page_content = await tab.get_content()
if "verification code" in page_content or "Enter code" in page_content:
if not totp_seed:
raise Exception("2FA required but no TOTP seed provided")
print("[*] 2FA detected, entering code...", file=sys.stderr)
totp_code = pyotp.TOTP(totp_seed).now()
code_input = await tab.select('input[type="text"]')
await code_input.send_keys(totp_code + "\n")
await asyncio.sleep(3)
# Get cookies
print("[*] Retrieving cookies...", file=sys.stderr)
for _ in range(20): # 20 second timeout
cookies = await browser.cookies.get_all()
cookies_dict = {cookie.name: cookie.value for cookie in cookies}
if "auth_token" in cookies_dict and "ct0" in cookies_dict:
# Extract ID from twid cookie (may be URL-encoded)
user_id = None
if "twid" in cookies_dict:
twid = cookies_dict["twid"]
# Try to extract the ID from twid (format: u%3D<id> or u=<id>)
if "u%3D" in twid:
user_id = twid.split("u%3D")[1].split("&")[0].strip('"')
elif "u=" in twid:
user_id = twid.split("u=")[1].split("&")[0].strip('"')
cookies_dict["username"] = username
if user_id:
cookies_dict["id"] = user_id
return cookies_dict
await asyncio.sleep(1)
raise Exception("Timeout waiting for cookies")
finally:
browser.stop()
async def main():
if len(sys.argv) < 2:
print(
"Usage: python3 create_sessions_browser.py <accounts_file> [--append sessions.jsonl] [--headless]"
)
sys.exit(1)
input = sys.argv[1]
append_file = None
headless = False
delay = 1
# Parse optional arguments
i = 2
while i < len(sys.argv):
arg = sys.argv[i]
if arg == "--append":
if i + 1 < len(sys.argv):
append_file = sys.argv[i + 1]
i += 2 # Skip '--append' and filename
else:
print("[!] Error: --append requires a filename", file=sys.stderr)
sys.exit(1)
elif arg == "--headless":
headless = True
i += 1
elif arg == "--delay":
delay = int(sys.argv[i + 1])
i += 2
else:
# Unkown args
print(f"[!] Warning: Unknown argument: {arg}", file=sys.stderr)
i += 1
accounts = []
with open(input) as f:
accounts = json.load(f)
if len(accounts) == 0:
print("no accounts in file")
sys.exit(0)
sessions = 0
for acc in accounts:
sessions += 1
try:
cookies = await login_and_get_cookies(acc, headless)
session = {
"kind": "cookie",
"username": cookies["username"],
"id": cookies.get("id"),
"auth_token": cookies["auth_token"],
"ct0": cookies["ct0"],
}
if append_file:
with open(append_file, "a") as f:
f.write(json.dumps(session) + "\n")
else:
print(json.dumps(session))
print(f"Progress: {sessions} / {len(accounts)}")
if sessions < len(accounts):
print("Waiting", delay, "seconds")
sleep(delay)
except Exception as error:
print(
f"[!] Error getting session for {acc["username"]}, skipping: {error}",
file=sys.stderr,
)
if __name__ == "__main__":
asyncio.run(main())
+4 -1
View File
@@ -21,7 +21,10 @@ def auth(username, password, otp_secret):
guest_token = requests.post( guest_token = requests.post(
"https://api.twitter.com/1.1/guest/activate.json", "https://api.twitter.com/1.1/guest/activate.json",
headers={'Authorization': bearer_token} headers={
'Authorization': bearer_token,
"User-Agent": "TwitterAndroid/10.21.0-release.0 (310210000-r-0) ONEPLUS+A3010/9"
}
).json().get('guest_token') ).json().get('guest_token')
if not guest_token: if not guest_token: