Webアプリケーションを作ってみたい
こんにちは
とある学校に通っている学生です。私の通っている学校は情報系でPythonやJavaなどのコーディングなどをしています。
どうやってやる?
いままで簡単なコードしか書いてこなかった私ですが、Webアプリケーションというとすごく複雑そうで大変なイメージがあります。
そうだ!AIに教えてもらおう
今回私がつくりたいのは LAN内で写真を共有して、簡単にプレビューできる仕組み を作りたいと思いました。最初は簡単なことから始めていこうと考えました。早速ChatGPTをアップグレードして、コーディングしてもらいました。
こんなサイトになりました
ここでアップロードして
ホーム画面
うん。まあ質素だけど出来てる凄い
目的: 同一LAN内の端末から写真をアップロード・閲覧・即時反映できる最小構成のWebアプリ。非同期対応(Quart)+ WebSocket でライブ更新。サムネイル自動生成(Pillow)。SQLite でメタデータ管理。
1) プロジェクト構成
lan-photo-share/
app.py
models.py
requirements.txt
media/
originals/ # 元画像
thumbs/ # サムネイル
templates/
base.html
index.html
upload.html
static/
app.js
app.css
2) requirements.txt
quart==0.19.6
hypercorn==0.16.0
SQLAlchemy[asyncio]==2.0.31
aiofiles==24.1.0
aiosqlite==0.20.0
Pillow==10.4.0
3) models.py(DBスキーマ)
from datetime import datetime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import Integer, String, DateTime
class Base(DeclarativeBase):
pass
class Photo(Base):
__tablename__ = "photos"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
filename: Mapped[str] = mapped_column(String(255), unique=True, index=True)
title: Mapped[str] = mapped_column(String(255), default="")
mime: Mapped[str] = mapped_column(String(100), default="image/jpeg")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# 初期化:
# python -c "from models import Base; from sqlalchemy import create_engine;
# engine=create_engine('sqlite:///app.db'); Base.metadata.create_all(engine)"
4) app.py(アプリ本体)
import os
import asyncio
from datetime import datetime
from pathlib import Path
from typing import Optional
from quart import Quart, render_template, request, jsonify, send_file, websocket, url_for, redirect
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
import aiofiles
from PIL import Image
from models import Base, Photo
APP_DIR = Path(__file__).resolve().parent
MEDIA_DIR = APP_DIR / "media"
ORIG_DIR = MEDIA_DIR / "originals"
THUMB_DIR = MEDIA_DIR / "thumbs"
DB_URL = "sqlite+aiosqlite:///" + str(APP_DIR / "app.db")
for d in (MEDIA_DIR, ORIG_DIR, THUMB_DIR):
d.mkdir(parents=True, exist_ok=True)
app = Quart(__name__, static_folder="static", static_url_path="/static")
engine = create_async_engine(DB_URL, echo=False, future=True)
Session = async_sessionmaker(engine, expire_on_commit=False)
# --- WebSocket: 接続中クライアント管理 ---
clients = set()
@app.websocket("/ws")
async def ws():
clients.add(websocket._get_current_object())
try:
while True:
# クライアントからのメッセージは特に使わない(ping用途)
await websocket.receive()
except Exception:
pass
finally:
clients.discard(websocket._get_current_object())
async def broadcast(event: dict):
if not clients:
return
dead = []
for ws in list(clients):
try:
await ws.send_json(event)
except Exception:
dead.append(ws)
for ws in dead:
clients.discard(ws)
# --- ユーティリティ ---
ALLOWED = {"image/jpeg", "image/png", "image/webp"}
def secure_name(name: str) -> str:
# 超シンプル版
name = os.path.basename(name).replace(" ", "_")
return "".join(c for c in name if c.isalnum() or c in ("_", "-", "."))
async def save_thumbnail(src_path: Path, dst_path: Path, size=(480, 480)):
loop = asyncio.get_running_loop()
def _make_thumb():
with Image.open(src_path) as im:
im.thumbnail(size)
im.save(dst_path)
await loop.run_in_executor(None, _make_thumb)
# --- ルーティング ---
@app.before_serving
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@app.get("/")
async def index():
async with Session() as s:
res = await s.execute(select(Photo).order_by(Photo.id.desc()))
photos = res.scalars().all()
return await render_template("index.html", photos=photos)
@app.get("/upload")
async def upload_form():
return await render_template("upload.html")
@app.post("/upload")
async def upload_post():
form = await request.form
title = form.get("title", "")
file = (await request.files).get("file")
if not file:
return jsonify({"error": "no file"}), 400
if file.mimetype not in ALLOWED:
return jsonify({"error": "unsupported type"}), 415
safe = secure_name(file.filename) or f"photo_{int(datetime.utcnow().timestamp())}.jpg"
orig_path = ORIG_DIR / safe
thumb_path = THUMB_DIR / safe
# 保存
async with aiofiles.open(orig_path, "wb") as f:
await f.write(file.read())
# サムネ生成
await save_thumbnail(orig_path, thumb_path)
# DB
async with Session() as s:
p = Photo(filename=safe, title=title, mime=file.mimetype)
s.add(p)
await s.commit()
await s.refresh(p)
# ブロードキャスト
await broadcast({
"type": "new_photo",
"id": p.id,
"thumb_url": url_for("thumb", filename=p.filename),
"title": p.title,
})
# フォームからは一覧へ
if request.headers.get("HX-Request"):
return jsonify({"ok": True, "id": p.id})
return redirect(url_for("index"))
@app.get("/media/thumbs/<path:filename>")
async def thumb(filename):
path = THUMB_DIR / filename
if not path.exists():
return {"error": "not found"}, 404
return await send_file(path)
@app.get("/media/originals/<path:filename>")
async def original(filename):
path = ORIG_DIR / filename
if not path.exists():
return {"error": "not found"}, 404
return await send_file(path)
if __name__ == "__main__":
app.run(debug=True, port=8000)
5) templates/base.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LAN Photo Share</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<header>
<h1><a href="/">📷 LAN Photo Share</a></h1>
<nav><a href="/upload">アップロード</a></nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<script src="/static/app.js"></script>
</body>
</html>
6) templates/index.html
{% extends "base.html" %}
{% block content %}
<section class="grid">
{% for p in photos %}
<a class="card" href="/media/originals/{{ p.filename }}" target="_blank">
<img loading="lazy" src="/media/thumbs/{{ p.filename }}" alt="{{ p.title or p.filename }}">
<div class="meta">
<strong>{{ p.title or "(無題)" }}</strong>
<small>#{{ p.id }} / {{ p.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
</div>
</a>
{% else %}
<p>まだ写真がありません。右上の「アップロード」から追加してね。</p>
{% endfor %}
</section>
{% endblock %}
7) templates/upload.html
{% extends "base.html" %}
{% block content %}
<h2>写真をアップロード</h2>
<form action="/upload" method="post" enctype="multipart/form-data">
<label>タイトル(任意)<br>
<input type="text" name="title" placeholder="例:新潟の海"></label>
<label>画像ファイル<br>
<input type="file" name="file" accept="image/*" required></label>
<button type="submit">アップロード</button>
</form>
<p class="hint">対応: JPEG / PNG / WebP</p>
{% endblock %}
8) static/app.css(極シンプル)
body{font-family:system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin:0;}
header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #ddd;}
header a{text-decoration:none;color:#000}
main{padding:16px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
.card{display:block;border:1px solid #eee;border-radius:12px;overflow:hidden;background:#fff}
.card img{width:100%;display:block;}
.card .meta{padding:8px}
label{display:block;margin:8px 0}
input[type="text"], input[type="file"]{width:100%;max-width:480px}
button{padding:8px 14px;border-radius:10px;border:1px solid #ccc;background:#f7f7f7;cursor:pointer}
.hint{color:#666}
9) static/app.js(WebSocketで新着を即反映)
(function(){
try{
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if(data.type === 'new_photo'){
const grid = document.querySelector('.grid');
if(!grid) return;
const a = document.createElement('a');
a.className = 'card';
a.target = '_blank';
a.href = '/media/originals/' + data.title ? data.title : data.thumb_url.replace('/media/thumbs/','/media/originals/');
a.innerHTML = `
<img loading="lazy" src="${data.thumb_url}">
<div class="meta">
<strong>${data.title || '(無題)'}</strong>
<small>new</small>
</div>`;
grid.prepend(a);
}
};
}catch(e){ /* no-op */ }
})();
注意: 上記
app.js
のhref
組み立ては簡略実装です。必要ならoriginal_url
をイベントに含めるようbroadcast()
を調整してください。
10) 起動方法
python -m venv .venv && source .venv/bin/activate # Windowsは .venv\Scripts\activate
pip install -r requirements.txt
python app.py # 開発: http://127.0.0.1:8000
# またはASGIサーバ
hypercorn app:app --bind 0.0.0.0:8000 --reload
LAN内アクセス
- ルータ側でPCのローカルIP(例:
192.168.0.23
)を確認 - Hypercorn等で
--bind 0.0.0.0:8000
で起動 - 他端末ブラウザで
http://192.168.0.23:8000/
11) セキュリティと運用メモ
- LAN限定とはいえ、不要なポート公開は避ける(NAT越え禁止)
- 最大ファイルサイズ制限:
app.config['MAX_CONTENT_LENGTH'] = ...
を設定 - 簡易パスワード保護を付けるなら、ルータのアクセス制限やNginxのBasic認証でOK
- EXIFの位置情報を自動で除去したい場合は Pillow で
Image.save(exif=b"")
などを検討 - 端末名自動発見(mDNS/Bonjour)を使いたい場合は
avahi
/zeroconf
を導入(任意)
12) 拡張アイデア(必要に応じて実装可)
- アルバム機能、タグ検索、撮影日での並び替え
- 複数ファイル同時アップロード(ドラッグ&ドロップ)
- サムネイルサイズのバリエーション生成(480/960)
- 簡易ログイン(共有PIN)
- 動画(mp4/webm)対応とサムネイル抽出(ffmpeg)
- 非同期タスクキュー(RQ / Dramatiq など)
最小構成でこれか
正直素人の私には、この膨大なコードを理解するのは、現段階では出来兼ねます。パート2ができる頃にはこのコードを理解できるレベルにまで上がっているかと思います。多分