From c7d6b4291c3fc527f867e0a9fe9dc4bcce9f9fb7 Mon Sep 17 00:00:00 2001 From: Cynthia Foxwell Date: Thu, 20 Mar 2025 11:42:34 -0600 Subject: [PATCH 01/12] Fix tweets not loading --- src/api.nim | 2 +- src/apiutils.nim | 3 ++- src/consts.nim | 42 ++++++++++++++++++-------------- src/parser.nim | 62 +++++++++++++++++++++++++----------------------- 4 files changed, 59 insertions(+), 50 deletions(-) diff --git a/src/api.nim b/src/api.nim index bc5db05..0a79071 100644 --- a/src/api.nim +++ b/src/api.nim @@ -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) diff --git a/src/apiutils.nim b/src/apiutils.nim index c5fe79b..b92b9d6 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -116,7 +116,8 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} = #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: diff --git a/src/consts.nim b/src/consts.nim index 8eb2057..68ade47 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -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", "") \ No newline at end of file +}""".replace(" ", "").replace("\n", "") diff --git a/src/parser.nim b/src/parser.nim index e306388..360b20a 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -505,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") 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 + 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)) From b3e35dba123c8c59695bb1422dc02d2bc5d879eb Mon Sep 17 00:00:00 2001 From: Cynthia Foxwell Date: Sat, 22 Mar 2025 22:17:34 -0600 Subject: [PATCH 02/12] mastoapi/activitypub spoof so i can have peak discord embeds :) --- src/formatters.nim | 3 +- src/nitter.nim | 5 +- src/routes/activityspoof.nim | 242 +++++++++++++++++++++++++++++++++++ src/routes/status.nim | 68 +++++++++- src/routes/timeline.nim | 20 ++- src/routes/twitter_api.nim | 2 +- src/views/general.nim | 32 +++-- src/views/mastoapi.nim | 178 ++++++++++++++++++++++++++ src/views/tweet.nim | 2 +- 9 files changed, 522 insertions(+), 30 deletions(-) create mode 100644 src/routes/activityspoof.nim create mode 100644 src/views/mastoapi.nim diff --git a/src/formatters.nim b/src/formatters.nim index 1fb9b43..29931cf 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -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, "") diff --git a/src/nitter.nim b/src/nitter.nim index a299280..15fa8dd 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -10,7 +10,8 @@ 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] + 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" @@ -51,6 +52,7 @@ createEmbedRouter(cfg) #createRssRouter(cfg) #createDebugRouter(cfg) createTwitterApiRouter(cfg) +createActivityPubRouter(cfg) settings: port = Port(cfg.port) @@ -103,5 +105,6 @@ routes: extend resolver, "" extend embed, "" #extend debug, "" + extend activityspoof, "" extend api, "" extend unsupported, "" diff --git a/src/routes/activityspoof.nim b/src/routes/activityspoof.nim new file mode 100644 index 0000000..c853554 --- /dev/null +++ b/src/routes/activityspoof.nim @@ -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"] = %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://gitdab.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 diff --git a/src/routes/status.nim b/src/routes/status.nim index dc109c3..7ca5322 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -1,16 +1,16 @@ # 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 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: @@ -41,6 +41,30 @@ proc createStatusRouter*(cfg: Config) = cond '.' notin @"name" 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 + if id.len > 19 or id.any(c => not c.isDigit): resp Http404, showError("Invalid tweet ID", cfg) @@ -78,8 +102,8 @@ proc createStatusRouter*(cfg: Config) = contextUrl = "" if tweet.quote.isSome(): - let - quote = tweet.quote.get() + let + quote = get(tweet.quote) quoteUser = quote.user if tweet.replyId != 0: context = &"↩ Replying to: @{tweet.replyHandle}\n↘ Quoting: {quoteUser.fullname} (@{quoteUser.username})" @@ -113,7 +137,37 @@ proc createStatusRouter*(cfg: Config) = 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) + context=context, contextUrl=contextUrl, id=id) + + get "/@name/status/@id.mp4": + cond '.' notin @"name" + let id = @"id" + + if id.len > 19 or id.any(c => not c.isDigit): + resp Http404, showError("Invalid tweet ID", cfg) + + let conv = await getTweet(id, getCursor()) + if conv == nil: + echo "nil conv" + + if conv == nil or conv.tweet == nil or conv.tweet.id == 0: + var error = "Tweet not found" + if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: + error = conv.tweet.tombstone + resp Http404, showError(error, cfg) + + let tweet = conv.tweet + + if tweet.video.isSome(): + let videoObj = get(tweet.video) + let vars = videoObj.variants.filterIt(it.contentType == mp4) + redirect(vars[^1].url) + elif tweet.gif.isSome(): + let gif = get(tweet.gif) + let url = getPicUrl(gif.url) + redirect(url) + + redirect("/$1/status/$2" % [@"name", @"id"]) get "/@name/@s/@id/@m/?@i?": cond @"s" in ["status", "statuses"] diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 70fd199..c951747 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -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, formatters, query, api] -import ../views/[general, profile, timeline, status, search] +import ../views/[general, profile, timeline, status, search, mastoapi] export vdom -export uri, sequtils +export uri, sequtils, json export router_utils export formatters, query, api -export profile, timeline, status +export profile, timeline, status, mastoapi proc getQuery*(request: Request; tab, name: string): Query = case tab @@ -137,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 diff --git a/src/routes/twitter_api.nim b/src/routes/twitter_api.nim index ba3507e..666c8a8 100644 --- a/src/routes/twitter_api.nim +++ b/src/routes/twitter_api.nim @@ -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 diff --git a/src/views/general.nim b/src/views/general.nim index bf94569..a100e0d 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -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=""; - time: Option[DateTime] = none(DateTime)): VNode = + id=""; time: Option[DateTime] = none(DateTime)): VNode = var theme = prefs.theme.toTheme if "theme" in req.params: theme = req.params["theme"].toTheme @@ -63,8 +63,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) 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="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="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,13 +104,15 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; var siteName = "Nitter" - if time.isSome: - let timeObj = time.get - let timeStr = $timeObj - meta(property="og:article:published_time", content=timeStr) + #let isDiscord = req.headers.hasKey("User-Agent") and req.headers["User-Agent"].contains("Discordbot") - let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss") - siteName = &"Nitter • {formattedTime}" + #if time.isSome and not isDiscord: + # let timeObj = time.get + # let timeStr = $timeObj + # meta(property="og:article:published_time", content=timeStr) + # + # let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss") + # siteName = &"Nitter • {formattedTime}" meta(property="og:site_name", content=siteName) @@ -155,12 +157,14 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; if 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") elif context != "" and contextUrl != "": var title = encodeUrl(finalizedTitleText) 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)}/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 @@ -170,14 +174,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 = ""; time: Option[DateTime] = none(DateTime) + 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, context, contextUrl, time) + rss, canonical, avatar, context, contextUrl, id, time) body: renderNavbar(cfg, req, rss, canonical) diff --git a/src/views/mastoapi.nim b/src/views/mastoapi.nim new file mode 100644 index 0000000..fa1bbfa --- /dev/null +++ b/src/views/mastoapi.nim @@ -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
{quote.user.fullName} (@{quote.user.username})\n{quoteContent}" + + if quote.video.isSome() or tweet.gif.isSome(): + content &= "\n📹" + if tweet.gif.isSome(): + content &= " (GIF)" + elif tweet.photos.len > 0: + content &= "\n🖼️" + if tweet.photos.len > 1: + content &= &" ({tweet.photos.len})" + + content &= "
" + + if tweet.birdwatch.isSome(): + let + note = get(tweet.birdwatch) + noteContent = replaceUrls(note.text, prefs) + content &= &"\n
ⓘ {note.title}\n{noteContent}
" + + result = content.replace("\n", "
") + +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"] = %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 diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 1603b2a..d986097 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -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: From 5240ccff2a2a4b4e4a820ce34fed8882d8485576 Mon Sep 17 00:00:00 2001 From: Cynthia Foxwell Date: Sat, 22 Mar 2025 22:50:31 -0600 Subject: [PATCH 03/12] fix direct video route --- src/routes/activityspoof.nim | 2 +- src/routes/status.nim | 41 ++++++++---------------------------- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/src/routes/activityspoof.nim b/src/routes/activityspoof.nim index c853554..863dfef 100644 --- a/src/routes/activityspoof.nim +++ b/src/routes/activityspoof.nim @@ -86,7 +86,7 @@ proc createActivityPubRouter*(cfg: Config) = mediaObj["id"] = %"150745989836308480" mediaObj["type"] = %"video" - mediaObj["url"] = %gif.url + mediaObj["url"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb)) mediaObj["preview_url"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb)) mediaObj["remote_url"] = newJNull() mediaObj["preview_remote_url"] = newJNull() diff --git a/src/routes/status.nim b/src/routes/status.nim index 7ca5322..6f9460a 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -39,7 +39,11 @@ 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): @@ -126,7 +130,7 @@ proc createStatusRouter*(cfg: Config) = elif tweet.gif.isSome(): let gif = get(tweet.gif) images = @[gif.thumb] - video = getPicUrl(gif.url) + video = getUrlPrefix(cfg) & getPicUrl(gif.url) #elif tweet.card.isSome(): # let card = tweet.card.get() # if card.image.len > 0: @@ -134,41 +138,14 @@ proc createStatusRouter*(cfg: Config) = # elif card.video.isSome(): # images = @[card.video.get().thumb] + if rawFile: + 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) - get "/@name/status/@id.mp4": - cond '.' notin @"name" - let id = @"id" - - if id.len > 19 or id.any(c => not c.isDigit): - resp Http404, showError("Invalid tweet ID", cfg) - - let conv = await getTweet(id, getCursor()) - if conv == nil: - echo "nil conv" - - if conv == nil or conv.tweet == nil or conv.tweet.id == 0: - var error = "Tweet not found" - if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: - error = conv.tweet.tombstone - resp Http404, showError(error, cfg) - - let tweet = conv.tweet - - if tweet.video.isSome(): - let videoObj = get(tweet.video) - let vars = videoObj.variants.filterIt(it.contentType == mp4) - redirect(vars[^1].url) - elif tweet.gif.isSome(): - let gif = get(tweet.gif) - let url = getPicUrl(gif.url) - redirect(url) - - redirect("/$1/status/$2" % [@"name", @"id"]) - get "/@name/@s/@id/@m/?@i?": cond @"s" in ["status", "statuses"] cond @"m" in ["video", "photo"] From 0d9ffa6aa2a15ae9f226b07592a2a6af40f3ca84 Mon Sep 17 00:00:00 2001 From: Cynthia Foxwell Date: Sat, 22 Mar 2025 22:52:28 -0600 Subject: [PATCH 04/12] forgot a check --- src/routes/status.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/status.nim b/src/routes/status.nim index 6f9460a..47b7871 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -138,7 +138,7 @@ proc createStatusRouter*(cfg: Config) = # elif card.video.isSome(): # images = @[card.video.get().thumb] - if rawFile: + if rawFile and video != "": redirect(video) let html = renderConversation(conv, prefs, getPath() & "#m") From 1a520f5792fce1eb452493127d4ef24b725169e9 Mon Sep 17 00:00:00 2001 From: Cynthia Foxwell Date: Sun, 23 Mar 2025 19:59:42 +0000 Subject: [PATCH 05/12] oop --- src/views/mastoapi.nim | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/views/mastoapi.nim b/src/views/mastoapi.nim index fa1bbfa..4f97013 100644 --- a/src/views/mastoapi.nim +++ b/src/views/mastoapi.nim @@ -12,13 +12,13 @@ proc formatTweetForMastoAPI*(tweet: Tweet, cfg: Config, prefs: Prefs): string = quoteUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}" content &= &"\n\n
{quote.user.fullName} (@{quote.user.username})\n{quoteContent}" - if quote.video.isSome() or tweet.gif.isSome(): + if quote.video.isSome() or quote.gif.isSome(): content &= "\n📹" - if tweet.gif.isSome(): + if quote.gif.isSome(): content &= " (GIF)" - elif tweet.photos.len > 0: + elif quote.photos.len > 0: content &= "\n🖼️" - if tweet.photos.len > 1: + if quote.photos.len > 1: content &= &" ({tweet.photos.len})" content &= "
" From d9aa2d17231e5702aa439f024ef0be838c26d8e3 Mon Sep 17 00:00:00 2001 From: Cynthia Foxwell Date: Sun, 23 Mar 2025 21:48:00 -0600 Subject: [PATCH 06/12] missed one --- src/views/mastoapi.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/mastoapi.nim b/src/views/mastoapi.nim index 4f97013..5cf27c3 100644 --- a/src/views/mastoapi.nim +++ b/src/views/mastoapi.nim @@ -19,7 +19,7 @@ proc formatTweetForMastoAPI*(tweet: Tweet, cfg: Config, prefs: Prefs): string = elif quote.photos.len > 0: content &= "\n🖼️" if quote.photos.len > 1: - content &= &" ({tweet.photos.len})" + content &= &" ({quote.photos.len})" content &= "" From 5772e4089cb565834c3dc195c8ae72dc3c15894b Mon Sep 17 00:00:00 2001 From: Cynthia Foxwell Date: Thu, 27 Mar 2025 10:42:21 -0600 Subject: [PATCH 07/12] rearrange icons so discord uses the transparent favicon --- src/views/general.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/general.nim b/src/views/general.nim index a100e0d..a80fb87 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -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="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png") 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="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, From 0023af43112283b2275983c4c8bb8499aa81524d Mon Sep 17 00:00:00 2001 From: Cynthia Foxwell Date: Thu, 27 Mar 2025 10:49:51 -0600 Subject: [PATCH 08/12] just make the apple touch icon transparent --- public/apple-touch-icon.png | Bin 1325 -> 3841 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 6909edc68a607068dc849f08dd9c48ff5c4fcdd4..2c8afeeda2412a52509c6d42f8d399d79bd8d34b 100644 GIT binary patch literal 3841 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 Date: Thu, 27 Mar 2025 10:54:01 -0600 Subject: [PATCH 09/12] update source url since itll be a while until gitdab comes back --- public/md/about.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/md/about.md b/public/md/about.md index 1bcb949..8a471af 100644 --- a/public/md/about.md +++ b/public/md/about.md @@ -5,7 +5,7 @@ privacy and performance. The source is available on GitHub at **This instance is running a fork, whose source can be found at** - + * No JavaScript or ads * All requests go through the backend, client never talks to Twitter From 42e8e7219e6b66d081cb330cb2b29dac844fcf59 Mon Sep 17 00:00:00 2001 From: Cynthia Foxwell Date: Thu, 27 Mar 2025 10:55:32 -0600 Subject: [PATCH 10/12] upstream search page change (users -> tweets) --- src/views/search.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/search.nim b/src/views/search.nim index abcc236..8dee8e6 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -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 = From 24a267da50da2b517145ad7275c4e76081c7da2d Mon Sep 17 00:00:00 2001 From: Cynthia Foxwell Date: Thu, 27 Mar 2025 11:11:28 -0600 Subject: [PATCH 11/12] forgot some urls --- src/routes/activityspoof.nim | 2 +- src/views/about.nim | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/activityspoof.nim b/src/routes/activityspoof.nim index 863dfef..eea061a 100644 --- a/src/routes/activityspoof.nim +++ b/src/routes/activityspoof.nim @@ -214,7 +214,7 @@ proc createActivityPubRouter*(cfg: Config) = nodeinfo["version"] = %"2.1" nodeinfo["software"] = %*{ "name": "Nitter", - "repository": "https://gitdab.com/Cynosphere/nitter" + "repository": "https://gitlab.com/Cynosphere/nitter" } var metadata = newJObject() diff --git a/src/views/about.nim b/src/views/about.nim index 891a610..4eb3a71 100644 --- a/src/views/about.nim +++ b/src/views/about.nim @@ -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://gitdab.com/Cynosphere/nitter/commit/" & hash + link = "https://gitlab.com/Cynosphere/nitter/commit/" & hash version = &"{date}-{hash}" var aboutHtml: string From 7a6548cb2b18001e0443f20ef93f76e57ef8acb8 Mon Sep 17 00:00:00 2001 From: Cynthia Foxwell Date: Sat, 29 Mar 2025 20:31:10 -0600 Subject: [PATCH 12/12] fix gifs in discord embeds --- src/routes/activityspoof.nim | 4 ++-- src/views/general.nim | 16 ++++++++-------- src/views/mastoapi.nim | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/routes/activityspoof.nim b/src/routes/activityspoof.nim index eea061a..6ecdd0a 100644 --- a/src/routes/activityspoof.nim +++ b/src/routes/activityspoof.nim @@ -14,7 +14,7 @@ export mastoapi proc createActivityPubRouter*(cfg: Config) = router activityspoof: - get "/api/v1/accounts": + get "/api/v1/accounts/?": resp Http200, {"Content-Type": "application/json"}, """[]""" get "/api/v1/statuses/@id": @@ -86,7 +86,7 @@ proc createActivityPubRouter*(cfg: Config) = mediaObj["id"] = %"150745989836308480" mediaObj["type"] = %"video" - mediaObj["url"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb)) + mediaObj["url"] = %(&"https://{gif.url}") mediaObj["preview_url"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb)) mediaObj["remote_url"] = newJNull() mediaObj["preview_remote_url"] = newJNull() diff --git a/src/views/general.nim b/src/views/general.nim index a80fb87..51447e3 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -104,15 +104,15 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; var siteName = "Nitter" - #let isDiscord = req.headers.hasKey("User-Agent") and req.headers["User-Agent"].contains("Discordbot") + 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) - # - # let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss") - # siteName = &"Nitter • {formattedTime}" + if time.isSome and not isDiscord: + let timeObj = time.get + let timeStr = $timeObj + meta(property="og:article:published_time", content=timeStr) + + let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss") + siteName = &"Nitter • {formattedTime}" meta(property="og:site_name", content=siteName) diff --git a/src/views/mastoapi.nim b/src/views/mastoapi.nim index 5cf27c3..bc57301 100644 --- a/src/views/mastoapi.nim +++ b/src/views/mastoapi.nim @@ -67,7 +67,7 @@ proc getActivityStream*(tweet: Tweet, cfg: Config, prefs: Prefs): JsonNode = mediaObj["type"] = %"Document" mediaObj["mediaType"] = %"video/mp4" - mediaObj["url"] = %gif.url + mediaObj["url"] = %(&"https://{gif.url}") mediaObj["name"] = newJNull() # FIXME a11y media.add(mediaObj)