コミットを比較
28 コミット
66938d173a
...
c5587a922e
作成者 | SHA1 | 日付 |
---|---|---|
テクニカル諏訪子 | c5587a922e | |
Zed | 51c6605d3f | |
Zed | d96550fcce | |
Zed | eed4d4033f | |
Zed | 173dd8f016 | |
Zed | dcac7e4a26 | |
Zed | 7590dc1cda | |
Zed | 80f7bc0a02 | |
Zed | b0a5e38b3f | |
Zed | ddc2be8439 | |
Zed | e3f6c72bf6 | |
Zed | 5e0eb02422 | |
Zed | ab94d9eb7d | |
Zed | fb10bfc5e3 | |
jackyzy823 | 52af6b2746 | |
Zed | ebffb6d251 | |
Zed | c09a8d87d9 | |
jackyzy823 | 6aa913ad62 | |
Zed | aa2fed19d7 | |
Zed | 1e1e034237 | |
Zed | 0a8fd2fce2 | |
Zed | 9b202e414b | |
Zed | 2a2e9625e1 | |
Zed | 99d3c46af5 | |
Zed | 6bcbe0ea9f | |
Timothy Bautista | 2edf54d5b3 | |
Faye Duxovni | 9c19e70a03 | |
Faye Duxovni | 26842fa0bf |
|
@ -12,9 +12,9 @@ listMinutes = 240 # how long to cache list info (not the tweets, so keep it hig
|
|||
rssMinutes = 10 # how long to cache rss queries
|
||||
redisHost = "localhost" # Change to "redis" if using docker-compose
|
||||
redisPort = 6379
|
||||
redisPassword = ""
|
||||
redisConnections = 20 # connection pool size
|
||||
redisMaxConnections = 30
|
||||
redisPassword = ""
|
||||
# max, new connections are opened when none are available, but if the pool size
|
||||
# goes above this, they're closed when released. don't worry about this unless
|
||||
# you receive tons of requests per second
|
||||
|
@ -22,6 +22,9 @@ redisPassword = ""
|
|||
[Config]
|
||||
hmacKey = "secretkey" # random key for cryptographic signing of video urls
|
||||
base64Media = false # use base64 encoding for proxied media urls
|
||||
enableRSS = true # set this to false to disable RSS feeds
|
||||
proxy = "" # proxy type http/https
|
||||
proxyAuth = ""
|
||||
tokenCount = 10
|
||||
# minimum amount of usable tokens. tokens are used to authorize API requests,
|
||||
# but they expire after ~1 hour, and have a limit of 187 requests.
|
||||
|
|
22
src/api.nim
22
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)
|
||||
|
@ -67,11 +74,16 @@ proc getSearch*[T](query: Query; after=""): Future[Result[T]] {.async.} =
|
|||
searchMode = ("tweet_search_mode", "live")
|
||||
parse = parseTimeline
|
||||
|
||||
let
|
||||
q = genQueryParam(query)
|
||||
url = search ? genParams(searchParams & @[("q", q), searchMode], after)
|
||||
result = parse(await fetch(url), after)
|
||||
result.query = query
|
||||
let q = genQueryParam(query)
|
||||
if q.len == 0 or q == emptyQuery:
|
||||
return Result[T](beginning: true, query: query)
|
||||
|
||||
let url = search ? genParams(searchParams & @[("q", q), searchMode], after)
|
||||
try:
|
||||
result = parse(await fetch(url), after)
|
||||
result.query = query
|
||||
except InternalError:
|
||||
return Result[T](beginning: true, query: query)
|
||||
|
||||
proc getTweetImpl(id: string; after=""): Future[Conversation] {.async.} =
|
||||
let url = tweet / (id & ".json") ? genParams(cursor=after)
|
||||
|
|
|
@ -14,10 +14,15 @@ proc genParams*(pars: openarray[(string, string)] = @[]; cursor="";
|
|||
result &= p
|
||||
if ext:
|
||||
result &= ("ext", "mediaStats")
|
||||
if cursor.len > 0:
|
||||
result &= ("cursor", cursor)
|
||||
if count.len > 0:
|
||||
result &= ("count", count)
|
||||
if cursor.len > 0:
|
||||
# The raw cursor often has plus signs, which sometimes get turned into spaces,
|
||||
# so we need to them back into a plus
|
||||
if " " in cursor:
|
||||
result &= ("cursor", cursor.replace(" ", "+"))
|
||||
else:
|
||||
result &= ("cursor", cursor)
|
||||
|
||||
proc genHeaders*(token: Token = nil): HttpHeaders =
|
||||
result = newHttpHeaders({
|
||||
|
@ -44,9 +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)
|
||||
uncompress(await resp.body)
|
||||
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)
|
||||
|
@ -64,8 +75,13 @@ proc fetch*(url: Uri; oldApi=false): Future[JsonNode] {.async.} =
|
|||
echo "fetch error: ", result.getError
|
||||
release(token, true)
|
||||
raise rateLimitError()
|
||||
|
||||
if resp.status == $Http400:
|
||||
raise newException(InternalError, $url)
|
||||
except InternalError as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
echo "error: ", e.msg, ", token: ", token[], ", url: ", url
|
||||
echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url
|
||||
if "length" notin e.msg and "descriptor" notin e.msg:
|
||||
release(token, true)
|
||||
raise rateLimitError()
|
||||
|
|
|
@ -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)
|
||||
|
@ -19,13 +19,16 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
|||
useHttps: cfg.get("Server", "https", true),
|
||||
httpMaxConns: cfg.get("Server", "httpMaxConnections", 100),
|
||||
|
||||
title: cfg.get("Server", "title", "Nitter"),
|
||||
hostname: cfg.get("Server", "hostname", "nitter.net"),
|
||||
title: cfg.get("Server", "title", "076ツイッター"),
|
||||
hostname: cfg.get("Server", "hostname", "twitter.076.ne.jp"),
|
||||
staticDir: cfg.get("Server", "staticDir", "./public"),
|
||||
|
||||
hmacKey: cfg.get("Config", "hmacKey", "secretkey"),
|
||||
base64Media: cfg.get("Config", "base64Media", false),
|
||||
minTokens: cfg.get("Config", "tokenCount", 10),
|
||||
enableRss: cfg.get("Config", "enableRSS", true),
|
||||
proxy: cfg.get("Config", "proxy", ""),
|
||||
proxyAuth: cfg.get("Config", "proxyAuth", ""),
|
||||
|
||||
listCacheTime: cfg.get("Cache", "listMinutes", 120),
|
||||
rssCacheTime: cfg.get("Cache", "rssMinutes", 10),
|
||||
|
|
|
@ -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,7 +15,9 @@ const
|
|||
# Images aren't supported due to errors from Teddit when the image
|
||||
# wasn't first displayed via a post on the Teddit instance.
|
||||
|
||||
twRegex = re"(?<![^\/> ])(?<![^\/]\/)(www\.|mobile\.)?twitter\.com"
|
||||
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
|
||||
twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>"""
|
||||
|
||||
cards = "cards.twitter.com/cards"
|
||||
tco = "https://t.co"
|
||||
|
||||
|
@ -29,7 +31,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 =
|
||||
|
@ -64,10 +66,12 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
|
|||
result = result.replace(odRegex, prefs.replaceOdysee)
|
||||
|
||||
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/")
|
||||
|
@ -165,7 +169,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",
|
||||
|
|
|
@ -6,10 +6,17 @@ type
|
|||
conns*: seq[AsyncHttpClient]
|
||||
|
||||
var maxConns {.threadvar.}: int
|
||||
var proxy {.threadvar.}: Proxy
|
||||
|
||||
proc setMaxHttpConns*(n: int) =
|
||||
maxConns = n
|
||||
|
||||
proc setHttpProxy*(url: string; auth: string) =
|
||||
if url.len > 0:
|
||||
proxy = newProxy(url, auth)
|
||||
else:
|
||||
proxy = nil
|
||||
|
||||
proc release*(pool: HttpPool; client: AsyncHttpClient) =
|
||||
if pool.conns.len >= maxConns:
|
||||
client.close()
|
||||
|
@ -20,7 +27,7 @@ template use*(pool: HttpPool; heads: HttpHeaders; body: untyped): untyped =
|
|||
var c {.inject.}: AsyncHttpClient
|
||||
|
||||
if pool.conns.len == 0:
|
||||
c = newAsyncHttpClient(headers=heads)
|
||||
c = newAsyncHttpClient(headers=heads, proxy=proxy)
|
||||
else:
|
||||
c = pool.conns.pop()
|
||||
c.headers = heads
|
||||
|
|
|
@ -13,6 +13,7 @@ import routes/[
|
|||
unsupported, embed, resolver, router_utils]
|
||||
|
||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||
|
||||
let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
|
||||
let (cfg, fullCfg) = getConfig(configPath)
|
||||
|
@ -31,6 +32,7 @@ setCacheTimes(cfg)
|
|||
setHmacKey(cfg.hmacKey)
|
||||
setProxyEncoding(cfg.base64Media)
|
||||
setMaxHttpConns(cfg.httpMaxConns)
|
||||
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
||||
|
||||
waitFor initRedisPool(cfg)
|
||||
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
|
||||
|
@ -75,11 +77,17 @@ routes:
|
|||
error Http404:
|
||||
resp Http404, showError("Page not found", cfg)
|
||||
|
||||
error InternalError:
|
||||
echo error.exc.name, ": ", error.exc.msg
|
||||
const link = a("open a GitHub issue", href = issuesUrl)
|
||||
resp Http500, showError(
|
||||
&"An error occurred, please {link} with the URL you tried to visit.", cfg)
|
||||
|
||||
error RateLimitError:
|
||||
echo error.exc.msg
|
||||
resp Http429, showError("Instance has been rate limited.<br>Use " &
|
||||
a("another instance", href = instancesUrl) &
|
||||
" or try again later.", cfg)
|
||||
echo error.exc.name, ": ", error.exc.msg
|
||||
const link = a("another instance", href = instancesUrl)
|
||||
resp Http429, showError(
|
||||
&"Instance has been rate limited.<br>Use {link} or try again later.", cfg)
|
||||
|
||||
extend unsupported, ""
|
||||
extend preferences, ""
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -12,6 +12,8 @@ const
|
|||
"verified", "safe"
|
||||
]
|
||||
|
||||
emptyQuery* = "include:nativeretweets"
|
||||
|
||||
template `@`(param: string): untyped =
|
||||
if param in pms: pms[param]
|
||||
else: ""
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -52,6 +52,7 @@ template respRss*(rss) =
|
|||
proc createRssRouter*(cfg: Config) =
|
||||
router rss:
|
||||
get "/search/rss":
|
||||
cond cfg.enableRss
|
||||
if @"q".len > 200:
|
||||
resp Http400, showError("Search input too long.", cfg)
|
||||
|
||||
|
@ -76,6 +77,7 @@ proc createRssRouter*(cfg: Config) =
|
|||
respRss(rss)
|
||||
|
||||
get "/@name/rss":
|
||||
cond cfg.enableRss
|
||||
cond '.' notin @"name"
|
||||
let
|
||||
cursor = getCursor()
|
||||
|
@ -92,6 +94,7 @@ proc createRssRouter*(cfg: Config) =
|
|||
respRss(rss)
|
||||
|
||||
get "/@name/@tab/rss":
|
||||
cond cfg.enableRss
|
||||
cond '.' notin @"name"
|
||||
cond @"tab" in ["with_replies", "media", "search"]
|
||||
let name = @"name"
|
||||
|
@ -117,6 +120,7 @@ proc createRssRouter*(cfg: Config) =
|
|||
respRss(rss)
|
||||
|
||||
get "/@name/lists/@list/rss":
|
||||
cond cfg.enableRss
|
||||
cond '.' notin @"name"
|
||||
let
|
||||
cursor = getCursor()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 = ""
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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 "/i/@i?/?@j?":
|
||||
cond @"i" notin ["status", "lists"]
|
||||
get "/intent/?@i?":
|
||||
cond @"i" notin ["user"]
|
||||
feature()
|
||||
|
||||
get "/i/@i?/?@j?":
|
||||
cond @"i" notin ["status", "lists" , "user"]
|
||||
feature()
|
||||
|
|
|
@ -158,8 +158,4 @@
|
|||
padding: .75em;
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
&.unavailable {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -191,6 +191,7 @@
|
|||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg_color);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tweet-link {
|
||||
|
|
|
@ -6,6 +6,7 @@ genPrefsType()
|
|||
|
||||
type
|
||||
RateLimitError* = object of CatchableError
|
||||
InternalError* = object of CatchableError
|
||||
|
||||
Token* = ref object
|
||||
tok*: string
|
||||
|
@ -215,6 +216,9 @@ type
|
|||
hmacKey*: string
|
||||
base64Media*: bool
|
||||
minTokens*: int
|
||||
enableRss*: bool
|
||||
proxy*: string
|
||||
proxyAuth*: string
|
||||
|
||||
rssCacheTime*: int
|
||||
listCacheTime*: int
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -11,30 +11,29 @@ const
|
|||
doctype = "<!DOCTYPE html>\n"
|
||||
lp = readFile("public/lp.svg")
|
||||
|
||||
proc renderNavbar*(title, 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"
|
||||
|
||||
buildHtml(nav):
|
||||
tdiv(class="inner-nav"):
|
||||
tdiv(class="nav-item"):
|
||||
a(class="site-name", href="/"): text title
|
||||
a(class="site-name", href="/"): text cfg.title
|
||||
|
||||
a(href="/"): img(class="site-logo", src="/logo.png", alt="Logo")
|
||||
|
||||
tdiv(class="nav-item right"):
|
||||
icon "search", title="Search", href="/search"
|
||||
if rss.len > 0:
|
||||
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,7 +57,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; titleText=""; desc=""; video="";
|
|||
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
||||
href=opensearchUrl)
|
||||
|
||||
if rss.len > 0:
|
||||
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 prefs.hlsPlayback:
|
||||
|
@ -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.title, rss, req)
|
||||
renderNavbar(cfg, req, rss, canonical)
|
||||
|
||||
tdiv(class="container"):
|
||||
body
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
<p>${text.replace("\n", "<br>\n")}</p>
|
||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||
# let quoteLink = getLink(get(tweet.quote))
|
||||
<p>${text}<br><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
|
||||
#else:
|
||||
<p>${text}</p>
|
||||
<p><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
|
||||
#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
|
||||
<item>
|
||||
<title>${getTitle(tweet, prefs, retweet)}</title>
|
||||
<title>${getTitle(tweet, retweet)}</title>
|
||||
<dc:creator>@${tweet.profile.username}</dc:creator>
|
||||
<description><![CDATA[${renderRssTweet(tweet, prefs, cfg).strip(chars={'\n'})}]]></description>
|
||||
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
||||
<guid>${urlPrefix & link}</guid>
|
||||
<link>${urlPrefix & link}</link>
|
||||
|
@ -107,7 +103,7 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||
<height>128</height>
|
||||
</image>
|
||||
#if timeline.content.len > 0:
|
||||
${renderRssTweets(timeline.content, getPrefs(cfg), cfg)}
|
||||
${renderRssTweets(timeline.content, cfg)}
|
||||
#end if
|
||||
</channel>
|
||||
</rss>
|
||||
|
@ -126,7 +122,7 @@ ${renderRssTweets(timeline.content, getPrefs(cfg), cfg)}
|
|||
<description>${getDescription(list.name & " by @" & list.username, cfg)}</description>
|
||||
<language>en-us</language>
|
||||
<ttl>40</ttl>
|
||||
${renderRssTweets(tweets, getPrefs(cfg), cfg)}
|
||||
${renderRssTweets(tweets, cfg)}
|
||||
</channel>
|
||||
</rss>
|
||||
#end proc
|
||||
|
@ -145,7 +141,7 @@ ${renderRssTweets(tweets, getPrefs(cfg), cfg)}
|
|||
<description>${getDescription("Search \"" & escName & "\"", cfg)}</description>
|
||||
<language>en-us</language>
|
||||
<ttl>40</ttl>
|
||||
${renderRssTweets(tweets, getPrefs(cfg), cfg)}
|
||||
${renderRssTweets(tweets, cfg)}
|
||||
</channel>
|
||||
</rss>
|
||||
#end proc
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import uri
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import ".."/[types, formatters]
|
||||
|
@ -38,7 +37,7 @@ proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
|
|||
renderReplyThread(thread, prefs, path)
|
||||
|
||||
if replies.bottom.len > 0:
|
||||
renderMore(Query(), encodeUrl(replies.bottom), focus="#r")
|
||||
renderMore(Query(), replies.bottom, focus="#r")
|
||||
|
||||
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
|
||||
let hasAfter = conv.after.content.len > 0
|
||||
|
|
|
@ -26,7 +26,7 @@ proc renderNewer*(query: Query; path: string; focus=""): VNode =
|
|||
|
||||
proc renderMore*(query: Query; cursor: string; focus=""): VNode =
|
||||
buildHtml(tdiv(class="show-more")):
|
||||
a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor)}{focus}")):
|
||||
a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
|
||||
text "もっと"
|
||||
|
||||
proc renderNoMore(): VNode =
|
||||
|
|
新しいイシューから参照