mastoapi/activitypub spoof so i can have peak discord embeds :)
This commit is contained in:
parent
c7d6b4291c
commit
b3e35dba12
9 changed files with 522 additions and 30 deletions
|
@ -31,8 +31,7 @@ let
|
||||||
illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]"
|
illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]"
|
||||||
|
|
||||||
proc getUrlPrefix*(cfg: Config): string =
|
proc getUrlPrefix*(cfg: Config): string =
|
||||||
if cfg.useHttps: https & cfg.hostname
|
"https://" & cfg.hostname
|
||||||
else: "http://" & cfg.hostname
|
|
||||||
|
|
||||||
proc shortLink*(text: string; length=28): string =
|
proc shortLink*(text: string; length=28): string =
|
||||||
result = text.replace(wwwRegex, "")
|
result = text.replace(wwwRegex, "")
|
||||||
|
|
|
@ -10,7 +10,8 @@ import types, config, prefs, formatters, redis_cache, http_pool
|
||||||
import views/[general, about]
|
import views/[general, about]
|
||||||
import routes/[
|
import routes/[
|
||||||
preferences, timeline, status, media, search, list, #rss, debug,
|
preferences, timeline, status, media, search, list, #rss, debug,
|
||||||
unsupported, embed, resolver, router_utils, home, follow, twitter_api]
|
unsupported, embed, resolver, router_utils, home, follow, twitter_api,
|
||||||
|
activityspoof]
|
||||||
|
|
||||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||||
|
@ -51,6 +52,7 @@ createEmbedRouter(cfg)
|
||||||
#createRssRouter(cfg)
|
#createRssRouter(cfg)
|
||||||
#createDebugRouter(cfg)
|
#createDebugRouter(cfg)
|
||||||
createTwitterApiRouter(cfg)
|
createTwitterApiRouter(cfg)
|
||||||
|
createActivityPubRouter(cfg)
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
port = Port(cfg.port)
|
port = Port(cfg.port)
|
||||||
|
@ -103,5 +105,6 @@ routes:
|
||||||
extend resolver, ""
|
extend resolver, ""
|
||||||
extend embed, ""
|
extend embed, ""
|
||||||
#extend debug, ""
|
#extend debug, ""
|
||||||
|
extend activityspoof, ""
|
||||||
extend api, ""
|
extend api, ""
|
||||||
extend unsupported, ""
|
extend unsupported, ""
|
||||||
|
|
242
src/routes/activityspoof.nim
Normal file
242
src/routes/activityspoof.nim
Normal 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"] = %gif.url
|
||||||
|
mediaObj["preview_url"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb))
|
||||||
|
mediaObj["remote_url"] = newJNull()
|
||||||
|
mediaObj["preview_remote_url"] = newJNull()
|
||||||
|
mediaObj["text_url"] = newJNull()
|
||||||
|
mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y)
|
||||||
|
# FIXME but this probably isnt used by discord
|
||||||
|
mediaObj["meta"] = newJObject()
|
||||||
|
|
||||||
|
media.add(mediaObj)
|
||||||
|
|
||||||
|
var postJson = newJObject()
|
||||||
|
postJson["id"] = %(&"{tweet.id}")
|
||||||
|
postJson["url"] = %tweetUrl
|
||||||
|
postJson["uri"] = %tweetUrl
|
||||||
|
postJson["created_at"] = %($tweet.time)
|
||||||
|
postJson["edited_at"] = newJNull()
|
||||||
|
postJson["reblog"] = newJNull()
|
||||||
|
if tweet.replyId != 0:
|
||||||
|
postJson["in_reply_to_id"] = %(&"{tweet.replyId}")
|
||||||
|
postJson["in_reply_to_account_id"] = %""
|
||||||
|
else:
|
||||||
|
postJson["in_reply_to_id"] = newJNull()
|
||||||
|
postJson["in_reply_to_account_id"] = newJNull()
|
||||||
|
postJson["language"] = %"en" # FIXME
|
||||||
|
postJson["content"] = %formatTweetForMastoAPI(tweet, cfg, prefs)
|
||||||
|
postJson["spoiler_text"] = %""
|
||||||
|
postJson["visibility"] = %"public"
|
||||||
|
postJson["application"] = %*{
|
||||||
|
"name": "Nitter",
|
||||||
|
"website": getUrlPrefix(cfg)
|
||||||
|
}
|
||||||
|
postJson["media_attachments"] = %media
|
||||||
|
postJson["account"] = %*{
|
||||||
|
"id": &"{tweet.user.id}",
|
||||||
|
"display_name": tweet.user.fullname,
|
||||||
|
"username": tweet.user.username,
|
||||||
|
"acct": tweet.user.username,
|
||||||
|
"url": &"{getUrlPrefix(cfg)}/{tweet.user.username}",
|
||||||
|
"uri": &"{getUrlPrefix(cfg)}/{tweet.user.username}",
|
||||||
|
"created_at": $tweet.user.joinDate,
|
||||||
|
"locked": tweet.user.protected,
|
||||||
|
"bot": false, # TODO?
|
||||||
|
"discoverable": true,
|
||||||
|
"indexable": false,
|
||||||
|
"group": false,
|
||||||
|
"avatar": getUrlPrefix(cfg) & getPicUrl(tweet.user.userPic),
|
||||||
|
"avatar_static": getUrlPrefix(cfg) & getPicUrl(tweet.user.userPic),
|
||||||
|
"header": getUrlPrefix(cfg) & getPicUrl(tweet.user.banner),
|
||||||
|
"header_static": getUrlPrefix(cfg) & getPicUrl(tweet.user.banner),
|
||||||
|
"followers_count": tweet.user.followers,
|
||||||
|
"following_count": tweet.user.following,
|
||||||
|
"statuses_count": tweet.user.tweets,
|
||||||
|
"hide_collections": false,
|
||||||
|
"noindex": false,
|
||||||
|
"emojis": @[],
|
||||||
|
"roles": @[],
|
||||||
|
"fields": @[],
|
||||||
|
}
|
||||||
|
postJson["mentions"] = newJArray() # TODO: parse?
|
||||||
|
postJson["tags"] = newJArray() # TODO: parse?
|
||||||
|
postJson["emojis"] = newJArray()
|
||||||
|
postJson["card"] = newJNull()
|
||||||
|
postJson["poll"] = newJNull() # TODO: parse?
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $postJson
|
||||||
|
|
||||||
|
get "/users/@name/statuses/@id":
|
||||||
|
let id = @"id"
|
||||||
|
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
|
||||||
|
if id.len > 19 or id.any(c => not c.isDigit):
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
|
||||||
|
|
||||||
|
let prefs = cookiePrefs()
|
||||||
|
|
||||||
|
let conv = await getTweet(id)
|
||||||
|
if conv == nil:
|
||||||
|
echo "nil conv"
|
||||||
|
|
||||||
|
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
||||||
|
var error = "Record not found"
|
||||||
|
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
|
||||||
|
error = conv.tweet.tombstone
|
||||||
|
|
||||||
|
var errJson = newJObject()
|
||||||
|
errJson["error"] = %error
|
||||||
|
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, $errJson
|
||||||
|
|
||||||
|
let postJson = getActivityStream(conv.tweet, cfg, prefs)
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $postJson
|
||||||
|
|
||||||
|
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||||
|
|
||||||
|
get "/users/@name":
|
||||||
|
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
|
||||||
|
let user = await getGraphUser(@"name")
|
||||||
|
if user.suspended or user.id.len == 0:
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
|
||||||
|
|
||||||
|
let prefs = cookiePrefs()
|
||||||
|
|
||||||
|
let userJson = getActivityStream(user, cfg, prefs)
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $userJson
|
||||||
|
|
||||||
|
redirect("/" & @"name")
|
||||||
|
|
||||||
|
# might as well
|
||||||
|
get "/.well-known/nodeinfo":
|
||||||
|
var nodeinfo = newJObject()
|
||||||
|
let link: JsonNode = %*{
|
||||||
|
"href": &"{getUrlPrefix(cfg)}/nodeinfo/2.1.json",
|
||||||
|
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.1"
|
||||||
|
}
|
||||||
|
var links: seq[JsonNode] = @[]
|
||||||
|
links.add(link)
|
||||||
|
|
||||||
|
nodeinfo["links"] = %links
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $nodeinfo
|
||||||
|
|
||||||
|
get "/nodeinfo/2.1.json":
|
||||||
|
var nodeinfo = newJObject()
|
||||||
|
nodeinfo["version"] = %"2.1"
|
||||||
|
nodeinfo["software"] = %*{
|
||||||
|
"name": "Nitter",
|
||||||
|
"repository": "https://gitdab.com/Cynosphere/nitter"
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata = newJObject()
|
||||||
|
metadata["features"] = newJArray()
|
||||||
|
metadata["federation"] = newJObject()
|
||||||
|
metadata["nodeDescription"] = %"Alternative Twitter front-end (ActivityPub support added for Discord)"
|
||||||
|
metadata["nodeName"] = %"Nitter"
|
||||||
|
metadata["private"] = %true
|
||||||
|
metadata["maintainer"] = %*{
|
||||||
|
"name": "Cynthia",
|
||||||
|
"email": "gamers@riseup.net"
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeinfo["metadata"] = metadata
|
||||||
|
nodeinfo["openRegistrations"] = %false
|
||||||
|
nodeinfo["protocols"] = newJArray()
|
||||||
|
|
||||||
|
var services = newJObject()
|
||||||
|
services["inbound"] = newJArray()
|
||||||
|
services["outbound"] = newJArray()
|
||||||
|
|
||||||
|
nodeinfo["services"] = services
|
||||||
|
nodeinfo["usage"] = newJObject()
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $nodeinfo
|
|
@ -1,16 +1,16 @@
|
||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import asyncdispatch, strutils, sequtils, uri, options, sugar, strformat
|
import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times
|
||||||
|
|
||||||
import jester, karax/vdom
|
import jester, karax/vdom
|
||||||
|
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[types, formatters, api]
|
import ".."/[types, formatters, api]
|
||||||
import ../views/[general, status, search]
|
import ../views/[general, status, search, mastoapi]
|
||||||
|
|
||||||
export uri, sequtils, options, sugar
|
export json, uri, sequtils, options, sugar, times
|
||||||
export router_utils
|
export router_utils
|
||||||
export api, formatters
|
export api, formatters
|
||||||
export status
|
export status, mastoapi
|
||||||
|
|
||||||
proc createStatusRouter*(cfg: Config) =
|
proc createStatusRouter*(cfg: Config) =
|
||||||
router status:
|
router status:
|
||||||
|
@ -41,6 +41,30 @@ proc createStatusRouter*(cfg: Config) =
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
let id = @"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
|
||||||
|
|
||||||
if id.len > 19 or id.any(c => not c.isDigit):
|
if id.len > 19 or id.any(c => not c.isDigit):
|
||||||
resp Http404, showError("Invalid tweet ID", cfg)
|
resp Http404, showError("Invalid tweet ID", cfg)
|
||||||
|
|
||||||
|
@ -78,8 +102,8 @@ proc createStatusRouter*(cfg: Config) =
|
||||||
contextUrl = ""
|
contextUrl = ""
|
||||||
|
|
||||||
if tweet.quote.isSome():
|
if tweet.quote.isSome():
|
||||||
let
|
let
|
||||||
quote = tweet.quote.get()
|
quote = get(tweet.quote)
|
||||||
quoteUser = quote.user
|
quoteUser = quote.user
|
||||||
if tweet.replyId != 0:
|
if tweet.replyId != 0:
|
||||||
context = &"↩ Replying to: @{tweet.replyHandle}\n↘ Quoting: {quoteUser.fullname} (@{quoteUser.username})"
|
context = &"↩ Replying to: @{tweet.replyHandle}\n↘ Quoting: {quoteUser.fullname} (@{quoteUser.username})"
|
||||||
|
@ -113,7 +137,37 @@ proc createStatusRouter*(cfg: Config) =
|
||||||
let html = renderConversation(conv, prefs, getPath() & "#m")
|
let html = renderConversation(conv, prefs, getPath() & "#m")
|
||||||
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
|
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
|
||||||
images=images, video=video, avatar=avatar, time=time,
|
images=images, video=video, avatar=avatar, time=time,
|
||||||
context=context, contextUrl=contextUrl)
|
context=context, contextUrl=contextUrl, id=id)
|
||||||
|
|
||||||
|
get "/@name/status/@id.mp4":
|
||||||
|
cond '.' notin @"name"
|
||||||
|
let id = @"id"
|
||||||
|
|
||||||
|
if id.len > 19 or id.any(c => not c.isDigit):
|
||||||
|
resp Http404, showError("Invalid tweet ID", cfg)
|
||||||
|
|
||||||
|
let conv = await getTweet(id, getCursor())
|
||||||
|
if conv == nil:
|
||||||
|
echo "nil conv"
|
||||||
|
|
||||||
|
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
||||||
|
var error = "Tweet not found"
|
||||||
|
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
|
||||||
|
error = conv.tweet.tombstone
|
||||||
|
resp Http404, showError(error, cfg)
|
||||||
|
|
||||||
|
let tweet = conv.tweet
|
||||||
|
|
||||||
|
if tweet.video.isSome():
|
||||||
|
let videoObj = get(tweet.video)
|
||||||
|
let vars = videoObj.variants.filterIt(it.contentType == mp4)
|
||||||
|
redirect(vars[^1].url)
|
||||||
|
elif tweet.gif.isSome():
|
||||||
|
let gif = get(tweet.gif)
|
||||||
|
let url = getPicUrl(gif.url)
|
||||||
|
redirect(url)
|
||||||
|
|
||||||
|
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||||
|
|
||||||
get "/@name/@s/@id/@m/?@i?":
|
get "/@name/@s/@id/@m/?@i?":
|
||||||
cond @"s" in ["status", "statuses"]
|
cond @"s" in ["status", "statuses"]
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import asyncdispatch, strutils, sequtils, uri, options, times
|
import asyncdispatch, strutils, sequtils, uri, options, times, json
|
||||||
import jester, karax/vdom
|
import jester, karax/vdom
|
||||||
|
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[types, formatters, query, api]
|
import ".."/[types, formatters, query, api]
|
||||||
import ../views/[general, profile, timeline, status, search]
|
import ../views/[general, profile, timeline, status, search, mastoapi]
|
||||||
|
|
||||||
export vdom
|
export vdom
|
||||||
export uri, sequtils
|
export uri, sequtils, json
|
||||||
export router_utils
|
export router_utils
|
||||||
export formatters, query, api
|
export formatters, query, api
|
||||||
export profile, timeline, status
|
export profile, timeline, status, mastoapi
|
||||||
|
|
||||||
proc getQuery*(request: Request; tab, name: string): Query =
|
proc getQuery*(request: Request; tab, name: string): Query =
|
||||||
case tab
|
case tab
|
||||||
|
@ -137,6 +137,18 @@ proc createTimelineRouter*(cfg: Config) =
|
||||||
of "following":
|
of "following":
|
||||||
resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
|
resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
|
||||||
else:
|
else:
|
||||||
|
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
|
||||||
|
let userId = await getUserId(@"name")
|
||||||
|
|
||||||
|
if userId == "suspended" or userId.len == 0:
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
|
||||||
|
|
||||||
|
let user = await getGraphUser(@"name")
|
||||||
|
|
||||||
|
let userJson = getActivityStream(user, cfg, prefs)
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $userJson
|
||||||
|
|
||||||
var query = request.getQuery(@"tab", @"name")
|
var query = request.getQuery(@"tab", @"name")
|
||||||
if names.len != 1:
|
if names.len != 1:
|
||||||
query.fromUser = names
|
query.fromUser = names
|
||||||
|
|
|
@ -138,7 +138,7 @@ proc createTwitterApiRouter*(cfg: Config) =
|
||||||
let response = await getUserProfileJson(username)
|
let response = await getUserProfileJson(username)
|
||||||
respJson response
|
respJson response
|
||||||
|
|
||||||
# get "/api/user/@id/tweets":
|
#get "/api/user/@id/tweets":
|
||||||
# let id = @"id"
|
# let id = @"id"
|
||||||
# let response = await getUserTweetsJson(id)
|
# let response = await getUserTweetsJson(id)
|
||||||
# respJson response
|
# respJson response
|
||||||
|
|
|
@ -3,7 +3,7 @@ import uri, strutils, strformat, times, options
|
||||||
import karax/[karaxdsl, vdom]
|
import karax/[karaxdsl, vdom]
|
||||||
|
|
||||||
import renderutils
|
import renderutils
|
||||||
import ../utils, ../types, ../prefs, ../formatters
|
import ".."/[utils, types, prefs, formatters]
|
||||||
|
|
||||||
import jester
|
import jester
|
||||||
|
|
||||||
|
@ -39,7 +39,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
||||||
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||||
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
||||||
rss=""; canonical=""; avatar=""; context=""; contextUrl="";
|
rss=""; canonical=""; avatar=""; context=""; contextUrl="";
|
||||||
time: Option[DateTime] = none(DateTime)): VNode =
|
id=""; time: Option[DateTime] = none(DateTime)): VNode =
|
||||||
var theme = prefs.theme.toTheme
|
var theme = prefs.theme.toTheme
|
||||||
if "theme" in req.params:
|
if "theme" in req.params:
|
||||||
theme = req.params["theme"].toTheme
|
theme = req.params["theme"].toTheme
|
||||||
|
@ -63,8 +63,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||||
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
||||||
|
|
||||||
link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png")
|
link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png")
|
||||||
link(rel="icon", type="image/png", sizes="32x32", href="/favicon-32x32.png")
|
link(rel="icon", type="image/png", sizes="32x32", href=(&"{getUrlPrefix(cfg)}/favicon-32x32.png"))
|
||||||
link(rel="icon", type="image/png", sizes="16x16", href="/favicon-16x16.png")
|
link(rel="icon", type="image/png", sizes="16x16", href=(&"{getUrlPrefix(cfg)}/favicon-16x16.png"))
|
||||||
link(rel="manifest", href="/site.webmanifest")
|
link(rel="manifest", href="/site.webmanifest")
|
||||||
link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60")
|
link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60")
|
||||||
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
||||||
|
@ -104,13 +104,15 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||||
|
|
||||||
var siteName = "Nitter"
|
var siteName = "Nitter"
|
||||||
|
|
||||||
if time.isSome:
|
#let isDiscord = req.headers.hasKey("User-Agent") and req.headers["User-Agent"].contains("Discordbot")
|
||||||
let timeObj = time.get
|
|
||||||
let timeStr = $timeObj
|
|
||||||
meta(property="og:article:published_time", content=timeStr)
|
|
||||||
|
|
||||||
let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss")
|
#if time.isSome and not isDiscord:
|
||||||
siteName = &"Nitter • {formattedTime}"
|
# let timeObj = time.get
|
||||||
|
# let timeStr = $timeObj
|
||||||
|
# meta(property="og:article:published_time", content=timeStr)
|
||||||
|
#
|
||||||
|
# let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss")
|
||||||
|
# siteName = &"Nitter • {formattedTime}"
|
||||||
|
|
||||||
meta(property="og:site_name", content=siteName)
|
meta(property="og:site_name", content=siteName)
|
||||||
|
|
||||||
|
@ -155,12 +157,14 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||||
if contextUrl != "":
|
if contextUrl != "":
|
||||||
url = contextUrl
|
url = contextUrl
|
||||||
|
|
||||||
verbatim &"<link rel=\"alternate\" href=\"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(url)}\" type=\"application/json+oembed\" />"
|
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(url)}"), type="application/json+oembed")
|
||||||
elif context != "" and contextUrl != "":
|
elif context != "" and contextUrl != "":
|
||||||
var
|
var
|
||||||
title = encodeUrl(finalizedTitleText)
|
title = encodeUrl(finalizedTitleText)
|
||||||
author = encodeUrl(context)
|
author = encodeUrl(context)
|
||||||
verbatim &"<link rel=\"alternate\" href=\"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(contextUrl)}\" type=\"application/json+oembed\" />"
|
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(contextUrl)}"), type="application/json+oembed")
|
||||||
|
|
||||||
|
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/users/i/statuses/{id}"), type="application/activity+json")
|
||||||
|
|
||||||
# this is last so images are also preloaded
|
# this is last so images are also preloaded
|
||||||
# if this is done earlier, Chrome only preloads one image for some reason
|
# if this is done earlier, Chrome only preloads one image for some reason
|
||||||
|
@ -170,14 +174,14 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||||
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
||||||
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
||||||
images: seq[string] = @[]; banner=""; avatar=""; context="";
|
images: seq[string] = @[]; banner=""; avatar=""; context="";
|
||||||
contextUrl = ""; time: Option[DateTime] = none(DateTime)
|
contextUrl=""; id=""; time: Option[DateTime] = none(DateTime)
|
||||||
): string =
|
): string =
|
||||||
|
|
||||||
let canonical = getTwitterLink(req.path, req.params)
|
let canonical = getTwitterLink(req.path, req.params)
|
||||||
|
|
||||||
let node = buildHtml(html(lang="en")):
|
let node = buildHtml(html(lang="en")):
|
||||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||||
rss, canonical, avatar, context, contextUrl, time)
|
rss, canonical, avatar, context, contextUrl, id, time)
|
||||||
|
|
||||||
body:
|
body:
|
||||||
renderNavbar(cfg, req, rss, canonical)
|
renderNavbar(cfg, req, rss, canonical)
|
||||||
|
|
178
src/views/mastoapi.nim
Normal file
178
src/views/mastoapi.nim
Normal 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 tweet.gif.isSome():
|
||||||
|
content &= "\n📹"
|
||||||
|
if tweet.gif.isSome():
|
||||||
|
content &= " (GIF)"
|
||||||
|
elif tweet.photos.len > 0:
|
||||||
|
content &= "\n🖼️"
|
||||||
|
if tweet.photos.len > 1:
|
||||||
|
content &= &" ({tweet.photos.len})"
|
||||||
|
|
||||||
|
content &= "</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"] = %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
|
|
@ -351,7 +351,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||||
if tweet.quote.isSome:
|
if tweet.quote.isSome:
|
||||||
renderQuote(tweet.quote.get(), prefs, path)
|
renderQuote(tweet.quote.get(), prefs, path)
|
||||||
|
|
||||||
if mainTweet and tweet.birdwatch.isSome:
|
if tweet.birdwatch.isSome:
|
||||||
renderCommunityNote(tweet.birdwatch.get(), prefs)
|
renderCommunityNote(tweet.birdwatch.get(), prefs)
|
||||||
|
|
||||||
if mainTweet:
|
if mainTweet:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue