Compare commits

...

12 commits

17 changed files with 559 additions and 81 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

View file

@ -5,7 +5,7 @@ privacy and performance. The source is available on GitHub at
<https://github.com/zedeus/nitter> <https://github.com/zedeus/nitter>
**This instance is running a fork, whose source can be found at** **This instance is running a fork, whose source can be found at**
<https://gitdab.com/Cynosphere/nitter> <https://gitlab.com/Cynosphere/nitter>
* 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

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

@ -116,7 +116,8 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
#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:

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",

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

@ -10,7 +10,8 @@ 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, list, #rss, 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"
@ -51,6 +52,7 @@ 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)
@ -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

@ -505,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") or entryId.startswith("reply-mixer-conversation"): 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))

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,16 +1,16 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils, sequtils, uri, options, sugar, strformat 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)
@ -79,7 +107,7 @@ proc createStatusRouter*(cfg: Config) =
if tweet.quote.isSome(): if tweet.quote.isSome():
let let
quote = tweet.quote.get() quote = get(tweet.quote)
quoteUser = quote.user quoteUser = quote.user
if tweet.replyId != 0: if tweet.replyId != 0:
context = &"↩ Replying to: @{tweet.replyHandle}\n↘ Quoting: {quoteUser.fullname} (@{quoteUser.username})" context = &"↩ Replying to: @{tweet.replyHandle}\n↘ Quoting: {quoteUser.fullname} (@{quoteUser.username})"
@ -102,7 +130,7 @@ proc createStatusRouter*(cfg: Config) =
elif tweet.gif.isSome(): elif tweet.gif.isSome():
let gif = get(tweet.gif) let gif = get(tweet.gif)
images = @[gif.thumb] images = @[gif.thumb]
video = getPicUrl(gif.url) video = getUrlPrefix(cfg) & getPicUrl(gif.url)
#elif tweet.card.isSome(): #elif tweet.card.isSome():
# let card = tweet.card.get() # let card = tweet.card.get()
# if card.image.len > 0: # if card.image.len > 0:
@ -110,10 +138,13 @@ proc createStatusRouter*(cfg: Config) =
# 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) 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, 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 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
@ -137,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

@ -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://gitdab.com/Cynosphere/nitter/commit/" & hash link = "https://gitlab.com/Cynosphere/nitter/commit/" & hash
version = &"{date}-{hash}" version = &"{date}-{hash}"
var aboutHtml: string var aboutHtml: string

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
@ -39,7 +39,7 @@ 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=""; context=""; contextUrl=""; 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
@ -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,
@ -104,7 +104,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
var siteName = "Nitter" var siteName = "Nitter"
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)
@ -155,12 +157,14 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
if contextUrl != "": if contextUrl != "":
url = contextUrl url = contextUrl
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\" />" 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 != "": elif context != "" and contextUrl != "":
var var
title = encodeUrl(finalizedTitleText) title = encodeUrl(finalizedTitleText)
author = encodeUrl(context) author = encodeUrl(context)
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\" />" 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
@ -170,14 +174,14 @@ 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=""; context=""; images: seq[string] = @[]; banner=""; avatar=""; context="";
contextUrl = ""; time: Option[DateTime] = none(DateTime) contextUrl=""; id=""; time: Option[DateTime] = none(DateTime)
): string = ): 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, context, contextUrl, 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 Normal 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: