clipboard
This commit is contained in:
@@ -7,3 +7,4 @@ managed_components
|
|||||||
.vscode
|
.vscode
|
||||||
.clangd
|
.clangd
|
||||||
.secret_key
|
.secret_key
|
||||||
|
server/clipboard/
|
||||||
|
|||||||
+33
-5
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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 }} · {{ 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user