mixed media in tweets, allow discord embeds to select shown media, ...more

- direct image linking (buggy if you try and do /photo or /video with no index
or slash)
- fix activitypub images not being images
- gross hack to tell discord to fetch a single image for fedi (broken for videos
lol, discord's media proxy is not activitystream spec compliant)
This commit is contained in:
Cynthia Foxwell 2025-04-09 18:11:20 -06:00
parent 71c772d6c9
commit a6412968fe
No known key found for this signature in database
6 changed files with 266 additions and 45 deletions

View File

@ -125,6 +125,8 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
raise e raise e
except OSError as e: except OSError as e:
raise e raise e
except ProtocolError as e:
raise e
except Exception as e: except Exception as e:
echo "error: ", e.name, ", msg: ", e.msg, ", url: ", url echo "error: ", e.name, ", msg: ", e.msg, ", url: ", url
#if "length" notin e.msg and "descriptor" notin e.msg: #if "length" notin e.msg and "descriptor" notin e.msg:
@ -134,12 +136,11 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
template retry(bod) = template retry(bod) =
try: try:
bod bod
except RateLimitError: except ProtocolError:
echo "[accounts] Rate limited, retrying ", api, " request..."
bod bod
proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} = proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} =
#retry: retry:
var body: string var body: string
fetchImpl(body, additional_headers): fetchImpl(body, additional_headers):
if body.startsWith('{') or body.startsWith('['): if body.startsWith('{') or body.startsWith('['):
@ -157,7 +158,7 @@ proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders
raise rateLimitError() raise rateLimitError()
proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} = proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
#retry: retry:
fetchImpl(result, additional_headers): fetchImpl(result, additional_headers):
if not (result.startsWith('{') or result.startsWith('[')): if not (result.startsWith('{') or result.startsWith('[')):
echo resp.status, ": ", result, " --- url: ", url echo resp.status, ": ", result, " --- url: ", url

View File

@ -33,7 +33,15 @@ proc createActivityPubRouter*(cfg: Config) =
resp Http200, {"Content-Type": "application/json"}, $getMastoAPIUser(user, cfg) resp Http200, {"Content-Type": "application/json"}, $getMastoAPIUser(user, cfg)
get "/api/v1/statuses/@id": get "/api/v1/statuses/@id":
let id = @"id" var
id = @"id"
query = ""
# stupid hack to trick discord lmao
if id.find("_") != -1:
let parts = id.split("_")
id = parts[0]
query = parts[1]
if id.len > 19 or id.any(c => not c.isDigit): if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}""" resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
@ -54,11 +62,44 @@ proc createActivityPubRouter*(cfg: Config) =
resp Http404, {"Content-Type": "application/json"}, $errJson 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 let
tweet = conv.tweet tweet = conv.tweet
tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{id}" tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{id}"
var media: seq[JsonNode] = @[] 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: if tweet.photos.len > 0:
for imageObj in tweet.photos: for imageObj in tweet.photos:
let image = getUrlPrefix(cfg) & getPicUrl(imageObj.url) let image = getUrlPrefix(cfg) & getPicUrl(imageObj.url)
@ -169,7 +210,25 @@ proc createActivityPubRouter*(cfg: Config) =
resp Http200, {"Content-Type": "application/json"}, $postJson resp Http200, {"Content-Type": "application/json"}, $postJson
get "/users/@name/statuses/@id": get "/users/@name/statuses/@id":
let id = @"id" var
id = @"id"
query = ""
# stupid hack to trick discord lmao
if id.find("_") != -1:
let parts = id.split("_")
id = parts[0]
query = parts[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 request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
if id.len > 19 or id.any(c => not c.isDigit): if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}""" resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
@ -190,7 +249,33 @@ proc createActivityPubRouter*(cfg: Config) =
resp Http404, {"Content-Type": "application/json"}, $errJson resp Http404, {"Content-Type": "application/json"}, $errJson
let postJson = getActivityStream(conv.tweet, cfg, prefs) 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 resp Http200, {"Content-Type": "application/json"}, $postJson

View File

@ -37,13 +37,29 @@ proc createStatusRouter*(cfg: Config) =
resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs), resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs),
request, cfg, prefs) request, cfg, prefs)
get "/@name/status/@id/?": get "/@name/status/@id/?@m?/?@i?/?":
cond '.' notin @"name" cond '.' notin @"name"
var id = @"id" var
var rawFile = false id = @"id"
if id.endsWith(".mp4"): media = @"m"
rawFile = true mediaIndex = @"i"
id.removeSuffix(".mp4")
let url = $request.getNativeReq().url
var
rawVideo = false
rawImage = false
if url.endsWith(".mp4") or url.endsWith(".gif"):
rawVideo = true
elif url.endsWith(".png") or url.endsWith(".jpg"):
rawImage = true
for ext in @[".mp4", ".gif", ".png", ".jpg"]:
if id.endsWith(ext):
id.removeSuffix(ext)
if media.endsWith(ext):
media.removeSuffix(ext)
if mediaIndex.endsWith(ext):
mediaIndex.removeSuffix(ext)
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json": if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
if id.len > 19 or id.any(c => not c.isDigit): if id.len > 19 or id.any(c => not c.isDigit):
@ -65,7 +81,33 @@ proc createStatusRouter*(cfg: Config) =
resp Http404, {"Content-Type": "application/json"}, $errJson resp Http404, {"Content-Type": "application/json"}, $errJson
let postJson = getActivityStream(conv.tweet, cfg, prefs) let tweet = conv.tweet
if media.len > 0:
if media == "video" and tweet.video.isSome or tweet.gif.isSome:
tweet.photos = @[]
elif media == "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 resp Http200, {"Content-Type": "application/json"}, $postJson
@ -99,6 +141,36 @@ proc createStatusRouter*(cfg: Config) =
avatar = tweet.user.userPic avatar = tweet.user.userPic
time = some(tweet.time) time = some(tweet.time)
let isDiscord = request.headers.getOrDefault("User-Agent").toString().contains("Discordbot")
var
realMediaIndex = mediaIndex
realUseVideo = false
if isDiscord and media.len > 0:
if media == "video" and tweet.video.isSome or tweet.gif.isSome:
tweet.photos = @[]
elif media == "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
realUseVideo = true
else:
index = tweet.photos.len
elif index < 1:
index = 1
if useVideo:
tweet.photos = @[]
else:
realMediaIndex = $index
index -= 1
tweet.video = none(Video)
let image = tweet.photos[index]
tweet.photos = @[]
tweet.photos.add(image)
var var
images = tweet.photos images = tweet.photos
video = "" video = ""
@ -123,7 +195,7 @@ proc createStatusRouter*(cfg: Config) =
if tweet.video.isSome(): if tweet.video.isSome():
let videoObj = get(tweet.video) let videoObj = get(tweet.video)
images = @[Image(url:videoObj.thumb)] images.add(Image(url:videoObj.thumb))
let vars = videoObj.variants.filterIt(it.contentType == mp4) let vars = videoObj.variants.filterIt(it.contentType == mp4)
# idk why this wont sort when it sorts everywhere else # idk why this wont sort when it sorts everywhere else
@ -131,7 +203,7 @@ proc createStatusRouter*(cfg: Config) =
video = vars[^1].url video = vars[^1].url
elif tweet.gif.isSome(): elif tweet.gif.isSome():
let gif = get(tweet.gif) let gif = get(tweet.gif)
images = @[Image(url:gif.thumb)] images.add(Image(url:gif.thumb))
video = getUrlPrefix(cfg) & getPicUrl(gif.url) video = getUrlPrefix(cfg) & getPicUrl(gif.url)
#elif tweet.card.isSome(): #elif tweet.card.isSome():
# let card = tweet.card.get() # let card = tweet.card.get()
@ -140,20 +212,44 @@ proc createStatusRouter*(cfg: Config) =
# elif card.video.isSome(): # elif card.video.isSome():
# images = @[card.video.get().thumb] # images = @[card.video.get().thumb]
if rawFile and video != "": if rawVideo and video != "":
redirect(video) redirect(video)
elif rawImage and images.len > 0:
if media == "photo" and mediaIndex.len > 0:
var index = parseInt(mediaIndex)
var useVideo = false
if index > tweet.photos.len:
if video != "":
useVideo = true
else:
index = tweet.photos.len
elif index < 1:
index = 1
if useVideo:
redirect(video)
else:
index -= 1
redirect(getPicUrl(images[index].url))
else:
redirect(getPicUrl(images[0].url))
var query = ""
if media == "video":
query = "video"
elif media == "photo" and mediaIndex.len > 0:
if realUseVideo and video != "":
query = "video"
else:
query = &"photo:{realMediaIndex}"
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, id=id) context=context, contextUrl=contextUrl, id=id, media=query
)
get "/@name/@s/@id/@m/?@i?": get "/@name/statuses/@id/?@m?/?@i?":
cond @"s" in ["status", "statuses"]
cond @"m" in ["video", "photo"]
redirect("/$1/status/$2" % [@"name", @"id"])
get "/@name/statuses/@id/?":
redirect("/$1/status/$2" % [@"name", @"id"]) redirect("/$1/status/$2" % [@"name", @"id"])
get "/i/web/status/@id": get "/i/web/status/@id":

View File

@ -39,7 +39,8 @@ 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[Image] = @[]; banner=""; ogTitle=""; video=""; images: seq[Image] = @[]; banner=""; ogTitle="";
rss=""; canonical=""; avatar=""; context=""; contextUrl=""; rss=""; canonical=""; avatar=""; context=""; contextUrl="";
id=""; time: Option[DateTime] = none(DateTime)): VNode = id=""; time: Option[DateTime] = none(DateTime); media=""
): 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
@ -166,7 +167,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
author = encodeUrl(context) 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)}/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") var fediUrl = &"{getUrlPrefix(cfg)}/users/i/statuses/{id}"
if media.len > 0:
fediUrl &= "_" & media
link(rel="alternate", href=fediUrl, 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
@ -176,14 +180,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[Image] = @[]; banner=""; avatar=""; context=""; images: seq[Image] = @[]; banner=""; avatar=""; context="";
contextUrl=""; id=""; time: Option[DateTime] = none(DateTime) contextUrl=""; id=""; time: Option[DateTime] = none(DateTime);
): string = media=""): 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, id, time) rss, canonical, avatar, context, contextUrl, id, time, media)
body: body:
renderNavbar(cfg, req, rss, canonical) renderNavbar(cfg, req, rss, canonical)

View File

@ -64,11 +64,10 @@ proc getActivityStream*(tweet: Tweet, cfg: Config, prefs: Prefs): JsonNode =
filetype = "jpeg" filetype = "jpeg"
var mediaObj = newJObject() var mediaObj = newJObject()
mediaObj["type"] = %"Document" mediaObj["type"] = %"Image"
mediaObj["mediaType"] = %("image/" & filetype) mediaObj["mediaType"] = %("image/" & filetype)
mediaObj["url"] = %image mediaObj["url"] = %image
mediaObj["name"] = %imageObj.description mediaObj["name"] = %imageObj.description
media.add(mediaObj) media.add(mediaObj)
if tweet.video.isSome(): if tweet.video.isSome():
@ -79,25 +78,59 @@ proc getActivityStream*(tweet: Tweet, cfg: Config, prefs: Prefs): JsonNode =
if videoObj.description.len > 0: if videoObj.description.len > 0:
description = videoObj.description description = videoObj.description
var mediaObj = newJObject() let splitUrl = videoObj.thumb.split('.')
mediaObj["type"] = %"Document" var filetype = splitUrl[^1]
mediaObj["mediaType"] = %"video/mp4" if filetype == "jpg":
mediaObj["url"] = %vars[^1].url filetype = "jpeg"
mediaObj["name"] = %description
media.add(mediaObj) var url: seq[JsonNode] = @[]
var thumb = newJObject()
thumb["type"] = %"Link"
thumb["mediaType"] = %("image/" & filetype)
thumb["href"] = %(getUrlPrefix(cfg) & getPicUrl(videoObj.thumb))
url.add(thumb)
var mediaObj = newJObject()
mediaObj["type"] = %"Link"
mediaObj["mediaType"] = %"video/mp4"
mediaObj["href"] = %vars[^1].url
url.add(mediaObj)
var wrapper = newJObject()
wrapper["type"] = %"Video"
wrapper["name"] = %description
wrapper["url"] = %url
media.add(wrapper)
elif tweet.gif.isSome(): elif tweet.gif.isSome():
let let
gif = get(tweet.gif) gif = get(tweet.gif)
gifUrl = getUrlPrefix(cfg) & getPicUrl(gif.url) gifUrl = getUrlPrefix(cfg) & getPicUrl(gif.url)
var mediaObj = newJObject() let splitUrl = gif.thumb.split('.')
mediaObj["type"] = %"Document" var filetype = splitUrl[^1]
mediaObj["mediaType"] = %"video/mp4" if filetype == "jpg":
mediaObj["url"] = %gifUrl filetype = "jpeg"
mediaObj["name"] = newJNull() # FIXME a11y
media.add(mediaObj) var url: seq[JsonNode] = @[]
var thumb = newJObject()
thumb["type"] = %"Link"
thumb["mediaType"] = %("image/" & filetype)
thumb["href"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb))
url.add(thumb)
var mediaObj = newJObject()
mediaObj["type"] = %"Link"
mediaObj["mediaType"] = %"video/mp4"
mediaObj["href"] = %gifUrl
url.add(mediaObj)
var wrapper = newJObject()
wrapper["type"] = %"Video"
wrapper["name"] = newJNull()
wrapper["url"] = %url
media.add(wrapper)
var context: seq[JsonNode] = @[] var context: seq[JsonNode] = @[]
let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams" let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams"

View File

@ -225,7 +225,8 @@ proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="quote-media-container")): buildHtml(tdiv(class="quote-media-container")):
if quote.photos.len > 0: if quote.photos.len > 0:
renderAlbum(quote) renderAlbum(quote)
elif quote.video.isSome:
if quote.video.isSome:
renderVideo(quote.video.get(), prefs, path) renderVideo(quote.video.get(), prefs, path)
elif quote.gif.isSome: elif quote.gif.isSome:
renderGif(quote.gif.get(), prefs) renderGif(quote.gif.get(), prefs)
@ -343,7 +344,8 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if tweet.photos.len > 0: if tweet.photos.len > 0:
renderAlbum(tweet) renderAlbum(tweet)
elif tweet.video.isSome:
if tweet.video.isSome:
renderVideo(tweet.video.get(), prefs, path) renderVideo(tweet.video.get(), prefs, path)
views = tweet.video.get().views views = tweet.video.get().views
elif tweet.gif.isSome: elif tweet.gif.isSome: