from __future__ import annotations import html import hashlib import io import math from collections import OrderedDict from pathlib import Path from threading import RLock from urllib.parse import urlencode from PIL import Image from DB import Database from Hatenatools import PPM, UGO from hatena import client_ip, request_args, request_headers, request_path from reximemo_image_formats import encode_image ROOT_HOST = "flipnote.hatena.com" ROOT = Path(__file__).resolve().parent _THUMBNAILS = OrderedDict() _THUMBNAIL_LOCK = RLock() def region_from_request(request): for part in request_path(request).split("/"): if part.startswith("v2-"): return part[3:] return "xx" def host_for_region(region): return "ugomemo.hatena.ne.jp" if region == "jp" else ROOT_HOST def dsi_url(request, suffix): region = region_from_request(request) return f"http://{host_for_region(region)}/ds/v2-{region}/{str(suffix).lstrip('/')}" def current_account(request): return Database.account_for_ip(client_ip(request)) def posting_account(request): from server_config import load_config account = current_account(request) if account is None and not load_config()["guest_posting"]: return None, dialog(request, "Sign in from Account before posting on this server.") return account, None def read_ppm_upload(request): from server_config import load_config maximum = int(load_config()["max_upload_bytes"]) data = request.content.read(maximum + 1) if len(data) > maximum: raise ValueError("This Flipnote is larger than the server's upload limit.") return data def current_creator(request, *, fsid="", display_name=""): account = current_account(request) return Database.get_or_create_creator( client_ip(request), int(account["id"]) if account else None, fsid=fsid, display_name=display_name, ) def keyboard_value(request): return request_headers(request).get("x-email-addr", "").strip() def search_keyboard_response(request, kind="flipnotes"): query = keyboard_value(request)[:100].strip() if not query: return dialog(request, "Enter a title or creator name to search for.") scope = "creators" if kind == "creators" else "flipnotes" forward(request, dsi_url(request, "search.uls?") + urlencode({"q": query, "kind": scope})) return b"OK" def dialog(request, message, status=200): request.setResponseCode(int(status)) request.setHeader(b"X-DSi-Dialog-Type", b"1") request.setHeader(b"content-type", b"text/plain") return str(message).encode("utf-16le") def forward(request, target): request.setHeader(b"X-DSi-Forwarder", str(target).encode("ascii", "ignore")) def dsi_text_html(value, preserve_newlines=False): """Escape DSi text nodes without unsupported quote entities.""" text = "" if value is None else str(value) text = text.replace("\x00", "") text = text.replace("&", "&").replace("<", "<").replace(">", ">") if preserve_newlines: text = text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "
") return text def html_page(request, title, body, extra_head=""): request.setHeader(b"content-type", b"text/html; charset=utf-8") page = f"""{dsi_text_html(title)} {extra_head}
{body}
""" return page.encode("utf-8") def query_int(request, name, default=1, minimum=1, maximum=1000000): try: n = int(request_args(request).get(name, [str(default)])[0]) except (TypeError, ValueError): n = default return min(maximum, max(minimum, n)) def page_number(request, total, per_page=50, name="page"): return min(query_int(request, name), max(1, int(math.ceil(total / float(per_page))))) def pagination_html(request, base, page, total, per_page=30, params=None, name="page"): pages = max(1, int(math.ceil(total / float(per_page)))) if pages == 1: return "" params = dict(params or {}) links = [] if page > 1: query = dict(params, **{name: page - 1}) links.append('Previous' % html.escape(dsi_url(request, base) + "?" + urlencode(query), quote=True)) links.append("Page %d of %d" % (page, pages)) if page < pages: query = dict(params, **{name: page + 1}) links.append('Next' % html.escape(dsi_url(request, base) + "?" + urlencode(query), quote=True)) return '
' + " | ".join(links) + "
" def make_menu(layout=(4,), title=None, subtitle_top="", subtitle_bottom="", left="", right="", buttons=None, dropdowns=None, corner=None): ugo = UGO() ugo.Loaded = True ugo.Items = [("layout", tuple(layout))] if title is not None: ugo.Items.append(("topscreen text", [str(title), str(left), str(right), str(subtitle_top), str(subtitle_bottom)], 0)) for d in dropdowns or []: ugo.Items.append(("category", d["url"], d["label"], bool(d.get("selected")))) if corner: ugo.Items.append(("post", corner["url"], corner["label"])) embed_index = 0 for b in buttons or []: if b.get("thumbnail") is not None: ugo.Items.append(( "button", 3, b.get("label", ""), b["url"], (str(b.get("stars", 0)), str(b.get("lock", 765)), "573", "0"), (b.get("embed_name", "thumb.tmb"), b["thumbnail"]), )) embed_index += 1 elif b.get("icon_ntft") is not None: icon_data = bytes(b["icon_ntft"]) if len(icon_data) != 2048: raise ValueError("UGO menu icons must be 32x32 NTFT data") ugo.Items.append(( "button", int(b.get("icon_trait", embed_index)), b.get("label", ""), b["url"], ("", "0"), (b.get("embed_name", "icon.ntft"), icon_data), )) embed_index += 1 else: ugo.Items.append(("button", int(b.get("icon", 100)), b.get("label", ""), b["url"], ("", "0"), None)) return ugo.Pack() def placeholder_icon(name="browse"): path = ROOT / "hatenadir" / "images" / "ds" / f"{name}.ntft" return path.read_bytes() def flipnote_buttons(request, rows): buttons = [] for fn in rows: try: tmb = Database.GetFlipnoteTMB(fn["creator_fsid"], fn["filename"]) except OSError: continue buttons.append({ "label": "", "url": dsi_url(request, f"movie/{fn['creator_fsid']}/{fn['filename']}.htm"), "thumbnail": tmb, "embed_name": fn["filename"] + ".tmb", "stars": int(fn.get("stars") or 0), "lock": 765, }) return buttons def pagination_buttons(request, base, page, total, per_page=50, params=None): params = dict(params or {}) max_page = max(1, int(math.ceil(total / float(per_page)))) out = [] if page > 1: q = dict(params); q["page"] = page - 1 out.append({"label": "Previous Page", "url": dsi_url(request, base) + "?" + urlencode(q), "icon": 116}) if page < max_page: q = dict(params); q["page"] = page + 1 out.append({"label": "Next Page", "url": dsi_url(request, base) + "?" + urlencode(q), "icon": 115}) return out def ppm_first_frame_image(raw): return ppm_frame_image(raw, 0) def ppm_frame_image(raw, frame_index): ppm = PPM().Read(raw, ReadFrames=True, ReadSound=False) if not ppm: raise ValueError("Invalid PPM") frame_index = int(frame_index) if frame_index < 0 or frame_index >= int(ppm.FrameCount): frame_index = 0 frame = ppm.GetFrame(frame_index) if frame is False or frame is None: raise ValueError("Missing frame") data = frame.tobytes(order="F") return Image.frombytes("RGBA", (len(frame), len(frame[0])), data) def ppm_thumbnail_png(raw, size=(256, 192), *, first_frame=False): raw = bytes(raw) size = tuple(map(int, size)) key = (hashlib.sha256(raw).digest(), size, bool(first_frame)) with _THUMBNAIL_LOCK: cached = _THUMBNAILS.get(key) if cached is not None: _THUMBNAILS.move_to_end(key) return cached frame_index = 0 if first_frame else int.from_bytes(raw[0x12:0x14], "little") image = ppm_frame_image(raw, frame_index).convert("RGB") image.thumbnail(size, Image.Resampling.NEAREST) out = io.BytesIO() image.save(out, "PNG", optimize=True) result = out.getvalue() with _THUMBNAIL_LOCK: _THUMBNAILS[key] = result _THUMBNAILS.move_to_end(key) while len(_THUMBNAILS) > 64: _THUMBNAILS.popitem(last=False) return result def comment_npf(comment_id, width=128, height=64): raw = Database.comment_ppm(int(comment_id)) if raw is None: return None image = ppm_first_frame_image(raw).convert("RGBA") image = image.resize((int(width), int(height)), Image.Resampling.NEAREST) return encode_image(image, "npf").data