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