Compare commits
12
Commits
ff9056fece
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3175067af5 | ||
|
|
66afdccfee | ||
|
|
7edf8b45cf | ||
|
|
0093d9b20d | ||
|
|
5aec0a2c2c | ||
|
|
2dcc7ebbf9 | ||
|
|
0c03348c88 | ||
|
|
768ccab09f | ||
|
|
becd73c810 | ||
|
|
d2d89c36e0 | ||
|
|
a739218fb0 | ||
|
|
7e64c5b4cf |
@@ -1,9 +1,400 @@
|
|||||||
# lapp
|
# L@pp
|
||||||
|
|
||||||
list app
|
L@pp is a small, mobile-friendly shared-list application built with Flask. It is
|
||||||
|
designed primarily for grocery lists, but works just as well for chores, packing
|
||||||
|
lists, or any other lightweight checklist.
|
||||||
|
|
||||||
install
|
Users can create and share lists, add items with quantities and categories, and
|
||||||
1. clone repo
|
check items off from a phone-friendly interface. The included web app can also
|
||||||
2. create subfolder "instance" to store database file
|
be installed as a Progressive Web App (PWA).
|
||||||
3. for an initial instance: run python3 initialze_data.py, with the python from the virtual-env
|
|
||||||
4. add the below config to your apache2 enabled site
|
## Features
|
||||||
|
|
||||||
|
- Multiple lists per user
|
||||||
|
- List sharing between registered users
|
||||||
|
- Item quantities and units, including input such as `Milk 2 cartons`
|
||||||
|
- Grocery categories with icons
|
||||||
|
- Reusable suggestions for previously completed items
|
||||||
|
- Bulk item entry
|
||||||
|
- Responsive, installable PWA interface
|
||||||
|
- User registration with group invitation keys
|
||||||
|
- Administrator approval for new accounts
|
||||||
|
- Session-based JSON API for companion clients
|
||||||
|
- SQLite by default, with other SQLAlchemy database URLs supported
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.9 or newer
|
||||||
|
- `pip` and Python virtual-environment support
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Clone the repository and enter its directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd lapp
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a virtual environment and install the dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -r requirement.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialize the database with an administrator, a regular user, two groups, and
|
||||||
|
some example lists:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python initialize_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The command creates `instance/secrets.yaml` and `instance/app.db`. It does not
|
||||||
|
print the generated passwords or registration keys; open
|
||||||
|
`instance/secrets.yaml` locally to retrieve them. The seeded usernames are
|
||||||
|
`admin` and `ignace`.
|
||||||
|
|
||||||
|
Start the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python lapp.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open <http://127.0.0.1:5001>. The built-in server runs in debug mode and is
|
||||||
|
intended for local development only.
|
||||||
|
|
||||||
|
Running the application without `initialize_data.py` creates an empty database
|
||||||
|
schema, but no users or registration groups.
|
||||||
|
|
||||||
|
## Configuration and secrets
|
||||||
|
|
||||||
|
Private configuration lives in `instance/secrets.yaml`. On first use, missing
|
||||||
|
values are generated automatically and the file is restricted to mode `0600`.
|
||||||
|
A generated file has this structure:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runtime:
|
||||||
|
secret_key: <Flask session secret>
|
||||||
|
application_key: <key used by /api/validate_key>
|
||||||
|
database_uri: sqlite:///app.db
|
||||||
|
initial_data:
|
||||||
|
admin_password: <initial admin password>
|
||||||
|
admin_group: <initial admin registration key>
|
||||||
|
user_password: <initial user password>
|
||||||
|
user_group: <initial user registration key>
|
||||||
|
fernet:
|
||||||
|
key: <Fernet key used by test.py>
|
||||||
|
```
|
||||||
|
|
||||||
|
For SQLite, relative database paths are resolved inside Flask's `instance/`
|
||||||
|
directory. To use another database, set `runtime.database_uri` to a compatible
|
||||||
|
SQLAlchemy URL and install the corresponding database driver.
|
||||||
|
|
||||||
|
Keep `runtime.secret_key` stable. Replacing it invalidates active sessions and
|
||||||
|
the seven-day auto-login cookie. The `runtime.application_key` is checked by
|
||||||
|
`POST /api/validate_key`.
|
||||||
|
|
||||||
|
The complete `instance/` directory is ignored by Git. Back it up securely and
|
||||||
|
never commit or share its contents. In particular, back up both `app.db` and
|
||||||
|
`secrets.yaml` together when using the default SQLite setup.
|
||||||
|
|
||||||
|
### Customizing the initial accounts
|
||||||
|
|
||||||
|
To choose credentials instead of using generated values:
|
||||||
|
|
||||||
|
1. Generate the configuration and empty schema without seeding accounts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c 'from lapp import create_app; create_app()'
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit the values under `initial_data` in `instance/secrets.yaml`.
|
||||||
|
3. Run `python initialize_data.py`.
|
||||||
|
|
||||||
|
Initialization is deliberately idempotent: if the database already contains a
|
||||||
|
user, no seed data is added. It also refuses to initialize a partially populated
|
||||||
|
database.
|
||||||
|
|
||||||
|
## How accounts and sharing work
|
||||||
|
|
||||||
|
Registration requires a group key. The two keys created during initialization
|
||||||
|
are stored as `initial_data.admin_group` and `initial_data.user_group`. A newly
|
||||||
|
registered account remains pending until an administrator approves it at
|
||||||
|
`/admin/`.
|
||||||
|
|
||||||
|
After signing in, a user can create, activate, rename, share, or delete their own
|
||||||
|
lists. A shared list is visible and editable by every selected user. Only the
|
||||||
|
owner can change the list itself or its sharing settings.
|
||||||
|
|
||||||
|
Administrators can create additional registration groups from `/groups`.
|
||||||
|
|
||||||
|
## Item input
|
||||||
|
|
||||||
|
L@pp extracts simple quantities and units from item text. For example:
|
||||||
|
|
||||||
|
| Input | Label | Quantity | Unit |
|
||||||
|
| --- | --- | ---: | --- |
|
||||||
|
| `Apples 4` | Apples | 4 | `x` |
|
||||||
|
| `Milk 2 cartons` | Milk | 2 | `cartons` |
|
||||||
|
| `3 kg potatoes` | Potatoes | 3 | `kg` |
|
||||||
|
|
||||||
|
Cleaning a list moves checked items into its suggestion history. Those items can
|
||||||
|
then be added again quickly from the add-items screen.
|
||||||
|
|
||||||
|
## API overview
|
||||||
|
|
||||||
|
API requests use form-encoded fields and return JSON. Authentication is stored
|
||||||
|
in the Flask session cookie, so API clients must retain cookies after login.
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST` | `/api/validate_key` | Validate the configured application key (`key`) |
|
||||||
|
| `POST` | `/api/login` | Sign in with `user` and `password` |
|
||||||
|
| `POST` | `/api/register` | Register with `user` and a group key in `password` |
|
||||||
|
| `POST` | `/api/load_lists` | Load lists available to the signed-in user |
|
||||||
|
| `POST` | `/api/load_items` | Load active items for `listid` |
|
||||||
|
|
||||||
|
Responses use a numeric `status` field: `1` means success, `0` means failure,
|
||||||
|
and registration uses `2` to indicate that approval is pending.
|
||||||
|
|
||||||
|
Example login with a cookie jar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -c cookies.txt \
|
||||||
|
-X POST \
|
||||||
|
-d 'user=ignace' \
|
||||||
|
-d 'password=YOUR_PASSWORD' \
|
||||||
|
http://127.0.0.1:5001/api/login
|
||||||
|
|
||||||
|
curl -b cookies.txt \
|
||||||
|
-X POST \
|
||||||
|
http://127.0.0.1:5001/api/load_lists
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production deployment with Nginx and Gunicorn
|
||||||
|
|
||||||
|
The following example targets Debian or Ubuntu, installs L@pp in `/srv/lapp`,
|
||||||
|
and serves it from `lists.example.com`. Substitute your own installation path
|
||||||
|
and domain where necessary.
|
||||||
|
|
||||||
|
Gunicorn loads the existing Flask application factory as
|
||||||
|
`lapp:create_app()`. The repository's `lapp.wsgi` file is only needed for a
|
||||||
|
mod_wsgi deployment and is not used here.
|
||||||
|
|
||||||
|
### 1. Install the system packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install nginx python3 python3-venv
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a dedicated, unprivileged service account:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd --system \
|
||||||
|
--home /srv/lapp \
|
||||||
|
--shell /usr/sbin/nologin \
|
||||||
|
lapp
|
||||||
|
```
|
||||||
|
|
||||||
|
Place or clone the repository at `/srv/lapp`, then install the application and
|
||||||
|
Gunicorn:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/lapp
|
||||||
|
|
||||||
|
sudo python3 -m venv .venv
|
||||||
|
sudo .venv/bin/pip install --upgrade pip
|
||||||
|
sudo .venv/bin/pip install -r requirement.txt
|
||||||
|
sudo .venv/bin/pip install 'gunicorn>=23,<24'
|
||||||
|
```
|
||||||
|
|
||||||
|
Gunicorn should run as the service account, not as `root`, and should only be
|
||||||
|
reachable through Nginx.
|
||||||
|
|
||||||
|
### 2. Initialize the application
|
||||||
|
|
||||||
|
Create the private instance directory with permissions that allow the service
|
||||||
|
account to maintain the SQLite database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -d \
|
||||||
|
-o lapp \
|
||||||
|
-g www-data \
|
||||||
|
-m 0750 \
|
||||||
|
/srv/lapp/instance
|
||||||
|
|
||||||
|
sudo -u lapp /srv/lapp/.venv/bin/python \
|
||||||
|
/srv/lapp/initialize_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
View the generated passwords and registration keys locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u lapp sed -n '1,120p' \
|
||||||
|
/srv/lapp/instance/secrets.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Back up `instance/app.db` and `instance/secrets.yaml` together. Do not expose
|
||||||
|
the `instance/` directory through Nginx.
|
||||||
|
|
||||||
|
### 3. Run Gunicorn with systemd
|
||||||
|
|
||||||
|
Create `/etc/systemd/system/lapp.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=L@pp service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lapp
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/srv/lapp
|
||||||
|
|
||||||
|
RuntimeDirectory=lapp
|
||||||
|
RuntimeDirectoryMode=0750
|
||||||
|
UMask=0007
|
||||||
|
|
||||||
|
ExecStart=/srv/lapp/.venv/bin/gunicorn \
|
||||||
|
--workers 2 \
|
||||||
|
--bind unix:/run/lapp/lapp.sock \
|
||||||
|
--access-logfile - \
|
||||||
|
--error-logfile - \
|
||||||
|
lapp:create_app()
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
PrivateTmp=true
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
One or two workers is a sensible starting point for this small SQLite
|
||||||
|
application. Adding many workers can increase SQLite write contention.
|
||||||
|
|
||||||
|
Enable and start the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now lapp
|
||||||
|
sudo systemctl status lapp
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow its logs or verify that its socket exists:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u lapp -f
|
||||||
|
sudo ls -l /run/lapp/lapp.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Configure Nginx
|
||||||
|
|
||||||
|
Create `/etc/nginx/sites-available/lapp`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
|
||||||
|
server_name lapp.suy.nl;
|
||||||
|
|
||||||
|
client_max_body_size 2m;
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
alias /srv/lapp/static/;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://unix:/run/lapp/lapp.sock;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable the site and validate the configuration before reloading Nginx:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/lapp \
|
||||||
|
/etc/nginx/sites-enabled/lapp
|
||||||
|
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
The application should now be available at `http://lists.example.com`.
|
||||||
|
|
||||||
|
### 5. Enable HTTPS
|
||||||
|
|
||||||
|
Once the domain's DNS records point to the server, obtain a TLS certificate
|
||||||
|
with your preferred ACME client. For example, using Certbot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install certbot python3-certbot-nginx
|
||||||
|
sudo certbot --nginx -d lists.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not expose L@pp publicly over plain HTTP: it handles passwords and persistent
|
||||||
|
login cookies.
|
||||||
|
|
||||||
|
### Updating a deployment
|
||||||
|
|
||||||
|
Back up the database and secrets first. Then update the source and dependencies
|
||||||
|
and restart Gunicorn:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/lapp
|
||||||
|
sudo .venv/bin/pip install -r requirement.txt
|
||||||
|
sudo systemctl restart lapp
|
||||||
|
sudo systemctl status lapp
|
||||||
|
```
|
||||||
|
|
||||||
|
See the official [Flask Gunicorn deployment guide](https://flask.palletsprojects.com/en/stable/deploying/gunicorn/),
|
||||||
|
[Gunicorn documentation](https://docs.gunicorn.org/en/stable/run.html), and
|
||||||
|
[Nginx proxy module documentation](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
|
||||||
|
for additional configuration options.
|
||||||
|
|
||||||
|
## Utility scripts
|
||||||
|
|
||||||
|
- `initialize_data.py` safely seeds a new database.
|
||||||
|
- `show_db.py` prints all database records for local diagnostics. Its output
|
||||||
|
includes password hashes and group secrets, so treat it as sensitive.
|
||||||
|
- `update_db.py` contains historical, manual migration snippets. Review and
|
||||||
|
adapt it before running it against any database.
|
||||||
|
- `test.py` is a Fernet encryption example, not an automated test suite.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
lapp.py Application factory and blueprint registration
|
||||||
|
auth.py Login, registration, logout, and password changes
|
||||||
|
admin.py Account approval
|
||||||
|
groups.py Registration-group management
|
||||||
|
lists.py List creation, ownership, and sharing
|
||||||
|
items.py Item entry, categories, quantities, and completion
|
||||||
|
api.py Session-based JSON API
|
||||||
|
models.py SQLAlchemy models
|
||||||
|
templates/ Jinja templates
|
||||||
|
static/ CSS, JavaScript, icons, and PWA manifest
|
||||||
|
instance/ Local secrets and database (generated, ignored by Git)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development notes
|
||||||
|
|
||||||
|
There is currently no automated test suite or migration framework. Before
|
||||||
|
upgrading an existing installation, back up the database and secrets, then test
|
||||||
|
the change on a copy of the data.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, request
|
from flask import Blueprint, render_template, redirect, url_for, request, current_app
|
||||||
from flask_login import login_required, current_user, login_user
|
from flask_login import login_required, current_user, login_user
|
||||||
from models import db
|
from models import db
|
||||||
from models import User, Item
|
from models import User, Item
|
||||||
@@ -14,7 +14,7 @@ def validate_key():
|
|||||||
result['data'] = ''
|
result['data'] = ''
|
||||||
if "key" in request.form:
|
if "key" in request.form:
|
||||||
key = request.form["key"]
|
key = request.form["key"]
|
||||||
if key == 'NogNietNodigHier':
|
if key == current_app.config["APPLICATION_KEY"]:
|
||||||
result['status'] = 1
|
result['status'] = 1
|
||||||
else:
|
else:
|
||||||
result['message'] = 'Unknown Application-Key'
|
result['message'] = 'Unknown Application-Key'
|
||||||
|
|||||||
@@ -1,64 +1,35 @@
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, current_app
|
||||||
from flask_login import login_user, logout_user, login_required, current_user
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
from models import db
|
from models import db
|
||||||
from models import User
|
from models import User
|
||||||
from log import Log
|
from log import Log
|
||||||
import pickle
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
|
|
||||||
auth_bp = Blueprint("auth", __name__)
|
auth_bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
# =====================================================
|
AUTOLOGIN_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
|
||||||
# SECRET KEY (only for this program)
|
AUTOLOGIN_SALT = "lapp-autologin"
|
||||||
# =====================================================
|
|
||||||
# Generate once using: Fernet.generate_key()
|
|
||||||
_SECRET_KEY = b'YFK7QCyTzhyLO4vqrnRxvDAI5uu8mXEYrInEjbRoQgs='
|
|
||||||
fernet = Fernet(_SECRET_KEY)
|
|
||||||
|
|
||||||
# =====================================================
|
def autologin_serializer():
|
||||||
# ENCRYPT
|
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt=AUTOLOGIN_SALT)
|
||||||
# =====================================================
|
|
||||||
def encrypt_object(obj) -> bytes:
|
|
||||||
"""
|
|
||||||
Encrypt any Python object and return encrypted bytes.
|
|
||||||
"""
|
|
||||||
serialized = pickle.dumps(obj)
|
|
||||||
encrypted = fernet.encrypt(serialized)
|
|
||||||
return encrypted
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# DECRYPT
|
|
||||||
# =====================================================
|
|
||||||
def decrypt_object(encrypted_data: bytes):
|
|
||||||
"""
|
|
||||||
Decrypt bytes back into the original Python object.
|
|
||||||
"""
|
|
||||||
decrypted = fernet.decrypt(encrypted_data)
|
|
||||||
obj = pickle.loads(decrypted)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def create_cookie(userid):
|
def create_cookie(userid):
|
||||||
cookie_data = {
|
cookie_data = {
|
||||||
"user": userid,
|
"user": userid
|
||||||
"datetime": datetime.datetime.now()
|
|
||||||
}
|
}
|
||||||
return encrypt_object(cookie_data).decode('utf-8')
|
return autologin_serializer().dumps(cookie_data)
|
||||||
|
|
||||||
def is_cookie_ok_to_autologin(cookie_content):
|
def is_cookie_ok_to_autologin(cookie_content):
|
||||||
user = 0
|
if not cookie_content:
|
||||||
autologin = False
|
return 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cookie_data = decrypt_object(cookie_content)
|
cookie_data = autologin_serializer().loads(cookie_content,
|
||||||
user = cookie_data["user"]
|
max_age=AUTOLOGIN_MAX_AGE_SECONDS)
|
||||||
dt = cookie_data["datetime"]
|
return int(cookie_data["user"])
|
||||||
lastweek = datetime.datetime.now() - datetime.timedelta(days=7)
|
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
|
||||||
autologin = dt > lastweek
|
|
||||||
except:
|
|
||||||
Log.info("Cookie error")
|
Log.info("Cookie error")
|
||||||
if autologin:
|
|
||||||
return user
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
#. @auth_bp.route("/", methods=["GET", "POST"])
|
#. @auth_bp.route("/", methods=["GET", "POST"])
|
||||||
@@ -81,11 +52,16 @@ def login():
|
|||||||
if user.group_id>0:
|
if user.group_id>0:
|
||||||
login_user(user)
|
login_user(user)
|
||||||
return redirect(url_for("lists.home"))
|
return redirect(url_for("lists.home"))
|
||||||
flash("Invalid key credentials.")
|
# flash("Invalid key credentials.")
|
||||||
Log.info(f"Authorization failure for user '{request.form["name"]}' ")
|
Log.info(f"Authorization failure for user '{request.form["name"]}' ")
|
||||||
|
|
||||||
|
|
||||||
resp = make_response(render_template("login.html"))
|
login_failed = request.method == "POST"
|
||||||
|
resp = make_response(render_template(
|
||||||
|
"login.html",
|
||||||
|
name_placeholder="Sorry" if login_failed else "Name",
|
||||||
|
password_placeholder="Try again" if login_failed else "Password",
|
||||||
|
))
|
||||||
resp.set_cookie('username', '')
|
resp.set_cookie('username', '')
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
@@ -129,14 +105,16 @@ def change_pwd():
|
|||||||
pwd_old = request.form["password_old"]
|
pwd_old = request.form["password_old"]
|
||||||
pwd_new1 = request.form["password_new1"]
|
pwd_new1 = request.form["password_new1"]
|
||||||
pwd_new2 = request.form["password_new2"]
|
pwd_new2 = request.form["password_new2"]
|
||||||
if current_user.check_password(pwd_old):
|
if not current_user.check_password(pwd_old):
|
||||||
if pwd_new1 == pwd_new2:
|
flash("Invalid old credentials")
|
||||||
|
Log.info(f"Authorization failure for user '{current_user.name}' ")
|
||||||
|
elif pwd_new1 != pwd_new2:
|
||||||
|
flash("New Passwords are not identical")
|
||||||
|
else:
|
||||||
current_user.set_password(pwd_new2)
|
current_user.set_password(pwd_new2)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return redirect(url_for("lists.home"))
|
return redirect(url_for("lists.home"))
|
||||||
flash("New Passwords are not identical")
|
|
||||||
flash("Invalid credentials")
|
|
||||||
Log.info(f"Authorization failure for user '{request.form["name"]}' ")
|
|
||||||
|
|
||||||
return render_template(url_for("lists.home"))
|
return redirect(url_for("auth.change_pwd"))
|
||||||
|
|
||||||
return render_template("change_pwd.html")
|
return render_template("change_pwd.html")
|
||||||
|
|||||||
+100
-35
@@ -1,38 +1,103 @@
|
|||||||
from lapp import create_app
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from models import db, User, ListOfItems, Shared, Item, Group
|
|
||||||
|
from lapp import create_app
|
||||||
|
from models import Group, Item, ListOfItems, Shared, User, db
|
||||||
|
from secrets_config import load_secrets
|
||||||
|
|
||||||
|
|
||||||
|
def has_rows(model):
|
||||||
|
primary_key = next(iter(model.__table__.primary_key.columns))
|
||||||
|
return db.session.scalar(sa.select(primary_key).limit(1)) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def initialize(app):
|
||||||
|
initial_data = load_secrets(app.instance_path)["initial_data"]
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
with db.session.begin():
|
||||||
|
if has_rows(User):
|
||||||
|
return False
|
||||||
|
|
||||||
|
partially_populated = [
|
||||||
|
model.__tablename__
|
||||||
|
for model in (Group, ListOfItems, Item, Shared)
|
||||||
|
if has_rows(model)
|
||||||
|
]
|
||||||
|
if partially_populated:
|
||||||
|
tables = ", ".join(partially_populated)
|
||||||
|
raise RuntimeError(
|
||||||
|
"Refusing to initialize a partially populated database. "
|
||||||
|
f"These tables contain data while user is empty: {tables}"
|
||||||
|
)
|
||||||
|
|
||||||
|
admin_group = Group(secret=initial_data["admin_group"])
|
||||||
|
user_group = Group(secret=initial_data["user_group"])
|
||||||
|
db.session.add_all([admin_group, user_group])
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
admin = User(
|
||||||
|
name="admin",
|
||||||
|
group_id=admin_group.id,
|
||||||
|
is_admin=True,
|
||||||
|
is_approved=True,
|
||||||
|
is_private=True,
|
||||||
|
)
|
||||||
|
admin.set_password(initial_data["admin_password"])
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
name="ignace",
|
||||||
|
group_id=user_group.id,
|
||||||
|
is_admin=False,
|
||||||
|
is_approved=True,
|
||||||
|
)
|
||||||
|
user.set_password(initial_data["user_password"])
|
||||||
|
db.session.add_all([admin, user])
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
groceries = ListOfItems(
|
||||||
|
owner_user_id=user.id,
|
||||||
|
name="Supermarkt",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
chores = ListOfItems(
|
||||||
|
owner_user_id=user.id,
|
||||||
|
name="Klusjes",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.session.add_all([groceries, chores])
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
db.session.add_all([
|
||||||
|
Item(listofitems_id=groceries.id, label="Stokbrood"),
|
||||||
|
Item(
|
||||||
|
listofitems_id=groceries.id,
|
||||||
|
label="Speltbroodje",
|
||||||
|
category="Brood",
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
listofitems_id=groceries.id,
|
||||||
|
label="Boter",
|
||||||
|
category="Zuivel",
|
||||||
|
unit="x",
|
||||||
|
quantity=1,
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
listofitems_id=groceries.id,
|
||||||
|
label="Notenbroodjes",
|
||||||
|
is_checked=True,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = create_app()
|
||||||
|
if initialize(app):
|
||||||
|
print("Created initial credentials from instance/secrets.yaml")
|
||||||
|
else:
|
||||||
|
print("Database already contains users; no initial data was added")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app = create_app()
|
main()
|
||||||
app.app_context().push()
|
|
||||||
query = sa.select(User)
|
|
||||||
users = db.session.scalars(query).all()
|
|
||||||
if len(users) == 0:
|
|
||||||
g = Group(secret="This is the group just for the admin")
|
|
||||||
db.session.add(g)
|
|
||||||
g = Group(secret="GroupForIgnaceAndLoversThatDoGroceries")
|
|
||||||
db.session.add(g)
|
|
||||||
admin = User(name="admin", group_id=1, is_admin=True, is_approved=True, is_private=True)
|
|
||||||
admin.set_password("suy2025")
|
|
||||||
db.session.add(admin)
|
|
||||||
user1 = User(name="ignace", group_id=2, is_admin=False, is_approved=True)
|
|
||||||
user1.set_password("xanderj2")
|
|
||||||
db.session.add(user1)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
lol1 = ListOfItems(owner_user_id = user1.id, name="Supermarkt", is_active=True)
|
|
||||||
db.session.add(lol1)
|
|
||||||
lol2 = ListOfItems(owner_user_id = user1.id, name="Klusjes", is_active=True)
|
|
||||||
db.session.add(lol2)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
item1 = Item(listofitems_id=lol1.id, label="Stokbrood")
|
|
||||||
item2 = Item(listofitems_id=lol1.id, label="Speltbroodje", category="Brood")
|
|
||||||
item3 = Item(listofitems_id=lol1.id, label="Boter", category="Zuivel", unit='x', quantity=1)
|
|
||||||
item4 = Item(listofitems_id=lol1.id, label="Notenbroodjes", is_checked=True)
|
|
||||||
db.session.add(item1)
|
|
||||||
db.session.add(item2)
|
|
||||||
db.session.add(item3)
|
|
||||||
db.session.add(item4)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from flask_login import login_required, current_user
|
|||||||
from models import db
|
from models import db
|
||||||
from models import Item, ListOfItems
|
from models import Item, ListOfItems
|
||||||
from sqlalchemy import desc, text
|
from sqlalchemy import desc, text
|
||||||
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
from log import Log
|
from log import Log
|
||||||
from auth import create_cookie
|
from auth import create_cookie
|
||||||
@@ -23,6 +24,28 @@ def require_item_access(itemid):
|
|||||||
abort(403)
|
abort(403)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
def client_update_time():
|
||||||
|
client_updated_at = request.args.get("client_updated_at")
|
||||||
|
if not client_updated_at:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(int(client_updated_at) / 1000)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def item_update_is_current(item):
|
||||||
|
updated_at = client_update_time()
|
||||||
|
if updated_at is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return item.updated_at is None or updated_at >= item.updated_at
|
||||||
|
|
||||||
|
def stamp_item_update(item):
|
||||||
|
updated_at = client_update_time()
|
||||||
|
if updated_at is not None:
|
||||||
|
item.updated_at = updated_at
|
||||||
|
|
||||||
# home screen for items
|
# home screen for items
|
||||||
@items_bp.route("/items/<listid>")
|
@items_bp.route("/items/<listid>")
|
||||||
@login_required
|
@login_required
|
||||||
@@ -42,7 +65,10 @@ def items(listid):
|
|||||||
@login_required
|
@login_required
|
||||||
def item_update(itemid, itemchecked):
|
def item_update(itemid, itemchecked):
|
||||||
i = require_item_access(itemid)
|
i = require_item_access(itemid)
|
||||||
|
if not item_update_is_current(i):
|
||||||
|
return '', 204
|
||||||
i.is_checked = True if itemchecked == 'true' else False
|
i.is_checked = True if itemchecked == 'true' else False
|
||||||
|
stamp_item_update(i)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return '', 204
|
return '', 204
|
||||||
|
|
||||||
@@ -170,6 +196,8 @@ def items_multiappend(listid):
|
|||||||
@login_required
|
@login_required
|
||||||
def item_delete(itemid):
|
def item_delete(itemid):
|
||||||
i = require_item_access(itemid)
|
i = require_item_access(itemid)
|
||||||
|
if not item_update_is_current(i):
|
||||||
|
return redirect(url_for("items.items_append", listid=i.listofitems_id))
|
||||||
listid = i.listofitems_id
|
listid = i.listofitems_id
|
||||||
db.session.delete(i)
|
db.session.delete(i)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -195,6 +223,12 @@ def items_clean(listid):
|
|||||||
@login_required
|
@login_required
|
||||||
def item_addone(itemid):
|
def item_addone(itemid):
|
||||||
i = require_item_access(itemid)
|
i = require_item_access(itemid)
|
||||||
|
if not item_update_is_current(i):
|
||||||
|
resp = make_response(render_template("items_append.html", user=current_user,
|
||||||
|
items=Item.query.filter_by(listofitems_id=i.listofitems_id).order_by(Item.label).all(),
|
||||||
|
logo_url=url_for("items.items", listid=i.listofitems_id)))
|
||||||
|
resp.set_cookie('username', create_cookie(current_user.id))
|
||||||
|
return resp
|
||||||
listid = i.listofitems_id
|
listid = i.listofitems_id
|
||||||
# if it is checked or not on the list yet, set quantity to one
|
# if it is checked or not on the list yet, set quantity to one
|
||||||
if i.is_suggestion or i.is_checked:
|
if i.is_suggestion or i.is_checked:
|
||||||
@@ -208,6 +242,7 @@ def item_addone(itemid):
|
|||||||
# if no unit given, make it 'x'
|
# if no unit given, make it 'x'
|
||||||
if not i.unit:
|
if not i.unit:
|
||||||
i.unit = 'x'
|
i.unit = 'x'
|
||||||
|
stamp_item_update(i)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
resp = make_response(render_template("items_append.html", user=current_user,
|
resp = make_response(render_template("items_append.html", user=current_user,
|
||||||
@@ -221,6 +256,12 @@ def item_addone(itemid):
|
|||||||
@login_required
|
@login_required
|
||||||
def item_addquantity(itemid, quantity):
|
def item_addquantity(itemid, quantity):
|
||||||
i = require_item_access(itemid)
|
i = require_item_access(itemid)
|
||||||
|
if not item_update_is_current(i):
|
||||||
|
resp = make_response(render_template("items_append.html", user=current_user,
|
||||||
|
items=Item.query.filter_by(listofitems_id=i.listofitems_id).order_by(Item.label).all(),
|
||||||
|
logo_url=url_for("items.items", listid=i.listofitems_id)))
|
||||||
|
resp.set_cookie('username', create_cookie(current_user.id))
|
||||||
|
return resp
|
||||||
listid = i.listofitems_id
|
listid = i.listofitems_id
|
||||||
# always add the item to the active todo
|
# always add the item to the active todo
|
||||||
if i.is_suggestion or i.is_checked:
|
if i.is_suggestion or i.is_checked:
|
||||||
@@ -229,6 +270,7 @@ def item_addquantity(itemid, quantity):
|
|||||||
i.is_checked = False
|
i.is_checked = False
|
||||||
else:
|
else:
|
||||||
i.quantity += int(quantity)
|
i.quantity += int(quantity)
|
||||||
|
stamp_item_update(i)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
resp = make_response(render_template("items_append.html", user=current_user,
|
resp = make_response(render_template("items_append.html", user=current_user,
|
||||||
@@ -242,6 +284,9 @@ def item_addquantity(itemid, quantity):
|
|||||||
@login_required
|
@login_required
|
||||||
def item_upitem_update_categorydate(itemid, category):
|
def item_upitem_update_categorydate(itemid, category):
|
||||||
i = require_item_access(itemid)
|
i = require_item_access(itemid)
|
||||||
|
if not item_update_is_current(i):
|
||||||
|
return '', 204
|
||||||
i.category = category
|
i.category = category
|
||||||
|
stamp_item_update(i)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return '', 204
|
return '', 204
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ from lists import lists_bp
|
|||||||
from items import items_bp
|
from items import items_bp
|
||||||
from api import api_bp
|
from api import api_bp
|
||||||
from groups import groups_bp
|
from groups import groups_bp
|
||||||
|
from secrets_config import load_secrets
|
||||||
|
|
||||||
login_manager = LoginManager()
|
login_manager = LoginManager()
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__, instance_relative_config=True)
|
||||||
app.config["SECRET_KEY"] = "9823740934riurehoiuwerf873487qe78feri"
|
secret_values = load_secrets(app.instance_path)
|
||||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///app.db"
|
app.config["SECRET_KEY"] = secret_values["runtime"]["secret_key"]
|
||||||
|
app.config["APPLICATION_KEY"] = secret_values["runtime"]["application_key"]
|
||||||
|
app.config["SQLALCHEMY_DATABASE_URI"] = secret_values["runtime"]["database_uri"]
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
|
|||||||
@@ -37,12 +37,7 @@ def edit():
|
|||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
newname = request.form["name"].strip()
|
newname = request.form["name"].strip()
|
||||||
double = False
|
if not newname or ListOfItems.name_exists_for_owner(current_user.id, newname, exclude_id=ilist_id):
|
||||||
for d in ListOfItems.query.filter_by(owner_user_id=current_user.id, name=newname).all():
|
|
||||||
if not d.id == ilist_id:
|
|
||||||
# there is a duplicate name
|
|
||||||
double = True
|
|
||||||
if double:
|
|
||||||
return render_template("lists_edit.html", user=current_user,
|
return render_template("lists_edit.html", user=current_user,
|
||||||
ilists=current_user.my_owned_lists(with_inactive=True),
|
ilists=current_user.my_owned_lists(with_inactive=True),
|
||||||
users=not_current_user(current_user.id),
|
users=not_current_user(current_user.id),
|
||||||
@@ -76,8 +71,7 @@ def edit():
|
|||||||
# must be a new list
|
# must be a new list
|
||||||
newlist = ListOfItems(name=request.form["name"].strip(), owner_user_id=current_user.id)
|
newlist = ListOfItems(name=request.form["name"].strip(), owner_user_id=current_user.id)
|
||||||
# check if name exists
|
# check if name exists
|
||||||
duplicate = ListOfItems.query.filter_by(owner_user_id=current_user.id, name=newlist.name).first()
|
if not newlist.name or ListOfItems.name_exists_for_owner(current_user.id, newlist.name):
|
||||||
if duplicate:
|
|
||||||
# there is a duplicate name
|
# there is a duplicate name
|
||||||
return render_template("lists_edit.html", user=current_user,
|
return render_template("lists_edit.html", user=current_user,
|
||||||
ilists=current_user.my_owned_lists(with_inactive=True),
|
ilists=current_user.my_owned_lists(with_inactive=True),
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ from flask_sqlalchemy import SQLAlchemy
|
|||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
db = SQLAlchemy() # Create the extension object
|
db = SQLAlchemy() # Create the extension object
|
||||||
|
|
||||||
class User(UserMixin, db.Model):
|
class User(UserMixin, db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
updated_at = db.Column(db.TIMESTAMP, default=datetime.now(), onupdate=datetime.now(), nullable=False)
|
updated_at = db.Column(db.TIMESTAMP, default=datetime.now, onupdate=datetime.now, nullable=False)
|
||||||
group_id = db.Column(db.Integer, default=0, nullable=False)
|
group_id = db.Column(db.Integer, default=0, nullable=False)
|
||||||
name = db.Column(db.String(40), unique=True, nullable=False)
|
name = db.Column(db.String(40), unique=True, nullable=False)
|
||||||
password_hash = db.Column(db.String(256))
|
password_hash = db.Column(db.String(256))
|
||||||
@@ -72,12 +73,26 @@ class User(UserMixin, db.Model):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
class ListOfItems(db.Model):
|
class ListOfItems(db.Model):
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("owner_user_id", "name", name="uq_list_owner_name"),
|
||||||
|
)
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
updated_at = db.Column(db.TIMESTAMP, default=datetime.now(), onupdate=datetime.now(), nullable=False)
|
updated_at = db.Column(db.TIMESTAMP, default=datetime.now, onupdate=datetime.now, nullable=False)
|
||||||
owner_user_id = db.Column(db.Integer, nullable=False)
|
owner_user_id = db.Column(db.Integer, nullable=False)
|
||||||
name = db.Column(db.String(40), unique=True, nullable=False)
|
name = db.Column(db.String(40), nullable=False)
|
||||||
is_active = db.Column(db.Boolean, default=False)
|
is_active = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def name_exists_for_owner(cls, owner_user_id, name, exclude_id=None):
|
||||||
|
query = cls.query.filter(
|
||||||
|
cls.owner_user_id == owner_user_id,
|
||||||
|
func.lower(cls.name) == name.strip().lower(),
|
||||||
|
)
|
||||||
|
if exclude_id is not None:
|
||||||
|
query = query.filter(cls.id != exclude_id)
|
||||||
|
return query.first() is not None
|
||||||
|
|
||||||
def as_dict(self):
|
def as_dict(self):
|
||||||
result = {}
|
result = {}
|
||||||
for p in ['id','name','owner_user_id','is_active']:
|
for p in ['id','name','owner_user_id','is_active']:
|
||||||
@@ -94,7 +109,7 @@ class ListOfItems(db.Model):
|
|||||||
|
|
||||||
class Item(db.Model):
|
class Item(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
updated_at = db.Column(db.TIMESTAMP, default=datetime.now(), onupdate=datetime.now(), nullable=False)
|
updated_at = db.Column(db.TIMESTAMP, default=datetime.now, onupdate=datetime.now, nullable=False)
|
||||||
listofitems_id = db.Column(db.Integer, nullable=False)
|
listofitems_id = db.Column(db.Integer, nullable=False)
|
||||||
label = db.Column(db.String(40), nullable=False)
|
label = db.Column(db.String(40), nullable=False)
|
||||||
quantity = db.Column(db.Integer, default=0)
|
quantity = db.Column(db.Integer, default=0)
|
||||||
@@ -117,5 +132,5 @@ class Shared(db.Model):
|
|||||||
|
|
||||||
class Group(db.Model):
|
class Group(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
updated_at = db.Column(db.TIMESTAMP, default=datetime.now(), onupdate=datetime.now(), nullable=False)
|
updated_at = db.Column(db.TIMESTAMP, default=datetime.now, onupdate=datetime.now, nullable=False)
|
||||||
secret = db.Column(db.String(128))
|
secret = db.Column(db.String(128))
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Flask>=3.0,<4.0
|
||||||
|
Flask-Login>=0.6,<1.0
|
||||||
|
Flask-SQLAlchemy>=3.1,<4.0
|
||||||
|
cryptography>=42,<46
|
||||||
|
PyYAML>=6.0,<7.0
|
||||||
|
gunicorn
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import base64
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet_key():
|
||||||
|
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_secrets():
|
||||||
|
return {
|
||||||
|
"runtime": {
|
||||||
|
"secret_key": secrets.token_urlsafe(48),
|
||||||
|
"application_key": secrets.token_urlsafe(48),
|
||||||
|
"database_uri": "sqlite:///app.db",
|
||||||
|
},
|
||||||
|
"initial_data": {
|
||||||
|
"admin_password": secrets.token_urlsafe(18),
|
||||||
|
"admin_group": secrets.token_urlsafe(18),
|
||||||
|
"user_password": secrets.token_urlsafe(18),
|
||||||
|
"user_group": secrets.token_urlsafe(18),
|
||||||
|
},
|
||||||
|
"fernet": {
|
||||||
|
"key": _fernet_key(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_missing(target, defaults):
|
||||||
|
changed = False
|
||||||
|
for key, value in defaults.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
current = target.get(key)
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
target[key] = {}
|
||||||
|
current = target[key]
|
||||||
|
changed = True
|
||||||
|
if _merge_missing(current, value):
|
||||||
|
changed = True
|
||||||
|
elif not target.get(key):
|
||||||
|
target[key] = value
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def load_secrets(instance_path):
|
||||||
|
instance_dir = Path(instance_path)
|
||||||
|
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
secrets_path = instance_dir / "secrets.yaml"
|
||||||
|
existed = secrets_path.exists()
|
||||||
|
if existed:
|
||||||
|
secrets_path.chmod(0o600)
|
||||||
|
else:
|
||||||
|
secrets_path.touch(mode=0o600)
|
||||||
|
|
||||||
|
if existed:
|
||||||
|
loaded = yaml.safe_load(secrets_path.read_text(encoding="utf-8")) or {}
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
raise ValueError(f"{secrets_path} must contain a YAML mapping")
|
||||||
|
else:
|
||||||
|
loaded = {}
|
||||||
|
|
||||||
|
defaults = _default_secrets()
|
||||||
|
legacy_files = {
|
||||||
|
"secret_key": instance_dir / "secret_key",
|
||||||
|
"application_key": instance_dir / "application_key",
|
||||||
|
}
|
||||||
|
migrated_paths = []
|
||||||
|
for key, legacy_path in legacy_files.items():
|
||||||
|
if legacy_path.exists() and not loaded.get("runtime", {}).get(key):
|
||||||
|
defaults["runtime"][key] = legacy_path.read_text(encoding="utf-8").strip()
|
||||||
|
migrated_paths.append(legacy_path)
|
||||||
|
|
||||||
|
changed = _merge_missing(loaded, defaults)
|
||||||
|
if changed or not existed:
|
||||||
|
secrets_path.write_text(
|
||||||
|
yaml.safe_dump(loaded, sort_keys=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
secrets_path.chmod(0o600)
|
||||||
|
for legacy_path in migrated_paths:
|
||||||
|
legacy_path.unlink()
|
||||||
|
return loaded
|
||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Set a L@pp user's password from the command line."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from lapp import create_app
|
||||||
|
from models import User, db
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Set a new password for an existing L@pp user.",
|
||||||
|
usage="%(prog)s <username> <new-pwd>",
|
||||||
|
)
|
||||||
|
parser.add_argument("username", help="name of the user to update")
|
||||||
|
parser.add_argument("new_password", metavar="new-pwd", help="new password")
|
||||||
|
return parser, parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser, args = parse_args()
|
||||||
|
|
||||||
|
if not args.username:
|
||||||
|
parser.error("username must not be empty")
|
||||||
|
if not args.new_password:
|
||||||
|
parser.error("new-pwd must not be empty")
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
user = db.session.scalar(
|
||||||
|
sa.select(User).where(User.name == args.username)
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
print(f"User {args.username!r} does not exist.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
user.set_password(args.new_password)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
print(f"Password updated for user {args.username!r}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,117 @@
|
|||||||
|
(function () {
|
||||||
|
const STORAGE_KEY = "lapp.pendingItemUpdates";
|
||||||
|
|
||||||
|
function readQueue() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeQueue(queue) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(queue));
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueRequest(request) {
|
||||||
|
const queue = readQueue();
|
||||||
|
const queuedRequest = {
|
||||||
|
url: request.url,
|
||||||
|
method: request.method || "GET",
|
||||||
|
coalesceKey: request.coalesceKey || null,
|
||||||
|
clientUpdatedAt: request.clientUpdatedAt || Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (queuedRequest.coalesceKey) {
|
||||||
|
const existingIndex = queue.findIndex(function (item) {
|
||||||
|
return item.coalesceKey === queuedRequest.coalesceKey;
|
||||||
|
});
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
queue[existingIndex] = queuedRequest;
|
||||||
|
} else {
|
||||||
|
queue.push(queuedRequest);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
queue.push(queuedRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeQueue(queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendRequest(request) {
|
||||||
|
const url = new URL(request.url, window.location.origin);
|
||||||
|
if (request.clientUpdatedAt) {
|
||||||
|
url.searchParams.set("client_updated_at", request.clientUpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: request.method || "GET",
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "same-origin"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.redirected && new URL(response.url).pathname === "/login") {
|
||||||
|
const error = new Error("Login required");
|
||||||
|
error.retryable = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error("Request failed: " + response.status);
|
||||||
|
error.retryable = response.status >= 500 || response.status === 408 || response.status === 429;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueableItemRequest(url, options) {
|
||||||
|
const request = Object.assign({}, options || {}, {
|
||||||
|
url: url,
|
||||||
|
clientUpdatedAt: Date.now()
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await sendRequest(request);
|
||||||
|
flushItemUpdateQueue();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.retryable !== false) {
|
||||||
|
queueRequest(request);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushItemUpdateQueue() {
|
||||||
|
if (flushItemUpdateQueue.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flushItemUpdateQueue.running = true;
|
||||||
|
|
||||||
|
const queue = readQueue();
|
||||||
|
const remaining = [];
|
||||||
|
|
||||||
|
for (const request of queue) {
|
||||||
|
try {
|
||||||
|
await sendRequest(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.retryable !== false) {
|
||||||
|
remaining.push(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeQueue(remaining);
|
||||||
|
flushItemUpdateQueue.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.lappQueueItemRequest = queueableItemRequest;
|
||||||
|
window.lappFlushItemUpdateQueue = flushItemUpdateQueue;
|
||||||
|
|
||||||
|
window.addEventListener("online", flushItemUpdateQueue);
|
||||||
|
document.addEventListener("visibilitychange", function () {
|
||||||
|
if (!document.hidden) {
|
||||||
|
flushItemUpdateQueue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener("DOMContentLoaded", flushItemUpdateQueue);
|
||||||
|
})();
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -17,6 +17,7 @@
|
|||||||
<link href="/static/bootstrap.min.css" rel="stylesheet">
|
<link href="/static/bootstrap.min.css" rel="stylesheet">
|
||||||
<script src="/static/jquery.min.js"></script>
|
<script src="/static/jquery.min.js"></script>
|
||||||
<script src="/static/bootstrap.min.js"></script>
|
<script src="/static/bootstrap.min.js"></script>
|
||||||
|
<script src="/static/item_update_queue.js"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h3 class="text-center mb-4"><img style="width: 3em; height: 3em" src="/static/login.svg"></h3>
|
<h3 class="text-center mb-4"><img style="width: 3em; height: 3em" src="/static/login.svg"></h3>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-danger" role="alert">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
|
||||||
<input class="form-control mb-3" name="password_old" type="password" placeholder="Old Password" required>
|
<input class="form-control mb-3" name="password_old" type="password" placeholder="Old Password" required>
|
||||||
|
|||||||
@@ -6,17 +6,29 @@
|
|||||||
|
|
||||||
// handle Delete
|
// handle Delete
|
||||||
function handleDelete(button) {
|
function handleDelete(button) {
|
||||||
window.location.href = '/item_delete/'+button.name ;
|
lappQueueItemRequest('/item_delete/'+button.name, {
|
||||||
|
coalesceKey: 'item:' + button.name
|
||||||
|
}).then(function (sent) {
|
||||||
|
if (sent) {
|
||||||
|
window.location.href = '/items_append/{{ listid }}';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle AddOne
|
// handle AddOne
|
||||||
function handleAddOne(button) {
|
function handleAddOne(button) {
|
||||||
const response = fetch('/item_addone/'+button.name);
|
lappQueueItemRequest('/item_addone/'+button.name, {
|
||||||
|
coalesceKey: 'item:' + button.name
|
||||||
|
});
|
||||||
|
clearSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle Add with quantity
|
// handle Add with quantity
|
||||||
function handleAddWithQuantity(button, q) {
|
function handleAddWithQuantity(button, q) {
|
||||||
const response = fetch('/item_addquantity/'+button.name+'/'+q);
|
lappQueueItemRequest('/item_addquantity/'+button.name+'/'+q, {
|
||||||
|
coalesceKey: 'item:' + button.name
|
||||||
|
});
|
||||||
|
clearSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
//trigger change category button
|
//trigger change category button
|
||||||
@@ -27,7 +39,9 @@
|
|||||||
// selected the category
|
// selected the category
|
||||||
function clickedUpdateCategory4Item(icon) {
|
function clickedUpdateCategory4Item(icon) {
|
||||||
image_to_update.src="{{ iconpath }}" + icon.name + ".svg"
|
image_to_update.src="{{ iconpath }}" + icon.name + ".svg"
|
||||||
const response = fetch('/item_update_category/'+image_to_update.name+'/'+icon.name);
|
lappQueueItemRequest('/item_update_category/'+image_to_update.name+'/'+icon.name, {
|
||||||
|
coalesceKey: 'category:' + image_to_update.name
|
||||||
|
});
|
||||||
$('#staticBackdrop').modal('hide');
|
$('#staticBackdrop').modal('hide');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +54,11 @@ $(document).ready(function(){
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
$("#myInput").val("");
|
||||||
|
$("#myList tbody tr").show();
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form method="post" action="/items_append/{{ listid }}">
|
<form method="post" action="/items_append/{{ listid }}">
|
||||||
|
|||||||
@@ -35,7 +35,9 @@
|
|||||||
|
|
||||||
// handle Checboc clicks
|
// handle Checboc clicks
|
||||||
function handleCBClick(cb) {
|
function handleCBClick(cb) {
|
||||||
const response = fetch('/item_update/'+cb.name+'/'+cb.checked);
|
lappQueueItemRequest('/item_update/'+cb.name+'/'+cb.checked, {
|
||||||
|
coalesceKey: 'checked:' + cb.name
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -1,9 +1,20 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h3 class="text-center mb-4"><img style="width: 3em; height: 3em" src="/static/login.svg"></h3>
|
<div class="text-center mb-4">
|
||||||
<form method="post">
|
<div
|
||||||
<input class="form-control mb-3" name="name" placeholder="Name" required>
|
class="d-inline-block"
|
||||||
<input class="form-control mb-3" name="password" type="password" placeholder="Password" required>
|
style="width: 6em; height: 6em; background: url('{{ url_for('static', filename='wwwsuynl.png') }}') center / cover no-repeat;"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="{{ url_for('static', filename='login.svg') }}"
|
||||||
|
alt="Login"
|
||||||
|
style="width: 6em; height: 6em;"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" autocomplete="off">
|
||||||
|
<input class="form-control mb-3" name="name" placeholder="{{ name_placeholder }}" autocomplete="off" required>
|
||||||
|
<input class="form-control mb-3" name="password" type="password" placeholder="{{ password_placeholder }}" autocomplete="new-password" required>
|
||||||
<input
|
<input
|
||||||
type="image"
|
type="image"
|
||||||
src="/static/save.svg"
|
src="/static/save.svg"
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import pickle
|
import pickle
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
from secrets_config import load_secrets
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# SECRET KEY (only your program has this)
|
# SECRET KEY (only your program has this)
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# Generate once using: Fernet.generate_key()
|
# The key is generated once and stored outside Git in instance/secrets.yaml.
|
||||||
_SECRET_KEY = b'YFK7QCyTzhyLO4vqrnRxvDAI5uu8mXEYrInEjbRoQgs='
|
_SECRET_KEY = load_secrets(Path(__file__).parent / "instance")["fernet"]["key"].encode("ascii")
|
||||||
|
|
||||||
fernet = Fernet(_SECRET_KEY)
|
fernet = Fernet(_SECRET_KEY)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user