pull updates from cynthia foxwell's fork
Some checks failed
Build and Publish Docker / build (push) Has been cancelled

This commit is contained in:
Eir Sunny 2025-08-07 20:25:37 -05:00
parent 775f04eeaa
commit cb84ed219b
20 changed files with 716 additions and 156 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -2,8 +2,11 @@
Nitter is a free and open source alternative Twitter front-end focused on Nitter is a free and open source alternative Twitter front-end focused on
privacy and performance. The source is available on GitHub at privacy and performance. The source is available on GitHub at
<https://gitdab.com/Cynosphere/nitter> (original Nitter repo: <https://github.com/zedeus/nitter>
<https://github.com/zedeus/nitter>)
**This instance is running a fork, whose source can be found at**
<https://git.eir-nya.gay/eir/nitter>
Additionally, I am copying changes from <https://gitlab.com/Cynosphere/nitter> ([her instance](https://tw.counter-strike.gay))
* No JavaScript or ads * No JavaScript or ads
* All requests go through the backend, client never talks to Twitter * All requests go through the backend, client never talks to Twitter
@ -20,6 +23,14 @@ Nitter's GitHub wiki contains
[browser extensions](https://github.com/zedeus/nitter/wiki/Extensions) [browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
maintained by the community. maintained by the community.
### Fork features (from [Cynthia Foxwell's fork](<https://gitlab.com/Cynosphere/nitter>))
* Localized following via cookies (list exportable and editable in preferences)
* Image zooming/carousel (requires JavaScript)
* Up to date Twitter features, e.g. Community Notes
* Embeds for chat services on-par with services like [FxTwitter](https://github.com/FixTweet/FxTwitter) and [vxTwitter](https://github.com/dylanpdx/BetterTwitFix)
* No dependency on Redis, as it has caused ratelimiting issues, but also forcably disables RSS
## Why use Nitter? ## Why use Nitter?
It's impossible to use Twitter without JavaScript enabled. For privacy-minded It's impossible to use Twitter without JavaScript enabled. For privacy-minded
@ -37,10 +48,6 @@ Twitter without JavaScript while retaining your privacy. In addition to
respecting your privacy, Nitter is on average around 15 times lighter than respecting your privacy, Nitter is on average around 15 times lighter than
Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster). Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster).
In the future a simple account system will be added that lets you follow Twitter
users, allowing you to have a clean chronological timeline without needing a
Twitter account.
## Donating ## Donating
Donations go to zedeus, original creator of Nitter. Donations go to zedeus, original creator of Nitter.
@ -52,8 +59,24 @@ ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \ LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
## Instance info ## Credits
Based on [Cynthia Foxwell's fork](https://gitdab.com/Cynosphere/nitter) ([her instance](https://tw.counter-strike.gay)). * Cynthia Foxwell for her fork of this project
* Zedeus for this project
* PrivacyDevel, cmj, and taskylizard for keeping this project alive with forks after the main repo went inactive
* Every other contributors who've committed to the main repo in the past
[Source code for this instance](https://git.eir-nya.gay/eir/nitter). ## To any law enforcement agencies and copyright holders (from [Cynthia Foxwell's fork](<https://gitlab.com/Cynosphere/nitter>))
**All illegal content should be reported to Twitter directly.** This service is
merely a proxy of Twitter and no content is hosted on this server. Do not waste
your time contacting internet service providers, hosting providers and/or domain
registrars.
If you would like more context, you can read about this exact issue happening to
[PussTheCat.org's instance](https://pussthecat.org/nitter/).
I emplore all Nitter instance hosts to not enable media proxying, even if it
"phones home" to Twitter's CDN (which doesn't really pose a tracking risk and
breaks videos anyways), as it [has been used as an attack vector to take down
nitter.net](https://github.com/zedeus/nitter/issues/1150#issuecomment-1890855255).

View File

@ -100,7 +100,7 @@ proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = tweetVariables % [id, cursor] variables = tweetVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures} params = {"variables": variables, "features": gqlFeatures, "fieldToggles": tweetFieldToggles}
js = await fetch(graphTweet ? params, Api.tweetDetail) js = await fetch(graphTweet ? params, Api.tweetDetail)
result = parseGraphConversation(js, id) result = parseGraphConversation(js, id)

View File

@ -30,12 +30,13 @@ proc genParams*(pars: openArray[(string, string)] = @[]; cursor="";
else: else:
result &= ("cursor", cursor) result &= ("cursor", cursor)
proc genHeaders*(token: Token = nil): HttpHeaders = #proc genHeaders*(token: Token = nil): HttpHeaders =
proc genHeaders*(): HttpHeaders =
result = newHttpHeaders({ result = newHttpHeaders({
"connection": "keep-alive", "connection": "keep-alive",
"authorization": auth, "authorization": auth,
"content-type": "application/json", "content-type": "application/json",
"x-guest-token": if token == nil: "" else: token.tok, #"x-guest-token": if token == nil: "" else: token.tok,
"x-twitter-active-user": "yes", "x-twitter-active-user": "yes",
"authority": "api.twitter.com", "authority": "api.twitter.com",
"accept-encoding": "gzip", "accept-encoding": "gzip",
@ -44,20 +45,20 @@ proc genHeaders*(token: Token = nil): HttpHeaders =
"DNT": "1" "DNT": "1"
}) })
template updateToken() = #template updateToken() =
if resp.headers.hasKey(rlRemaining): # if resp.headers.hasKey(rlRemaining):
let # let
remaining = parseInt(resp.headers[rlRemaining]) # remaining = parseInt(resp.headers[rlRemaining])
reset = parseInt(resp.headers[rlReset]) # reset = parseInt(resp.headers[rlReset])
token.setRateLimit(api, remaining, reset) # token.setRateLimit(api, remaining, reset)
template fetchImpl(result, additional_headers, fetchBody) {.dirty.} = template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
once: once:
pool = HttpPool() pool = HttpPool()
var token = await getToken(api) #var token = await getToken(api)
if token.tok.len == 0: #if token.tok.len == 0:
raise rateLimitError() # raise rateLimitError()
if len(cfg.cookieHeader) != 0: if len(cfg.cookieHeader) != 0:
additional_headers.add("Cookie", cfg.cookieHeader) additional_headers.add("Cookie", cfg.cookieHeader)
@ -66,7 +67,8 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
try: try:
var resp: AsyncResponse var resp: AsyncResponse
var headers = genHeaders(token) #var headers = genHeaders(token)
var headers = genHeaders()
for key, value in additional_headers.pairs(): for key, value in additional_headers.pairs():
headers.add(key, value) headers.add(key, value)
pool.use(headers): pool.use(headers):
@ -87,7 +89,7 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
let let
remaining = parseInt(resp.headers[rlRemaining]) remaining = parseInt(resp.headers[rlRemaining])
reset = parseInt(resp.headers[rlReset]) reset = parseInt(resp.headers[rlReset])
token.setRateLimit(api, remaining, reset) #token.setRateLimit(api, remaining, reset)
if result.len > 0: if result.len > 0:
if resp.headers.getOrDefault("content-encoding") == "gzip": if resp.headers.getOrDefault("content-encoding") == "gzip":
@ -97,35 +99,36 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
let errors = result.fromJson(Errors) let errors = result.fromJson(Errors)
if errors in {expiredToken, badToken, authorizationError}: if errors in {expiredToken, badToken, authorizationError}:
echo "fetch error: ", errors echo "fetch error: ", errors
release(token, invalid=true) #release(token, invalid=true)
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(account, api) #setLimited(account, api)
raise rateLimitError() raise rateLimitError()
elif result.startsWith("429 Too Many Requests"): elif result.startsWith("429 Too Many Requests"):
echo "[accounts] 429 error, API: ", api, ", token: ", token[] #echo "[accounts] 429 error, API: ", api, ", token: ", token[]
#account.apis[api].remaining = 0 #account.apis[api].remaining = 0
# rate limit hit, resets after the 15 minute window # rate limit hit, resets after the 15 minute window
raise rateLimitError() raise rateLimitError()
fetchBody fetchBody
release(token, used=true) #release(token, used=true)
if resp.status == $Http400: if resp.status == $Http400:
raise newException(InternalError, $url) let errText = "body: '" & result & "' url: " & $url
raise newException(InternalError, errText)
except InternalError as e: except InternalError as e:
raise e raise e
except BadClientError as e: except BadClientError as e:
release(token, used=true) #release(token, used=true)
raise e raise e
except OSError as e: except OSError as e:
raise e raise e
except Exception as e: except Exception as e:
echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url echo "error: ", e.name, ", msg: ", e.msg, ", url: ", url
if "length" notin e.msg and "descriptor" notin e.msg: #if "length" notin e.msg and "descriptor" notin e.msg:
release(token, invalid=true) #release(token, invalid=true)
raise rateLimitError() raise rateLimitError()
template retry(bod) = template retry(bod) =
@ -136,7 +139,7 @@ template retry(bod) =
bod bod
proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} = proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} =
retry: #retry:
var body: string var body: string
fetchImpl(body, additional_headers): fetchImpl(body, additional_headers):
if body.startsWith('{') or body.startsWith('['): if body.startsWith('{') or body.startsWith('['):
@ -145,26 +148,26 @@ proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders
echo resp.status, ": ", body, " --- url: ", url echo resp.status, ": ", body, " --- url: ", url
result = newJNull() result = newJNull()
updateToken() #updateToken()
let error = result.getError let error = result.getError
if error in {expiredToken, badToken}: if error in {expiredToken, badToken}:
echo "fetch error: ", result.getError echo "fetch error: ", result.getError
release(token, invalid=true) #release(token, invalid=true)
raise rateLimitError() raise rateLimitError()
proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} = proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
retry: #retry:
fetchImpl(result, additional_headers): fetchImpl(result, additional_headers):
if not (result.startsWith('{') or result.startsWith('[')): if not (result.startsWith('{') or result.startsWith('[')):
echo resp.status, ": ", result, " --- url: ", url echo resp.status, ": ", result, " --- url: ", url
result.setLen(0) result.setLen(0)
updateToken() #updateToken()
if result.startsWith("{\"errors"): if result.startsWith("{\"errors"):
let errors = result.fromJson(Errors) let errors = result.fromJson(Errors)
if errors in {expiredToken, badToken}: if errors in {expiredToken, badToken}:
echo "fetch error: ", errors echo "fetch error: ", errors
release(token, invalid=true) #release(token, invalid=true)
raise rateLimitError() raise rateLimitError()

