113 lines
5.5 KiB
Python
113 lines
5.5 KiB
Python
"""Friendica 2026.05: accounts made by its console as peers/friendica.sh makes fruser ("soapbox" pages, which take
|
|||
|
|
followers without asking or following back, unless locked; each one's outbox read once so that a first Follow does not make Friendica recurse), its
|
||
|
|
Mastodon API with HTTP Basic credentials (a session's token is "nick:password"), objects read from its MySQL
|
||
|
|
(`friendica`, the `post-view` view by uri). An edit through its Mastodon API never federates, so edits go through its
|
||
|
|
web editor, signed in with a cookie. Its API says "private" for a DM too: Friendica keeps both as private posts."""
|
||
|
|
import base64
|
||
|
|
|
||
|
|
from core import podman
|
||
|
|
from dialects.base import Session, Stored, Unsupported
|
||
|
|
from dialects.mastodon_api import MastodonApi
|
||
|
|
|
||
|
|
CONTAINER = "pasture-friendica"
|
||
|
|
DB = "friendica"
|
||
|
|
# post-view.private, as its Mastodon API names it
|
||
|
|
VIS = {0: "public", 1: "followers", 2: "unlisted"}
|
||
|
|
|
||
|
|
|
||
|
|
def basic(token):
|
||
|
|
return "Basic " + base64.b64encode(token.encode()).decode()
|
||
|
|
|
||
|
|
|
||
|
|
class Friendica(MastodonApi):
|
||
|
|
platform = "friendica"
|
||
|
|
caps = frozenset({"post", "reply", "cw", "media", "like", "boost", "bookmark", "follow", "block", "mute", "dm", "delete",
|
||
|
|
"edit", "profile"})
|
||
|
|
|
||
|
|
def __init__(self, host):
|
||
|
|
super().__init__(host)
|
||
|
|
self._cookies = {} # nick -> session cookie of its web editor
|
||
|
|
|
||
|
|
def api(self, s, method, path, ok=None, **kw):
|
||
|
|
headers = kw.pop("headers", {})
|
||
|
|
if s is not None:
|
||
|
|
headers["Authorization"] = basic(s.token)
|
||
|
|
return self.http.request(method, self.base + path, headers=headers, ok=ok, template=path.split("?")[0], **kw)
|
||
|
|
|
||
|
|
def session_from_token(self, account, token):
|
||
|
|
me = self.api_json(None, "GET", "/api/v1/accounts/verify_credentials", headers={"Authorization": basic(token)})
|
||
|
|
return Session(account, token, me["id"], self.actor_uri(account.username) or me.get("url"))
|
||
|
|
|
||
|
|
def flag(self, value):
|
||
|
|
# its API reads `locked` as a number: "true" is 0, unlocked
|
||
|
|
return 1 if value else 0
|
||
|
|
|
||
|
|
def _console(self, *args):
|
||
|
|
return podman.run("exec", "-u", "www-data", CONTAINER, "php", "/var/www/html/bin/console.php", *args, check=False)
|
||
|
|
|
||
|
|
def provision(self, accounts):
|
||
|
|
existing = set(podman.mysql_json(DB, "select json_arrayagg(nickname) from user") or [])
|
||
|
|
for a in accounts:
|
||
|
|
if a.username not in existing:
|
||
|
|
self._console("user", "add", a.username, a.username, f"{a.username}@{self.host}", "en", "")
|
||
|
|
self._console("user", "password", a.username, a.password)
|
||
|
|
# the API makes an unlocked account a soapbox page (it takes followers without asking and follows nobody
|
||
|
|
# back) and a locked one a normal page, which holds each request
|
||
|
|
self.http.request("PATCH", self.base + "/api/v1/accounts/update_credentials", ok={200},
|
||
|
|
headers={"Authorization": basic(f"{a.username}:{a.password}")}, form={"locked": self.flag(a.locked)})
|
||
|
|
self.http.request("GET", self.base + f"/outbox/{a.username}", headers={"Accept": "application/activity+json"})
|
||
|
|
return [self.session_from_token(a, f"{a.username}:{a.password}") for a in accounts]
|
||
|
|
|
||
|
|
def post(self, s, spec):
|
||
|
|
if spec.poll:
|
||
|
|
raise Unsupported(self.platform, "post a poll")
|
||
|
|
return super().post(s, spec)
|
||
|
|
|
||
|
|
def bookmark(self, s, uri):
|
||
|
|
row = self._rows([uri]).get(uri)
|
||
|
|
if row and row.parent_uri:
|
||
|
|
raise Unsupported(self.platform, "bookmark a reply") # "Only starting posts can be bookmarked"
|
||
|
|
super().bookmark(s, uri)
|
||
|
|
|
||
|
|
def edit(self, s, uri, spec):
|
||
|
|
sid = self._own(s, uri)
|
||
|
|
text = spec.text
|
||
|
|
for acct in spec.mentions:
|
||
|
|
if f"@{acct}" not in text:
|
||
|
|
text = f"@{acct} {text}"
|
||
|
|
item = podman.mysql_json(DB, f"select id from `post-user` where `uri-id` = {int(sid)} and uid = "
|
||
|
|
f"(select uid from user where nickname = {podman.mysql_quote(s.account.username)})")
|
||
|
|
form = {"post_id": item, "body": text}
|
||
|
|
if spec.cw:
|
||
|
|
form["summary"] = spec.cw
|
||
|
|
self._web(s, "POST", "/item", form)
|
||
|
|
|
||
|
|
def _web(self, s, method, path, form):
|
||
|
|
nick, password = s.token.split(":", 1)
|
||
|
|
if nick not in self._cookies:
|
||
|
|
r = self.http.request("POST", self.base + "/login", form={"auth-params": "login", "username": nick, "password": password})
|
||
|
|
self._cookies[nick] = "; ".join(v.split(";")[0] for k, v in r.headers if k.lower() == "set-cookie")
|
||
|
|
return self.http.request(method, self.base + path, headers={"Cookie": self._cookies[nick]}, form=form)
|
||
|
|
|
||
|
|
def _rows(self, uris):
|
||
|
|
if not uris:
|
||
|
|
return {}
|
||
|
|
# (its uri columns are binary, which json_object would give as base64)
|
||
|
|
listed = ", ".join(podman.mysql_quote(u) for u in uris)
|
||
|
|
rows = podman.mysql_json(DB, f"""
|
||
|
|
select json_arrayagg(json_object(
|
||
|
|
'uri', convert(p.uri using utf8mb4), 'local_id', p.`uri-id`, 'private', p.private, 'deleted', p.deleted, 'gravity', p.gravity,
|
||
|
|
'text', p.body, 'cw', p.`content-warning`, 'title', p.title, 'edited', p.edited > p.created,
|
||
|
|
'parent', case when p.gravity = 6 then convert(p.`thr-parent` using utf8mb4) end,
|
||
|
|
'likes', (select count(*) from `post-view` a where a.`thr-parent-id` = p.`uri-id` and a.gravity = 3
|
||
|
|
and a.deleted = 0 and a.verb like '%/like'),
|
||
|
|
'boosts', (select count(*) from `post-view` a where a.`thr-parent-id` = p.`uri-id` and a.gravity = 3
|
||
|
|
and a.deleted = 0 and (a.verb like '%/share' or a.verb like '%#Announce')),
|
||
|
|
'replies', (select count(*) from `post-view` a where a.`thr-parent-id` = p.`uri-id` and a.gravity = 6 and a.deleted = 0)))
|
||
|
|
from `post-view` p where p.uri in ({listed}) and p.gravity in (0, 6)""") or []
|
||
|
|
out = {}
|
||
|
|
for r in rows:
|
||
|
|
out[r["uri"]] = Stored(True, bool(r["deleted"]), str(r["local_id"]), VIS.get(r["private"], str(r["private"])), r["text"],
|
||
|
|
r["cw"] or None, bool(r["edited"]), r["parent"], r["likes"], r["boosts"], r["replies"], None, {}, r)
|
||
|
|
return out
|
||
|
|
|