diff --git a/src/api.nim b/src/api.nim index a213992..61d33bb 100644 --- a/src/api.nim +++ b/src/api.nim @@ -22,6 +22,7 @@ proc getGraphListById*(id: string): Future[List] {.async.} = result = parseGraphList(js) proc getListTimeline*(id: string; after=""): Future[Timeline] {.async.} = + if id.len == 0: return let ps = genParams({"list_id": id, "ranking_mode": "reverse_chronological"}, after) url = listTimeline ? ps @@ -40,6 +41,12 @@ proc getProfile*(username: string): Future[Profile] {.async.} = url = userShow ? ps result = parseUserShow(await fetch(url, oldApi=true), username) +proc getProfileById*(userId: string): Future[Profile] {.async.} = + let + ps = genParams({"user_id": userId}) + url = userShow ? ps + result = parseUserShowId(await fetch(url, oldApi=true), userId) + proc getTimeline*(id: string; after=""; replies=false): Future[Timeline] {.async.} = let ps = genParams({"userId": id, "include_tweet_replies": $replies}, after) diff --git a/src/apiutils.nim b/src/apiutils.nim index 2ea4424..fc433ae 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -49,11 +49,15 @@ proc fetch*(url: Uri; oldApi=false): Future[JsonNode] {.async.} = let headers = genHeaders(token) try: var resp: AsyncResponse - let body = pool.use(headers): + var body = pool.use(headers): resp = await c.get($url) - let raw = await resp.body - if raw.len == 0: "" - else: uncompress(raw) + await resp.body + + if body.len > 0: + if resp.headers.getOrDefault("content-encoding") == "gzip": + body = uncompress(body, dfGzip) + else: + echo "non-gzip body, url: ", url, ", body: ", body if body.startsWith('{') or body.startsWith('['): result = parseJson(body) diff --git a/src/config.nim b/src/config.nim index 4739f35..bb60a74 100644 --- a/src/config.nim +++ b/src/config.nim @@ -2,8 +2,8 @@ import parsecfg except Config import types, strutils -proc get*[T](config: parseCfg.Config; s, v: string; default: T): T = - let val = config.getSectionValue(s, v) +proc get*[T](config: parseCfg.Config; section, key: string; default: T): T = + let val = config.getSectionValue(section, key) if val.len == 0: return default when T is int: parseInt(val) diff --git a/src/formatters.nim b/src/formatters.nim index f2cdfcf..9264830 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, times, uri, tables, xmltree, htmlparser +import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen import regex import types, utils, query @@ -15,6 +15,8 @@ const # wasn't first displayed via a post on the Teddit instance. twRegex = re"(?<=(?twitter\.com(\S+)""" + cards = "cards.twitter.com/cards" tco = "https://t.co" @@ -28,7 +30,7 @@ const twitter = parseUri("https://twitter.com") proc getUrlPrefix*(cfg: Config): string = - if cfg.useHttps: "https://" & cfg.hostname + if cfg.useHttps: https & cfg.hostname else: "http://" & cfg.hostname proc stripHtml*(text: string): string = @@ -57,10 +59,12 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string = result = result.replace("/c/", "/") if prefs.replaceTwitter.len > 0 and - (twRegex in result or tco in result): - result = result.replace(tco, "https://" & prefs.replaceTwitter & "/t.co") + (twRegex in result or twLinkRegex in result or tco in result): + result = result.replace(tco, https & prefs.replaceTwitter & "/t.co") result = result.replace(cards, prefs.replaceTwitter & "/cards") result = result.replace(twRegex, prefs.replaceTwitter) + result = result.replace(twLinkRegex, a( + prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) if prefs.replaceReddit.len > 0 and (rdRegex in result or "redd.it" in result): result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/") @@ -158,7 +162,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string = path = "/search" if "/search" notin path and query.fromUser.len < 2: - return $(twitter / path ? filterParams(params)) + return $(twitter / path) let p = { "f": if query.kind == users: "user" else: "live", diff --git a/src/parser.nim b/src/parser.nim index 5d3c74d..054e6ca 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -38,6 +38,18 @@ proc parseUserShow*(js: JsonNode; username: string): Profile = result = parseProfile(js) +proc parseUserShowId*(js: JsonNode; userId: string): Profile = + if js.isNull: + return Profile(id: userId) + + with error, js{"errors"}: + result = Profile(id: userId) + if error.getError == suspended: + result.suspended = true + return + + result = parseProfile(js) + proc parseGraphProfile*(js: JsonNode; username: string): Profile = if js.isNull: return with error, js{"errors"}: @@ -271,10 +283,9 @@ proc parseTweet(js: JsonNode): Tweet = else: discard with jsWithheld, js{"withheld_in_countries"}: - var withheldInCountries: seq[string] - - if jsWithheld.kind == JArray: - withheldInCountries = jsWithheld.to(seq[string]) + let withheldInCountries: seq[string] = + if jsWithheld.kind != JArray: @[] + else: jsWithheld.to(seq[string]) # XX - Content is withheld in all countries # XY - Content is withheld due to a DMCA request. @@ -282,6 +293,7 @@ proc parseTweet(js: JsonNode): Tweet = withheldInCountries.len > 0 and ("XX" in withheldInCountries or "XY" in withheldInCountries or "withheld" in result.text): + result.text.removeSuffix(" Learn more.") result.available = false proc finalizeTweet(global: GlobalObjects; id: string): Tweet = diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index fe95b6c..00a4899 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -97,15 +97,15 @@ genPrefs: "Autoplay gifs" "Link replacements (blank to disable)": - replaceTwitter(input, "nitter.net"): + replaceTwitter(input, ""): "Twitter -> Nitter" placeholder: "Nitter hostname" - replaceYouTube(input, "piped.kavin.rocks"): + replaceYouTube(input, ""): "YouTube -> Piped/Invidious" placeholder: "Piped hostname" - replaceReddit(input, "teddit.net"): + replaceReddit(input, ""): "Reddit -> Teddit/Libreddit" placeholder: "Teddit hostname" diff --git a/src/redis_cache.nim b/src/redis_cache.nim index 83656a4..ef6f9d3 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -78,6 +78,7 @@ proc cache*(data: Profile) {.async.} = pool.withAcquire(r): r.startPipelining() discard await r.setex(name.profileKey, baseCacheTime, compress(toFlatty(data))) + discard await r.setex("i:" & data.id , baseCacheTime, data.username) discard await r.hset(name.pidKey, name, data.id) discard await r.flushPipeline() @@ -110,6 +111,15 @@ proc getCachedProfile*(username: string; fetch=true): Future[Profile] {.async.} elif fetch: result = await getProfile(username) +proc getCachedProfileUsername*(userId: string): Future[string] {.async.} = + let username = await get("i:" & userId) + if username != redisNil: + result = username + else: + let profile = await getProfileById(userId) + result = profile.username + await cache(profile) + proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} = if name.len == 0: return let rail = await get("pr:" & toLower(name)) diff --git a/src/routes/search.nim b/src/routes/search.nim index 0fcd9ee..b3f8db7 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -4,7 +4,7 @@ import strutils, uri import jester import router_utils -import ".."/[query, types, api] +import ".."/[query, types, api, formatters] import ../views/[general, search] include "../views/opensearch.nimf" @@ -40,7 +40,6 @@ proc createSearchRouter*(cfg: Config) = redirect("/search?q=" & encodeUrl("#" & @"hash")) get "/opensearch": - var url = if cfg.useHttps: "https://" else: "http://" - url &= cfg.hostname & "/search?q=" + let url = getUrlPrefix(cfg) & "/search?q=" resp Http200, {"Content-Type": "application/opensearchdescription+xml"}, generateOpenSearchXML(cfg.title, cfg.hostname, url) diff --git a/src/routes/status.nim b/src/routes/status.nim index bad8569..70e31cb 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -18,6 +18,7 @@ proc createStatusRouter*(cfg: Config) = cond '.' notin @"name" let prefs = cookiePrefs() + # used for the infinite scroll feature if @"scroll".len > 0: let replies = await getReplies(@"id", getCursor()) if replies.content.len == 0: @@ -34,10 +35,12 @@ proc createStatusRouter*(cfg: Config) = error = conv.tweet.tombstone resp Http404, showError(error, cfg) - var + let title = pageTitle(conv.tweet) ogTitle = pageTitle(conv.tweet.profile) desc = conv.tweet.text + + var images = conv.tweet.photos video = "" diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 65d2652..e904f2d 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -78,9 +78,6 @@ proc fetchSingleTimeline*(after: string; query: Query; skipRail=false): return (profile, timeline, await rail) -proc get*(req: Request; key: string): string = - params(req).getOrDefault(key) - proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; rss, after: string): Future[string] {.async.} = if query.fromUser.len != 1: @@ -105,8 +102,22 @@ template respTimeline*(timeline: typed) = resp Http404, showError("User \"" & @"name" & "\" not found", cfg) resp t +template respUserId*() = + cond @"user_id".len > 0 + let username = await getCachedProfileUsername(@"user_id") + if username.len > 0: + redirect("/" & username) + else: + resp Http404, showError("User not found", cfg) + proc createTimelineRouter*(cfg: Config) = router timeline: + get "/i/user/@user_id": + respUserId() + + get "/intent/user": + respUserId() + get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video"] @@ -120,6 +131,7 @@ proc createTimelineRouter*(cfg: Config) = if names.len != 1: query.fromUser = names + # used for the infinite scroll feature if @"scroll".len > 0: if query.fromUser.len != 1: var timeline = await getSearch[Tweet](query, after) @@ -132,10 +144,12 @@ proc createTimelineRouter*(cfg: Config) = timeline.beginning = true resp $renderTimelineTweets(timeline, prefs, getPath()) - var rss = "/$1/$2/rss" % [@"name", @"tab"] - if @"tab".len == 0: - rss = "/$1/rss" % @"name" - elif @"tab" == "search": - rss &= "?" & genQueryUrl(query) + let rss = + if @"tab".len == 0: + "/$1/rss" % @"name" + elif @"tab" == "search": + "/$1/search/rss?$2" % [@"name", genQueryUrl(query)] + else: + "/$1/$2/rss" % [@"name", @"tab"] respTimeline(await showTimeline(request, query, cfg, prefs, rss, after)) diff --git a/src/routes/unsupported.nim b/src/routes/unsupported.nim index 2cbcbb1..4bafb96 100644 --- a/src/routes/unsupported.nim +++ b/src/routes/unsupported.nim @@ -11,10 +11,13 @@ proc createUnsupportedRouter*(cfg: Config) = resp renderMain(renderFeature(), request, cfg, themePrefs()) get "/about/feature": feature() - get "/intent/?@i?": feature() get "/login/?@i?": feature() get "/@name/lists/?": feature() + get "/intent/?@i?": + cond @"i" notin ["user"] + feature() + get "/i/@i?/?@j?": - cond @"i" notin ["status", "lists"] + cond @"i" notin ["status", "lists" , "user"] feature() diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index 8ffbf79..db19bed 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -158,8 +158,4 @@ padding: .75em; display: flex; position: relative; - - &.unavailable { - flex-direction: column; - } } diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index e6069b3..e29b880 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -191,6 +191,7 @@ box-sizing: border-box; border-radius: 10px; background-color: var(--bg_color); + z-index: 2; } .tweet-link { diff --git a/src/types.nim b/src/types.nim index 59f141b..c577244 100644 --- a/src/types.nim +++ b/src/types.nim @@ -229,11 +229,6 @@ type redisMaxConns*: int redisPassword*: string - replaceTwitter*: string - replaceYouTube*: string - replaceReddit*: string - replaceInstagram*: string - Rss* = object feed*, cursor*: string diff --git a/src/utils.nim b/src/utils.nim index 518d7af..e92d220 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -52,8 +52,10 @@ proc cleanFilename*(filename: string): string = result &= ".png" proc filterParams*(params: Table): seq[(string, string)] = - const filter = ["name", "id", "list", "referer", "scroll"] - toSeq(params.pairs()).filterIt(it[0] notin filter and it[1].len > 0) + const filter = ["name", "tab", "id", "list", "referer", "scroll"] + for p in params.pairs(): + if p[1].len > 0 and p[0] notin filter: + result.add p proc isTwitterUrl*(uri: Uri): bool = uri.hostname in twitterDomains diff --git a/src/views/general.nim b/src/views/general.nim index fc47c5a..1d10811 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -11,8 +11,7 @@ const doctype = "\n" lp = readFile("public/lp.svg") -proc renderNavbar*(cfg: Config, rss: string; req: Request): VNode = - let twitterPath = getTwitterLink(req.path, req.params) +proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = var path = $(parseUri(req.path) ? filterParams(req.params)) if "/status" in path: path.add "#m" @@ -27,14 +26,14 @@ proc renderNavbar*(cfg: Config, rss: string; req: Request): VNode = icon "search", title="Search", href="/search" if cfg.enableRss and rss.len > 0: icon "rss-feed", title="RSS Feed", href=rss - icon "bird", title="Open in Twitter", href=twitterPath + icon "bird", title="Open in Twitter", href=canonical a(href="https://liberapay.com/zedeus"): verbatim lp icon "info", title="About", href="/about" iconReferer "cog", "/settings", path, title="Preferences" proc renderHead*(prefs: Prefs; cfg: Config; titleText=""; desc=""; video=""; images: seq[string] = @[]; banner=""; ogTitle=""; theme=""; - rss=""): VNode = + rss=""; canonical=""): VNode = let ogType = if video.len > 0: "video" elif rss.len > 0: "object" @@ -44,7 +43,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; titleText=""; desc=""; video=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=4") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=6") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2") if theme.len > 0: @@ -58,6 +57,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; titleText=""; desc=""; video=""; link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, href=opensearchUrl) + 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") @@ -117,11 +119,14 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; if "theme" in req.params: theme = toLowerAscii(req.params["theme"]).replace(" ", "_") + let canonical = getTwitterLink(req.path, req.params) + let node = buildHtml(html(lang="en")): - renderHead(prefs, cfg, titleText, desc, video, images, banner, ogTitle, theme, rss) + renderHead(prefs, cfg, titleText, desc, video, images, banner, ogTitle, + theme, rss, canonical) body: - renderNavbar(cfg, rss, req) + renderNavbar(cfg, req, rss, canonical) tdiv(class="container"): body diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 427cd60..9fcc837 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -31,7 +31,7 @@ proc linkUser*(profile: Profile, class=""): VNode = icon "lock", title="Protected account" proc linkText*(text: string; class=""): VNode = - let url = if "http" notin text: "http://" & text else: text + let url = if "http" notin text: https & text else: text buildHtml(): a(href=url, class=class): text text diff --git a/src/views/rss.nimf b/src/views/rss.nimf index b6538ad..d000060 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -1,21 +1,18 @@ #? stdtmpl(subsChar = '$', metaChar = '#') ## SPDX-License-Identifier: AGPL-3.0-only -#import strutils, xmltree, strformat, options -#import ../types, ../utils, ../formatters +#import strutils, xmltree, strformat, options, unicode +#import ../types, ../utils, ../formatters, ../prefs # -#proc getPrefs(cfg: Config): Prefs = -#result.replaceTwitter = cfg.replaceTwitter -#result.replaceYouTube = cfg.replaceYouTube -#result.replaceReddit = cfg.replaceReddit -#result.replaceInstagram = cfg.replaceInstagram -#end proc -# -#proc getTitle(tweet: Tweet; prefs: Prefs; retweet: string): string = +#proc getTitle(tweet: Tweet; retweet: string): string = #if tweet.pinned: result = "Pinned: " #elif retweet.len > 0: result = &"RT by @{retweet}: " #elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: " #end if -#result &= xmltree.escape(stripHtml(tweet.text)) +#var text = stripHtml(tweet.text) +#if unicode.runeLen(text) > 32: +# text = unicode.runeSubStr(text, 0, 32) & "..." +#end if +#result &= xmltree.escape(text) #if result.len > 0: return #end if #if tweet.photos.len > 0: @@ -31,15 +28,14 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} #end proc # -#proc renderRssTweet(tweet: Tweet; prefs: Prefs; cfg: Config): string = +#proc renderRssTweet(tweet: Tweet; cfg: Config): string = #let tweet = tweet.retweet.get(tweet) #let urlPrefix = getUrlPrefix(cfg) -#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix) +#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix) +
${text.replace("\n", "
\n")}
${text}
${cfg.hostname}${quoteLink}
${text}
+ #end if #if tweet.photos.len > 0: # for photo in tweet.photos: @@ -60,7 +56,7 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} #end if #end proc # -#proc renderRssTweets(tweets: seq[Tweet]; prefs: Prefs; cfg: Config): string = +#proc renderRssTweets(tweets: seq[Tweet]; cfg: Config): string = #let urlPrefix = getUrlPrefix(cfg) #var links: seq[string] #for t in tweets: @@ -71,9 +67,9 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} # end if # links.add link