View File

@ -20,7 +20,7 @@ const
graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2" graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2"
graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2" graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2"
graphUserMedia* = graphql / "dexO_2tohK86JDudXXG3Yw/UserMedia" graphUserMedia* = graphql / "dexO_2tohK86JDudXXG3Yw/UserMedia"
graphTweet* = graphql / "q94uRCEn65LZThakYcPT6g/TweetDetail" graphTweet* = graphql / "y90SwUGBZ3yz0yNUmCHgTw/TweetDetail"
graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery"
graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline" graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId" graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
@ -95,30 +95,36 @@ const
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false, "unified_cards_ad_metadata_container_dynamic_card_content_query_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": false "view_counts_everywhere_api_enabled": false,
"premium_content_api_read_enabled": false,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_analysis_button_from_backend": false,
"responsive_web_grok_analyze_post_followups_enabled": false,
"responsive_web_jetfuel_frame": false,
"profile_label_improvements_pcf_label_in_post_enabled": true,
"responsive_web_grok_image_annotation_enabled": false,
"responsive_web_grok_share_attachment_enabled": false,
"rweb_video_screen_enabled": false
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
tweetVariables* = """{ tweetVariables* = """{
"focalTweetId": "$1", "focalTweetId": "$1",
$2 $2
"includeHasBirdwatchNotes": false, "with_rux_injections": false,
"rankingMode": "Relevance",
"includePromotedContent": false, "includePromotedContent": false,
"withBirdwatchNotes": false, "withCommunity": true,
"withVoice": false, "withQuickPromoteEligibilityTweetFields": false,
"withV2Timeline": true "withBirdwatchNotes": true,
"withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
# oldUserTweetsVariables* = """{ tweetFieldToggles* = """{
# "userId": "$1", $2 "withArticleRichContentState": false,
# "count": 20, "withArticlePlainText": true,
# "includePromotedContent": false, "withGrokAnalyze": false,
# "withDownvotePerspective": false, "withDisallowedReplyControls": false
# "withReactionsMetadata": false, }""".replace(" ", "").replace("\n", "")
# "withReactionsPerspective": false,
# "withVoice": false,
# "withV2Timeline": true
# }
# """.replace(" ", "").replace("\n", "")
userTweetsVariables* = """{ userTweetsVariables* = """{
"rest_id": "$1", "rest_id": "$1",
@ -151,4 +157,4 @@ const
$2 $2
"count": 20, "count": 20,
"includePromotedContent": false "includePromotedContent": false
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")

View File

@ -31,8 +31,7 @@ let
illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]" illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]"
proc getUrlPrefix*(cfg: Config): string = proc getUrlPrefix*(cfg: Config): string =
if cfg.useHttps: https & cfg.hostname "https://" & cfg.hostname
else: "http://" & cfg.hostname
proc shortLink*(text: string; length=28): string = proc shortLink*(text: string; length=28): string =
result = text.replace(wwwRegex, "") result = text.replace(wwwRegex, "")

View File

@ -9,8 +9,9 @@ import jester
import types, config, prefs, formatters, redis_cache, http_pool import types, config, prefs, formatters, redis_cache, http_pool
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, list, #rss, debug,
unsupported, embed, resolver, router_utils, home, follow, twitter_api] unsupported, embed, resolver, router_utils, home, follow, twitter_api,
activityspoof]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues" const issuesUrl = "https://github.com/zedeus/nitter/issues"
@ -35,9 +36,9 @@ setMaxHttpConns(cfg.httpMaxConns)
setHttpProxy(cfg.proxy, cfg.proxyAuth) setHttpProxy(cfg.proxy, cfg.proxyAuth)
initAboutPage(cfg.staticDir) initAboutPage(cfg.staticDir)
waitFor initRedisPool(cfg) #waitFor initRedisPool(cfg)
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" #stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
stdout.flushFile #stdout.flushFile
createUnsupportedRouter(cfg) createUnsupportedRouter(cfg)
createResolverRouter(cfg) createResolverRouter(cfg)
@ -48,9 +49,10 @@ createStatusRouter(cfg)
createSearchRouter(cfg) createSearchRouter(cfg)
createMediaRouter(cfg) createMediaRouter(cfg)
createEmbedRouter(cfg) createEmbedRouter(cfg)
createRssRouter(cfg) #createRssRouter(cfg)
#createDebugRouter(cfg) #createDebugRouter(cfg)
createTwitterApiRouter(cfg) createTwitterApiRouter(cfg)
createActivityPubRouter(cfg)
settings: settings:
port = Port(cfg.port) port = Port(cfg.port)
@ -93,7 +95,7 @@ routes:
extend home, "" extend home, ""
extend follow, "" extend follow, ""
extend rss, "" #extend rss, ""
extend status, "" extend status, ""
extend search, "" extend search, ""
extend timeline, "" extend timeline, ""
@ -103,5 +105,6 @@ routes:
extend resolver, "" extend resolver, ""
extend embed, "" extend embed, ""
#extend debug, "" #extend debug, ""
extend activityspoof, ""
extend api, "" extend api, ""
extend unsupported, "" extend unsupported, ""

View File

@ -208,6 +208,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
id: js{"id_str"}.getId, id: js{"id_str"}.getId,
threadId: js{"conversation_id_str"}.getId, threadId: js{"conversation_id_str"}.getId,
replyId: js{"in_reply_to_status_id_str"}.getId, replyId: js{"in_reply_to_status_id_str"}.getId,
replyHandle: js{"in_reply_to_screen_name"}.getStr,
text: js{"full_text"}.getStr, text: js{"full_text"}.getStr,
time: js{"created_at"}.getTime, time: js{"created_at"}.getTime,
hasThread: js{"self_thread"}.notNull, hasThread: js{"self_thread"}.notNull,
@ -504,39 +505,41 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if instructions.len == 0: if instructions.len == 0:
return return
for e in instructions[0]{"entries"}: for i in instructions:
let entryId = e{"entryId"}.getStr if i{"type"}.getStr == "TimelineAddEntries":
if entryId.startsWith("tweet"): for e in i{"entries"}:
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: let entryId = e{"entryId"}.getStr
let tweet = parseGraphTweet(tweetResult, true) if entryId.startsWith("tweet"):
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
let tweet = parseGraphTweet(tweetResult, true)
if not tweet.available: if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId()) tweet.id = parseBiggestInt(entryId.getId())
if $tweet.id == tweetId: if $tweet.id == tweetId:
result.tweet = tweet result.tweet = tweet
else: else:
result.before.content.add tweet result.before.content.add tweet
elif entryId.startsWith("tombstone"): elif entryId.startsWith("tombstone"):
let id = entryId.getId() let id = entryId.getId()
let tweet = Tweet( let tweet = Tweet(
id: parseBiggestInt(id), id: parseBiggestInt(id),
available: false, available: false,
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
) )
if id == tweetId: if id == tweetId:
result.tweet = tweet result.tweet = tweet
else: else:
result.before.content.add tweet result.before.content.add tweet
elif entryId.startsWith("conversationthread"): elif entryId.startsWith("conversationthread") or entryId.startswith("reply-mixer-conversation"):
let (thread, self) = parseGraphThread(e) let (thread, self) = parseGraphThread(e)
if self: if self:
result.after = thread result.after = thread
else: else:
result.replies.content.add thread result.replies.content.add thread
elif entryId.startsWith("cursor-bottom"): elif entryId.startsWith("cursor-bottom"):
result.replies.bottom = e{"content", "itemContent", "value"}.getStr result.replies.bottom = e{"content", "value"}.getStr
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
result = Profile(tweets: Timeline(beginning: after.len == 0)) result = Profile(tweets: Timeline(beginning: after.len == 0))

242
src/routes/activityspoof.nim Executable file
View File

@ -0,0 +1,242 @@
# SPDX-License-Identifier: AGPL-3.0-only
import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times
import jester
import router_utils
import ".."/[types, formatters, api]
import ../views/[mastoapi]
export json, uri, sequtils, options, sugar, times
export router_utils
export api, formatters
export mastoapi
proc createActivityPubRouter*(cfg: Config) =
router activityspoof:
get "/api/v1/accounts/?":
resp Http200, {"Content-Type": "application/json"}, """[]"""
get "/api/v1/statuses/@id":
let id = @"id"
if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
let prefs = cookiePrefs()
let conv = await getTweet(id)
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Record not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone
var errJson = newJObject()
errJson["error"] = %error
resp Http404, {"Content-Type": "application/json"}, $errJson
let
tweet = conv.tweet
tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{id}"
var media: seq[JsonNode] = @[]
if tweet.photos.len > 0:
for url in tweet.photos:
let image = getUrlPrefix(cfg) & getPicUrl(url)
var mediaObj = newJObject()
mediaObj["id"] = %"150745989836308480" # idk if discord even parses this snowflake, but its my user id why not
mediaObj["type"] = %"image"
mediaObj["url"] = %image
mediaObj["preview_url"] = %image
mediaObj["remote_url"] = newJNull()
mediaObj["preview_remote_url"] = newJNull()
mediaObj["text_url"] = newJNull()
mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y)
# FIXME but this probably isnt used by discord
mediaObj["meta"] = newJObject()
media.add(mediaObj)
if tweet.video.isSome():
let
videoObj = get(tweet.video)
vars = videoObj.variants.filterIt(it.contentType == mp4)
var mediaObj = newJObject()
mediaObj["id"] = %"150745989836308480"
mediaObj["type"] = %"video"
mediaObj["url"] = %vars[^1].url
mediaObj["preview_url"] = %(getUrlPrefix(cfg) & getPicUrl(videoObj.thumb))
mediaObj["remote_url"] = newJNull()
mediaObj["preview_remote_url"] = newJNull()
mediaObj["text_url"] = newJNull()
mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y)
# FIXME but this probably isnt used by discord
mediaObj["meta"] = newJObject()
media.add(mediaObj)
elif tweet.gif.isSome():
let gif = get(tweet.gif)
var mediaObj = newJObject()
mediaObj["id"] = %"150745989836308480"
mediaObj["type"] = %"video"
mediaObj["url"] = %(&"https://{gif.url}")
mediaObj["preview_url"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb))
mediaObj["remote_url"] = newJNull()
mediaObj["preview_remote_url"] = newJNull()
mediaObj["text_url"] = newJNull()
mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y)
# FIXME but this probably isnt used by discord
mediaObj["meta"] = newJObject()
media.add(mediaObj)
var postJson = newJObject()
postJson["id"] = %(&"{tweet.id}")
postJson["url"] = %tweetUrl
postJson["uri"] = %tweetUrl
postJson["created_at"] = %($tweet.time)
postJson["edited_at"] = newJNull()
postJson["reblog"] = newJNull()
if tweet.replyId != 0:
postJson["in_reply_to_id"] = %(&"{tweet.replyId}")
postJson["in_reply_to_account_id"] = %""
else:
postJson["in_reply_to_id"] = newJNull()
postJson["in_reply_to_account_id"] = newJNull()
postJson["language"] = %"en" # FIXME
postJson["content"] = %formatTweetForMastoAPI(tweet, cfg, prefs)
postJson["spoiler_text"] = %""
postJson["visibility"] = %"public"
postJson["application"] = %*{
"name": "Nitter",
"website": getUrlPrefix(cfg)
}
postJson["media_attachments"] = %media
postJson["account"] = %*{
"id": &"{tweet.user.id}",
"display_name": tweet.user.fullname,
"username": tweet.user.username,
"acct": tweet.user.username,
"url": &"{getUrlPrefix(cfg)}/{tweet.user.username}",
"uri": &"{getUrlPrefix(cfg)}/{tweet.user.username}",
"created_at": $tweet.user.joinDate,
"locked": tweet.user.protected,
"bot": false, # TODO?
"discoverable": true,
"indexable": false,
"group": false,
"avatar": getUrlPrefix(cfg) & getPicUrl(tweet.user.userPic),
"avatar_static": getUrlPrefix(cfg) & getPicUrl(tweet.user.userPic),
"header": getUrlPrefix(cfg) & getPicUrl(tweet.user.banner),
"header_static": getUrlPrefix(cfg) & getPicUrl(tweet.user.banner),
"followers_count": tweet.user.followers,
"following_count": tweet.user.following,
"statuses_count": tweet.user.tweets,
"hide_collections": false,
"noindex": false,
"emojis": @[],
"roles": @[],
"fields": @[],
}
postJson["mentions"] = newJArray() # TODO: parse?
postJson["tags"] = newJArray() # TODO: parse?
postJson["emojis"] = newJArray()
postJson["card"] = newJNull()
postJson["poll"] = newJNull() # TODO: parse?
resp Http200, {"Content-Type": "application/json"}, $postJson
get "/users/@name/statuses/@id":
let id = @"id"
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
let prefs = cookiePrefs()
let conv = await getTweet(id)
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Record not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone
var errJson = newJObject()
errJson["error"] = %error
resp Http404, {"Content-Type": "application/json"}, $errJson
let postJson = getActivityStream(conv.tweet, cfg, prefs)
resp Http200, {"Content-Type": "application/json"}, $postJson
redirect("/$1/status/$2" % [@"name", @"id"])
get "/users/@name":
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
let user = await getGraphUser(@"name")
if user.suspended or user.id.len == 0:
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
let prefs = cookiePrefs()
let userJson = getActivityStream(user, cfg, prefs)
resp Http200, {"Content-Type": "application/json"}, $userJson
redirect("/" & @"name")
# might as well
get "/.well-known/nodeinfo":
var nodeinfo = newJObject()
let link: JsonNode = %*{
"href": &"{getUrlPrefix(cfg)}/nodeinfo/2.1.json",
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.1"
}
var links: seq[JsonNode] = @[]
links.add(link)
nodeinfo["links"] = %links
resp Http200, {"Content-Type": "application/json"}, $nodeinfo
get "/nodeinfo/2.1.json":
var nodeinfo = newJObject()
nodeinfo["version"] = %"2.1"
nodeinfo["software"] = %*{
"name": "Nitter",
"repository": "https://gitlab.com/Cynosphere/nitter"
}
var metadata = newJObject()
metadata["features"] = newJArray()
metadata["federation"] = newJObject()
metadata["nodeDescription"] = %"Alternative Twitter front-end (ActivityPub support added for Discord)"
metadata["nodeName"] = %"Nitter"
metadata["private"] = %true
metadata["maintainer"] = %*{
"name": "Cynthia",
"email": "gamers@riseup.net"
}
nodeinfo["metadata"] = metadata
nodeinfo["openRegistrations"] = %false
nodeinfo["protocols"] = newJArray()
var services = newJObject()
services["inbound"] = newJArray()
services["outbound"] = newJArray()
nodeinfo["services"] = services
nodeinfo["usage"] = newJObject()
resp Http200, {"Content-Type": "application/json"}, $nodeinfo

