From cb84ed219b7e671e46cc98690ba0938d8ae5d04c Mon Sep 17 00:00:00 2001 From: Eir Sunny Date: Thu, 7 Aug 2025 20:25:37 -0500 Subject: [PATCH] pull updates from cynthia foxwell's fork --- public/apple-touch-icon.png | Bin 1325 -> 3841 bytes public/md/about.md | 41 ++++-- src/api.nim | 2 +- src/apiutils.nim | 57 +++++---- src/consts.nim | 42 +++--- src/formatters.nim | 3 +- src/nitter.nim | 17 ++- src/parser.nim | 63 ++++----- src/routes/activityspoof.nim | 242 +++++++++++++++++++++++++++++++++++ src/routes/home.nim | 4 +- src/routes/list.nim | 16 ++- src/routes/status.nim | 87 ++++++++++--- src/routes/timeline.nim | 43 +++++-- src/routes/twitter_api.nim | 2 +- src/types.nim | 1 + src/views/about.nim | 6 +- src/views/general.nim | 62 +++++---- src/views/mastoapi.nim | 178 ++++++++++++++++++++++++++ src/views/search.nim | 4 +- src/views/tweet.nim | 2 +- 20 files changed, 716 insertions(+), 156 deletions(-) create mode 100755 src/routes/activityspoof.nim create mode 100755 src/views/mastoapi.nim 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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 (original Nitter repo: -) + + +**This instance is running a fork, whose source can be found at** + +Additionally, I am copying changes from ([her instance](https://tw.counter-strike.gay)) * No JavaScript or ads * All requests go through the backend, client never talks to Twitter @@ -20,6 +23,14 @@ Nitter's GitHub wiki contains [browser extensions](https://github.com/zedeus/nitter/wiki/Extensions) maintained by the community. +### Fork features (from [Cynthia Foxwell's fork]()) + +* Localized following via cookies (list exportable and editable in preferences) +* Image zooming/carousel (requires JavaScript) +* Up to date Twitter features, e.g. Community Notes +* Embeds for chat services on-par with services like [FxTwitter](https://github.com/FixTweet/FxTwitter) and [vxTwitter](https://github.com/dylanpdx/BetterTwitFix) +* No dependency on Redis, as it has caused ratelimiting issues, but also forcably disables RSS + ## Why use Nitter? It's impossible to use Twitter without JavaScript enabled. For privacy-minded @@ -37,10 +48,6 @@ Twitter without JavaScript while retaining your privacy. In addition to respecting your privacy, Nitter is on average around 15 times lighter than Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster). -In the future a simple account system will be added that lets you follow Twitter -users, allowing you to have a clean chronological timeline without needing a -Twitter account. - ## Donating Donations go to zedeus, original creator of Nitter. @@ -52,8 +59,24 @@ ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \ LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \ XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL -## Instance info +## Credits -Based on [Cynthia Foxwell's fork](https://gitdab.com/Cynosphere/nitter) ([her instance](https://tw.counter-strike.gay)). +* Cynthia Foxwell for her fork of this project +* Zedeus for this project +* PrivacyDevel, cmj, and taskylizard for keeping this project alive with forks after the main repo went inactive +* Every other contributors who've committed to the main repo in the past -[Source code for this instance](https://git.eir-nya.gay/eir/nitter). +## To any law enforcement agencies and copyright holders (from [Cynthia Foxwell's fork]()) + +**All illegal content should be reported to Twitter directly.** This service is +merely a proxy of Twitter and no content is hosted on this server. Do not waste +your time contacting internet service providers, hosting providers and/or domain +registrars. + +If you would like more context, you can read about this exact issue happening to +[PussTheCat.org's instance](https://pussthecat.org/nitter/). + +I emplore all Nitter instance hosts to not enable media proxying, even if it +"phones home" to Twitter's CDN (which doesn't really pose a tracking risk and +breaks videos anyways), as it [has been used as an attack vector to take down +nitter.net](https://github.com/zedeus/nitter/issues/1150#issuecomment-1890855255). 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 404c824..94e3a2f 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -30,12 +30,13 @@ proc genParams*(pars: openArray[(string, string)] = @[]; cursor=""; else: result &= ("cursor", cursor) -proc genHeaders*(token: Token = nil): HttpHeaders = +#proc genHeaders*(token: Token = nil): HttpHeaders = +proc genHeaders*(): HttpHeaders = result = newHttpHeaders({ "connection": "keep-alive", "authorization": auth, "content-type": "application/json", - "x-guest-token": if token == nil: "" else: token.tok, + #"x-guest-token": if token == nil: "" else: token.tok, "x-twitter-active-user": "yes", "authority": "api.twitter.com", "accept-encoding": "gzip", @@ -44,20 +45,20 @@ proc genHeaders*(token: Token = nil): HttpHeaders = "DNT": "1" }) -template updateToken() = - if resp.headers.hasKey(rlRemaining): - let - remaining = parseInt(resp.headers[rlRemaining]) - reset = parseInt(resp.headers[rlReset]) - token.setRateLimit(api, remaining, reset) +#template updateToken() = +# if resp.headers.hasKey(rlRemaining): +# let +# remaining = parseInt(resp.headers[rlRemaining]) +# reset = parseInt(resp.headers[rlReset]) +# token.setRateLimit(api, remaining, reset) template fetchImpl(result, additional_headers, fetchBody) {.dirty.} = once: pool = HttpPool() - var token = await getToken(api) - if token.tok.len == 0: - raise rateLimitError() + #var token = await getToken(api) + #if token.tok.len == 0: + # raise rateLimitError() if len(cfg.cookieHeader) != 0: additional_headers.add("Cookie", cfg.cookieHeader) @@ -66,7 +67,8 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} = try: var resp: AsyncResponse - var headers = genHeaders(token) + #var headers = genHeaders(token) + var headers = genHeaders() for key, value in additional_headers.pairs(): headers.add(key, value) pool.use(headers): @@ -87,7 +89,7 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} = let remaining = parseInt(resp.headers[rlRemaining]) reset = parseInt(resp.headers[rlReset]) - token.setRateLimit(api, remaining, reset) + #token.setRateLimit(api, remaining, reset) if result.len > 0: if resp.headers.getOrDefault("content-encoding") == "gzip": @@ -97,35 +99,36 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} = let errors = result.fromJson(Errors) if errors in {expiredToken, badToken, authorizationError}: echo "fetch error: ", errors - release(token, invalid=true) + #release(token, invalid=true) raise rateLimitError() elif errors in {rateLimited}: # rate limit hit, resets after 24 hours #setLimited(account, api) raise rateLimitError() elif result.startsWith("429 Too Many Requests"): - echo "[accounts] 429 error, API: ", api, ", token: ", token[] + #echo "[accounts] 429 error, API: ", api, ", token: ", token[] #account.apis[api].remaining = 0 # rate limit hit, resets after the 15 minute window raise rateLimitError() fetchBody - release(token, used=true) + #release(token, used=true) if resp.status == $Http400: - raise newException(InternalError, $url) + let errText = "body: '" & result & "' url: " & $url + raise newException(InternalError, errText) except InternalError as e: raise e except BadClientError as e: - release(token, used=true) + #release(token, used=true) raise e except OSError as e: raise e except Exception as e: - echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url - if "length" notin e.msg and "descriptor" notin e.msg: - release(token, invalid=true) + echo "error: ", e.name, ", msg: ", e.msg, ", url: ", url + #if "length" notin e.msg and "descriptor" notin e.msg: + #release(token, invalid=true) raise rateLimitError() template retry(bod) = @@ -136,7 +139,7 @@ template retry(bod) = bod proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} = - retry: + #retry: var body: string fetchImpl(body, additional_headers): if body.startsWith('{') or body.startsWith('['): @@ -145,26 +148,26 @@ proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders echo resp.status, ": ", body, " --- url: ", url result = newJNull() - updateToken() + #updateToken() let error = result.getError if error in {expiredToken, badToken}: echo "fetch error: ", result.getError - release(token, invalid=true) + #release(token, invalid=true) raise rateLimitError() proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} = - retry: + #retry: fetchImpl(result, additional_headers): if not (result.startsWith('{') or result.startsWith('[')): echo resp.status, ": ", result, " --- url: ", url result.setLen(0) - updateToken() + #updateToken() if result.startsWith("{\"errors"): let errors = result.fromJson(Errors) if errors in {expiredToken, badToken}: echo "fetch error: ", errors - release(token, invalid=true) + #release(token, invalid=true) raise rateLimitError() \ No newline at end of file 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/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 d3901ae..15fa8dd 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -9,8 +9,9 @@ import jester import types, config, prefs, formatters, redis_cache, http_pool import views/[general, about] import routes/[ - preferences, timeline, status, media, search, rss, list, #debug, - unsupported, embed, resolver, router_utils, home, follow, twitter_api] + preferences, timeline, status, media, search, list, #rss, debug, + unsupported, embed, resolver, router_utils, home, follow, twitter_api, + activityspoof] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" @@ -35,9 +36,9 @@ setMaxHttpConns(cfg.httpMaxConns) setHttpProxy(cfg.proxy, cfg.proxyAuth) initAboutPage(cfg.staticDir) -waitFor initRedisPool(cfg) -stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" -stdout.flushFile +#waitFor initRedisPool(cfg) +#stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" +#stdout.flushFile createUnsupportedRouter(cfg) createResolverRouter(cfg) @@ -48,9 +49,10 @@ createStatusRouter(cfg) createSearchRouter(cfg) createMediaRouter(cfg) createEmbedRouter(cfg) -createRssRouter(cfg) +#createRssRouter(cfg) #createDebugRouter(cfg) createTwitterApiRouter(cfg) +createActivityPubRouter(cfg) settings: port = Port(cfg.port) @@ -93,7 +95,7 @@ routes: extend home, "" extend follow, "" - extend rss, "" + #extend rss, "" extend status, "" extend search, "" extend timeline, "" @@ -103,5 +105,6 @@ routes: extend resolver, "" extend embed, "" #extend debug, "" + extend activityspoof, "" extend api, "" extend unsupported, "" diff --git a/src/parser.nim b/src/parser.nim index 10d2866..360b20a 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -208,6 +208,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = id: js{"id_str"}.getId, threadId: js{"conversation_id_str"}.getId, replyId: js{"in_reply_to_status_id_str"}.getId, + replyHandle: js{"in_reply_to_screen_name"}.getStr, text: js{"full_text"}.getStr, time: js{"created_at"}.getTime, hasThread: js{"self_thread"}.notNull, @@ -504,39 +505,41 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = if instructions.len == 0: return - for e in instructions[0]{"entries"}: - let entryId = e{"entryId"}.getStr - if entryId.startsWith("tweet"): - with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: - let tweet = parseGraphTweet(tweetResult, true) + for i in instructions: + if i{"type"}.getStr == "TimelineAddEntries": + for e in i{"entries"}: + let entryId = e{"entryId"}.getStr + if entryId.startsWith("tweet"): + with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: + let tweet = parseGraphTweet(tweetResult, true) - if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) - if $tweet.id == tweetId: - result.tweet = tweet - else: - result.before.content.add tweet - elif entryId.startsWith("tombstone"): - let id = entryId.getId() - let tweet = Tweet( - id: parseBiggestInt(id), - available: false, - text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone - ) + if $tweet.id == tweetId: + result.tweet = tweet + else: + result.before.content.add tweet + elif entryId.startsWith("tombstone"): + let id = entryId.getId() + let tweet = Tweet( + id: parseBiggestInt(id), + available: false, + text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone + ) - if id == tweetId: - result.tweet = tweet - else: - result.before.content.add tweet - elif entryId.startsWith("conversationthread"): - let (thread, self) = parseGraphThread(e) - if self: - result.after = thread - else: - result.replies.content.add thread - elif entryId.startsWith("cursor-bottom"): - result.replies.bottom = e{"content", "itemContent", "value"}.getStr + if id == tweetId: + result.tweet = tweet + else: + result.before.content.add tweet + elif entryId.startsWith("conversationthread") or entryId.startswith("reply-mixer-conversation"): + let (thread, self) = parseGraphThread(e) + if self: + result.after = thread + else: + result.replies.content.add thread + elif entryId.startsWith("cursor-bottom"): + result.replies.bottom = e{"content", "value"}.getStr proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) diff --git a/src/routes/activityspoof.nim b/src/routes/activityspoof.nim new file mode 100755 index 0000000..6ecdd0a --- /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"] = %(&"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 diff --git a/src/routes/home.nim b/src/routes/home.nim index dcdbb5e..43ff4e7 100644 --- a/src/routes/home.nim +++ b/src/routes/home.nim @@ -1,6 +1,6 @@ import jester import asyncdispatch, strutils, options, router_utils, timeline -import ".."/[prefs, types, utils, redis_cache] +import ".."/[prefs, types, utils] import ../views/[general, home, search] export home @@ -43,7 +43,7 @@ proc createHomeRouter*(cfg: Config) = query.kind = userList for name in names: - let prof = await getCachedUser(name) + let prof = await getGraphUser(name) profs &= @[prof] resp renderMain(renderFollowing(query, profs, prefs), request, cfg, prefs) diff --git a/src/routes/list.nim b/src/routes/list.nim index ac3e97e..0bd7305 100644 --- a/src/routes/list.nim +++ b/src/routes/list.nim @@ -4,7 +4,7 @@ import strutils, strformat, uri import jester import router_utils -import ".."/[types, redis_cache, api] +import ".."/[types, api] import ../views/[general, timeline, list] template respList*(list, timeline, title, vnode: typed) = @@ -20,6 +20,14 @@ template respList*(list, timeline, title, vnode: typed) = proc title*(list: List): string = &"@{list.username}/{list.name}" + +proc getList*(username=""; slug=""; id=""): Future[List] {.async.} = + if id.len > 0: + result = await getGraphList(id) + else: + result = await getGraphListBySlug(username, slug) + + proc createListRouter*(cfg: Config) = router list: get "/@name/lists/@slug/?": @@ -28,7 +36,7 @@ proc createListRouter*(cfg: Config) = cond @"slug" != "memberships" let slug = decodeUrl(@"slug") - list = await getCachedList(@"name", slug) + list = await getList(@"name", slug) if list.id.len == 0: resp Http404, showError(&"""List "{@"slug"}" not found""", cfg) redirect(&"/i/lists/{list.id}") @@ -37,7 +45,7 @@ proc createListRouter*(cfg: Config) = cond '.' notin @"id" let prefs = cookiePrefs() - list = await getCachedList(id=(@"id")) + list = await getList(id=(@"id")) timeline = await getGraphListTweets(list.id, getCursor()) vnode = renderTimelineTweets(timeline, prefs, request.path) respList(list, timeline, list.title, vnode) @@ -46,6 +54,6 @@ proc createListRouter*(cfg: Config) = cond '.' notin @"id" let prefs = cookiePrefs() - list = await getCachedList(id=(@"id")) + list = await getList(id=(@"id")) members = await getGraphListMembers(list, getCursor()) respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path)) diff --git a/src/routes/status.nim b/src/routes/status.nim index 66bddb4..47b7871 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 +import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times import jester, karax/vdom import router_utils import ".."/[types, formatters, api] -import ../views/[general, status, search] +import ../views/[general, status, search, mastoapi] -export uri, sequtils, options, sugar +export json, uri, sequtils, options, sugar, times export router_utils export api, formatters -export status +export status, mastoapi proc createStatusRouter*(cfg: Config) = router status: @@ -39,7 +39,35 @@ proc createStatusRouter*(cfg: Config) = get "/@name/status/@id/?": cond '.' notin @"name" - let id = @"id" + var id = @"id" + var rawFile = false + if id.endsWith(".mp4"): + rawFile = true + id.removeSuffix(".mp4") + + if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json": + if id.len > 19 or id.any(c => not c.isDigit): + resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}""" + + let prefs = cookiePrefs() + + let conv = await getTweet(id) + if conv == nil: + echo "nil conv" + + if conv == nil or conv.tweet == nil or conv.tweet.id == 0: + var error = "Record not found" + if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: + error = conv.tweet.tombstone + + var errJson = newJObject() + errJson["error"] = %error + + resp Http404, {"Content-Type": "application/json"}, $errJson + + let postJson = getActivityStream(conv.tweet, cfg, prefs) + + resp Http200, {"Content-Type": "application/json"}, $postJson if id.len > 19 or id.any(c => not c.isDigit): resp Http404, showError("Invalid tweet ID", cfg) @@ -64,38 +92,59 @@ proc createStatusRouter*(cfg: Config) = resp Http404, showError(error, cfg) let - title = pageTitle(conv.tweet) - ogTitle = pageTitle(conv.tweet.user) - desc = conv.tweet.text - avatar = conv.tweet.user.userPic - time = some(conv.tweet.time) + tweet = conv.tweet + title = pageTitle(tweet) + ogTitle = pageTitle(tweet.user) + desc = tweet.text + avatar = tweet.user.userPic + time = some(tweet.time) var - images = conv.tweet.photos + images = tweet.photos video = "" + context = "" + contextUrl = "" - if conv.tweet.video.isSome(): - let videoObj = get(conv.tweet.video) + if tweet.quote.isSome(): + let + quote = get(tweet.quote) + quoteUser = quote.user + if tweet.replyId != 0: + context = &"↩ Replying to: @{tweet.replyHandle}\n↘ Quoting: {quoteUser.fullname} (@{quoteUser.username})" + contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}" + else: + context = &"↘ Quoting: {quoteUser.fullname} (@{quoteUser.username})" + contextUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}" + elif tweet.replyId != 0: + context = &"↩ Replying to: @{tweet.replyHandle}" + contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}" + + if tweet.video.isSome(): + let videoObj = get(tweet.video) images = @[videoObj.thumb] let vars = videoObj.variants.filterIt(it.contentType == mp4) # idk why this wont sort when it sorts everywhere else #video = vars.sortedByIt(it.bitrate)[^1].url video = vars[^1].url - elif conv.tweet.gif.isSome(): - let gif = get(conv.tweet.gif) + elif tweet.gif.isSome(): + let gif = get(tweet.gif) images = @[gif.thumb] - video = getPicUrl(gif.url) - #elif conv.tweet.card.isSome(): - # let card = conv.tweet.card.get() + video = getUrlPrefix(cfg) & getPicUrl(gif.url) + #elif tweet.card.isSome(): + # let card = tweet.card.get() # if card.image.len > 0: # images = @[card.image] # elif card.video.isSome(): # images = @[card.video.get().thumb] + if rawFile and video != "": + redirect(video) + let html = renderConversation(conv, prefs, getPath() & "#m") resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, - images=images, video=video, avatar=avatar, time=time) + images=images, video=video, avatar=avatar, time=time, + context=context, contextUrl=contextUrl, id=id) get "/@name/@s/@id/@m/?@i?": cond @"s" in ["status", "statuses"] diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 96e61a5..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, redis_cache, formatters, query, api] -import ../views/[general, profile, timeline, status, search] +import ".."/[types, formatters, query, api] +import ../views/[general, profile, timeline, status, search, mastoapi] export vdom -export uri, sequtils +export uri, sequtils, json export router_utils -export redis_cache, formatters, query, api -export profile, timeline, status +export formatters, query, api +export profile, timeline, status, mastoapi proc getQuery*(request: Request; tab, name: string): Query = case tab @@ -28,6 +28,19 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] = else: body +proc getUserId(username: string): Future[string] {.async.} = + let user = await getGraphUser(username) + if user.suspended: + return "suspended" + else: + return user.id + + +proc getUsername*(userId: string): Future[string] {.async.} = + let user = await getGraphUserById(userId) + result = user.username + + proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false; skipPinned=false): Future[Profile] {.async.} = let @@ -48,9 +61,9 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false; let rail = skipIf(skipRail or query.kind == media, @[]): - getCachedPhotoRail(name) + getPhotoRail(name) - user = getCachedUser(name) + user = getGraphUser(name) result = case query.kind @@ -94,7 +107,7 @@ template respTimeline*(timeline: typed) = template respUserId*() = cond @"user_id".len > 0 - let username = await getCachedUsername(@"user_id") + let username = await getUsername(@"user_id") if username.len > 0: redirect("/" & username) else: @@ -124,6 +137,18 @@ proc createTimelineRouter*(cfg: Config) = of "following": resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs) else: + if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json": + let userId = await getUserId(@"name") + + if userId == "suspended" or userId.len == 0: + resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}""" + + let user = await getGraphUser(@"name") + + let userJson = getActivityStream(user, cfg, prefs) + + resp Http200, {"Content-Type": "application/json"}, $userJson + var query = request.getQuery(@"tab", @"name") if names.len != 1: query.fromUser = names 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/types.nim b/src/types.nim index 1275329..8209483 100644 --- a/src/types.nim +++ b/src/types.nim @@ -211,6 +211,7 @@ type text*: string time*: DateTime reply*: seq[string] + replyHandle*: string pinned*: bool hasThread*: bool available*: bool diff --git a/src/views/about.nim b/src/views/about.nim index 978b60f..f969921 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://github.com/zedeus/nitter/commit/" & hash + link = "https://git.eir-nya.gay/nitter/commit/" & hash version = &"{date}-{hash}" var aboutHtml: string @@ -20,3 +20,7 @@ proc initAboutPage*(dir: string) = proc renderAbout*(): VNode = buildHtml(tdiv(class="overlay-panel")): verbatim aboutHtml + h2: text "Instance info" + p: + text "Version " + a(href=link): text version diff --git a/src/views/general.nim b/src/views/general.nim index 1f877c9..953679f 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 @@ -29,8 +29,8 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = tdiv(class="nav-item right"): icon "search", title="Search", href="/search" - if cfg.enableRss and rss.len > 0: - icon "rss-feed", title="RSS Feed", href=rss + #if cfg.enableRss and rss.len > 0: + #icon "rss-feed", title="RSS Feed", href=rss icon "bird", title="Open in Twitter", href=canonical a(href="https://liberapay.com/zedeus"): verbatim lp icon "info", title="About", href="/about" @@ -38,15 +38,15 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; video=""; images: seq[string] = @[]; banner=""; ogTitle=""; - rss=""; canonical=""; avatar=""; - time: Option[DateTime] = none(DateTime)): VNode = + rss=""; canonical=""; avatar=""; context=""; contextUrl=""; + id=""; time: Option[DateTime] = none(DateTime)): VNode = var theme = prefs.theme.toTheme if "theme" in req.params: theme = req.params["theme"].toTheme let ogType = if video.len > 0: "video.other" - elif rss.len > 0: "object" + #elif rss.len > 0: "object" elif images.len > 0: "photo" else: "article" @@ -62,9 +62,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; if theme.len > 0: link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) + link(rel="icon", type="image/png", sizes="32x32", href=(&"{getUrlPrefix(cfg)}/favicon-32x32.png")) + link(rel="icon", type="image/png", sizes="16x16", href=(&"{getUrlPrefix(cfg)}/favicon-16x16.png")) link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png") - link(rel="icon", type="image/png", sizes="32x32", href="/favicon-32x32.png") - link(rel="icon", type="image/png", sizes="16x16", href="/favicon-16x16.png") link(rel="manifest", href="/site.webmanifest") link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60") link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, @@ -73,8 +73,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; if canonical.len > 0: link(rel="canonical", href=canonical) - if cfg.enableRss and rss.len > 0: - link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") + #if cfg.enableRss and rss.len > 0: + #link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") if prefs.hlsPlayback: script(src="/js/hls.light.min.js", `defer`="") @@ -107,7 +107,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; var siteName = cfg.title - if time.isSome: + let isDiscord = req.headers.getOrDefault("User-Agent").toString().contains("Discordbot") + + if time.isSome and not isDiscord: let timeObj = time.get let timeStr = $timeObj meta(property="og:article:published_time", content=timeStr) @@ -131,10 +133,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; meta(property="og:image", content=image) if video.len == 0: meta(property="twitter:image:src", content=image) - if rss.len > 0: - meta(property="twitter:card", content="summary") - elif video.len == 0: meta(property="twitter:card", content="summary_large_image") + else: + meta(property="twitter:card", content="summary") elif avatar.len > 0: let avatarUrl = getUrlPrefix(cfg) & getPicUrl(avatar) meta(property="og:image", content=avatarUrl) @@ -143,16 +144,30 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; meta(property="og:video:url", content=video) meta(property="og:video:secure_url", content=video) meta(property="og:video:type", content="video/mp4") - var title = encodeUrl(finalizedDesc) - var author = encodeUrl(finalizedTitleText) + + var + title = encodeUrl(finalizedDesc) + author = encodeUrl(finalizedTitleText) + url = req.path + if len(finalizedDesc) > 67: title = author author = encodeUrl(finalizedDesc) - verbatim &"" - #link(rel="alternate", - # href=&"{getUrlPrefix(cfg)}/oembed.json?type=video&title={encodeUrl(stripHtml(desc))}&user={encodeUrl(finalizedTitleText)}&url={encodeUrl(req.path)}", - # `type`="application/json+oembed") + if context != "": + author = encodeUrl(context & "\n") & author + + if contextUrl != "": + url = contextUrl + + link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(url)}"), type="application/json+oembed") + elif context != "" and contextUrl != "": + var + title = encodeUrl(finalizedTitleText) + author = encodeUrl(context) + link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(contextUrl)}"), type="application/json+oembed") + + link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/users/i/statuses/{id}"), type="application/activity+json") # this is last so images are also preloaded # if this is done earlier, Chrome only preloads one image for some reason @@ -161,14 +176,15 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; titleText=""; desc=""; ogTitle=""; rss=""; video=""; - images: seq[string] = @[]; banner=""; avatar=""; - time: Option[DateTime] = none(DateTime)): string = + images: seq[string] = @[]; banner=""; avatar=""; context=""; + contextUrl=""; id=""; time: Option[DateTime] = none(DateTime) + ): string = let canonical = getTwitterLink(req.path, req.params) let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, - rss, canonical, avatar, time) + rss, canonical, avatar, context, contextUrl, id, time) body: renderNavbar(cfg, req, rss, canonical) diff --git a/src/views/mastoapi.nim b/src/views/mastoapi.nim new file mode 100755 index 0000000..bc57301 --- /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 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 &= "
" + + 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"] = %(&"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 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 = 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: