最初のコミット

This commit is contained in:
yuuki 2024-06-14 08:03:49 +00:00
commit 0ee792a8e3
20 changed files with 397 additions and 0 deletions

1
.dockerignore Normal file
View file

@ -0,0 +1 @@
/.git

6
Dockerfile Normal file
View file

@ -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"]

202
app.py Normal file
View file

@ -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

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
requests==2.32.3
fastapi==0.111.0
pymongo==4.7.3
Jinja2==3.1.4

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

104
static/script.js Normal file
View file

@ -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();
});
});

54
static/style.css Normal file
View file

@ -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;
}

25
templates/index.html Normal file
View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LiteY</title>
<link rel="stylesheet" href="style.css">
<script src="script.js"></script>
</head>
<body>
<h1>掲示板へようこそ!</h1>
<p>良識のあるメッセージを心がけてください</p>
<textarea placeholder="ここにメッセージを入力" rows="4" cols="50" id="message"></textarea>
<input type="submit" value="今すぐ送信!" onclick="notePost();">
{% for note in notes %}
<div id="note">
<div>{{ note.ip | ip_to_uid }}</div>
<div>{{ note.content | replace_ng_words(ng_words) }}</div>
<div id="attachments" data-linksets="{{ note.content | content_to_linksets | urlencode() }}"></div>
<code id="date" data-date="{{ note.date | urlencode() }}"></code>
<input type="submit" value="削除" onclick="noteDelete(this);" data-id="{{ note.id | urlencode() }}" data-preview="{{ note.content | urlencode() }}">
</div>
{% endfor %}
</body>
</html>

1
user_agent.txt Normal file
View file

@ -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