diff --git a/CHANGELOG.markdown b/CHANGELOG.markdown index b6fc2c5..98ec7df 100644 --- a/CHANGELOG.markdown +++ b/CHANGELOG.markdown @@ -1,5 +1,7 @@ ## vNEXT +* add: Misskey support (Thanks @Johann150 !) + * fix: lowered database impact of a background task * fix: wording on "favourited posts" is unclear diff --git a/README.markdown b/README.markdown index cac9ca9..dc7219d 100644 --- a/README.markdown +++ b/README.markdown @@ -7,8 +7,8 @@ [![Test coverage](https://img.shields.io/codecov/c/github/codl/forget.svg)](https://codecov.io/gh/codl/forget) [![Code quality](https://img.shields.io/codacy/grade/1780ac6071c04cbd9ccf75de0891e798.svg)](https://www.codacy.com/app/codl/forget?utm_source=github.com&utm_medium=referral&utm_content=codl/forget&utm_campaign=badger) -Forget is a post deleting service for Twitter and Mastodon. It lives at -. +Forget is a post deleting service for Twitter, Mastodon, and Misskey. It lives +at . ## Running your own diff --git a/assets/instance_buttons.js b/assets/instance_buttons.js index 6c2bfc1..d5bbf53 100644 --- a/assets/instance_buttons.js +++ b/assets/instance_buttons.js @@ -2,14 +2,23 @@ import {SLOTS, normalize_known, known_load, known_save} from './known_instances. (function instance_buttons(){ - const container = document.querySelector('#mastodon_instance_buttons'); - const button_template = Function('first', 'instance', - 'return `' + document.querySelector('#instance_button_template').innerHTML + '`;'); - const another_button_template = Function( + const mastodon_container = document.querySelector('#mastodon_instance_buttons'); + const mastodon_button_template = Function('first', 'instance', + 'return `' + document.querySelector('#mastodon_instance_button_template').innerHTML + '`;'); + const mastodon_another_button_template = Function( 'return `' + - document.querySelector('#another_instance_button_template').innerHTML + '`;'); - const top_instances = - Function('return JSON.parse(`' + document.querySelector('#top_instances').innerHTML + '`);')(); + document.querySelector('#mastodon_another_instance_button_template').innerHTML + '`;'); + const mastodon_top_instances = + Function('return JSON.parse(`' + document.querySelector('#mastodon_top_instances').innerHTML + '`);')(); + + const misskey_container = document.querySelector('#misskey_instance_buttons'); + const misskey_button_template = Function('first', 'instance', + 'return `' + document.querySelector('#misskey_instance_button_template').innerHTML + '`;'); + const misskey_another_button_template = Function( + 'return `' + + document.querySelector('#misskey_another_instance_button_template').innerHTML + '`;'); + const misskey_top_instances = + Function('return JSON.parse(`' + document.querySelector('#misskey_top_instances').innerHTML + '`);')(); async function get_known(){ let known = known_load(); @@ -19,10 +28,16 @@ import {SLOTS, normalize_known, known_load, known_save} from './known_instances. known = await resp.json(); } else { - known = [{ - "instance": "mastodon.social", - "hits": 0 - }]; + known = { + mastodon:[{ + "instance": "mastodon.social", + "hits": 0 + }], + misskey:[{ + "instance": "misskey.io", + "hits": 0 + }], + }; } known_save(known) fetch('/api/known_instances', {method: 'DELETE'}) @@ -31,17 +46,12 @@ import {SLOTS, normalize_known, known_load, known_save} from './known_instances. return known; } - - async function replace_buttons(){ - let known = await get_known(); - - known = normalize_known(known); - known_save(known); - + function replace_buttons(top_instances, known_instances, container, + template, template_another_instance){ let filtered_top_instances = [] for(let instance of top_instances){ let found = false; - for(let k of known){ + for(let k of known_instances){ if(k['instance'] == instance['instance']){ found = true; break; @@ -52,20 +62,35 @@ import {SLOTS, normalize_known, known_load, known_save} from './known_instances. } } - let instances = known.concat(filtered_top_instances).slice(0, SLOTS); + let instances = known_instances.concat(filtered_top_instances).slice(0, SLOTS); let html = ''; let first = true; for(let instance of instances){ - html += button_template(first, instance['instance']) + html += template(first, instance['instance']) first = false; } - html += another_button_template(); + html += template_another_instance(); container.innerHTML = html; } - replace_buttons(); + async function init_buttons(){ + let known = await get_known(); + + known.mastodon = normalize_known(known.mastodon); + known.misskey = normalize_known(known.misskey); + known_save(known); + + replace_buttons(mastodon_top_instances, known.mastodon, + mastodon_container, mastodon_button_template, + mastodon_another_button_template); + replace_buttons(misskey_top_instances, known.misskey, + misskey_container, misskey_button_template, + misskey_another_button_template); + } + + init_buttons(); })(); diff --git a/assets/known_instances.js b/assets/known_instances.js index eb1e746..cd6fff6 100644 --- a/assets/known_instances.js +++ b/assets/known_instances.js @@ -1,6 +1,24 @@ -const STORAGE_KEY = 'forget_known_instances'; +const STORAGE_KEY = 'forget_known_instances@2021-12-09'; export const SLOTS = 5; +function load_and_migrate_old(){ + const OLD_KEY = "forget_known_instances"; + let olddata = localStorage.getItem(OLD_KEY); + if(olddata != null){ + olddata = JSON.parse(olddata) + let newdata = { + mastodon: olddata, + misskey: [{ + "instance": "misskey.io", + "hits": 0 + }] + }; + known_save(newdata); + localStorage.removeItem(OLD_KEY); + return newdata; + } +} + export function known_save(known){ localStorage.setItem(STORAGE_KEY, JSON.stringify(known)); } @@ -9,6 +27,8 @@ export function known_load(){ let known = localStorage.getItem(STORAGE_KEY); if(known){ known = JSON.parse(known); + } else { + known = load_and_migrate_old(); } return known; } diff --git a/assets/misskey.png b/assets/misskey.png new file mode 100644 index 0000000..1e57da6 Binary files /dev/null and b/assets/misskey.png differ diff --git a/assets/settings.js b/assets/settings.js index 3b400ec..9aad005 100644 --- a/assets/settings.js +++ b/assets/settings.js @@ -194,10 +194,10 @@ import {known_load, known_save} from './known_instances.js' }) } - function bump_instance(instance_name){ + function bump_instance(service, instance_name){ let known_instances = known_load(); let found = false; - for(let instance of known_instances){ + for(let instance of known_instances[service]){ if(instance['instance'] == instance_name){ instance.hits ++; found = true; @@ -206,15 +206,17 @@ import {known_load, known_save} from './known_instances.js' } if(!found){ let instance = {"instance": instance_name, "hits": 1}; - known_instances.push(instance); + known_instances[service].push(instance); } known_save(known_instances); } - if(viewer_from_dom['service'] == 'mastodon' && location.hash == '#bump_instance'){ - bump_instance(viewer_from_dom['id'].split('@')[1]) + if(location.hash == '#bump_instance' && ( + viewer_from_dom['service'] == 'mastodon' || viewer_from_dom['service'] == 'misskey' + )){ + bump_instance(viewer_from_dom['service'], viewer_from_dom['id'].split('@')[1]) let url = new URL(location.href) url.hash = ''; history.replaceState('', '', url); diff --git a/assets/styles.css b/assets/styles.css index b9dfc1a..c1dda3a 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -235,6 +235,10 @@ button { background-color: #282c37; } +.btn.primary.misskey-colored { + background-color: #66b300; +} + .btn.secondary { background-color: rgba(255,255,255,0.5); box-shadow: inset 0 0 0 1px rgba(0,0,0,0.3); diff --git a/dodo.py b/dodo.py index 75ceb08..f737ba2 100644 --- a/dodo.py +++ b/dodo.py @@ -60,7 +60,7 @@ def task_service_icon(): formats = ('webp', 'png') for width in widths: for image_format in formats: - for basename in ('twitter', 'mastodon'): + for basename in ('twitter', 'mastodon', 'misskey'): yield dict( name='{}-{}.{}'.format(basename, width, image_format), actions=[(resize_image, (basename, width, image_format))], diff --git a/libforget/misskey.py b/libforget/misskey.py new file mode 100644 index 0000000..06e3449 --- /dev/null +++ b/libforget/misskey.py @@ -0,0 +1,191 @@ +from app import db, sentry +from model import MisskeyApp, MisskeyInstance, Account, OAuthToken, Post +from uuid import uuid4 +from hashlib import sha256 +from libforget.exceptions import TemporaryError, PermanentError +from libforget.session import make_session + +def get_or_create_app(instance_url, callback, website, session): + instance_url = instance_url + app = MisskeyApp.query.get(instance_url) + + if not app: + # check if the instance uses https while getting instance infos + try: + r = session.post('https://{}/api/meta'.format(instance_url)) + r.raise_for_status() + proto = 'https' + except Exception: + r = session.post('http://{}/api/meta'.format(instance_url)) + r.raise_for_status() + proto = 'http' + + # This is using the legacy authentication method, because the newer + # Miauth method breaks the ability to log out and log back into forget. + + app = MisskeyApp() + app.instance = instance_url + app.protocol = proto + + # register the app + r = session.post('{}://{}/api/app/create'.format(app.protocol, app.instance), json = { + 'name': 'forget', + 'description': website, + 'permission': ['read:favorites', 'write:notes'], + 'callbackUrl': callback + }) + r.raise_for_status() + app.client_secret = r.json()['secret'] + + return app + +def login_url(app, callback, session): + # will use the callback we gave the server in `get_or_create_app` + r = session.post('{}://{}/api/auth/session/generate'.format(app.protocol, app.instance), json = { + 'appSecret': app.client_secret + }) + r.raise_for_status() + # we already get the retrieval token here, but we get it again later so + # we do not have to store it + return r.json()['url'] + +def receive_token(token, app): + session = make_session() + + r = session.post('{}://{}/api/auth/session/userkey'.format(app.protocol, app.instance), json = { + 'appSecret': app.client_secret, + 'token': token + }) + r.raise_for_status() + + token = sha256(r.json()['accessToken'].encode('utf-8') + app.client_secret.encode('utf-8')).hexdigest() + + acc = account_from_user(r.json()['user'], app.instance) + acc = db.session.merge(acc) + token = OAuthToken(token = token) + token = db.session.merge(token) + token.account = acc + + return token + +def check_auth(account, app, session): + # there is no explicit check, we can only try getting user info + r = session.post('{}://{}/api/i'.format(app.protocol, app.instance), json = {'i': account.tokens[0].token}) + + if r.status_code != 200: + raise TemporaryError("{} {}".format(r.status_code, r.text)) + + if r.json()['isSuspended']: + # this is technically a temporary error, but like for twitter + # its handled as permanent to not make useless API calls + raise PermanentError("Misskey account suspended") + +def account_from_user(user, host): + return Account( + # in objects that get returned from misskey, the local host is + # set to None + misskey_instance=host, + misskey_id=user['id'], + screen_name='{}@{}'.format(user['username'], host), + display_name=user['name'], + avatar_url=user['avatarUrl'], + # the notes count is not always included, especially not when + # fetching posts. in that case assume its not needed + reported_post_count=user.get('notesCount', None), + ) + +def post_from_api_object(obj, host): + return Post( + # in objects that get returned from misskey, the local host is + # set to None + misskey_instance=host, + misskey_id=obj['id'], + favourite=('myReaction' in obj + and bool(obj['myReaction'])), + has_media=('fileIds' in obj + and bool(obj['fileIds'])), + created_at=obj['createdAt'], + author_id=account_from_user(obj['user'], host).id, + direct=obj['visibility'] == 'specified', + is_reblog=obj['renoteId'] is not None, + ) + +def fetch_posts(acc, max_id, since_id): + app = MisskeyApp.query.get(acc.misskey_instance) + session = make_session() + check_auth(acc, app, session) + + kwargs = dict( + limit=100, + userId=acc.misskey_id, + # for some reason the token is needed so misskey also sends `myReaction` + i=acc.tokens[0].token + ) + if max_id: + kwargs['untilId'] = max_id + if since_id: + kwargs['sinceId'] = since_id + + notes = session.post('{}://{}/api/users/notes'.format(app.protocol, app.instance), json=kwargs) + + if notes.status_code != 200: + raise TemporaryError('{} {}'.format(notes.status_code, notes.text)) + + return [post_from_api_object(note, app.instance) for note in notes.json()] + + +def refresh_posts(posts): + acc = posts[0].author + app = MisskeyApp.query.get(acc.misskey_instance) + session = make_session() + check_auth(acc, app, session) + + new_posts = list() + with db.session.no_autoflush: + for post in posts: + print('Refreshing {}'.format(post)) + r = session.post('{}://{}/api/notes/show'.format(app.protocol, app.instance), json={ + 'i': acc.tokens[0].token, + 'noteId': post.misskey_id + }) + if r.status_code != 200: + try: + if r.json()['error']['code'] == 'NO_SUCH_NOTE': + db.session.delete(post) + continue + except Exception as e: + raise TemporaryError(e) + raise TemporaryError('{} {}'.format(r.status_code, r.text)) + + new_post = db.session.merge(post_from_api_object(r.json(), app.instance)) + new_post.touch() + new_posts.append(new_post) + return new_posts + +def delete(post): + acc = post.author + app = MisskeyApp.query.get(post.misskey_instance) + session = make_session() + if not app: + # how? if this happens, it doesnt make sense to repeat it, + # so use a permanent error + raise PermanentError("instance not registered for delete") + + r = session.post('{}://{}/api/notes/delete'.format(app.protocol, app.instance), json = { + 'i': acc.tokens[0].token, + 'noteId': post.misskey_id + }) + + if r.status_code != 204: + raise TemporaryError("{} {}".format(r.status_code, r.text)) + + db.session.delete(post) + +def suggested_instances(limit=5, min_popularity=5, blocklist=tuple()): + return tuple((ins.instance for ins in ( + MisskeyInstance.query + .filter(MisskeyInstance.popularity > min_popularity) + .filter(~MisskeyInstance.instance.in_(blocklist)) + .order_by(db.desc(MisskeyInstance.popularity), + MisskeyInstance.instance) + .limit(limit).all()))) diff --git a/migrations/versions/740fe24a7712_add_misskey.py b/migrations/versions/740fe24a7712_add_misskey.py new file mode 100644 index 0000000..9e87946 --- /dev/null +++ b/migrations/versions/740fe24a7712_add_misskey.py @@ -0,0 +1,48 @@ +"""add misskey + +Revision ID: 740fe24a7712 +Revises: af763dccc0b4 +Create Date: 2021-11-10 00:13:37.344364 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '740fe24a7712' +down_revision = 'af763dccc0b4' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('misskey_instances', + sa.Column('instance', sa.String(), nullable=False), + sa.Column('popularity', sa.Float(), server_default='10', nullable=False), + sa.PrimaryKeyConstraint('instance', name=op.f('pk_misskey_instances')) + ) + op.execute(""" + INSERT INTO misskey_instances (instance, popularity) VALUES + ('misskey.io', 100), + ('cliq.social', 60), + ('misskey.dev', 50), + ('quietplace.xyz', 40), + ('mk.nixnet.social', 30), + ('jigglypuff.club', 20); + """) + + op.create_table('misskey_apps', + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('instance', sa.String(), nullable=False), + sa.Column('client_secret', sa.String(), nullable=False), + sa.Column('protocol', sa.Enum('http', 'https', name='enum_protocol_misskey'), nullable=False), + sa.PrimaryKeyConstraint('instance', name=op.f('pk_misskey_apps')) + ) + + +def downgrade(): + op.drop_table('misskey_instances') + op.drop_table('misskey_apps') + op.execute('DROP TYPE enum_protocol_misskey;') diff --git a/model.py b/model.py index 01cfd29..b65dbc0 100644 --- a/model.py +++ b/model.py @@ -67,6 +67,34 @@ class RemoteIDMixin(object): @mastodon_id.setter def mastodon_id(self, id_): self.id = "mastodon:{}@{}".format(id_, self.mastodon_instance) + + @property + def misskey_instance(self): + if not self.id: + return None + if self.service != "misskey": + raise Exception( + "tried to get misskey instance for a {} {}" + .format(self.service, type(self))) + return self.id.split(":", 1)[1].split('@')[1] + + @misskey_instance.setter + def misskey_instance(self, instance): + self.id = "misskey:{}@{}".format(self.misskey_id, instance) + + @property + def misskey_id(self): + if not self.id: + return None + if self.service != "misskey": + raise Exception( + "tried to get misskey id for a {} {}" + .format(self.service, type(self))) + return self.id.split(":", 1)[1].split('@')[0] + + @misskey_id.setter + def misskey_id(self, id_): + self.id = "misskey:{}@{}".format(id_, self.misskey_instance) @property def remote_id(self): @@ -74,6 +102,8 @@ class RemoteIDMixin(object): return self.twitter_id elif self.service == 'mastodon': return self.mastodon_id + elif self.service == 'misskey': + return self.misskey_id ThreeWayPolicyEnum = db.Enum('keeponly', 'deleteonly', 'none', @@ -364,3 +394,24 @@ class MastodonInstance(db.Model): def bump(self, value=1): self.popularity = (self.popularity or 10) + value + +class MisskeyApp(db.Model, TimestampMixin): + __tablename__ = 'misskey_apps' + + instance = db.Column(db.String, primary_key=True) + protocol = db.Column(db.String, nullable=False) + client_secret = db.Column(db.String, nullable=False) + +class MisskeyInstance(db.Model): + """ + this is for the autocomplete in the misskey login form + it isn't coupled with anything else so that we can seed it with + some popular instances ahead of time + """ + __tablename__ = 'misskey_instances' + + instance = db.Column(db.String, primary_key=True) + popularity = db.Column(db.Float, server_default='10', nullable=False) + + def bump(self, value=1): + self.popularity = (self.popularity or 10) + value diff --git a/routes/__init__.py b/routes/__init__.py index bfa2976..f665fe6 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -3,13 +3,16 @@ from flask import render_template, url_for, redirect, request, g,\ from datetime import datetime, timedelta, timezone import libforget.twitter import libforget.mastodon +import libforget.misskey from libforget.auth import require_auth, csrf,\ get_viewer -from model import Session, TwitterArchive, MastodonApp +from libforget.session import make_session +from model import Session, TwitterArchive, MastodonApp, MisskeyApp from app import app, db, sentry, imgproxy import tasks from zipfile import BadZipFile from twitter import TwitterError +from urllib.parse import urlparse from urllib.error import URLError import libforget.version import libforget.settings @@ -34,10 +37,12 @@ def index(): @app.route('/about/') def about(): - instances = libforget.mastodon.suggested_instances() + mastodon_instances = libforget.mastodon.suggested_instances() + misskey_instances = libforget.misskey.suggested_instances() return render_template( 'about.html', - mastodon_instances=instances, + mastodon_instances=mastodon_instances, + misskey_instances=misskey_instances, twitter_login_error='twitter_login_error' in request.args) @@ -171,6 +176,9 @@ def logout(): return redirect(url_for('about')) +def domain_from_url(url): + return urlparse(url).netloc.lower() or urlparse("//"+url).netloc.lower() + @app.route('/login/mastodon', methods=('GET', 'POST')) def mastodon_login_step1(instance=None): @@ -188,14 +196,7 @@ def mastodon_login_step1(instance=None): generic_error='error' in request.args ) - instance_url = instance_url.lower() - # strip protocol - instance_url = re.sub('^https?://', '', instance_url, - count=1, flags=re.IGNORECASE) - # strip username - instance_url = instance_url.split("@")[-1] - # strip trailing path - instance_url = instance_url.split('/')[0] + instance_url = domain_from_url(instance_url) callback = url_for('mastodon_login_step2', instance_url=instance_url, _external=True) @@ -240,6 +241,67 @@ def mastodon_login_step2(instance_url): return resp +@app.route('/login/misskey', methods=('GET', 'POST')) +def misskey_login(instance=None): + instance_url = (request.args.get('instance_url', None) + or request.form.get('instance_url', None)) + + if not instance_url: + instances = libforget.misskey.suggested_instances( + limit = 30, + min_popularity = 1 + ) + return render_template( + 'misskey_login.html', instances=instances, + address_error=request.method == 'POST', + generic_error='error' in request.args + ) + + instance_url = domain_from_url(instance_url) + + callback = url_for('misskey_callback', + instance_url=instance_url, _external=True) + + try: + session = make_session() + app = libforget.misskey.get_or_create_app( + instance_url, + callback, + url_for('index', _external=True), + session) + db.session.merge(app) + + db.session.commit() + + return redirect(libforget.misskey.login_url(app, callback, session)) + + except Exception: + if sentry: + sentry.captureException() + return redirect(url_for('misskey_login', error=True)) + + +@app.route('/login/misskey/callback/') +def misskey_callback(instance_url): + # legacy auth and miauth use different parameter names + token = request.args.get('token', None) or request.args.get('session', None) + app = MisskeyApp.query.get(instance_url) + if not token or not app: + return redirect(url_for('misskey_login', error=True)) + + token = libforget.misskey.receive_token(token, app) + account = token.account + + session = login(account.id) + + db.session.commit() + + g.viewer = session + + resp = redirect(url_for('index', _anchor='bump_instance')) + return resp + + @app.route('/sentry/setup.js') def sentry_setup(): client_dsn = app.config.get('SENTRY_DSN').split('@') diff --git a/tasks.py b/tasks.py index 61fc555..d0f594e 100644 --- a/tasks.py +++ b/tasks.py @@ -2,9 +2,10 @@ from celery import Celery, Task from app import app as flaskapp from app import db from model import Session, Account, TwitterArchive, Post, OAuthToken,\ - MastodonInstance + MastodonInstance, MisskeyInstance import libforget.twitter import libforget.mastodon +import libforget.misskey from datetime import timedelta, datetime, timezone from time import time from zipfile import ZipFile @@ -151,6 +152,8 @@ def fetch_acc(id_): fetch_posts = libforget.twitter.fetch_posts elif (account.service == 'mastodon'): fetch_posts = libforget.mastodon.fetch_posts + elif (account.service == 'misskey'): + fetch_posts = libforget.misskey.fetch_posts posts = fetch_posts(account, max_id, since_id) if posts is None: @@ -291,6 +294,10 @@ def delete_from_account(account_id): if refreshed and is_eligible(refreshed[0]): to_delete = refreshed[0] break + elif account.service == 'misskey': + action = libforget.misskey.delete + posts = refresh_posts(posts) + to_delete = next(filter(is_eligible, posts), None) if to_delete: print("Deleting {}".format(to_delete)) @@ -317,6 +324,8 @@ def refresh_posts(posts): return libforget.twitter.refresh_posts(posts) elif posts[0].service == 'mastodon': return libforget.mastodon.refresh_posts(posts) + elif posts[0].service == 'misskey': + return libforget.misskey.refresh_posts(posts) @app.task() @@ -474,6 +483,33 @@ def update_mastodon_instances_popularity(): }) db.session.commit() +@app.task +def update_misskey_instances_popularity(): + # bump score for each active account + for acct in (Account.query.options(db.joinedload(Account.sessions)) + .filter(~Account.dormant).filter( + Account.id.like('misskey:%'))): + instance = MisskeyInstance.query.get(acct.misskey_instance) + if not instance: + instance = MisskeyInstance( + instance=acct.misskey_instance, popularity=10) + db.session.add(instance) + amount = 0.01 + if acct.policy_enabled: + amount = 0.5 + for _ in acct.sessions: + amount += 0.1 + instance.bump(amount / max(1, instance.popularity)) + + # normalise scores so the top is 20 + top_pop = (db.session.query(db.func.max(MisskeyInstance.popularity)) + .scalar()) + MisskeyInstance.query.update({ + MisskeyInstance.popularity: + MisskeyInstance.popularity * 20 / top_pop + }) + db.session.commit() + app.add_periodic_task(40, queue_fetch_for_most_stale_accounts) app.add_periodic_task(9, queue_deletes) @@ -481,6 +517,7 @@ app.add_periodic_task(6, refresh_account_with_oldest_post) app.add_periodic_task(50, refresh_account_with_longest_time_since_refresh) app.add_periodic_task(300, periodic_cleanup) app.add_periodic_task(300, update_mastodon_instances_popularity) +app.add_periodic_task(300, update_misskey_instances_popularity) if __name__ == '__main__': app.worker_main() diff --git a/templates/about.html b/templates/about.html index ede9d57..aa6e3c4 100644 --- a/templates/about.html +++ b/templates/about.html @@ -46,11 +46,37 @@ {% endif %} +

