#!/usr/bin/env python3 """bellows のサーバー。127.0.0.1 だけで動く。 暗号文の出し入れしかしない。合言葉も鍵も平文も、このプロセスには来ない。 暗号化と復号はブラウザの中(WebCrypto)で行う。 箱は合言葉ごとに1つある。<箱> は作ったときに付ける16進8字で、中身とは関係がない。 どの合言葉がどの箱かはサーバーは知らない。ブラウザが全部の箱の頭に合言葉を当てて決める。 matters/<箱>/box.head 鍵を作る塩と、包んだ中身の鍵(合言葉用と復旧キー用) matters/<箱>/box.log 暗号文の記録を後ろへ足していく。1件は 1KB の倍数にそろえてある matters/<箱>/box.log.pack まとめ直している途中の新しい記録。入れ替えるまで box.log は触らない matters/<箱>/att/<名前> 添付1つの暗号文。名前は16進12字のでたらめな字で、中身とは関係がない。64KB の倍数にそろえてある matters/<箱>/box.img 添付を1つのファイルに足していたころの形。開いたときにブラウザが att/ へ移し、消す piles/<箱>/box.log.1 まとめ直す前の記録(1つだけ残す) piles/<箱>/box.head.1〜5 頭の前の世代 matters/ の直下に box.head があれば、箱が1つだったころの形だ。起動したときに箱のフォルダへ移す。 標準ライブラリだけで動く。 """ import http.server import json import os import pathlib import re import secrets import shutil import sys import threading import time import urllib.parse ROOT = pathlib.Path(__file__).resolve().parent.parent COMMONS = pathlib.Path(__file__).resolve().parent # 画面のファイルは server.py と同じフォルダにある。手元では commons/、公開版では app/ だ(2026-09-28。前は commons/ と決め打ちしていて、公開版で画面が開かなかった) MATTERS = pathlib.Path(os.environ.get('BELLOWS_MATTERS', ROOT / 'matters')) PILES = pathlib.Path(os.environ.get('BELLOWS_PILES', ROOT / 'piles')) LOG_MAGIC = b'BELLOWS1\n' IMG_MAGIC = b'BELLOWSI\n' PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8782 MAX_BODY = 256 * 1024 * 1024 LOCK = threading.Lock() # 書き込みは1つずつ TYPES = {'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.woff2': 'font/woff2'} CSP = ("default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; " "style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; " "connect-src 'self'; frame-src blob:; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'") def etag(p): st = p.stat() return f'"{st.st_mtime_ns}-{st.st_size}"' def size(p): return p.stat().st_size if p.exists() else 0 BOX_RE = re.compile(r'[0-9a-f]{8}') ATT_RE = re.compile(r'[0-9a-f]{12}') # 添付の名前。これ以外は受け付けない。../ などで箱の外を触らせない class Box: """1つの箱の置き場所。""" def __init__(self, name): self.name = name self.dir = MATTERS / name self.piles = PILES / name self.head = self.dir / 'box.head' self.log = self.dir / 'box.log' self.pack = self.dir / 'box.log.pack' self.img = self.dir / 'box.img' self.att = self.dir / 'att' def boxes(): if not MATTERS.exists(): return [] return [Box(d.name) for d in MATTERS.iterdir() if d.is_dir() and BOX_RE.fullmatch(d.name) and (d / 'box.head').exists()] def migrate(): """箱が1つだったころの形(matters/box.head)を、箱のフォルダへ移す。""" if not (MATTERS / 'box.head').exists(): return b = Box(secrets.token_hex(4)) b.dir.mkdir(parents=True, mode=0o700) for n in ('box.head', 'box.log', 'box.log.pack', 'box.img'): if (MATTERS / n).exists(): os.replace(MATTERS / n, b.dir / n) if PILES.exists(): olds = [p for p in PILES.iterdir() if p.is_file() and p.name.startswith('box.')] if olds: b.piles.mkdir(parents=True, exist_ok=True, mode=0o700) for p in olds: os.replace(p, b.piles / p.name) print(f'bellows: 箱を matters/{b.name}/ へ移した', flush=True) def rotate(src, piles, name, gens=5): piles.mkdir(parents=True, exist_ok=True, mode=0o700) for i in range(gens, 1, -1): a, b = piles / f'{name}.{i - 1}', piles / f'{name}.{i}' if a.exists(): os.replace(a, b) shutil.copy2(src, piles / f'{name}.1') def write_atomic(path, data): tmp = path.with_name(path.name + '.tmp') with open(tmp, 'wb') as f: f.write(data) f.flush() os.fsync(f.fileno()) os.replace(tmp, path) def append(p, data, expect, magic=LOG_MAGIC): """後ろへ足す。いまの大きさが expect と違えば足さない(よそで書き足されている)。""" now = size(p) if expect != str(now): return None, now p.parent.mkdir(parents=True, exist_ok=True) with open(p, 'ab') as f: if now == 0: f.write(magic) f.write(data) f.flush() os.fsync(f.fileno()) return size(p), None class Handler(http.server.BaseHTTPRequestHandler): server_version = 'bellows' sys_version = '' def log_message(self, fmt, *args): pass # 別のサイトからの読み書きと DNS rebinding を断る def host_ok(self): return self.headers.get('Host', '') in (f'127.0.0.1:{PORT}', f'localhost:{PORT}') def write_ok(self): origin = self.headers.get('Origin') return (self.headers.get('X-Bellows') == '1' and origin in (None, f'http://127.0.0.1:{PORT}', f'http://localhost:{PORT}')) def reply(self, code, body=b'', ctype='application/octet-stream', headers=None, head=False): self.send_response(code) self.send_header('Content-Type', ctype) self.send_header('Content-Length', str(len(body))) self.send_header('Cache-Control', 'no-store') self.send_header('X-Content-Type-Options', 'nosniff') self.send_header('Referrer-Policy', 'no-referrer') self.send_header('Cross-Origin-Opener-Policy', 'same-origin') if ctype.startswith('text/html'): self.send_header('Content-Security-Policy', CSP) for k, v in (headers or {}).items(): self.send_header(k, v) self.end_headers() if not head: self.wfile.write(body) def json(self, code, obj): self.reply(code, json.dumps(obj, ensure_ascii=False).encode(), 'application/json; charset=utf-8') def body(self): n = int(self.headers.get('Content-Length') or 0) if n > MAX_BODY: raise ValueError('大きすぎる') return self.rfile.read(n) def static(self, rel): if not re.fullmatch(r'[\w\-.]+(/[\w\-.]+)*', rel) or '..' in rel: return self.reply(404) p = COMMONS / rel if not p.is_file(): return self.reply(404) self.reply(200, p.read_bytes(), TYPES.get(p.suffix, 'application/octet-stream')) def path_only(self): return self.path.split('?')[0] def query(self): return urllib.parse.parse_qs(self.path.split('?', 1)[1] if '?' in self.path else '') def pack(self): return self.query().get('to') == ['pack'] def box(self): """?box= で名指された箱。ないか形が違えば None。""" name = (self.query().get('box') or [''])[0] if not BOX_RE.fullmatch(name) or not (MATTERS / name / 'box.head').exists(): return None return Box(name) def att_file(self, b): name = (self.query().get('id') or [''])[0] return b.att / name if ATT_RE.fullmatch(name) else None def do_HEAD(self): if not self.host_ok(): return self.reply(403, head=True) b = self.box() if b is None: return self.reply(404, head=True) if self.path_only() == '/api/log': return self.reply(200, headers={'X-Size': str(size(b.pack if self.pack() else b.log))}, head=True) if self.path_only() == '/api/img': return self.reply(200, headers={'X-Size': str(size(b.img))}, head=True) self.reply(404, head=True) def do_GET(self): if not self.host_ok(): return self.reply(403) path = self.path_only() if path in ('/', '/index.html'): return self.static('index.html') if path.startswith('/vendor/'): return self.static(path[1:]) if path == '/api/heads': # 全部の箱の頭。最近書いた箱を先に並べる。ふだん使う箱ほど早く開く out = [] for b in sorted(boxes(), key=lambda b: -max(b.head.stat().st_mtime, b.log.stat().st_mtime if b.log.exists() else 0)): try: out.append({'box': b.name, 'etag': etag(b.head), 'head': json.loads(b.head.read_bytes())}) except ValueError: pass return self.json(200, out) b = self.box() if b is None: return self.reply(404) if path == '/api/log': if not b.log.exists(): return self.reply(200, b'', headers={'X-Size': '0'}) data = b.log.read_bytes() return self.reply(200, data, headers={'X-Size': str(len(data))}) if path == '/api/img': return self.img_range(b) if path == '/api/att': f = self.att_file(b) if f is None or not f.is_file(): return self.reply(404) return self.reply(200, f.read_bytes()) if path == '/api/atts': names = sorted(p.name for p in b.att.iterdir() if ATT_RE.fullmatch(p.name)) if b.att.exists() else [] return self.json(200, names) if path == '/api/files': out = [] for d in (b.dir, b.piles): if d.exists(): for p in sorted(d.iterdir()): if p.is_file() and not p.name.startswith('.'): st = p.stat() out.append({'dir': f'{d.parent.name}/{d.name}', 'name': p.name, 'size': st.st_size, 'mtime': time.strftime('%Y-%m-%d %H:%M', time.localtime(st.st_mtime))}) if b.att.exists(): fs = [p for p in b.att.iterdir() if p.is_file()] if fs: last = max(p.stat().st_mtime for p in fs) out.append({'dir': f'matters/{b.name}', 'name': f'att/({len(fs)})', 'size': sum(p.stat().st_size for p in fs), 'mtime': time.strftime('%Y-%m-%d %H:%M', time.localtime(last))}) return self.json(200, out) self.reply(404) def img_range(self, b): if not b.img.exists(): return self.reply(404) m = re.fullmatch(r'bytes=(\d+)-(\d+)', self.headers.get('Range', '')) if not m: return self.reply(416) x, y = int(m[1]), int(m[2]) n = b.img.stat().st_size if x > y or y >= n: return self.reply(416) with open(b.img, 'rb') as f: f.seek(x) data = f.read(y - x + 1) self.reply(206, data, headers={'Content-Range': f'bytes {x}-{y}/{n}'}) def do_PUT(self): if not (self.host_ok() and self.write_ok()): return self.reply(403) if self.path_only() == '/api/att': # 添付を1つ書く。同じ名前がもうあれば書かない data = self.body() with LOCK: b = self.box() f = b and self.att_file(b) if not f: return self.json(404, {'error': '箱か名前が違う'}) if f.exists(): return self.json(409, {'error': 'その名前はもうある'}) b.att.mkdir(exist_ok=True, mode=0o700) write_atomic(f, data) return self.json(200, {'size': len(data)}) if self.path_only() != '/api/head': return self.reply(404) data = self.body() try: json.loads(data) except ValueError: return self.json(400, {'error': 'JSON ではない'}) want = self.headers.get('If-Match', '') with LOCK: if self.query().get('box') == ['new']: # 新しい箱を作る。名前はサーバーが付ける if want != 'new': return self.json(409, {'error': '新しい箱には If-Match: new が要る'}) b = Box(secrets.token_hex(4)) while b.dir.exists(): b = Box(secrets.token_hex(4)) b.dir.mkdir(parents=True, mode=0o700) write_atomic(b.head, data) return self.json(200, {'etag': etag(b.head), 'box': b.name}) b = self.box() if b is None: return self.json(409, {'error': '箱がない'}) if want != etag(b.head): return self.json(409, {'error': 'よそで書き替えられている'}) rotate(b.head, b.piles, 'box.head') write_atomic(b.head, data) return self.json(200, {'etag': etag(b.head), 'box': b.name}) def do_DELETE(self): # 添付を1つ消す(/api/att)か、添付を1つのファイルに足していたころの box.img を消す(/api/img) if not (self.host_ok() and self.write_ok()): return self.reply(403) with LOCK: b = self.box() if b is None: return self.json(404, {'error': '箱がない'}) if self.path_only() == '/api/att': f = self.att_file(b) if f is None: return self.json(400, {'error': '名前が違う'}) if f.exists(): f.unlink() return self.json(200, {}) if self.path_only() == '/api/img': if b.img.exists(): b.img.unlink() return self.json(200, {}) self.reply(404) def do_POST(self): if not (self.host_ok() and self.write_ok()): return self.reply(403) path = self.path_only() data = self.body() with LOCK: b = self.box() if b is None: return self.json(404, {'error': '箱がない'}) if path == '/api/log': p = b.pack if self.pack() else b.log if self.pack() and self.headers.get('X-Reset') == '1' and b.pack.exists(): b.pack.unlink() # 前に途中でやめた分を捨てて、初めから書く n, now = append(p, data, self.headers.get('X-Expect-Size')) if n is None: return self.json(409, {'error': '記録の大きさが合わない', 'size': now}) return self.json(200, {'size': n}) if path == '/api/img': n, now = append(b.img, data, self.headers.get('X-Expect-Size'), IMG_MAGIC) if n is None: return self.json(409, {'error': '画像の大きさが合わない', 'size': now}) return self.json(200, {'size': n}) if path == '/api/commit': # まとめ直した記録といまの記録を入れ替える。前の記録は piles/<箱>/ に1つ残す if not b.pack.exists(): return self.json(409, {'error': 'まとめ直した記録がない'}) if self.headers.get('X-Expect-Size') != str(size(b.log)): return self.json(409, {'error': 'まとめ直すあいだに書き足された'}) b.piles.mkdir(parents=True, exist_ok=True, mode=0o700) if b.log.exists(): os.replace(b.log, b.piles / 'box.log.1') os.replace(b.pack, b.log) return self.json(200, {'size': size(b.log)}) self.reply(404) if __name__ == '__main__': migrate() httpd = http.server.ThreadingHTTPServer(('127.0.0.1', PORT), Handler) print(f'bellows: http://127.0.0.1:{PORT}/', flush=True) httpd.serve_forever()