nitter/src/routes/activityspoof.nim
2025-12-06 17:12:26 -07:00

351 lines
12 KiB
Nim

# 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, redis_cache]
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/accounts/@id":
let id = @"id"
#if id.len > 19 or id.any(c => not c.isDigit):
if not id.allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}):
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid account ID"}"""
#var username = await getCachedUsername(id)
#if username.len == 0:
#resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
let user = await getCachedUser(id)
if user.suspended or user.id.len == 0:
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
resp Http200, {"Content-Type": "application/json"}, $getMastoAPIUser(user, cfg)
get "/api/v1/statuses/@id":
var
id = @"id"
query = ""
# stupid hack to trick discord lmao
if id.startsWith("422209040515"):
query = "video"
id.removePrefix("422209040515")
elif id.startsWith("421608152015"):
query = "photo:"
id.removePrefix("421608152015")
query &= id[0]
id = id[1..^1]
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 getCachedTweet(id)
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 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
var
mediaType = ""
mediaIndex = ""
if query.len > 0:
let parts = query.split(":")
mediaType = parts[0]
if parts.len == 2:
mediaIndex = parts[1]
let
tweet = conv.tweet
tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{id}"
var media: seq[JsonNode] = @[]
if mediaType.len > 0:
if mediaType == "video" and tweet.video.isSome or tweet.gif.isSome:
tweet.photos = @[]
elif mediaType == "photo" and tweet.photos.len > 0:
if mediaIndex.len > 0:
var index = parseInt(mediaIndex)
var useVideo = false
if index > tweet.photos.len:
if tweet.video.isSome or tweet.gif.isSome:
useVideo = true
else:
index = tweet.photos.len
elif index < 1:
index = 1
if useVideo:
tweet.photos = @[]
else:
index -= 1
tweet.video = none(Video)
let image = tweet.photos[index]
tweet.photos = @[]
tweet.photos.add(image)
if tweet.photos.len > 0:
for imageObj in tweet.photos:
let image = getUrlPrefix(cfg) & getPicUrl(imageObj.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"] = %image
mediaObj["preview_remote_url"] = %image
mediaObj["text_url"] = newJNull()
mediaObj["description"] = %imageObj.description
# 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)
videoUrl = vars[^1].url.replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", "")
videoPreview = getUrlPrefix(cfg) & getPicUrl(videoObj.thumb)
var mediaObj = newJObject()
var description = videoObj.title
if videoObj.description.len > 0:
description = videoObj.description
mediaObj["id"] = %"150745989836308480"
mediaObj["type"] = %"video"
mediaObj["url"] = %videoUrl
mediaObj["preview_url"] = %videoPreview
mediaObj["remote_url"] = %videoUrl
mediaObj["preview_remote_url"] = %videoPreview
mediaObj["text_url"] = newJNull()
mediaObj["description"] = %description
# FIXME but this probably isnt used by discord
mediaObj["meta"] = newJObject()
media.add(mediaObj)
elif tweet.gif.isSome():
let
gif = get(tweet.gif)
gifUrl = (https & gif.url).replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", "")
gifPreview = getUrlPrefix(cfg) & getPicUrl(gif.thumb)
var mediaObj = newJObject()
mediaObj["id"] = %"150745989836308480"
mediaObj["type"] = %"video"
mediaObj["url"] = %gifUrl
mediaObj["preview_url"] = %gifPreview
mediaObj["remote_url"] = %gifUrl
mediaObj["preview_remote_url"] = %gifPreview
mediaObj["text_url"] = newJNull()
mediaObj["description"] = newJNull() # FIXME this requires refactoring gifs
# FIXME but this probably isnt used by discord
mediaObj["meta"] = newJObject()
media.add(mediaObj)
var fields: seq[JsonNode] = @[]
if tweet.user.location.len > 0:
var location = newJObject()
location["name"] = %"Location"
location["value"] = %tweet.user.location
location["verified_at"] = newJNull()
fields.add(location)
if tweet.user.website.len > 0:
var website = newJObject()
website["name"] = %"Website"
website["value"] = %(&"<a href=\"{tweet.user.website}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{tweet.user.website}</a>")
website["verified_at"] = newJNull()
fields.add(website)
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.len != 0:
postJson["in_reply_to_id"] = %(&"{tweet.replyId}")
postJson["in_reply_to_account_id"] = %tweet.replyHandle
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"] = getMastoAPIUser(tweet.user, cfg)
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":
var
id = @"id"
query = ""
# stupid hack to trick discord lmao
if id.startsWith("422209040515"):
query = "video"
id.removePrefix("422209040515")
elif id.startsWith("421608152015"):
query = "photo:"
id.removePrefix("421608152015")
query &= id[0]
id = id[1..^1]
var
mediaType = ""
mediaIndex = ""
if query.len > 0:
let parts = query.split(":")
mediaType = parts[0]
if parts.len == 2:
mediaIndex = parts[1]
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 getCachedTweet(id)
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 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
if mediaType.len > 0:
if mediaType == "video" and tweet.video.isSome or tweet.gif.isSome:
tweet.photos = @[]
elif mediaType == "photo" and tweet.photos.len > 0:
if mediaIndex.len > 0:
var index = parseInt(mediaIndex)
var useVideo = false
if index > tweet.photos.len:
if tweet.video.isSome or tweet.gif.isSome:
useVideo = true
else:
index = tweet.photos.len
elif index < 1:
index = 1
if useVideo:
tweet.photos = @[]
else:
index -= 1
tweet.video = none(Video)
let image = tweet.photos[index]
tweet.photos = @[]
tweet.photos.add(image)
let postJson = getActivityStream(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 getCachedUser(@"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