+ +

+{% for instance in misskey_instances %} + +{% if loop.first %} + {{picture(st, 'misskey', (20,40,80), ('webp', 'png'))}} + Log in with +{% endif %} + {{instance}} + +{% else %} + + {{picture(st, 'misskey', (20,40,80), ('webp', 'png'))}} + Log in with Misskey + +{% endfor %} + + +{% if misskey_instances %} + + Another Misskey instance + +{% endif %} +

+ - - - + + + + + + + + + {% endif %} @@ -86,7 +143,7 @@
  • Delete your posts when they cross an age threshold.
  • Or keep your post count in check, deleting old posts when you go over.
  • -
  • Preserve old posts that matter by giving them a favourite.
  • +
  • Preserve old posts that matter by giving them a favourite or a reaction (Misskey only).
  • Set it and forget it. Forget works continuously in the background.
diff --git a/templates/misskey_login.html b/templates/misskey_login.html new file mode 100644 index 0000000..c830078 --- /dev/null +++ b/templates/misskey_login.html @@ -0,0 +1,29 @@ +{% extends 'lib/layout.html' %} +{% block body %} +
+

Log in with Misskey

+ +{% if generic_error %} + +{% endif %} + +{% if address_error %} + +{% endif %} + +
+ + + + +
+ + +
+{% endblock %} diff --git a/templates/privacy.html b/templates/privacy.html index 593d377..8cf1669 100644 --- a/templates/privacy.html +++ b/templates/privacy.html @@ -12,12 +12,12 @@
  • A unique post identifier
  • The post's time and date of publishing
  • Whether the post has any media attached
  • -
  • Whether the post has been favourited by you
  • -
  • (Mastodon only) Whether the post is a direct message
  • +
  • Whether the post has been favourited by you (only Twitter or Mastodon); or if (not how) you reacted to the post (Misskey only)
  • +
  • Whether the post is a direct message (only Mastodon or Misskey)
  • No other post metadata and no post contents are stored by Forget.

    -

    Last updated on 2021-05-14. History.

    +

    Last updated on 2021-11-11. History.

    {% endblock %}