View File

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

View File

@ -4,7 +4,7 @@ import strutils, strformat, uri
import jester import jester
import router_utils import router_utils
import ".."/[types, redis_cache, api] import ".."/[types, api]
import ../views/[general, timeline, list] import ../views/[general, timeline, list]
template respList*(list, timeline, title, vnode: typed) = template respList*(list, timeline, title, vnode: typed) =
@ -20,6 +20,14 @@ template respList*(list, timeline, title, vnode: typed) =
proc title*(list: List): string = proc title*(list: List): string =
&"@{list.username}/{list.name}" &"@{list.username}/{list.name}"
proc getList*(username=""; slug=""; id=""): Future[List] {.async.} =
if id.len > 0:
result = await getGraphList(id)
else:
result = await getGraphListBySlug(username, slug)
proc createListRouter*(cfg: Config) = proc createListRouter*(cfg: Config) =
router list: router list:
get "/@name/lists/@slug/?": get "/@name/lists/@slug/?":
@ -28,7 +36,7 @@ proc createListRouter*(cfg: Config) =
cond @"slug" != "memberships" cond @"slug" != "memberships"
let let
slug = decodeUrl(@"slug") slug = decodeUrl(@"slug")
list = await getCachedList(@"name", slug) list = await getList(@"name", slug)
if list.id.len == 0: if list.id.len == 0:
resp Http404, showError(&"""List "{@"slug"}" not found""", cfg) resp Http404, showError(&"""List "{@"slug"}" not found""", cfg)
redirect(&"/i/lists/{list.id}") redirect(&"/i/lists/{list.id}")
@ -37,7 +45,7 @@ proc createListRouter*(cfg: Config) =
cond '.' notin @"id" cond '.' notin @"id"
let let
prefs = cookiePrefs() prefs = cookiePrefs()
list = await getCachedList(id=(@"id")) list = await getList(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)
respList(list, timeline, list.title, vnode) respList(list, timeline, list.title, vnode)
@ -46,6 +54,6 @@ proc createListRouter*(cfg: Config) =
cond '.' notin @"id" cond '.' notin @"id"
let let
prefs = cookiePrefs() prefs = cookiePrefs()
list = await getCachedList(id=(@"id")) list = await getList(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))

View File

@ -1,16 +1,16 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils, sequtils, uri, options, sugar import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times
import jester, karax/vdom import jester, karax/vdom
import router_utils import router_utils
import ".."/[types, formatters, api] import ".."/[types, formatters, api]
import ../views/[general, status, search] import ../views/[general, status, search, mastoapi]
export uri, sequtils, options, sugar export json, uri, sequtils, options, sugar, times
export router_utils export router_utils
export api, formatters export api, formatters
export status export status, mastoapi
proc createStatusRouter*(cfg: Config) = proc createStatusRouter*(cfg: Config) =
router status: router status:
@ -39,7 +39,35 @@ proc createStatusRouter*(cfg: Config) =
get "/@name/status/@id/?": get "/@name/status/@id/?":
cond '.' notin @"name" cond '.' notin @"name"
let id = @"id" var id = @"id"
var rawFile = false
if id.endsWith(".mp4"):
rawFile = true
id.removeSuffix(".mp4")
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
let prefs = cookiePrefs()
let conv = await getTweet(id)
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Record not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone
var errJson = newJObject()
errJson["error"] = %error
resp Http404, {"Content-Type": "application/json"}, $errJson
let postJson = getActivityStream(conv.tweet, cfg, prefs)
resp Http200, {"Content-Type": "application/json"}, $postJson
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)
@ -64,38 +92,59 @@ proc createStatusRouter*(cfg: Config) =
resp Http404, showError(error, cfg) resp Http404, showError(error, cfg)
let let
title = pageTitle(conv.tweet) tweet = conv.tweet
ogTitle = pageTitle(conv.tweet.user) title = pageTitle(tweet)
desc = conv.tweet.text ogTitle = pageTitle(tweet.user)
avatar = conv.tweet.user.userPic desc = tweet.text
time = some(conv.tweet.time) avatar = tweet.user.userPic
time = some(tweet.time)
var var
images = conv.tweet.photos images = tweet.photos
video = "" video = ""
context = ""
contextUrl = ""
if conv.tweet.video.isSome(): if tweet.quote.isSome():
let videoObj = get(conv.tweet.video) let
quote = get(tweet.quote)
quoteUser = quote.user
if tweet.replyId != 0:
context = &"↩ Replying to: @{tweet.replyHandle}\n↘ Quoting: {quoteUser.fullname} (@{quoteUser.username})"
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
else:
context = &"↘ Quoting: {quoteUser.fullname} (@{quoteUser.username})"
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}"
elif tweet.replyId != 0:
context = &"↩ Replying to: @{tweet.replyHandle}"
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
if tweet.video.isSome():
let videoObj = get(tweet.video)
images = @[videoObj.thumb] images = @[videoObj.thumb]
let vars = videoObj.variants.filterIt(it.contentType == mp4) let vars = videoObj.variants.filterIt(it.contentType == mp4)
# idk why this wont sort when it sorts everywhere else # idk why this wont sort when it sorts everywhere else
#video = vars.sortedByIt(it.bitrate)[^1].url #video = vars.sortedByIt(it.bitrate)[^1].url
video = vars[^1].url video = vars[^1].url
elif conv.tweet.gif.isSome(): elif tweet.gif.isSome():
let gif = get(conv.tweet.gif) let gif = get(tweet.gif)
images = @[gif.thumb] images = @[gif.thumb]
video = getPicUrl(gif.url) video = getUrlPrefix(cfg) & getPicUrl(gif.url)
#elif conv.tweet.card.isSome(): #elif tweet.card.isSome():
# let card = conv.tweet.card.get() # let card = tweet.card.get()
# if card.image.len > 0: # if card.image.len > 0:
# images = @[card.image] # images = @[card.image]
# elif card.video.isSome(): # elif card.video.isSome():
# images = @[card.video.get().thumb] # images = @[card.video.get().thumb]
if rawFile and video != "":
redirect(video)
let html = renderConversation(conv, prefs, getPath() & "#m") let html = renderConversation(conv, prefs, getPath() & "#m")
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
images=images, video=video, avatar=avatar, time=time) images=images, video=video, avatar=avatar, time=time,
context=context, contextUrl=contextUrl, id=id)
get "/@name/@s/@id/@m/?@i?": get "/@name/@s/@id/@m/?@i?":
cond @"s" in ["status", "statuses"] cond @"s" in ["status", "statuses"]

