commit 0ee792a8e37f94c6aa8f1d897d249783184de243 Author: yuuki <> Date: Fri Jun 14 08:03:49 2024 +0000 最初のコミット diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c3fba8c --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +/.git diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2cc124c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3 +COPY . /app +WORKDIR /app +RUN pip install -r requirements.txt +ENV PYTHONUNBUFFERED 1 +CMD ["fastapi", "run", "--workers", "5"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..f0814dd --- /dev/null +++ b/app.py @@ -0,0 +1,202 @@ +from typing import Callable, Awaitable, Optional, List +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 +from os import environ +from urllib.parse import urlparse +from base64 import b64encode +from re import escape, compile, IGNORECASE + +from requests import get +from fastapi import FastAPI, Request, Response, status +from fastapi.responses import JSONResponse, Response, PlainTextResponse, FileResponse +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel +from pymongo import MongoClient, DESCENDING + +# Jinja2 + +def ip_to_uid(ip: Optional[str]) -> str: + if not ip: + return "-" + + return b64encode(ip.encode("utf-8")).decode("utf-8")[:11] + +def replace_ng_words(src: str, ng_words: List[str]) -> str: + result = src + + for ng_word in ng_words: + pattern = compile(escape(ng_word), IGNORECASE) + if len(ng_word) == 1: + result = pattern.sub("🆖", result) + elif len(ng_word) >= 2: + result = pattern.sub(ng_word[0] + "🆖" + ng_word[2:], result) + + return result + +def content_to_linksets(content: str) -> str: + pattern = compile(r"https?:\/\/\S+") + groups = pattern.findall(content) + return "\n".join(groups) + +# 初期化 + +templates = Jinja2Templates("templates") + +templates.env.filters["ip_to_uid"] = ip_to_uid +templates.env.filters["replace_ng_words"] = replace_ng_words +templates.env.filters["content_to_linksets"] = content_to_linksets + +mongo_client = MongoClient( + environ.get("MONGO_URI", "mongodb://127.0.0.1:27017/"), + username=environ.get("MONGO_USER"), + password=environ.get("MONGO_PASSWORD") +) + +mongo_client.litey.ngs.create_index("word", unique=True) + +# スニペット + +class LiteYItem(BaseModel): + content: str + +class LiteYDeleteItem(BaseModel): + id: str + +class NGItem(BaseModel): + word: str + +def fastapi_serve(dir: str, ref: str, indexes: List[str] = ["index.html", "index.htm"]) -> Response: + url_path = urlparse(ref or "/").path + root = Path(dir) + + try_files = [] + + if url_path.endswith("/"): + try_files += [root / url_path.lstrip("/") / i for i in indexes] + + try_files += [root / url_path] + + try_files_tried = [t for t in try_files if t.is_file()] + + print(try_files, try_files_tried) + + if not try_files_tried: + return PlainTextResponse("指定されたファイルが見つかりません", status.HTTP_404_NOT_FOUND) + + path = try_files_tried[0] + + print(path, "をサーブ中") + + return FileResponse(path) + +def get_ip(req: Request) -> str: + return req.headers.get("CF-Connecting-IP") or req.client.host + +def get_litey_notes(id: str = None) -> List[dict]: + if not id: + cursor = mongo_client.litey.notes.find({}, { "_id": False }).sort("date", DESCENDING) + return list(cursor) + + return mongo_client.litey.notes.find_one({ "id": id }, { "_id": False }) + +def get_ng_words(): + cursor = mongo_client.litey.ngs.find({}, { "_id": False }) + return [ng["word"] for ng in list(cursor) if "word" in ng] + +# FastAPI + +app = FastAPI() + +@app.middleware("http") +async def cors_handler(req: Request, call_next: Callable[[Request], Awaitable[Response]]): + res = await call_next(req) + + if req.url.path.startswith("/api/"): + res.headers["Access-Control-Allow-Origin"] = "*" + res.headers["Access-Control-Allow-Credentials"] = "true" + res.headers["Access-Control-Allow-Methods"] = "*" + res.headers["Access-Control-Allow-Headers"] = "*" + + if req.method == "OPTIONS": + res.status_code = status.HTTP_200_OK + + return res + +@app.get("/api/litey/get") +async def api_get(id: str = None): + res = JSONResponse(get_litey_notes(id)) + res.headers["Cache-Control"] = f"public, max-age=0, s-maxage=0" + res.headers["CDN-Cache-Control"] = f"max-age=0" + return res + +@app.post("/api/litey/post") +async def api_post(item: LiteYItem, req: Request): + mongo_client.litey.notes.insert_one({ + "id": str(uuid4()), + "content": item.content, + "date": datetime.now().astimezone(timezone.utc).isoformat(), + "ip": get_ip(req) + }) + + return PlainTextResponse("OK") + +@app.post("/api/litey/delete") +async def api_delete(item: LiteYDeleteItem): + mongo_client.litey.notes.delete_one({ "id": item.id }) + + return PlainTextResponse("OK") + +@app.get("/api/litey/image-proxy") +async def api_image_proxy(url: str): + result = get(url, timeout=5, headers={ + "User-Agent": Path("user_agent.txt").read_text("UTF-8").rstrip("\n") + }) + + content = result.content + media_type = result.headers.get("Content-Type") + + res = Response(content, media_type=media_type) + res.headers["Cache-Control"] = f"public, max-age=3600, s-maxage=3600" + res.headers["CDN-Cache-Control"] = f"max-age=3600" + return res + +@app.get("/api/ng/get") +async def api_ng_get(): + res = PlainTextResponse("\n".join(get_ng_words())) + res.headers["Cache-Control"] = f"public, max-age=0, s-maxage=0" + res.headers["CDN-Cache-Control"] = f"max-age=0" + return res + +@app.post("/api/ng/post") +async def api_ng_post(item: NGItem): + mongo_client.litey.ngs.insert_one({ + "word": item.word + }) + + return PlainTextResponse("OK") + +@app.post("/api/ng/delete") +async def api_ng_post(item: NGItem): + mongo_client.litey.ngs.delete_one({ + "word": item.word + }) + + return PlainTextResponse("OK") + +@app.get("/") +async def home(req: Request): + res = templates.TemplateResponse(req, "index.html", { + "notes": get_litey_notes(), + "ng_words": get_ng_words() + }) + res.headers["Cache-Control"] = f"public, max-age=0, s-maxage=0" + res.headers["CDN-Cache-Control"] = f"max-age=0" + return res + +@app.get("/{ref:path}") +async def static(ref: str = None): + res = fastapi_serve("static", ref) + res.headers["Cache-Control"] = f"public, max-age=3600, s-maxage=3600" + res.headers["CDN-Cache-Control"] = f"max-age=3600" + return res diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a98b0c9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests==2.32.3 +fastapi==0.111.0 +pymongo==4.7.3 +Jinja2==3.1.4 diff --git a/static/roboto/Roboto-Black.ttf b/static/roboto/Roboto-Black.ttf new file mode 100644 index 0000000..0112e7d Binary files /dev/null and b/static/roboto/Roboto-Black.ttf differ diff --git a/static/roboto/Roboto-BlackItalic.ttf b/static/roboto/Roboto-BlackItalic.ttf new file mode 100644 index 0000000..b2c6aca Binary files /dev/null and b/static/roboto/Roboto-BlackItalic.ttf differ diff --git a/static/roboto/Roboto-Bold.ttf b/static/roboto/Roboto-Bold.ttf new file mode 100644 index 0000000..43da14d Binary files /dev/null and b/static/roboto/Roboto-Bold.ttf differ diff --git a/static/roboto/Roboto-BoldItalic.ttf b/static/roboto/Roboto-BoldItalic.ttf new file mode 100644 index 0000000..bcfdab4 Binary files /dev/null and b/static/roboto/Roboto-BoldItalic.ttf differ diff --git a/static/roboto/Roboto-Italic.ttf b/static/roboto/Roboto-Italic.ttf new file mode 100644 index 0000000..1b5eaa3 Binary files /dev/null and b/static/roboto/Roboto-Italic.ttf differ diff --git a/static/roboto/Roboto-Light.ttf b/static/roboto/Roboto-Light.ttf new file mode 100644 index 0000000..e7307e7 Binary files /dev/null and b/static/roboto/Roboto-Light.ttf differ diff --git a/static/roboto/Roboto-LightItalic.ttf b/static/roboto/Roboto-LightItalic.ttf new file mode 100644 index 0000000..2d277af Binary files /dev/null and b/static/roboto/Roboto-LightItalic.ttf differ diff --git a/static/roboto/Roboto-Medium.ttf b/static/roboto/Roboto-Medium.ttf new file mode 100644 index 0000000..ac0f908 Binary files /dev/null and b/static/roboto/Roboto-Medium.ttf differ diff --git a/static/roboto/Roboto-MediumItalic.ttf b/static/roboto/Roboto-MediumItalic.ttf new file mode 100644 index 0000000..fc36a47 Binary files /dev/null and b/static/roboto/Roboto-MediumItalic.ttf differ diff --git a/static/roboto/Roboto-Regular.ttf b/static/roboto/Roboto-Regular.ttf new file mode 100644 index 0000000..ddf4bfa Binary files /dev/null and b/static/roboto/Roboto-Regular.ttf differ diff --git a/static/roboto/Roboto-Thin.ttf b/static/roboto/Roboto-Thin.ttf new file mode 100644 index 0000000..2e0dee6 Binary files /dev/null and b/static/roboto/Roboto-Thin.ttf differ diff --git a/static/roboto/Roboto-ThinItalic.ttf b/static/roboto/Roboto-ThinItalic.ttf new file mode 100644 index 0000000..084f9c0 Binary files /dev/null and b/static/roboto/Roboto-ThinItalic.ttf differ diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..e4d1590 --- /dev/null +++ b/static/script.js @@ -0,0 +1,104 @@ +function notePost() { + const msg = document.querySelector("#message"); + + fetch("/api/litey/post", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: msg.value, + }), + }) + .then((res) => { + if (res.ok) { + msg.value = ""; + alert("投稿に成功しました!"); + } else { + alert("投稿に失敗しました。"); + } + }); +} + +function noteDelete(submit) { + const preview = decodeURIComponent(submit.dataset.preview); + const id = decodeURIComponent(submit.dataset.id); + + if (!confirm(`本当にこのメッセージを削除しますか?\n${preview}`)) { + return; + } + + fetch("/api/litey/delete", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id, + }), + }) + .then((res) => { + if (res.ok) { + alert("削除に成功しました!"); + } else { + alert("削除に失敗しました。"); + } + }); +} + +function createAttachment(link, proxyLink, mediaType) { + if (mediaType.startsWith("image/")) { + const img = document.createElement("img"); + img.src = proxyLink; + img.loading = "lazy"; + img.decoding = "async"; + return img; + } else if (mediaType.startsWith("audio/")) { + const audio = document.createElement("audio"); + audio.src = proxyLink; + audio.controls = true; + audio.loop = true; + audio.preload = "none"; + return audio; + } else if (mediaType.startsWith("video/")) { + const video = document.createElement("video"); + video.src = proxyLink; + video.controls = true; + video.loop = true; + video.preload = "none"; + return video; + } else { + const a = document.createElement("a"); + a.href = link; + a.textContent = link; + return a; + } +} + +addEventListener("load", () => { + document.querySelectorAll("#attachments").forEach((attachments) => { + const linksets = decodeURIComponent(attachments.dataset.linksets); + const links = linksets.split("\n").filter(Boolean); + + links.forEach((link) => { + const proxyLink = `/api/litey/image-proxy?url=${encodeURIComponent(link)}`; + + fetch(proxyLink) + .then((res) => { + if (res.ok) { + const mediaType = res.headers.get("Content-Type"); + const attachment = createAttachment(link, proxyLink, mediaType); + attachments.insertAdjacentElement("beforeend", attachment); + } + }); + }); + }); +}); + +addEventListener("load", () => { + document.querySelectorAll("#date").forEach((date) => { + const dateStr = decodeURIComponent(date.dataset.date); + + date.textContent = new Date(dateStr).toLocaleString(); + }); +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..0da9d4a --- /dev/null +++ b/static/style.css @@ -0,0 +1,54 @@ +@font-face { + font-family: "Roboto"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(roboto/Roboto-Regular.ttf) format("truetype"); +} + +body { + margin: 0; + padding: 2rem; +} + +body * { + margin: 0; + font-family: "Roboto", sans-serif; + word-break: break-all; + white-space: pre-line; +} + +body > :not(:last-child) { + margin-bottom: 2rem; +} + +img, audio, video { + max-width: 100%; +} + +code { + font-family: monospace; + background-color: #eee; + border-radius: 4px; + padding: 4px; +} + +#note { + all: revert; +} + +#note:not(:last-child) { + margin-bottom: 2rem; +} + +#note > :nth-child(1) { + font-size: 0.5rem; +} + +#note > :nth-child(3) > * { + display: block; +} + +#note > :nth-child(n+4):nth-child(-n+4) { + margin-right: 4px; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..b553736 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,25 @@ + + + + + + LiteY + + + + +

掲示板へようこそ!

+

良識のあるメッセージを心がけてください

+ + + {% for note in notes %} +
+
{{ note.ip | ip_to_uid }}
+
{{ note.content | replace_ng_words(ng_words) }}
+
+ + +
+ {% endfor %} + + diff --git a/user_agent.txt b/user_agent.txt new file mode 100644 index 0000000..fdcef5e --- /dev/null +++ b/user_agent.txt @@ -0,0 +1 @@ +Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36