diff --git a/maddy-chpw/.gitignore b/maddy-chpw/.gitignore
new file mode 100644
index 0000000..274e371
--- /dev/null
+++ b/maddy-chpw/.gitignore
@@ -0,0 +1,3 @@
+data.sql
+data.tsv
+config.py
diff --git a/maddy-chpw/config.py.example b/maddy-chpw/config.py.example
new file mode 100644
index 0000000..5d5a873
--- /dev/null
+++ b/maddy-chpw/config.py.example
@@ -0,0 +1,7 @@
+# fernet.Fernet.generate_key()
+FERNET_KEY = b'7FlZwGcQhhtGCfv7A-px98RtJZ-BTnnyXBF5hi4hVwg='
+DB_URL = 'postgresql:///'
+
+CLIENT_ID = 'aaaaaaaaaaaaaaaaaaaa'
+CLIENT_SECRET = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
+TARGET_ORG = 'ggggggggggg'
diff --git a/maddy-chpw/index-loggedin.html b/maddy-chpw/index-loggedin.html
new file mode 100644
index 0000000..de2e7e0
--- /dev/null
+++ b/maddy-chpw/index-loggedin.html
@@ -0,0 +1,35 @@
+
+
+
+
archlinuxcn.org 社区邮箱更改密码
+
+
+ {username}, 你好!
+
+ 密码最长为72字节。邮件地址若有异议,请与管理员联系。
+
+
+
+
+
diff --git a/maddy-chpw/index.html b/maddy-chpw/index.html
new file mode 100644
index 0000000..9963628
--- /dev/null
+++ b/maddy-chpw/index.html
@@ -0,0 +1,9 @@
+
+
+
+archlinuxcn.org 社区邮箱更改密码
+
+ 请先使用 GitHub 登录。
+
+
+
diff --git a/maddy-chpw/main.py b/maddy-chpw/main.py
new file mode 100755
index 0000000..a9ba445
--- /dev/null
+++ b/maddy-chpw/main.py
@@ -0,0 +1,172 @@
+#!/usr/bin/python3
+
+import logging
+import subprocess
+
+import aiohttp
+from aiohttp import web
+from aiohttp_session import setup, get_session, new_session
+from aiohttp_session.cookie_storage import EncryptedCookieStorage
+from cryptography import fernet
+from yarl import URL
+import asyncpg
+
+import config
+
+logger = logging.getLogger(__name__)
+KEY_DB = web.AppKey('db', asyncpg.Pool)
+
+async def index(request):
+ session = await get_session(request)
+ username = session.get('username')
+ if not username:
+ with open('index.html', 'rb') as f:
+ body = f.read()
+ return web.Response(
+ body = body,
+ content_type = 'text/html',
+ charset = 'utf-8',
+ )
+
+ db = request.app[KEY_DB]
+ mailaddr, _new = await get_mailinfo(db, username)
+ with open('index-loggedin.html') as f:
+ body = f.read()
+ return web.Response(
+ text = body.format(username=username, mailaddr=f'{mailaddr}@archlinuxcn.org'),
+ content_type = 'text/html',
+ charset = 'utf-8',
+ )
+
+async def github_login(request):
+ code = request.query.get('code')
+ if not code:
+ url = URL('https://github.com/login/oauth/authorize') % {
+ 'client_id': config.CLIENT_ID,
+ 'redirect_uri': f'https://{request.host}/mail/login',
+ 'scope': 'read:org',
+ }
+ raise web.HTTPFound(str(url))
+
+ async with aiohttp.ClientSession() as session:
+ url = 'https://github.com/login/oauth/access_token'
+ data = {
+ 'client_id': config.CLIENT_ID,
+ 'client_secret': config.CLIENT_SECRET,
+ 'code': code,
+ }
+ headers = {
+ 'Accept': 'application/json',
+ }
+ async with session.post(url, data=data, headers=headers) as res:
+ j = await res.json()
+ access_token = j['access_token']
+
+ url = 'https://api.github.com/user/orgs'
+ headers = {
+ 'Accept': 'application/vnd.github+json',
+ 'Authorization': f'Bearer {access_token}',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ }
+ async with session.get(url, headers=headers) as res:
+ j = await res.json()
+ orgs = [o['login'] for o in j]
+ if config.TARGET_ORG not in orgs:
+ raise web.HTTPForbidden()
+
+ url = 'https://api.github.com/user'
+ async with session.get(url, headers=headers) as res:
+ j = await res.json()
+ username = j['login']
+ session = await new_session(request)
+ session['username'] = username
+
+ raise web.HTTPFound('/mail/chpw')
+
+async def submit(request):
+ session = await get_session(request)
+ username = session.get('username')
+ if not username:
+ raise web.HTTPForbidden()
+
+ data = await request.post()
+ newpass = data.get('password')
+ if not newpass:
+ raise web.HTTPBadRequest()
+
+ db = request.app[KEY_DB]
+ mailaddr, new = await get_mailinfo(db, username)
+ logger.info('%s wants to change password for %s (new? %s).', username, mailaddr, new)
+ await chpw(db, mailaddr, newpass, new)
+
+ return web.Response(text='OK')
+
+async def get_mailinfo(db, username):
+ async with db.acquire() as conn, conn.transaction():
+ rs = await conn.fetch('select mailname, new from mailinfo where github ilike $1', username)
+ if not rs:
+ raise web.HTTPNotFound(reason='user not found in database')
+
+ return rs[0]
+
+async def chpw(db, addr, newpass, new):
+ newpass = newpass.encode()
+ mailaddr = f'{addr}@archlinuxcn.org'
+ if new:
+ cmd = ['maddy', 'creds', 'create', mailaddr]
+ subprocess.run(cmd, input=newpass, check=True)
+ cmd = ['maddy', 'imap-acct', 'create', mailaddr]
+ subprocess.run(cmd, check=True)
+ async with db.acquire() as conn, conn.transaction():
+ await conn.execute('update mailinfo set new = false where mailname = $1', addr)
+ else:
+ cmd = ['maddy', 'creds', 'password', mailaddr]
+ subprocess.run(cmd, input=newpass, check=True)
+
+async def init_db(app):
+ app[KEY_DB] = await asyncpg.create_pool(config.DB_URL, setup=conn_init, min_size=0)
+ yield
+ await app[KEY_DB].close()
+
+async def conn_init(conn):
+ await conn.execute("set search_path to 'mailusers'")
+
+def setup_app(app):
+ app.cleanup_ctx.append(init_db)
+
+ f = fernet.Fernet(config.FERNET_KEY)
+ setup(app, EncryptedCookieStorage(
+ f, path='/mail/chpw', max_age=86400,
+ secure=True, samesite='Lax',
+ ))
+
+ app.router.add_get('/mail/chpw', index)
+ app.router.add_post('/mail/chpw', submit)
+ app.router.add_get('/mail/login', github_login)
+
+def main():
+ import argparse
+
+ from nicelogger import enable_pretty_logging
+
+ parser = argparse.ArgumentParser(
+ description = 'change password for maddy accounts',
+ )
+ parser.add_argument('--port', default=9008, type=int,
+ help='port to listen on')
+ parser.add_argument('--ip', default='127.0.0.1',
+ help='address to listen on')
+ parser.add_argument('--loglevel', default='info',
+ choices=['debug', 'info', 'warn', 'error'],
+ help='log level')
+ args = parser.parse_args()
+
+ enable_pretty_logging(args.loglevel.upper())
+
+ app = web.Application()
+ setup_app(app)
+
+ web.run_app(app, host=args.ip, port=args.port)
+
+if __name__ == '__main__':
+ main()