Merge branch 'misskey'

This commit is contained in:
codl 2022-02-27 00:38:03 +01:00
commit 9d2147e905
16 changed files with 579 additions and 51 deletions

View File

@ -1,5 +1,7 @@
## vNEXT
* add: Misskey support (Thanks @Johann150 !)
<https://github.com/codl/forget/pull/544>
* fix: lowered database impact of a background task
<https://github.com/codl/forget/issue/166>
* fix: wording on "favourited posts" is unclear

View File

@ -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
<https://forget.codl.fr>.
Forget is a post deleting service for Twitter, Mastodon, and Misskey. It lives
at <https://forget.codl.fr>.
## Running your own

View File

@ -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();
})();

View File

@ -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;
}

BIN
assets/misskey.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -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);

View File

@ -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);

View File

@ -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))],

191
libforget/misskey.py Normal file
View File

@ -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())))

View File

@ -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;')

View File

@ -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

View File

@ -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/<instance_url>')
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('@')

View File

@ -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()

View File

@ -46,11 +46,37 @@
</a>
{% endif %}
</p>
<p id='misskey_instance_buttons'>
{% for instance in misskey_instances %}
<a class='btn primary misskey-colored' href="{{ url_for('misskey_login', instance_url=instance) }}">
{% if loop.first %}
{{picture(st, 'misskey', (20,40,80), ('webp', 'png'))}}
Log in with
{% endif %}
{{instance}}
</a>
{% else %}
<a class='btn primary misskey-colored' href="{{ url_for('misskey_login') }}">
{{picture(st, 'misskey', (20,40,80), ('webp', 'png'))}}
Log in with Misskey
</a>
{% endfor %}
{% if misskey_instances %}
<a class='btn secondary' href="{{ url_for('misskey_login') }}">
Another Misskey instance
</a>
{% endif %}
</p>
</section>
<!-- Mastodon -->
<script type="application/json" id="top_instances">
<script type="application/json" id="mastodon_top_instances">
[
{% for instance in mastodon_instances %}
{"instance": "{{instance}}"}
@ -61,7 +87,7 @@
]
</script>
<script type="text/html+template" id="instance_button_template">
<script type="text/html+template" id="mastodon_instance_button_template">
<a class='btn primary mastodon-colored'
href="{{ url_for('mastodon_login_step1') }}?instance_url=${encodeURIComponent(instance)}">
${ !first? '' : `
@ -72,12 +98,43 @@
</a>
</script>
<script type="text/html+template" id="another_instance_button_template">
<script type="text/html+template" id="mastodon_another_instance_button_template">
<a class='btn secondary' href="{{ url_for('mastodon_login_step1') }}">
Another Mastodon instance
</a>
</script>
<!-- Misskey -->
<script type="application/json" id="misskey_top_instances">
[
{% for instance in misskey_instances %}
{"instance": "{{instance}}"}
{%- if not loop.last -%}
,
{%- endif %}
{% endfor %}
]
</script>
<script type="text/html+template" id="misskey_instance_button_template">
<a class='btn primary misskey-colored'
href="{{ url_for('misskey_login') }}?instance_url=${encodeURIComponent(instance)}">
${ !first? '' : `
{{picture(st, 'misskey', (20,40,80), ('webp', 'png'))}}
Log in with
`}
${ instance }
</a>
</script>
<script type="text/html+template" id="misskey_another_instance_button_template">
<a class='btn secondary' href="{{ url_for('misskey_login') }}">
Another Misskey instance
</a>
</script>
{% endif %}
@ -86,7 +143,7 @@
<ul>
<li>Delete your posts when they cross an age threshold.</li>
<li>Or keep your post count in check, deleting old posts when you go over.</li>
<li>Preserve old posts that matter by giving them a favourite.</li>
<li>Preserve old posts that matter by giving them a favourite or a reaction (Misskey only).</li>
<li>Set it and <i>forget</i> it. Forget works continuously in the background.</li>
</ul>
</section>

View File

@ -0,0 +1,29 @@
{% extends 'lib/layout.html' %}
{% block body %}
<section>
<h2>Log in with Misskey</h2>
{% if generic_error %}
<div class='banner error'>Something went wrong while logging in. Try again?</div>
{% endif %}
{% if address_error %}
<div class='banner error'>This doesn't look like a misskey instance url. Try again?</div>
{% endif %}
<form method='post'>
<label>misskey instance:
<input type='text' name='instance_url' list='instances' placeholder='social.example.net'/>
</label>
<datalist id='instances'>
<option value=''>
{% for instance in instances %}
<option value='{{instance}}'>
{% endfor %}
</datalist>
<input name='confirm' value='Log in' type='submit'/>
</form>
</div>
</section>
{% endblock %}

View File

@ -12,12 +12,12 @@
<li>A unique post identifier</li>
<li>The post's time and date of publishing</li>
<li>Whether the post has any media attached</li>
<li>Whether the post has been favourited by you</li>
<li>(Mastodon only) Whether the post is a direct message</li>
<li>Whether the post has been favourited by you (only Twitter or Mastodon); or if (not how) you reacted to the post (Misskey only)</li>
<li>Whether the post is a direct message (only Mastodon or Misskey)</li>
</ul>
<p>No other post metadata and no post contents are stored by Forget.</p>
<p>Last updated on 2021-05-14. <a href="https://github.com/codl/forget/commits/master/templates/privacy.html">History</a>.</p>
<p>Last updated on 2021-11-11. <a href="https://github.com/codl/forget/commits/master/templates/privacy.html">History</a>.</p>
</section>
{% endblock %}