View File

@ -1,16 +1,16 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils, sequtils, uri, options, times import asyncdispatch, strutils, sequtils, uri, options, times, json
import jester, karax/vdom import jester, karax/vdom
import router_utils import router_utils
import ".."/[types, redis_cache, formatters, query, api] import ".."/[types, formatters, query, api]
import ../views/[general, profile, timeline, status, search] import ../views/[general, profile, timeline, status, search, mastoapi]
export vdom export vdom
export uri, sequtils export uri, sequtils, json
export router_utils export router_utils
export redis_cache, formatters, query, api export formatters, query, api
export profile, timeline, status export profile, timeline, status, mastoapi
proc getQuery*(request: Request; tab, name: string): Query = proc getQuery*(request: Request; tab, name: string): Query =
case tab case tab
@ -28,6 +28,19 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
else: else:
body body
proc getUserId(username: string): Future[string] {.async.} =
let user = await getGraphUser(username)
if user.suspended:
return "suspended"
else:
return user.id
proc getUsername*(userId: string): Future[string] {.async.} =
let user = await getGraphUserById(userId)
result = user.username
proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false; proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
skipPinned=false): Future[Profile] {.async.} = skipPinned=false): Future[Profile] {.async.} =
let let
@ -48,9 +61,9 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
let let
rail = rail =
skipIf(skipRail or query.kind == media, @[]): skipIf(skipRail or query.kind == media, @[]):
getCachedPhotoRail(name) getPhotoRail(name)
user = getCachedUser(name) user = getGraphUser(name)
result = result =
case query.kind case query.kind
@ -94,7 +107,7 @@ template respTimeline*(timeline: typed) =
template respUserId*() = template respUserId*() =
cond @"user_id".len > 0 cond @"user_id".len > 0
let username = await getCachedUsername(@"user_id") let username = await getUsername(@"user_id")
if username.len > 0: if username.len > 0:
redirect("/" & username) redirect("/" & username)
else: else:
@ -124,6 +137,18 @@ proc createTimelineRouter*(cfg: Config) =
of "following": of "following":
resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs) resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
else: else:
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
let userId = await getUserId(@"name")
if userId == "suspended" or userId.len == 0:
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
let user = await getGraphUser(@"name")
let userJson = getActivityStream(user, cfg, prefs)
resp Http200, {"Content-Type": "application/json"}, $userJson
var query = request.getQuery(@"tab", @"name") var query = request.getQuery(@"tab", @"name")
if names.len != 1: if names.len != 1:
query.fromUser = names query.fromUser = names

View File

@ -138,7 +138,7 @@ proc createTwitterApiRouter*(cfg: Config) =
let response = await getUserProfileJson(username) let response = await getUserProfileJson(username)
respJson response respJson response
# get "/api/user/@id/tweets": #get "/api/user/@id/tweets":
# let id = @"id" # let id = @"id"
# let response = await getUserTweetsJson(id) # let response = await getUserTweetsJson(id)
# respJson response # respJson response

View File

@ -211,6 +211,7 @@ type
text*: string text*: string
time*: DateTime time*: DateTime
reply*: seq[string] reply*: seq[string]
replyHandle*: string
pinned*: bool pinned*: bool
hasThread*: bool hasThread*: bool
available*: bool available*: bool

View File

@ -5,7 +5,7 @@ import karax/[karaxdsl, vdom]
const const
date = staticExec("git show -s --format=\"%cd\" --date=format:\"%Y.%m.%d\"") date = staticExec("git show -s --format=\"%cd\" --date=format:\"%Y.%m.%d\"")
hash = staticExec("git show -s --format=\"%h\"") hash = staticExec("git show -s --format=\"%h\"")
link = "https://github.com/zedeus/nitter/commit/" & hash link = "https://git.eir-nya.gay/nitter/commit/" & hash
version = &"{date}-{hash}" version = &"{date}-{hash}"
var aboutHtml: string var aboutHtml: string
@ -20,3 +20,7 @@ proc initAboutPage*(dir: string) =
proc renderAbout*(): VNode = proc renderAbout*(): VNode =
buildHtml(tdiv(class="overlay-panel")): buildHtml(tdiv(class="overlay-panel")):
verbatim aboutHtml verbatim aboutHtml
h2: text "Instance info"
p:
text "Version "
a(href=link): text version

View File

