diff --git a/.gitignore b/.gitignore index 1c9de0c..48cfc58 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ managed_components .vscode .clangd .secret_key +server/clipboard/ diff --git a/server/README.md b/server/README.md index fd0c5f0..53abe8f 100644 --- a/server/README.md +++ b/server/README.md @@ -3,9 +3,9 @@ Two Flask services with separate responsibilities and separate databases: - **main app** (`app.py`) — all user-facing views: login, home, notes, - notifications, API docs and the `/sensors` dashboard. It owns the **main app** - database (`main_app.db`: users, notes and notifications) and proxies data - requests to the sensors API. + notifications, clipboard, API docs and the `/sensors` dashboard. It owns the + **main app** database (`main_app.db`: users, notes, notifications and clipboard + file metadata) and proxies data requests to the sensors API. - **sensors app** (`sensors_app.py`) — API only. It owns the **sensors** database (`sensors.db`, tables `sensor` and `sensor_readout`) and has no knowledge of users. Its API is open to every user on the local machine. @@ -35,7 +35,7 @@ server/ ├── auth.py # Flask-Login setup ├── config.py # Config (databases, secret key, sensors URL) ├── models.py # Sensor, SensorReadout (sensors DB) -├── user_models.py # User, Note, NoteItem, Notification +├── user_models.py # User, Note, NoteItem, Notification, ClipboardFile ├── seed.py # Sample sensor data ├── migrate_legacy_db.py # Import old combined data.db into split DBs ├── run.sh # Development launcher (main/sensors + port) @@ -47,6 +47,7 @@ server/ ├── alembic_users/ # Users DB migration scripts ├── sensors.db # Sensors database (sensors app) ├── main_app.db # Users database (main app) +├── clipboard/ # Uploaded clipboard files (runtime, not in git) ├── data.db # Legacy combined DB (not used by default) ├── requirements.txt └── templates/ @@ -54,6 +55,7 @@ server/ ├── login.html ├── notes.html ├── notifications.html + ├── clipboard.html ├── docs.html └── sensors.html ``` @@ -164,6 +166,27 @@ sudo -u mateusz env USERS_DB_PATH=/data/main_app.db \ The service does not need to be restarted; the new account is used on the next login. +## Clipboard + +Logged-in users can upload, download and delete files at `/clipboard`. Files +are stored on the server in the clipboard directory (random file name) and only +metadata (original name, content type, size, owner) is kept in `main_app.db`. + +Limits: + +- one file: **100 MB** (`CLIPBOARD_MAX_FILE_BYTES`) +- total storage per user: **1 GB** (`CLIPBOARD_MAX_TOTAL_BYTES`) + +The per-file limit is enforced while uploading; the per-user total quota is +checked before a file is stored. + +Apply the users database migration once (the production launcher does this +automatically): + +```bash +alembic -c alembic_users.ini upgrade head +``` + ## Notes Logged-in users manage their own private short notes at `/notes`. Each note has @@ -186,6 +209,8 @@ Each user has their own short text notifications in `main_app.db`. A notification is either **new** or **read**. The main navigation shows the number of new notifications next to the `Notifications` link. Opening a notification from the `/notifications` page marks it as read and moves it below the new ones. +A notification can be deleted from the `/notifications` page with the `[x]` +button (endpoint: `POST /notifications//delete`). Create notifications with the CLI (run it as the same user as the service): @@ -257,6 +282,9 @@ BIND=0.0.0.0:8001 ./run-prod.sh sensors 8001 | `USERS_DATABASE_URL` | `sqlite:////main_app.db` | Users database URL | | `USERS_DB_PATH` | `/main_app.db` | SQLite path when URL is unset | | `SECRET_KEY` | `.secret_key` file | Main app session signing key | +| `CLIPBOARD_DIR` | `/clipboard` | Directory where uploaded files are stored | +| `CLIPBOARD_MAX_FILE_BYTES` | `104857600` (100 MB) | Maximum size of a single uploaded file | +| `CLIPBOARD_MAX_TOTAL_BYTES` | `1073741824` (1 GB) | Maximum total size of files per user | | `HOST` | main `0.0.0.0`, sensors `127.0.0.1` | Listen address | | `BIND` | `:` | Gunicorn bind override | @@ -321,7 +349,7 @@ deployment path or user is different. | Service | Port | Routes | |---|---|---| -| main | 8000 | `/`, `/login`, `/home`, `/notes`, `/notifications`, `/sensors`, `/api/docs`, `/api/*` (proxy) | +| main | 8000 | `/`, `/login`, `/home`, `/clipboard`, `/notes`, `/notifications`, `/sensors`, `/api/docs`, `/api/*` (proxy) | | sensors | 8001 | `/api/*` (sensors and readouts) | ### Tests diff --git a/server/alembic_users/versions/f5c7d9a1b3e8_create_clipboard_files.py b/server/alembic_users/versions/f5c7d9a1b3e8_create_clipboard_files.py new file mode 100644 index 0000000..2248f83 --- /dev/null +++ b/server/alembic_users/versions/f5c7d9a1b3e8_create_clipboard_files.py @@ -0,0 +1,45 @@ +"""create clipboard files + +Revision ID: f5c7d9a1b3e8 +Revises: e2b6d8f0a4c1 +Create Date: 2026-11-01 20:45:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "f5c7d9a1b3e8" +down_revision: Union[str, Sequence[str], None] = "e2b6d8f0a4c1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "clipboard_file", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("original_name", sa.String(length=255), nullable=False), + sa.Column("stored_name", sa.String(length=64), nullable=False), + sa.Column("size", sa.BigInteger(), nullable=False), + sa.Column("content_type", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("stored_name"), + ) + op.create_index( + op.f("ix_clipboard_file_user_id"), + "clipboard_file", + ["user_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_clipboard_file_user_id"), table_name="clipboard_file") + op.drop_table("clipboard_file") diff --git a/server/app.py b/server/app.py index 26ff492..8726ef5 100644 --- a/server/app.py +++ b/server/app.py @@ -1,20 +1,52 @@ +import fcntl import os +import uuid +from contextlib import contextmanager +from pathlib import Path import click from flask import ( Flask, + flash, jsonify, redirect, render_template, request, + send_file, url_for, ) from flask_login import current_user, login_required, login_user, logout_user +from sqlalchemy import func from auth import init_login_manager -from config import load_secret_key, sensors_api_url, users_database_uri +from config import ( + clipboard_dir, + clipboard_max_file_bytes, + clipboard_max_total_bytes, + load_secret_key, + sensors_api_url, + users_database_uri, +) from sensors_client import SensorsAPIError, SensorsClient -from user_models import Note, NoteItem, Notification, User, user_db +from user_models import ( + ClipboardFile, + Note, + NoteItem, + Notification, + User, + user_db, +) + + +def format_bytes(value: int) -> str: + size = float(value) + for unit in ("B", "KB", "MB", "GB", "TB"): + if size < 1024 or unit == "TB": + if unit == "B": + return f"{int(size)} B" + return f"{size:.1f} {unit}" + size /= 1024 + return f"{int(size)} B" def create_app(): @@ -24,6 +56,14 @@ def create_app(): app.config["SQLALCHEMY_DATABASE_URI"] = users_database_uri() app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SENSORS_API_URL"] = sensors_api_url() + app.config["CLIPBOARD_DIR"] = str(clipboard_dir()) + app.config["CLIPBOARD_MAX_FILE_BYTES"] = clipboard_max_file_bytes() + app.config["CLIPBOARD_MAX_TOTAL_BYTES"] = clipboard_max_total_bytes() + app.config["MAX_CONTENT_LENGTH"] = ( + app.config["CLIPBOARD_MAX_FILE_BYTES"] + 1024 * 1024 + ) + Path(app.config["CLIPBOARD_DIR"]).mkdir(parents=True, exist_ok=True) + app.add_template_filter(format_bytes, "filesize") user_db.init_app(app) sensors = SensorsClient(app.config["SENSORS_API_URL"]) @@ -111,6 +151,181 @@ def create_app(): def api_docs(): return render_template("docs.html", user=current_user) + # --- Clipboard --- + + def _clipboard_path(name): + return Path(app.config["CLIPBOARD_DIR"]) / name + + @contextmanager + def _clipboard_lock(): + lock_path = Path(app.config["CLIPBOARD_DIR"]) / ".clipboard.lock" + with open(lock_path, "a+") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) + + def _clipboard_file_or_404(file_id): + return ClipboardFile.query.filter_by( + id=file_id, user_id=current_user.id + ).first_or_404() + + @app.route("/clipboard") + @login_required + def clipboard_page(): + files = ( + ClipboardFile.query.filter_by(user_id=current_user.id) + .order_by(ClipboardFile.created_at.desc(), ClipboardFile.id.desc()) + .all() + ) + total_used = ( + user_db.session.query( + func.coalesce(func.sum(ClipboardFile.size), 0) + ) + .filter(ClipboardFile.user_id == current_user.id) + .scalar() + or 0 + ) + max_total = app.config["CLIPBOARD_MAX_TOTAL_BYTES"] + return render_template( + "clipboard.html", + user=current_user, + files=files, + total_used=total_used, + max_total=max_total, + remaining=max(0, max_total - total_used), + max_file=app.config["CLIPBOARD_MAX_FILE_BYTES"], + ) + + @app.errorhandler(413) + def request_too_large(_error): + flash( + "Upload too large. Maximum file size is " + f"{format_bytes(app.config['CLIPBOARD_MAX_FILE_BYTES'])}.", + "error", + ) + return redirect(url_for("clipboard_page")) + + @app.route("/clipboard/upload", methods=["POST"]) + @login_required + def clipboard_upload(): + upload = request.files.get("file") + if not upload or not upload.filename: + flash("Choose a file to upload.", "error") + return redirect(url_for("clipboard_page")) + + original_name = ( + os.path.basename(upload.filename.replace("\\", "/")) + .strip() + .replace("\x00", "") + ) + if not original_name: + flash("The file name is invalid.", "error") + return redirect(url_for("clipboard_page")) + original_name = original_name[:255] + + directory = Path(app.config["CLIPBOARD_DIR"]) + stored_name = uuid.uuid4().hex + temp_path = directory / f".{stored_name}.tmp" + final_path = directory / stored_name + + try: + upload.save(str(temp_path)) + size = temp_path.stat().st_size + except OSError: + temp_path.unlink(missing_ok=True) + flash("Could not save the uploaded file.", "error") + return redirect(url_for("clipboard_page")) + + if size <= 0: + temp_path.unlink(missing_ok=True) + flash("The file is empty.", "error") + return redirect(url_for("clipboard_page")) + + max_file = app.config["CLIPBOARD_MAX_FILE_BYTES"] + if size > max_file: + temp_path.unlink(missing_ok=True) + flash( + f"File is too large. Maximum size is {format_bytes(max_file)}.", + "error", + ) + return redirect(url_for("clipboard_page")) + + with _clipboard_lock(): + total_used = ( + user_db.session.query( + func.coalesce(func.sum(ClipboardFile.size), 0) + ) + .filter(ClipboardFile.user_id == current_user.id) + .scalar() + or 0 + ) + max_total = app.config["CLIPBOARD_MAX_TOTAL_BYTES"] + if total_used + size > max_total: + temp_path.unlink(missing_ok=True) + flash( + f"Clipboard storage is full " + f"({format_bytes(max_total)} limit).", + "error", + ) + return redirect(url_for("clipboard_page")) + + try: + os.replace(temp_path, final_path) + user_db.session.add( + ClipboardFile( + user_id=current_user.id, + original_name=original_name, + stored_name=stored_name, + size=size, + content_type=( + upload.mimetype or "application/octet-stream" + )[:255], + ) + ) + user_db.session.commit() + except Exception: + user_db.session.rollback() + final_path.unlink(missing_ok=True) + temp_path.unlink(missing_ok=True) + flash("Could not save the uploaded file.", "error") + return redirect(url_for("clipboard_page")) + + flash(f"Uploaded {original_name}.", "success") + return redirect(url_for("clipboard_page")) + + @app.route("/clipboard//download") + @login_required + def clipboard_download(file_id): + entry = _clipboard_file_or_404(file_id) + path = _clipboard_path(entry.stored_name) + if not path.is_file(): + flash("The file is missing from the server.", "error") + return redirect(url_for("clipboard_page")) + return send_file( + path, + as_attachment=True, + download_name=entry.original_name, + mimetype=entry.content_type or "application/octet-stream", + ) + + @app.route("/clipboard//delete", methods=["POST"]) + @login_required + def clipboard_delete(file_id): + entry = _clipboard_file_or_404(file_id) + name = entry.original_name + path = _clipboard_path(entry.stored_name) + try: + path.unlink(missing_ok=True) + except OSError: + flash("Could not delete the file from disk.", "error") + return redirect(url_for("clipboard_page")) + user_db.session.delete(entry) + user_db.session.commit() + flash(f"Deleted {name}.", "success") + return redirect(url_for("clipboard_page")) + # --- Notifications (stored in main_app.db) --- @app.route("/notifications") @@ -145,6 +360,17 @@ def create_app(): ) ) + @app.route("/notifications//delete", methods=["POST"]) + @login_required + def notification_delete(notification_id): + notification = Notification.query.filter_by( + id=notification_id, user_id=current_user.id + ).first_or_404() + user_db.session.delete(notification) + user_db.session.commit() + flash("Deleted notification.", "success") + return redirect(url_for("notifications_page")) + # --- Notes (stored in main_app.db) --- def _note_or_404(note_id): diff --git a/server/config.py b/server/config.py index 5758635..d2d03d0 100644 --- a/server/config.py +++ b/server/config.py @@ -5,6 +5,8 @@ BASE_DIR = Path(__file__).resolve().parent SECRET_FILE = BASE_DIR / ".secret_key" DEFAULT_SENSORS_PORT = 8001 +DEFAULT_CLIPBOARD_MAX_FILE_BYTES = 100 * 1024 * 1024 +DEFAULT_CLIPBOARD_MAX_TOTAL_BYTES = 1024 * 1024 * 1024 def load_secret_key() -> str: @@ -43,3 +45,27 @@ def sensors_api_url() -> str: if configured: return configured.rstrip("/") return f"http://localhost:{DEFAULT_SENSORS_PORT}" + + +def clipboard_dir() -> Path: + """Directory where uploaded clipboard files are stored.""" + configured = os.environ.get("CLIPBOARD_DIR") + if configured: + return Path(configured).expanduser().resolve() + return BASE_DIR / "clipboard" + + +def clipboard_max_file_bytes() -> int: + return int( + os.environ.get( + "CLIPBOARD_MAX_FILE_BYTES", DEFAULT_CLIPBOARD_MAX_FILE_BYTES + ) + ) + + +def clipboard_max_total_bytes() -> int: + return int( + os.environ.get( + "CLIPBOARD_MAX_TOTAL_BYTES", DEFAULT_CLIPBOARD_MAX_TOTAL_BYTES + ) + ) diff --git a/server/static/retro.css b/server/static/retro.css index b2d0610..2d157a4 100644 --- a/server/static/retro.css +++ b/server/static/retro.css @@ -444,3 +444,50 @@ textarea { background: var(--mc-yellow); color: var(--mc-black); } +.notification-delete-form { margin: 0; } +.notification-delete-form button { + background: var(--mc-red); + color: var(--mc-bright); + box-shadow: none; + padding: 0.1em 0.4em; + font-size: 0.85em; + letter-spacing: 0; +} +.notification-delete-form button:hover { background: var(--mc-red-bright); } + +/* --- Flash messages --- */ +.flash-messages { list-style: none; padding: 0; margin: 1rem 0; } +.flash { + border: 2px solid var(--mc-bright); + box-shadow: 4px 4px 0 var(--mc-black); + padding: 0.4em 0.8em; + margin-bottom: 0.6rem; +} +.flash-success { background: var(--mc-deep); border-left: 6px solid var(--mc-cyan); } +.flash-error { background: var(--mc-red); color: var(--mc-bright); border-left: 6px solid var(--mc-red-bright); } + +/* --- Clipboard --- */ +.clipboard-list { list-style: none; padding: 0; margin: 1rem 0; } +.clipboard-item { + display: flex; + align-items: center; + gap: 0.6em; + border: 2px solid var(--mc-dim); + background: var(--mc-deep); + margin: 0.5rem 0; + padding: 0.4em 0.6em; +} +.clipboard-main { min-width: 0; } +.clipboard-item a { color: var(--mc-cyan); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.clipboard-item a:hover { text-decoration: underline; } +.clipboard-meta { color: var(--mc-dim); font-size: 0.8em; margin-left: 0.6em; } +.clipboard-delete-form { margin: 0; flex: 0 0 auto; flex-direction: row; align-items: center; } +.clipboard-delete-form button { + background: var(--mc-red); + color: var(--mc-bright); + box-shadow: none; + padding: 0.1em 0.4em; + font-size: 0.85em; + letter-spacing: 0; +} +.clipboard-delete-form button:hover { background: var(--mc-red-bright); } diff --git a/server/templates/clipboard.html b/server/templates/clipboard.html new file mode 100644 index 0000000..07317b4 --- /dev/null +++ b/server/templates/clipboard.html @@ -0,0 +1,72 @@ + + + + + + Clipboard — Yggdrasil + + + + + + +

Clipboard

+ +
+

Upload file

+

+ Max single file: {{ max_file | filesize }}. Total quota: + {{ max_total | filesize }} — used: {{ total_used | filesize }}, + free: {{ remaining | filesize }}. +

+
+ + + +
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
    + {% for category, message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + + {% if not files %} +

No files yet. Upload your first file above.

+ {% else %} +
    + {% for file in files %} +
  • +
    + {{ file.original_name }} + {{ file.size | filesize }} · {{ file.created_at.strftime("%Y-%m-%d %H:%M") }} +
    +
    + +
    +
  • + {% endfor %} +
+ {% endif %} + +
+ {{ user.username }}@yggdrasil:~/clipboard$ +
+ + diff --git a/server/templates/docs.html b/server/templates/docs.html index 6c6ef0b..2437ab7 100644 --- a/server/templates/docs.html +++ b/server/templates/docs.html @@ -13,6 +13,7 @@ Home Sensors Notes + Clipboard Notifications{% if unread_notifications %} {{ unread_notifications }}{% endif %} API Log out diff --git a/server/templates/home.html b/server/templates/home.html index a04206c..b867d23 100644 --- a/server/templates/home.html +++ b/server/templates/home.html @@ -13,6 +13,7 @@ Home Sensors Notes + Clipboard Notifications{% if unread_notifications %} {{ unread_notifications }}{% endif %} API Log out @@ -27,9 +28,10 @@
  • 2Notes — title, text and checklists
  • 3Notifications{% if unread_notifications %} ({{ unread_notifications }} new){% endif %}
  • 4API — documentation
  • +
  • 5Clipboard — upload and share files
  • 0Log out
  • -

    Tip: press 1, 2, 3, 4 or 0 to navigate.

    +

    Tip: press 1, 2, 3, 4, 5 or 0 to navigate.