Add native search forwarding, page handling, posting limits and cached previews using the saved thumbnail frame.
262 lines
9.2 KiB
Python
262 lines
9.2 KiB
Python
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", "<br>")
|
|
return text
|
|
|
|
|
|
def html_page(request, title, body, extra_head=""):
|
|
request.setHeader(b"content-type", b"text/html; charset=utf-8")
|
|
page = f"""<!doctype html><html><head><title>{dsi_text_html(title)}</title>
|
|
<meta name="uppertitle" content="{html.escape(str(title), quote=True)}">
|
|
<link rel="stylesheet" type="text/css" href="http://{ROOT_HOST}/css/ds/common.css">
|
|
<link rel="stylesheet" type="text/css" href="http://{ROOT_HOST}/css/ds/basic.css">
|
|
<link rel="stylesheet" type="text/css" href="http://{ROOT_HOST}/css/ds/ose.css">
|
|
{extra_head}</head><body><div class="container">{body}</div></body></html>"""
|
|
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('<a href="%s">Previous</a>' % 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('<a href="%s">Next</a>' % html.escape(dsi_url(request, base) + "?" + urlencode(query), quote=True))
|
|
return '<div align="center">' + " | ".join(links) + "</div>"
|
|
|
|
|
|
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
|
