clipboard

This commit is contained in:
2026-11-01 20:47:27 +01:00
parent b77b348d54
commit cbf5f20c75
14 changed files with 563 additions and 9 deletions
+1
View File
@@ -7,3 +7,4 @@ managed_components
.vscode .vscode
.clangd .clangd
.secret_key .secret_key
server/clipboard/
+33 -5
View File
@@ -3,9 +3,9 @@
Two Flask services with separate responsibilities and separate databases: Two Flask services with separate responsibilities and separate databases:
- **main app** (`app.py`) — all user-facing views: login, home, notes, - **main app** (`app.py`) — all user-facing views: login, home, notes,
notifications, API docs and the `/sensors` dashboard. It owns the **main app** notifications, clipboard, API docs and the `/sensors` dashboard. It owns the
database (`main_app.db`: users, notes and notifications) and proxies data **main app** database (`main_app.db`: users, notes, notifications and clipboard
requests to the sensors API. file metadata) and proxies data requests to the sensors API.
- **sensors app** (`sensors_app.py`) — API only. It owns the **sensors** - **sensors app** (`sensors_app.py`) — API only. It owns the **sensors**
database (`sensors.db`, tables `sensor` and `sensor_readout`) and has no 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. knowledge of users. Its API is open to every user on the local machine.
@@ -35,7 +35,7 @@ server/
├── auth.py # Flask-Login setup ├── auth.py # Flask-Login setup
├── config.py # Config (databases, secret key, sensors URL) ├── config.py # Config (databases, secret key, sensors URL)
├── models.py # Sensor, SensorReadout (sensors DB) ├── 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 ├── seed.py # Sample sensor data
├── migrate_legacy_db.py # Import old combined data.db into split DBs ├── migrate_legacy_db.py # Import old combined data.db into split DBs
├── run.sh # Development launcher (main/sensors + port) ├── run.sh # Development launcher (main/sensors + port)
@@ -47,6 +47,7 @@ server/
├── alembic_users/ # Users DB migration scripts ├── alembic_users/ # Users DB migration scripts
├── sensors.db # Sensors database (sensors app) ├── sensors.db # Sensors database (sensors app)
├── main_app.db # Users database (main 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) ├── data.db # Legacy combined DB (not used by default)
├── requirements.txt ├── requirements.txt
└── templates/ └── templates/
@@ -54,6 +55,7 @@ server/
├── login.html ├── login.html
├── notes.html ├── notes.html
├── notifications.html ├── notifications.html
├── clipboard.html
├── docs.html ├── docs.html
└── sensors.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 The service does not need to be restarted; the new account is used on the next
login. 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 ## Notes
Logged-in users manage their own private short notes at `/notes`. Each note has 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 notification is either **new** or **read**. The main navigation shows the number
of new notifications next to the `Notifications` link. Opening a notification 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. 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/<id>/delete`).
Create notifications with the CLI (run it as the same user as the service): 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:///<server>/main_app.db` | Users database URL | | `USERS_DATABASE_URL` | `sqlite:///<server>/main_app.db` | Users database URL |
| `USERS_DB_PATH` | `<server>/main_app.db` | SQLite path when URL is unset | | `USERS_DB_PATH` | `<server>/main_app.db` | SQLite path when URL is unset |
| `SECRET_KEY` | `.secret_key` file | Main app session signing key | | `SECRET_KEY` | `.secret_key` file | Main app session signing key |
| `CLIPBOARD_DIR` | `<server>/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 | | `HOST` | main `0.0.0.0`, sensors `127.0.0.1` | Listen address |
| `BIND` | `<HOST>:<port>` | Gunicorn bind override | | `BIND` | `<HOST>:<port>` | Gunicorn bind override |
@@ -321,7 +349,7 @@ deployment path or user is different.
| Service | Port | Routes | | 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) | | sensors | 8001 | `/api/*` (sensors and readouts) |
### Tests ### Tests
@@ -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")
+228 -2
View File
@@ -1,20 +1,52 @@
import fcntl
import os import os
import uuid
from contextlib import contextmanager
from pathlib import Path
import click import click
from flask import ( from flask import (
Flask, Flask,
flash,
jsonify, jsonify,
redirect, redirect,
render_template, render_template,
request, request,
send_file,
url_for, url_for,
) )
from flask_login import current_user, login_required, login_user, logout_user from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy import func
from auth import init_login_manager 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 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(): def create_app():
@@ -24,6 +56,14 @@ def create_app():
app.config["SQLALCHEMY_DATABASE_URI"] = users_database_uri() app.config["SQLALCHEMY_DATABASE_URI"] = users_database_uri()
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SENSORS_API_URL"] = sensors_api_url() 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) user_db.init_app(app)
sensors = SensorsClient(app.config["SENSORS_API_URL"]) sensors = SensorsClient(app.config["SENSORS_API_URL"])
@@ -111,6 +151,181 @@ def create_app():
def api_docs(): def api_docs():
return render_template("docs.html", user=current_user) 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/<int:file_id>/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/<int:file_id>/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) --- # --- Notifications (stored in main_app.db) ---
@app.route("/notifications") @app.route("/notifications")
@@ -145,6 +360,17 @@ def create_app():
) )
) )
@app.route("/notifications/<int:notification_id>/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) --- # --- Notes (stored in main_app.db) ---
def _note_or_404(note_id): def _note_or_404(note_id):
+26
View File
@@ -5,6 +5,8 @@ BASE_DIR = Path(__file__).resolve().parent
SECRET_FILE = BASE_DIR / ".secret_key" SECRET_FILE = BASE_DIR / ".secret_key"
DEFAULT_SENSORS_PORT = 8001 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: def load_secret_key() -> str:
@@ -43,3 +45,27 @@ def sensors_api_url() -> str:
if configured: if configured:
return configured.rstrip("/") return configured.rstrip("/")
return f"http://localhost:{DEFAULT_SENSORS_PORT}" 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
)
)
+47
View File
@@ -444,3 +444,50 @@ textarea {
background: var(--mc-yellow); background: var(--mc-yellow);
color: var(--mc-black); 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); }
+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clipboard — Yggdrasil</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
</head>
<body>
<nav>
<a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</a>
<a href="{{ url_for('clipboard_page') }}">Clipboard</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
<a href="{{ url_for('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a>
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
</nav>
<h1>Clipboard</h1>
<div class="panel">
<h2>Upload file</h2>
<p class="hint">
Max single file: {{ max_file | filesize }}. Total quota:
{{ max_total | filesize }} — used: {{ total_used | filesize }},
free: {{ remaining | filesize }}.
</p>
<form method="post" action="{{ url_for('clipboard_upload') }}" enctype="multipart/form-data">
<label for="file-upload">File:</label>
<input type="file" id="file-upload" name="file" required>
<button type="submit">[ Upload ]</button>
</form>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<ul class="flash-messages">
{% for category, message in messages %}
<li class="flash flash-{{ category }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% if not files %}
<p class="hint">No files yet. Upload your first file above.</p>
{% else %}
<ul class="clipboard-list">
{% for file in files %}
<li class="clipboard-item" id="clipboard-{{ file.id }}">
<div class="clipboard-main">
<a href="{{ url_for('clipboard_download', file_id=file.id) }}"
download="{{ file.original_name }}"
title="{{ file.original_name }}">{{ file.original_name }}</a>
<span class="clipboard-meta">{{ file.size | filesize }} &middot; {{ file.created_at.strftime("%Y-%m-%d %H:%M") }}</span>
</div>
<form method="post" action="{{ url_for('clipboard_delete', file_id=file.id) }}" class="clipboard-delete-form">
<button type="submit" title="Delete {{ file.original_name }}">[x]</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
<footer class="statusbar">
<span>{{ user.username }}@yggdrasil:~/clipboard$</span>
</footer>
</body>
</html>
+1
View File
@@ -13,6 +13,7 @@
<a href="{{ url_for('home') }}">Home</a> <a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a> <a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</a> <a href="{{ url_for('notes_page') }}">Notes</a>
<a href="{{ url_for('clipboard_page') }}">Clipboard</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a> <a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
<a href="{{ url_for('api_docs') }}">API</a> <a href="{{ url_for('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a> <a href="{{ url_for('logout') }}">Log out</a>
+4 -1
View File
@@ -13,6 +13,7 @@
<a href="{{ url_for('home') }}">Home</a> <a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a> <a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</a> <a href="{{ url_for('notes_page') }}">Notes</a>
<a href="{{ url_for('clipboard_page') }}">Clipboard</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a> <a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
<a href="{{ url_for('api_docs') }}">API</a> <a href="{{ url_for('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a> <a href="{{ url_for('logout') }}">Log out</a>
@@ -27,9 +28,10 @@
<li><a href="{{ url_for('notes_page') }}"><span class="mc-key">2</span>Notes — title, text and checklists</a></li> <li><a href="{{ url_for('notes_page') }}"><span class="mc-key">2</span>Notes — title, text and checklists</a></li>
<li><a href="{{ url_for('notifications_page') }}"><span class="mc-key">3</span>Notifications{% if unread_notifications %} ({{ unread_notifications }} new){% endif %}</a></li> <li><a href="{{ url_for('notifications_page') }}"><span class="mc-key">3</span>Notifications{% if unread_notifications %} ({{ unread_notifications }} new){% endif %}</a></li>
<li><a href="{{ url_for('api_docs') }}"><span class="mc-key">4</span>API — documentation</a></li> <li><a href="{{ url_for('api_docs') }}"><span class="mc-key">4</span>API — documentation</a></li>
<li><a href="{{ url_for('clipboard_page') }}"><span class="mc-key">5</span>Clipboard — upload and share files</a></li>
<li><a href="{{ url_for('logout') }}"><span class="mc-key">0</span>Log out</a></li> <li><a href="{{ url_for('logout') }}"><span class="mc-key">0</span>Log out</a></li>
</ul> </ul>
<p class="hint">Tip: press <b>1</b>, <b>2</b>, <b>3</b>, <b>4</b> or <b>0</b> to navigate.</p> <p class="hint">Tip: press <b>1</b>, <b>2</b>, <b>3</b>, <b>4</b>, <b>5</b> or <b>0</b> to navigate.</p>
</div> </div>
<img class="logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo"> <img class="logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
<footer class="statusbar"> <footer class="statusbar">
@@ -47,6 +49,7 @@
'2': "{{ url_for('notes_page') }}", '2': "{{ url_for('notes_page') }}",
'3': "{{ url_for('notifications_page') }}", '3': "{{ url_for('notifications_page') }}",
'4': "{{ url_for('api_docs') }}", '4': "{{ url_for('api_docs') }}",
'5': "{{ url_for('clipboard_page') }}",
'0': "{{ url_for('logout') }}" '0': "{{ url_for('logout') }}"
}; };
if (targets[e.key]) window.location.href = targets[e.key]; if (targets[e.key]) window.location.href = targets[e.key];
+1
View File
@@ -13,6 +13,7 @@
<a href="{{ url_for('home') }}">Home</a> <a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a> <a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</a> <a href="{{ url_for('notes_page') }}">Notes</a>
<a href="{{ url_for('clipboard_page') }}">Clipboard</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a> <a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
<a href="{{ url_for('api_docs') }}">API</a> <a href="{{ url_for('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a> <a href="{{ url_for('logout') }}">Log out</a>
+4
View File
@@ -13,6 +13,7 @@
<a href="{{ url_for('home') }}">Home</a> <a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a> <a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</a> <a href="{{ url_for('notes_page') }}">Notes</a>
<a href="{{ url_for('clipboard_page') }}">Clipboard</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a> <a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
<a href="{{ url_for('api_docs') }}">API</a> <a href="{{ url_for('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a> <a href="{{ url_for('logout') }}">Log out</a>
@@ -43,6 +44,9 @@
<span>{{ notification.created_at.strftime("%Y-%m-%d %H:%M") }}</span> <span>{{ notification.created_at.strftime("%Y-%m-%d %H:%M") }}</span>
</span> </span>
</a> </a>
<form method="post" action="{{ url_for('notification_delete', notification_id=notification.id) }}" class="notification-delete-form">
<button type="submit" title="Delete notification">[x]</button>
</form>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
+1
View File
@@ -14,6 +14,7 @@
<a href="{{ url_for('home') }}">Home</a> <a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a> <a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</a> <a href="{{ url_for('notes_page') }}">Notes</a>
<a href="{{ url_for('clipboard_page') }}">Clipboard</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a> <a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
<a href="{{ url_for('api_docs') }}">API</a> <a href="{{ url_for('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a> <a href="{{ url_for('logout') }}">Log out</a>
+84 -1
View File
@@ -197,6 +197,88 @@ class MainAppTest(unittest.TestCase):
self.assertEqual(hourly.status_code, 200) self.assertEqual(hourly.status_code, 200)
self.assertEqual(len(hourly.get_json()), 1) self.assertEqual(len(hourly.get_json()), 1)
def test_clipboard_requires_login(self):
response = self.client.get("/clipboard")
self.assertEqual(response.status_code, 302)
self.assertIn("/login", response.headers["Location"])
def test_clipboard_page_shows_upload_form(self):
self.login()
response = self.client.get("/clipboard")
html = response.get_data(as_text=True)
self.assertEqual(response.status_code, 200)
self.assertIn('action="/clipboard/upload"', html)
self.assertIn('name="file"', html)
def test_clipboard_upload_download_delete_flow(self):
import io
from sqlalchemy import func
from user_models import ClipboardFile, User, user_db
self.login()
upload = self.client.post(
"/clipboard/upload",
data={
"file": (io.BytesIO(b"clipboard payload"), "hello.txt", "text/plain")
},
content_type="multipart/form-data",
follow_redirects=False,
)
self.assertEqual(upload.status_code, 302)
with self.main_app.app_context():
user = User.query.filter_by(username="tester").one()
stored = (
ClipboardFile.query.filter_by(user_id=user.id)
.order_by(ClipboardFile.id.desc())
.first()
)
self.assertIsNotNone(stored)
file_id = stored.id
original_name = stored.original_name
size = stored.size
total = (
user_db.session.query(func.coalesce(func.sum(ClipboardFile.size), 0))
.filter(ClipboardFile.user_id == user.id)
.scalar()
)
self.assertEqual(original_name, "hello.txt")
self.assertEqual(size, len(b"clipboard payload"))
self.assertEqual(total, len(b"clipboard payload"))
download = self.client.get(f"/clipboard/{file_id}/download")
self.assertEqual(download.status_code, 200)
self.assertEqual(download.data, b"clipboard payload")
delete = self.client.post(f"/clipboard/{file_id}/delete")
self.assertEqual(delete.status_code, 302)
with self.main_app.app_context():
user_db.session.expire_all()
self.assertEqual(ClipboardFile.query.filter_by(id=file_id).count(), 0)
def test_notifications_can_be_deleted(self):
from user_models import Notification, User, user_db
self.login()
with self.main_app.app_context():
user = User.query.filter_by(username="tester").one()
first = Notification(user_id=user.id, message="first notification")
second = Notification(user_id=user.id, message="second notification")
user_db.session.add_all([first, second])
user_db.session.commit()
first_id = first.id
second_id = second.id
delete = self.client.post(f"/notifications/{first_id}/delete")
self.assertEqual(delete.status_code, 302)
with self.main_app.app_context():
user_db.session.expire_all()
self.assertEqual(Notification.query.filter_by(id=first_id).count(), 0)
self.assertEqual(Notification.query.filter_by(id=second_id).count(), 1)
def test_notes_require_login(self): def test_notes_require_login(self):
response = self.client.get("/notes") response = self.client.get("/notes")
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@@ -332,11 +414,12 @@ class MainAppTest(unittest.TestCase):
'href="/home"', 'href="/home"',
'href="/sensors"', 'href="/sensors"',
'href="/notes"', 'href="/notes"',
'href="/clipboard"',
'href="/notifications"', 'href="/notifications"',
'href="/api/docs"', 'href="/api/docs"',
'href="/logout"', 'href="/logout"',
) )
for path in ("/home", "/sensors", "/notes", "/notifications", "/api/docs"): for path in ("/home", "/sensors", "/notes", "/clipboard", "/notifications", "/api/docs"):
html = self.client.get(path).get_data(as_text=True) html = self.client.get(path).get_data(as_text=True)
for link in links: for link in links:
self.assertIn(link, html, f"{link} missing on {path}") self.assertIn(link, html, f"{link} missing on {path}")
+16
View File
@@ -103,3 +103,19 @@ class Notification(user_db.Model):
if not self.is_read: if not self.is_read:
self.is_read = True self.is_read = True
self.read_at = datetime.now(timezone.utc) self.read_at = datetime.now(timezone.utc)
class ClipboardFile(user_db.Model):
__tablename__ = "clipboard_file"
id = user_db.Column(user_db.Integer, primary_key=True)
user_id = user_db.Column(
user_db.Integer, user_db.ForeignKey("user.id"), nullable=False, index=True
)
original_name = user_db.Column(user_db.String(255), nullable=False)
stored_name = user_db.Column(user_db.String(64), nullable=False, unique=True)
size = user_db.Column(user_db.BigInteger, nullable=False)
content_type = user_db.Column(user_db.String(255), nullable=True)
created_at = user_db.Column(
user_db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)
)