Compare commits
No commits in common. "7a6548cb2b18001e0443f20ef93f76e57ef8acb8" and "6e86e086c3221b991181c7391930d25a32d937b8" have entirely different histories.
7a6548cb2b
...
6e86e086c3
17 changed files with 81 additions and 559 deletions
Binary file not shown.
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
@ -5,7 +5,7 @@ privacy and performance. The source is available on GitHub at
|
|||
<https://github.com/zedeus/nitter>
|
||||
|
||||
**This instance is running a fork, whose source can be found at**
|
||||
<https://gitlab.com/Cynosphere/nitter>
|
||||
<https://gitdab.com/Cynosphere/nitter>
|
||||
|
||||
* No JavaScript or ads
|
||||
* All requests go through the backend, client never talks to Twitter
|
||||
|
|
|
@ -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, "fieldToggles": tweetFieldToggles}
|
||||
params = {"variables": variables, "features": gqlFeatures}
|
||||
js = await fetch(graphTweet ? params, Api.tweetDetail)
|
||||
result = parseGraphConversation(js, id)
|
||||
|
||||
|
|
|
@ -116,8 +116,7 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
|
|||
#release(token, used=true)
|
||||
|
||||
if resp.status == $Http400:
|
||||
let errText = "body: '" & result & "' url: " & $url
|
||||
raise newException(InternalError, errText)
|
||||
raise newException(InternalError, $url)
|
||||
except InternalError as e:
|
||||
raise e
|
||||
except BadClientError as e:
|
||||
|
|
|
@ -20,7 +20,7 @@ const
|
|||
graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2"
|
||||
graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2"
|
||||
graphUserMedia* = graphql / "dexO_2tohK86JDudXXG3Yw/UserMedia"
|
||||
graphTweet* = graphql / "y90SwUGBZ3yz0yNUmCHgTw/TweetDetail"
|
||||
graphTweet* = graphql / "q94uRCEn65LZThakYcPT6g/TweetDetail"
|
||||
graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery"
|
||||
graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
|
||||
graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
|
||||
|
@ -95,36 +95,30 @@ 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,
|
||||
"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
|
||||
"view_counts_everywhere_api_enabled": false
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
tweetVariables* = """{
|
||||
"focalTweetId": "$1",
|
||||
$2
|
||||
"with_rux_injections": false,
|
||||
"rankingMode": "Relevance",
|
||||
"includeHasBirdwatchNotes": false,
|
||||
"includePromotedContent": false,
|
||||
"withCommunity": true,
|
||||
"withQuickPromoteEligibilityTweetFields": false,
|
||||
"withBirdwatchNotes": true,
|
||||
"withVoice": true
|
||||
"withBirdwatchNotes": false,
|
||||
"withVoice": false,
|
||||
"withV2Timeline": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
tweetFieldToggles* = """{
|
||||
"withArticleRichContentState": false,
|
||||
"withArticlePlainText": true,
|
||||
"withGrokAnalyze": false,
|
||||
"withDisallowedReplyControls": false
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
# oldUserTweetsVariables* = """{
|
||||
# "userId": "$1", $2
|
||||
# "count": 20,
|
||||
# "includePromotedContent": false,
|
||||
# "withDownvotePerspective": false,
|
||||
# "withReactionsMetadata": false,
|
||||
# "withReactionsPerspective": false,
|
||||
# "withVoice": false,
|
||||
# "withV2Timeline": true
|
||||
# }
|
||||
# """.replace(" ", "").replace("\n", "")
|
||||
|
||||
userTweetsVariables* = """{
|
||||
"rest_id": "$1",
|
||||
|
@ -157,4 +151,4 @@ const
|
|||
$2
|
||||
"count": 20,
|
||||
"includePromotedContent": false
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
}""".replace(" ", "").replace("\n", "")
|
|
@ -31,7 +31,8 @@ let
|
|||
illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]"
|
||||
|
||||
proc getUrlPrefix*(cfg: Config): string =
|
||||
"https://" & cfg.hostname
|
||||
if cfg.useHttps: https & cfg.hostname
|
||||
else: "http://" & cfg.hostname
|
||||
|
||||
proc shortLink*(text: string; length=28): string =
|
||||
result = text.replace(wwwRegex, "")
|
||||
|
|
|
@ -10,8 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool
|
|||
import views/[general, about]
|
||||
import routes/[
|
||||
preferences, timeline, status, media, search, list, #rss, debug,
|
||||
unsupported, embed, resolver, router_utils, home, follow, twitter_api,
|
||||
activityspoof]
|
||||
unsupported, embed, resolver, router_utils, home, follow, twitter_api]
|
||||
|
||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||
|
@ -52,7 +51,6 @@ createEmbedRouter(cfg)
|
|||
#createRssRouter(cfg)
|
||||
#createDebugRouter(cfg)
|
||||
createTwitterApiRouter(cfg)
|
||||
createActivityPubRouter(cfg)
|
||||
|
||||
settings:
|
||||
port = Port(cfg.port)
|
||||
|
@ -105,6 +103,5 @@ routes:
|
|||
extend resolver, ""
|
||||
extend embed, ""
|
||||
#extend debug, ""
|
||||
extend activityspoof, ""
|
||||
extend api, ""
|
||||
extend unsupported, ""
|
||||
|
|
|
@ -505,41 +505,39 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
|||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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") 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
|
||||
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", "itemContent", "value"}.getStr
|
||||
|
||||
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
||||
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
||||
|
|
|
@ -1,242 +0,0 @@
|
|||
# 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
|
|
@ -1,16 +1,16 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times
|
||||
import asyncdispatch, strutils, sequtils, uri, options, sugar, strformat
|
||||
|
||||
import jester, karax/vdom
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, formatters, api]
|
||||
import ../views/[general, status, search, mastoapi]
|
||||
import ../views/[general, status, search]
|
||||
|
||||
export json, uri, sequtils, options, sugar, times
|
||||
export uri, sequtils, options, sugar
|
||||
export router_utils
|
||||
export api, formatters
|
||||
export status, mastoapi
|
||||
export status
|
||||
|
||||
proc createStatusRouter*(cfg: Config) =
|
||||
router status:
|
||||
|
@ -39,35 +39,7 @@ proc createStatusRouter*(cfg: Config) =
|
|||
|
||||
get "/@name/status/@id/?":
|
||||
cond '.' notin @"name"
|
||||
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
|
||||
let id = @"id"
|
||||
|
||||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, showError("Invalid tweet ID", cfg)
|
||||
|
@ -106,8 +78,8 @@ proc createStatusRouter*(cfg: Config) =
|
|||
contextUrl = ""
|
||||
|
||||
if tweet.quote.isSome():
|
||||
let
|
||||
quote = get(tweet.quote)
|
||||
let
|
||||
quote = tweet.quote.get()
|
||||
quoteUser = quote.user
|
||||
if tweet.replyId != 0:
|
||||
context = &"↩ Replying to: @{tweet.replyHandle}\n↘ Quoting: {quoteUser.fullname} (@{quoteUser.username})"
|
||||
|
@ -130,7 +102,7 @@ proc createStatusRouter*(cfg: Config) =
|
|||
elif tweet.gif.isSome():
|
||||
let gif = get(tweet.gif)
|
||||
images = @[gif.thumb]
|
||||
video = getUrlPrefix(cfg) & getPicUrl(gif.url)
|
||||
video = getPicUrl(gif.url)
|
||||
#elif tweet.card.isSome():
|
||||
# let card = tweet.card.get()
|
||||
# if card.image.len > 0:
|
||||
|
@ -138,13 +110,10 @@ proc createStatusRouter*(cfg: Config) =
|
|||
# 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,
|
||||
context=context, contextUrl=contextUrl, id=id)
|
||||
context=context, contextUrl=contextUrl)
|
||||
|
||||
get "/@name/@s/@id/@m/?@i?":
|
||||
cond @"s" in ["status", "statuses"]
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import asyncdispatch, strutils, sequtils, uri, options, times, json
|
||||
import asyncdispatch, strutils, sequtils, uri, options, times
|
||||
import jester, karax/vdom
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, formatters, query, api]
|
||||
import ../views/[general, profile, timeline, status, search, mastoapi]
|
||||
import ../views/[general, profile, timeline, status, search]
|
||||
|
||||
export vdom
|
||||
export uri, sequtils, json
|
||||
export uri, sequtils
|
||||
export router_utils
|
||||
export formatters, query, api
|
||||
export profile, timeline, status, mastoapi
|
||||
export profile, timeline, status
|
||||
|
||||
proc getQuery*(request: Request; tab, name: string): Query =
|
||||
case tab
|
||||
|
@ -137,18 +137,6 @@ 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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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://gitlab.com/Cynosphere/nitter/commit/" & hash
|
||||
link = "https://gitdab.com/Cynosphere/nitter/commit/" & hash
|
||||
version = &"{date}-{hash}"
|
||||
|
||||
var aboutHtml: string
|
||||
|
|
|
@ -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
|
||||
|
||||
|
@ -39,7 +39,7 @@ 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=""; context=""; contextUrl="";
|
||||
id=""; time: Option[DateTime] = none(DateTime)): VNode =
|
||||
time: Option[DateTime] = none(DateTime)): VNode =
|
||||
var theme = prefs.theme.toTheme
|
||||
if "theme" in req.params:
|
||||
theme = req.params["theme"].toTheme
|
||||
|
@ -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,
|
||||
|
@ -104,9 +104,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||
|
||||
var siteName = "Nitter"
|
||||
|
||||
let isDiscord = req.headers.getOrDefault("User-Agent").toString().contains("Discordbot")
|
||||
|
||||
if time.isSome and not isDiscord:
|
||||
if time.isSome:
|
||||
let timeObj = time.get
|
||||
let timeStr = $timeObj
|
||||
meta(property="og:article:published_time", content=timeStr)
|
||||
|
@ -157,14 +155,12 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||
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")
|
||||
verbatim &"<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")
|
||||
verbatim &"<link rel=\"alternate\" href=\"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(contextUrl)}\" type=\"application/json+oembed\" />"
|
||||
|
||||
# this is last so images are also preloaded
|
||||
# if this is done earlier, Chrome only preloads one image for some reason
|
||||
|
@ -174,14 +170,14 @@ 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=""; context="";
|
||||
contextUrl=""; id=""; time: Option[DateTime] = none(DateTime)
|
||||
contextUrl = ""; time: Option[DateTime] = none(DateTime)
|
||||
): string =
|
||||
|
||||
let canonical = getTwitterLink(req.path, req.params)
|
||||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||
rss, canonical, avatar, context, contextUrl, id, time)
|
||||
rss, canonical, avatar, context, contextUrl, time)
|
||||
|
||||
body:
|
||||
renderNavbar(cfg, req, rss, canonical)
|
||||
|
|
|
@ -1,178 +0,0 @@
|
|||
# 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
|
|
@ -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", "tweets")
|
||||
hiddenField("f", "users")
|
||||
input(`type`="text", name="q", autofocus="",
|
||||
placeholder="Search...", dir="auto")
|
||||
placeholder="Enter username...", dir="auto")
|
||||
button(`type`="submit"): icon "search"
|
||||
|
||||
proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode =
|
||||
|
|
|
@ -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 tweet.birdwatch.isSome:
|
||||
if mainTweet and tweet.birdwatch.isSome:
|
||||
renderCommunityNote(tweet.birdwatch.get(), prefs)
|
||||
|
||||
if mainTweet:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue