pull updates from cynthia foxwell's fork
Some checks failed
Build and Publish Docker / build (push) Has been cancelled
Some checks failed
Build and Publish Docker / build (push) Has been cancelled
This commit is contained in:
parent
775f04eeaa
commit
cb84ed219b
Binary file not shown.
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.8 KiB |
@ -2,8 +2,11 @@
|
||||
|
||||
Nitter is a free and open source alternative Twitter front-end focused on
|
||||
privacy and performance. The source is available on GitHub at
|
||||
<https://gitdab.com/Cynosphere/nitter> (original Nitter repo:
|
||||
<https://github.com/zedeus/nitter>)
|
||||
<https://github.com/zedeus/nitter>
|
||||
|
||||
**This instance is running a fork, whose source can be found at**
|
||||
<https://git.eir-nya.gay/eir/nitter>
|
||||
Additionally, I am copying changes from <https://gitlab.com/Cynosphere/nitter> ([her instance](https://tw.counter-strike.gay))
|
||||
|
||||
* No JavaScript or ads
|
||||
* All requests go through the backend, client never talks to Twitter
|
||||
@ -20,6 +23,14 @@ Nitter's GitHub wiki contains
|
||||
[browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
|
||||
maintained by the community.
|
||||
|
||||
### Fork features (from [Cynthia Foxwell's fork](<https://gitlab.com/Cynosphere/nitter>))
|
||||
|
||||
* Localized following via cookies (list exportable and editable in preferences)
|
||||
* Image zooming/carousel (requires JavaScript)
|
||||
* Up to date Twitter features, e.g. Community Notes
|
||||
* Embeds for chat services on-par with services like [FxTwitter](https://github.com/FixTweet/FxTwitter) and [vxTwitter](https://github.com/dylanpdx/BetterTwitFix)
|
||||
* No dependency on Redis, as it has caused ratelimiting issues, but also forcably disables RSS
|
||||
|
||||
## Why use Nitter?
|
||||
|
||||
It's impossible to use Twitter without JavaScript enabled. For privacy-minded
|
||||
@ -37,10 +48,6 @@ Twitter without JavaScript while retaining your privacy. In addition to
|
||||
respecting your privacy, Nitter is on average around 15 times lighter than
|
||||
Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster).
|
||||
|
||||
In the future a simple account system will be added that lets you follow Twitter
|
||||
users, allowing you to have a clean chronological timeline without needing a
|
||||
Twitter account.
|
||||
|
||||
## Donating
|
||||
|
||||
Donations go to zedeus, original creator of Nitter.
|
||||
@ -52,8 +59,24 @@ ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
|
||||
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
|
||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
|
||||
|
||||
## Instance info
|
||||
## Credits
|
||||
|
||||
Based on [Cynthia Foxwell's fork](https://gitdab.com/Cynosphere/nitter) ([her instance](https://tw.counter-strike.gay)).
|
||||
* Cynthia Foxwell for her fork of this project
|
||||
* Zedeus for this project
|
||||
* PrivacyDevel, cmj, and taskylizard for keeping this project alive with forks after the main repo went inactive
|
||||
* Every other contributors who've committed to the main repo in the past
|
||||
|
||||
[Source code for this instance](https://git.eir-nya.gay/eir/nitter).
|
||||
## To any law enforcement agencies and copyright holders (from [Cynthia Foxwell's fork](<https://gitlab.com/Cynosphere/nitter>))
|
||||
|
||||
**All illegal content should be reported to Twitter directly.** This service is
|
||||
merely a proxy of Twitter and no content is hosted on this server. Do not waste
|
||||
your time contacting internet service providers, hosting providers and/or domain
|
||||
registrars.
|
||||
|
||||
If you would like more context, you can read about this exact issue happening to
|
||||
[PussTheCat.org's instance](https://pussthecat.org/nitter/).
|
||||
|
||||
I emplore all Nitter instance hosts to not enable media proxying, even if it
|
||||
"phones home" to Twitter's CDN (which doesn't really pose a tracking risk and
|
||||
breaks videos anyways), as it [has been used as an attack vector to take down
|
||||
nitter.net](https://github.com/zedeus/nitter/issues/1150#issuecomment-1890855255).
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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()
|
@ -20,7 +20,7 @@ const
|
||||
graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2"
|
||||
graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2"
|
||||
graphUserMedia* = graphql / "dexO_2tohK86JDudXXG3Yw/UserMedia"
|
||||
graphTweet* = graphql / "q94uRCEn65LZThakYcPT6g/TweetDetail"
|
||||
graphTweet* = graphql / "y90SwUGBZ3yz0yNUmCHgTw/TweetDetail"
|
||||
graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery"
|
||||
graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
|
||||
graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
|
||||
@ -95,30 +95,36 @@ const
|
||||
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
|
||||
"verified_phone_label_enabled": false,
|
||||
"vibe_api_enabled": false,
|
||||
"view_counts_everywhere_api_enabled": false
|
||||
"view_counts_everywhere_api_enabled": false,
|
||||
"premium_content_api_read_enabled": false,
|
||||
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
|
||||
"responsive_web_grok_analysis_button_from_backend": false,
|
||||
"responsive_web_grok_analyze_post_followups_enabled": false,
|
||||
"responsive_web_jetfuel_frame": false,
|
||||
"profile_label_improvements_pcf_label_in_post_enabled": true,
|
||||
"responsive_web_grok_image_annotation_enabled": false,
|
||||
"responsive_web_grok_share_attachment_enabled": false,
|
||||
"rweb_video_screen_enabled": false
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
tweetVariables* = """{
|
||||
"focalTweetId": "$1",
|
||||
$2
|
||||
"includeHasBirdwatchNotes": false,
|
||||
"with_rux_injections": false,
|
||||
"rankingMode": "Relevance",
|
||||
"includePromotedContent": false,
|
||||
"withBirdwatchNotes": false,
|
||||
"withVoice": false,
|
||||
"withV2Timeline": true
|
||||
"withCommunity": true,
|
||||
"withQuickPromoteEligibilityTweetFields": false,
|
||||
"withBirdwatchNotes": true,
|
||||
"withVoice": true
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
# oldUserTweetsVariables* = """{
|
||||
# "userId": "$1", $2
|
||||
# "count": 20,
|
||||
# "includePromotedContent": false,
|
||||
# "withDownvotePerspective": false,
|
||||
# "withReactionsMetadata": false,
|
||||
# "withReactionsPerspective": false,
|
||||
# "withVoice": false,
|
||||
# "withV2Timeline": true
|
||||
# }
|
||||
# """.replace(" ", "").replace("\n", "")
|
||||
tweetFieldToggles* = """{
|
||||
"withArticleRichContentState": false,
|
||||
"withArticlePlainText": true,
|
||||
"withGrokAnalyze": false,
|
||||
"withDisallowedReplyControls": false
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
||||
userTweetsVariables* = """{
|
||||
"rest_id": "$1",
|
||||
@ -151,4 +157,4 @@ const
|
||||
$2
|
||||
"count": 20,
|
||||
"includePromotedContent": false
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
}""".replace(" ", "").replace("\n", "")
|
||||
|
@ -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, "")
|
||||
|
@ -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, ""
|
||||
|
@ -208,6 +208,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||
id: js{"id_str"}.getId,
|
||||
threadId: js{"conversation_id_str"}.getId,
|
||||
replyId: js{"in_reply_to_status_id_str"}.getId,
|
||||
replyHandle: js{"in_reply_to_screen_name"}.getStr,
|
||||
text: js{"full_text"}.getStr,
|
||||
time: js{"created_at"}.getTime,
|
||||
hasThread: js{"self_thread"}.notNull,
|
||||
@ -504,39 +505,41 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
||||
if instructions.len == 0:
|
||||
return
|
||||
|
||||
for e in instructions[0]{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult, true)
|
||||
for i in instructions:
|
||||
if i{"type"}.getStr == "TimelineAddEntries":
|
||||
for e in i{"entries"}:
|
||||
let entryId = e{"entryId"}.getStr
|
||||
if entryId.startsWith("tweet"):
|
||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||
let tweet = parseGraphTweet(tweetResult, true)
|
||||
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
if not tweet.available:
|
||||
tweet.id = parseBiggestInt(entryId.getId())
|
||||
|
||||
if $tweet.id == tweetId:
|
||||
result.tweet = tweet
|
||||
else:
|
||||
result.before.content.add tweet
|
||||
elif entryId.startsWith("tombstone"):
|
||||
let id = entryId.getId()
|
||||
let tweet = Tweet(
|
||||
id: parseBiggestInt(id),
|
||||
available: false,
|
||||
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
|
||||
)
|
||||
if $tweet.id == tweetId:
|
||||
result.tweet = tweet
|
||||
else:
|
||||
result.before.content.add tweet
|
||||
elif entryId.startsWith("tombstone"):
|
||||
let id = entryId.getId()
|
||||
let tweet = Tweet(
|
||||
id: parseBiggestInt(id),
|
||||
available: false,
|
||||
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
|
||||
)
|
||||
|
||||
if id == tweetId:
|
||||
result.tweet = tweet
|
||||
else:
|
||||
result.before.content.add tweet
|
||||
elif entryId.startsWith("conversationthread"):
|
||||
let (thread, self) = parseGraphThread(e)
|
||||
if self:
|
||||
result.after = thread
|
||||
else:
|
||||
result.replies.content.add thread
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.replies.bottom = e{"content", "itemContent", "value"}.getStr
|
||||
if id == tweetId:
|
||||
result.tweet = tweet
|
||||
else:
|
||||
result.before.content.add tweet
|
||||
elif entryId.startsWith("conversationthread") or entryId.startswith("reply-mixer-conversation"):
|
||||
let (thread, self) = parseGraphThread(e)
|
||||
if self:
|
||||
result.after = thread
|
||||
else:
|
||||
result.replies.content.add thread
|
||||
elif entryId.startsWith("cursor-bottom"):
|
||||
result.replies.bottom = e{"content", "value"}.getStr
|
||||
|
||||
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
||||
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
||||
|
242
src/routes/activityspoof.nim
Executable file
242
src/routes/activityspoof.nim
Executable file
@ -0,0 +1,242 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times
|
||||
|
||||
import jester
|
||||
|
||||
import router_utils
|
||||
import ".."/[types, formatters, api]
|
||||
import ../views/[mastoapi]
|
||||
|
||||
export json, uri, sequtils, options, sugar, times
|
||||
export router_utils
|
||||
export api, formatters
|
||||
export mastoapi
|
||||
|
||||
proc createActivityPubRouter*(cfg: Config) =
|
||||
router activityspoof:
|
||||
get "/api/v1/accounts/?":
|
||||
resp Http200, {"Content-Type": "application/json"}, """[]"""
|
||||
|
||||
get "/api/v1/statuses/@id":
|
||||
let id = @"id"
|
||||
|
||||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
|
||||
|
||||
let prefs = cookiePrefs()
|
||||
|
||||
let conv = await getTweet(id)
|
||||
if conv == nil:
|
||||
echo "nil conv"
|
||||
|
||||
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
||||
var error = "Record not found"
|
||||
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
|
||||
error = conv.tweet.tombstone
|
||||
|
||||
var errJson = newJObject()
|
||||
errJson["error"] = %error
|
||||
|
||||
resp Http404, {"Content-Type": "application/json"}, $errJson
|
||||
|
||||
let
|
||||
tweet = conv.tweet
|
||||
tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{id}"
|
||||
var media: seq[JsonNode] = @[]
|
||||
|
||||
if tweet.photos.len > 0:
|
||||
for url in tweet.photos:
|
||||
let image = getUrlPrefix(cfg) & getPicUrl(url)
|
||||
var mediaObj = newJObject()
|
||||
|
||||
mediaObj["id"] = %"150745989836308480" # idk if discord even parses this snowflake, but its my user id why not
|
||||
mediaObj["type"] = %"image"
|
||||
mediaObj["url"] = %image
|
||||
mediaObj["preview_url"] = %image
|
||||
mediaObj["remote_url"] = newJNull()
|
||||
mediaObj["preview_remote_url"] = newJNull()
|
||||
mediaObj["text_url"] = newJNull()
|
||||
mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y)
|
||||
# FIXME but this probably isnt used by discord
|
||||
mediaObj["meta"] = newJObject()
|
||||
|
||||
media.add(mediaObj)
|
||||
|
||||
if tweet.video.isSome():
|
||||
let
|
||||
videoObj = get(tweet.video)
|
||||
vars = videoObj.variants.filterIt(it.contentType == mp4)
|
||||
var mediaObj = newJObject()
|
||||
|
||||
mediaObj["id"] = %"150745989836308480"
|
||||
mediaObj["type"] = %"video"
|
||||
mediaObj["url"] = %vars[^1].url
|
||||
mediaObj["preview_url"] = %(getUrlPrefix(cfg) & getPicUrl(videoObj.thumb))
|
||||
mediaObj["remote_url"] = newJNull()
|
||||
mediaObj["preview_remote_url"] = newJNull()
|
||||
mediaObj["text_url"] = newJNull()
|
||||
mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y)
|
||||
# FIXME but this probably isnt used by discord
|
||||
mediaObj["meta"] = newJObject()
|
||||
|
||||
media.add(mediaObj)
|
||||
elif tweet.gif.isSome():
|
||||
let gif = get(tweet.gif)
|
||||
var mediaObj = newJObject()
|
||||
|
||||
mediaObj["id"] = %"150745989836308480"
|
||||
mediaObj["type"] = %"video"
|
||||
mediaObj["url"] = %(&"https://{gif.url}")
|
||||
mediaObj["preview_url"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb))
|
||||
mediaObj["remote_url"] = newJNull()
|
||||
mediaObj["preview_remote_url"] = newJNull()
|
||||
mediaObj["text_url"] = newJNull()
|
||||
mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y)
|
||||
# FIXME but this probably isnt used by discord
|
||||
mediaObj["meta"] = newJObject()
|
||||
|
||||
media.add(mediaObj)
|
||||
|
||||
var postJson = newJObject()
|
||||
postJson["id"] = %(&"{tweet.id}")
|
||||
postJson["url"] = %tweetUrl
|
||||
postJson["uri"] = %tweetUrl
|
||||
postJson["created_at"] = %($tweet.time)
|
||||
postJson["edited_at"] = newJNull()
|
||||
postJson["reblog"] = newJNull()
|
||||
if tweet.replyId != 0:
|
||||
postJson["in_reply_to_id"] = %(&"{tweet.replyId}")
|
||||
postJson["in_reply_to_account_id"] = %""
|
||||
else:
|
||||
postJson["in_reply_to_id"] = newJNull()
|
||||
postJson["in_reply_to_account_id"] = newJNull()
|
||||
postJson["language"] = %"en" # FIXME
|
||||
postJson["content"] = %formatTweetForMastoAPI(tweet, cfg, prefs)
|
||||
postJson["spoiler_text"] = %""
|
||||
postJson["visibility"] = %"public"
|
||||
postJson["application"] = %*{
|
||||
"name": "Nitter",
|
||||
"website": getUrlPrefix(cfg)
|
||||
}
|
||||
postJson["media_attachments"] = %media
|
||||
postJson["account"] = %*{
|
||||
"id": &"{tweet.user.id}",
|
||||
"display_name": tweet.user.fullname,
|
||||
"username": tweet.user.username,
|
||||
"acct": tweet.user.username,
|
||||
"url": &"{getUrlPrefix(cfg)}/{tweet.user.username}",
|
||||
"uri": &"{getUrlPrefix(cfg)}/{tweet.user.username}",
|
||||
"created_at": $tweet.user.joinDate,
|
||||
"locked": tweet.user.protected,
|
||||
"bot": false, # TODO?
|
||||
"discoverable": true,
|
||||
"indexable": false,
|
||||
"group": false,
|
||||
"avatar": getUrlPrefix(cfg) & getPicUrl(tweet.user.userPic),
|
||||
"avatar_static": getUrlPrefix(cfg) & getPicUrl(tweet.user.userPic),
|
||||
"header": getUrlPrefix(cfg) & getPicUrl(tweet.user.banner),
|
||||
"header_static": getUrlPrefix(cfg) & getPicUrl(tweet.user.banner),
|
||||
"followers_count": tweet.user.followers,
|
||||
"following_count": tweet.user.following,
|
||||
"statuses_count": tweet.user.tweets,
|
||||
"hide_collections": false,
|
||||
"noindex": false,
|
||||
"emojis": @[],
|
||||
"roles": @[],
|
||||
"fields": @[],
|
||||
}
|
||||
postJson["mentions"] = newJArray() # TODO: parse?
|
||||
postJson["tags"] = newJArray() # TODO: parse?
|
||||
postJson["emojis"] = newJArray()
|
||||
postJson["card"] = newJNull()
|
||||
postJson["poll"] = newJNull() # TODO: parse?
|
||||
|
||||
resp Http200, {"Content-Type": "application/json"}, $postJson
|
||||
|
||||
get "/users/@name/statuses/@id":
|
||||
let id = @"id"
|
||||
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
|
||||
if id.len > 19 or id.any(c => not c.isDigit):
|
||||
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
|
||||
|
||||
let prefs = cookiePrefs()
|
||||
|
||||
let conv = await getTweet(id)
|
||||
if conv == nil:
|
||||
echo "nil conv"
|
||||
|
||||
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
||||
var error = "Record not found"
|
||||
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
|
||||
error = conv.tweet.tombstone
|
||||
|
||||
var errJson = newJObject()
|
||||
errJson["error"] = %error
|
||||
|
||||
resp Http404, {"Content-Type": "application/json"}, $errJson
|
||||
|
||||
let postJson = getActivityStream(conv.tweet, cfg, prefs)
|
||||
|
||||
resp Http200, {"Content-Type": "application/json"}, $postJson
|
||||
|
||||
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||
|
||||
get "/users/@name":
|
||||
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
|
||||
let user = await getGraphUser(@"name")
|
||||
if user.suspended or user.id.len == 0:
|
||||
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
|
||||
|
||||
let prefs = cookiePrefs()
|
||||
|
||||
let userJson = getActivityStream(user, cfg, prefs)
|
||||
|
||||
resp Http200, {"Content-Type": "application/json"}, $userJson
|
||||
|
||||
redirect("/" & @"name")
|
||||
|
||||
# might as well
|
||||
get "/.well-known/nodeinfo":
|
||||
var nodeinfo = newJObject()
|
||||
let link: JsonNode = %*{
|
||||
"href": &"{getUrlPrefix(cfg)}/nodeinfo/2.1.json",
|
||||
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.1"
|
||||
}
|
||||
var links: seq[JsonNode] = @[]
|
||||
links.add(link)
|
||||
|
||||
nodeinfo["links"] = %links
|
||||
|
||||
resp Http200, {"Content-Type": "application/json"}, $nodeinfo
|
||||
|
||||
get "/nodeinfo/2.1.json":
|
||||
var nodeinfo = newJObject()
|
||||
nodeinfo["version"] = %"2.1"
|
||||
nodeinfo["software"] = %*{
|
||||
"name": "Nitter",
|
||||
"repository": "https://gitlab.com/Cynosphere/nitter"
|
||||
}
|
||||
|
||||
var metadata = newJObject()
|
||||
metadata["features"] = newJArray()
|
||||
metadata["federation"] = newJObject()
|
||||
metadata["nodeDescription"] = %"Alternative Twitter front-end (ActivityPub support added for Discord)"
|
||||
metadata["nodeName"] = %"Nitter"
|
||||
metadata["private"] = %true
|
||||
metadata["maintainer"] = %*{
|
||||
"name": "Cynthia",
|
||||
"email": "gamers@riseup.net"
|
||||
}
|
||||
|
||||
nodeinfo["metadata"] = metadata
|
||||
nodeinfo["openRegistrations"] = %false
|
||||
nodeinfo["protocols"] = newJArray()
|
||||
|
||||
var services = newJObject()
|
||||
services["inbound"] = newJArray()
|
||||
services["outbound"] = newJArray()
|
||||
|
||||
nodeinfo["services"] = services
|
||||
nodeinfo["usage"] = newJObject()
|
||||
|
||||
resp Http200, {"Content-Type": "application/json"}, $nodeinfo
|
@ -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)
|
||||
|
@ -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))
|
||||
|
@ -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"]
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -211,6 +211,7 @@ type
|
||||
text*: string
|
||||
time*: DateTime
|
||||
reply*: seq[string]
|
||||
replyHandle*: string
|
||||
pinned*: bool
|
||||
hasThread*: bool
|
||||
available*: bool
|
||||
|
@ -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
|
||||
|
@ -3,7 +3,7 @@ import uri, strutils, strformat, times, options
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import renderutils
|
||||
import ../utils, ../types, ../prefs, ../formatters
|
||||
import ".."/[utils, types, prefs, formatters]
|
||||
|
||||
import jester
|
||||
|
||||
@ -29,8 +29,8 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
||||
|
||||
tdiv(class="nav-item right"):
|
||||
icon "search", title="Search", href="/search"
|
||||
if cfg.enableRss and rss.len > 0:
|
||||
icon "rss-feed", title="RSS Feed", href=rss
|
||||
#if cfg.enableRss and rss.len > 0:
|
||||
#icon "rss-feed", title="RSS Feed", href=rss
|
||||
icon "bird", title="Open in Twitter", href=canonical
|
||||
a(href="https://liberapay.com/zedeus"): verbatim lp
|
||||
icon "info", title="About", href="/about"
|
||||
@ -38,15 +38,15 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
||||
|
||||
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
||||
rss=""; canonical=""; avatar="";
|
||||
time: Option[DateTime] = none(DateTime)): VNode =
|
||||
rss=""; canonical=""; avatar=""; context=""; contextUrl="";
|
||||
id=""; time: Option[DateTime] = none(DateTime)): VNode =
|
||||
var theme = prefs.theme.toTheme
|
||||
if "theme" in req.params:
|
||||
theme = req.params["theme"].toTheme
|
||||
|
||||
let ogType =
|
||||
if video.len > 0: "video.other"
|
||||
elif rss.len > 0: "object"
|
||||
#elif rss.len > 0: "object"
|
||||
elif images.len > 0: "photo"
|
||||
else: "article"
|
||||
|
||||
@ -62,9 +62,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
if theme.len > 0:
|
||||
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
||||
|
||||
link(rel="icon", type="image/png", sizes="32x32", href=(&"{getUrlPrefix(cfg)}/favicon-32x32.png"))
|
||||
link(rel="icon", type="image/png", sizes="16x16", href=(&"{getUrlPrefix(cfg)}/favicon-16x16.png"))
|
||||
link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png")
|
||||
link(rel="icon", type="image/png", sizes="32x32", href="/favicon-32x32.png")
|
||||
link(rel="icon", type="image/png", sizes="16x16", href="/favicon-16x16.png")
|
||||
link(rel="manifest", href="/site.webmanifest")
|
||||
link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60")
|
||||
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
||||
@ -73,8 +73,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
if canonical.len > 0:
|
||||
link(rel="canonical", href=canonical)
|
||||
|
||||
if cfg.enableRss and rss.len > 0:
|
||||
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
||||
#if cfg.enableRss and rss.len > 0:
|
||||
#link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
||||
|
||||
if prefs.hlsPlayback:
|
||||
script(src="/js/hls.light.min.js", `defer`="")
|
||||
@ -107,7 +107,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
|
||||
var siteName = cfg.title
|
||||
|
||||
if time.isSome:
|
||||
let isDiscord = req.headers.getOrDefault("User-Agent").toString().contains("Discordbot")
|
||||
|
||||
if time.isSome and not isDiscord:
|
||||
let timeObj = time.get
|
||||
let timeStr = $timeObj
|
||||
meta(property="og:article:published_time", content=timeStr)
|
||||
@ -131,10 +133,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
meta(property="og:image", content=image)
|
||||
if video.len == 0:
|
||||
meta(property="twitter:image:src", content=image)
|
||||
if rss.len > 0:
|
||||
meta(property="twitter:card", content="summary")
|
||||
elif video.len == 0:
|
||||
meta(property="twitter:card", content="summary_large_image")
|
||||
else:
|
||||
meta(property="twitter:card", content="summary")
|
||||
elif avatar.len > 0:
|
||||
let avatarUrl = getUrlPrefix(cfg) & getPicUrl(avatar)
|
||||
meta(property="og:image", content=avatarUrl)
|
||||
@ -143,16 +144,30 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
meta(property="og:video:url", content=video)
|
||||
meta(property="og:video:secure_url", content=video)
|
||||
meta(property="og:video:type", content="video/mp4")
|
||||
var title = encodeUrl(finalizedDesc)
|
||||
var author = encodeUrl(finalizedTitleText)
|
||||
|
||||
var
|
||||
title = encodeUrl(finalizedDesc)
|
||||
author = encodeUrl(finalizedTitleText)
|
||||
url = req.path
|
||||
|
||||
if len(finalizedDesc) > 67:
|
||||
title = author
|
||||
author = encodeUrl(finalizedDesc)
|
||||
|
||||
verbatim &"<link rel=\"alternate\" href=\"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(req.path)}\" type=\"application/json+oembed\" />"
|
||||
#link(rel="alternate",
|
||||
# href=&"{getUrlPrefix(cfg)}/oembed.json?type=video&title={encodeUrl(stripHtml(desc))}&user={encodeUrl(finalizedTitleText)}&url={encodeUrl(req.path)}",
|
||||
# `type`="application/json+oembed")
|
||||
if context != "":
|
||||
author = encodeUrl(context & "\n") & author
|
||||
|
||||
if contextUrl != "":
|
||||
url = contextUrl
|
||||
|
||||
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(url)}"), type="application/json+oembed")
|
||||
elif context != "" and contextUrl != "":
|
||||
var
|
||||
title = encodeUrl(finalizedTitleText)
|
||||
author = encodeUrl(context)
|
||||
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(contextUrl)}"), type="application/json+oembed")
|
||||
|
||||
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/users/i/statuses/{id}"), type="application/activity+json")
|
||||
|
||||
# this is last so images are also preloaded
|
||||
# if this is done earlier, Chrome only preloads one image for some reason
|
||||
@ -161,14 +176,15 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
|
||||
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
||||
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
||||
images: seq[string] = @[]; banner=""; avatar="";
|
||||
time: Option[DateTime] = none(DateTime)): string =
|
||||
images: seq[string] = @[]; banner=""; avatar=""; context="";
|
||||
contextUrl=""; id=""; time: Option[DateTime] = none(DateTime)
|
||||
): string =
|
||||
|
||||
let canonical = getTwitterLink(req.path, req.params)
|
||||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||
rss, canonical, avatar, time)
|
||||
rss, canonical, avatar, context, contextUrl, id, time)
|
||||
|
||||
body:
|
||||
renderNavbar(cfg, req, rss, canonical)
|
||||
|
178
src/views/mastoapi.nim
Executable file
178
src/views/mastoapi.nim
Executable file
@ -0,0 +1,178 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import strutils, strformat, options, json, sequtils, times
|
||||
import ".."/[types, formatters, utils]
|
||||
|
||||
proc formatTweetForMastoAPI*(tweet: Tweet, cfg: Config, prefs: Prefs): string =
|
||||
var content = replaceUrls(tweet.text, prefs)
|
||||
|
||||
if tweet.quote.isSome():
|
||||
let
|
||||
quote = get(tweet.quote)
|
||||
quoteContent = replaceUrls(quote.text, prefs)
|
||||
quoteUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}"
|
||||
content &= &"\n\n<blockquote><b>↘ <a href=\"{quoteUrl}\">{quote.user.fullName} (@{quote.user.username})</a></b>\n{quoteContent}"
|
||||
|
||||
if quote.video.isSome() or quote.gif.isSome():
|
||||
content &= "\n📹"
|
||||
if quote.gif.isSome():
|
||||
content &= " (GIF)"
|
||||
elif quote.photos.len > 0:
|
||||
content &= "\n🖼️"
|
||||
if quote.photos.len > 1:
|
||||
content &= &" ({quote.photos.len})"
|
||||
|
||||
content &= "</blockquote>"
|
||||
|
||||
if tweet.birdwatch.isSome():
|
||||
let
|
||||
note = get(tweet.birdwatch)
|
||||
noteContent = replaceUrls(note.text, prefs)
|
||||
content &= &"\n<blockquote><b>ⓘ {note.title}</b>\n{noteContent}</blockquote>"
|
||||
|
||||
result = content.replace("\n", "<br>")
|
||||
|
||||
proc getActivityStream*(tweet: Tweet, cfg: Config, prefs: Prefs): JsonNode =
|
||||
let
|
||||
tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.id}"
|
||||
tweetContent = formatTweetForMastoAPI(tweet, cfg, prefs)
|
||||
var media: seq[JsonNode] = @[]
|
||||
|
||||
if tweet.photos.len > 0:
|
||||
for url in tweet.photos:
|
||||
let image = getUrlPrefix(cfg) & getPicUrl(url)
|
||||
var mediaObj = newJObject()
|
||||
|
||||
mediaObj["type"] = %"Document"
|
||||
mediaObj["mediaType"] = %"image/png"
|
||||
mediaObj["url"] = %image
|
||||
mediaObj["name"] = newJNull() # FIXME a11y
|
||||
|
||||
media.add(mediaObj)
|
||||
|
||||
if tweet.video.isSome():
|
||||
let
|
||||
videoObj = get(tweet.video)
|
||||
vars = videoObj.variants.filterIt(it.contentType == mp4)
|
||||
var mediaObj = newJObject()
|
||||
|
||||
mediaObj["type"] = %"Document"
|
||||
mediaObj["mediaType"] = %"video/mp4"
|
||||
mediaObj["url"] = %vars[^1].url
|
||||
mediaObj["name"] = newJNull() # FIXME a11y
|
||||
|
||||
media.add(mediaObj)
|
||||
elif tweet.gif.isSome():
|
||||
let gif = get(tweet.gif)
|
||||
var mediaObj = newJObject()
|
||||
|
||||
mediaObj["type"] = %"Document"
|
||||
mediaObj["mediaType"] = %"video/mp4"
|
||||
mediaObj["url"] = %(&"https://{gif.url}")
|
||||
mediaObj["name"] = newJNull() # FIXME a11y
|
||||
|
||||
media.add(mediaObj)
|
||||
|
||||
var context: seq[JsonNode] = @[]
|
||||
let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams"
|
||||
context.add(contextUrl)
|
||||
let asProps: JsonNode = %*{
|
||||
"ostatus": "http://ostatus.org#",
|
||||
"atomUri": "ostatus:atomUri",
|
||||
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
|
||||
"conversation": "ostatus:conversation",
|
||||
"sensitive": "as:sensitive",
|
||||
}
|
||||
context.add(asProps)
|
||||
|
||||
var postJson = newJObject()
|
||||
postJson["@context"] = %context
|
||||
postJson["id"] = %tweetUrl
|
||||
postJson["type"] = %"Note"
|
||||
postJson["summary"] = newJNull()
|
||||
if tweet.replyId != 0:
|
||||
let replyUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
|
||||
postJson["inReplyTo"] = %replyUrl
|
||||
postJson["inReplyToAtomUri"] = %replyUrl
|
||||
else:
|
||||
postJson["inReplyTo"] = newJNull()
|
||||
postJson["inReplyToAtomUri"] = newJNull()
|
||||
postJson["published"] = %($tweet.time)
|
||||
postJson["url"] = %tweetUrl
|
||||
postJson["attributedTo"] = %(&"{getUrlPrefix(cfg)}/users/{tweet.user.username}")
|
||||
postJson["to"] = newJArray()
|
||||
postJson["cc"] = %(@["https://www.w3.org/ns/activitystreams#Public"])
|
||||
postJson["sensitive"] = %false # FIXME
|
||||
postJson["atomUri"] = %tweetUrl
|
||||
postJson["conversation"] = %""
|
||||
postJson["content"] = %tweetContent
|
||||
postJson["contentMap"] = %*{
|
||||
"en": tweetContent
|
||||
}
|
||||
postJson["attachment"] = %media
|
||||
postJson["tag"] = newJArray() # TODO: parse?
|
||||
postJson["replies"] = newJObject()
|
||||
|
||||
result = postJson
|
||||
|
||||
proc getActivityStream*(user: User, cfg: Config, prefs: Prefs): JsonNode =
|
||||
let userUrl = &"{getUrlPrefix(cfg)}/{user.username}"
|
||||
|
||||
var context: seq[JsonNode] = @[]
|
||||
let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams"
|
||||
context.add(contextUrl)
|
||||
let contextUrl2: JsonNode = %"https://w3id.org/security/v1"
|
||||
context.add(contextUrl2)
|
||||
|
||||
let contextAka: JsonNode = %*{
|
||||
"@id": "as:alsoKnownAs",
|
||||
"@type": "@id"
|
||||
}
|
||||
let contextMovedTo = %*{
|
||||
"@id": "as:movedTo",
|
||||
"@type": "@id"
|
||||
}
|
||||
var asProps: JsonNode = %*{
|
||||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
||||
"schema": "http://schema.org#",
|
||||
"PropertyValue": "schema:PropertyValue",
|
||||
"value": "schema:value",
|
||||
}
|
||||
asProps["alsoKnownAs"] = contextAka
|
||||
asProps["movedTo"] = contextMovedTo
|
||||
context.add(asProps)
|
||||
|
||||
var userJson = newJObject()
|
||||
userJson["@context"] = %context
|
||||
userJson["id"] = %userUrl
|
||||
userJson["type"] = %"Person"
|
||||
userJson["following"] = %(userUrl & "/following")
|
||||
userJson["followers"] = %(userUrl & "/followers")
|
||||
userJson["inbox"] = newJNull()
|
||||
userJson["outbox"] = newJNull()
|
||||
userJson["featured"] = newJNull()
|
||||
userJson["featuredTags"] = newJNull()
|
||||
userJson["preferredUsername"] = %user.username
|
||||
userJson["name"] = %user.fullname
|
||||
userJson["summary"] = %user.bio
|
||||
userJson["url"] = %userUrl
|
||||
userJson["manuallyApprovesFollowers"] = %user.protected
|
||||
userJson["discoverable"] = %true
|
||||
userJson["indexable"] = %false
|
||||
userJson["published"] = %($user.joinDate)
|
||||
userJson["memorial"] = %false
|
||||
userJson["publicKey"] = newJNull()
|
||||
userJson["tag"] = newJArray()
|
||||
userJson["attachment"] = newJArray()
|
||||
userJson["endpoints"] = newJObject()
|
||||
userJson["icon"] = %*{
|
||||
"type": "Image",
|
||||
"mediaType": "image/jpeg",
|
||||
"url": getUrlPrefix(cfg) & getPicUrl(user.userPic)
|
||||
}
|
||||
userJson["image"] = %*{
|
||||
"type": "Image",
|
||||
"mediaType": "image/jpeg",
|
||||
"url": getUrlPrefix(cfg) & getPicUrl(user.banner)
|
||||
}
|
||||
|
||||
result = userJson
|
@ -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 =
|
||||
|
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user