最初のコミット
This commit is contained in:
commit
0ee792a8e3
20 changed files with 397 additions and 0 deletions
1
.dockerignore
Normal file
1
.dockerignore
Normal file
|
@ -0,0 +1 @@
|
|||
/.git
|
6
Dockerfile
Normal file
6
Dockerfile
Normal 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
202
app.py
Normal 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
4
requirements.txt
Normal file
|
@ -0,0 +1,4 @@
|
|||
requests==2.32.3
|
||||
fastapi==0.111.0
|
||||
pymongo==4.7.3
|
||||
Jinja2==3.1.4
|
BIN
static/roboto/Roboto-Black.ttf
Normal file
BIN
static/roboto/Roboto-Black.ttf
Normal file
Binary file not shown.
BIN
static/roboto/Roboto-BlackItalic.ttf
Normal file
BIN
static/roboto/Roboto-BlackItalic.ttf
Normal file
Binary file not shown.
BIN
static/roboto/Roboto-Bold.ttf
Normal file
BIN
static/roboto/Roboto-Bold.ttf
Normal file
Binary file not shown.
BIN
static/roboto/Roboto-BoldItalic.ttf
Normal file
BIN
static/roboto/Roboto-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
static/roboto/Roboto-Italic.ttf
Normal file
BIN
static/roboto/Roboto-Italic.ttf
Normal file
Binary file not shown.
BIN
static/roboto/Roboto-Light.ttf
Normal file
BIN
static/roboto/Roboto-Light.ttf
Normal file
Binary file not shown.
BIN
static/roboto/Roboto-LightItalic.ttf
Normal file
BIN
static/roboto/Roboto-LightItalic.ttf
Normal file
Binary file not shown.
BIN
static/roboto/Roboto-Medium.ttf
Normal file
BIN
static/roboto/Roboto-Medium.ttf
Normal file
Binary file not shown.
BIN
static/roboto/Roboto-MediumItalic.ttf
Normal file
BIN
static/roboto/Roboto-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
static/roboto/Roboto-Regular.ttf
Normal file
BIN
static/roboto/Roboto-Regular.ttf
Normal file
Binary file not shown.
BIN
static/roboto/Roboto-Thin.ttf
Normal file
BIN
static/roboto/Roboto-Thin.ttf
Normal file
Binary file not shown.
BIN
static/roboto/Roboto-ThinItalic.ttf
Normal file
BIN
static/roboto/Roboto-ThinItalic.ttf
Normal file
Binary file not shown.
104
static/script.js
Normal file
104
static/script.js
Normal 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
54
static/style.css
Normal 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
25
templates/index.html
Normal 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
1
user_agent.txt
Normal 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
|
Loading…
Add table
Reference in a new issue