2026-07-22 20:38:10 +02:00
|
|
|
# L@pp
|
2026-01-01 19:58:16 +00:00
|
|
|
|
2026-07-22 20:38:10 +02:00
|
|
|
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.
|
2026-01-01 21:33:12 +01:00
|
|
|
|
2026-07-22 20:38:10 +02:00
|
|
|
Users can create and share lists, add items with quantities and categories, and
|
|
|
|
|
check items off from a phone-friendly interface. The included web app can also
|
|
|
|
|
be installed as a Progressive Web App (PWA).
|
2026-05-14 21:16:51 +02:00
|
|
|
|
2026-07-22 20:38:10 +02:00
|
|
|
## Features
|
2026-05-14 21:16:51 +02:00
|
|
|
|
2026-07-22 20:38:10 +02:00
|
|
|
- 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
|
2026-05-14 21:16:51 +02:00
|
|
|
|
2026-07-22 20:38:10 +02:00
|
|
|
## Requirements
|
2026-05-14 21:16:51 +02:00
|
|
|
|
2026-07-22 20:38:10 +02:00
|
|
|
- Python 3.9 or newer
|
|
|
|
|
- `pip` and Python virtual-environment support
|
2026-05-14 21:16:51 +02:00
|
|
|
|
2026-07-22 20:38:10 +02:00
|
|
|
## Quick start
|
2026-05-14 21:16:51 +02:00
|
|
|
|
2026-07-22 20:38:10 +02:00
|
|
|
Clone the repository and enter its directory:
|
2026-05-14 21:16:51 +02:00
|
|
|
|
2026-07-22 20:38:10 +02:00
|
|
|
```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.
|