import argparse import enum import errno import logging import requests import sys import stat from datetime import datetime as dtp from fuse import ( FUSE, Operations, FuseOSError, LoggingMixIn, fuse_exit, fuse_get_context, ) class APIChoice(enum.Enum): ACTIVITYPUB = "ActivityPub" MASTODON = "Mastodon" api_url_template_ap = "https://{server}/users/{user}/outbox?min_id=0&page=true" class Status(object): def __init__(self, id: str, content: str, published: str): self.id = id self.content = content self.published = published class HelloWorld(Operations, LoggingMixIn): def __init__(self, api: APIChoice, server: str, user: str): self.statuses: list[Status] = [] self.fd = 0 self.api = api self.server = server self.user = user def getattr(self, path, fh=None): (uid, gid, _) = fuse_get_context() if path == "/": return { "st_mode": (stat.S_IFDIR | 0o700), # Directory "st_nlink": 2, "st_uid": uid, "st_gid": gid, } found = next((s for s in self.statuses if "/" + s.id == path), None) if found: published_dt = dtp.fromisoformat(found.published) pubunix = published_dt.timestamp() return { "st_mode": (stat.S_IFREG | 0o400), "st_size": len(found.content.encode("utf8")), "st_nlink": 1, "st_uid": uid, "st_gid": gid, "st_ctime": pubunix, "st_mtime": pubunix, } raise FuseOSError(errno.ENOENT) def load_statuses(self): url = api_url_template_ap.format(server=self.server, user=self.user) res = requests.get(url) res.raise_for_status() stats = res.json() logging.debug(f"Status: ${stats['id']}") self.statuses = [ Status( s["object"]["id"].split("/")[-1], s["object"]["content"], s["object"]["published"], ) for s in stats["orderedItems"] ] pass def list_dir(self) -> list[str]: return [s.id for s in self.statuses] def readdir(self, path, fh): dir_entries = [] if path != "/": raise FuseOSError(errno.ENOENT) dir_entries = [".", ".."] if not self.statuses: self.load_statuses() dir_entries += self.list_dir() return dir_entries def open(self, path, flags): self.fd += 1 return self.fd def read(self, path, size, offset, fh): found = next(s for s in self.statuses if "/" + s.id == path) if found: return found.content.encode("utf8") raise FuseOSError(errno.ENOENT) def parse_arguments(): parser = argparse.ArgumentParser( description="Mount a read-only FUSE filesystem for ActivityPub or Mastodon" ) parser.add_argument( "mountpoint", help="The directory where the filesystem will be mounted" ) group = parser.add_mutually_exclusive_group(required=True) group.add_argument( "-a", "--activitypub", action="store_true", help="Use ActivityPub API" ) group.add_argument("-m", "--mastodon", action="store_true", help="Use Mastodon API") parser.add_argument("-s", "--server", required=True, help="The server/host URL") parser.add_argument( "-u", "--username", required=True, help="The username to fetch statuses for" ) args = parser.parse_args() if args.activitypub: args.api_choice = APIChoice.ACTIVITYPUB elif args.mastodon: args.api_choice = APIChoice.MASTODON else: parser.error("Must choose either ActivityPub or Mastodon API") if args.api_choice != APIChoice.ACTIVITYPUB: raise NotImplementedError("Not supported yet") return args def main(args): try: FUSE( HelloWorld(args.api_choice, args.server, args.username), args.mountpoint, nothreads=True, foreground=True, ) except: fuse_exit() raise if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) args = parse_arguments() main(args)