├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pylintrc ├── .vscode └── settings.json ├── LICENSE.md ├── NOTES.md ├── Pipfile ├── Pipfile.lock ├── README.md ├── backendpy ├── __init__.py ├── client.py ├── factory.py ├── models.py ├── other.py ├── server.py ├── shared.py ├── smap.py ├── telemetry.py ├── test │ ├── __init__.py │ ├── test_datastore.py │ ├── test_factory.py │ ├── test_server.py │ └── test_utils.py └── utils.py ├── dev.env ├── newprod.env ├── package.json ├── packages ├── client │ ├── .env.development │ ├── .env.devstage │ ├── .env.production │ ├── .gitignore │ ├── README.md │ ├── config-overrides.js │ ├── jest.config.js │ ├── package-lock.json │ ├── package.json │ ├── public │ │ ├── 404.html │ │ ├── favicon.ico │ │ ├── googleac16fe8226e8fe6e.html │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ ├── src │ │ ├── AboutDialog.tsx │ │ ├── AnalyticsDialog.css │ │ ├── AnalyticsDialog.tsx │ │ ├── App.css │ │ ├── App.test.js │ │ ├── App.tsx │ │ ├── Callback.tsx │ │ ├── ComlinkHook.ts │ │ ├── Controls.test.js │ │ ├── Controls.tsx │ │ ├── CreateListDialog.tsx │ │ ├── DeleteListDialog.tsx │ │ ├── ExportListDialog.test.js │ │ ├── ExportListDialog.tsx │ │ ├── ImportListDialog.tsx │ │ ├── LoginForm.css │ │ ├── LoginForm.js │ │ ├── MainApp.tsx │ │ ├── MainPage.js │ │ ├── Manager.css │ │ ├── Manager.tsx │ │ ├── TimeoutDialog.tsx │ │ ├── TopBar.test.js │ │ ├── TopBar.tsx │ │ ├── UsersTable.css │ │ ├── UsersTable.test.js │ │ ├── UsersTable.tsx │ │ ├── __mocks__ │ │ │ ├── ComlinkHook.ts │ │ │ └── windowutils.ts │ │ ├── clientworker.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── logo.svg │ │ ├── reportWebVitals.js │ │ ├── serverworker.ts │ │ ├── setupTests.js │ │ ├── windowutils.ts │ │ └── workerbase.ts │ ├── tsconfig.json │ ├── tsconfig.test.json │ └── tslint.json ├── server │ ├── .gitignore │ ├── analytics_server.test.js │ ├── analytics_server.ts │ ├── datastore.ts │ ├── factory.ts │ ├── follow_server.test.js │ ├── follow_server.ts │ ├── info_server.test.js │ ├── info_server.ts │ ├── jest.config.js │ ├── listcrud_server.test.js │ ├── listcrud_server.ts │ ├── package.json │ ├── ts-jest.config.cjs │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── utils.ts ├── shared │ ├── analytics.ts │ ├── follow.ts │ ├── index.ts │ ├── info.ts │ ├── listcrud.ts │ ├── package.json │ ├── tsconfig.json │ ├── types.ts │ └── utils.ts ├── tsconfig.base.json └── tsconfig.json ├── prod.env ├── serverless.yml ├── serverless ├── cfwebsite.yml ├── functions.yml ├── s3policy.yml ├── s3sync.yml └── tables.yml └── yarn.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - "**" 5 | 6 | jobs: 7 | frontend_test: 8 | name: Run frontend tests 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Use node 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 19 17 | 18 | - name: npm install, build, test 19 | run: | 20 | yarn install 21 | yarn build:packages 22 | yarn test 23 | 24 | backend_test: 25 | name: Run backend tests 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: actions/setup-python@v4 31 | with: 32 | python-version: "3.9" 33 | - name: Install and test 34 | run: | 35 | pip3 install pipenv 36 | pipenv install 37 | pipenv run test 38 | 39 | check: 40 | runs-on: ubuntu-latest 41 | # the jobs need to pass 42 | needs: [frontend_test, backend_test] 43 | # use always() so if previous jobs fail, this job will still run 44 | if: always() 45 | steps: 46 | - uses: maxisam/create-summary-check-status@v1.1.0 47 | with: 48 | jobResults: | 49 | ${{ needs.frontend_test.result }} 50 | ${{ needs.backend_test.result }} 51 | authToken: ${{secrets.GITHUB_TOKEN}} 52 | # name showing on the check 53 | checkName: CI PASSED 54 | # if any result in job1,job2 is [failure,cacelled], the check will be failure, otherwise the check will be success 55 | failureStates: | 56 | failure 57 | cancelled 58 | # for PR or Push 59 | sha: ${{ github.event.pull_request.head.sha || github.sha }} 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | cfg.py 3 | *.secret 4 | .serverless 5 | debug_log.txt 6 | 7 | # A local place for archived items 8 | archive 9 | 10 | # Local notes 11 | TODO.md 12 | 13 | # No dist / build folders 14 | dist 15 | .build 16 | 17 | # No tmp folders 18 | tmp 19 | xxx 20 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | init-hook='import sys; sys.path.append("backendpy")' 3 | 4 | [MESSAGES CONTROL] 5 | 6 | disable=invalid-name,unspecified-encoding,unnecessary-lambda-assignment,duplicate-code,too-many-locals,too-few-public-methods,too-many-arguments,too-many-return-statements -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2022 Andrew Beers 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | # Notes on deployment 2 | 3 | Things I did for deployment to my own domain: 4 | 5 | - Manually purchased a domain via Route 53. 6 | 7 | - Manually created a certificate for use with the domain. This certificate had 8 | to be created in `us-east-1`, because of some limitations in Cloudfront, 9 | documented 10 | [here](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cnames-and-https-requirements.html). 11 | 12 | - Used `serverless-domain-manager` to put my Lambda functions behind the custom domain. 13 | 14 | - Used `serverless-s3-sync` for synchronizing a built website to S3 and configuring it. 15 | 16 | - Used `serverless-website-domain` to put the above behind a custom domain 17 | 18 | - Used `serverless-cloudfront-invalidate` to ensure website content was invalidated from Cloudfront on publish. 19 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | black = "*" 8 | boto3 = "*" 9 | pynamodb = "*" 10 | pytz = "*" 11 | requests = "*" 12 | decorator = "*" 13 | "mastodon.py" = "*" 14 | sourcemap = "*" 15 | 16 | [dev-packages] 17 | pylint = "*" 18 | 19 | [scripts] 20 | format = "black backendpy" 21 | lint = "pylint backendpy" 22 | test = "python -m unittest discover backendpy" 23 | 24 | [requires] 25 | python_version = "3.9" 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # List manager for Mastodon 2 | 3 | A simple list manager, bulit in the style of Twitter List Manager. 4 | 5 | This is running live [HERE](https://www.mastodonlistmanager.org). 6 | 7 | ## Development Setup 8 | 9 | To get ready for development, ensure all packages are installed: 10 | 11 | ``` 12 | pipenv install 13 | npm i 14 | cd list-manager 15 | npm i 16 | ``` 17 | 18 | Also ensure Node installed. I have developed this with Node 19.7 19 | 20 | If you make changes in `packages/shared`, which contains shared Typescript 21 | libraries, you'll have to run `yarn run build:packages` for the other workspaces 22 | to see interface changes. 23 | 24 | ## Running locally 25 | 26 | To run locally, you'll first have to do a deploy of the backend to create the 27 | DynamoDB tables for tracking authentication. Using an account that has enough 28 | permissions (Serverless recommends an account with full admin access): 29 | 30 | ``` 31 | AWS_PROFILE= sls deploy 32 | ``` 33 | 34 | Then you can run things locally: 35 | 36 | ``` 37 | AWS_PROFILE= sls offline 38 | ``` 39 | 40 | Run the front end via yarn 41 | 42 | ``` 43 | yarn start 44 | ``` 45 | 46 | To use, go to `http://localhost:3000/main`, which will allow you to enter your 47 | server name to start the login process. 48 | 49 | ## Architectural notes 50 | 51 | The app is mostly a Single Page Application, with all of the traffic to and from 52 | the Mastodon server being handled by a Web Worker (via the very fine 53 | [Comlink](https://github.com/GoogleChromeLabs/comlink) library) to keep secrets 54 | from prying eyes. 55 | 56 | Authentication still happens mostly via a backend process, deployed to AWS 57 | Lambda, written in Python. This allows me to minimize the "applications" that 58 | are registered on a given Mastodon server, and protect the client IDs and 59 | secrets associated with those applications. After authentication happens, the 60 | token is disclosed to the client and further API calls are made directly by the 61 | Javascript app. The token is discarded when the page is closed (it is not 62 | persisted to local storage or stored as a cookie). 63 | 64 | The core work of the List Manager (list creation, account adds and deletes, 65 | etc.) are implemented in Typescript and run directly in the browser. They can 66 | also run via published Lambda functions via the server (though there is no 67 | user-friendly way yet to switch between these modes) 68 | 69 | The app reqeusts hopefully a minimal set of scopes from Mastodon: `read:lists`, 70 | `read:follows`, `read:accounts`, `write:lists`, `write:follows` to minimize the 71 | things it can change about the account. 72 | 73 | There are three configured stages for AWS: 74 | 75 | - `dev`, which mostly contains the DynamoDB tables and lambda functions 76 | 77 | - `devstage`, which contains everything, including the web app and its hosted 78 | domain. 79 | 80 | - `newprod`, which contains everything, including the web app and its hosted 81 | public-facing domain (https://www.mastodonlistmanager.org) 82 | -------------------------------------------------------------------------------- /backendpy/__init__.py: -------------------------------------------------------------------------------- 1 | """ Make module loading work in a subdirectory """ 2 | import os 3 | import sys 4 | 5 | sys.path.append(os.path.dirname(__file__)) 6 | -------------------------------------------------------------------------------- /backendpy/client.py: -------------------------------------------------------------------------------- 1 | """Functions supporting client-side implementation""" 2 | 3 | import json 4 | import logging 5 | 6 | from mastodon import ( 7 | MastodonAPIError, 8 | ) 9 | 10 | from factory import MastodonFactory, NoAuthInfo 11 | from models import Datastore 12 | from shared import callback_helper 13 | from utils import err_response, response 14 | 15 | 16 | def clientlogout(event, _): 17 | """Logs out, given an oauth token and a domain""" 18 | 19 | body = json.loads(event["body"]) 20 | token = body.get("token", None) 21 | domain = body.get("domain", None) 22 | 23 | # Log out of the mastodon server 24 | try: 25 | cfg = Datastore.get_host_config(domain) 26 | if cfg is None: 27 | raise NoAuthInfo 28 | 29 | mastodon = MastodonFactory.from_config(cfg, token=token) 30 | mastodon.revoke_access_token() 31 | 32 | except MastodonAPIError as e: 33 | logging.error("ERROR - other API error: %s", str(e)) 34 | return err_response("ERROR - API error") 35 | except NoAuthInfo: 36 | return err_response(f"ERROR no host config found for {domain}") 37 | 38 | return response(json.dumps({"status": "OK"})) 39 | 40 | 41 | def clientcallback(event, context): 42 | """oAuth callback for the client-side version of the API""" 43 | 44 | def finish(token): 45 | return response(json.dumps({"status": "OK", "token": token})) 46 | 47 | return callback_helper(event, context, finish) 48 | -------------------------------------------------------------------------------- /backendpy/factory.py: -------------------------------------------------------------------------------- 1 | """Factory class for Mastodon instances""" 2 | 3 | from mastodon import ( 4 | Mastodon, 5 | ) 6 | from models import Datastore 7 | 8 | # Our User Agent 9 | USER_AGENT = "mastodonlistmanager" 10 | 11 | 12 | class NoAuthInfo(Exception): 13 | """Internal exception class for when we don't have auth info""" 14 | 15 | 16 | class NotMastodon(Exception): 17 | """Internal exception for when we think we don't have a Mastodon connection""" 18 | 19 | 20 | class MastodonFactory: 21 | """Factory class for Mastodon instances""" 22 | 23 | @classmethod 24 | def from_cookie(cls, cookie): 25 | """Construct a mastodon object from the cookie""" 26 | authinfo = Datastore.get_auth(cookie) 27 | if authinfo is None: 28 | raise NoAuthInfo 29 | 30 | # Get the configuration that we need 31 | cfg = Datastore.get_host_config(authinfo.domain) 32 | if cfg is None: 33 | raise NoAuthInfo 34 | 35 | return MastodonFactory.from_config(cfg, token=authinfo.token) 36 | 37 | @classmethod 38 | def from_config(cls, cfg, token=None): 39 | """Create a Mastodon interface from a HostConfig object""" 40 | mastodon = Mastodon( 41 | client_id=cfg.client_id, 42 | client_secret=cfg.client_secret, 43 | access_token=token, 44 | user_agent=USER_AGENT, 45 | api_base_url=f"https://{cfg.host}", 46 | ) 47 | # If the version check failed, then most likely this is an unusable 48 | # instance. This can happen when e.g. we are blocked by CloudFlare 49 | if not mastodon.version_check_worked: 50 | raise NotMastodon 51 | 52 | return mastodon 53 | -------------------------------------------------------------------------------- /backendpy/models.py: -------------------------------------------------------------------------------- 1 | """Models for DynamoDB access""" 2 | 3 | import datetime 4 | import hashlib 5 | import os 6 | import time 7 | 8 | from pynamodb.models import Model 9 | from pynamodb.indexes import GlobalSecondaryIndex, AllProjection 10 | from pynamodb.attributes import UnicodeAttribute, NumberAttribute 11 | 12 | 13 | class MyModel(Model): 14 | """An extension to pynamodb.Model""" 15 | 16 | @classmethod 17 | def lookup(cls, key): 18 | """Lookup a single value or return none""" 19 | res = list(cls.query(key)) 20 | if len(res) == 0: 21 | return None 22 | return res[0] 23 | 24 | 25 | class AuthTable(MyModel): 26 | """ 27 | Auth information for a user 28 | """ 29 | 30 | class Meta: 31 | """Metadata for this table""" 32 | 33 | table_name = os.environ.get("TABLE_AUTH", "list-manager-auth-dev") 34 | region = "us-west-2" 35 | 36 | key = UnicodeAttribute(hash_key=True) 37 | token = UnicodeAttribute() 38 | domain = UnicodeAttribute() 39 | expires_at = NumberAttribute() 40 | 41 | 42 | class AllowedHost(MyModel): 43 | """ 44 | A list of allowed hosts 45 | """ 46 | 47 | class Meta: 48 | """Metadata for this table""" 49 | 50 | table_name = os.environ.get("TABLE_ALLOWED", "list-manager-allowedHosts-dev") 51 | region = "us-west-2" 52 | 53 | host = UnicodeAttribute(hash_key=True) 54 | 55 | 56 | class BlockedHost(MyModel): 57 | """ 58 | A list of blocked hosts 59 | """ 60 | 61 | class Meta: 62 | """Metadata for this table""" 63 | 64 | table_name = os.environ.get("TABLE_BLOCKED", "list-manager-blockedHosts-dev") 65 | region = "us-west-2" 66 | 67 | hash = UnicodeAttribute(hash_key=True) 68 | host = UnicodeAttribute() 69 | timestamp = UnicodeAttribute() 70 | 71 | 72 | class HostConfig(MyModel): 73 | """ 74 | A list of allowed hosts 75 | """ 76 | 77 | class Meta: 78 | """Metadata for this table""" 79 | 80 | table_name = os.environ.get("TABLE_HOSTCFG", "list-manager-hostConfig-dev") 81 | region = "us-west-2" 82 | 83 | host = UnicodeAttribute(hash_key=True) 84 | client_id = UnicodeAttribute() 85 | client_secret = UnicodeAttribute() 86 | 87 | 88 | def get_expire(): 89 | """Compute a 1-day expire time""" 90 | now = datetime.datetime.now() 91 | expire = now + datetime.timedelta(days=1) 92 | unix = time.mktime(expire.timetuple()) 93 | return unix 94 | 95 | 96 | class Datastore: 97 | """A mockable interface to the above ORM classes""" 98 | 99 | @classmethod 100 | def get_auth(cls, cookie): 101 | """Given a cookie, returns any auth associated with it or None""" 102 | return AuthTable.lookup(cookie) 103 | 104 | @classmethod 105 | def set_auth(cls, cookie, token, domain): 106 | """Sets a token and domain for a given cookie""" 107 | authinfo = AuthTable( 108 | cookie, token=token, domain=domain, expires_at=get_expire() 109 | ) 110 | authinfo.save() 111 | 112 | @classmethod 113 | def drop_auth(cls, cookie): 114 | """Drop the token associated with the cookie""" 115 | authinfo = cls.get_auth(cookie) 116 | if authinfo is not None: 117 | authinfo.delete() 118 | 119 | @classmethod 120 | def is_allowed(cls, host): 121 | """Returns true if this host is allowed""" 122 | 123 | # Host is blocked if on the blocklist, unless it is also on the allow 124 | # list. 125 | lhost = host.lower().strip() 126 | allow = AllowedHost.lookup(lhost) 127 | if allow is not None: 128 | return True 129 | 130 | # Hosts are stored as a hash 131 | m = hashlib.sha256() 132 | m.update(lhost.encode("utf-8")) 133 | sha = m.hexdigest() 134 | print(sha) 135 | block = BlockedHost.lookup(sha) 136 | return block is None 137 | 138 | @classmethod 139 | def block_host(cls, sha, host): 140 | """Adds an entry to the blocked hosts list""" 141 | bh = BlockedHost(sha, host=host) 142 | bh.save() 143 | 144 | @classmethod 145 | def batch_block_host(cls, hosts, ts): 146 | """Adds multiple entries to the blocked host list""" 147 | 148 | # First, write/update the new hosts 149 | with BlockedHost.batch_write() as batch: 150 | items = [ 151 | BlockedHost(x["digest"], host=x["domain"], timestamp=str(ts)) 152 | for x in hosts 153 | ] 154 | for item in items: 155 | batch.save(item) 156 | # Manual rate limiting. This is dumb, but batch_write() doesn't 157 | # support rate-limiting in pynamodb. 158 | time.sleep(0.1) 159 | 160 | # Now, query for and delete hosts that didn't get updated 161 | # Since this involves a scan, it will be expensive. But, the blocked table 162 | # is generally pretty small, so I'm not going to worry about it for now. 163 | cnt = 0 164 | for item in BlockedHost.scan(BlockedHost.timestamp != str(ts), rate_limit=1): 165 | item.delete() 166 | cnt = cnt + 1 167 | 168 | @classmethod 169 | def get_host_config(cls, host): 170 | """Returns configuration information for the host""" 171 | cfg = HostConfig.lookup(host) 172 | return cfg 173 | 174 | @classmethod 175 | def set_host_config(cls, host, client_id, client_secret): 176 | """Stores client ID and secret for the given host""" 177 | cfg = HostConfig(host, client_id=client_id, client_secret=client_secret) 178 | cfg.save() 179 | return cfg 180 | -------------------------------------------------------------------------------- /backendpy/other.py: -------------------------------------------------------------------------------- 1 | """Lambda routines that don't directly process requests""" 2 | 3 | import requests 4 | import time 5 | from models import Datastore 6 | 7 | 8 | def block_update(_event, _context): 9 | """Pulls a list of hosts to block from github and populates our blocked host 10 | table""" 11 | 12 | # NOTE: There doesn't seem to be a Mastodon.py method for this. 13 | resp = requests.get( 14 | "https://hachyderm.io/api/v1/instance/domain_blocks", timeout=60 15 | ) 16 | js = resp.json() 17 | ts = int(time.time()) 18 | Datastore.batch_block_host(js,ts) 19 | -------------------------------------------------------------------------------- /backendpy/server.py: -------------------------------------------------------------------------------- 1 | """Server auth backend for Mastodon List Manager""" 2 | 3 | import json 4 | import logging 5 | import uuid 6 | 7 | from mastodon import ( 8 | MastodonAPIError, 9 | ) 10 | 11 | from factory import MastodonFactory 12 | from models import Datastore 13 | from shared import callback_helper 14 | from utils import response, err_response 15 | 16 | # AWS doens't set a logging level, so set it here. 17 | logging.getLogger("root").setLevel(logging.INFO) 18 | # But don't log much from botocore 19 | logging.getLogger("botocore").setLevel(logging.ERROR) 20 | 21 | 22 | def get_cookie(event): 23 | """Retrieves the auth cookie from the event object""" 24 | headers = event.get("headers", {}) 25 | cookie = headers.get("authorization", None) 26 | return cookie 27 | 28 | 29 | def callback(event, context): 30 | """oAuth callback for the server-side version of the API""" 31 | 32 | def finish(token): 33 | params = event.get("queryStringParameters", {}) or {} 34 | domain = params.get("domain", "UNKNOWN") 35 | 36 | cookie = uuid.uuid4().urn 37 | 38 | Datastore.set_auth(cookie, token=token, domain=domain) 39 | 40 | return {"statusCode": 200, "body": json.dumps({"status": "OK", "auth": cookie})} 41 | 42 | return callback_helper(event, context, finish) 43 | 44 | 45 | def logout(event, _): 46 | """Logs out""" 47 | 48 | cookie = get_cookie(event) 49 | 50 | # Log out of the mastodon server 51 | try: 52 | mastodon = MastodonFactory.from_cookie(cookie) 53 | mastodon.revoke_access_token() 54 | 55 | # Dump the cookie 56 | Datastore.drop_auth(cookie) 57 | except MastodonAPIError as e: 58 | logging.error("ERROR - other API error: %s", str(e)) 59 | return err_response("ERROR - API error") 60 | 61 | return response(json.dumps({"status": "OK"})) 62 | -------------------------------------------------------------------------------- /backendpy/shared.py: -------------------------------------------------------------------------------- 1 | """Functions shared between server and client implementations""" 2 | 3 | import json 4 | import logging 5 | import os 6 | import requests 7 | 8 | from mastodon import ( 9 | Mastodon, 10 | MastodonAPIError, 11 | MastodonIllegalArgumentError, 12 | MastodonNetworkError, 13 | ) 14 | 15 | from factory import MastodonFactory, NoAuthInfo, NotMastodon, USER_AGENT 16 | from models import Datastore 17 | from utils import ( 18 | get_cookie, 19 | response, 20 | cleandomain, 21 | blocked_response, 22 | err_response, 23 | badhost_response, 24 | ) 25 | 26 | # The list of scopes that we need for our app 27 | # NOTE: If this of scopes changes, you'll have to remove items in the 28 | # hostsCfg table to allow them to be recreated. 29 | SCOPES = ["read:lists", "read:follows", "read:accounts", "write:lists", "write:follows"] 30 | 31 | 32 | def make_app(domain, redirect_url): 33 | """Creates a Mastodon app on a given host""" 34 | # Some web gateways will block requests without a User-Agent header 35 | # The Mastodon object can be constructed with an agent= parameter, but 36 | # the create_app class method doesn't support it. Create a session with the 37 | # correct header 38 | s = requests.Session() 39 | s.headers.update({"User-Agent": USER_AGENT}) 40 | (client_id, client_secret) = Mastodon.create_app( 41 | "Mastodon List Manager", 42 | scopes=SCOPES, 43 | redirect_uris=redirect_url, 44 | session=s, 45 | api_base_url=f"https://{domain}", 46 | ) 47 | return (client_id, client_secret) 48 | 49 | 50 | def make_redirect_url(_, domain): 51 | """Create a redirect URL based on the origin of the request""" 52 | redirect_base = os.environ.get("AUTH_REDIRECT", "http://localhost:3000") 53 | redirect = f"{redirect_base}/callback?domain={domain}" 54 | return redirect 55 | 56 | 57 | def auth(event, _): 58 | """ 59 | Handler for the start of an authentication flow. 60 | """ 61 | # First, see if we have an active session 62 | cookie = get_cookie(event) 63 | 64 | params = event.get("queryStringParameters", {}) or {} 65 | rawdomain = params.get("domain", None) 66 | domain = cleandomain(rawdomain) 67 | 68 | # Ignore the cookie if it belongs to some other domain 69 | if cookie is not None: 70 | authinfo = Datastore.get_auth(cookie) 71 | if authinfo is not None: 72 | if domain is None: 73 | domain = authinfo.domain 74 | elif authinfo.domain != domain: 75 | cookie = None 76 | 77 | if cookie is not None: 78 | try: 79 | test = MastodonFactory.from_cookie(cookie) 80 | test.me() 81 | logging.info("Already logged in") 82 | return {"statusCode": 200, "body": json.dumps({"status": "OK"})} 83 | except MastodonAPIError: 84 | # If here, we aren't logged in, so drop through to start the 85 | # oAuth flow. 86 | pass 87 | except NoAuthInfo: 88 | # If here, we didn't get a mastodon instance back, so start the 89 | # oAuth flow 90 | pass 91 | 92 | # If we don't have a domain here, then we have to bail 93 | if domain is None or domain == "": 94 | return badhost_response(domain) 95 | 96 | # See if this domain is allowed 97 | allow = Datastore.is_allowed(domain.lower()) 98 | if not allow: 99 | res = {"status": "not_allowed"} 100 | logging.info("auth: domain denied: %s", domain) 101 | return response(json.dumps(res)) 102 | 103 | logging.info("auth: starting OAuth path for %s", domain) 104 | 105 | # For now, we'll create the right redirect_url based on the event object. 106 | redirect_url = make_redirect_url(event, domain) 107 | 108 | cfg = Datastore.get_host_config(domain) 109 | 110 | if cfg is None: 111 | # Make an app 112 | logging.debug("auth: making app for %s", domain) 113 | try: 114 | (client_id, client_secret) = make_app(domain, redirect_url) 115 | logging.debug("auth: Made the app!") 116 | except MastodonNetworkError as e: 117 | # Log what the user typed with the error. 118 | print("mastodon network error") 119 | print(e) 120 | print(redirect_url) 121 | return badhost_response(rawdomain) 122 | 123 | cfg = Datastore.set_host_config( 124 | domain, client_id=client_id, client_secret=client_secret 125 | ) 126 | 127 | logging.debug("creating from config") 128 | try: 129 | mastodon = MastodonFactory.from_config(cfg) 130 | except NotMastodon: 131 | return blocked_response() 132 | 133 | logging.debug("created from config") 134 | 135 | url = mastodon.auth_request_url( 136 | scopes=SCOPES, 137 | redirect_uris=redirect_url, 138 | ) 139 | return response(json.dumps({"url": url})) 140 | 141 | 142 | def callback_helper(event, _, finish): 143 | """The callback method of the oAuth dance""" 144 | 145 | # Need to know the domain to complete the oauth handshake. 146 | params = event.get("queryStringParameters", {}) or {} 147 | domain = params.get("domain", "UNKNOWN") 148 | code = params.get("code") 149 | 150 | cfg = Datastore.get_host_config(domain) 151 | logging.debug("callback for %s", domain) 152 | 153 | # It shouldn't be possible to have cfg be None at this point, but 154 | # I have seen it happen. So, test for it. 155 | # 156 | if cfg is None: 157 | logging.error("callback_helper: cfg is None!") 158 | logging.error("Domain is %s", domain) 159 | return err_response("ERROR - no host config") 160 | 161 | mastodon = Mastodon( 162 | client_id=cfg.client_id, 163 | client_secret=cfg.client_secret, 164 | user_agent=USER_AGENT, 165 | api_base_url=f"https://{domain}", 166 | ) 167 | 168 | # For now, we'll create the right redirect_url based on the event object. 169 | redirect_url = make_redirect_url(event, domain) 170 | 171 | token = None 172 | try: 173 | token = mastodon.log_in( 174 | code=code, 175 | redirect_uri=redirect_url, 176 | scopes=SCOPES, 177 | ) 178 | except MastodonIllegalArgumentError: 179 | logging.error( 180 | "MastodonIllegalArgumentError, code = %s, redirect_uri = %s, domain = %s", 181 | code, 182 | redirect_url, 183 | domain, 184 | ) 185 | return err_response("ERROR - illegal argument") 186 | 187 | return finish(token) 188 | -------------------------------------------------------------------------------- /backendpy/smap.py: -------------------------------------------------------------------------------- 1 | """Source mapping for JS""" 2 | import re 3 | 4 | import requests 5 | import sourcemap 6 | 7 | cachedurls = {} 8 | 9 | 10 | def get_info(url, lineno, column): 11 | """Gets info about a particular URL in a stack trace""" 12 | if url not in cachedurls: 13 | cachedurls[url] = requests.get(url, timeout=10).text 14 | js = cachedurls[url] 15 | path = sourcemap.discover(js) 16 | parts = url.split("/") 17 | urlbase = "/".join(parts[0 : len(parts) - 1]) 18 | mapurl = urlbase + "/" + path 19 | if mapurl not in cachedurls: 20 | cachedurls[mapurl] = requests.get(mapurl, timeout=10).text 21 | smap = cachedurls[mapurl] 22 | index = sourcemap.loads(smap) 23 | token = index.lookup(line=lineno, column=column) 24 | return f"{token} ({token.src}:{token.src_line})" 25 | 26 | 27 | def map_stacktrace(txt): 28 | """Parses a string stacktrace, returning an array of mapped frames in the trace.""" 29 | res = [] 30 | for line in txt.split("\n"): 31 | m = re.search(r"(http[s]?://[^/]+[^:]*):(\d+):(\d+)", line) 32 | if m is not None: 33 | url = m.group(1) 34 | lineno = int(m.group(2)) - 1 35 | column = int(m.group(3)) 36 | res.append(get_info(url, lineno, column)) 37 | return res 38 | -------------------------------------------------------------------------------- /backendpy/telemetry.py: -------------------------------------------------------------------------------- 1 | """Telemetry functions for Mastodon List Manager""" 2 | 3 | import json 4 | import logging 5 | 6 | from smap import map_stacktrace 7 | 8 | # AWS doens't set a logging level, so set it here. 9 | logging.getLogger("root").setLevel(logging.INFO) 10 | # But don't log much from botocore 11 | logging.getLogger("botocore").setLevel(logging.ERROR) 12 | 13 | 14 | def ok_response(): 15 | """Return an OK response""" 16 | return { 17 | "statusCode": 200, 18 | "body": json.dumps({"status": "OK"}), 19 | } 20 | 21 | 22 | def telemetry(event, _): 23 | """Log a telemetry event""" 24 | 25 | # Basically log everything that we got. 26 | logging.info(event["body"]) 27 | return ok_response() 28 | 29 | 30 | def error(event, _): 31 | """Log an error event""" 32 | # Basically log everything that we got. 33 | data = json.loads(event["body"]) 34 | if "stack" in data: 35 | frames = map_stacktrace(data["stack"]) 36 | if len(frames) > 0: 37 | data["stack"] = "\n".join(frames) 38 | logging.error(json.dumps(data)) 39 | return ok_response() 40 | -------------------------------------------------------------------------------- /backendpy/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acbeers/mastodonlm/134ae81fa830708cd32d8b8567842003067b0083/backendpy/test/__init__.py -------------------------------------------------------------------------------- /backendpy/test/test_datastore.py: -------------------------------------------------------------------------------- 1 | """Tests for Datastore interface""" 2 | 3 | from unittest.mock import patch 4 | from unittest import TestCase 5 | from models import Datastore 6 | 7 | 8 | class TestDatastore(TestCase): 9 | """Tests for MastodonFactory methods""" 10 | 11 | @patch("models.AuthTable") 12 | def test_getauth(self, authmock): 13 | """Test for Datastore.get_auth""" 14 | 15 | Datastore.get_auth("cookie") 16 | authmock.lookup.assert_called_with("cookie") 17 | 18 | @patch("models.HostConfig") 19 | def test_gethostconfig(self, hostmock): 20 | """Test for Datastore.get_host_config""" 21 | 22 | Datastore.get_host_config("host") 23 | hostmock.lookup.assert_called_with("host") 24 | 25 | @patch("models.AllowedHost") 26 | def test_allowed(self, allowmock): 27 | """Test for allowed hosts""" 28 | res = Datastore.is_allowed("host") 29 | allowmock.lookup.assert_called_with("host") 30 | self.assertTrue(res) 31 | 32 | @patch("models.AllowedHost") 33 | @patch("models.BlockedHost") 34 | def test_blocked(self, blockmock, allowmock): 35 | """Test for blocked hosts""" 36 | allowmock.lookup.return_value = None 37 | res = Datastore.is_allowed("host") 38 | allowmock.lookup.assert_called_with("host") 39 | blockmock.lookup.assert_called_with( 40 | "4740ae6347b0172c01254ff55bae5aff5199f4446e7f6d643d40185b3f475145" 41 | ) 42 | self.assertFalse(res) 43 | 44 | @patch("models.AllowedHost") 45 | @patch("models.BlockedHost") 46 | def test_not_blocked(self, blockmock, allowmock): 47 | """Test for hosts neither allowed nor blocked""" 48 | allowmock.lookup.return_value = None 49 | blockmock.lookup.return_value = None 50 | res = Datastore.is_allowed("host") 51 | allowmock.lookup.assert_called_with("host") 52 | blockmock.lookup.assert_called_with( 53 | "4740ae6347b0172c01254ff55bae5aff5199f4446e7f6d643d40185b3f475145" 54 | ) 55 | self.assertTrue(res) 56 | -------------------------------------------------------------------------------- /backendpy/test/test_factory.py: -------------------------------------------------------------------------------- 1 | """Tests for Mastodon factory""" 2 | 3 | import logging 4 | from unittest.mock import MagicMock, patch, sentinel 5 | from unittest import TestCase 6 | from factory import MastodonFactory, USER_AGENT 7 | 8 | # Here, reconfigure the logger to send output to a file during tests. 9 | logging.basicConfig(level=logging.INFO, filename="debug_log.txt") 10 | 11 | 12 | def mock_hostconfig(): 13 | """Returns a mock HostConfig""" 14 | cfg = MagicMock() 15 | cfg.client_id = sentinel.client_id 16 | cfg.client_secret = sentinel.client_secret 17 | cfg.host = sentinel.host 18 | return cfg 19 | 20 | 21 | class TestFactory(TestCase): 22 | """Tests for MastodonFactory methods""" 23 | 24 | @patch("factory.Mastodon") 25 | def test_fromconfig_notoken(self, mastomock): 26 | """Test for MastodonFactory.from_config without a token""" 27 | 28 | cfg = mock_hostconfig() 29 | MastodonFactory.from_config(cfg) 30 | 31 | mastomock.assert_called_with( 32 | client_id=sentinel.client_id, 33 | client_secret=sentinel.client_secret, 34 | access_token=None, 35 | user_agent=USER_AGENT, 36 | api_base_url=f"https://{sentinel.host}", 37 | ) 38 | 39 | @patch("factory.Mastodon") 40 | def test_fromconfig_withtoken(self, mastomock): 41 | """Test for MastodonFactory.from_config with a token""" 42 | 43 | cfg = mock_hostconfig() 44 | MastodonFactory.from_config(cfg, token=sentinel.token) 45 | 46 | mastomock.assert_called_with( 47 | client_id=sentinel.client_id, 48 | client_secret=sentinel.client_secret, 49 | access_token=sentinel.token, 50 | user_agent=USER_AGENT, 51 | api_base_url=f"https://{sentinel.host}", 52 | ) 53 | 54 | @patch("factory.Datastore") 55 | @patch("factory.MastodonFactory.from_config") 56 | def test_fromcookie(self, from_config, data_store): 57 | """Test for MastodonFactory.from_config with a token""" 58 | 59 | auth = MagicMock() 60 | auth.domain = sentinel.domain 61 | auth.token = sentinel.token 62 | data_store.get_auth.return_value = auth 63 | data_store.get_host_config.return_value = sentinel.host_config 64 | 65 | cookie = sentinel.cookie 66 | MastodonFactory.from_cookie(cookie) 67 | 68 | data_store.get_host_config.assert_called_with(sentinel.domain) 69 | from_config.assert_called_with(sentinel.host_config, token=sentinel.token) 70 | -------------------------------------------------------------------------------- /backendpy/test/test_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for utility functions""" 2 | 3 | from unittest import TestCase 4 | import utils 5 | 6 | 7 | class TestUtils(TestCase): 8 | """Tests for /auth methods""" 9 | 10 | def test_cleandomain_url(self): 11 | """Test cleandomain with a URL""" 12 | res = utils.cleandomain("https://domain") 13 | self.assertEqual(res, "domain") 14 | 15 | def test_cleandomain_url_http(self): 16 | """Test cleandomain with a URL""" 17 | res = utils.cleandomain("http://domain") 18 | self.assertEqual(res, "domain") 19 | 20 | def test_cleandomain_account(self): 21 | """Test cleandomain with an account name""" 22 | res = utils.cleandomain("user@somedomain") 23 | self.assertEqual(res, "somedomain") 24 | 25 | def test_cleandomain_garbase(self): 26 | """Test cleandomain with a bunch of junk""" 27 | res = utils.cleandomain("aBc12#$%3_ruie[-") 28 | self.assertEqual(res, "abc123_ruie-") 29 | -------------------------------------------------------------------------------- /backendpy/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions""" 2 | 3 | import json 4 | import logging 5 | import re 6 | 7 | 8 | def get_cookie(event): 9 | """Retrieves the auth cookie from the event object""" 10 | headers = event.get("headers", {}) 11 | cookie = headers.get("authorization", None) 12 | return cookie 13 | 14 | 15 | def response(body, statusCode=200): 16 | """Construct a lambda response object""" 17 | # Log a mesasge for error-like things. 18 | if statusCode >= 404: 19 | logging.error("Returning %s with %s", statusCode, body) 20 | elif statusCode >= 400: 21 | logging.info("Returning %s with %s", statusCode, body) 22 | return { 23 | "statusCode": statusCode, 24 | "body": body, 25 | } 26 | 27 | 28 | def err_response(msg): 29 | """Construct a lambda error response""" 30 | obj = {"status": msg} 31 | return response(json.dumps(obj), statusCode=500) 32 | 33 | 34 | def blocked_response(): 35 | """Returns a 'blocked' response""" 36 | return err_response("blocked") 37 | 38 | 39 | def badhost_response(host): 40 | """Returns a "bad_host" response""" 41 | 42 | obj = {"status": "bad_host", "host": host} 43 | return response(json.dumps(obj), statusCode=500) 44 | 45 | 46 | def cleandomain(domain): 47 | """Clean up a domain input - all lowercase, no @""" 48 | if domain is None: 49 | return domain 50 | 51 | # Clean up some common problems: 52 | # - https://domain 53 | # - http://domain 54 | # - username@domain 55 | 56 | ldomain = domain.lower() 57 | 58 | # The URL cases 59 | m = re.match("https://([^/]*)", ldomain) 60 | if m is not None: 61 | return m.group(1) 62 | 63 | m = re.match("http://([^/]*)", ldomain) 64 | if m is not None: 65 | return m.group(1) 66 | 67 | # The username case 68 | m = re.match("([^@]*)@([a-zA-Z0-9_.-]*)", ldomain) 69 | if m is not None: 70 | return m.group(2) 71 | 72 | # Otherwise, just get rid of garbage. 73 | return re.sub("[^a-zA-Z0-9_.-]", "", ldomain) 74 | # return domain.strip().lower().replace("@", "") 75 | -------------------------------------------------------------------------------- /dev.env: -------------------------------------------------------------------------------- 1 | # Environment variables for dev environment 2 | 3 | export TABLE_AUTH="list-manager-auth-dev" 4 | export TABLE_ALLOWED="list-manager-allowedHosts-dev" 5 | export TABLE_BLOCKED="list-manager-blockedHosts-dev" 6 | export TABLE_HOSTCFG="list-manager-hostConfig-dev" -------------------------------------------------------------------------------- /newprod.env: -------------------------------------------------------------------------------- 1 | # Environment variables for prod environment 2 | 3 | export TABLE_AUTH="list-manager-auth-newprod" 4 | export TABLE_ALLOWED="list-manager-allowedHosts-newprod" 5 | export TABLE_BLOCKED="list-manager-blockedHosts-newprod" 6 | export TABLE_HOSTCFG="list-manager-hostConfig-newprod" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "yarn workspace client start", 4 | "test": "yarn workspace client test && yarn workspace @mastodonlm/server test", 5 | "build:packages": "npx tsc -b packages", 6 | "build:packages:watch": "npx tsc -b packages --watch", 7 | "predeploy:devstage": "yarn workspace client env-cmd -f .env.devstage npm run build", 8 | "deploy:devstage": "sls deploy --aws-profile slsdeploy deploy --stage devstage", 9 | "predeploy": "yarn workspace client build", 10 | "deploy": "sls deploy --aws-profile slsdeploy deploy --stage newprod" 11 | }, 12 | "dependencies": { 13 | "@nova-odm/annotations": "^0.8.3", 14 | "@nova-odm/mapper": "^0.8.3", 15 | "serverless-cloudfront-invalidate": "^1.12.2", 16 | "serverless-offline": "^13.3.2", 17 | "serverless-python-requirements": "^6.0.1" 18 | }, 19 | "resolutions": { 20 | "**/graceful-fs": "4.2.9" 21 | }, 22 | "devDependencies": { 23 | "serverless-domain-manager": "^6.2.2", 24 | "serverless-plugin-typescript": "^2.1.4", 25 | "serverless-s3-sync": "^3.1.0", 26 | "serverless-website-domain": "^3.0.1", 27 | "typescript": "^5.0.4" 28 | }, 29 | "private": true, 30 | "workspaces": [ 31 | "packages/client", 32 | "packages/shared", 33 | "packages/server" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /packages/client/.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_BACKEND_URL=http://localhost:4000 2 | REACT_APP_BASE_PATH=/ 3 | -------------------------------------------------------------------------------- /packages/client/.env.devstage: -------------------------------------------------------------------------------- 1 | REACT_APP_BACKEND_URL=https://backend-devstage.mastodonlistmanager.org 2 | REACT_APP_BASE_PATH=/ 3 | -------------------------------------------------------------------------------- /packages/client/.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_BACKEND_URL=https://backend.mastodonlistmanager.org 2 | REACT_APP_BASE_PATH=/ 3 | -------------------------------------------------------------------------------- /packages/client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /packages/client/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser. 13 | 14 | The page will reload when you make changes.\ 15 | You may also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can't go back!** 35 | 36 | If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. 39 | 40 | You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `npm run build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /packages/client/config-overrides.js: -------------------------------------------------------------------------------- 1 | const getCacheIdentifier = require("react-dev-utils/getCacheIdentifier"); 2 | 3 | const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== "false"; 4 | 5 | module.exports = function override(config, webpackEnv) { 6 | console.log("overriding webpack config..."); 7 | 8 | const isEnvDevelopment = webpackEnv === "development"; 9 | const isEnvProduction = webpackEnv === "production"; 10 | const loaders = config.module.rules[1].oneOf; 11 | 12 | loaders.splice(loaders.length - 1, 0, { 13 | test: /\.(js|mjs|cjs)$/, 14 | exclude: /@babel(?:\/|\\{1,2})runtime/, 15 | loader: require.resolve("babel-loader"), 16 | options: { 17 | babelrc: false, 18 | //configFile: false, 19 | compact: false, 20 | presets: [ 21 | [ 22 | require.resolve("babel-preset-react-app/dependencies"), 23 | { helpers: true }, 24 | ], 25 | ], 26 | cacheDirectory: true, 27 | // See #6846 for context on why cacheCompression is disabled 28 | //cacheCompression: false, 29 | // @remove-on-eject-begin 30 | cacheIdentifier: getCacheIdentifier( 31 | isEnvProduction ? "production" : isEnvDevelopment && "development", 32 | [ 33 | "babel-plugin-named-asset-import", 34 | "babel-preset-react-app", 35 | "react-dev-utils", 36 | "react-scripts", 37 | ] 38 | ), 39 | // @remove-on-eject-end 40 | // Babel sourcemaps are needed for debugging into node_modules 41 | // code. Without the options below, debuggers like VSCode 42 | // show incorrect code and set breakpoints on the wrong lines. 43 | sourceMap: shouldUseSourceMap, 44 | inputSourceMap: shouldUseSourceMap, 45 | }, 46 | }); 47 | 48 | return config; 49 | }; 50 | -------------------------------------------------------------------------------- /packages/client/jest.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | moduleNameMapper: { 3 | "ComlinkHook.js": "/jest_ComlinkHook.js", 4 | }, 5 | }; 6 | 7 | module.exports = config; 8 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.10.5", 7 | "@emotion/styled": "^11.10.5", 8 | "@mui/icons-material": "^5.10.14", 9 | "@mui/material": "^5.10.13", 10 | "@testing-library/jest-dom": "^5.16.5", 11 | "@testing-library/react": "^13.4.0", 12 | "@testing-library/user-event": "^13.5.0", 13 | "@types/jest": "^29.2.5", 14 | "@types/node": "^18.11.18", 15 | "@types/react": "^18.0.26", 16 | "@types/react-dom": "^18.0.10", 17 | "comlink": "^4.3.1", 18 | "components": "*", 19 | "env-cmd": "^10.1.0", 20 | "file-saver": "^2.0.5", 21 | "masto": "^5.5.1", 22 | "query-string": "^8.1.0", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "react-router-dom": "^6.4.3", 26 | "react-scripts": "5.0.1", 27 | "react-scripts-ts": "3.1.0", 28 | "react-use-comlink": "^2.0.1", 29 | "shared": "*", 30 | "ts-jest": "^29.0.5", 31 | "typescript": "^4.9.4", 32 | "uuid": "^9.0.0", 33 | "web-vitals": "^2.1.4" 34 | }, 35 | "scripts": { 36 | "predeploy": "npm run build", 37 | "deploy": "gh-pages -d build", 38 | "start": "react-app-rewired start", 39 | "build": "react-app-rewired build", 40 | "test": "react-app-rewired test", 41 | "eject": "react-app-rewired eject" 42 | }, 43 | "eslintConfig": { 44 | "extends": [ 45 | "react-app", 46 | "react-app/jest" 47 | ] 48 | }, 49 | "browserslist": { 50 | "production": [ 51 | ">0.2%", 52 | "not dead", 53 | "not op_mini all" 54 | ], 55 | "development": [ 56 | "last 1 chrome version", 57 | "last 1 firefox version", 58 | "last 1 safari version" 59 | ] 60 | }, 61 | "devDependencies": { 62 | "@types/file-saver": "^2.0.5", 63 | "@types/uuid": "^9.0.1", 64 | "babel-core": "7.0.0-bridge.0", 65 | "gh-pages": "^4.0.0", 66 | "react-app-rewired": "^2.2.1" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/client/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Single Page Apps for GitHub Pages 6 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /packages/client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acbeers/mastodonlm/134ae81fa830708cd32d8b8567842003067b0083/packages/client/public/favicon.ico -------------------------------------------------------------------------------- /packages/client/public/googleac16fe8226e8fe6e.html: -------------------------------------------------------------------------------- 1 | google-site-verification: googleac16fe8226e8fe6e.html -------------------------------------------------------------------------------- /packages/client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 46 | 55 | Mastodon List Manager 56 | 57 | 58 | 59 |
60 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /packages/client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acbeers/mastodonlm/134ae81fa830708cd32d8b8567842003067b0083/packages/client/public/logo192.png -------------------------------------------------------------------------------- /packages/client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acbeers/mastodonlm/134ae81fa830708cd32d8b8567842003067b0083/packages/client/public/logo512.png -------------------------------------------------------------------------------- /packages/client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /packages/client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /packages/client/src/AboutDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEvent } from "react"; 2 | import Button from "@mui/material/Button"; 3 | import Dialog from "@mui/material/Dialog"; 4 | import DialogActions from "@mui/material/DialogActions"; 5 | import DialogContent from "@mui/material/DialogContent"; 6 | import DialogContentText from "@mui/material/DialogContentText"; 7 | import DialogTitle from "@mui/material/DialogTitle"; 8 | 9 | type AboutDialogProps = { 10 | open: boolean; 11 | handleClose: (evt: MouseEvent) => void; 12 | }; 13 | 14 | function AboutDialog({ open, handleClose }: AboutDialogProps) { 15 | return ( 16 | 22 | 23 | About Mastodon List Manager 24 | 25 | 26 | 30 | This is an app that lets you manage membership of your follows in 31 | Lists on Mastodon, in the style of Twitter List Manager. Click on a 32 | cell in the table to add/remove the user in that row to the list in 33 | that column. 34 | 35 | 36 | Maintained by{" "} 37 | @acbeers@hachyderm.io. 38 | 39 | 40 | Released under{" "} 41 | 42 | MIT license 43 | 44 | 45 | 46 | 47 | 50 | 51 | 52 | ); 53 | } 54 | 55 | export default AboutDialog; 56 | -------------------------------------------------------------------------------- /packages/client/src/AnalyticsDialog.css: -------------------------------------------------------------------------------- 1 | table.analytics tr th { 2 | text-align: right; 3 | font-weight: normal; 4 | padding-right: 5px; 5 | } 6 | 7 | a { 8 | text-decoration: none; 9 | color: black; 10 | } 11 | 12 | div.bar_orig { 13 | display: inline-block; 14 | background-color: #aaf; 15 | outline: 1px solid #22f; 16 | height: 1em; 17 | } 18 | 19 | div.orig_swatch { 20 | display: inline-block; 21 | background-color: #aaf; 22 | outline: 1px solid #22f; 23 | height: 1em; 24 | width: 1em; 25 | margin-right: 5px; 26 | } 27 | 28 | div.bar_boost { 29 | display: inline-block; 30 | background-color: #faa; 31 | outline: 1px solid #f22; 32 | height: 1em; 33 | } 34 | 35 | div.boost_swatch { 36 | display: inline-block; 37 | background-color: #faa; 38 | outline: 1px solid #f22; 39 | height: 1em; 40 | width: 1em; 41 | margin-right: 5px; 42 | } 43 | 44 | span.bar_label { 45 | margin-left: 5px; 46 | font-size: 10px; 47 | } 48 | 49 | td.date { 50 | font-size: 9pt; 51 | } 52 | -------------------------------------------------------------------------------- /packages/client/src/AnalyticsDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from "react"; 2 | import Button from "@mui/material/Button"; 3 | import Dialog from "@mui/material/Dialog"; 4 | import DialogActions from "@mui/material/DialogActions"; 5 | import DialogContent from "@mui/material/DialogContent"; 6 | import DialogTitle from "@mui/material/DialogTitle"; 7 | 8 | import { Box } from "@mui/system"; 9 | import Table from "@mui/material/Table"; 10 | import TableBody from "@mui/material/TableBody"; 11 | import TableRow from "@mui/material/TableRow"; 12 | import TableCell from "@mui/material/TableCell"; 13 | 14 | import { ListAnalytics, List } from "@mastodonlm/shared"; 15 | 16 | // For our API work 17 | import type APIWorker from "./clientworker"; 18 | import * as Comlink from "comlink"; 19 | 20 | import "./AnalyticsDialog.css"; 21 | 22 | type AnalyticsDialogProps = { 23 | open: boolean; 24 | list: List | null; 25 | api: Promise>; 26 | handleClose: () => void; 27 | }; 28 | 29 | function AnalyticsDialog({ 30 | open, 31 | list, 32 | api, 33 | handleClose, 34 | }: AnalyticsDialogProps) { 35 | const [analytics, setAnalytics] = useState(null); 36 | const [error, setError] = useState(""); 37 | 38 | // NOTE: Duplicated from Manager.tsx, since this dialog does its own 39 | // network calls. 40 | const telemetryCB = useCallback( 41 | async (data: Record) => { 42 | const remote = await api; 43 | remote.telemetry(data); 44 | }, 45 | [api] 46 | ); 47 | 48 | const loadDataCB = useCallback(async () => { 49 | if (list) { 50 | const remote = await api; 51 | const startTime = new Date().getTime(); 52 | remote 53 | .listAnalytics(list) 54 | .then((la: ListAnalytics) => { 55 | const endTime = new Date().getTime(); 56 | setAnalytics(la); 57 | // Send telemetry 58 | const data = { 59 | action: "analytics", 60 | num_posts: la.num_posts, 61 | num_boosts: la.num_boosts, 62 | date_range: 63 | (la.latest_post.getTime() - la.earliest_post.getTime()) / 64 | 1000 / 65 | 60 / 66 | 60 / 67 | 24, 68 | elapsed_ms: (endTime - startTime) / 1000, 69 | }; 70 | telemetryCB(data); 71 | }) 72 | .catch((err) => { 73 | setError("Unable to load analytics"); 74 | }); 75 | } 76 | }, [api, list, telemetryCB]); 77 | 78 | useEffect(() => { 79 | loadDataCB(); 80 | }, [loadDataCB, list]); 81 | 82 | const onClose = () => { 83 | console.log("CLOSING"); 84 | setAnalytics(null); 85 | handleClose(); 86 | }; 87 | 88 | let content = ( 89 | 90 | Loading posts... 91 | 92 | ); 93 | if (error) { 94 | content = ( 95 | 96 | {error} 97 | 98 | ); 99 | } 100 | if (analytics) { 101 | const topusersrows = analytics.top_posters.map((x) => { 102 | const barwidth_orig = (x.count_orig / analytics.num_posts) * 200; 103 | const barwidth_boost = (x.count_boost / analytics.num_posts) * 200; 104 | const [user, domain] = x.acct.acct.split("@"); 105 | const link = `https://${domain}/@${user}`; 106 | return ( 107 | 108 | 111 | 112 | {x.acct.display_name} 113 | 114 | 115 | 116 |
120 |
124 | 125 | {x.count_orig} / {x.count_boost} 126 | 127 |
128 |
129 | ); 130 | }); 131 | 132 | const topusers = ( 133 | 134 | {topusersrows} 135 |
136 | ); 137 | 138 | const topboostsrows = analytics.top_boosts.map((x) => { 139 | const barwidth = (x.count / analytics.num_boosts) * 200; 140 | const [user, domain] = x.acct.acct.split("@"); 141 | const link = `https://${domain}/@${user}`; 142 | return ( 143 | 144 | 147 | 148 | {x.acct.display_name} 149 | 150 | 151 | 152 |
156 | {x.count} 157 |
158 |
159 | ); 160 | }); 161 | const topboosts = ( 162 | 163 | {topboostsrows} 164 |
165 | ); 166 | 167 | const diffDays = Math.ceil( 168 | (analytics.latest_post.getTime() - analytics.earliest_post.getTime()) / 169 | 1000 / 170 | 60 / 171 | 60 / 172 | 24 173 | ); 174 | const postsPerDay = Math.ceil(analytics.num_posts / diffDays); 175 | 176 | content = ( 177 | 178 | 179 | <>Total posts: 180 | 181 | 187 | 188 | 189 | 197 | {analytics.num_orig_posts} 198 | 199 | 200 |
original posts 201 |
202 |
203 | 204 | 207 | {analytics.num_boosts} 208 | 209 | 210 |
boosts 211 |
212 |
213 | 214 | 219 | 220 | Since {analytics.earliest_post.toDateString()} 221 | 222 | 223 | 224 | 229 | 230 | {postsPerDay} posts per day 231 | 232 | 233 |
234 |
235 | 236 | <>Top posters: 237 | 238 | {topusers} 239 | 240 | <>Most boosted accounts: 241 | 242 | {topboosts} 243 |
244 | ); 245 | } 246 | 247 | const title = list ? ( 248 | Analytics for {list.title} 249 | ) : ( 250 | Analytics 251 | ); 252 | 253 | const buttonText = analytics ? "Great!" : "Cancel"; 254 | 255 | return ( 256 | 262 | {title} 263 | {content} 264 | 265 | 268 | 269 | 270 | ); 271 | } 272 | 273 | export default AnalyticsDialog; 274 | -------------------------------------------------------------------------------- /packages/client/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | margin-left: 8px; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/client/src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import App from "./App"; 3 | 4 | jest.mock("./ComlinkHook"); 5 | 6 | test("renders without exception", async () => { 7 | render(); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; 3 | import MainPage from "./MainPage"; 4 | import MainApp from "./MainApp"; 5 | import Callback from "./Callback"; 6 | import { clientComlinkHook, serverComlinkHook } from "./ComlinkHook"; 7 | import "./App.css"; 8 | 9 | /* 10 | /manager is where the manager is stored. 11 | /main shows the welcome interface, asks for a domain 12 | /callback is an endpoint for the last step in oauth authorization 13 | */ 14 | 15 | function getQueryVariable(variable: string): string | null { 16 | var query = window.location.search.substring(1); 17 | var vars = query.split("&"); 18 | for (var i = 0; i < vars.length; i++) { 19 | var pair = vars[i].split("="); 20 | if (decodeURIComponent(pair[0]) === variable) { 21 | return decodeURIComponent(pair[1]); 22 | } 23 | } 24 | return null; 25 | } 26 | 27 | const useClientAPIWorker = clientComlinkHook(); 28 | const useServerAPIWorker = serverComlinkHook(); 29 | 30 | function App() { 31 | // Here, create our worker. 32 | // If we think we are authenticated, just render the Manager 33 | // If we aren't and we have a code, then pass it off to the worker and then render the manager 34 | // If we have another route, then do that. 35 | // } /> 36 | 37 | // Doing this here allows my worker communication to 38 | // work in the development server. 39 | const clientApiClass = useClientAPIWorker(); 40 | const serverApiClass = useServerAPIWorker(); 41 | 42 | // Which client should we use? 43 | // Set this in client-side storage so we remember it. 44 | const mode = getQueryVariable("mode"); 45 | if (mode) { 46 | localStorage.setItem("mastodonlm-mode", mode); 47 | } 48 | 49 | // our API instance 50 | const api = useMemo(() => { 51 | const mode = localStorage.getItem("mastodonlm-mode") || "client"; 52 | const apiClass = mode === "server" ? serverApiClass : clientApiClass; 53 | const res = new apiClass.proxy(); 54 | return res; 55 | }, [clientApiClass, serverApiClass]); 56 | 57 | // Change the default number of stack frames 58 | 59 | Error.stackTraceLimit = 30; 60 | 61 | // Set up a global error handler 62 | 63 | window.onerror = async (_event, _source, _lineno, _colno, error) => { 64 | const remote = await api; 65 | if (error) { 66 | const telem = { 67 | stack: error.stack, 68 | message: error.message, 69 | }; 70 | remote.error(telem); 71 | } 72 | }; 73 | 74 | return ( 75 | 76 | 77 | 78 | } /> 79 | } /> 80 | } /> 81 | } /> 82 | 83 | 84 | 85 | ); 86 | } 87 | 88 | export default App; 89 | -------------------------------------------------------------------------------- /packages/client/src/Callback.tsx: -------------------------------------------------------------------------------- 1 | // This file handles an oAuth callback, by simply storing the code and domain in 2 | // local storage, and then redirecting to the main app. We do this so the main 3 | // app can redeem the code for a token without loading a new URL, because a web 4 | // worker will handle authorization and token storage, and must stay loaded so 5 | // it doesn't lose that token. 6 | 7 | import { useLocation } from "react-router-dom"; 8 | import { Navigate } from "react-router-dom"; 9 | 10 | export default function Callback() { 11 | // For extracting query string parameters 12 | const { search } = useLocation(); 13 | 14 | // If we have a code, redeem it. 15 | const query = new URLSearchParams(search); 16 | const code = query.get("code"); 17 | const domain = query.get("domain"); 18 | 19 | if (code && domain) { 20 | localStorage.setItem("code", code); 21 | localStorage.setItem("domain", domain); 22 | 23 | return ; 24 | } 25 | return ; 26 | } 27 | -------------------------------------------------------------------------------- /packages/client/src/ComlinkHook.ts: -------------------------------------------------------------------------------- 1 | import { createComlink } from "react-use-comlink"; 2 | import APIWorker from "./clientworker"; 3 | 4 | export function clientComlinkHook() { 5 | return createComlink( 6 | () => new Worker(new URL("./clientworker.ts", import.meta.url)) 7 | ); 8 | } 9 | 10 | export function serverComlinkHook() { 11 | return createComlink( 12 | () => new Worker(new URL("./serverworker.ts", import.meta.url)) 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /packages/client/src/Controls.test.js: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from "@testing-library/react"; 2 | import Controls from "./Controls"; 3 | 4 | test("renders the select element", () => { 5 | render(); 6 | const selectElement = screen.getByTestId("controls-groupby-select"); 7 | expect(selectElement).toBeInTheDocument(); 8 | }); 9 | 10 | test("selects the none groupby", () => { 11 | render(); 12 | const selectedElement = screen.getByText("Nothing"); 13 | expect(selectedElement).toBeInTheDocument(); 14 | }); 15 | 16 | test("selects the name groupby", () => { 17 | render(); 18 | const selectedElement = screen.getByText("Name (first letter)"); 19 | expect(selectedElement).toBeInTheDocument(); 20 | }); 21 | 22 | test("selects the domain groupby", () => { 23 | render(); 24 | const selectedElement = screen.getByText("Account domain"); 25 | expect(selectedElement).toBeInTheDocument(); 26 | }); 27 | 28 | test("renders the search element", () => { 29 | render(); 30 | const searchElement = screen.getByTestId("controls-search-textfield"); 31 | expect(searchElement).toBeInTheDocument(); 32 | }); 33 | 34 | test("fires the search method", (done) => { 35 | const handler = (val) => { 36 | expect(val).toEqual("A"); 37 | done(); 38 | }; 39 | render(); 40 | const searchElement = screen.getByLabelText("Search"); 41 | fireEvent.change(searchElement, { target: { value: "A" } }); 42 | }); 43 | 44 | test("fires the groupby method", (done) => { 45 | const handler = (val) => { 46 | expect(val).toEqual("name"); 47 | done(); 48 | }; 49 | render(); 50 | const selectElement = screen.getByTestId("controls-groupby-input"); 51 | fireEvent.change(selectElement, { target: { value: "name" } }); 52 | }); 53 | 54 | test("renders the filter element - everything", () => { 55 | render(); 56 | const searchElement = screen.getByTestId("controls-filter-select"); 57 | expect(searchElement).toBeInTheDocument(); 58 | const selectedElement = screen.getByText("Everything"); 59 | expect(selectedElement).toBeInTheDocument(); 60 | }); 61 | 62 | test("renders the filter element - nolists", () => { 63 | render(); 64 | const searchElement = screen.getByTestId("controls-filter-select"); 65 | expect(searchElement).toBeInTheDocument(); 66 | const selectedElement = screen.getByText("Not on any list"); 67 | expect(selectedElement).toBeInTheDocument(); 68 | }); 69 | 70 | test("renders the filter element - lists", () => { 71 | const lists = [ 72 | { id: 1, title: "List A" }, 73 | { id: 2, title: "List B" }, 74 | ]; 75 | render(); 76 | const searchElement = screen.getByTestId("controls-filter-select"); 77 | expect(searchElement).toBeInTheDocument(); 78 | const selectedElement = screen.getByText("Not on List B"); 79 | expect(selectedElement).toBeInTheDocument(); 80 | }); 81 | 82 | test("fires the filter method", (done) => { 83 | const handler = (val) => { 84 | expect(val).toEqual("nolists"); 85 | done(); 86 | }; 87 | render(); 88 | const selectElement = screen.getByTestId("controls-filter-input"); 89 | fireEvent.change(selectElement, { target: { value: "nolists" } }); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/client/src/Controls.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Button from "@mui/material/Button"; 3 | import FormControl from "@mui/material/FormControl"; 4 | import InputLabel from "@mui/material/InputLabel"; 5 | import MenuItem from "@mui/material/MenuItem"; 6 | import Select from "@mui/material/Select"; 7 | import TextField from "@mui/material/TextField"; 8 | import { List, Relationship } from "@mastodonlm/shared"; 9 | 10 | type ControlsProps = { 11 | relationships: Relationship; 12 | handleRelationshipChange: (r: Relationship) => void; 13 | groupBy: string; 14 | handleGroupByChange: (g: string) => void; 15 | lists: List[]; 16 | filter: string; 17 | handleFilterChange: (g: string) => void; 18 | search: string; 19 | handleSearchChange: (g: string) => void; 20 | refresh: () => Promise; 21 | pageSize: number; 22 | handlePageSizeChange: (ps: number) => void; 23 | }; 24 | 25 | export default function Controls({ 26 | relationships, 27 | handleRelationshipChange, 28 | groupBy, 29 | handleGroupByChange, 30 | lists, 31 | filter, 32 | handleFilterChange, 33 | search, 34 | handleSearchChange, 35 | pageSize = 500, 36 | handlePageSizeChange, 37 | refresh, 38 | }: ControlsProps) { 39 | const notOnfilterItems = (lists || []).map((list) => ( 40 | 41 | Not on {list.title} 42 | 43 | )); 44 | const onfilterItems = (lists || []).map((list) => ( 45 | 46 | On {list.title} 47 | 48 | )); 49 | return ( 50 |
51 | 63 | 64 | 68 | Relationships 69 | 70 | 86 | 87 | 88 | 92 | Group By 93 | 94 | 108 | 109 | 110 | 111 | Filter to 112 | 113 | 128 | 129 | 130 | handleSearchChange(event.target.value)} 135 | /> 136 | 137 | 138 | 139 | Page size 140 | 141 | 159 | 160 |
161 | ); 162 | } 163 | -------------------------------------------------------------------------------- /packages/client/src/CreateListDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Button from "@mui/material/Button"; 3 | import Dialog from "@mui/material/Dialog"; 4 | import DialogActions from "@mui/material/DialogActions"; 5 | import DialogContent from "@mui/material/DialogContent"; 6 | import DialogTitle from "@mui/material/DialogTitle"; 7 | import TextField from "@mui/material/TextField"; 8 | 9 | type CreateListDialogProps = { 10 | open: boolean; 11 | handleCreate: (title: string) => void; 12 | handleClose: () => void; 13 | }; 14 | 15 | function CreateListDialog({ 16 | open, 17 | handleCreate, 18 | handleClose, 19 | }: CreateListDialogProps) { 20 | const [value, setValue] = useState(""); 21 | 22 | const clear = () => { 23 | setValue(""); 24 | }; 25 | const onCreate = (value: string) => { 26 | handleCreate(value); 27 | clear(); 28 | }; 29 | const onClose = () => { 30 | clear(); 31 | handleClose(); 32 | }; 33 | 34 | return ( 35 | 41 | Create New List 42 | 43 | setValue(evt.target.value)} 49 | onKeyPress={(ev) => { 50 | if (ev.key === "Enter") { 51 | onCreate(value); 52 | ev.preventDefault(); 53 | } 54 | }} 55 | > 56 | 57 | 58 | 59 | 62 | 63 | 64 | ); 65 | } 66 | 67 | export default CreateListDialog; 68 | -------------------------------------------------------------------------------- /packages/client/src/DeleteListDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Button from "@mui/material/Button"; 3 | import Dialog from "@mui/material/Dialog"; 4 | import DialogActions from "@mui/material/DialogActions"; 5 | import DialogContent from "@mui/material/DialogContent"; 6 | import DialogContentText from "@mui/material/DialogContentText"; 7 | import DialogTitle from "@mui/material/DialogTitle"; 8 | import { List } from "@mastodonlm/shared"; 9 | 10 | type DeleteListDialogProps = { 11 | open: boolean; 12 | list: List; 13 | handleClose: () => void; 14 | handleDelete: (list: List) => void; 15 | }; 16 | 17 | function DeleteListDialog({ 18 | open, 19 | list, 20 | handleClose, 21 | handleDelete, 22 | }: DeleteListDialogProps) { 23 | return ( 24 | 30 | Confirm list deletion 31 | 32 | 33 | Are you sure you want to delete the list {list.title}?{" "} 34 | 35 | 36 | 37 | 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | export default DeleteListDialog; 47 | -------------------------------------------------------------------------------- /packages/client/src/ExportListDialog.test.js: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen, mount } from "@testing-library/react"; 2 | import ExportListDialog from "./ExportListDialog"; 3 | 4 | test("renders the select element", () => { 5 | const handler = () => {}; 6 | render( 7 | 13 | ); 14 | const selectElement = screen.getByTestId("export-list-select"); 15 | expect(selectElement).toBeInTheDocument(); 16 | }); 17 | 18 | test("calls the callback", (done) => { 19 | let closed = false; 20 | const closeHandler = () => { 21 | closed = true; 22 | }; 23 | const handler = (list) => { 24 | expect(list.id).toBe("1"); 25 | if (closed) done(); 26 | }; 27 | const lists = [ 28 | { id: "1", title: "foo" }, 29 | { id: "2", title: "bar" }, 30 | ]; 31 | render( 32 | 38 | ); 39 | 40 | const selectElement = screen.getByTestId("export-list-input"); 41 | // eslint-disable-next-line testing-library/no-node-access 42 | const selectButton = selectElement.parentNode.querySelector("[role=button]"); 43 | fireEvent.mouseDown(selectButton); 44 | 45 | const fooElement = screen.getByText("foo"); 46 | fireEvent.click(fooElement); 47 | 48 | const button = screen.getByTestId("export-list-button"); 49 | fireEvent.click(button); 50 | }); 51 | -------------------------------------------------------------------------------- /packages/client/src/ExportListDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Button from "@mui/material/Button"; 3 | import Dialog from "@mui/material/Dialog"; 4 | import DialogActions from "@mui/material/DialogActions"; 5 | import DialogContent from "@mui/material/DialogContent"; 6 | import DialogTitle from "@mui/material/DialogTitle"; 7 | import FormControl from "@mui/material/FormControl"; 8 | import MenuItem from "@mui/material/MenuItem"; 9 | import Select from "@mui/material/Select"; 10 | import { List } from "@mastodonlm/shared"; 11 | 12 | type ExportListDialogProps = { 13 | open: boolean; 14 | lists: List[]; 15 | handleExport: (list: List) => void; 16 | handleClose: () => void; 17 | }; 18 | 19 | function ExportListDialog({ 20 | open, 21 | lists, 22 | handleExport, 23 | handleClose, 24 | }: ExportListDialogProps) { 25 | const [value, setValue] = useState(""); 26 | 27 | const opts = lists.map((list, index) => ( 28 | 29 | {list.title} 30 | 31 | )); 32 | 33 | return ( 34 | 40 | Export List 41 | 42 | 45 | 56 | 57 | 58 | 59 | 60 | 72 | 73 | 74 | ); 75 | } 76 | 77 | export default ExportListDialog; 78 | -------------------------------------------------------------------------------- /packages/client/src/ImportListDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Button from "@mui/material/Button"; 3 | import Dialog from "@mui/material/Dialog"; 4 | import DialogActions from "@mui/material/DialogActions"; 5 | import DialogContent from "@mui/material/DialogContent"; 6 | import DialogTitle from "@mui/material/DialogTitle"; 7 | import FormControl from "@mui/material/FormControl"; 8 | import TextField from "@mui/material/TextField"; 9 | import Typography from "@mui/material/Typography"; 10 | import { User } from "@mastodonlm/shared"; 11 | 12 | type ImportListDialogProps = { 13 | open: boolean; 14 | users: User[]; // Users that we know about 15 | handleImport: ( 16 | list_name: string, 17 | tofollow: string[], 18 | toadd: string[] 19 | ) => void; 20 | handleClose: () => void; 21 | }; 22 | 23 | function ImportListDialog({ 24 | open, 25 | users, 26 | handleImport, 27 | handleClose, 28 | }: ImportListDialogProps) { 29 | const [name, setName] = useState(""); 30 | // A list of people we are importing that we already follow 31 | const [following, setFollowing] = useState([]); 32 | // A list of people we are importing that we don't yet follow 33 | const [notfollowing, setNotfollowing] = useState([]); 34 | // The full list of people 35 | const [data, setData] = useState([]); 36 | 37 | const clear = () => { 38 | setName(""); 39 | setData([]); 40 | }; 41 | 42 | // On file upload (click the upload button) 43 | const handleFiles = (files: FileList | null) => { 44 | // Details of the uploaded file 45 | if (files) { 46 | const file = files[0]; 47 | 48 | const reader = file.stream().getReader(); 49 | const decoder = new TextDecoder(); 50 | reader.read().then(({ done, value }) => { 51 | // Convert to text, drop the header row. 52 | const strs = decoder.decode(value).split("\n").slice(1); 53 | setData(strs); 54 | 55 | // Build a map of the people we know. 56 | const userMap: Record = {}; 57 | users.forEach((u) => { 58 | userMap[u.acct] = u; 59 | }); 60 | 61 | // Sort data into two lists 62 | const fol: string[] = []; 63 | const nfol: string[] = []; 64 | strs.forEach((acct) => { 65 | if (acct === "") return; 66 | 67 | if (acct in userMap && userMap[acct].following) { 68 | fol.push(acct); 69 | } else { 70 | nfol.push(acct); 71 | } 72 | }); 73 | setFollowing(fol); 74 | setNotfollowing(nfol); 75 | }); 76 | } 77 | }; 78 | 79 | const sample = data.slice(0, 5).map((txt) =>
{txt}
); 80 | 81 | const nflist = ( 82 | 88 | ); 89 | 90 | const flist = ( 91 | 97 | ); 98 | 99 | const showSample = 100 | sample.length > 0 ? ( 101 |
102 |

{notfollowing.length} accounts to follow and add:

103 | {nflist} 104 |

{following.length} accounts to add:

105 | {flist} 106 |
107 |
108 | Note: adding more than 100 will likely run afoul of Mastodon API limits. 109 |
110 | ) : ( 111 | 112 | ); 113 | 114 | return ( 115 | 121 | Import New List 122 | 123 | 126 | setName(evt.target.value)} 132 | > 133 | 134 |
135 | handleFiles(evt.target.files)} /> 136 | {showSample} 137 |
138 | 139 | 147 | 160 | 161 |
162 | ); 163 | } 164 | 165 | export default ImportListDialog; 166 | -------------------------------------------------------------------------------- /packages/client/src/LoginForm.css: -------------------------------------------------------------------------------- 1 | div.loginForm { 2 | display: inline-block; 3 | padding: 15px; 4 | max-width: 400px; 5 | text-align: left; 6 | border: 1px solid lightgray; 7 | } 8 | 9 | .loginform_container { 10 | margin-top: 25px; 11 | text-align: center; 12 | } 13 | 14 | .error { 15 | margin-top: 5px; 16 | } 17 | -------------------------------------------------------------------------------- /packages/client/src/LoginForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Navigate } from "react-router-dom"; 3 | import Button from "@mui/material/Button"; 4 | import TextField from "@mui/material/TextField"; 5 | import Typography from "@mui/material/Typography"; 6 | import "./LoginForm.css"; 7 | 8 | const urlAuth = process.env.REACT_APP_BACKEND_URL + "/auth"; 9 | 10 | function LoginForm() { 11 | const [error, setError] = useState(null); 12 | const [redirect, setRedirect] = useState(null); 13 | const [domain, setDomain] = useState(""); 14 | const [enabled, setEnabled] = useState(true); 15 | 16 | const handleGo = () => { 17 | setError(""); 18 | setEnabled(false); 19 | fetch(`${urlAuth}?domain=${domain}`, { 20 | credentials: "include", 21 | }) 22 | .then((resp) => resp.json()) 23 | .then((data) => { 24 | if (data.errorType === "LambdaTimeoutError") { 25 | setError("Seems the backend has timed out. Try again another time."); 26 | setEnabled(true); 27 | } else if (data.status === "OK") { 28 | setRedirect(`/manager`); 29 | } else if (data.status === "bad_host") { 30 | setError( 31 | "Please enter a hostname of a Mastodon server (e.g. mastodon.social)" 32 | ); 33 | setEnabled(true); 34 | } else if (data.status === "blocked") { 35 | setError("That host does not supoport this app."); 36 | setEnabled(true); 37 | } else if (data.status === "not_allowed") { 38 | setError("Looks like your domain is not currently supported!"); 39 | setEnabled(true); 40 | } else if (data.url) { 41 | // This one has to be done here, as it is to an external URL. 42 | const url = data.url; 43 | window.location = url; 44 | } else { 45 | setError("Hm. Something has gone wrong. Try again later."); 46 | setEnabled(true); 47 | } 48 | }) 49 | .catch((err) => { 50 | setEnabled(true); 51 | setError("Hm. Something has gone wrong. Try again later."); 52 | }); 53 | }; 54 | 55 | if (redirect) { 56 | return ; 57 | } 58 | 59 | return ( 60 |
61 |
62 | 63 | To get started, enter your instance name below (e.g. mastodon.social) 64 | 65 | setDomain(evt.target.value)} 68 | sx={{ width: "100%", mt: 2, mb: 1 }} 69 | label="Host" 70 | onKeyPress={(ev) => { 71 | if (ev.key === "Enter") { 72 | handleGo(); 73 | ev.preventDefault(); 74 | } 75 | }} 76 | /> 77 |
78 | 87 |
88 | 89 | {error} 90 | 91 |
92 |
93 |
94 | ); 95 | } 96 | 97 | export default LoginForm; 98 | -------------------------------------------------------------------------------- /packages/client/src/MainApp.tsx: -------------------------------------------------------------------------------- 1 | // This is a wrapper for the Manager experience, whose job it is to 2 | // get the web worker started and authorized. 3 | // 4 | 5 | import React, { useState, useEffect, useCallback } from "react"; 6 | import APIWorker from "./clientworker"; 7 | import Manager from "./Manager"; 8 | import { windowLocation } from "./windowutils"; 9 | import { Remote } from "comlink"; 10 | 11 | type MainAppProps = { 12 | api: Promise>; 13 | }; 14 | 15 | const cache: Record> = {}; 16 | 17 | // ensures that callback() is only called once in development mode. 18 | function justOneCallback(api: Remote, code: string, domain: string) { 19 | const key = code + domain; 20 | if (key in cache) return cache[key]; 21 | 22 | const res = api.callback(code, domain); 23 | cache[key] = res; 24 | return res; 25 | } 26 | 27 | export default function MainApp({ api }: MainAppProps) { 28 | // Whether or not we think we are ready 29 | const [ready, setReady] = useState(false); 30 | // A redirect when we need it 31 | const [redirect, setRedirect] = useState(null); 32 | // A timer 33 | const [timer, setTimer] = useState(null); 34 | 35 | useEffect(() => { 36 | const timer = setTimeout(() => { 37 | setRedirect("/main"); 38 | }, 10000); 39 | setTimer(timer); 40 | return () => clearTimeout(timer); 41 | }, []); 42 | 43 | // Method to transform a code into a login token (which we'll never see, 44 | // but our worker will) 45 | const callbackCB = useCallback( 46 | async (code: string, domain: string) => { 47 | const remote = await api; 48 | justOneCallback(remote, code, domain) 49 | .then(async () => await remote.ready()) 50 | .then((ready: boolean) => { 51 | setReady(ready); 52 | }); 53 | }, 54 | [api, setReady] 55 | ); 56 | 57 | if (redirect) { 58 | windowLocation.href = redirect; 59 | return

Redirecting for authorization...

; 60 | } 61 | 62 | // If we have a code and domain, redeem here. 63 | const code = localStorage.getItem("code"); 64 | const domain = localStorage.getItem("domain"); 65 | 66 | if (code && domain) { 67 | // TODO: Add catch here. 68 | callbackCB(code, domain); 69 | } 70 | 71 | // If we are ready, render the manager. 72 | if (ready) { 73 | // Dump the code and domain 74 | localStorage.removeItem("code"); 75 | localStorage.removeItem("domain"); 76 | // If we have made it here, cancel the timer. 77 | if (timer) clearTimeout(timer); 78 | return ; 79 | } 80 | 81 | // Now, we'll also need a timeout in case we get stuck here for some reason 82 | // (we can't get to the ready state, e.g. because callback fails) 83 | 84 | // Last resort render 85 | return Loading...; 86 | } 87 | -------------------------------------------------------------------------------- /packages/client/src/MainPage.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Box from "@mui/material/Box"; 3 | import Container from "@mui/material/Container"; 4 | import Link from "@mui/material/Link"; 5 | import Typography from "@mui/material/Typography"; 6 | import { createTheme, ThemeProvider } from "@mui/material/styles"; 7 | import LoginForm from "./LoginForm"; 8 | 9 | function Copyright() { 10 | return ( 11 |
12 | 13 | {"Copyright © "} 14 | 15 | Andrew Beers 16 | {" "} 17 | {new Date().getFullYear()} 18 | {". "} 19 | 20 | 21 | 25 | License 26 | 27 | {" | "} 28 | 29 | Source 30 | 31 | 32 |
33 | ); 34 | } 35 | 36 | const theme = createTheme(); 37 | 38 | function MainPage() { 39 | return ( 40 | 41 | 48 | 49 | 56 | Mastodon List Manager 57 | 58 | 64 | A really simple manager for all of your Mastodon Lists, letting you 65 | quickly assign people that you are following to one or more lists. 66 | Search, filter, and organize your follow list to make reasoning 67 | about your lists easy. 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | Other stuff you should know: 76 | 77 | 78 |
    79 |
  • 80 | This should work even if you are following many thousands of 81 | accounts. Use the page size to keep the size of the tables under 82 | control, and search/group/filter to find the accounts you want 83 | to change. 84 |
  • 85 |
  • 86 | I collect some telemetry about usage that's tied to your account 87 | name that I store for two weeks, to aid in support. 88 |
  • 89 |
  • 90 | So far everything is free, and you are limited only by the API 91 | limits on your Mastodon instance. However, if you'd like to 92 | support, please feel free to{" "} 93 | buy me a coffee! 94 |
  • 95 |
96 |
97 |
98 |
99 | {/* Footer */} 100 | 104 | 105 | 106 | {/* End footer */} 107 |
108 | ); 109 | } 110 | 111 | export default MainPage; 112 | -------------------------------------------------------------------------------- /packages/client/src/Manager.css: -------------------------------------------------------------------------------- 1 | .Manager { 2 | margin-left: 8px; 3 | display: flex; 4 | flex-direction: column; 5 | height: 100vh; 6 | } 7 | 8 | th { 9 | display: table-cell; 10 | vertical-align: bottom; 11 | text-align: center; 12 | } 13 | 14 | table { 15 | border-spacing: 0; 16 | } 17 | 18 | .cell { 19 | cursor: arrow; 20 | user-select: none; 21 | border-right: 1px solid lightgray; 22 | border-bottom: 1px dashed #eee; 23 | padding: 0; 24 | text-align: center; 25 | } 26 | 27 | .userpop { 28 | max-width: 400px; 29 | } 30 | 31 | div.group { 32 | font-size: 12pt; 33 | cursor: pointer; 34 | background-color: #eee; 35 | } 36 | 37 | #alltables { 38 | flex-grow: 1; 39 | overflow: scroll; 40 | } 41 | -------------------------------------------------------------------------------- /packages/client/src/TimeoutDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Button from "@mui/material/Button"; 3 | import Dialog from "@mui/material/Dialog"; 4 | import DialogActions from "@mui/material/DialogActions"; 5 | import DialogContent from "@mui/material/DialogContent"; 6 | import DialogContentText from "@mui/material/DialogContentText"; 7 | import DialogTitle from "@mui/material/DialogTitle"; 8 | 9 | type TimeoutDialogProps = { 10 | open: boolean; 11 | handleClose: () => void; 12 | }; 13 | 14 | function TimeoutDialog({ open, handleClose }: TimeoutDialogProps) { 15 | return ( 16 | 22 | A Timeout Occurred 23 | 24 | 25 |

26 | A timeout has occurred communicating with your Mastodon server. It 27 | could be that the server is busy, in which case you could try again 28 | later. Or, you may be following lots of accounts or have long lists 29 | which are taking too long to retrieve. I'm working on changes to 30 | this service which will allow for longer lists and/or slower 31 | servers. 32 |

33 |
34 |
35 | 36 | 39 | 40 |
41 | ); 42 | } 43 | 44 | export default TimeoutDialog; 45 | -------------------------------------------------------------------------------- /packages/client/src/TopBar.test.js: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from "@testing-library/react"; 2 | import TopBar from "./TopBar"; 3 | 4 | test("renders the account info", () => { 5 | render(); 6 | const acct = screen.getByText(/@test@test.io/); 7 | expect(acct).toBeInTheDocument(); 8 | }); 9 | 10 | test("triggers new list callback", (done) => { 11 | const handler = () => done(); 12 | render(); 13 | // Open menu 14 | const menu = screen.getByTestId("topbar-menu"); 15 | fireEvent.click(menu); 16 | // Now click the button 17 | const button = screen.getByText("New List"); 18 | fireEvent.click(button); 19 | }); 20 | 21 | test("triggers about callback", (done) => { 22 | const handler = () => done(); 23 | render(); 24 | // Open menu 25 | const menu = screen.getByTestId("topbar-menu"); 26 | fireEvent.click(menu); 27 | // Now click the button 28 | const button = screen.getByText("About"); 29 | fireEvent.click(button); 30 | }); 31 | 32 | test("triggers logout callback", (done) => { 33 | const handler = () => done(); 34 | render(); 35 | // Open menu 36 | const menu = screen.getByTestId("topbar-menu"); 37 | fireEvent.click(menu); 38 | // Now click the button 39 | const button = screen.getByText("Logout"); 40 | fireEvent.click(button); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/client/src/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEvent } from "react"; 2 | import AppBar from "@mui/material/AppBar"; 3 | import Box from "@mui/material/Box"; 4 | import Menu from "@mui/material/Menu"; 5 | import MenuItem from "@mui/material/MenuItem"; 6 | import Toolbar from "@mui/material/Toolbar"; 7 | import Typography from "@mui/material/Typography"; 8 | 9 | import IconButton from "@mui/material/IconButton"; 10 | import MenuIcon from "@mui/icons-material/Menu"; 11 | 12 | type TopBarProps = { 13 | acct: string; 14 | handleMenuNewList: () => void; 15 | handleMenuExportList: () => void; 16 | handleMenuImportList: () => void; 17 | handleMenuAbout: () => void; 18 | handleMenuLogout: () => void; 19 | }; 20 | 21 | export default function TopBar({ 22 | acct, 23 | handleMenuNewList, 24 | handleMenuExportList, 25 | handleMenuImportList, 26 | handleMenuAbout, 27 | handleMenuLogout, 28 | }: TopBarProps) { 29 | // Menu anchor and handlers 30 | const [anchorMenuEl, setAnchorMenuEl] = React.useState( 31 | null 32 | ); 33 | const handleMenuClick = (event: MouseEvent) => { 34 | setAnchorMenuEl(event.currentTarget); 35 | }; 36 | const handleMenuClose = () => { 37 | setAnchorMenuEl(null); 38 | }; 39 | 40 | const clickMenuNewList = () => { 41 | handleMenuClose(); 42 | handleMenuNewList(); 43 | }; 44 | const clickMenuExportList = () => { 45 | handleMenuClose(); 46 | handleMenuExportList(); 47 | }; 48 | const clickMenuImportList = () => { 49 | handleMenuClose(); 50 | handleMenuImportList(); 51 | }; 52 | const clickMenuNewAbout = () => { 53 | handleMenuClose(); 54 | handleMenuAbout(); 55 | }; 56 | const clickMenuLogout = () => { 57 | handleMenuClose(); 58 | handleMenuLogout(); 59 | }; 60 | 61 | const menuOpen = Boolean(anchorMenuEl); 62 | 63 | return ( 64 | 65 | 66 | 67 | 76 | 77 | 78 | 87 | New List 88 | Export List 89 | Import List 90 | About 91 | Logout 92 | {" "} 93 | Mastodon List Manager  94 | (@{acct}) 95 |
96 | 101 | Buy Me a Coffee at ko-fi.com 107 | {" "} 108 |
109 |
110 |
111 |
112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /packages/client/src/UsersTable.css: -------------------------------------------------------------------------------- 1 | .followingTable { 2 | margin-bottom: 12px; 3 | } 4 | 5 | .followingTable thead { 6 | position: sticky; 7 | top: 0px; 8 | background-color: white; 9 | } 10 | 11 | .followingTable tr.following-row:hover { 12 | background-color: #efefff; 13 | } 14 | 15 | .followingTable tr td.hover { 16 | background-color: #efefff; 17 | } 18 | 19 | .followingTable tr th.hover { 20 | background-color: #efefff; 21 | } 22 | 23 | .followingTable tr td.nofollow { 24 | background-color: #ccc; 25 | } 26 | 27 | th.listname div.icon { 28 | display: inline; 29 | writing-mode: horizontal-tb; 30 | transform-origin: 12px 12px; 31 | transform: rotate(180deg); 32 | color: lightgray; 33 | } 34 | 35 | th.listname div.icon:hover { 36 | color: darkgray; 37 | } 38 | 39 | span.listTitle { 40 | writing-mode: vertical-lr; 41 | -webkit-transform: rotate(180deg); 42 | text-overflow: ellipsis; 43 | white-space: nowrap; 44 | max-height: 150px; 45 | overflow: hidden; 46 | } 47 | 48 | span.listTitle.expanded { 49 | max-height: none; 50 | } 51 | 52 | span.listTitle.newList { 53 | color: #77f; 54 | cursor: pointer; 55 | } 56 | 57 | td.usercell { 58 | padding: 0; 59 | padding-right: 4px; 60 | border-right: 1px solid lightgray; 61 | width: 300px; 62 | } 63 | 64 | td.suspended { 65 | background-color: #fbb; 66 | } 67 | 68 | td.limited { 69 | background-color: #ffb; 70 | } 71 | 72 | td.moved { 73 | background-color: #bff; 74 | } 75 | -------------------------------------------------------------------------------- /packages/client/src/UsersTable.test.js: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from "@testing-library/react"; 2 | import UsersTable from "./UsersTable"; 3 | 4 | /* TEST DATA */ 5 | 6 | const group_2members = { 7 | users: [ 8 | { 9 | id: "1", 10 | display_name: "user-1", 11 | acct: "user-1@domain", 12 | following: true, 13 | follower: false, 14 | lists: [], 15 | }, 16 | { 17 | id: "2", 18 | display_name: "user-2", 19 | acct: "user-2@domain", 20 | following: true, 21 | follower: false, 22 | lists: ["a"], 23 | }, 24 | ], 25 | }; 26 | 27 | const group_2members_suspended = { 28 | users: [ 29 | { 30 | id: "1", 31 | display_name: "user-1", 32 | acct: "user-1@domain", 33 | following: true, 34 | follower: false, 35 | lists: [], 36 | }, 37 | { 38 | id: "2", 39 | display_name: "user-2", 40 | acct: "user-2@domain", 41 | following: true, 42 | follower: false, 43 | suspended: true, 44 | lists: ["a"], 45 | }, 46 | ], 47 | }; 48 | 49 | const group_2members_limited = { 50 | users: [ 51 | { 52 | id: "1", 53 | display_name: "user-1", 54 | acct: "user-1@domain", 55 | following: true, 56 | follower: false, 57 | lists: [], 58 | }, 59 | { 60 | id: "2", 61 | display_name: "user-2", 62 | acct: "user-2@domain", 63 | following: true, 64 | follower: false, 65 | limited: true, 66 | lists: ["a"], 67 | }, 68 | ], 69 | }; 70 | 71 | const group_2members_moved = { 72 | users: [ 73 | { 74 | id: "1", 75 | display_name: "user-1", 76 | acct: "user-1@domain", 77 | following: true, 78 | follower: false, 79 | lists: [], 80 | }, 81 | { 82 | id: "2", 83 | display_name: "user-2", 84 | acct: "user-2@domain", 85 | following: true, 86 | follower: false, 87 | moved: { 88 | id: "11", 89 | acct: "moved@moved.io", 90 | }, 91 | lists: ["a"], 92 | }, 93 | ], 94 | }; 95 | 96 | const lists_2members = [ 97 | { id: "a", title: "list-a" }, 98 | { id: "b", title: "list-b" }, 99 | ]; 100 | 101 | function generateGroup(num) { 102 | return { 103 | users: Array.from(Array(num)).map((_, x) => ({ 104 | id: `${x}`, 105 | display_name: "user-" + x, 106 | acct: `user-${x}@domain`, 107 | following: true, 108 | follower: false, 109 | lists: x % 2 ? ["a"] : ["b"], 110 | })), 111 | }; 112 | } 113 | 114 | test("renders the whole list of users when open", () => { 115 | const group = group_2members; 116 | const lists = lists_2members; 117 | 118 | render( 119 | 120 | ); 121 | 122 | group.users.forEach((fol) => { 123 | const elt = screen.getByText(fol.display_name); 124 | expect(elt).toBeInTheDocument(); 125 | }); 126 | }); 127 | 128 | test("renders suspended users correctly when open", () => { 129 | const group = group_2members; 130 | const lists = lists_2members; 131 | 132 | render( 133 | 134 | ); 135 | 136 | group.users.forEach((fol) => { 137 | const elt = screen.getByText(fol.display_name); 138 | expect(elt).toBeInTheDocument(); 139 | // eslint-disable-next-line testing-library/no-node-access 140 | const row = elt.closest("td"); 141 | 142 | expect(row.className).toBe( 143 | fol.suspended ? "usercell suspended" : "usercell" 144 | ); 145 | }); 146 | }); 147 | 148 | test("renders all lists when open", () => { 149 | const group = group_2members; 150 | const lists = lists_2members; 151 | 152 | render( 153 | 154 | ); 155 | 156 | lists.forEach((list) => { 157 | const elt = screen.getByText(list.title); 158 | expect(elt).toBeInTheDocument(); 159 | }); 160 | }); 161 | 162 | test("has new list", () => { 163 | const group = group_2members; 164 | const lists = lists_2members; 165 | 166 | render( 167 | 168 | ); 169 | 170 | const newlist = screen.queryByTestId("new-list"); 171 | expect(newlist).toBeInTheDocument(); 172 | }); 173 | 174 | test("new list creates new list", (done) => { 175 | const group = group_2members; 176 | const lists = lists_2members; 177 | 178 | const handler = () => done(); 179 | 180 | render( 181 | 188 | ); 189 | 190 | const newlist = screen.queryByTestId("new-list"); 191 | fireEvent.click(newlist); 192 | }); 193 | 194 | test("opens and closes", () => { 195 | const group = group_2members; 196 | const lists = lists_2members; 197 | 198 | render(); 199 | 200 | const nouser = screen.queryByText(group.users[0].display_name); 201 | expect(nouser).toEqual(null); 202 | 203 | const expando = screen.getByTestId("ft-expando"); 204 | 205 | fireEvent.click(expando); 206 | const user = screen.queryByText(group.users[0].display_name); 207 | expect(user).toBeInTheDocument(); 208 | 209 | fireEvent.click(expando); 210 | const nouser2 = screen.queryByText(group.users[0].display_name); 211 | expect(nouser2).toEqual(null); 212 | }); 213 | 214 | test("adds", (done) => { 215 | const group = group_2members; 216 | const lists = lists_2members; 217 | 218 | const handler = () => done(); 219 | 220 | render( 221 | 228 | ); 229 | 230 | const u1 = group.users[0].id; 231 | const l1 = lists[0].id; 232 | const cell = screen.getByTestId(`${l1}${u1}`); 233 | 234 | fireEvent.click(cell); 235 | }); 236 | 237 | test("removes", (done) => { 238 | const group = group_2members; 239 | const lists = lists_2members; 240 | 241 | const handler = () => done(); 242 | 243 | render( 244 | 251 | ); 252 | 253 | const u2 = group.users[1].id; 254 | const l1 = lists[0].id; 255 | const cell = screen.getByTestId(`${l1}${u2}`); 256 | 257 | fireEvent.click(cell); 258 | }); 259 | 260 | test("adds on page 2", (done) => { 261 | const group = generateGroup(20); 262 | const lists = lists_2members; 263 | 264 | const uidx = 10; 265 | const u1 = group.users[uidx].id; 266 | const l1 = lists[0].id; 267 | 268 | const handler = (groupIndex, index, lid) => { 269 | expect(groupIndex).toEqual(1); 270 | expect(index).toEqual(uidx); 271 | expect(lid).toEqual(l1); 272 | done(); 273 | }; 274 | 275 | render( 276 | 284 | ); 285 | 286 | const nextButton = screen.getByTestId("next-page"); 287 | 288 | fireEvent.click(nextButton); 289 | 290 | const cell = screen.getByTestId(`${l1}${u1}`); 291 | fireEvent.click(cell); 292 | }); 293 | 294 | test("removes on page 2", (done) => { 295 | const group = generateGroup(20); 296 | const lists = lists_2members; 297 | 298 | const uidx = 11; 299 | const u1 = group.users[uidx].id; 300 | const l1 = lists[0].id; 301 | 302 | const handler = (groupIndex, index, lid) => { 303 | expect(groupIndex).toEqual(1); 304 | expect(index).toEqual(uidx); 305 | expect(lid).toEqual(l1); 306 | done(); 307 | }; 308 | 309 | render( 310 | 318 | ); 319 | 320 | const nextButton = screen.getByTestId("next-page"); 321 | 322 | fireEvent.click(nextButton); 323 | 324 | const cell = screen.getByTestId(`${l1}${u1}`); 325 | fireEvent.click(cell); 326 | }); 327 | 328 | test("shade suspended row", () => { 329 | const group = group_2members_suspended; 330 | const lists = lists_2members; 331 | 332 | render( 333 | 334 | ); 335 | 336 | const u1 = group.users[1].id; 337 | const cell = screen.getByTestId(`${u1}`); 338 | expect(cell.className).toContain("suspended"); 339 | }); 340 | 341 | test("shade limited row", () => { 342 | const group = group_2members_limited; 343 | const lists = lists_2members; 344 | 345 | render( 346 | 347 | ); 348 | 349 | const u1 = group.users[1].id; 350 | const cell = screen.getByTestId(`${u1}`); 351 | expect(cell.className).toContain("limited"); 352 | }); 353 | 354 | test("shade moved row", () => { 355 | const group = group_2members_moved; 356 | const lists = lists_2members; 357 | 358 | render( 359 | 360 | ); 361 | 362 | const u1 = group.users[1].id; 363 | const cell = screen.getByTestId(`${u1}`); 364 | expect(cell.className).toContain("moved"); 365 | }); 366 | -------------------------------------------------------------------------------- /packages/client/src/__mocks__/ComlinkHook.ts: -------------------------------------------------------------------------------- 1 | // This is incomplete - need all of the API methods mocked! 2 | class mockClientAPI { 3 | async auth(): Promise { 4 | return "mock_url"; 5 | } 6 | async ready(): Promise { 7 | return false; 8 | } 9 | } 10 | const clientComlinkHook = () => () => ({ proxy: mockClientAPI }); 11 | const serverComlinkHook = () => () => ({ proxy: mockClientAPI }); 12 | 13 | export { clientComlinkHook, serverComlinkHook }; 14 | -------------------------------------------------------------------------------- /packages/client/src/__mocks__/windowutils.ts: -------------------------------------------------------------------------------- 1 | export const windowLocation = { href: null }; 2 | -------------------------------------------------------------------------------- /packages/client/src/clientworker.ts: -------------------------------------------------------------------------------- 1 | // Web Worker 2 | 3 | import { 4 | User, 5 | List, 6 | APIData, 7 | ListAnalytics, 8 | TimeoutError, 9 | AuthError, 10 | } from "@mastodonlm/shared"; 11 | import * as Comlink from "comlink"; 12 | import { login } from "masto"; 13 | import { WorkerBase } from "./workerbase"; 14 | import { 15 | fetchAnalytics, 16 | follow, 17 | unfollow, 18 | follow_by_names, 19 | list_create, 20 | list_delete, 21 | list_add, 22 | list_remove, 23 | info_meta, 24 | info_following, 25 | info_followers, 26 | info_lists, 27 | } from "@mastodonlm/shared"; 28 | 29 | // Change the default number of stack frames 30 | Error.stackTraceLimit = 30; 31 | 32 | // Endpoints 33 | const urlAuth = process.env.REACT_APP_BACKEND_URL + "/auth"; 34 | const urlCallback = process.env.REACT_APP_BACKEND_URL + "/clientcallback"; 35 | const urlLogout = process.env.REACT_APP_BACKEND_URL + "/clientlogout"; 36 | 37 | // Given a fetch response, check it for errors and throw 38 | // reasonable exceptions if so. Otherwise, return the response 39 | // converted to JSON. 40 | const checkJSON = (resp: Response) => { 41 | if (resp.status === 401 || resp.status === 403) throw new AuthError(); 42 | if (!resp.ok) throw Error("An error occurred"); 43 | 44 | return resp 45 | .json() 46 | .then((data) => { 47 | if (data.errorType === "LambdaTimeoutError") 48 | throw new TimeoutError("Backend timeout"); 49 | if (data.message === "Service Unavailable") 50 | throw new TimeoutError("Backend timeout (likely)"); 51 | return data; 52 | }) 53 | .catch((err) => { 54 | throw new Error(`An error occurred: ${err}`); 55 | }); 56 | }; 57 | 58 | export default class APIWorker extends WorkerBase { 59 | private token: string | null = null; 60 | private domain: string | null = null; 61 | private debug: number = 0; 62 | 63 | constructor() { 64 | super(); 65 | this.debug = Date.now(); 66 | } 67 | 68 | private instance() { 69 | if (!this.token) throw Error("API not ready"); 70 | 71 | return login({ 72 | url: `https://${this.domain}`, 73 | accessToken: this.token, 74 | disableVersionCheck: true, 75 | }); 76 | } 77 | 78 | async ready(): Promise { 79 | return this.token !== null; 80 | } 81 | 82 | async logout(): Promise { 83 | const params = { 84 | token: this.token, 85 | domain: this.domain, 86 | }; 87 | return fetch(urlLogout, { 88 | credentials: "include", 89 | method: "POST", 90 | body: JSON.stringify(params), 91 | }).then(() => { 92 | return; 93 | }); 94 | } 95 | 96 | // Returns a string with the authorize redirect 97 | async auth(domain: string): Promise { 98 | const res: string = await fetch(`${urlAuth}?domain=${domain}`) 99 | .then((resp) => checkJSON(resp)) 100 | .then((data) => data.url); 101 | return res; 102 | } 103 | 104 | // Given a code, completes the OAuth dance, storing a token for this 105 | // worker to use to access APIs. 106 | async callback(code: string, domain: string): Promise { 107 | // If we already have a token, don't run the below. 108 | if (this.token) return; 109 | 110 | const self = this; 111 | await fetch(`${urlCallback}?code=${code}&domain=${domain}`, { 112 | credentials: "include", 113 | method: "POST", 114 | }) 115 | .then((resp) => checkJSON(resp)) 116 | .then((data) => { 117 | self.token = data.token; 118 | self.domain = domain; 119 | }); 120 | } 121 | 122 | // Returns information about follows and lists 123 | // Returns an object of type APIData 124 | async info(callback: (value: number) => void): Promise { 125 | if (!this.ready()) throw Error("API not ready"); 126 | if (!this.token) throw Error("API not ready"); 127 | if (!this.domain) throw Error("API not ready - no domain"); 128 | 129 | const domain = this.domain; 130 | 131 | const self = this; 132 | return login({ 133 | url: `https://${this.domain}`, 134 | accessToken: this.token, 135 | disableVersionCheck: true, 136 | }).then(async (masto) => { 137 | // Fetch our server version and store it. 138 | await masto.v1.instances.fetch().then((inst) => { 139 | self.server_version = inst.version; 140 | }); 141 | return info_meta(masto, domain) 142 | .then((res) => ({ 143 | me: res.me, 144 | lists: res.lists, 145 | users: [], 146 | })) 147 | .then(async (data: APIData) => { 148 | // Remember ourselves 149 | self.me = data.me; 150 | // Build a map to track duplicates 151 | const userMap: Record = {}; 152 | const totalwork = 153 | data.me.following_count + 154 | data.me.follower_count + 155 | data.lists.length; 156 | 157 | // First people that we are following 158 | let following: User[] = []; 159 | const followingcb = (num: number) => 160 | callback((100 * num) / totalwork); 161 | await info_following(masto, data.me.id, domain, followingcb).then( 162 | (res) => { 163 | following = res; 164 | following.forEach((fol) => { 165 | userMap[fol.id] = fol; 166 | }); 167 | } 168 | ); 169 | // Now, those following us 170 | const followerscb = (num: number) => 171 | callback((100 * (data.me.following_count + num)) / totalwork); 172 | await info_followers(masto, data.me.id, domain, followerscb).then( 173 | (res) => { 174 | res.forEach((acct) => { 175 | if (userMap[acct.id]) { 176 | userMap[acct.id].follower = true; 177 | } else { 178 | userMap[acct.id] = acct; 179 | } 180 | }); 181 | } 182 | ); 183 | // Now pull list memberships 184 | const listscb = (num: number) => 185 | callback( 186 | (100 * (data.me.following_count + data.me.follower_count + num)) / 187 | totalwork 188 | ); 189 | await info_lists(masto, listscb).then((res) => { 190 | data.lists.forEach((list) => { 191 | const userids = res[list.id]; 192 | userids.forEach((userid) => { 193 | const fol = userMap[userid]; 194 | if (fol) { 195 | fol.lists.push(list.id); 196 | } 197 | }); 198 | }); 199 | }); 200 | return { 201 | users: Object.values(userMap), 202 | lists: data.lists, 203 | me: data.me, 204 | }; 205 | }) 206 | .then((data) => data); 207 | }); 208 | } 209 | 210 | // Creates a new list 211 | async createList(list_name: string): Promise { 212 | return this.instance().then((masto) => list_create(masto, list_name)); 213 | } 214 | 215 | // Deletes a list 216 | async deleteList(list_id: string): Promise { 217 | return this.instance().then((masto) => list_delete(masto, list_id)); 218 | } 219 | 220 | // Adds a user to a list 221 | async addToList(list_id: string, follower_id: string): Promise { 222 | if (!this.ready()) throw Error("API not ready"); 223 | if (!this.token) throw Error("API not ready"); 224 | 225 | return this.instance().then((masto) => 226 | list_add(masto, list_id, [follower_id]) 227 | ); 228 | } 229 | 230 | // Removes a user from a list 231 | async removeFromList(list_id: string, follower_id: string): Promise { 232 | if (!this.ready()) throw Error("API not ready"); 233 | if (!this.token) throw Error("API not ready"); 234 | 235 | return this.instance().then((masto) => 236 | list_remove(masto, list_id, [follower_id]) 237 | ); 238 | } 239 | 240 | // Creates a new list and imports data into it 241 | async importList(list_name: string, account_ids: string[]): Promise { 242 | // FIXME: We should allow importing into an existing list. 243 | // FIXME: Use list_create and list_add instead. 244 | return this.instance().then((masto) => { 245 | list_create(masto, list_name).then((list) => { 246 | list_add(masto, list.id, account_ids); 247 | }); 248 | }); 249 | } 250 | 251 | // Computes analytics for the given list 252 | async listAnalytics(list: List): Promise { 253 | return this.instance().then((masto) => fetchAnalytics(masto, list)); 254 | } 255 | 256 | // Follows an account 257 | async follow(userid: string): Promise { 258 | return this.instance().then((masto) => follow(masto, userid)); 259 | } 260 | 261 | // Follows an account 262 | async unfollow(userid: string): Promise { 263 | return this.instance().then((masto) => unfollow(masto, userid)); 264 | } 265 | 266 | // Follow a list of accounts by name (not ID) 267 | async follow_by_names(names: string[]): Promise { 268 | return this.instance().then((masto) => follow_by_names(masto, names)); 269 | } 270 | } 271 | Comlink.expose(APIWorker); 272 | -------------------------------------------------------------------------------- /packages/client/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /packages/client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client"; 2 | import "./index.css"; 3 | import App from "./App"; 4 | import reportWebVitals from "./reportWebVitals"; 5 | 6 | const docRoot = document.getElementById("root"); 7 | if (docRoot) { 8 | const root = ReactDOM.createRoot(docRoot); 9 | root.render(); 10 | } 11 | 12 | // If you want to start measuring performance in your app, pass a function 13 | // to log results (for example: reportWebVitals(console.log)) 14 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 15 | reportWebVitals(); 16 | -------------------------------------------------------------------------------- /packages/client/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/client/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /packages/client/src/serverworker.ts: -------------------------------------------------------------------------------- 1 | // API methods for List Manager 2 | 3 | import { 4 | User, 5 | List, 6 | APIData, 7 | ListAnalytics, 8 | TimeoutError, 9 | AuthError, 10 | } from "@mastodonlm/shared"; 11 | import * as Comlink from "comlink"; 12 | import { WorkerBase } from "./workerbase"; 13 | 14 | // Our endpoints 15 | const urlFollowing = process.env.REACT_APP_BACKEND_URL + "/following"; 16 | const urlFollowers = process.env.REACT_APP_BACKEND_URL + "/followers"; 17 | const urlMeta = process.env.REACT_APP_BACKEND_URL + "/meta"; 18 | const urlLists = process.env.REACT_APP_BACKEND_URL + "/lists"; 19 | const urlAdd = process.env.REACT_APP_BACKEND_URL + "/add"; 20 | const urlRemove = process.env.REACT_APP_BACKEND_URL + "/remove"; 21 | const urlCreate = process.env.REACT_APP_BACKEND_URL + "/create"; 22 | const urlDelete = process.env.REACT_APP_BACKEND_URL + "/delete"; 23 | const urlAuth = process.env.REACT_APP_BACKEND_URL + "/auth"; 24 | const urlCallback = process.env.REACT_APP_BACKEND_URL + "/callback"; 25 | const urlLogout = process.env.REACT_APP_BACKEND_URL + "/logout"; 26 | const urlAnalytics = process.env.REACT_APP_BACKEND_URL + "/analytics"; 27 | const urlFollow = process.env.REACT_APP_BACKEND_URL + "/follow"; 28 | const urlUnfollow = process.env.REACT_APP_BACKEND_URL + "/unfollow"; 29 | const urlFollowByNames = process.env.REACT_APP_BACKEND_URL + "/follow_by_names"; 30 | 31 | // Given a fetch response, check it for errors and throw 32 | // reasonable exceptions if so. Otherwise, return the response 33 | // converted to JSON. 34 | const checkJSON = (resp: Response) => { 35 | if (resp.status === 401 || resp.status === 403) throw new AuthError(); 36 | if (!resp.ok) throw Error("An error occurred"); 37 | 38 | return resp 39 | .json() 40 | .then((data) => { 41 | if (data.errorType === "LambdaTimeoutError") 42 | throw new TimeoutError("Backend timeout"); 43 | if (data.message === "Service Unavailable") 44 | throw new TimeoutError("Backend timeout (likely)"); 45 | return data; 46 | }) 47 | .catch((err) => { 48 | throw new Error(`An error occurred: ${err}`); 49 | }); 50 | }; 51 | 52 | // A server API that matches the client API in behavior. 53 | // This also runs in a Worker via Comlink. 54 | // NOTE: This may not be a great pattern, since the Server API should 55 | // more transparently deal with cookies. But if I just make it an async 56 | // API then the user of it needs to know, since it won't be accessible via comlink. 57 | // I could make Comlink proxy to something that is in this thread, though. 58 | // 59 | export default class ServerAPIWorker extends WorkerBase { 60 | private cookie: string = ""; 61 | 62 | // A version of fetch that passes our authentication 63 | private authenticatedFetch(url: string, options: RequestInit) { 64 | const requestHeaders: HeadersInit = new Headers(); 65 | const val = this.cookie; 66 | if (val) requestHeaders.set("authorization", val); 67 | 68 | return fetch(url, { 69 | credentials: "include", 70 | method: options.method || "GET", 71 | headers: requestHeaders, 72 | }); 73 | } 74 | 75 | private authGET(url: string) { 76 | return this.authenticatedFetch(url, { method: "GET" }); 77 | } 78 | 79 | private authPOST(url: string) { 80 | return this.authenticatedFetch(url, { method: "POST" }); 81 | } 82 | 83 | async ready(): Promise { 84 | return this.cookie !== null; 85 | } 86 | 87 | // Returns a string with the authorize redirect 88 | async auth(domain: string): Promise { 89 | return this.authGET(urlAuth) 90 | .then((resp) => checkJSON(resp)) 91 | .then((data) => data.url); 92 | } 93 | 94 | // Redeem an oauth code for a token. 95 | async callback(code: string, domain: string): Promise { 96 | const self = this; 97 | await fetch(`${urlCallback}?code=${code}&domain=${domain}`, { 98 | credentials: "include", 99 | method: "POST", 100 | body: JSON.stringify({ code: code, domain: domain }), 101 | }) 102 | .then((resp) => checkJSON(resp)) 103 | .then((data) => { 104 | self.cookie = data.auth; 105 | }); 106 | } 107 | 108 | async logout(): Promise { 109 | this.authPOST(urlLogout).then((resp) => checkJSON(resp)); 110 | } 111 | 112 | // Returns information about follows and lists 113 | // Returns an object of type APIData 114 | async info(callback: (value: number) => void): Promise { 115 | const self = this; 116 | return this.authGET(urlMeta) 117 | .then((resp) => checkJSON(resp)) 118 | .then((meta) => { 119 | // If this isn't the right kind nof response, just bail. 120 | // e.g. this may be a status: response that will kick off authentication. 121 | if (!meta.me) return meta; 122 | 123 | let following: User[] = []; 124 | let lists = meta.lists; 125 | const total = 3; 126 | 127 | self.me = meta.me; 128 | 129 | // Get followers 130 | // Get lists 131 | // Build per-follower list membership 132 | 133 | return this.authGET(urlFollowing) 134 | .then((resp) => checkJSON(resp)) 135 | .then((x) => { 136 | following = x.following; 137 | following.forEach((x) => { 138 | x.following = true; 139 | x.follower = false; 140 | }); 141 | callback(100 / total); 142 | }) 143 | .then(() => this.authGET(urlFollowers)) 144 | .then((resp) => checkJSON(resp)) 145 | .then((followers) => { 146 | // Update 147 | const followingMap: Record = {}; 148 | following.forEach((fol) => { 149 | followingMap[fol.id] = fol; 150 | }); 151 | followers.followers.forEach((fol: User) => { 152 | fol.follower = true; 153 | fol.following = false; 154 | if (fol.id in followingMap) { 155 | followingMap[fol.id].follower = true; 156 | } else following.push(fol); 157 | }); 158 | callback(100 / total); 159 | }) 160 | .then(() => this.authGET(urlLists)) 161 | .then((resp) => checkJSON(resp)) 162 | .then((resp) => { 163 | const listaccts = resp.lists; 164 | callback(200 / total); 165 | // Build up lists for each follower 166 | following.forEach((x) => { 167 | x.lists = []; 168 | }); 169 | // A map for easy lookup 170 | const followerMap: Record = {}; 171 | following.forEach((x) => (followerMap[x.id] = x)); 172 | // And, for each list 173 | lists.forEach((list: List, index: number) => { 174 | const accts = listaccts[list.id]; 175 | accts.forEach((acct: number) => { 176 | const fol = followerMap[acct]; 177 | if (fol) fol.lists.push(list.id); 178 | }); 179 | }); 180 | return { 181 | users: following, 182 | lists: lists, 183 | me: meta.me, 184 | }; 185 | }); 186 | }); 187 | } 188 | 189 | // Creates a new list 190 | async createList(list_name: string): Promise { 191 | return this.authPOST(`${urlCreate}?list_name=${list_name}`).then((resp) => 192 | checkJSON(resp) 193 | ); 194 | } 195 | 196 | // Deletes a list 197 | async deleteList(list_id: string): Promise { 198 | return this.authPOST(`${urlDelete}?list_id=${list_id}`).then((resp) => 199 | checkJSON(resp) 200 | ); 201 | } 202 | 203 | // Adds a user to a list 204 | async addToList(list_id: string, follower_id: string): Promise { 205 | return this.authPOST( 206 | `${urlAdd}?list_id=${list_id}&account_id=${follower_id}` 207 | ).then((resp) => checkJSON(resp)); 208 | } 209 | 210 | // Removes a user from a list 211 | async removeFromList(list_id: string, follower_id: string): Promise { 212 | return this.authPOST( 213 | `${urlRemove}?list_id=${list_id}&account_id=${follower_id}` 214 | ).then((resp) => checkJSON(resp)); 215 | } 216 | 217 | // Creates a new list and imports data into it 218 | async importList(list_name: string, accounts: string[]): Promise { 219 | return this.authPOST(`${urlCreate}?list_name=${list_name}`) 220 | .then((resp) => checkJSON(resp)) 221 | .then((list) => { 222 | return this.authPOST( 223 | `${urlAdd}?list_id=${list.id}&account_id=${accounts.join(",")}` 224 | ); 225 | }) 226 | .then((resp) => checkJSON(resp)); 227 | } 228 | 229 | // Computes analytics for the given list 230 | async listAnalytics(list: List): Promise { 231 | return this.authGET(`${urlAnalytics}?list_id=${list.id}`) 232 | .then((resp) => checkJSON(resp)) 233 | .then((data) => { 234 | // Data is just a JSON object. Fix some points 235 | const la = { 236 | ...data, 237 | latest_post: new Date(data.latest_post), 238 | earliest_post: new Date(data.earliest_post), 239 | }; 240 | return la; 241 | }); 242 | } 243 | 244 | // Follows an account 245 | async follow(userid: string): Promise { 246 | return this.authPOST(`${urlFollow}?user_id=${userid}`).then((resp) => 247 | checkJSON(resp) 248 | ); 249 | } 250 | 251 | // Follows an account 252 | async unfollow(userid: string): Promise { 253 | return this.authPOST(`${urlUnfollow}?user_id=${userid}`).then((resp) => 254 | checkJSON(resp) 255 | ); 256 | } 257 | 258 | // Follow a list of accounts by name (not ID) 259 | async follow_by_names(names: string[]): Promise { 260 | // FIXME: This is not returning the an array of User 261 | return this.authPOST(`${urlFollowByNames}?names=${names.join(",")}`) 262 | .then((resp) => checkJSON(resp)) 263 | .then((data) => data.users); 264 | } 265 | } 266 | 267 | Comlink.expose(ServerAPIWorker); 268 | -------------------------------------------------------------------------------- /packages/client/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /packages/client/src/windowutils.ts: -------------------------------------------------------------------------------- 1 | export const windowLocation = window.location; 2 | -------------------------------------------------------------------------------- /packages/client/src/workerbase.ts: -------------------------------------------------------------------------------- 1 | // Baseclass for client and server workers 2 | 3 | import { User } from "@mastodonlm/shared"; 4 | import { v4 as uuidv4 } from "uuid"; 5 | 6 | // Endpoints 7 | const urlTelemetry = process.env.REACT_APP_BACKEND_URL + "/telemetry"; 8 | const urlError = process.env.REACT_APP_BACKEND_URL + "/error"; 9 | 10 | export class WorkerBase { 11 | protected me: User | null = null; 12 | protected server_version: string | null = null; 13 | protected session: string = ""; 14 | 15 | constructor() { 16 | this.session = uuidv4(); 17 | } 18 | 19 | // Logs a telemetry event 20 | async telemetry(info: Record): Promise { 21 | const data = { ...info }; 22 | if (this.me) data.acct = this.me.acct; 23 | if (this.server_version) data.server_version = this.server_version; 24 | data.session = this.session; 25 | 26 | return fetch(urlTelemetry, { 27 | credentials: "include", 28 | method: "POST", 29 | body: JSON.stringify(data), 30 | }).then(() => { 31 | return; 32 | }); 33 | } 34 | 35 | // Logs an error event 36 | async error(info: Record): Promise { 37 | const data = { ...info }; 38 | if (this.me) data.acct = this.me.acct; 39 | data.session = this.session; 40 | 41 | return fetch(urlError, { 42 | credentials: "include", 43 | method: "POST", 44 | body: JSON.stringify(data), 45 | }).then(() => { 46 | return; 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/client/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/client/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], 3 | "linterOptions": { 4 | "exclude": [ 5 | "config/**/*.js", 6 | "node_modules/**/*.ts", 7 | "coverage/lcov-report/*.js" 8 | ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/.gitignore: -------------------------------------------------------------------------------- 1 | analytics_driver.ts 2 | -------------------------------------------------------------------------------- /packages/server/analytics_server.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | 3 | // Modules I'm mocking 4 | jest.unstable_mockModule("./factory", () => ({ 5 | Factory: { fromCookie: jest.fn() }, 6 | })); 7 | jest.unstable_mockModule("./datastore", () => ({ 8 | Datastore: { getAuth: jest.fn() }, 9 | })); 10 | jest.unstable_mockModule("@mastodonlm/shared", () => ({ 11 | fetchAnalytics: jest.fn(), 12 | })); 13 | 14 | // Things I'm mocking 15 | // Some values will be set inside tests. 16 | const factory = await import("./factory"); 17 | const Factory = factory.Factory; 18 | const datastore = await import("./datastore"); 19 | const Datastore = datastore.Datastore; 20 | const shared = await import("@mastodonlm/shared"); 21 | const fetchAnalytics = shared.fetchAnalytics; 22 | 23 | // fetchAnalytics should resolve to a value. 24 | fetchAnalytics.mockResolvedValue("test_value"); 25 | 26 | // Unit under test 27 | const mod = await import("./analytics_server"); 28 | const analytics = mod.analytics; 29 | 30 | afterEach(() => { 31 | jest.clearAllMocks(); 32 | }); 33 | 34 | beforeEach(() => { 35 | Factory.fromCookie.mockResolvedValue(jest.fn()); 36 | Datastore.getAuth.mockResolvedValue(jest.fn()); 37 | }); 38 | 39 | test("handles no cookie", (done) => { 40 | const event = { 41 | headers: {}, 42 | }; 43 | 44 | Factory.fromCookie.mockResolvedValue(null); 45 | 46 | analytics(event, {}).then((res) => { 47 | try { 48 | expect(res.statusCode).toBe(401); 49 | done(); 50 | } catch (err) { 51 | done(err); 52 | } 53 | }); 54 | }); 55 | 56 | test("handles cookie", (done) => { 57 | const event = { 58 | headers: { authorization: "value" }, 59 | queryStringParameters: { list_id: "listid" }, 60 | }; 61 | 62 | analytics(event, {}).then((res) => { 63 | try { 64 | expect(res.statusCode).toBe(200); 65 | expect(fetchAnalytics).toHaveBeenCalled(); 66 | done(); 67 | } catch (err) { 68 | done(err); 69 | } 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /packages/server/analytics_server.ts: -------------------------------------------------------------------------------- 1 | // Analytics TS backend driver 2 | 3 | import { fetchAnalytics } from "@mastodonlm/shared"; 4 | import { Handler } from "aws-lambda"; 5 | import { gen_handler } from "./utils"; 6 | import type { mastodon } from "masto"; 7 | 8 | // Convenient type aliases 9 | type Client = mastodon.Client; 10 | 11 | export const analytics: Handler = async (event, context) => { 12 | const list = { id: event.queryStringParameters?.list_id, title: "" }; 13 | const method = (masto: Client, domain: string) => fetchAnalytics(masto, list); 14 | return gen_handler(event, context, method); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/server/datastore.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; 2 | 3 | import { attribute, hashKey, table } from "@nova-odm/annotations"; 4 | import { DataMapper } from "@nova-odm/mapper"; 5 | 6 | // A class representing stored authentication information. 7 | @table(process.env.TABLE_AUTH || "list-manager") 8 | export class AuthTable { 9 | @hashKey() 10 | key: string; 11 | 12 | @attribute() 13 | token: string; 14 | 15 | @attribute() 16 | domain: string; 17 | 18 | @attribute() 19 | expires_at: string; 20 | } 21 | 22 | // A class representing stored authentication information. 23 | @table(process.env.TABLE_HOSTCFG || "list-manager") 24 | export class HostCfgTable { 25 | @hashKey() 26 | host: string; 27 | 28 | @attribute() 29 | client_id: string; 30 | 31 | @attribute() 32 | client_secret: string; 33 | } 34 | 35 | // Given a cookie, return stored login information 36 | export async function getAuth(cookie: string): Promise { 37 | const client = new DynamoDBClient({ region: "us-west-2" }); 38 | const mapper = new DataMapper({ client: client }); 39 | 40 | const toFetch = new AuthTable(); 41 | toFetch.key = cookie; 42 | return mapper.get({ item: toFetch }).then((ret) => { 43 | if ("item" in ret) return ret.item; 44 | return ret; 45 | }); 46 | } 47 | 48 | // Given a host, return its host configuration 49 | export async function getHostCfg(host: string): Promise { 50 | const client = new DynamoDBClient({ region: "us-west-2" }); 51 | const mapper = new DataMapper({ client: client }); 52 | 53 | const toFetch = new HostCfgTable(); 54 | toFetch.host = host; 55 | return mapper.get({ item: toFetch }).then((ret) => { 56 | if ("item" in ret) return ret.item; 57 | return ret; 58 | }); 59 | } 60 | export const Datastore = { getAuth, getHostCfg }; 61 | -------------------------------------------------------------------------------- /packages/server/factory.ts: -------------------------------------------------------------------------------- 1 | // Factory functions for Masto instances 2 | 3 | import { Datastore } from "./datastore"; 4 | import type { mastodon } from "masto"; 5 | import { login } from "masto"; 6 | 7 | // Convenient type aliases 8 | type Client = mastodon.Client; 9 | 10 | // Create a mastodon instance 11 | function instance(domain: string, token: string) { 12 | return login({ 13 | url: `https://${domain}`, 14 | accessToken: token, 15 | }); 16 | } 17 | 18 | async function fromCookie(cookie: string): Promise { 19 | // Get auth info 20 | return Datastore.getAuth(cookie).then((auth) => { 21 | if (!auth) return null; 22 | 23 | return instance(auth.domain, auth.token); 24 | }); 25 | } 26 | 27 | async function unauthenticated(domain: string): Promise { 28 | return login({ url: `https://{domain}` }); 29 | } 30 | 31 | export const Factory = { fromCookie, unauthenticated }; 32 | -------------------------------------------------------------------------------- /packages/server/follow_server.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | 3 | // Modules I'm mocking 4 | jest.unstable_mockModule("./factory", () => ({ 5 | Factory: { fromCookie: jest.fn() }, 6 | })); 7 | jest.unstable_mockModule("./datastore", () => ({ 8 | Datastore: { getAuth: jest.fn() }, 9 | })); 10 | jest.unstable_mockModule("@mastodonlm/shared", () => ({ 11 | follow: jest.fn(), 12 | unfollow: jest.fn(), 13 | follow_by_names: jest.fn(), 14 | })); 15 | 16 | // Things I'm mocking 17 | // Some values will be set inside tests. 18 | const factory = await import("./factory"); 19 | const Factory = factory.Factory; 20 | const datastore = await import("./datastore"); 21 | const Datastore = datastore.Datastore; 22 | const shared = await import("@mastodonlm/shared"); 23 | const follow = shared.follow; 24 | const unfollow = shared.unfollow; 25 | const follow_by_names = shared.follow_by_names; 26 | 27 | // follow should resolve to a value. 28 | follow.mockResolvedValue(void 0); 29 | unfollow.mockResolvedValue(void 0); 30 | 31 | // Unit under test 32 | const mod = await import("./follow_server"); 33 | const follow_handler = mod.follow_handler; 34 | const unfollow_handler = mod.unfollow_handler; 35 | const follow_by_names_handler = mod.follow_by_names_handler; 36 | 37 | test("follow succeeds", (done) => { 38 | const event = { 39 | headers: {}, 40 | queryStringParameters: { user_id: "123" }, 41 | }; 42 | 43 | Factory.fromCookie.mockResolvedValue(jest.fn()); 44 | Datastore.getAuth.mockResolvedValue(jest.fn()); 45 | follow.mockResolvedValue(void 0); 46 | 47 | follow_handler(event, {}).then((res) => { 48 | try { 49 | expect(res.statusCode).toBe(200); 50 | done(); 51 | } catch (err) { 52 | done(err); 53 | } 54 | }); 55 | }); 56 | 57 | test("follow fails", (done) => { 58 | const event = { 59 | headers: {}, 60 | queryStringParameters: { user_id: "123" }, 61 | }; 62 | 63 | Factory.fromCookie.mockResolvedValue(jest.fn()); 64 | Datastore.getAuth.mockResolvedValue(jest.fn()); 65 | follow.mockRejectedValue(void 0); 66 | 67 | follow_handler(event, {}).then((res) => { 68 | try { 69 | expect(res.statusCode).toBe(500); 70 | done(); 71 | } catch (err) { 72 | done(err); 73 | } 74 | }); 75 | }); 76 | 77 | test("unfollow succeeds", (done) => { 78 | const event = { 79 | headers: {}, 80 | queryStringParameters: { user_id: "123" }, 81 | }; 82 | 83 | Factory.fromCookie.mockResolvedValue(jest.fn()); 84 | Datastore.getAuth.mockResolvedValue(jest.fn()); 85 | unfollow.mockResolvedValue(void 0); 86 | 87 | unfollow_handler(event, {}).then((res) => { 88 | try { 89 | expect(res.statusCode).toBe(200); 90 | done(); 91 | } catch (err) { 92 | done(err); 93 | } 94 | }); 95 | }); 96 | 97 | test("unfollow fails", (done) => { 98 | const event = { 99 | headers: {}, 100 | queryStringParameters: { user_id: "123" }, 101 | }; 102 | 103 | Factory.fromCookie.mockResolvedValue(jest.fn()); 104 | Datastore.getAuth.mockResolvedValue(jest.fn()); 105 | unfollow.mockRejectedValue(void 0); 106 | 107 | unfollow_handler(event, {}).then((res) => { 108 | try { 109 | expect(res.statusCode).toBe(500); 110 | done(); 111 | } catch (err) { 112 | done(err); 113 | } 114 | }); 115 | }); 116 | 117 | test("follow_by_names succeeds", (done) => { 118 | const event = { 119 | headers: {}, 120 | queryStringParameters: { user_id: "123" }, 121 | }; 122 | 123 | Factory.fromCookie.mockResolvedValue(jest.fn()); 124 | Datastore.getAuth.mockResolvedValue(jest.fn()); 125 | follow_by_names.mockResolvedValue([{}]); 126 | 127 | follow_by_names_handler(event, {}).then((res) => { 128 | try { 129 | expect(res.statusCode).toBe(200); 130 | done(); 131 | } catch (err) { 132 | done(err); 133 | } 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /packages/server/follow_server.ts: -------------------------------------------------------------------------------- 1 | // Follow / unfollow backend server 2 | 3 | import { follow, unfollow, follow_by_names } from "@mastodonlm/shared"; 4 | import { Handler } from "aws-lambda"; 5 | import { gen_handler } from "./utils"; 6 | import type { mastodon } from "masto"; 7 | 8 | // Convenient type aliases 9 | type Client = mastodon.Client; 10 | 11 | export const follow_handler: Handler = async (event, context) => { 12 | const userid = event.queryStringParameters?.user_id; 13 | const method = (masto: Client, domain: string) => 14 | follow(masto, userid).then(() => ({})); 15 | return gen_handler(event, context, method); 16 | }; 17 | 18 | export const unfollow_handler: Handler = async (event, context) => { 19 | const userid = event.queryStringParameters?.user_id; 20 | const method = (masto: Client, domain: string) => 21 | unfollow(masto, userid).then(() => ({})); 22 | return gen_handler(event, context, method); 23 | }; 24 | 25 | export const follow_by_names_handler: Handler = async (event, context) => { 26 | const namesStr = event.queryStringParameters?.names || ""; 27 | const names = namesStr.split(","); 28 | const method = (masto: Client, domain: string) => 29 | follow_by_names(masto, names).then((res) => { 30 | return { users: res }; 31 | }); 32 | return gen_handler(event, context, method); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/server/info_server.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | 3 | // Modules I'm mocking 4 | jest.unstable_mockModule("./factory", () => ({ 5 | Factory: { fromCookie: jest.fn() }, 6 | })); 7 | jest.unstable_mockModule("./datastore", () => ({ 8 | Datastore: { getAuth: jest.fn() }, 9 | })); 10 | jest.unstable_mockModule("@mastodonlm/shared", () => ({ 11 | info_meta: jest.fn(), 12 | info_following: jest.fn(), 13 | info_followers: jest.fn(), 14 | info_lists: jest.fn(), 15 | })); 16 | 17 | // Things I'm mocking 18 | // Some values will be set inside tests. 19 | const factory = await import("./factory"); 20 | const Factory = factory.Factory; 21 | const datastore = await import("./datastore"); 22 | const Datastore = datastore.Datastore; 23 | const shared = await import("@mastodonlm/shared"); 24 | const info_meta = shared.info_meta; 25 | const info_following = shared.info_following; 26 | const info_followers = shared.info_followers; 27 | 28 | // Unit under test 29 | const mod = await import("./info_server"); 30 | const info_meta_handler = mod.info_meta_handler; 31 | const info_following_handler = mod.info_following_handler; 32 | const info_followers_handler = mod.info_followers_handler; 33 | 34 | afterEach(() => { 35 | jest.clearAllMocks(); 36 | }); 37 | 38 | beforeEach(() => { 39 | Factory.fromCookie.mockResolvedValue(jest.fn()); 40 | Datastore.getAuth.mockResolvedValue(jest.fn()); 41 | }); 42 | 43 | test("meta succeeds", (done) => { 44 | const event = { 45 | headers: {}, 46 | queryStringParameters: {}, 47 | }; 48 | 49 | info_meta.mockResolvedValue({}); 50 | 51 | info_meta_handler(event, {}).then((res) => { 52 | try { 53 | expect(info_meta).toHaveBeenCalled(); 54 | expect(res.statusCode).toBe(200); 55 | done(); 56 | } catch (err) { 57 | done(err); 58 | } 59 | }); 60 | }); 61 | 62 | test("following succeeds", (done) => { 63 | const event = { 64 | headers: {}, 65 | queryStringParameters: {}, 66 | }; 67 | 68 | info_following.mockResolvedValue({}); 69 | 70 | info_following_handler(event, {}).then((res) => { 71 | try { 72 | expect(info_following).toHaveBeenCalled(); 73 | expect(res.statusCode).toBe(200); 74 | done(); 75 | } catch (err) { 76 | done(err); 77 | } 78 | }); 79 | }); 80 | 81 | test("followers succeeds", (done) => { 82 | const event = { 83 | headers: {}, 84 | queryStringParameters: {}, 85 | }; 86 | 87 | info_followers.mockResolvedValue({}); 88 | 89 | info_followers_handler(event, {}).then((res) => { 90 | try { 91 | expect(info_followers).toHaveBeenCalled(); 92 | expect(res.statusCode).toBe(200); 93 | done(); 94 | } catch (err) { 95 | done(err); 96 | } 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /packages/server/info_server.ts: -------------------------------------------------------------------------------- 1 | // Info operations backend server 2 | 3 | import { 4 | info_meta, 5 | info_following, 6 | info_followers, 7 | info_lists, 8 | } from "@mastodonlm/shared"; 9 | import { Handler } from "aws-lambda"; 10 | import { gen_handler } from "./utils"; 11 | import type { mastodon } from "masto"; 12 | 13 | // Convenient type aliases 14 | type Client = mastodon.Client; 15 | 16 | export const info_meta_handler: Handler = async (event, context) => { 17 | const method = (masto: Client, domain: string) => info_meta(masto, domain); 18 | return gen_handler(event, context, method); 19 | }; 20 | 21 | export const info_following_handler: Handler = async (event, context) => { 22 | const method = (masto: Client, domain: string) => { 23 | return info_following(masto, null, domain, null).then((res) => ({ 24 | following: res, 25 | })); 26 | }; 27 | 28 | return gen_handler(event, context, method); 29 | }; 30 | 31 | export const info_followers_handler: Handler = async (event, context) => { 32 | const method = (masto: Client, domain: string) => { 33 | return info_followers(masto, null, domain, null).then((res) => ({ 34 | followers: res, 35 | })); 36 | }; 37 | 38 | return gen_handler(event, context, method); 39 | }; 40 | 41 | export const info_lists_handler: Handler = async (event, context) => { 42 | const method = (masto: Client, domain: string) => 43 | info_lists(masto, null).then((res) => ({ lists: res })); 44 | return gen_handler(event, context, method); 45 | }; 46 | -------------------------------------------------------------------------------- /packages/server/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | const config = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | transform: { 6 | "^.+\\.tsx?$": "ts-jest", 7 | }, 8 | }; 9 | export default config; 10 | -------------------------------------------------------------------------------- /packages/server/listcrud_server.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | 3 | // Modules I'm mocking 4 | jest.unstable_mockModule("./factory", () => ({ 5 | Factory: { fromCookie: jest.fn() }, 6 | })); 7 | jest.unstable_mockModule("./datastore", () => ({ 8 | Datastore: { getAuth: jest.fn() }, 9 | })); 10 | jest.unstable_mockModule("@mastodonlm/shared", () => ({ 11 | list_create: jest.fn(), 12 | list_delete: jest.fn(), 13 | list_add: jest.fn(), 14 | list_remove: jest.fn(), 15 | })); 16 | 17 | // Things I'm mocking 18 | // Some values will be set inside tests. 19 | const factory = await import("./factory"); 20 | const Factory = factory.Factory; 21 | const datastore = await import("./datastore"); 22 | const Datastore = datastore.Datastore; 23 | const shared = await import("@mastodonlm/shared"); 24 | const list_create = shared.list_create; 25 | const list_delete = shared.list_delete; 26 | const list_add = shared.list_add; 27 | const list_remove = shared.list_remove; 28 | 29 | // Unit under test 30 | const mod = await import("./listcrud_server"); 31 | 32 | const list_create_handler = mod.list_create_handler; 33 | const list_delete_handler = mod.list_delete_handler; 34 | const list_add_handler = mod.list_add_handler; 35 | const list_remove_handler = mod.list_remove_handler; 36 | 37 | afterEach(() => { 38 | jest.clearAllMocks(); 39 | }); 40 | 41 | beforeEach(() => { 42 | Factory.fromCookie.mockResolvedValue(jest.fn()); 43 | Datastore.getAuth.mockResolvedValue(jest.fn()); 44 | }); 45 | 46 | test("list create succeeds, return a list", (done) => { 47 | const event = { 48 | headers: {}, 49 | queryStringParameters: { list_name: "list-123" }, 50 | }; 51 | 52 | list_create.mockResolvedValue({ list: { id: 123, title: "list-123" } }); 53 | 54 | list_create_handler(event, {}).then((res) => { 55 | const exp = { 56 | list: { id: 123, title: "list-123" }, 57 | status: "OK", 58 | }; 59 | try { 60 | expect(res.statusCode).toBe(200); 61 | expect(res.body).toBe(JSON.stringify(exp)); 62 | expect(list_create).toHaveBeenCalled(); 63 | done(); 64 | } catch (err) { 65 | done(err); 66 | } 67 | }); 68 | }); 69 | 70 | test("list create fails, return an error", (done) => { 71 | const event = { 72 | headers: {}, 73 | queryStringParameters: { list_name: "list-123" }, 74 | }; 75 | 76 | list_create.mockRejectedValue(jest.fn()); 77 | 78 | list_create_handler(event, {}).then((res) => { 79 | try { 80 | expect(res.statusCode).toBe(500); 81 | expect(list_create).toHaveBeenCalled(); 82 | done(); 83 | } catch (err) { 84 | done(err); 85 | } 86 | }); 87 | }); 88 | 89 | test("list delete succeeds", (done) => { 90 | const event = { 91 | headers: {}, 92 | queryStringParameters: { list_id: "123" }, 93 | }; 94 | 95 | list_delete.mockResolvedValue(jest.fn()); 96 | 97 | list_delete_handler(event, {}).then((res) => { 98 | try { 99 | expect(res.statusCode).toBe(200); 100 | expect(list_delete).toHaveBeenCalled(); 101 | done(); 102 | } catch (err) { 103 | done(err); 104 | } 105 | }); 106 | }); 107 | 108 | test("list delete fails, return an error", (done) => { 109 | const event = { 110 | headers: {}, 111 | queryStringParameters: { list_name: "list-123" }, 112 | }; 113 | 114 | list_delete.mockRejectedValue(jest.fn()); 115 | 116 | list_delete_handler(event, {}).then((res) => { 117 | try { 118 | expect(res.statusCode).toBe(500); 119 | expect(list_delete).toHaveBeenCalled(); 120 | done(); 121 | } catch (err) { 122 | done(err); 123 | } 124 | }); 125 | }); 126 | 127 | test("list add succeeds", (done) => { 128 | const event = { 129 | headers: {}, 130 | queryStringParameters: { list_id: "123", account_id: "45" }, 131 | }; 132 | 133 | list_add.mockResolvedValue(jest.fn()); 134 | 135 | list_add_handler(event, {}).then((res) => { 136 | try { 137 | expect(list_add).toBeCalled(); 138 | expect(res.statusCode).toBe(200); 139 | done(); 140 | } catch (err) { 141 | done(err); 142 | } 143 | }); 144 | }); 145 | 146 | test("list add fails, return an error", (done) => { 147 | const event = { 148 | headers: {}, 149 | queryStringParameters: { list_name: "list-123", account_id: "45" }, 150 | }; 151 | 152 | list_add.mockRejectedValue(jest.fn()); 153 | 154 | list_add_handler(event, {}).then((res) => { 155 | try { 156 | expect(list_add).toBeCalled(); 157 | expect(res.statusCode).toBe(500); 158 | done(); 159 | } catch (err) { 160 | done(err); 161 | } 162 | }); 163 | }); 164 | 165 | test("list remove succeeds", (done) => { 166 | const event = { 167 | headers: {}, 168 | queryStringParameters: { list_id: "123", account_id: "45" }, 169 | }; 170 | 171 | list_remove.mockResolvedValue(jest.fn()); 172 | 173 | list_remove_handler(event, {}).then((res) => { 174 | try { 175 | expect(list_remove).toBeCalled(); 176 | expect(res.statusCode).toBe(200); 177 | done(); 178 | } catch (err) { 179 | done(err); 180 | } 181 | }); 182 | }); 183 | 184 | test("list remove fails, return an error", (done) => { 185 | const event = { 186 | headers: {}, 187 | queryStringParameters: { list_name: "list-123", account_id: "45" }, 188 | }; 189 | 190 | list_remove.mockRejectedValue(jest.fn()); 191 | 192 | list_remove_handler(event, {}).then((res) => { 193 | try { 194 | expect(list_remove).toBeCalled(); 195 | expect(res.statusCode).toBe(500); 196 | done(); 197 | } catch (err) { 198 | done(err); 199 | } 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /packages/server/listcrud_server.ts: -------------------------------------------------------------------------------- 1 | // List operations backend server 2 | 3 | import { 4 | list_create, 5 | list_delete, 6 | list_add, 7 | list_remove, 8 | } from "@mastodonlm/shared"; 9 | import { Handler } from "aws-lambda"; 10 | import { Factory } from "./factory"; 11 | import { ok_response, err_response, auth_response, gen_handler } from "./utils"; 12 | import type { mastodon } from "masto"; 13 | 14 | // Convenient type aliases 15 | type Client = mastodon.Client; 16 | 17 | export const list_create_handler: Handler = async (event, context) => { 18 | const list_name = event.queryStringParameters?.list_name; 19 | 20 | const method = (masto: Client, domain: string) => 21 | list_create(masto, list_name); 22 | return gen_handler(event, context, method); 23 | }; 24 | 25 | export const list_delete_handler: Handler = async (event, context) => { 26 | const list_id = event.queryStringParameters?.list_id; 27 | 28 | const method = (masto: Client, domain: string) => 29 | list_delete(masto, list_id).then(() => ({})); 30 | return gen_handler(event, context, method); 31 | }; 32 | 33 | export const list_add_handler: Handler = async (event, context) => { 34 | const list_id = event.queryStringParameters?.list_id; 35 | const accountids = event.queryStringParameters?.account_id.split(","); 36 | 37 | const method = (masto: Client, domain: string) => 38 | list_add(masto, list_id, accountids).then(() => ({})); 39 | return gen_handler(event, context, method); 40 | }; 41 | 42 | export const list_remove_handler: Handler = async (event, context) => { 43 | const list_id = event.queryStringParameters?.list_id; 44 | const accountids = event.queryStringParameters?.account_id.split(","); 45 | 46 | const method = (masto: Client, domain: string) => 47 | list_remove(masto, list_id, accountids).then(() => ({})); 48 | return gen_handler(event, context, method); 49 | }; 50 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mastodonlm/server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "type": "module", 6 | "scripts": { 7 | "test": "export TS_JEST_DISABLE_VER_CHECKER=true && node --experimental-vm-modules node_modules/jest/bin/jest.js --config ts-jest.config.cjs" 8 | }, 9 | "author": "acbeers@gmail.com", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@aws-sdk/client-dynamodb": "^3.310.0", 13 | "aws-lambda": "^1.0.7", 14 | "aws-sdk": "^2.1528.0", 15 | "ts-jest": "^29.1.0" 16 | }, 17 | "devDependencies": { 18 | "@types/aws-lambda": "^8.10.114", 19 | "jest": "^29.5.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/server/ts-jest.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | extensionsToTreatAsEsm: [".ts"], 4 | transform: { 5 | "\\.ts?$": ["ts-jest", { useESM: true }], 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | // 👇️ this may vary depending on how you 4 | // name your test files 5 | "exclude": ["*.test.ts", "dist"] 6 | } 7 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "types": ["node", "jest"], 6 | "preserveConstEnums": true, 7 | "strictNullChecks": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "allowJs": false, 11 | "moduleResolution": "node", 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "resolveJsonModule": true, 15 | "esModuleInterop": true, 16 | "outDir": "dist" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/utils.ts: -------------------------------------------------------------------------------- 1 | import { Handler, APIGatewayEvent, Context } from "aws-lambda"; 2 | import type { mastodon } from "masto"; 3 | import { Factory } from "./factory"; 4 | import { Datastore } from "./datastore"; 5 | 6 | // Convenient type aliases 7 | type Client = mastodon.Client; 8 | 9 | // Returns a OK response - a "status" key with a value 10 | // of "OK" will be injected into the provided object 11 | // and returned as an AWS lambda response. 12 | // 13 | export function ok_response(obj: object) { 14 | const body = { ...obj, status: "OK" }; 15 | const response = { 16 | statusCode: 200, 17 | body: JSON.stringify(body), 18 | }; 19 | 20 | return response; 21 | } 22 | 23 | // Returns an error response with the provided message 24 | // as a "status" key in an object. 25 | export function err_response(msg: string, obj = {}) { 26 | const body = { ...obj, status: msg }; 27 | const response = { 28 | statusCode: 500, 29 | body: JSON.stringify(body), 30 | }; 31 | 32 | return response; 33 | } 34 | 35 | // More specific errors 36 | export const badhost_response = (host: string) => 37 | err_response("bad_host", { host }); 38 | export const blocked_response = (host: string) => 39 | err_response("blocked", { host }); 40 | 41 | // Returns a HTTP status 401 AWS lambda response. 42 | export function auth_response() { 43 | return { statusCode: 401, body: "" }; 44 | } 45 | 46 | // A 'general' handler, which will get a mastodon object, then call 47 | // a caller-provided method which will return an object, which will 48 | // then be serialized to output. Errors will be caught and turned 49 | // into API-friendly responses. 50 | // 51 | export const gen_handler = async ( 52 | event: APIGatewayEvent, 53 | context: Context, 54 | method: (masto: Client, domain: string) => Promise 55 | ) => { 56 | const headers = event.headers || {}; 57 | const cookie = headers.authorization || ""; 58 | 59 | return Factory.fromCookie(cookie).then((masto) => { 60 | if (!masto) return auth_response(); 61 | 62 | return Datastore.getAuth(cookie).then((auth) => { 63 | const domain = auth.domain; 64 | 65 | // FIXME: ok_response assumes that the response is an object. info_following() 66 | // returns a list, which gets pretty shredded by ok_response. 67 | // Either accept this and fix ok_response, or standardize my responses to always be 68 | // an object e.g. {status:OK, followers:[]} 69 | return method(masto, domain) 70 | .then((info) => ok_response(info)) 71 | .catch((err) => err_response("meta failed")); 72 | }); 73 | }); 74 | }; 75 | 76 | export function auth_request_url( 77 | base_url: string, 78 | client_id: string, 79 | redirect_uris: string = "urn:ietf:wg:oauth:2.0:oob", 80 | scopes: string[] = ["read"], 81 | force_login: boolean = false, 82 | state = null, 83 | lang = null 84 | ) { 85 | const params = { 86 | client_id: client_id, 87 | response_type: "code", 88 | redirect_uri: redirect_uris, 89 | scope: scopes.join(" "), 90 | force_login: force_login, 91 | state: state, 92 | lang: lang, 93 | }; 94 | 95 | const formatted_params = Object.keys(params).map( 96 | (param) => `${param}=${encodeURIComponent(params[param])}` 97 | ); 98 | return `${base_url}/oauth/authorize?${formatted_params.join("&")}`; 99 | } 100 | -------------------------------------------------------------------------------- /packages/shared/analytics.ts: -------------------------------------------------------------------------------- 1 | import type { mastodon } from "masto"; 2 | import { List, ListAnalytics, User } from "./types"; 3 | 4 | // Convenient type aliases 5 | type Status = mastodon.v1.Status; 6 | type Account = mastodon.v1.Account; 7 | type Client = mastodon.Client; 8 | 9 | // Convert a masto.Account object to a User object 10 | function makeUser(acct: Account): User { 11 | return { 12 | id: acct.id, 13 | display_name: acct.displayName, 14 | username: acct.username, 15 | avatar: acct.avatar, 16 | acct: acct.acct, 17 | note: acct.note, 18 | lists: [], 19 | following_count: acct.followingCount, 20 | follower_count: acct.followersCount, 21 | following: false, 22 | follower: false, 23 | suspended: false, 24 | limited: false, 25 | moved: undefined, 26 | }; 27 | } 28 | 29 | // From a list of statuses, pull out a mapping of account id to 30 | // a User object. 31 | // Second parameter maps from a status to an account object or null. 32 | // e.g. pass in ((st) => st.reblog.account) to get the boosted account. 33 | function getAccountMap( 34 | statuses: Status[], 35 | accessor?: (s: Status) => Account | null 36 | ) { 37 | const getacc = accessor || ((s: Status) => s.account); 38 | 39 | const res: Record = {}; 40 | statuses.forEach((st) => { 41 | const acc = getacc(st); 42 | if (acc) res[acc.id] = acc; 43 | }); 44 | 45 | return res; 46 | } 47 | 48 | interface CountAccounts { 49 | accessor?: (s: Status) => Account | null; 50 | predicate?: (s: Status) => boolean; 51 | } 52 | // Given a list of statuses, compute counts of account IDs 53 | // Second parameter maps from a status to an account object or null. 54 | // e.g. pass in ((st) => st.reblog.account) to get the boosted account. 55 | function countAccounts( 56 | statuses: Status[], 57 | { 58 | accessor = (s: Status) => s.account, 59 | predicate = (s: Status) => true, 60 | }: CountAccounts 61 | ) { 62 | const counts: Record = {}; 63 | 64 | statuses.forEach((st) => { 65 | const acc = accessor(st); 66 | if (predicate(st)) { 67 | if (acc) counts[acc.id] = (counts[acc.id] || 0) + 1; 68 | } 69 | }); 70 | return counts; 71 | } 72 | 73 | // Given a mapping from string to a count, return the top num 74 | // records, in descending order. 75 | function topCounts(counts: Record, num = 5) { 76 | const sortedKeys = Object.keys(counts).sort((a, b) => counts[b] - counts[a]); 77 | return sortedKeys.slice(0, 5); 78 | } 79 | 80 | // Fetch list analytics 81 | // 82 | export async function fetchAnalytics( 83 | masto: Client, 84 | list: List 85 | ): Promise { 86 | // Parameters for our analytics. 87 | // We are going to try to pull posts spanning at least minDays 88 | // But, we'll never pull fewer than minPosts posts, and will pull 89 | // a maximum of maxPosts posts. 90 | const minDays = 7 * 1000 * 60 * 60 * 24; 91 | const minPosts = 100; 92 | const maxPosts = 500; 93 | 94 | let statuses: Status[] = []; 95 | const now = new Date(); 96 | for await (const st of masto.v1.timelines.listList(list.id)) { 97 | if (st.length === 0) break; 98 | 99 | statuses = statuses.concat(st); 100 | const earliest = new Date(st[st.length - 1].createdAt); 101 | const diff = now.getTime() - earliest.getTime(); 102 | const count = statuses.length; 103 | if (count > maxPosts) break; 104 | if (count > minPosts && diff > minDays) break; 105 | } 106 | 107 | // How many are replies to someone other than the author? 108 | const numRepliesToAnother = statuses.filter( 109 | (x) => x.inReplyToId !== null && x.inReplyToAccountId !== x.account.id 110 | ).length; 111 | 112 | // Who are the authors? 113 | const authors = getAccountMap(statuses); 114 | 115 | // Who are the top authors? 116 | const authorCounts = countAccounts(statuses, { 117 | predicate: (st) => st.reblog == null, 118 | }); 119 | const authorReblogCounts = countAccounts(statuses, { 120 | predicate: (st) => st.reblog !== null, 121 | }); 122 | const topAuthors = topCounts(authorCounts); 123 | 124 | // How many reblogs? 125 | const numReblogs = statuses.filter((x) => x.reblog !== null).length; 126 | 127 | // Who are we reblogging? 128 | const reblogs = statuses.filter((x) => x.reblog); 129 | const getReblogAcc = (st: Status) => (st.reblog ? st.reblog.account : null); 130 | const reblogAuthors = getAccountMap(reblogs, getReblogAcc); 131 | const reblogAuthorCounts = countAccounts(reblogs, { accessor: getReblogAcc }); 132 | const topReblogAuthors = topCounts(reblogAuthorCounts); 133 | 134 | // What is our timeline? 135 | const hours: Record = {}; 136 | statuses.forEach((st) => { 137 | const timez = new Date(st.createdAt); 138 | // What's our local timezone 139 | const localZone = Intl.DateTimeFormat().resolvedOptions().timeZone; 140 | const locale = new Intl.NumberFormat().resolvedOptions().locale; 141 | const localeTimeStr = timez.toLocaleString(locale, { 142 | timeZone: localZone, 143 | }); 144 | const localTime = new Date(localeTimeStr); 145 | localTime.setMilliseconds(0); 146 | localTime.setSeconds(0); 147 | localTime.setMinutes(0); 148 | hours[localTime.getTime()] = (hours[localTime.getTime()] || 0) + 1; 149 | }); 150 | 151 | const res = { 152 | list_id: list.id, 153 | list_name: list.title, 154 | latest_post: new Date(statuses[0].createdAt), 155 | earliest_post: new Date(statuses[statuses.length - 1].createdAt), 156 | num_posts: statuses.length, 157 | num_orig_posts: statuses.length - numReblogs, 158 | num_boosts: numReblogs, 159 | num_replies: numRepliesToAnother, 160 | top_posters: topAuthors.map((x) => ({ 161 | acct: makeUser(authors[x]), 162 | count_orig: authorCounts[x] || 0, 163 | count_boost: authorReblogCounts[x] || 0, 164 | })), 165 | top_boosts: topReblogAuthors.map((x) => ({ 166 | acct: makeUser(reblogAuthors[x]), 167 | count: reblogAuthorCounts[x] || 0, 168 | })), 169 | }; 170 | return res; 171 | } 172 | -------------------------------------------------------------------------------- /packages/shared/follow.ts: -------------------------------------------------------------------------------- 1 | // Functions for following and unfollowing accounts 2 | import type { mastodon } from "masto"; 3 | import { User } from "./types"; 4 | import { account2User } from "./utils"; 5 | 6 | // Convenient type aliases 7 | type Client = mastodon.Client; 8 | 9 | export async function follow(masto: Client, userid: string): Promise { 10 | return masto.v1.accounts.follow(userid).then(() => {}); 11 | } 12 | 13 | // Follows an account 14 | export async function unfollow(masto: Client, userid: string): Promise { 15 | await masto.v1.accounts.unfollow(userid); 16 | } 17 | 18 | // Follows a list of accounts by account name 19 | // 20 | export async function follow_by_names( 21 | masto: Client, 22 | names: string[] 23 | ): Promise { 24 | // Lookup accounts first. 25 | // FIXME: Here we ignore accounts that fail lookup. Better would be to report 26 | // them as failed. 27 | const proms = names.map((acct) => 28 | masto.v1.accounts.lookup({ acct }).catch(() => null) 29 | ); 30 | // Now, using these account objects, follow them 31 | // Filter out nulls first. 32 | return Promise.all(proms) 33 | .then((accts) => accts.filter((v) => v)) 34 | .then((accts: mastodon.v1.Account[]) => { 35 | const ids = accts.map((x) => x.id); 36 | const proms = ids.map((id) => masto.v1.accounts.follow(id)); 37 | return Promise.all(proms).then(() => 38 | // TODO: Need a better value for domain here other than null 39 | // For my use right now, it doesn't matter very much. 40 | accts.map((x) => account2User(x, false, false, null)) 41 | ); 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /packages/shared/index.ts: -------------------------------------------------------------------------------- 1 | // Shared types 2 | export * from "./types"; 3 | 4 | // List analytics function 5 | export { fetchAnalytics } from "./analytics"; 6 | export { follow, unfollow, follow_by_names } from "./follow"; 7 | export { list_create, list_delete, list_add, list_remove } from "./listcrud"; 8 | export { info_meta, info_following, info_followers, info_lists } from "./info"; 9 | export { account2User } from "./utils"; 10 | -------------------------------------------------------------------------------- /packages/shared/info.ts: -------------------------------------------------------------------------------- 1 | // Info / read operations 2 | 3 | // Functions for following and unfollowing accounts 4 | import type { mastodon } from "masto"; 5 | import { List, User } from "./types"; 6 | import { account2User } from "./utils"; 7 | 8 | // Convenient type aliases 9 | type Client = mastodon.Client; 10 | 11 | interface Meta { 12 | me: User; 13 | lists: List[]; 14 | } 15 | 16 | async function asyncForEach( 17 | array: any[], 18 | callback: (a: any, n: number, x: any[]) => void 19 | ) { 20 | for (let index = 0; index < array.length; index++) { 21 | await callback(array[index], index, array); 22 | } 23 | } 24 | 25 | // Returns metadata about me and my lists 26 | export async function info_meta(masto: Client, domain: string): Promise { 27 | return masto.v1.accounts.verifyCredentials().then(async (me) => { 28 | const meuser = { 29 | id: me.id, 30 | display_name: me.displayName, 31 | username: me.username, 32 | avatar: me.avatar, 33 | acct: me.acct + "@" + domain, 34 | note: me.note, 35 | lists: [], 36 | following_count: me.followingCount, 37 | follower_count: me.followersCount, 38 | following: false, 39 | follower: false, 40 | moved: undefined, 41 | limited: false, 42 | suspended: false, 43 | }; 44 | // Now fetch lists. 45 | let mylists: List[] = []; 46 | for await (const lists of masto.v1.lists.list()) { 47 | const batch: List[] = lists.map((list) => ({ 48 | id: list.id, 49 | title: list.title, 50 | })); 51 | mylists = mylists.concat(batch); 52 | } 53 | 54 | const res = { me: meuser, lists: mylists }; 55 | return res; 56 | }); 57 | } 58 | 59 | // Returns list of accounts I follow 60 | export async function info_following( 61 | masto: Client, 62 | user_id: string | null, 63 | domain: string, 64 | callback: ((num_loaded: number) => void) | null 65 | ): Promise { 66 | let meid = user_id; 67 | if (!meid) 68 | await masto.v1.accounts.verifyCredentials().then((me) => { 69 | meid = me.id; 70 | }); 71 | 72 | if (meid) { 73 | // First people that we are following 74 | let following: User[] = []; 75 | for await (const users of masto.v1.accounts.listFollowing(meid)) { 76 | const batch: User[] = users.map((acct) => { 77 | const u = account2User(acct, true, false, domain); 78 | //userMap[u.id] = u; 79 | return u; 80 | }); 81 | following = following.concat(batch); 82 | //callback((100 * following.length) / (totalrels + data.lists.length)); 83 | if (callback) callback(following.length); 84 | } 85 | return following; 86 | } 87 | return []; 88 | } 89 | 90 | // Returns list of accounts that follow the givenuser 91 | export async function info_followers( 92 | masto: Client, 93 | user_id: string | null, 94 | domain: string, 95 | callback: ((num_loaded: number) => void) | null 96 | ): Promise { 97 | let meid = user_id; 98 | if (!meid) 99 | await masto.v1.accounts.verifyCredentials().then((me) => { 100 | meid = me.id; 101 | }); 102 | 103 | if (meid) { 104 | // First people that we are following 105 | let followers: User[] = []; 106 | for await (const users of masto.v1.accounts.listFollowers(meid)) { 107 | const batch: User[] = users.map((acct) => { 108 | const u = account2User(acct, false, true, domain); 109 | //userMap[u.id] = u; 110 | return u; 111 | }); 112 | followers = followers.concat(batch); 113 | //callback((100 * following.length) / (totalrels + data.lists.length)); 114 | if (callback) callback(followers.length); 115 | } 116 | return followers; 117 | } 118 | return []; 119 | } 120 | 121 | // Return lists and the accounts on them 122 | export async function info_lists( 123 | masto: Client, 124 | callback: ((num_loaded: number) => void) | null 125 | ): Promise> { 126 | // Get info about lists 127 | let mylists: List[] = []; 128 | for await (const lists of masto.v1.lists.list()) { 129 | const batch: List[] = lists.map((list) => ({ 130 | id: list.id, 131 | title: list.title, 132 | })); 133 | mylists = mylists.concat(batch); 134 | } 135 | // Now, for each list, get users from that list. 136 | const res: Record = {}; 137 | await asyncForEach(mylists, async (list, idx) => { 138 | res[list.id] = []; 139 | for await (const users of masto.v1.lists.listAccounts(list.id)) { 140 | res[list.id] = res[list.id].concat(users.map((u) => u.id)); 141 | //callback((100 * (totalrels + idx)) / (totalrels + data.lists.length)); 142 | } 143 | if (callback) callback(idx); 144 | }); 145 | 146 | return res; 147 | } 148 | -------------------------------------------------------------------------------- /packages/shared/listcrud.ts: -------------------------------------------------------------------------------- 1 | // List CRUD operations 2 | 3 | // Functions for following and unfollowing accounts 4 | import type { mastodon } from "masto"; 5 | import { List } from "./types"; 6 | 7 | // Convenient type aliases 8 | type Client = mastodon.Client; 9 | 10 | // Creates a new list 11 | export async function list_create( 12 | masto: Client, 13 | list_name: string 14 | ): Promise { 15 | return masto.v1.lists.create({ title: list_name }); 16 | } 17 | 18 | // Deletes a list 19 | export async function list_delete( 20 | masto: Client, 21 | list_id: string 22 | ): Promise { 23 | masto.v1.lists.remove(list_id); 24 | } 25 | 26 | // Adds a user to a list 27 | export async function list_add( 28 | masto: Client, 29 | list_id: string, 30 | follower_ids: string[] 31 | ): Promise { 32 | return masto.v1.lists.addAccount(list_id, { accountIds: follower_ids }); 33 | } 34 | 35 | // Removes a user from a list 36 | export async function list_remove( 37 | masto: Client, 38 | list_id: string, 39 | follower_ids: string[] 40 | ): Promise { 41 | return masto.v1.lists.removeAccount(list_id, { 42 | accountIds: follower_ids, 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mastodonlm/shared", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "acbeers@gmail.com", 10 | "license": "MIT" 11 | } 12 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "dist" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/shared/types.ts: -------------------------------------------------------------------------------- 1 | // Types use in the Mastodon List Manager 2 | 3 | // A list returned by a Mastodon API 4 | export type List = { 5 | id: string; 6 | title: string; 7 | }; 8 | 9 | export enum Relationship { 10 | Unknown = "unknown", 11 | Follower = "follower", 12 | Following = "following", 13 | Mutual = "mutual", 14 | } 15 | 16 | // A user returned by the Mastodon API 17 | export type User = { 18 | id: string; 19 | display_name: string; 20 | username: string; 21 | avatar: string; 22 | acct: string; 23 | note: string; 24 | lists: string[]; 25 | suspended: boolean; 26 | limited: boolean; 27 | following_count: number; 28 | follower_count: number; 29 | // The account that we have moved to 30 | moved: User | undefined; 31 | // Our relationships 32 | following: boolean; 33 | follower: boolean; 34 | }; 35 | 36 | // A group of users as shown in the interface 37 | export type Group = { 38 | key: string; 39 | users: User[]; 40 | }; 41 | 42 | // The data returned by our backend API 43 | export type APIData = { 44 | users: User[]; 45 | lists: List[]; 46 | me: User; 47 | }; 48 | 49 | // Analytics data for a list 50 | export type ListAnalytics = { 51 | list_id: string; 52 | list_name: string; 53 | latest_post: Date; 54 | earliest_post: Date; 55 | num_posts: number; 56 | num_orig_posts: number; 57 | num_boosts: number; 58 | num_replies: number; 59 | top_posters: { acct: User; count_orig: number; count_boost: number }[]; 60 | top_boosts: { acct: User; count: number }[]; 61 | }; 62 | 63 | // A data structure representing an in progress list operation 64 | export type InProgress = { 65 | list: string; 66 | follower: string; 67 | }; 68 | 69 | export class TimeoutError extends Error { 70 | constructor(msg: string) { 71 | super(); 72 | this.name = "TimeoutError"; 73 | this.message = msg; 74 | } 75 | } 76 | 77 | export class AuthError extends Error { 78 | constructor() { 79 | super(); 80 | this.name = "AuthError"; 81 | this.message = "Not authenticated"; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /packages/shared/utils.ts: -------------------------------------------------------------------------------- 1 | import type { mastodon } from "masto"; 2 | import { User } from "./types"; 3 | 4 | // A version of the Account class with properties that are returned 5 | // by later versions of the API, even if they are unknown to the 6 | // Typescript definition in masto.js 7 | // 8 | // This type can be eliminated when masto.js is updated to support 9 | // these properties 10 | // 11 | interface Account_v415 { 12 | id: string; 13 | displayName: string; 14 | avatar: string; 15 | acct: string | null; 16 | note: string; 17 | suspended: boolean | null; 18 | limited: boolean | null; 19 | } 20 | 21 | export function account2User( 22 | account: mastodon.v1.Account, 23 | following: boolean, 24 | follower: boolean, 25 | domain: string | null 26 | ): User { 27 | const v415 = account as unknown as Account_v415; 28 | 29 | const u: User = { 30 | id: account.id, 31 | display_name: account.displayName, 32 | username: account.username, 33 | avatar: account.avatar, 34 | acct: 35 | account.acct && account.acct.indexOf("@") > 0 36 | ? account.acct 37 | : account.acct + "@" + domain, 38 | note: account.note, 39 | suspended: account.suspended || false, 40 | limited: v415.limited || false, 41 | moved: undefined, 42 | following_count: 0, 43 | follower_count: 0, 44 | following: following, 45 | follower: follower, 46 | lists: [], 47 | }; 48 | if (account.moved) { 49 | u.moved = account2User(account.moved, false, false, domain); 50 | } 51 | return u; 52 | } 53 | -------------------------------------------------------------------------------- /packages/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "lib": ["es6", "dom"], 6 | "sourceMap": true, 7 | "allowJs": true, 8 | "jsx": "react", 9 | "moduleResolution": "node", 10 | "forceConsistentCasingInFileNames": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "noImplicitAny": true, 14 | "strictNullChecks": true, 15 | "suppressImplicitAnyIndexErrors": true, 16 | "noUnusedLocals": true, 17 | "composite": true, 18 | "allowSyntheticDefaultImports": true, 19 | "ignoreDeprecations": "5.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "include": [], 4 | "references": [{ "path": "./shared" }, { "path": "./server" }] 5 | } 6 | -------------------------------------------------------------------------------- /prod.env: -------------------------------------------------------------------------------- 1 | # Environment variables for prod environment 2 | 3 | export TABLE_AUTH="list-manager-auth-prod" 4 | export TABLE_ALLOWED="list-manager-allowedHosts-prod" 5 | export TABLE_BLOCKED="list-manager-blockedHosts-prod" 6 | export TABLE_HOSTCFG="list-manager-hostConfig-prod" -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: list-manager 2 | 3 | frameworkVersion: "3" 4 | 5 | #variablesResolutionMode: 20210326 6 | 7 | provider: 8 | name: aws 9 | runtime: python3.9 10 | stage: ${opt:stage, "dev"} 11 | region: us-west-2 12 | logRetentionInDays: 14 13 | environment: 14 | TABLE_AUTH: ${self:custom.authTable} 15 | TABLE_ALLOWED: ${self:custom.allowedTable} 16 | TABLE_BLOCKED: ${self:custom.blockedTable} 17 | TABLE_HOSTCFG: ${self:custom.hostcfgTable} 18 | AUTH_REDIRECT: ${self:custom.redirects.${self:provider.stage}} 19 | httpApi: 20 | cors: 21 | allowedOrigins: ${self:custom.origins.${self:provider.stage}} 22 | allowedHeaders: 23 | - Content-Type 24 | - Authorization 25 | allowedMethods: 26 | - GET 27 | - POST 28 | - OPTIONS 29 | allowCredentials: true 30 | 31 | iam: 32 | role: 33 | statements: 34 | - Effect: "Allow" 35 | Action: 36 | - "dynamodb:PutItem" 37 | - "dynamodb:GetItem" 38 | - "dynamodb:DeleteItem" 39 | - "dynamodb:Query" 40 | Resource: "arn:aws:dynamodb:us-west-2:879669037085:table/${self:custom.authTable}" 41 | - Effect: "Allow" 42 | Action: 43 | - "dynamodb:PutItem" 44 | - "dynamodb:GetItem" 45 | - "dynamodb:Query" 46 | Resource: "arn:aws:dynamodb:us-west-2:879669037085:table/${self:custom.allowedTable}" 47 | - Effect: "Allow" 48 | Action: 49 | - "dynamodb:PutItem" 50 | - "dynamodb:GetItem" 51 | - "dynamodb:Query" 52 | Resource: "arn:aws:dynamodb:us-west-2:879669037085:table/${self:custom.hostcfgTable}" 53 | - Effect: "Allow" 54 | Action: 55 | - "dynamodb:PutItem" 56 | - "dynamodb:GetItem" 57 | - "dynamodb:Query" 58 | - "dynamodb:Scan" 59 | - "dynamodb:DeleteItem" 60 | - "dynamodb:BatchWriteItem" 61 | Resource: "arn:aws:dynamodb:us-west-2:879669037085:table/${self:custom.blockedTable}" 62 | 63 | resources: # CloudFormation template syntax from here on. 64 | Outputs: 65 | myCloudfrontDomain: 66 | Value: 67 | "Fn::GetAtt": [CloudFrontDistribution, DomainName] 68 | 69 | CDNDistributionId: 70 | Description: CDN distribution id. 71 | Value: 72 | Ref: CloudFrontDistribution 73 | Export: 74 | Name: CDNDistributionId-${self:provider.stage} 75 | 76 | Resources: 77 | # Stores auth tokens for users and other info 78 | usersTable: ${file(serverless/tables.yml):usersTable} 79 | # Essentially, a list of allowed hosts. 80 | allowTable: ${file(serverless/tables.yml):allowTable} 81 | # Essentially, a list of blocked hosts. 82 | blockTable: ${file(serverless/tables.yml):blockTable} 83 | # Stores host information (secrets, urls, etc) 84 | hostsTable: ${file(serverless/tables.yml):hostsTable} 85 | # Cloudfront configuration for domain handling 86 | CloudFrontDistribution: ${file(serverless/cfwebsite.yml):CloudFrontDistribution} 87 | AssetsBucket: ${file(serverless/cfwebsite.yml):AssetsBucket} 88 | BucketPolicy: ${file(serverless/s3policy.yml)} 89 | 90 | functions: ${file(serverless/functions.yml)} 91 | 92 | package: 93 | patterns: 94 | - "!node_modules" 95 | - "!packages" 96 | # serverless-plugin-typescript seems to break the inclusion of *.py files. 97 | # so be explicit. 98 | - "backendpy/*.py" 99 | 100 | custom: 101 | # Table names 102 | authTable: "${self:service}-auth-${self:provider.stage}" 103 | allowedTable: "${self:service}-allowedHosts-${self:provider.stage}" 104 | blockedTable: "${self:service}-blockedHosts-${self:provider.stage}" 105 | hostcfgTable: "${self:service}-hostConfig-${self:provider.stage}" 106 | # Offline configuration 107 | serverless-offline: 108 | httpPort: 4000 109 | # Database stuff 110 | readcapacity: ${file(serverless/tables.yml):readcapacity} 111 | writecapacity: ${file(serverless/tables.yml):writecapacity} 112 | dynamodb: ${file(serverless/tables.yml):dynamodb} 113 | 114 | # Redirects 115 | redirects: 116 | newprod: https://www.mastodonlistmanager.org 117 | devstage: https://devstage.mastodonlistmanager.org 118 | dev: http://localhost:3000 119 | 120 | # Origins 121 | origins: 122 | newprod: 123 | - https://www.mastodonlistmanager.org 124 | - https://mastodonlistmanager.org 125 | devstage: 126 | - https://www-devstage.mastodonlistmanager.org 127 | - https://devstage.mastodonlistmanager.org 128 | dev: 129 | - http://localhost:3000 130 | 131 | # Should we s3 sync? 132 | enableS3Sync: 133 | newprod: true 134 | devstage: true 135 | dev: false 136 | 137 | serverlessPluginTypescript: 138 | tsConfigFileLocation: "packages/server/tsconfig.json" 139 | 140 | # Domain name support 141 | name: ${self:service}-${self:provider.stage} 142 | websiteBucket: ${self:custom.name} 143 | cloudfrontOriginId: ${self:custom.name}-cloud-front 144 | # Configuration for backend domain 145 | customDomain: ${file(serverless/cfwebsite.yml):customDomain} 146 | # Configuration for frontend (website) domain 147 | domainComponents: ${file(serverless/cfwebsite.yml):domainComponents} 148 | websiteDomain: ${file(serverless/cfwebsite.yml):websiteDomain} 149 | 150 | # For publishing our website 151 | s3Sync: ${file(serverless/s3sync.yml)} 152 | cloudfrontInvalidate: 153 | - distributionIdKey: "CDNDistributionId" # conditional, distributionId or distributionIdKey is required. 154 | autoInvalidate: true # Can be set to false to avoid automatic invalidation after the deployment. Useful if you want to manually trigger the invalidation later. Defaults to true. 155 | items: # one or more paths required 156 | - "/index.html" 157 | - "/static/js/*" 158 | - "/static/css/*" 159 | 160 | plugins: 161 | - serverless-python-requirements 162 | # For running typescript 163 | - serverless-plugin-typescript 164 | - serverless-offline 165 | # For putting lambda functions behind a custom domain 166 | - serverless-domain-manager 167 | # For putting a static website behind a custom domain 168 | - serverless-website-domain 169 | # For syncing a built website to S3. 170 | - serverless-s3-sync 171 | # For invalidating things in Cloudfront on publish 172 | - serverless-cloudfront-invalidate 173 | -------------------------------------------------------------------------------- /serverless/cfwebsite.yml: -------------------------------------------------------------------------------- 1 | CloudFrontDistribution: 2 | Type: AWS::CloudFront::Distribution 3 | Properties: 4 | DistributionConfig: 5 | Enabled: true 6 | Aliases: 7 | - ${self:custom.domainComponents.withWWW} 8 | - ${self:custom.domainComponents.withoutWWW} 9 | CustomErrorResponses: 10 | - ErrorCode: 404 11 | ResponseCode: 200 12 | ResponsePagePath: /index.html 13 | DefaultCacheBehavior: 14 | LambdaFunctionAssociations: 15 | - EventType: viewer-request 16 | LambdaFunctionARN: ${websiteDomain(edgeLambdaArn)} 17 | TargetOriginId: ${self:custom.cloudfrontOriginId} 18 | ForwardedValues: 19 | QueryString: "false" 20 | Cookies: 21 | Forward: none 22 | ViewerProtocolPolicy: redirect-to-https 23 | Origins: 24 | - DomainName: ${self:custom.websiteBucket}.s3.amazonaws.com 25 | CustomOriginConfig: 26 | HTTPPort: 80 27 | HTTPSPort: 443 28 | OriginProtocolPolicy: https-only 29 | Id: ${self:custom.cloudfrontOriginId} 30 | DefaultRootObject: index.html 31 | HttpVersion: "http2" 32 | ViewerCertificate: 33 | AcmCertificateArn: "arn:aws:acm:us-east-1:879669037085:certificate/7a8a12d8-1087-4886-a33c-657fbb3a2231" 34 | SslSupportMethod: "sni-only" 35 | 36 | AssetsBucket: 37 | Type: AWS::S3::Bucket 38 | Properties: 39 | BucketName: ${self:custom.websiteBucket} 40 | AccessControl: PublicRead 41 | WebsiteConfiguration: 42 | IndexDocument: index.html 43 | ErrorDocument: index.html 44 | 45 | # Custom domain for the backend 46 | customDomain: 47 | enableStages: 48 | newprod: true 49 | devstage: true 50 | dev: false 51 | enabled: ${self:custom.customDomain.enableStages.${self:provider.stage}} 52 | backendName: 53 | newprod: backend 54 | devstage: backend-devstage 55 | dev: backend-dev 56 | domainName: ${self:custom.customDomain.backendName.${self:provider.stage}}.mastodonlistmanager.org 57 | basePath: "" 58 | certificateName: "*.mastodonlistmanager.org" 59 | createRoute53Record: true 60 | createRoute53IPv6Record: true 61 | endpointType: "regional" 62 | securityPolicy: tls_1_2 63 | apiType: http 64 | autoDomain: false 65 | region: us-west-2 66 | # Configuration for frontend (website) domain 67 | domainComponents: 68 | domainBase: mastodonlistmanager.org 69 | namesWithWWW: 70 | newprod: www.${self:custom.domainComponents.domainBase} 71 | devstage: www-devstage.${self:custom.domainComponents.domainBase} 72 | dev: www-dev.${self:custom.domainComponents.domainBase} 73 | namesWithoutWWW: 74 | newprod: ${self:custom.domainComponents.domainBase} 75 | devstage: devstage.${self:custom.domainComponents.domainBase} 76 | dev: dev.${self:custom.domainComponents.domainBase} 77 | withWWW: ${self:custom.domainComponents.namesWithWWW.${self:provider.stage}} 78 | withoutWWW: ${self:custom.domainComponents.namesWithoutWWW.${self:provider.stage}} 79 | websiteDomain: 80 | cloudfrontOutputKey: "myCloudfrontDomain" 81 | domain: ${self:custom.domainComponents.withWWW} 82 | region: us-west-2 83 | edgeLambda: 84 | redirect: 85 | from: ${self:custom.domainComponents.withoutWWW} 86 | to: https://${self:custom.domainComponents.withWWW} 87 | -------------------------------------------------------------------------------- /serverless/functions.yml: -------------------------------------------------------------------------------- 1 | # Auth functions are all still in python, because the JS masto library 2 | # doesn't yet have oAuth support 3 | # 4 | auth: 5 | handler: backendpy.shared.auth 6 | timeout: 30 7 | runtime: python3.9 8 | events: 9 | - httpApi: 10 | path: /auth 11 | method: GET 12 | callback: 13 | handler: backendpy.server.callback 14 | timeout: 30 15 | events: 16 | - httpApi: 17 | path: /callback 18 | method: POST 19 | logout: 20 | timeout: 30 21 | handler: backendpy.server.logout 22 | events: 23 | - httpApi: 24 | path: /logout 25 | method: POST 26 | clientcallback: 27 | handler: backendpy.client.clientcallback 28 | timeout: 30 29 | events: 30 | - httpApi: 31 | path: /clientcallback 32 | method: POST 33 | clientlogout: 34 | timeout: 30 35 | handler: backendpy.client.clientlogout 36 | events: 37 | - httpApi: 38 | path: /clientlogout 39 | method: POST 40 | 41 | # Core functionality for the app is all in JS, that's shared between 42 | # server-side and client-side implementations 43 | # 44 | meta: 45 | timeout: 30 46 | runtime: nodejs20.x 47 | handler: packages/server/info_server.info_meta_handler 48 | events: 49 | - httpApi: 50 | path: /meta 51 | method: GET 52 | following: 53 | timeout: 30 54 | runtime: nodejs20.x 55 | handler: packages/server/info_server.info_following_handler 56 | events: 57 | - httpApi: 58 | path: /following 59 | method: GET 60 | followers: 61 | timeout: 30 62 | runtime: nodejs20.x 63 | handler: packages/server/info_server.info_followers_handler 64 | events: 65 | - httpApi: 66 | path: /followers 67 | method: GET 68 | lists: 69 | timeout: 30 70 | runtime: nodejs20.x 71 | handler: packages/server/info_server.info_lists_handler 72 | events: 73 | - httpApi: 74 | path: /lists 75 | method: GET 76 | add: 77 | timeout: 30 78 | runtime: nodejs20.x 79 | handler: packages/server/listcrud_server.list_add_handler 80 | events: 81 | - httpApi: 82 | path: /add 83 | method: POST 84 | remove: 85 | timeout: 30 86 | runtime: nodejs20.x 87 | handler: packages/server/listcrud_server.list_remove_handler 88 | events: 89 | - httpApi: 90 | path: /remove 91 | method: POST 92 | create: 93 | timeout: 30 94 | runtime: nodejs20.x 95 | handler: packages/server/listcrud_server.list_create_handler 96 | events: 97 | - httpApi: 98 | path: /create 99 | method: POST 100 | delete: 101 | timeout: 30 102 | runtime: nodejs20.x 103 | handler: packages/server/listcrud_server.list_delete_handler 104 | events: 105 | - httpApi: 106 | path: /delete 107 | method: POST 108 | import: 109 | timeout: 30 110 | runtime: nodejs20.x 111 | handler: packages/server/listcrud_server.list_import_handler 112 | events: 113 | - httpApi: 114 | path: /import 115 | method: POST 116 | follow: 117 | timeout: 30 118 | runtime: nodejs20.x 119 | handler: packages/server/follow_server.follow_handler 120 | events: 121 | - httpApi: 122 | path: /follow 123 | method: POST 124 | follow_by_names: 125 | timeout: 30 126 | runtime: nodejs20.x 127 | handler: packages/server/follow_server.follow_by_names_handler 128 | events: 129 | - httpApi: 130 | path: /follow_by_names 131 | method: POST 132 | unfollow: 133 | timeout: 30 134 | runtime: nodejs20.x 135 | handler: packages/server/follow_server.unfollow_handler 136 | events: 137 | - httpApi: 138 | path: /unfollow 139 | method: POST 140 | 141 | analytics: 142 | timeout: 30 143 | runtime: nodejs20.x 144 | handler: packages/server/analytics_server.analytics 145 | events: 146 | - httpApi: 147 | path: /analytics 148 | method: GET 149 | 150 | # Telemetry and error functions are in Python as well 151 | # 152 | telemetry: 153 | timeout: 30 154 | handler: backendpy.telemetry.telemetry 155 | events: 156 | - httpApi: 157 | path: /telemetry 158 | method: POST 159 | error: 160 | timeout: 30 161 | handler: backendpy.telemetry.error 162 | events: 163 | - httpApi: 164 | path: /error 165 | method: POST 166 | 167 | # This is a routine that will copy hachyderm.io's blocklist 168 | # 169 | blocklistUpdate: 170 | handler: backendpy.other.block_update 171 | timeout: 300 172 | events: 173 | - schedule: cron(0 8 * * ? *) 174 | -------------------------------------------------------------------------------- /serverless/s3policy.yml: -------------------------------------------------------------------------------- 1 | Type: AWS::S3::BucketPolicy 2 | Properties: 3 | Bucket: 4 | Ref: AssetsBucket 5 | PolicyDocument: 6 | Statement: 7 | - Sid: PublicReadGetObject 8 | Action: 9 | - s3:GetObject 10 | Effect: Allow 11 | Principal: "*" 12 | Resource: arn:aws:s3:::${self:custom.websiteBucket}/* 13 | -------------------------------------------------------------------------------- /serverless/s3sync.yml: -------------------------------------------------------------------------------- 1 | # Confifugration for s3sync. Should be included like this: 2 | # custom: 3 | # s3sync: ${file(serverless/s3sync.yml} 4 | 5 | - bucketName: ${self:custom.websiteBucket} 6 | localDir: packages/client/build 7 | enabled: ${self:custom.enableS3Sync.${self:provider.stage}} 8 | params: 9 | - index.html: 10 | CacheControl: "no-cache" 11 | - "*.js": 12 | CacheControl: "public, max-age=31536000" 13 | -------------------------------------------------------------------------------- /serverless/tables.yml: -------------------------------------------------------------------------------- 1 | # Stores auth tokens for users and other info 2 | usersTable: 3 | Type: AWS::DynamoDB::Table 4 | Properties: 5 | TableName: "${self:custom.authTable}" 6 | AttributeDefinitions: 7 | - AttributeName: key 8 | AttributeType: S 9 | KeySchema: 10 | - AttributeName: key 11 | KeyType: HASH 12 | BillingMode: PAY_PER_REQUEST 13 | TimeToLiveSpecification: 14 | Enabled: true 15 | AttributeName: expires_at 16 | 17 | # Essentially, a list of allowed hosts. 18 | allowTable: 19 | Type: AWS::DynamoDB::Table 20 | Properties: 21 | TableName: "${self:custom.allowedTable}" 22 | AttributeDefinitions: 23 | - AttributeName: host 24 | AttributeType: S 25 | KeySchema: 26 | - AttributeName: host 27 | KeyType: HASH 28 | BillingMode: PAY_PER_REQUEST 29 | 30 | # Essentially, a list of blocked hosts. 31 | blockTable: 32 | Type: AWS::DynamoDB::Table 33 | Properties: 34 | TableName: "${self:custom.blockedTable}" 35 | AttributeDefinitions: 36 | - AttributeName: hash 37 | AttributeType: S 38 | KeySchema: 39 | - AttributeName: hash 40 | KeyType: HASH 41 | BillingMode: PAY_PER_REQUEST 42 | 43 | # Stores host information (secrets, urls, etc) 44 | hostsTable: 45 | Type: AWS::DynamoDB::Table 46 | Properties: 47 | TableName: "${self:custom.hostcfgTable}" 48 | AttributeDefinitions: 49 | - AttributeName: host 50 | AttributeType: S 51 | KeySchema: 52 | - AttributeName: host 53 | KeyType: HASH 54 | BillingMode: PAY_PER_REQUEST 55 | 56 | readcapacity: 57 | dev: 2 58 | devstage: 2 59 | prod: 5 60 | newprod: 5 61 | writecapacity: 62 | dev: 2 63 | devstage: 2 64 | prod: 5 65 | newprod: 5 66 | dynamodb: 67 | ttl: 68 | - table: ${self:custom.authTable} 69 | field: expires_at 70 | --------------------------------------------------------------------------------