@ -3,7 +3,7 @@ import uri, strutils, strformat, times, options
import karax/[karaxdsl, vdom] import karax/[karaxdsl, vdom]
import renderutils import renderutils
import ../utils, ../types, ../prefs, ../formatters import ".."/[utils, types, prefs, formatters]
import jester import jester
@ -29,8 +29,8 @@ 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 cfg.enableRss and rss.len > 0:
icon "rss-feed", title="RSS Feed", href=rss #icon "rss-feed", title="RSS Feed", href=rss
icon "bird", title="Open in Twitter", href=canonical icon "bird", title="Open in Twitter", 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"
@ -38,15 +38,15 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
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=""; avatar=""; rss=""; canonical=""; avatar=""; context=""; contextUrl="";
time: Option[DateTime] = none(DateTime)): VNode = id=""; time: Option[DateTime] = none(DateTime)): VNode =
var theme = prefs.theme.toTheme var theme = prefs.theme.toTheme
if "theme" in req.params: if "theme" in req.params:
theme = req.params["theme"].toTheme theme = req.params["theme"].toTheme
let ogType = let ogType =
if video.len > 0: "video.other" if video.len > 0: "video.other"
elif rss.len > 0: "object" #elif rss.len > 0: "object"
elif images.len > 0: "photo" elif images.len > 0: "photo"
else: "article" else: "article"
@ -62,9 +62,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
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"))
link(rel="icon", type="image/png", sizes="32x32", href=(&"{getUrlPrefix(cfg)}/favicon-32x32.png"))
link(rel="icon", type="image/png", sizes="16x16", href=(&"{getUrlPrefix(cfg)}/favicon-16x16.png"))
link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png") link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png")
link(rel="icon", type="image/png", sizes="32x32", href="/favicon-32x32.png")
link(rel="icon", type="image/png", sizes="16x16", href="/favicon-16x16.png")
link(rel="manifest", href="/site.webmanifest") link(rel="manifest", href="/site.webmanifest")
link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60") link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60")
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
@ -73,8 +73,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
if canonical.len > 0: if canonical.len > 0:
link(rel="canonical", href=canonical) link(rel="canonical", href=canonical)
if cfg.enableRss and rss.len > 0: #if cfg.enableRss and 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:
script(src="/js/hls.light.min.js", `defer`="") script(src="/js/hls.light.min.js", `defer`="")
@ -107,7 +107,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
var siteName = cfg.title var siteName = cfg.title
if time.isSome: let isDiscord = req.headers.getOrDefault("User-Agent").toString().contains("Discordbot")
if time.isSome and not isDiscord:
let timeObj = time.get let timeObj = time.get
let timeStr = $timeObj let timeStr = $timeObj
meta(property="og:article:published_time", content=timeStr) meta(property="og:article:published_time", content=timeStr)
@ -131,10 +133,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
meta(property="og:image", content=image) meta(property="og:image", content=image)
if video.len == 0: if video.len == 0:
meta(property="twitter:image:src", content=image) meta(property="twitter:image:src", content=image)
if rss.len > 0:
meta(property="twitter:card", content="summary")
elif video.len == 0:
meta(property="twitter:card", content="summary_large_image") meta(property="twitter:card", content="summary_large_image")
else:
meta(property="twitter:card", content="summary")
elif avatar.len > 0: elif avatar.len > 0:
let avatarUrl = getUrlPrefix(cfg) & getPicUrl(avatar) let avatarUrl = getUrlPrefix(cfg) & getPicUrl(avatar)
meta(property="og:image", content=avatarUrl) meta(property="og:image", content=avatarUrl)
@ -143,16 +144,30 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
meta(property="og:video:url", content=video) meta(property="og:video:url", content=video)
meta(property="og:video:secure_url", content=video) meta(property="og:video:secure_url", content=video)
meta(property="og:video:type", content="video/mp4") meta(property="og:video:type", content="video/mp4")
var title = encodeUrl(finalizedDesc)
var author = encodeUrl(finalizedTitleText) var
title = encodeUrl(finalizedDesc)
author = encodeUrl(finalizedTitleText)
url = req.path
if len(finalizedDesc) > 67: if len(finalizedDesc) > 67:
title = author title = author
author = encodeUrl(finalizedDesc) author = encodeUrl(finalizedDesc)
verbatim &"<link rel=\"alternate\" href=\"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(req.path)}\" type=\"application/json+oembed\" />" if context != "":
#link(rel="alternate", author = encodeUrl(context & "\n") & author
# href=&"{getUrlPrefix(cfg)}/oembed.json?type=video&title={encodeUrl(stripHtml(desc))}&user={encodeUrl(finalizedTitleText)}&url={encodeUrl(req.path)}",
# `type`="application/json+oembed") if contextUrl != "":
url = contextUrl
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(url)}"), type="application/json+oembed")
elif context != "" and contextUrl != "":
var
title = encodeUrl(finalizedTitleText)
author = encodeUrl(context)
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(contextUrl)}"), type="application/json+oembed")
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/users/i/statuses/{id}"), type="application/activity+json")
# this is last so images are also preloaded # this is last so images are also preloaded
# if this is done earlier, Chrome only preloads one image for some reason # if this is done earlier, Chrome only preloads one image for some reason
@ -161,14 +176,15 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video=""; titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[string] = @[]; banner=""; avatar=""; images: seq[string] = @[]; banner=""; avatar=""; context="";
time: Option[DateTime] = none(DateTime)): string = contextUrl=""; id=""; time: Option[DateTime] = none(DateTime)
): string =
let canonical = getTwitterLink(req.path, req.params) let canonical = 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, avatar, time) rss, canonical, avatar, context, contextUrl, id, time)
body: body:
renderNavbar(cfg, req, rss, canonical) renderNavbar(cfg, req, rss, canonical)

178
src/views/mastoapi.nim Executable file
View File

