4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

素人がwebアプリケーションを挑戦してみる #1

Last updated at Posted at 2025-08-27

Webアプリケーションを作ってみたい

こんにちは
とある学校に通っている学生です。私の通っている学校は情報系でPythonやJavaなどのコーディングなどをしています。

どうやってやる?

いままで簡単なコードしか書いてこなかった私ですが、Webアプリケーションというとすごく複雑そうで大変なイメージがあります。

そうだ!AIに教えてもらおう

今回私がつくりたいのは LAN内で写真を共有して、簡単にプレビューできる仕組み を作りたいと思いました。最初は簡単なことから始めていこうと考えました。早速ChatGPTをアップグレードして、コーディングしてもらいました。

こんなサイトになりました

ここでアップロードして

スクリーンショット 2025-08-27 145950.png

ホーム画面

スクリーンショット 2025-08-27 150008.png

うん。まあ質素だけど出来てる凄い

目的: 同一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.jshref 組み立ては簡略実装です。必要なら 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ができる頃にはこのコードを理解できるレベルにまで上がっているかと思います。多分

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?