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
+33 -5
View File
@@ -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/<id>/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:///<server>/main_app.db` | Users database URL |
| `USERS_DB_PATH` | `<server>/main_app.db` | SQLite path when URL is unset |
| `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 |
| `BIND` | `<HOST>:<port>` | 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
@@ -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 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/<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) ---
@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) ---
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"
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
)
)
+47
View File
@@ -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); }
+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('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>
+4 -1
View File
@@ -13,6 +13,7 @@
<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>
@@ -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('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('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>
</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>
<img class="logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
<footer class="statusbar">
@@ -47,6 +49,7 @@
'2': "{{ url_for('notes_page') }}",
'3': "{{ url_for('notifications_page') }}",
'4': "{{ url_for('api_docs') }}",
'5': "{{ url_for('clipboard_page') }}",
'0': "{{ url_for('logout') }}"
};
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('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>
+4
View File
@@ -13,6 +13,7 @@
<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>
@@ -43,6 +44,9 @@
<span>{{ notification.created_at.strftime("%Y-%m-%d %H:%M") }}</span>
</span>
</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>
{% endfor %}
</ul>
+1
View File
@@ -14,6 +14,7 @@
<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>
+84 -1
View File
@@ -197,6 +197,88 @@ class MainAppTest(unittest.TestCase):
self.assertEqual(hourly.status_code, 200)
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):
response = self.client.get("/notes")
self.assertEqual(response.status_code, 302)
@@ -332,11 +414,12 @@ class MainAppTest(unittest.TestCase):
'href="/home"',
'href="/sensors"',
'href="/notes"',
'href="/clipboard"',
'href="/notifications"',
'href="/api/docs"',
'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)
for link in links:
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:
self.is_read = True
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)
)