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
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
* 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)
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?
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
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
Donations go to zedeus, original creator of Nitter.
@ -52,8 +59,24 @@ ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
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
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = tweetVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
params = {"variables": variables, "features": gqlFeatures, "fieldToggles": tweetFieldToggles}
js = await fetch(graphTweet ? params, Api.tweetDetail)
result = parseGraphConversation(js, id)

View File

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

View File

@ -20,7 +20,7 @@ const
graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2"
graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2"
graphUserMedia* = graphql / "dexO_2tohK86JDudXXG3Yw/UserMedia"
graphTweet* = graphql / "q94uRCEn65LZThakYcPT6g/TweetDetail"
graphTweet* = graphql / "y90SwUGBZ3yz0yNUmCHgTw/TweetDetail"
graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery"
graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
@ -95,30 +95,36 @@ const
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
"verified_phone_label_enabled": false,
"vibe_api_enabled": false,
"view_counts_everywhere_api_enabled": false
"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", "")
tweetVariables* = """{
"focalTweetId": "$1",
$2
"includeHasBirdwatchNotes": false,
"with_rux_injections": false,
"rankingMode": "Relevance",
"includePromotedContent": false,
"withBirdwatchNotes": false,
"withVoice": false,
"withV2Timeline": true
"withCommunity": true,
"withQuickPromoteEligibilityTweetFields": false,
"withBirdwatchNotes": true,
"withVoice": true
}""".replace(" ", "").replace("\n", "")
# oldUserTweetsVariables* = """{
# "userId": "$1", $2
# "count": 20,
# "includePromotedContent": false,
# "withDownvotePerspective": false,
# "withReactionsMetadata": false,
# "withReactionsPerspective": false,
# "withVoice": false,
# "withV2Timeline": true
# }
# """.replace(" ", "").replace("\n", "")
tweetFieldToggles* = """{
"withArticleRichContentState": false,
"withArticlePlainText": true,
"withGrokAnalyze": false,
"withDisallowedReplyControls": false
}""".replace(" ", "").replace("\n", "")
userTweetsVariables* = """{
"rest_id": "$1",
@ -151,4 +157,4 @@ const
$2
"count": 20,
"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}]"
proc getUrlPrefix*(cfg: Config): string =
if cfg.useHttps: https & cfg.hostname
else: "http://" & cfg.hostname
"https://" & cfg.hostname
proc shortLink*(text: string; length=28): string =
result = text.replace(wwwRegex, "")

View File

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

View File

@ -208,6 +208,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
id: js{"id_str"}.getId,
threadId: js{"conversation_id_str"}.getId,
replyId: js{"in_reply_to_status_id_str"}.getId,
replyHandle: js{"in_reply_to_screen_name"}.getStr,
text: js{"full_text"}.getStr,
time: js{"created_at"}.getTime,
hasThread: js{"self_thread"}.notNull,
@ -504,39 +505,41 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if instructions.len == 0:
return
for e in instructions[0]{"entries"}:
let entryId = e{"entryId"}.getStr
if entryId.startsWith("tweet"):
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
let tweet = parseGraphTweet(tweetResult, true)
for i in instructions:
if i{"type"}.getStr == "TimelineAddEntries":
for e in i{"entries"}:
let entryId = e{"entryId"}.getStr
if entryId.startsWith("tweet"):
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
let tweet = parseGraphTweet(tweetResult, true)
if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId())
if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId())
if $tweet.id == tweetId:
result.tweet = tweet
else:
result.before.content.add tweet
elif entryId.startsWith("tombstone"):
let id = entryId.getId()
let tweet = Tweet(
id: parseBiggestInt(id),
available: false,
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
)
if $tweet.id == tweetId:
result.tweet = tweet
else:
result.before.content.add tweet
elif entryId.startsWith("tombstone"):
let id = entryId.getId()
let tweet = Tweet(
id: parseBiggestInt(id),
available: false,
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
)
if id == tweetId:
result.tweet = tweet
else:
result.before.content.add tweet
elif entryId.startsWith("conversationthread"):
let (thread, self) = parseGraphThread(e)
if self:
result.after = thread
else:
result.replies.content.add thread
elif entryId.startsWith("cursor-bottom"):
result.replies.bottom = e{"content", "itemContent", "value"}.getStr
if id == tweetId:
result.tweet = tweet
else:
result.before.content.add tweet
elif entryId.startsWith("conversationthread") or entryId.startswith("reply-mixer-conversation"):
let (thread, self) = parseGraphThread(e)
if self:
result.after = thread
else:
result.replies.content.add thread
elif entryId.startsWith("cursor-bottom"):
result.replies.bottom = e{"content", "value"}.getStr
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
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 asyncdispatch, strutils, options, router_utils, timeline
import ".."/[prefs, types, utils, redis_cache]
import ".."/[prefs, types, utils]
import ../views/[general, home, search]
export home
@ -43,7 +43,7 @@ proc createHomeRouter*(cfg: Config) =
query.kind = userList
for name in names:
let prof = await getCachedUser(name)
let prof = await getGraphUser(name)
profs &= @[prof]
resp renderMain(renderFollowing(query, profs, prefs), request, cfg, prefs)

View File

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

View File

@ -1,16 +1,16 @@
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils, sequtils, uri, options, sugar
import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times
import jester, karax/vdom
import router_utils
import ".."/[types, formatters, api]
import ../views/[general, status, search]
import ../views/[general, status, search, mastoapi]
export uri, sequtils, options, sugar
export json, uri, sequtils, options, sugar, times
export router_utils
export api, formatters
export status
export status, mastoapi
proc createStatusRouter*(cfg: Config) =
router status:
@ -39,7 +39,35 @@ proc createStatusRouter*(cfg: Config) =
get "/@name/status/@id/?":
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):
resp Http404, showError("Invalid tweet ID", cfg)
@ -64,38 +92,59 @@ proc createStatusRouter*(cfg: Config) =
resp Http404, showError(error, cfg)
let
title = pageTitle(conv.tweet)
ogTitle = pageTitle(conv.tweet.user)
desc = conv.tweet.text
avatar = conv.tweet.user.userPic
time = some(conv.tweet.time)
tweet = conv.tweet
title = pageTitle(tweet)
ogTitle = pageTitle(tweet.user)
desc = tweet.text
avatar = tweet.user.userPic
time = some(tweet.time)
var
images = conv.tweet.photos
images = tweet.photos
video = ""
context = ""
contextUrl = ""
if conv.tweet.video.isSome():
let videoObj = get(conv.tweet.video)
if tweet.quote.isSome():
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]
let vars = videoObj.variants.filterIt(it.contentType == mp4)
# idk why this wont sort when it sorts everywhere else
#video = vars.sortedByIt(it.bitrate)[^1].url
video = vars[^1].url
elif conv.tweet.gif.isSome():
let gif = get(conv.tweet.gif)
elif tweet.gif.isSome():
let gif = get(tweet.gif)
images = @[gif.thumb]
video = getPicUrl(gif.url)
#elif conv.tweet.card.isSome():
# let card = conv.tweet.card.get()
video = getUrlPrefix(cfg) & getPicUrl(gif.url)
#elif tweet.card.isSome():
# let card = tweet.card.get()
# if card.image.len > 0:
# images = @[card.image]
# elif card.video.isSome():
# images = @[card.video.get().thumb]
if rawFile and video != "":
redirect(video)
let html = renderConversation(conv, prefs, getPath() & "#m")
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
images=images, video=video, avatar=avatar, time=time)
images=images, video=video, avatar=avatar, time=time,
context=context, contextUrl=contextUrl, id=id)
get "/@name/@s/@id/@m/?@i?":
cond @"s" in ["status", "statuses"]

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import karax/[karaxdsl, vdom]
const
date = staticExec("git show -s --format=\"%cd\" --date=format:\"%Y.%m.%d\"")
hash = staticExec("git show -s --format=\"%h\"")
link = "https://github.com/zedeus/nitter/commit/" & hash
link = "https://git.eir-nya.gay/nitter/commit/" & hash
version = &"{date}-{hash}"
var aboutHtml: string
@ -20,3 +20,7 @@ proc initAboutPage*(dir: string) =
proc renderAbout*(): VNode =
buildHtml(tdiv(class="overlay-panel")):
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 renderutils
import ../utils, ../types, ../prefs, ../formatters
import ".."/[utils, types, prefs, formatters]
import jester
@ -29,8 +29,8 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
tdiv(class="nav-item right"):
icon "search", title="Search", href="/search"
if cfg.enableRss and rss.len > 0:
icon "rss-feed", title="RSS Feed", href=rss
#if cfg.enableRss and rss.len > 0:
#icon "rss-feed", title="RSS Feed", href=rss
icon "bird", title="Open in Twitter", href=canonical
a(href="https://liberapay.com/zedeus"): verbatim lp
icon "info", title="About", href="/about"
@ -38,15 +38,15 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
rss=""; canonical=""; avatar="";
time: Option[DateTime] = none(DateTime)): VNode =
rss=""; canonical=""; avatar=""; context=""; contextUrl="";
id=""; time: Option[DateTime] = none(DateTime)): VNode =
var theme = prefs.theme.toTheme
if "theme" in req.params:
theme = req.params["theme"].toTheme
let ogType =
if video.len > 0: "video.other"
elif rss.len > 0: "object"
#elif rss.len > 0: "object"
elif images.len > 0: "photo"
else: "article"
@ -62,9 +62,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
if theme.len > 0:
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
link(rel="icon", type="image/png", sizes="32x32", href=(&"{getUrlPrefix(cfg)}/favicon-32x32.png"))
link(rel="icon", type="image/png", sizes="16x16", href=(&"{getUrlPrefix(cfg)}/favicon-16x16.png"))
link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png")
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="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60")
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:
link(rel="canonical", href=canonical)
if cfg.enableRss and rss.len > 0:
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
#if cfg.enableRss and rss.len > 0:
#link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
if prefs.hlsPlayback:
script(src="/js/hls.light.min.js", `defer`="")
@ -107,7 +107,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
var siteName = cfg.title
if time.isSome:
let isDiscord = req.headers.getOrDefault("User-Agent").toString().contains("Discordbot")
if time.isSome and not isDiscord:
let timeObj = time.get
let timeStr = $timeObj
meta(property="og:article:published_time", content=timeStr)
@ -131,10 +133,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
meta(property="og:image", content=image)
if video.len == 0:
meta(property="twitter:image:src", content=image)
if rss.len > 0:
meta(property="twitter:card", content="summary")
elif video.len == 0:
meta(property="twitter:card", content="summary_large_image")
else:
meta(property="twitter:card", content="summary")
elif avatar.len > 0:
let avatarUrl = getUrlPrefix(cfg) & getPicUrl(avatar)
meta(property="og:image", content=avatarUrl)
@ -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:secure_url", content=video)
meta(property="og:video:type", content="video/mp4")
var title = encodeUrl(finalizedDesc)
var author = encodeUrl(finalizedTitleText)
var
title = encodeUrl(finalizedDesc)
author = encodeUrl(finalizedTitleText)
url = req.path
if len(finalizedDesc) > 67:
title = author
author = encodeUrl(finalizedDesc)
verbatim &"<link rel=\"alternate\" href=\"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(req.path)}\" type=\"application/json+oembed\" />"
#link(rel="alternate",
# href=&"{getUrlPrefix(cfg)}/oembed.json?type=video&title={encodeUrl(stripHtml(desc))}&user={encodeUrl(finalizedTitleText)}&url={encodeUrl(req.path)}",
# `type`="application/json+oembed")
if context != "":
author = encodeUrl(context & "\n") & author
if contextUrl != "":
url = contextUrl
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(url)}"), type="application/json+oembed")
elif context != "" and contextUrl != "":
var
title = encodeUrl(finalizedTitleText)
author = encodeUrl(context)
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(contextUrl)}"), type="application/json+oembed")
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/users/i/statuses/{id}"), type="application/activity+json")
# this is last so images are also preloaded
# if this is done earlier, Chrome only preloads one image for some reason
@ -161,14 +176,15 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[string] = @[]; banner=""; avatar="";
time: Option[DateTime] = none(DateTime)): string =
images: seq[string] = @[]; banner=""; avatar=""; context="";
contextUrl=""; id=""; time: Option[DateTime] = none(DateTime)
): string =
let canonical = getTwitterLink(req.path, req.params)
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
rss, canonical, avatar, time)
rss, canonical, avatar, context, contextUrl, id, time)
body:
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")):
tdiv(class="search-bar"):
form(`method`="get", action="/search", autocomplete="off"):
hiddenField("f", "users")
hiddenField("f", "tweets")
input(`type`="text", name="q", autofocus="",
placeholder="Enter username...", dir="auto")
placeholder="Search...", dir="auto")
button(`type`="submit"): icon "search"
proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode =

View File

@ -351,7 +351,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if tweet.quote.isSome:
renderQuote(tweet.quote.get(), prefs, path)
if mainTweet and tweet.birdwatch.isSome:
if tweet.birdwatch.isSome:
renderCommunityNote(tweet.birdwatch.get(), prefs)
if mainTweet: