Compare commits

...
12 Commits
Author SHA1 Message Date
ignace 3175067af5 added artwork 2026-08-09 14:45:03 +02:00
ignace 66afdccfee added pwd facility 2026-07-29 19:55:47 +02:00
ignace 7edf8b45cf dont autocomplete logins 2026-07-22 22:07:55 +02:00
ignace 0093d9b20d better login and change pwd 2026-07-22 22:04:00 +02:00
ignace 5aec0a2c2c support for gunicorn 2026-07-22 20:38:10 +02:00
ignace 2dcc7ebbf9 updated model and initialization 2026-07-19 11:29:10 +02:00
ignace 0c03348c88 better secrets admin 2026-07-19 09:58:27 +02:00
ignace 768ccab09f shuffles logo 2026-07-18 23:22:58 +02:00
ignace becd73c810 added logo 2026-07-18 22:58:38 +02:00
ignace d2d89c36e0 clear search after selecting an item 2026-05-14 21:43:42 +02:00
ignace a739218fb0 chatgpt added offline items update 2026-05-14 21:31:33 +02:00
ignace 7e64c5b4cf removed secrets from the code (reom from chatgpt) 2026-05-14 21:16:51 +02:00
20 changed files with 914 additions and 126 deletions
+398 -7
View File
@@ -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.
+2 -2
View File
@@ -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'
+30 -52
View File
@@ -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
View File
@@ -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()
+45
View File
@@ -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
+6 -3
View File
@@ -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)
+2 -8
View File
@@ -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),
+20 -5
View File
@@ -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))
+6
View File
@@ -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
+85
View File
@@ -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
View File
@@ -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

+117
View File
@@ -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

+1
View File
@@ -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 {
+5
View File
@@ -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>
+23 -4
View File
@@ -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 }}">
+3 -1
View File
@@ -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
View File
@@ -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"
+5 -2
View File
@@ -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)