@ -0,0 +1,178 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, options, json, sequtils, times
import ".."/[types, formatters, utils]
proc formatTweetForMastoAPI*(tweet: Tweet, cfg: Config, prefs: Prefs): string =
var content = replaceUrls(tweet.text, prefs)
if tweet.quote.isSome():
let
quote = get(tweet.quote)
quoteContent = replaceUrls(quote.text, prefs)
quoteUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}"
content &= &"\n\n<blockquote><b>↘ <a href=\"{quoteUrl}\">{quote.user.fullName} (@{quote.user.username})</a></b>\n{quoteContent}"
if quote.video.isSome() or quote.gif.isSome():
content &= "\n📹"
if quote.gif.isSome():
content &= " (GIF)"
elif quote.photos.len > 0:
content &= "\n🖼️"
if quote.photos.len > 1:
content &= &" ({quote.photos.len})"
content &= "</blockquote>"
if tweet.birdwatch.isSome():
let
note = get(tweet.birdwatch)
noteContent = replaceUrls(note.text, prefs)
content &= &"\n<blockquote><b>ⓘ {note.title}</b>\n{noteContent}</blockquote>"
result = content.replace("\n", "<br>")
proc getActivityStream*(tweet: Tweet, cfg: Config, prefs: Prefs): JsonNode =
let
tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.id}"
tweetContent = formatTweetForMastoAPI(tweet, cfg, prefs)
var media: seq[JsonNode] = @[]
if tweet.photos.len > 0:
for url in tweet.photos:
let image = getUrlPrefix(cfg) & getPicUrl(url)
var mediaObj = newJObject()
mediaObj["type"] = %"Document"
mediaObj["mediaType"] = %"image/png"
mediaObj["url"] = %image
mediaObj["name"] = newJNull() # FIXME a11y
media.add(mediaObj)
if tweet.video.isSome():
let
videoObj = get(tweet.video)
vars = videoObj.variants.filterIt(it.contentType == mp4)
var mediaObj = newJObject()
mediaObj["type"] = %"Document"
mediaObj["mediaType"] = %"video/mp4"
mediaObj["url"] = %vars[^1].url
mediaObj["name"] = newJNull() # FIXME a11y
media.add(mediaObj)
elif tweet.gif.isSome():
let gif = get(tweet.gif)
var mediaObj = newJObject()
mediaObj["type"] = %"Document"
mediaObj["mediaType"] = %"video/mp4"
mediaObj["url"] = %(&"https://{gif.url}")
mediaObj["name"] = newJNull() # FIXME a11y
media.add(mediaObj)
var context: seq[JsonNode] = @[]
let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams"
context.add(contextUrl)
let asProps: JsonNode = %*{
"ostatus": "http://ostatus.org#",
"atomUri": "ostatus:atomUri",
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"sensitive": "as:sensitive",
}
context.add(asProps)
var postJson = newJObject()
postJson["@context"] = %context
postJson["id"] = %tweetUrl
postJson["type"] = %"Note"
postJson["summary"] = newJNull()
if tweet.replyId != 0:
let replyUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
postJson["inReplyTo"] = %replyUrl
postJson["inReplyToAtomUri"] = %replyUrl
else:
postJson["inReplyTo"] = newJNull()
postJson["inReplyToAtomUri"] = newJNull()
postJson["published"] = %($tweet.time)
postJson["url"] = %tweetUrl
postJson["attributedTo"] = %(&"{getUrlPrefix(cfg)}/users/{tweet.user.username}")
postJson["to"] = newJArray()
postJson["cc"] = %(@["https://www.w3.org/ns/activitystreams#Public"])
postJson["sensitive"] = %false # FIXME
postJson["atomUri"] = %tweetUrl
postJson["conversation"] = %""
postJson["content"] = %tweetContent
postJson["contentMap"] = %*{
"en": tweetContent
}
postJson["attachment"] = %media
postJson["tag"] = newJArray() # TODO: parse?
postJson["replies"] = newJObject()
result = postJson
proc getActivityStream*(user: User, cfg: Config, prefs: Prefs): JsonNode =
let userUrl = &"{getUrlPrefix(cfg)}/{user.username}"
var context: seq[JsonNode] = @[]
let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams"
context.add(contextUrl)
let contextUrl2: JsonNode = %"https://w3id.org/security/v1"
context.add(contextUrl2)
let contextAka: JsonNode = %*{
"@id": "as:alsoKnownAs",
"@type": "@id"
}
let contextMovedTo = %*{
"@id": "as:movedTo",
"@type": "@id"
}
var asProps: JsonNode = %*{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
}
asProps["alsoKnownAs"] = contextAka
asProps["movedTo"] = contextMovedTo
context.add(asProps)
var userJson = newJObject()
userJson["@context"] = %context
userJson["id"] = %userUrl
userJson["type"] = %"Person"
userJson["following"] = %(userUrl & "/following")
userJson["followers"] = %(userUrl & "/followers")
userJson["inbox"] = newJNull()
userJson["outbox"] = newJNull()
userJson["featured"] = newJNull()
userJson["featuredTags"] = newJNull()
userJson["preferredUsername"] = %user.username
userJson["name"] = %user.fullname
userJson["summary"] = %user.bio
userJson["url"] = %userUrl
userJson["manuallyApprovesFollowers"] = %user.protected
userJson["discoverable"] = %true
userJson["indexable"] = %false
userJson["published"] = %($user.joinDate)
userJson["memorial"] = %false
userJson["publicKey"] = newJNull()
userJson["tag"] = newJArray()
userJson["attachment"] = newJArray()
userJson["endpoints"] = newJObject()
userJson["icon"] = %*{
"type": "Image",
"mediaType": "image/jpeg",
"url": getUrlPrefix(cfg) & getPicUrl(user.userPic)
}
userJson["image"] = %*{
"type": "Image",
"mediaType": "image/jpeg",
"url": getUrlPrefix(cfg) & getPicUrl(user.banner)
}
result = userJson

View File

@ -24,9 +24,9 @@ proc renderSearch*(): VNode =
buildHtml(tdiv(class="panel-container")): buildHtml(tdiv(class="panel-container")):
tdiv(class="search-bar"): tdiv(class="search-bar"):
form(`method`="get", action="/search", autocomplete="off"): form(`method`="get", action="/search", autocomplete="off"):
hiddenField("f", "users") hiddenField("f", "tweets")
input(`type`="text", name="q", autofocus="", input(`type`="text", name="q", autofocus="",
placeholder="Enter username...", dir="auto") placeholder="Search...", dir="auto")
button(`type`="submit"): icon "search" button(`type`="submit"): icon "search"
proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode = proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode =

View File

@ -351,7 +351,7 @@ 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)
if mainTweet and tweet.birdwatch.isSome: if tweet.birdwatch.isSome:
renderCommunityNote(tweet.birdwatch.get(), prefs) renderCommunityNote(tweet.birdwatch.get(), prefs)
if mainTweet: if mainTweet: