124 lines
6.2 KiB
Python
124 lines
6.2 KiB
Python
"""Hollo 0.9 (Fedify): one login (made by peers/hollo.sh) owning every town account, each its own actor, the way a PrivaPub
|
|||
|
|
root owns its personas. Accounts are made through its /accounts form and tokens through its OAuth consent page, which
|
||
|
|
asks the signed-in owner which account the token is for. Its forms check only that Origin is its own (Hono's csrf())."""
|
||
|
|
import html
|
||
|
|
import re
|
||
|
|
import urllib.parse
|
||
|
|
|
||
|
|
from core import podman
|
||
|
|
from dialects.base import Stored
|
||
|
|
from dialects.mastodon_api import MastodonApi
|
||
|
|
|
||
|
|
OOB = "urn:ietf:wg:oauth:2.0:oob"
|
||
|
|
OWNER = ("owner@hollo.test", "Hollo-Pasture-Pass-1")
|
||
|
|
VIS = {"public": "public", "unlisted": "unlisted", "private": "followers", "direct": "direct"}
|
||
|
|
|
||
|
|
|
||
|
|
class Hollo(MastodonApi):
|
||
|
|
platform = "hollo"
|
||
|
|
caps = MastodonApi.caps | {"quote", "react"}
|
||
|
|
|
||
|
|
def __init__(self, host):
|
||
|
|
super().__init__(host)
|
||
|
|
self._cookies = {}
|
||
|
|
|
||
|
|
def _form(self, method, path, form=None):
|
||
|
|
headers = {"Accept": "text/html,*/*", "Origin": self.base}
|
||
|
|
if self._cookies:
|
||
|
|
headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in self._cookies.items())
|
||
|
|
r = self.http.request(method, self.base + path, headers=headers, form=form)
|
||
|
|
for k, v in r.headers:
|
||
|
|
if k.lower() == "set-cookie":
|
||
|
|
name, _, value = v.split(";")[0].partition("=")
|
||
|
|
self._cookies[name.strip()] = value.strip()
|
||
|
|
return r
|
||
|
|
|
||
|
|
def _login(self):
|
||
|
|
if "login" not in self._cookies:
|
||
|
|
self._form("POST", "/login", {"email": OWNER[0], "password": OWNER[1]})
|
||
|
|
|
||
|
|
def provision(self, accounts):
|
||
|
|
self._login()
|
||
|
|
existing = {r["handle"].split("@")[1] for r in podman.psql("hollo", "select handle from accounts where handle like '@%'")
|
||
|
|
if r["handle"].count("@") >= 2}
|
||
|
|
for a in accounts:
|
||
|
|
if a.username in existing:
|
||
|
|
continue
|
||
|
|
self._form("POST", "/accounts", {"username": a.username, "name": a.name or a.username, "bio": a.bio or "",
|
||
|
|
"discoverable": "on", "language": a.lang or "en", "visibility": "public", "themeColor": "azure",
|
||
|
|
**({"protected": "on"} if a.locked else {})})
|
||
|
|
app = self.http.post(self.base + "/api/v1/apps", ok={200}, form={
|
||
|
|
"client_name": "pasture-town", "redirect_uris": OOB, "scopes": "read write follow"}).json()
|
||
|
|
return [self.session_from_token(a, self._token(app, a.username)) for a in accounts]
|
||
|
|
|
||
|
|
def _token(self, app, username):
|
||
|
|
query = urllib.parse.urlencode({"client_id": app["client_id"], "redirect_uri": OOB, "response_type": "code",
|
||
|
|
"scope": "read write follow"})
|
||
|
|
page = self._form("GET", f"/oauth/authorize?{query}").text
|
||
|
|
form = dict((k, html.unescape(v)) for k, v in re.findall(r'<input type="hidden" name="([^"]+)" value="([^"]*)"', page))
|
||
|
|
account = None
|
||
|
|
for block in re.findall(r'<label[^>]*>(.*?)</label>', page, re.S):
|
||
|
|
if f"@{username}@" in block:
|
||
|
|
found = re.search(r'name="account_id"[^>]*value="([^"]+)"|value="([^"]+)"[^>]*name="account_id"', block)
|
||
|
|
if found:
|
||
|
|
account = found.group(1) or found.group(2)
|
||
|
|
if account is None:
|
||
|
|
raise RuntimeError(f"Hollo's consent page offers no account @{username}")
|
||
|
|
form.update({"account_id": account, "decision": "allow"})
|
||
|
|
r = self._form("POST", "/oauth/authorize", form)
|
||
|
|
location = r.header("Location") or ""
|
||
|
|
code = urllib.parse.parse_qs(urllib.parse.urlsplit(location).query).get("code", [None])[0]
|
||
|
|
if code is None:
|
||
|
|
found = re.search(r'<code[^>]*>([^<]+)</code>', r.text)
|
||
|
|
code = found.group(1).strip() if found else None
|
||
|
|
if code is None:
|
||
|
|
raise RuntimeError(f"Hollo gave @{username} no authorization code ({r.status})")
|
||
|
|
return self.http.post(self.base + "/oauth/token", ok={200}, form={
|
||
|
|
"grant_type": "authorization_code", "code": code, "client_id": app["client_id"],
|
||
|
|
"client_secret": app["client_secret"], "redirect_uri": OOB}).json()["access_token"]
|
||
|
|
|
||
|
|
def update_profile(self, s, account):
|
||
|
|
# Hollo has no update_credentials for fields and images beyond its own forms; name, bio and lock are enough here
|
||
|
|
form = {"display_name": account.name, "note": account.bio, "locked": account.locked}
|
||
|
|
self.api(s, "PATCH", "/api/v1/accounts/update_credentials", ok={200}, form=form)
|
||
|
|
|
||
|
|
def post(self, s, spec):
|
||
|
|
"""Hollo reads a status as JSON (its poll is a nested object a form cannot carry)."""
|
||
|
|
from dialects.base import Made, Unsupported
|
||
|
|
if spec.kind not in ("note", "image", "video", "audio"):
|
||
|
|
raise Unsupported(self.platform, f"post a {spec.kind}")
|
||
|
|
form = self.status_form(s, spec)
|
||
|
|
body = {k: v for k, v in form.items() if not k.startswith("poll[")}
|
||
|
|
if spec.poll:
|
||
|
|
body["poll"] = {"options": spec.poll["options"], "expires_in": spec.poll.get("expires_in", 86400),
|
||
|
|
"multiple": bool(spec.poll.get("multiple"))}
|
||
|
|
status = self.api_json(s, "POST", "/api/v1/statuses", json=body)
|
||
|
|
return Made(status["uri"], status["id"], status.get("url"))
|
||
|
|
|
||
|
|
def vote(self, s, uri, choices):
|
||
|
|
sid = self.local_status_id(s, uri) or self.resolve(s, uri)
|
||
|
|
poll = (self.api_json(s, "GET", f"/api/v1/statuses/{sid}").get("poll") or {})
|
||
|
|
if not poll:
|
||
|
|
raise LookupError(f"{self.host} shows no poll on {uri}")
|
||
|
|
self.api(s, "POST", f"/api/v1/polls/{poll['id']}/votes", ok={200}, json={"choices": choices})
|
||
|
|
|
||
|
|
def react(self, s, uri, emoji):
|
||
|
|
sid = self.local_status_id(s, uri) or self.resolve(s, uri)
|
||
|
|
self.api(s, "PUT", f"/api/v1/statuses/{sid}/emoji_reactions/{urllib.parse.quote(emoji)}", ok={200})
|
||
|
|
|
||
|
|
def _rows(self, uris):
|
||
|
|
if not uris:
|
||
|
|
return {}
|
||
|
|
rows = podman.psql("hollo", """
|
||
|
|
select p.id::text as local_id, p.iri as uri, p.visibility::text as visibility, p.content_html as text,
|
||
|
|
p.summary as cw, p.updated > p.published as edited, r.iri as parent_uri,
|
||
|
|
(select count(*) from likes l where l.post_id = p.id) as likes,
|
||
|
|
p.shares_count as boosts, p.replies_count as replies,
|
||
|
|
(select array_agg(o.votes_count order by o.index) from poll_options o where o.poll_id = p.poll_id) as votes,
|
||
|
|
(select json_object_agg(e.emoji, e.n) from (select emoji, count(*) n from reactions x where x.post_id = p.id
|
||
|
|
group by emoji) e) as reactions
|
||
|
|
from posts p left join posts r on r.id = p.reply_target_id
|
||
|
|
where p.sharing_id is null and p.iri = any(string_to_array(:'p1', ' '))""", " ".join(uris))
|
||
|
|
return {r["uri"]: Stored(True, False, r["local_id"], VIS.get(r["visibility"], r["visibility"]), r["text"], r["cw"],
|
||
|
|
bool(r["edited"]), r["parent_uri"], r["likes"], r["boosts"], r["replies"], r["votes"], r["reactions"] or {}, r)
|
||
|
|
for r in rows}
|