rewritten after codex recommendations

This commit is contained in:
2026-07-18 20:27:19 +02:00
parent f9dc885937
commit 399f539b4f
22 changed files with 1448 additions and 996 deletions
+4
View File
@@ -4,3 +4,7 @@ __pycache__/flower_vase.cpython-314.pyc
__pycache__/flowers.cpython-314.pyc __pycache__/flowers.cpython-314.pyc
__pycache__/FlowerServices.cpython-314.pyc __pycache__/FlowerServices.cpython-314.pyc
__pycache__/Log.cpython-314.pyc __pycache__/Log.cpython-314.pyc
__pycache__/
tests/__pycache__/
.pytest_cache/
secrets.yaml
+58 -30
View File
@@ -1,40 +1,68 @@
from Config import * """Small file-backed login throttle.
import pickle, os
import datetime The legacy pickle file is deliberately not read: pickle is unsafe for mutable
runtime files. A JSON document is used instead and malformed files fail closed
to an empty recent-attempt list.
"""
from __future__ import annotations
import json
import os
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from Config import ACCESSFILE
from Log import Log from Log import Log
class Access():
access_list = []
def __init__(self): class Access:
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5) limit = 3
new_list = [] window = timedelta(minutes=5)
if os.path.exists(ACCESSFILE):
with open(ACCESSFILE, 'rb') as pf:
self.access_list = pickle.load(pf)
for entry in self.access_list:
if entry['time'] > five_minutes_ago:
new_list.append(entry)
self.access_list = new_list
def granted(self, ipaddress): def __init__(self, path: str = ACCESSFILE) -> None:
result = 0 self.path = Path(path)
for entry in self.access_list: self.access_list = self._load()
if entry['ip'] == ipaddress:
result += 1
if result<3: def granted(self, ipaddress: str) -> bool:
failures = sum(entry["ip"] == ipaddress for entry in self.access_list)
if failures < self.limit:
return True return True
Log.info(f"Access denied for {ipaddress}")
Log.info("Access denied for {}".format(ipaddress))
self.deny(ipaddress)
return False return False
def deny(self, ipaddress: str) -> None:
self.access_list.append(
{"ip": str(ipaddress), "time": datetime.now(timezone.utc).isoformat()}
)
self._save()
def deny(self, ipaddress): def _load(self) -> list[dict[str, str]]:
now = datetime.datetime.now() cutoff = datetime.now(timezone.utc) - self.window
self.access_list.append({'ip': ipaddress, 'time': now}) try:
with open(ACCESSFILE, 'wb') as pf: raw = json.loads(self.path.read_text(encoding="utf-8"))
pickle.dump(self.access_list, pf, 2) recent = []
for entry in raw:
recorded = datetime.fromisoformat(entry["time"])
if recorded.tzinfo is None:
recorded = recorded.replace(tzinfo=timezone.utc)
if recorded > cutoff:
recent.append({"ip": str(entry["ip"]), "time": recorded.isoformat()})
return recent
except (OSError, ValueError, TypeError, KeyError):
return []
def _save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(
prefix=f".{self.path.name}.", dir=str(self.path.parent), text=True
)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(self.access_list, handle, separators=(",", ":"))
os.chmod(temporary, 0o600)
os.replace(temporary, self.path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
+61 -8
View File
@@ -1,9 +1,62 @@
"""Runtime configuration for Flowers.
LOGFILE = '/tmp/wsgi_flowers.log' Secrets are read from the git-ignored ``secrets.yaml`` file. Environment
DEBUG = 0 variables take precedence, which is useful for container deployments.
MAXTEXTLEN = 70 """
MINFIELDLEN = 3
INFO = 1 import os
DONOTSETFILTER = 'DoNotSetFilterinCookie' import secrets
ACCESSFILE = '/tmp/wsgi_flower_accessfile' from pathlib import Path
URLPREFIX = '' #'/flowers'
import yaml
BASE_DIR = Path(__file__).resolve().parent
SECRETS_FILE = Path(
os.getenv("FLOWERS_SECRETS_FILE", str(BASE_DIR / "secrets.yaml"))
)
def _load_secrets(path: Path) -> dict:
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except FileNotFoundError:
return {}
except (OSError, yaml.YAMLError) as exc:
raise RuntimeError(f"Could not read Flowers secrets file: {path}") from exc
if not isinstance(data, dict):
raise RuntimeError(f"Flowers secrets file must contain a mapping: {path}")
return data
_SECRETS = _load_secrets(SECRETS_FILE)
_FLASK_SECRETS = _SECRETS.get("flask", {})
_DATABASE_SECRETS = _SECRETS.get("database", {})
if not isinstance(_FLASK_SECRETS, dict) or not isinstance(_DATABASE_SECRETS, dict):
raise RuntimeError("The flask and database secrets sections must be mappings")
LOGFILE = os.getenv("FLOWERS_LOG_FILE", "/tmp/wsgi_flowers.log")
DEBUG = os.getenv("FLOWERS_DEBUG", "0") == "1"
INFO = os.getenv("FLOWERS_INFO", "1") == "1"
MAXTEXTLEN = int(os.getenv("FLOWERS_MAX_TEXT_LENGTH", "70"))
MINFIELDLEN = int(os.getenv("FLOWERS_MIN_FIELD_LENGTH", "3"))
DONOTSETFILTER = "DoNotSetFilterinCookie"
ACCESSFILE = os.getenv("FLOWERS_ACCESS_FILE", "/tmp/wsgi_flower_accessfile")
URLPREFIX = os.getenv("FLOWERS_URL_PREFIX", "").rstrip("/")
DB_HOST = os.getenv("FLOWERS_DB_HOST", "localhost")
DB_PORT = int(os.getenv("FLOWERS_DB_PORT", "3306"))
DB_NAME = os.getenv("FLOWERS_DB_NAME", "Flowers")
DB_ADMIN_USER = os.getenv("FLOWERS_DB_ADMIN_USER", "flower")
DB_ADMIN_PASSWORD = os.getenv(
"FLOWERS_DB_ADMIN_PASSWORD", str(_DATABASE_SECRETS.get("admin_password", ""))
)
SESSION_MINUTES = int(os.getenv("FLOWERS_SESSION_MINUTES", "10"))
SECRET_KEY = (
os.getenv("FLOWERS_SECRET_KEY")
or str(_FLASK_SECRETS.get("secret_key", ""))
or secrets.token_hex(32)
)
COOKIE_SECURE = os.getenv("FLOWERS_COOKIE_SECURE", "0") == "1"
+293 -235
View File
@@ -1,269 +1,327 @@
from fernet import Fernet """Database and encryption services for the legacy Flowers data format.
from Log import Log
from Config import * The public ``Flower`` API intentionally remains compatible with the original
application. Existing tables and Fernet ciphertext require no migration.
"""
from __future__ import annotations
import re
from contextlib import contextmanager
from typing import Callable, Iterator
import pymysql as mdb import pymysql as mdb
from fernet import Fernet
#encryption stuff from Config import (
SECRET = b'XBxB603cX_mULEXxfavOg3FDc0Ox3gChwYEY-Uxd3tE=' DB_ADMIN_PASSWORD,
DB_ADMIN_USER,
DB_HOST,
DB_NAME,
DB_PORT,
MINFIELDLEN,
)
from Log import Log
class Flower(): _USERNAME = re.compile(r"^[A-Za-z0-9_]{1,31}$")
def __init__(self, user, pwd):
self.db = 'Flowers' class InvalidCredentials(ValueError):
self.db_username = user + '_' """The supplied username/password cannot address a Flowers vault."""
self.db_password = (pwd*10)[:43]+"="
class StorageError(RuntimeError):
"""A database operation failed."""
def legacy_key(password: str) -> str:
"""Return the exact DB password/Fernet key used by historical releases."""
if not isinstance(password, str) or not password:
raise InvalidCredentials("A password is required")
key = (password * 10)[:43] + "="
try:
Fernet(key.encode("ascii"))
except (UnicodeEncodeError, ValueError) as exc:
raise InvalidCredentials(
"Password must use Base64-compatible characters"
) from exc
return key
def _identifier(username: str) -> str:
if not isinstance(username, str) or not _USERNAME.fullmatch(username):
raise InvalidCredentials(
"Username must contain only letters, numbers, and underscores"
)
return f"{username}_"
class Flower:
"""Read and write one user's existing Flowers table."""
def __init__(
self,
user: str,
pwd: str,
connection_factory: Callable[..., object] | None = None,
) -> None:
self.db = DB_NAME
self.db_username = _identifier(user)
self.db_password = legacy_key(pwd)
self.customer_table = self.db_username self.customer_table = self.db_username
self.fernet = Fernet(bytes(self.db_password, 'ascii')) self._table = f"`{self.customer_table}`"
self.fernet = Fernet(self.db_password.encode("ascii"))
self._connect = connection_factory or mdb.connect
def encode(self, s): @contextmanager
if len(s)==0: def _connection(self) -> Iterator[object]:
return '' connection = None
return self.fernet.encrypt(s.encode()).decode() try:
connection = self._connect(
host=DB_HOST,
port=DB_PORT,
user=self.db_username,
passwd=self.db_password,
db=self.db,
charset="latin1",
)
yield connection
except mdb.Error as exc:
Log.debug(f"Database error: {exc}")
raise StorageError("The vault database is unavailable") from exc
finally:
if connection is not None:
connection.close()
def decode(self, s): def encode(self, value: str) -> str:
if len(s)==0: if not value:
return '' return ""
return self.fernet.decrypt(s.encode()).decode() return self.fernet.encrypt(value.encode("utf-8")).decode("ascii")
def add(self, organization, myname, myid, secret, deleted=0, timestamp=None): def decode(self, value: str) -> str:
''' POST a new flower if not value:
''' return ""
if len(organization)>MINFIELDLEN and len(myid)>MINFIELDLEN and len(secret)>MINFIELDLEN: return self.fernet.decrypt(value.encode("ascii")).decode("utf-8")
sql = 'INSERT INTO {} (organization ,myID ,myName ,mySecret, deleted) VALUES ("{}", "{}", "{}", "{}", {})'.format(self.customer_table, organization, self.encode(myid), self.encode(myname), self.encode(secret), deleted)
if timestamp is not None:
sql = 'INSERT INTO {} (organization ,myID ,myName ,mySecret, deleted, dateCreated) VALUES ("{}", "{}", "{}", "{}", {}, "{}")'.format(self.customer_table, organization, self.encode(myid), self.encode(myname), self.encode(secret), deleted, timestamp)
result, data = self.do_db_commit(sql)
Log.debug(result)
if result==1:
return {'organization': organization, 'dateCreated': 'STORED', 'myID': myid, 'myName': myname,'mySecret': secret}
return {'organization': organization, 'dateCreated': 'FAILED', 'myID': myid, 'myName': myname,'mySecret': secret}
def authenticate(self) -> bool:
try:
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(f"SELECT COUNT(*) FROM {self._table}")
cursor.fetchone()
return True
except (StorageError, ValueError):
return False
def all(self, isdeleted=0): def numberOfEntries(self) -> int:
''' GET flowers list """Compatibility method used by older clients and scripts."""
''' try:
#sql = "SELECT organization, myID, myName, dateCreated FROM %s " % (self.customer_table) with self._connection() as connection:
sql = """ with connection.cursor() as cursor:
select uniqueflowers.organization, uniqueflowers.myID, uniqueflowers.dateCreated from {0} as uniqueflowers cursor.execute(f"SELECT COUNT(*) FROM {self._table}")
inner join ( row = cursor.fetchone()
select organization, max(dateCreated) as lastCreated from {0} group by organization order by dateCreated DESC) allflowers return int(row[0]) if row else 0
on allflowers.organization=uniqueflowers.organization and lastCreated=uniqueflowers.dateCreated where uniqueflowers.deleted={1} except StorageError:
""".format(self.customer_table, isdeleted)
result, data = self.do_db_allrows(sql)
#Log.debug(sql_result)
all_flowers=[]
if result==1:
for row in data:
all_flowers.append({'organization': str(row[0]), 'myID': self.decode(row[1]), 'dateCreated': str(row[2])})
return all_flowers
def one(self, organization, datetimestamp ):
''' flower for user, org and datetime
'''
result = -1
secret = ':-('
sql = "SELECT mySecret, myID, myName FROM %s where organization='%s' and dateCreated='%s' order by dateCreated DESC limit 1" % (self.customer_table, organization, datetimestamp)
result, data = self.do_db_1row(sql)
if result == 1:
return {'organization': organization, 'dateCreated': datetimestamp, 'myID': self.decode(data[1]), 'myName': self.decode(data[2]),'mySecret': self.decode(data[0])}
return {'organization': organization, 'dateCreated': datetimestamp, 'myID': '', 'myName': '','mySecret': '-- Not found --'}
def deactivate(self, organization, datetimestamp ):
''' flower for user, org and datetime
'''
result = -1
secret = ':-('
sql = "update {} set deleted=1 where organization='{}' and dateCreated='{}' ".format(self.customer_table, organization, datetimestamp)
result, data = self.do_db_commit(sql)
if result == 1:
secret = '- DELETED -'
return {'organization': organization, 'dateCreated': datetimestamp, 'myID': '', 'myName': '','mySecret': secret}
def empty(self):
''' empty flower
'''
return {'organization': '', 'dateCreated': '', 'myID': '', 'myName': '','mySecret': ''}
def numberOfEntries(self):
''' GET login info, check if its valid
'''
sql = "SELECT count(*) FROM {};".format(self.customer_table)
result, data = self.do_db_1row(sql)
if result == 1:
return(data[0])
return -1 return -1
def add(
self,
organization: str,
myname: str,
myid: str,
secret: str,
deleted: int = 0,
timestamp=None,
) -> dict:
item = self._item(organization, "FAILED", myid, myname, secret)
if not all(isinstance(v, str) for v in (organization, myname, myid, secret)):
return item
if not (
len(organization) > MINFIELDLEN
and len(myid) > MINFIELDLEN
and len(secret) > MINFIELDLEN
):
return item
def update_pwd(self, new_pwd): columns = "organization, myID, myName, mySecret, deleted"
values = [
organization,
self.encode(myid),
self.encode(myname),
self.encode(secret),
int(bool(deleted)),
]
placeholders = "%s, %s, %s, %s, %s"
if timestamp is not None:
columns += ", dateCreated"
placeholders += ", %s"
values.append(timestamp)
long_newpwd = (new_pwd*10)[:43]+"=" sql = f"INSERT INTO {self._table} ({columns}) VALUES ({placeholders})"
self.new_fernet = Fernet(bytes(long_newpwd, 'ascii')) self._execute_write(sql, values)
return self._item(organization, "STORED", myid, myname, secret)
def new_encode(s): def all(self, isdeleted: int = 0) -> list[dict]:
if len(s)==0: sql = f"""
return '' SELECT latest.organization, latest.myID, latest.dateCreated
return self.new_fernet.encrypt(s.encode()).decode() FROM {self._table} AS latest
INNER JOIN (
SELECT organization, MAX(dateCreated) AS lastCreated
FROM {self._table}
GROUP BY organization
) AS versions
ON versions.organization = latest.organization
AND versions.lastCreated = latest.dateCreated
WHERE latest.deleted = %s
ORDER BY latest.dateCreated DESC, latest.organization ASC
"""
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, (int(bool(isdeleted)),))
rows = cursor.fetchall()
return [
{
"organization": str(row[0]),
"myID": self.decode(row[1]),
"dateCreated": str(row[2]),
}
for row in rows
]
end_result = "ERROR updating hushes\n" def one(self, organization: str, datetimestamp) -> dict:
sql = """ sql = f"""
select myID, myName, mySecret, organization, dateCreated from {0}""".format(self.customer_table) SELECT mySecret, myID, myName
result, data = self.do_db_allrows(sql) FROM {self._table}
# Log.debug(sql_result) WHERE organization = %s AND dateCreated = %s
if result == 1: ORDER BY dateCreated DESC
# convert all hushes LIMIT 1
sql = "" """
for row in data: with self._connection() as connection:
sql += "update {} set myID='{}', myName='{}', mySecret='{}' where organization='{}' and dateCreated='{}';\n".\ with connection.cursor() as cursor:
format(self.customer_table, cursor.execute(sql, (organization, datetimestamp))
new_encode(self.decode(row[0])), row = cursor.fetchone()
new_encode(self.decode(row[1])), if row:
new_encode(self.decode(row[2])),row[3], row[4]) return self._item(
organization,
str(datetimestamp),
self.decode(row[1]),
self.decode(row[2]),
self.decode(row[0]),
)
return self._item(organization, str(datetimestamp), "", "", "-- Not found --")
result, data = self.do_db_commit(sql) def deactivate(self, organization: str, datetimestamp) -> dict:
if result == 1: sql = f"UPDATE {self._table} SET deleted = 1 WHERE organization = %s AND dateCreated = %s"
end_result = "ERROR updating user-password\n" changed = self._execute_write(sql, (organization, datetimestamp))
# update the password of the user message = "- DELETED -" if changed else "-- Not found --"
sql = "SET PASSWORD FOR '{}'@'localhost' = PASSWORD('{}'); ".format(self.db_username, long_newpwd) return self._item(organization, str(datetimestamp), "", "", message)
result, data = self.do_db_commit(sql)
if result == 1:
end_result = "SUCCESSFULLY updated, now login again\n"
return end_result
def empty(self) -> dict:
return self._item("", "", "", "", "")
def update_pwd(self, new_pwd: str) -> str:
"""Re-encrypt every value and update the legacy per-user DB password."""
new_db_password = legacy_key(new_pwd)
new_fernet = Fernet(new_db_password.encode("ascii"))
def do_db_commit(self, sql):
Log.debug('INSc: '+sql)
result = 0
data = ''
con = None
try: try:
con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db); with self._connection() as connection:
cur = con.cursor()
if ";\n" in sql:
for s in sql.split(";\n"):
if len(s)>1:
cur.execute(s)
else:
cur.execute(sql)
cur.close()
con.commit()
result = 1
except mdb.Error as e:
m = "Error %d: %s" % (e.args[0],e.args[1])
data = {'message': m}
Log.debug(m)
result = -1
finally:
if con:
con.close()
return result, data
def do_db_1row(self, sql):
Log.debug('1row sql: '+sql)
result = 0
con = None
try: try:
con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db); with connection.cursor() as cursor:
cur = con.cursor() cursor.execute(
cur.execute(sql) f"SELECT myID, myName, mySecret, organization, dateCreated FROM {self._table}"
data = cur.fetchone() )
if data: rows = cursor.fetchall()
result = 1 update = f"""
except mdb.Error as e: UPDATE {self._table}
m = "Error %d: %s" % (e.args[0],e.args[1]) SET myID = %s, myName = %s, mySecret = %s
data = {'message': m} WHERE organization = %s AND dateCreated = %s
Log.debug(m) """
result = -1 for row in rows:
finally: encrypted = [
if con: new_fernet.encrypt(self.decode(value).encode("utf-8")).decode("ascii")
con.close() if value else ""
return result, data for value in row[:3]
]
cursor.execute(update, (*encrypted, row[3], row[4]))
# This form is supported by the MariaDB versions used by
# historical Flowers installations.
cursor.execute("SET PASSWORD = PASSWORD(%s)", (new_db_password,))
connection.commit()
except Exception:
connection.rollback()
raise
except Exception as exc:
Log.debug(f"Password update failed: {exc}")
return "ERROR updating password"
return "SUCCESSFULLY updated, now login again"
def do_db_allrows(self, sql): def _execute_write(self, sql: str, parameters) -> int:
Log.debug('all rows sql: '+sql) with self._connection() as connection:
result = 0
con = None
try: try:
con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db); with connection.cursor() as cursor:
cur = con.cursor() changed = cursor.execute(sql, parameters)
cur.execute(sql) connection.commit()
data = cur.fetchall() return int(changed)
if data: except Exception:
result = 1 connection.rollback()
except mdb.Error as e: raise
m = "Error %d: %s" % (e.args[0],e.args[1])
data = {'message': m} @staticmethod
Log.debug(m) def _item(organization, timestamp, myid, myname, secret) -> dict:
result = -1 return {
finally: "organization": organization,
if con: "dateCreated": timestamp,
con.close() "myID": myid,
return result, data "myName": myname,
"mySecret": secret,
}
class SuperFlower(Flower): class SuperFlower(Flower):
"""Provision a legacy per-user table and database account."""
def __init__(self, customer, pwd): def __init__(self, customer: str, pwd: str, connection_factory=None) -> None:
super().__init__(customer, pwd) super().__init__(customer, pwd, connection_factory)
self.customer_name = customer + '_' self.customer_name = self.customer_table
self.customer_pwd = (pwd*10)[:43]+"=" self.customer_pwd = self.db_password
self.db_username = DB_ADMIN_USER
self.db_username = 'flower' self.db_password = DB_ADMIN_PASSWORD
self.db_password = '608f0b988db4a96066af7dd8870de96c'
def createNewTable(self):
# create new table and user
sql = """
CREATE TABLE `{}` (
`organization` varchar(80) NOT NULL,
`myID` varchar(255) NOT NULL,
`myName` varchar(255) NOT NULL,
`mySecret` varchar(255) NOT NULL,
`dateCreated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`deleted` int(11) DEFAULT '0',
PRIMARY KEY (`organization`,`dateCreated`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;""".format(self.customer_table)
result, data = self.do_db_1row(sql)
if result < 0:
Log.info("SQL ERROR:")
Log.info(data)
return 0
sql = """create user {}@localhost identified by '{}';""".format(self.customer_name, self.customer_pwd)
result, data = self.do_db_1row(sql)
if result < 0:
Log.info("SQL ERROR:")
Log.info(data)
return 0
sql = """grant select,update,insert on Flowers.{} to {}@localhost;""".format(self.customer_table, self.customer_name)
result, data = self.do_db_1row(sql)
if result < 0:
Log.info("SQL ERROR:")
Log.info(data)
return 0
self.flush_privs()
def createNewTable(self) -> int:
statements = [
f"""CREATE TABLE {self._table} (
organization varchar(80) NOT NULL,
myID varchar(255) NOT NULL,
myName varchar(255) NOT NULL,
mySecret varchar(255) NOT NULL,
dateCreated timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted int(11) DEFAULT 0,
PRIMARY KEY (organization, dateCreated)
) ENGINE=InnoDB DEFAULT CHARSET=latin1""",
f"CREATE USER '{self.customer_name}'@'localhost' IDENTIFIED BY %s",
f"GRANT SELECT, UPDATE, INSERT ON `{self.db}`.{self._table} TO '{self.customer_name}'@'localhost'",
]
try:
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(statements[0])
cursor.execute(statements[1], (self.customer_pwd,))
cursor.execute(statements[2])
connection.commit()
return 1 return 1
except StorageError as exc:
def flush_privs(self): Log.info(f"Could not create vault: {exc}")
sql = """flush privileges;"""
result, data = self.do_db_1row(sql)
if result < 0:
Log.info("SQL ERROR:")
Log.info(data)
return 0 return 0
def flush_privs(self) -> int:
try:
self._execute_write("FLUSH PRIVILEGES", ())
return 1 return 1
except StorageError:
return 0
if __name__ == "__main__":
print("This module is a library.")
if __name__ == '__main__':
print("this is a library, sorry dude")
+22 -15
View File
@@ -1,20 +1,27 @@
from sys import stderr """Compatibility logging facade."""
from Config import *
import logging
from logging.handlers import RotatingFileHandler
from Config import DEBUG, INFO, LOGFILE
_logger = logging.getLogger("flowers")
if not _logger.handlers and (DEBUG or INFO):
try:
handler = RotatingFileHandler(LOGFILE, maxBytes=1_000_000, backupCount=2)
handler.setFormatter(logging.Formatter("%(asctime)s flowers %(levelname)s %(message)s"))
_logger.addHandler(handler)
_logger.setLevel(logging.DEBUG if DEBUG else logging.INFO)
except OSError:
_logger.addHandler(logging.NullHandler())
class Log: class Log:
@staticmethod @staticmethod
def info(someString): def info(message) -> None:
if INFO: _logger.info("%s", message)
f = open(LOGFILE, "a")
f.write( "flower: %s \n" % someString )
f.close()
if DEBUG:
print(someString)
@staticmethod @staticmethod
def debug(someString): def debug(message) -> None:
if DEBUG: _logger.debug("%s", message)
f = open(LOGFILE, "a")
f.write( "flower: %s \n" % someString )
f.close()
+87 -175
View File
@@ -1,230 +1,142 @@
# Flowers # Flowers
Flowers is a small, self-hosted credential vault built with Flask and MariaDB/MySQL. It stores a label, login name, login ID, and secret for each entry, and provides both a server-rendered browser interface and a form-encoded JSON endpoint. Flowers is a small self-hosted credential vault built with Flask and MariaDB/MySQL. This version rewrites the legacy server and browser UI while intentionally preserving its on-disk data contract.
> [!WARNING] ## Existing database compatibility
> This is legacy, personal-use software and is **not production-ready in its current form**. The repository contains hard-coded application/database secrets and committed data exports (`db.sql` and `pins.csv`). The application also builds SQL with string interpolation, has no CSRF protection, and relies on an unusual password-derived encryption scheme. Treat every committed dump as sensitive, rotate any real credentials it contains, and review the [security notes](#security-notes) before exposing the service to a network.
## What it does No data migration is required. The rewritten application continues to use:
- Creates a separate database user and table for each Flowers user. - the `Flowers` database;
- Encrypts the login name, login ID, and secret with Fernet before storing them. - one database account and table named `<username>_` per vault;
- Lists the most recent active record for each organization. - the existing `organization`, `myID`, `myName`, `mySecret`, `dateCreated`, and `deleted` columns;
- Supports searching, viewing, adding, and soft-deleting records. - the historical password transformation `(password * 10)[:43] + "="` for both the database password and Fernet key;
- Keeps older records when a new version of an organization is added. - Fernet ciphertext in the three protected columns;
- Locks an IP address out after three failed logins within five minutes. - append-only edits, where the newest timestamp is the visible version; and
- Exposes the same core operations through `POST /app` for a companion client. - `deleted = 1` for deactivation.
The included `client/Daisy.py` is only a Kivy “Hello world” scaffold; it is not a working Flowers client. The old form-encoded `POST /app` endpoint is also retained. Its `list` and `flower` properties remain JSON strings inside the outer JSON response for existing client compatibility.
## How the application is organized The legacy key derivation is preserved only because changing it would make existing data unreadable. It is not a modern password KDF. A future KDF upgrade requires an explicit, tested data migration rather than an in-place code change.
```text ## What changed
flowers.py Flask application factory
flower_vase.py Browser routes and the form-based JSON endpoint
FlowerServices.py Fernet encryption and MariaDB/MySQL operations
AccessControl.py File-backed failed-login throttling
Config.py Paths, limits, logging, and URL prefix
templates/ Jinja browser interface
static/ CSS, JavaScript, fonts, and images
flowers.wsgi Legacy mod_wsgi entry point (currently stale)
apache.conf Legacy Apache/mod_wsgi example
db.sql, pins.csv Historical data exports; sensitive, not seed data
Services.py,
SecretServices.py Older AES-based implementations retained for migration
```
### Data model - All stored values are passed to MariaDB as query parameters.
- Usernames are validated before being used as table/account identifiers.
Each application username maps to: - Database and runtime settings can be supplied through environment variables.
- Browser forms have CSRF protection.
- a database account named `<username>_`; - Database credentials are stored in server memory behind an opaque session token, not in Flask's signed browser cookie.
- a table named `<username>_` in the `Flowers` database; and - Browser sessions expire after ten minutes of inactivity.
- a database password/Fernet key derived by repeating the application password and truncating it to 43 characters, then appending `=`. - Failed-login state is JSON rather than unsafe pickle data.
- The browser interface is responsive and no longer depends on jQuery, Pure CSS, or remote assets.
Each table contains: - The Flask application factory and WSGI entry point both work.
- Compatibility tests cover legacy encryption, reads, writes, listing, and the old API format.
| Column | Purpose |
| --- | --- |
| `organization` | Plain-text label used to group versions of an entry |
| `myID` | Fernet-encrypted login ID |
| `myName` | Fernet-encrypted display/login name |
| `mySecret` | Fernet-encrypted secret |
| `dateCreated` | Creation time and version identifier |
| `deleted` | Soft-delete flag |
The organization and timestamp are not encrypted. “Editing” an item inserts a new row, so the latest timestamp becomes the visible version. Deactivation marks a selected row as deleted.
## Requirements ## Requirements
- Python 3 - Python 3.10 or newer
- MariaDB or MySQL running on `localhost` - MariaDB or MySQL on the configured host
- Python packages: Flask, Waitress, `fernet`, PyMySQL, and pytz - the packages in `requirements.txt`
- Apache with `mod_proxy` only if using the optional reverse-proxy setup
No dependency lock file is included, so the project does not currently define tested package or Python version ranges.
## Local setup
### 1. Create a virtual environment
```bash ```bash
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install flask waitress fernet pymysql pytz pip install -r requirements.txt
``` ```
The code imports `Fernet` from the third-party `fernet` package, not from `cryptography.fernet`. ## Configuration
### 2. Bootstrap MariaDB/MySQL Local secrets are loaded from the git-ignored `secrets.yaml` file:
The application expects a database named `Flowers` and a provisioning account named `flower`. With the repository's current defaults, an administrator can create them with: ```yaml
flask:
secret_key: "a-long-random-value"
database:
admin_password: "the-provisioning-account-password"
```
Use `secrets.example.yaml` as a template. `FLOWERS_SECRETS_FILE` can point to a different file. Secret environment variables take precedence over YAML, which is useful for containers and managed deployments.
The other settings remain environment-based:
| Environment variable | Default |
| --- | --- |
| `FLOWERS_SECRETS_FILE` | `secrets.yaml` beside the application |
| `FLOWERS_SECRET_KEY` | overrides `flask.secret_key` from YAML |
| `FLOWERS_DB_HOST` | `localhost` |
| `FLOWERS_DB_PORT` | `3306` |
| `FLOWERS_DB_NAME` | `Flowers` |
| `FLOWERS_DB_ADMIN_USER` | `flower` |
| `FLOWERS_DB_ADMIN_PASSWORD` | overrides `database.admin_password` from YAML |
| `FLOWERS_URL_PREFIX` | empty |
| `FLOWERS_SESSION_MINUTES` | `10` |
| `FLOWERS_COOKIE_SECURE` | `0`; set to `1` behind HTTPS |
| `FLOWERS_ACCESS_FILE` | `/tmp/wsgi_flower_accessfile` |
| `FLOWERS_LOG_FILE` | `/tmp/wsgi_flowers.log` |
For example:
```bash
waitress-serve --threads=6 --host=127.0.0.1 --port=5012 --call flowers:create_app
```
The provisioning account is needed only by the web-based `/create` flow. Existing vault reads and writes connect with their existing per-user database accounts.
## Database bootstrap for a new installation
An existing Flowers database should be left untouched. For a new installation, create the database and provisioning user, replacing the example password:
```sql ```sql
CREATE DATABASE Flowers; CREATE DATABASE Flowers;
CREATE USER 'flower'@'localhost' CREATE USER 'flower'@'localhost' IDENTIFIED BY 'replace-this';
IDENTIFIED BY '608f0b988db4a96066af7dd8870de96c';
GRANT CREATE, SELECT, UPDATE, INSERT ON Flowers.* GRANT CREATE, SELECT, UPDATE, INSERT ON Flowers.*
TO 'flower'@'localhost' WITH GRANT OPTION; TO 'flower'@'localhost' WITH GRANT OPTION;
GRANT CREATE USER, RELOAD ON *.* TO 'flower'@'localhost'; GRANT CREATE USER, RELOAD ON *.* TO 'flower'@'localhost';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
``` ```
That password is hard-coded in `SuperFlower` in `FlowerServices.py`. Change it in both the database and the code before real use. Do **not** import `db.sql` as ordinary sample data: it is a historical dump containing user-specific encrypted records. Put the same value in `database.admin_password` in `secrets.yaml` before starting Flowers.
### 3. Select the URL prefix ## Run
Set `URLPREFIX` in `Config.py`: Development:
```python
URLPREFIX = '' # serve at http://127.0.0.1:5012/
# URLPREFIX = '/flowers' # serve below /flowers
```
### 4. Start the server
For local development:
```bash ```bash
python flowers.py python flowers.py
``` ```
For a non-debug application server: Production application server:
```bash ```bash
waitress-serve --threads=6 --host=127.0.0.1 --port=5012 --call flowers:create_app waitress-serve --threads=6 --host=127.0.0.1 --port=5012 --call flowers:create_app
``` ```
Open the configured base URL in a browser. To create the first vault user, visit `/create` under that base URL—for example, `http://127.0.0.1:5012/create` when `URLPREFIX` is empty. After creation, return to the home page and log in. Keep the service behind HTTPS. If it is mounted under `/flowers`, set `FLOWERS_URL_PREFIX=/flowers` before starting it.
The creation form limits passwords to 10 characters. Because the password is also transformed directly into a Fernet key, use only characters valid in URL-safe Base64 (`A-Z`, `a-z`, `0-9`, `-`, and `_`) with the current implementation. ## Tests
## Browser routes The tests do not need a live database; they use the actual legacy Fernet implementation with a MariaDB-compatible fake connection.
All routes are relative to `URLPREFIX`.
| Route | Methods | Purpose |
| --- | --- | --- |
| `/` | GET | Login page |
| `/browser` | GET, POST | Login and browser actions (`list`, `show`, `new`, `save`, `deactivate`, `logout`) |
| `/create` | GET, POST | Create a database user, table, and initial demo entry |
| `/update_pwd` | GET, POST | Re-encrypt all records and change the database password |
| `/app` | POST | Form-encoded programmatic interface returning JSON text |
Browser sessions expire after 10 minutes of inactivity. Failed logins are recorded in `ACCESSFILE`; three failures from one IP within five minutes cause temporary denial.
## Programmatic endpoint
`POST /app` accepts `application/x-www-form-urlencoded` fields. Every request includes:
- `action`: `login`, `list`, `one`, `save`, or `deactivate`;
- `name`: Flowers username; and
- `password`: Flowers password.
Additional fields depend on the action:
| Action | Additional fields |
| --- | --- |
| `list` | none |
| `one` | `organization`, `dateCreated` |
| `save` | `organization`, `myname`, `myid`, `secret` |
| `deactivate` | `organization`, `dateCreated` |
Example login request:
```bash ```bash
curl -X POST http://127.0.0.1:5012/app \ pip install -r requirements-dev.txt
--data-urlencode 'action=login' \ python -m pytest
--data-urlencode 'name=demo' \
--data-urlencode 'password=replace-me'
``` ```
A typical response is: They can also run using only the standard library test runner:
```json
{"result": 1, "message": "Access granted"}
```
For historical compatibility, the `list` and `flower` properties are JSON-encoded strings inside the outer JSON response, so clients must decode those properties a second time. The endpoint catches all exceptions and may return only the generic `{"result": -1, "message": "Invalid entry"}` response when an internal error occurs.
## Apache reverse proxy
Waitress can remain bound to localhost while Apache publishes the `/flowers` path. Set `URLPREFIX = '/flowers'`, enable the proxy modules, and add the proxy rules to the relevant virtual host:
```bash ```bash
sudo a2enmod proxy proxy_http python -m unittest discover -s tests -v
``` ```
```apache ## Remaining security constraints
ProxyPass "/flowers" "http://127.0.0.1:5012/flowers"
ProxyPassReverse "/flowers" "http://127.0.0.1:5012/flowers"
```
Then run Waitress with the command shown above and configure HTTPS on Apache. The checked-in `apache.conf` and `flowers.wsgi` describe an older mod_wsgi deployment; `flowers.wsgi` imports an `app` object that no longer exists, so use the application factory or update that file before relying on mod_wsgi. The rewrite removes the immediately exploitable SQL interpolation and browser-cookie credential storage, but Flowers remains a small personal vault with a legacy storage design:
## Configuration - the historical password-derived Fernet key is intentionally still weak;
- the web process still has provisioning privileges if `/create` is enabled;
- the in-memory credential store is suitable for one application process, not a multi-process cluster; and
- rate-limit state is local to one host.
`Config.py` contains the available settings: Use a strong unique password made only of Base64-compatible characters, bind Waitress to localhost, and expose it only through an authenticated HTTPS reverse proxy. Set stable provisioning and Flask secrets before deployment.
| Setting | Default purpose | The old `Services.py`, `SecretServices.py`, and ad-hoc root-level scripts are retained as historical migration references. Do not run them against production data without reviewing them first.
| --- | --- |
| `LOGFILE` | Application log path (`/tmp/wsgi_flowers.log`) |
| `DEBUG` | Enables debug messages and debug logging when non-zero |
| `INFO` | Enables informational file logging when non-zero |
| `MAXTEXTLEN` | Intended maximum text length used by the UI/domain code |
| `MINFIELDLEN` | Minimum accepted length for organization, login ID, and secret |
| `ACCESSFILE` | Pickle file used for failed-login throttling |
| `DONOTSETFILTER` | Sentinel used by the browser search/filter form |
| `URLPREFIX` | Blueprint prefix, such as `''` or `'/flowers'` |
The Flask session signing key is currently hard-coded in `flowers.py`. Configuration is not loaded from environment variables.
## Tests and maintenance scripts
There is currently no automated test suite. A syntax-only check for the primary modules can be run without a database:
```bash
python -m py_compile \
flowers.py flower_vase.py FlowerServices.py \
AccessControl.py Config.py Log.py
```
The files `test.py`, `test_fernet.py`, `import.py`, and `dumpl.py` are ad-hoc scripts with embedded usernames/passwords or destructive/database-dependent behavior. Read and edit them before running them; they are not safe, isolated unit tests.
## Security notes
Before any serious deployment, at minimum:
2. Move the Flask secret key, provisioning database password, database name/host, and filesystem paths to environment-based configuration.
3. Replace string-formatted SQL with parameterized queries and validate table/account identifiers.
4. Replace the current password-to-Fernet-key construction with a password KDF such as Argon2id, scrypt, or PBKDF2 using a unique salt and appropriate work factor.
5. Add CSRF protection, secure cookie settings, explicit error handling, and tests for authentication and authorization.
6. Run only behind HTTPS and restrict access at the firewall or reverse proxy.
7. Rework user provisioning so the web process does not hold `CREATE USER`, `RELOAD`, and grant privileges during normal operation.
8. Replace the pickle-backed access-control file with a safe, concurrency-aware rate limiter.
Fernet protects stored field contents from casual database inspection, but it does not compensate for weak/reused passwords, exposed keys, SQL injection, a compromised application host, or unencrypted transport.
## License
No license file is currently included. Unless a license is added, normal copyright restrictions apply.
+310 -184
View File
@@ -1,202 +1,328 @@
from flask import Flask, Blueprint, send_from_directory """HTTP routes for the Flowers browser UI and legacy form API."""
from Config import *
import sys from __future__ import annotations
import datetime
import pytz
from FlowerServices import Flower,SuperFlower
from AccessControl import Access
import json import json
from flask import Flask, request, session, g, redirect, url_for, abort, render_template, flash, json, make_response import secrets
from werkzeug.exceptions import HTTPException import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
# configuration from flask import (
# DEBUG = False Blueprint,
# SECRET_KEY = 'development key' abort,
flower_bp = Blueprint("flower_vase", __name__, url_prefix=URLPREFIX) current_app,
flash,
jsonify,
redirect,
render_template,
request,
session,
url_for,
)
from AccessControl import Access
from Config import DONOTSETFILTER, SESSION_MINUTES, URLPREFIX
from FlowerServices import Flower, InvalidCredentials, StorageError, SuperFlower
# app = Flask(__name__) flower_bp = Blueprint(
# app.config.from_object(__name__) "flower_vase",
__name__,
url_prefix=URLPREFIX,
static_folder="static",
static_url_path="/static",
template_folder="templates",
)
@flower_bp.route('/static/<filename>')
def statix(filename):
return send_from_directory('static', filename)
@flower_bp.route('/') @dataclass
class _Credential:
username: str
password: str
expires_at: datetime
class CredentialStore:
"""Keep database credentials server-side instead of in Flask's cookie."""
def __init__(self) -> None:
self._items: dict[str, _Credential] = {}
self._lock = threading.Lock()
def create(self, username: str, password: str) -> str:
token = secrets.token_urlsafe(32)
with self._lock:
self._purge()
self._items[token] = _Credential(username, password, self._deadline())
return token
def get(self, token: str | None) -> tuple[str, str] | None:
if not token:
return None
with self._lock:
self._purge()
credential = self._items.get(token)
if credential is None:
return None
credential.expires_at = self._deadline()
return credential.username, credential.password
def revoke(self, token: str | None) -> None:
if token:
with self._lock:
self._items.pop(token, None)
def _purge(self) -> None:
now = datetime.now(timezone.utc)
expired = [key for key, value in self._items.items() if value.expires_at <= now]
for key in expired:
del self._items[key]
@staticmethod
def _deadline() -> datetime:
return datetime.now(timezone.utc) + timedelta(minutes=SESSION_MINUTES)
def _credentials() -> CredentialStore:
return current_app.extensions["credential_store"]
def _csrf_token() -> str:
token = session.get("csrf_token")
if not token:
token = secrets.token_urlsafe(32)
session["csrf_token"] = token
return token
@flower_bp.context_processor
def template_context() -> dict:
return {"csrf_token": _csrf_token()}
@flower_bp.before_request
def protect_browser_posts():
if request.method != "POST" or request.endpoint == "flower_vase.web_service":
return None
supplied = request.form.get("csrf_token", "")
expected = session.get("csrf_token", "")
if not expected or not secrets.compare_digest(supplied, expected):
abort(400, description="Invalid or expired form token")
return None
def _vault_from_session() -> Flower | None:
credential = _credentials().get(session.get("auth_token"))
if not credential:
return None
try:
return Flower(*credential)
except InvalidCredentials:
return None
def _login_page(name="Welcome back", password="Enter your vault password", status=200):
return render_template(
"index.html", name_suggestion=name, pwd_suggestion=password
), status
@flower_bp.get("/")
def index(): def index():
print(request.remote_addr) return _login_page()
return render_template('index.html', name_suggestion="Hello", pwd_suggestion="Want to try your luck?")
@flower_bp.route('/browser' , methods=['POST', 'GET'])
@flower_bp.route("/browser", methods=["GET", "POST"])
def application(): def application():
now = datetime.datetime.now(pytz.timezone('Europe/Amsterdam')) if request.method == "GET":
vault = _vault_from_session()
if vault is None:
return redirect(url_for("flower_vase.index"))
try:
selected_filter = session.get("filter", "")
return render_template(
"list.html", flowers=vault.all(), filter=selected_filter
)
except StorageError:
flash("The vault database could not complete that request.", "error")
return redirect(url_for("flower_vase.index"))
if request.method == 'GET': access = Access()
return render_template('index.html', name_suggestion="Faded out", pwd_suggestion="Want to retry your luck?") client_ip = request.remote_addr or "unknown"
if not access.granted(client_ip):
return _login_page("Too many attempts", "Try again in five minutes", 429)
access_ctrl = Access() action = request.form.get("action", "")
if not access_ctrl.granted(request.remote_addr): if action == "login":
return render_template('index.html', name_suggestion="Sorry", pwd_suggestion="Access denied") return _login(access, client_ip)
if action == "logout":
_credentials().revoke(session.pop("auth_token", None))
session.pop("filter", None)
flash("You have been signed out.", "success")
return redirect(url_for("flower_vase.index"))
action=request.form['action'] vault = _vault_from_session()
filter='' if vault is None:
if 'filter' in session: session.pop("auth_token", None)
filter = session['filter'] flash("Your session expired. Please sign in again.", "error")
return redirect(url_for("flower_vase.index"))
if action == 'logout': selected_filter = session.get("filter", "")
session['filter'] = request.form['filter'] form_filter = request.form.get("filter", "")
session['timeout'] = now - datetime.timedelta(minutes=999) if len(form_filter) > 1 and form_filter != DONOTSETFILTER:
return render_template('index.html', name_suggestion="Bye", pwd_suggestion="Come back any time") selected_filter = form_filter
session["filter"] = selected_filter
if action == 'login':
user=request.form['name']
pwd=request.form['password']
F = Flower(user, pwd)
if F.numberOfEntries()>=0:
session['dbuser'] = user
session['dbpwd'] = pwd
session['timeout'] = now
session['filter'] = filter
return render_template('list.html', flowers=F.all(), filter=filter)
#login failure
access_ctrl.deny(request.remote_addr)
print("Flowers - authorization failed for {}".format(user))
return render_template('index.html', name_suggestion="Sorry", pwd_suggestion="Better next time")
if 'timeout' in session and session['timeout']+datetime.timedelta(minutes=10)>now:
session['timeout'] = now
if 'filter' in request.form.keys() and len(request.form['filter'])>1 and request.form['filter'] != DONOTSETFILTER:
session['filter']=request.form['filter']
filter = request.form['filter']
F = Flower(session['dbuser'], session['dbpwd'])
if F.numberOfEntries()>=0:
if action == 'list':
return render_template('list.html', flowers=F.all(), filter=filter)
if action == 'show':
return render_template('show.html', flower=F.one(request.form['organization'], request.form['datetime']))
if action == 'edit':
return render_template('edit.html', flower=F.one(request.form['organization'], request.form['datetime']))
if action == 'new':
return render_template('edit.html', flower=F.empty())
if action == 'save':
flower = F.add(request.form['organization'],request.form['myname'],request.form['myid'],request.form['secret'])
session['filter']=request.form['organization']
return render_template('show.html', flower=flower)
if action == 'deactivate':
flower = F.deactivate(request.form['organization'],request.form['datetime'])
session['filter']=''
return render_template('show.html', flower=flower)
if action == 'rehush':
return render_template('rehush.html', flower=F.empty())
else:
return render_template('new.html', flower=F.empty())
return render_template('index.html', name_suggestion="Sorry", pwd_suggestion="Better next time")
@flower_bp.route('/create' , methods=['POST','GET'])
def create():
access_ctrl = Access()
if not access_ctrl.granted(request.remote_addr):
return render_template('index.html', name_suggestion="Sorry", pwd_suggestion="Access denied")
if 'name' not in request.form:
return render_template('create.html')
newuser = request.form['name']
newhush = request.form['password']
# login db with generic user
f = SuperFlower(newuser, newhush)
if f.createNewTable():
# login as new user
u = Flower(newuser, newhush)
# add one line
u.add('Demo organisation', 'Demo name', 'Demo login', 'Demo hush')
# return to the login page
return render_template('index.html', name_suggestion="Now login", pwd_suggestion="for your very first time")
return render_template('create.html', message="Sorry - System error - check log files")
@flower_bp.route('/update_pwd', methods=['POST','GET'])
def update_pwd():
if 'name' not in request.form:
return render_template('rehush.html')
user = request.form['name']
oldhush = request.form['old_password']
newhush = request.form['new_password']
# login db with generic user
f = Flower(user, oldhush)
message = f.update_pwd(newhush)
f = SuperFlower(user, oldhush)
f.flush_privs()
return render_template('index.html', name_suggestion=message, pwd_suggestion="...")
# @app.route('/migrate', methods=['POST','GET'])
# def migrate():
# user = 'ignace'
# hush = 'black'
#
# f = SuperFlower(user, hush)
# f.migrate(user)
#
# return render_template('index.html', name_suggestion='login again', pwd_suggestion="...")
@flower_bp.route('/app' , methods=['POST'])
def web_service():
# same thing as 'aplication, but returns go in json
result = json.dumps({'result': -1, 'message': 'Invalid entry'})
try: try:
access_ctrl = Access() if action == "list":
if not access_ctrl.granted(request.remote_addr): return render_template("list.html", flowers=vault.all(), filter=selected_filter)
result = json.dumps({'result': -1, 'message': 'Access denied'}) if action in {"show", "edit"}:
flower = vault.one(
request.form.get("organization", ""), request.form.get("datetime", "")
)
return render_template(f"{action}.html", flower=flower, filter=selected_filter)
if action == "new":
return render_template("edit.html", flower=vault.empty(), filter=selected_filter)
if action == "save":
flower = vault.add(
request.form.get("organization", "").strip(),
request.form.get("myname", "").strip(),
request.form.get("myid", "").strip(),
request.form.get("secret", ""),
)
if flower["dateCreated"] == "FAILED":
flash("Organization, login, and secret must each be at least four characters.", "error")
return render_template("edit.html", flower=flower, filter=selected_filter), 422
session["filter"] = flower["organization"]
flash("Entry saved as a new version.", "success")
return render_template("show.html", flower=flower, filter=flower["organization"])
if action == "deactivate":
flower = vault.deactivate(
request.form.get("organization", ""), request.form.get("datetime", "")
)
session["filter"] = ""
flash("Entry removed from the active list.", "success")
return render_template("show.html", flower=flower, filter="")
if action == "rehush":
return render_template("rehush.html")
except StorageError:
flash("The vault database could not complete that request.", "error")
return redirect(url_for("flower_vase.index"))
else: abort(400, description="Unknown action")
#no session variables here - the client will take care of fileter and timeout
action = request.form['action']
user = request.form['name']
pwd = request.form['password']
F = Flower(user, pwd)
if F.numberOfEntries() == -1:
#login failure
access_ctrl.deny(request.remote_addr)
result = json.dumps({'result': 0, 'message': 'Invalid username or password'})
elif action == 'login':
result = json.dumps({'result': 1, 'message': 'Access granted'})
elif action == 'list':
result = json.dumps({'result': 1, 'message': 'Ok', 'list': json.dumps(F.all())})
elif action == 'one':
result = json.dumps({'result': 1, 'message': 'Ok', 'flower': json.dumps( F.one(request.form['organization'], request.form['dateCreated']) )})
elif action == 'save':
result = json.dumps({'result': 1, 'message': 'Ok', 'flower': json.dumps(F.add(request.form['organization'],request.form['myname'],request.form['myid'],request.form['secret']) )})
elif action == 'deactivate':
result = json.dumps({'result': 1, 'message': 'Ok', 'flower': json.dumps(F.deactivate(request.form['organization'], request.form['dateCreated']))})
except:
pass
return result
def _login(access: Access, client_ip: str):
username = request.form.get("name", "").strip()
password = request.form.get("password", "")
try:
vault = Flower(username, password)
authenticated = vault.authenticate()
except InvalidCredentials:
authenticated = False
if not authenticated:
access.deny(client_ip)
return _login_page("Sign-in failed", "Check your username and password", 401)
old_token = session.get("auth_token")
_credentials().revoke(old_token)
session.clear()
session["csrf_token"] = secrets.token_urlsafe(32)
session["auth_token"] = _credentials().create(username, password)
session["filter"] = ""
return redirect(url_for("flower_vase.application"))
@flower_bp.route("/create", methods=["GET", "POST"])
def create():
if request.method == "GET":
return render_template("create.html")
access = Access()
client_ip = request.remote_addr or "unknown"
if not access.granted(client_ip):
return _login_page("Too many attempts", "Try again later", 429)
username = request.form.get("name", "").strip()
password = request.form.get("password", "")
try:
provisioner = SuperFlower(username, password)
created = provisioner.createNewTable()
except InvalidCredentials as exc:
return render_template("create.html", message=str(exc)), 422
if created:
Flower(username, password).add(
"Demo organisation", "Demo name", "Demo login", "Demo secret"
)
flash("Your vault is ready. Sign in to continue.", "success")
return redirect(url_for("flower_vase.index"))
return render_template(
"create.html", message="The vault could not be created. Check the server log."
), 500
@flower_bp.route("/update_pwd", methods=["GET", "POST"])
def update_pwd():
if request.method == "GET":
return render_template("rehush.html")
try:
vault = Flower(
request.form.get("name", "").strip(),
request.form.get("old_password", ""),
)
if not vault.authenticate():
raise InvalidCredentials("Current credentials are not valid")
message = vault.update_pwd(request.form.get("new_password", ""))
except InvalidCredentials as exc:
return render_template("rehush.html", message=str(exc)), 422
_credentials().revoke(session.pop("auth_token", None))
flash(message, "success" if message.startswith("SUCCESS") else "error")
return redirect(url_for("flower_vase.index"))
@flower_bp.post("/app")
def web_service():
"""Preserve the historical form API, including double-encoded payloads."""
access = Access()
client_ip = request.remote_addr or "unknown"
if not access.granted(client_ip):
return jsonify(result=-1, message="Access denied")
action = request.form.get("action", "")
username = request.form.get("name", "")
password = request.form.get("password", "")
try:
vault = Flower(username, password)
if not vault.authenticate():
access.deny(client_ip)
return jsonify(result=0, message="Invalid username or password")
if action == "login":
return jsonify(result=1, message="Access granted")
if action == "list":
return jsonify(result=1, message="Ok", list=json.dumps(vault.all()))
if action == "one":
item = vault.one(
request.form.get("organization", ""),
request.form.get("dateCreated", ""),
)
return jsonify(result=1, message="Ok", flower=json.dumps(item))
if action == "save":
item = vault.add(
request.form.get("organization", ""),
request.form.get("myname", ""),
request.form.get("myid", ""),
request.form.get("secret", ""),
)
return jsonify(result=1, message="Ok", flower=json.dumps(item))
if action == "deactivate":
item = vault.deactivate(
request.form.get("organization", ""),
request.form.get("dateCreated", ""),
)
return jsonify(result=1, message="Ok", flower=json.dumps(item))
except (InvalidCredentials, StorageError, ValueError):
current_app.logger.exception("Flowers API request failed")
return jsonify(result=-1, message="Invalid entry")
+19 -8
View File
@@ -1,16 +1,27 @@
"""Flowers application factory."""
from flask import Flask from flask import Flask
from flower_vase import flower_bp
from Config import COOKIE_SECURE, DEBUG, SECRET_KEY
from flower_vase import CredentialStore, flower_bp
def create_app(): def create_app(test_config: dict | None = None) -> Flask:
app = Flask(__name__) app = Flask(__name__)
app.config["SECRET_KEY"] = 'adhf adsh 8347y92347rupqo;wjf cowuyergc9b24387ryx1 -923pqr pqwejf qy7i34' app.config.from_mapping(
SECRET_KEY=SECRET_KEY,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=COOKIE_SECURE,
MAX_CONTENT_LENGTH=64 * 1024,
)
if test_config:
app.config.update(test_config)
app.extensions["credential_store"] = CredentialStore()
app.register_blueprint(flower_bp) app.register_blueprint(flower_bp)
return app return app
if __name__ == '__main__': if __name__ == "__main__":
my_app = create_app() create_app().run(debug=DEBUG, host="127.0.0.1", port=5012)
# if needed - initialize data
my_app.run(debug=True, host='127.0.0.1', port=5012)
+4 -2
View File
@@ -1,7 +1,9 @@
#!/usr/bin/python #!/usr/bin/python
import sys import sys
sys.path.insert(0, '/usr/share/projects/wsgi_apps/flowers')
from flowers import app as application sys.path.insert(0, "/usr/share/projects/wsgi_apps/flowers")
from flowers import create_app
application = create_app()
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0,<10
+5
View File
@@ -0,0 +1,5 @@
Flask>=3.1,<4
fernet>=1.0,<2
PyMySQL>=1.1,<3
PyYAML>=6.0,<7
waitress>=3.0,<4
+6
View File
@@ -0,0 +1,6 @@
# Copy this file to secrets.yaml and replace both values.
flask:
secret_key: "replace-with-a-long-random-value"
database:
admin_password: "replace-with-the-flower-database-user-password"
+108 -22
View File
@@ -1,31 +1,117 @@
:root {
tr.flowerlist:hover td { --bg: #0b1010;
background:#eee; --panel: #131b1a;
cursor: pointer; --panel-2: #182321;
--line: #293735;
--text: #f3f4ec;
--muted: #98a6a1;
--accent: #e5ff78;
--accent-ink: #172000;
--danger: #ff8e85;
--radius: 18px;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
color: var(--text);
background: var(--bg);
font-synthesis: none;
} }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 12% 0%, #172522 0, transparent 35rem), var(--bg); }
button, input { font: inherit; }
a { color: inherit; }
.flowerlist { .site-header { min-height: 76px; padding: 16px clamp(20px, 5vw, 72px); display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); }
width: 100%; .brand { display: inline-flex; align-items: center; gap: 10px; font: 600 1.35rem Georgia, serif; text-decoration: none; }
} .brand-mark { display: grid; place-items: center; width: 34px; height: 34px; color: var(--accent); border: 1px solid #4c5b31; border-radius: 50%; }
.nav-actions { display: flex; align-items: center; gap: 8px; }
.nav-actions form { margin: 0; }
.text-link { color: var(--muted); text-decoration: none; font-size: .92rem; }
.text-link:hover { color: var(--text); }
tr.flowerlist a { .page-shell { width: min(1120px, calc(100% - 40px)); margin: 0 auto; padding: clamp(56px, 9vw, 112px) 0; min-height: calc(100vh - 150px); }
color: inherit; /* blue colors for links too */ footer { padding: 24px; color: #65716e; text-align: center; font-size: .8rem; }
text-decoration: inherit; /* no underline */ h1, h2, p { margin-top: 0; }
} h1 { margin-bottom: 18px; font: 600 clamp(2.5rem, 7vw, 5.6rem)/.96 Georgia, serif; letter-spacing: -.045em; }
h2 { font: 600 1.55rem Georgia, serif; }
.eyebrow { margin-bottom: 14px; color: var(--accent); font-size: .74rem; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; }
.muted, .intro p { color: var(--muted); line-height: 1.7; }
td.white { .auth-layout { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(320px, 430px); align-items: center; gap: clamp(44px, 9vw, 120px); }
color: #eee .intro { max-width: 620px; }
} .intro p:not(.eyebrow) { max-width: 520px; font-size: 1.05rem; }
.card { background: linear-gradient(145deg, rgba(27, 39, 37, .94), rgba(16, 23, 22, .98)); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: 0 26px 80px rgba(0, 0, 0, .25); }
.form-card { display: flex; flex-direction: column; padding: clamp(28px, 5vw, 44px); }
.form-card label { margin: 18px 0 8px; color: #c8cfcb; font-size: .82rem; font-weight: 600; }
.form-card label span { color: var(--muted); font-weight: 400; }
input { width: 100%; padding: 14px 15px; color: var(--text); background: #0d1413; border: 1px solid #344340; border-radius: 10px; outline: none; }
input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(229, 255, 120, .1); }
input::placeholder { color: #61706c; }
.form-card .button { margin-top: 26px; }
.field-help { margin: 8px 0 0; color: var(--muted); font-size: .76rem; line-height: 1.5; }
.field-error { padding: 10px 12px; color: #ffd0cc; background: #3b1d1b; border-radius: 8px; font-size: .85rem; }
td.left { .button, .icon-button { border: 0; cursor: pointer; }
text-align: left; .button { display: inline-flex; justify-content: center; align-items: center; min-height: 46px; padding: 0 19px; border-radius: 10px; font-weight: 700; }
} .button.primary { color: var(--accent-ink); background: var(--accent); }
.button.primary:hover { background: #efffa9; transform: translateY(-1px); }
.button.ghost { color: var(--text); background: transparent; border: 1px solid var(--line); }
.button.ghost:hover { background: var(--panel-2); }
.button.compact { min-height: 38px; padding: 0 14px; font-size: .82rem; }
.button.danger { color: #2c0906; background: var(--danger); }
td.right { .notices { position: fixed; z-index: 10; top: 88px; right: 24px; display: grid; gap: 8px; }
text-align: right; .notice { max-width: 380px; padding: 12px 16px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 12px 35px #0008; font-size: .86rem; }
} .notice-success { border-color: #53642f; }
.notice-error { border-color: #713b36; }
td.blue { .vault-header { display: flex; justify-content: space-between; align-items: end; gap: 32px; margin-bottom: 38px; }
color: #208dd6; .vault-header h1 { margin: 0; font-size: clamp(2.5rem, 6vw, 4.5rem); }
.search-field { width: min(100%, 360px); }
.entry-list { display: grid; gap: 10px; }
.entry-row { margin: 0; }
.entry-button { width: 100%; display: grid; grid-template-columns: 44px minmax(0, 1fr) auto 20px; align-items: center; gap: 15px; padding: 17px 18px; color: var(--text); text-align: left; background: rgba(19, 27, 26, .78); border: 1px solid var(--line); border-radius: 13px; cursor: pointer; }
.entry-button:hover { background: var(--panel-2); border-color: #42524f; transform: translateX(2px); }
.entry-icon { display: grid; place-items: center; width: 44px; height: 44px; color: var(--accent); background: #263020; border-radius: 12px; font: 600 1.15rem Georgia, serif; }
.entry-icon.large { width: 64px; height: 64px; font-size: 1.55rem; border-radius: 16px; }
.entry-copy { min-width: 0; display: grid; gap: 4px; }
.entry-copy strong, .entry-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.entry-copy span, .entry-button time { color: var(--muted); font-size: .82rem; }
.chevron { color: #6e7d78; font-size: 1.6rem; }
.empty-state { padding: 72px 20px; color: var(--muted); text-align: center; border: 1px dashed var(--line); border-radius: var(--radius); }
.empty-state > span { color: var(--accent); font-size: 2rem; }
.empty-state h2 { margin: 12px 0 6px; color: var(--text); }
.detail-card { max-width: 760px; margin: 0 auto; padding: clamp(28px, 6vw, 58px); }
.detail-heading { display: flex; align-items: center; gap: 20px; padding-bottom: 32px; border-bottom: 1px solid var(--line); }
.detail-heading h1 { margin: 0 0 5px; font-size: clamp(2rem, 5vw, 3.5rem); overflow-wrap: anywhere; }
.detail-heading p { margin-bottom: 6px; }
.details { margin: 0; }
.details > div { display: grid; grid-template-columns: 130px 1fr; gap: 20px; padding: 24px 0; border-bottom: 1px solid var(--line); }
.details dt { color: var(--muted); font-size: .8rem; }
.details dd { margin: 0; overflow-wrap: anywhere; }
.secret-line { display: flex; align-items: center; gap: 10px; }
.secret-line > span { flex: 1; font-family: ui-monospace, monospace; }
.icon-button { padding: 7px 10px; color: var(--accent); background: #263020; border-radius: 7px; font-size: .76rem; }
.detail-actions { display: flex; justify-content: flex-end; padding-top: 30px; }
.editor-layout { max-width: 760px; margin: 0 auto; }
.editor-layout > div:first-child { margin-bottom: 34px; }
.editor-layout h1 { font-size: clamp(2.6rem, 6vw, 4.5rem); }
.form-card.wide { padding: clamp(25px, 5vw, 44px); }
.danger-zone { display: flex; justify-content: flex-end; margin-top: 18px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
[hidden] { display: none !important; }
@media (max-width: 760px) {
.site-header { align-items: flex-start; padding: 14px 20px; }
.nav-actions { flex-wrap: wrap; justify-content: flex-end; }
.auth-layout { grid-template-columns: 1fr; }
.page-shell { padding-top: 55px; }
.vault-header { align-items: stretch; flex-direction: column; }
.search-field { width: 100%; }
.entry-button { grid-template-columns: 40px minmax(0, 1fr) 14px; }
.entry-icon { width: 40px; height: 40px; }
.entry-button time { display: none; }
.details > div { grid-template-columns: 1fr; gap: 8px; }
.secret-line { flex-wrap: wrap; }
} }
+24 -62
View File
@@ -2,74 +2,36 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="color-scheme" content="dark">
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1.0" />
<title>{% block title %}Flowers{% endblock %}</title> <title>{% block title %}Flowers{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('flower_vase.static', filename='flower.css') }}">
<!-- link rel="stylesheet" href="http://yui.yahooapis.com/pure/0.6.0/pure-min.css" -->
<link rel="stylesheet" href="static/pure-min.css">
<link rel="stylesheet" href="static/grids-responsive-min.css">
<link rel="stylesheet" href="static/font-awesome.css">
<link rel=stylesheet type=text/css href="static/marketing.css">
<link rel=stylesheet type=text/css href="static/flower.css">
<script type="text/javascript" src="static/jquery-min.js"></script>
</head> </head>
<body> <body>
<header class="site-header">
<a class="brand" href="{{ url_for('flower_vase.index') }}" aria-label="Flowers home">
<span class="brand-mark" aria-hidden="true"></span>
<span>Flowers</span>
</a>
<nav class="nav-actions" aria-label="Vault actions">
{% block menuoptions %}{% endblock %}
</nav>
</header>
{% block headandmenu %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="header"> <div class="notices" aria-live="polite">
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed"> {% for category, message in messages %}
<a class="pure-menu-heading" href="">Flowers</a> <div class="notice notice-{{ category }}">{{ message }}</div>
{% endfor %}
<ul class="pure-menu-list">
{% block menuoptions %}
<input type="text" id="search" placeholder="search..." value="{{filter}}">
<li class="pure-menu-item pure-menu-selected"><a href="javascript:DoSubmit('new')" class="pure-menu-link">New</a></li>
<li class="pure-menu-item"><a href="javascript:DoSubmit('logout')" class="pure-menu-link">Logout</a></li>
<li class="pure-menu-item"><a href="javascript:DoSubmit('rehush')" class="pure-menu-link">Password</a></li>
{% endblock menuoptions %}
</ul>
</div>
</div> </div>
{% endif %}
{% endwith %}
{% endblock headandmenu %} <main class="page-shell">
{% block content %}{% endblock %}
</main>
{% block content %}
{% endblock %}
<form id="gotopage" action="{{ url_for('flower_vase.application') }}" method="POST">
<input type="hidden" id="_action" name="action" value="">
<input type="hidden" id="_organization" name="organization" value="">
<input type="hidden" id="_myid" name="myid" value="">
<input type="hidden" id="_datetime" name="datetime" value="">
<input type="hidden" id="_filter" name="filter" value="">
</form>
<script language="javascript">
function DoSubmit(action, org, datetime, filter) {
$('#_action').val(action);
$('#_organization').val(org);
$('#_datetime').val(datetime);
$('#_filter').val( $('#search').val() );
$('#gotopage').submit();
}
</script>
{% block footer %}
<div class="footer l-box is-center">
Thank you for using the flowershop.
</div>
{% endblock %}
<footer>Private by design. Stored in your existing Flowers vault.</footer>
</body> </body>
</html> </html>
+19 -31
View File
@@ -1,35 +1,23 @@
{% extends "common.html" %} {% extends "common.html" %}
{% block title %}Create a vault · Flowers{% endblock %}
{% block headandmenu %} {% block menuoptions %}<a class="text-link" href="{{ url_for('flower_vase.index') }}">Sign in</a>{% endblock %}
<div class="header">
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed">
<a class="pure-menu-heading" href="">Flowers</a>
</div>
</div>
{% endblock headandmenu %}
{% block content %} {% block content %}
<div class="splash-container"> <section class="auth-layout">
<div class="splash"> <div class="intro">
<form class="pure-form pure-form-aligned" action="{{ url_for('flower_vase.create') }}" method="post"> <p class="eyebrow">A vase of your own</p>
<fieldset> <h1>Create a vault.</h1>
<div class="pure-control-group"> <p>This uses the existing Flowers database structure so it remains readable by compatible installations.</p>
<input maxlength="32" name="name" type="text" placeholder="make up your username">
</div> </div>
<form class="card form-card" action="{{ url_for('flower_vase.create') }}" method="post">
<div class="pure-control-group"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input maxlength="10" name="password" type="password" placeholder="make up you password, the longer the better"> <h2>New vault</h2>
</div> {% if message %}<p class="field-error">{{ message }}</p>{% endif %}
<label for="name">Username</label>
<div class="pure-controls"> <input maxlength="31" id="name" name="name" type="text" pattern="[A-Za-z0-9_]+" autocomplete="username" required>
<button type="submit" class="pure-button pure-button-primary">Create Vase</button> <label for="password">Password</label>
</div> <input maxlength="43" id="password" name="password" type="password" pattern="[A-Za-z0-9_+/-]+" autocomplete="new-password" required>
</fieldset> <p class="field-help">Use Base64-compatible characters for legacy encryption compatibility.</p>
<button class="button primary" type="submit">Create vault</button>
</form> </form>
{{ message }} </section>
{% endblock %}
</div>
</div>
{% endblock content %}
+37 -41
View File
@@ -1,45 +1,41 @@
{% extends "common.html" %} {% extends "common.html" %}
{% block title %}{% if flower.organization %}Edit{% else %}New entry{% endif %} · Flowers{% endblock %}
{% block menuoptions %} {% block menuoptions %}
<li class="pure-menu-item pure-menu-selected"><a href="javascript:$('#newFlower').submit()" class="pure-menu-link">Save</a></li> <form action="{{ url_for('flower_vase.application') }}" method="post">
<li class="pure-menu-item pure-menu-selected"><a href="javascript:DoSubmit('deactivate','{{ flower.organization }}','{{ flower.dateCreated }}','')" class="pure-menu-link">Delete</a></li> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<li class="pure-menu-item pure-menu-selected"><a href="javascript:DoSubmit('list','','','')" class="pure-menu-link">Cancel</a></li> <input type="hidden" name="action" value="list">
{% endblock menuoptions %} <button class="button ghost compact" type="submit">Cancel</button>
{% block content %}
<div class="splash-container">
<div class="splash">
<form class="pure-form pure-form-stacked" id="newFlower" action="{{ url_for('flower_vase.application') }}" method="post">
<fieldset>
<div class="pure-control-group">
<input maxlength="70" size="50" id="organization" name="organization" type="text" value="{{ flower.organization }}" placeholder="Organization">
</div>
<div class="pure-control-group">
<input maxlength="70" id="myname" name="myname" type="text" value="{{ flower.myName }}" placeholder="Your name (optional)">
</div>
<div class="pure-control-group">
<input maxlength="70" id="myid" name="myid" type="text" value="{{ flower.myID }}" placeholder="Your Login">
</div>
<div class="pure-control-group">
<input maxlength="30" id="secret" name="secret" type="text" placeholder="Your secret">
</div>
<input type="hidden" id="action" name="action" value="save">
<input type="hidden" id="filter" name="filter" value="DoNotSetFilterinCookie">
</fieldset>
</form> </form>
{% endblock %}
</div> {% block content %}
</div> <section class="editor-layout">
<div>
<script language="javascript"> <p class="eyebrow">{% if flower.organization %}New version{% else %}New credential{% endif %}</p>
<h1>{% if flower.organization %}Edit entry{% else %}Add an entry{% endif %}</h1>
</script> <p class="muted">Saving creates a new version. Your historical rows remain untouched.</p>
</div>
<form class="card form-card wide" action="{{ url_for('flower_vase.application') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="save">
<input type="hidden" name="filter" value="{{ filter|default('') }}">
<label for="organization">Organization</label>
<input maxlength="80" id="organization" name="organization" type="text" value="{{ flower.organization }}" autocomplete="organization" required>
<label for="myname">Your name <span>optional</span></label>
<input maxlength="70" id="myname" name="myname" type="text" value="{{ flower.myName }}" autocomplete="name">
<label for="myid">Login</label>
<input maxlength="70" id="myid" name="myid" type="text" value="{{ flower.myID }}" autocomplete="username" required>
<label for="secret">Secret</label>
<input maxlength="120" id="secret" name="secret" type="password" autocomplete="new-password" required>
<button class="button primary" type="submit">Save new version</button>
</form>
{% if flower.dateCreated %}
<form class="danger-zone" action="{{ url_for('flower_vase.application') }}" method="post" onsubmit="return confirm('Remove this entry from the active list?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="deactivate">
<input type="hidden" name="organization" value="{{ flower.organization }}">
<input type="hidden" name="datetime" value="{{ flower.dateCreated }}">
<button class="button danger" type="submit">Deactivate entry</button>
</form>
{% endif %}
</section>
{% endblock %} {% endblock %}
+20 -33
View File
@@ -1,37 +1,24 @@
{% extends "common.html" %} {% extends "common.html" %}
{% block title %}Sign in · Flowers{% endblock %}
{% block headandmenu %} {% block menuoptions %}
<div class="header"> <a class="text-link" href="{{ url_for('flower_vase.create') }}">Create a vault</a>
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed"> {% endblock %}
<a class="pure-menu-heading" href="">Flowers</a>
</div>
</div>
{% endblock headandmenu %}
{% block content %} {% block content %}
<div class="splash-container"> <section class="auth-layout">
<div class="splash"> <div class="intro">
<form class="pure-form pure-form-aligned" action="{{ url_for('flower_vase.application') }}" method="post"> <p class="eyebrow">Your private collection</p>
<fieldset> <h1>Credentials, kept close.</h1>
<div class="pure-control-group"> <p>Open your existing vault. Your encrypted records stay in the same database and format.</p>
<input name="name" type="text" placeholder="{{ name_suggestion }}">
</div> </div>
<form class="card form-card" action="{{ url_for('flower_vase.application') }}" method="post">
<div class="pure-control-group"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input name="password" type="password" placeholder="{{ pwd_suggestion }}"> <input type="hidden" name="action" value="login">
</div> <h2>Sign in</h2>
<label for="name">Username</label>
<div class="pure-controls"> <input id="name" name="name" type="text" autocomplete="username" placeholder="{{ name_suggestion }}" required autofocus>
<button type="submit" class="pure-button pure-button-primary">Submit</button> <label for="password">Password</label>
</div> <input id="password" name="password" type="password" autocomplete="current-password" placeholder="{{ pwd_suggestion }}" required>
</fieldset> <button class="button primary" type="submit">Open vault</button>
<input type="hidden" id="action" name="action" value="login">
<input type="hidden" id="filter" name="filter" value="DoNotSetFilterinCookie">
</form> </form>
</section>
</div> {% endblock %}
</div>
{% endblock content %}
+69 -42
View File
@@ -1,51 +1,78 @@
{% extends "common.html" %} {% extends "common.html" %}
{% block title %}Your vault · Flowers{% endblock %}
{% block menuoptions %}
<form action="{{ url_for('flower_vase.application') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="new">
<button class="button primary compact" type="submit">New entry</button>
</form>
<form action="{{ url_for('flower_vase.application') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="rehush">
<button class="button ghost compact" type="submit">Password</button>
</form>
<form action="{{ url_for('flower_vase.application') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="logout">
<button class="button ghost compact" type="submit">Sign out</button>
</form>
{% endblock %}
{% block content %} {% block content %}
<section class="vault-header">
<div class="content-wrapper"> <div>
<div class="content"> <p class="eyebrow">Active entries</p>
<div class="pure-g"> <h1>Your vault</h1>
<div class="l-box pure-u-1 pure-u-md-1-2 pure-u-lg-1-5">
</div> </div>
<div class="l-box pure-u-1 pure-u-md-1-2 pure-u-lg-3-5"> <label class="search-field" for="search">
<table id="flowerlist" class="flowerlist pure-table pure-table-horizontal"> <span class="sr-only">Search entries</span>
<input id="search" type="search" placeholder="Search organization or login" value="{{ filter|default('') }}" autocomplete="off">
</label>
</section>
<div class="entry-list" id="entry-list">
{% for flower in flowers %} {% for flower in flowers %}
<tr class="flowerlist"> <form class="entry-row" data-search="{{ flower.organization }} {{ flower.myID }}" action="{{ url_for('flower_vase.application') }}" method="post">
<td class="flowerlist"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<a href="javascript:DoSubmit('show', '{{flower.organization}}', '{{flower.dateCreated}}' , '')"> <input type="hidden" name="action" value="show">
{{ flower.organization }}, {{ flower.myID }} <input type="hidden" name="organization" value="{{ flower.organization }}">
</a> <input type="hidden" name="datetime" value="{{ flower.dateCreated }}">
</td> <input type="hidden" name="filter" class="current-filter" value="{{ filter|default('') }}">
<td class="right"><span style="white-space: nowrap;">{{ flower.dateCreated[:10] }}</span></td> <button type="submit" class="entry-button">
</tr> <span class="entry-icon" aria-hidden="true">{{ flower.organization[:1]|upper }}</span>
<span class="entry-copy">
<strong>{{ flower.organization }}</strong>
<span>{{ flower.myID }}</span>
</span>
<time datetime="{{ flower.dateCreated }}">{{ flower.dateCreated[:10] }}</time>
<span class="chevron" aria-hidden="true"></span>
</button>
</form>
{% else %}
<div class="empty-state">
<span aria-hidden="true"></span>
<h2>No active entries</h2>
<p>Create an entry to start your collection.</p>
</div>
{% endfor %} {% endfor %}
</table>
</div> </div>
<div class="l-box pure-u-1 pure-u-md-1-2 pure-u-lg-1-5"> <p class="empty-state filtered-empty" id="filtered-empty" hidden>No entries match that search.</p>
</div> <script>
</div> const search = document.querySelector('#search');
</div> const rows = [...document.querySelectorAll('.entry-row')];
const filteredEmpty = document.querySelector('#filtered-empty');
</div> function filterRows() {
const query = search.value.trim().toLocaleLowerCase();
<script language="javascript"> let visible = 0;
rows.forEach((row) => {
var $rows = $('#flowerlist tr'); const match = row.dataset.search.toLocaleLowerCase().includes(query);
$('#search').keyup(function() { row.hidden = !match;
var val = $.trim($(this).val()).replace(/ +/g, ' ').toLowerCase(); row.querySelector('.current-filter').value = search.value;
if (match) visible += 1;
$rows.show().filter(function() {
var text = $(this).text().replace(/\s+/g, ' ').toLowerCase();
return !~text.indexOf(val);
}).hide();
}); });
filteredEmpty.hidden = visible !== 0 || rows.length === 0;
$('#search').keyup(); }
search.addEventListener('input', filterRows);
filterRows();
</script> </script>
{% endblock %}
{% endblock content %}
+20 -36
View File
@@ -1,40 +1,24 @@
{% extends "common.html" %} {% extends "common.html" %}
{% block title %}Change password · Flowers{% endblock %}
{% block headandmenu %} {% block menuoptions %}<a class="text-link" href="{{ url_for('flower_vase.index') }}">Back to sign in</a>{% endblock %}
<div class="header">
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed">
<a class="pure-menu-heading" href="">Flowers</a>
</div>
</div>
{% endblock headandmenu %}
{% block content %} {% block content %}
<div class="splash-container"> <section class="auth-layout">
<div class="splash"> <div class="intro">
<form class="pure-form pure-form-aligned" action="{{ url_for('flower_vase.update_pwd') }}" method="post"> <p class="eyebrow">Re-encrypt your vault</p>
<fieldset> <h1>Change password.</h1>
<div class="pure-control-group"> <p>Every existing encrypted field will be rewritten with the new legacy-compatible key.</p>
<input name="name" type="text" placeholder="your existing name">
</div> </div>
<form class="card form-card" action="{{ url_for('flower_vase.update_pwd') }}" method="post">
<div class="pure-control-group"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input name="old_password" type="password" placeholder="your existing password"> <h2>Vault credentials</h2>
</div> {% if message %}<p class="field-error">{{ message }}</p>{% endif %}
<label for="name">Username</label>
<div class="pure-control-group"> <input id="name" name="name" type="text" autocomplete="username" required>
<input maxlength="10" name="new_password" type="password" placeholder="make up you password, the longer the better"> <label for="old-password">Current password</label>
</div> <input id="old-password" name="old_password" type="password" autocomplete="current-password" required>
<label for="new-password">New password</label>
<input maxlength="43" id="new-password" name="new_password" type="password" pattern="[A-Za-z0-9_+/-]+" autocomplete="new-password" required>
<div class="pure-controls"> <button class="button primary" type="submit">Re-encrypt vault</button>
<button type="submit" class="pure-button pure-button-primary">Update Password</button>
</div>
</fieldset>
</form> </form>
{{ message }} </section>
{% endblock %}
</div>
</div>
{% endblock content %}
+58 -44
View File
@@ -1,47 +1,61 @@
{% extends "common.html" %} {% extends "common.html" %}
{% block title %}{{ flower.organization }} · Flowers{% endblock %}
{% block menuoptions %} {% block menuoptions %}
<li class="pure-menu-item pure-menu-selected"><a href="javascript:editFlower()" class="pure-menu-link">Edit</a></li> <form action="{{ url_for('flower_vase.application') }}" method="post">
{% endblock menuoptions %} <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="list">
<input type="hidden" name="filter" value="{{ filter|default('') }}">
{% block content %} <button class="button ghost compact" type="submit">Back to vault</button>
<div class="splash-container"> </form>
<div class="splash"> {% endblock %}
<table class="pure-table" style="width: 100%"> {% block content %}
<tr><td class="white right">Org</td><td class="white left">{{ flower.organization }}</td> </tr> <section class="detail-card card">
<tr><td class="white right">You</td><td class="white left">{{ flower.myName }}</td> </tr> <div class="detail-heading">
<tr><td class="white right">Login</td><td class="white left">{{ flower.myID }}</td> </tr> <span class="entry-icon large" aria-hidden="true">{{ flower.organization[:1]|upper }}</span>
<tr><td class="white right">Hash</td><td id="secrettd" class="white left"><div id="secret" value="{{ flower.mySecret }}" onclick="myCopyFunction()">{{ flower.mySecret }}</div></td></tr> <div>
<tr><td class="white">{{ flower.dateCreated[:10] }}</td><td></td> </tr> <p class="eyebrow">Credential</p>
</table> <h1>{{ flower.organization }}</h1>
{% if flower.dateCreated not in ('STORED', 'FAILED') %}<p class="muted">Version from {{ flower.dateCreated[:10] }}</p>{% endif %}
</div> </div>
</div> </div>
<dl class="details">
<script language="javascript"> <div><dt>Name</dt><dd>{{ flower.myName or '—' }}</dd></div>
<div><dt>Login</dt><dd>{{ flower.myID }}</dd></div>
setTimeout(function(){ <div>
DoSubmit('list', '{{flower.organization}}', '{{flower.dateCreated}}', 'DoNotSetFilterinCookie') <dt>Secret</dt>
}, 5000); <dd class="secret-line">
<span id="secret" data-secret="{{ flower.mySecret }}">••••••••••••</span>
function editFlower() { <button class="icon-button" id="reveal" type="button">Reveal</button>
DoSubmit('edit', '{{flower.organization}}', '{{flower.dateCreated}}', 'DoNotSetFilterinCookie') <button class="icon-button" id="copy" type="button">Copy</button>
} </dd>
</div>
function myCopyFunction() { </dl>
var copyText = document.getElementById("secret"); {% if flower.dateCreated not in ('STORED', 'FAILED') %}
<div class="detail-actions">
// Copy the text inside the text field <form action="{{ url_for('flower_vase.application') }}" method="post">
navigator.clipboard.writeText(copyText.getAttribute("value")); <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="edit">
// alert("Copied the text: "+copyText.getAttribute("value")); <input type="hidden" name="organization" value="{{ flower.organization }}">
<input type="hidden" name="datetime" value="{{ flower.dateCreated }}">
$("#secrettd").removeClass("white"); <button class="button primary" type="submit">Edit entry</button>
$("#secrettd").addClass("blue"); </form>
} </div>
{% endif %}
</script> </section>
<script>
const secret = document.querySelector('#secret');
const reveal = document.querySelector('#reveal');
const copy = document.querySelector('#copy');
let visible = false;
reveal.addEventListener('click', () => {
visible = !visible;
secret.textContent = visible ? secret.dataset.secret : '••••••••••••';
reveal.textContent = visible ? 'Hide' : 'Reveal';
});
copy.addEventListener('click', async () => {
await navigator.clipboard.writeText(secret.dataset.secret);
copy.textContent = 'Copied';
window.setTimeout(() => copy.textContent = 'Copy', 1400);
});
</script>
{% endblock %} {% endblock %}
+74
View File
@@ -0,0 +1,74 @@
import json
import unittest
from unittest.mock import patch
from flowers import create_app
class FakeVault:
def __init__(self, *args):
pass
def authenticate(self):
return True
def all(self):
return [
{
"organization": "Acme",
"myID": "alice@example.test",
"dateCreated": "2024-02-03 12:00:00",
}
]
class AppTests(unittest.TestCase):
def setUp(self):
self.app = create_app({"TESTING": True, "SECRET_KEY": "test-secret"})
self.client = self.app.test_client()
def csrf(self):
self.client.get("/")
with self.client.session_transaction() as browser_session:
return browser_session["csrf_token"]
def test_login_form_requires_csrf(self):
response = self.client.post(
"/browser",
data={"action": "login", "name": "alice", "password": "legacy_pwd"},
)
self.assertEqual(response.status_code, 400)
@patch("flower_vase.Flower", FakeVault)
def test_login_renders_existing_entries(self):
response = self.client.post(
"/browser",
data={
"csrf_token": self.csrf(),
"action": "login",
"name": "alice",
"password": "legacy_pwd",
},
follow_redirects=True,
)
self.assertEqual(response.status_code, 200)
self.assertIn(b"Acme", response.data)
with self.client.session_transaction() as browser_session:
self.assertNotIn("dbpwd", browser_session)
self.assertNotIn("password", browser_session)
self.assertIn("auth_token", browser_session)
@patch("flower_vase.Flower", FakeVault)
def test_legacy_api_keeps_double_encoded_list(self):
response = self.client.post(
"/app",
data={"action": "list", "name": "alice", "password": "legacy_pwd"},
)
payload = response.get_json()
nested = json.loads(payload["list"])
self.assertEqual(payload["result"], 1)
self.assertEqual(nested[0]["organization"], "Acme")
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
import unittest
from FlowerServices import Flower, InvalidCredentials, legacy_key
class FakeCursor:
def __init__(self, one=None, many=(), changed=1):
self.one = one
self.many = many
self.changed = changed
self.calls = []
def __enter__(self):
return self
def __exit__(self, *args):
return False
def execute(self, sql, parameters=None):
self.calls.append((" ".join(sql.split()), parameters))
return self.changed
def fetchone(self):
return self.one
def fetchall(self):
return self.many
class FakeConnection:
def __init__(self, cursor):
self.fake_cursor = cursor
self.commits = 0
self.rollbacks = 0
self.closed = 0
def cursor(self):
return self.fake_cursor
def commit(self):
self.commits += 1
def rollback(self):
self.rollbacks += 1
def close(self):
self.closed += 1
class FlowerCompatibilityTests(unittest.TestCase):
def make_vault(self, cursor):
connection = FakeConnection(cursor)
calls = []
def connect(**kwargs):
calls.append(kwargs)
return connection
return Flower("alice", "legacy_pwd", connect), connection, calls
def test_legacy_key_derivation_is_unchanged(self):
self.assertEqual(legacy_key("black"), "blackblackblackblackblackblackblackblackbla=")
def test_legacy_ciphertext_round_trip(self):
vault, _, _ = self.make_vault(FakeCursor())
token = vault.encode("existing database secret")
self.assertEqual(vault.decode(token), "existing database secret")
def test_existing_row_is_decrypted_without_schema_changes(self):
seed, _, _ = self.make_vault(FakeCursor())
row = (seed.encode("secret"), seed.encode("login"), seed.encode("Alice"))
cursor = FakeCursor(one=row)
vault, _, calls = self.make_vault(cursor)
item = vault.one("Acme", "2024-02-03 12:00:00")
self.assertEqual(item["mySecret"], "secret")
self.assertEqual(item["myID"], "login")
self.assertEqual(item["myName"], "Alice")
self.assertEqual(
cursor.calls[0][1], ("Acme", "2024-02-03 12:00:00")
)
self.assertEqual(calls[0]["user"], "alice_")
self.assertEqual(calls[0]["db"], "Flowers")
def test_writes_use_parameters_and_existing_columns(self):
cursor = FakeCursor()
vault, connection, _ = self.make_vault(cursor)
hostile_label = 'Acme"; DROP TABLE alice_; --'
result = vault.add(hostile_label, "Alice", "login", "secret")
sql, parameters = cursor.calls[0]
self.assertIn("organization, myID, myName, mySecret, deleted", sql)
self.assertNotIn(hostile_label, sql)
self.assertEqual(parameters[0], hostile_label)
self.assertEqual(result["dateCreated"], "STORED")
self.assertEqual(connection.commits, 1)
def test_latest_active_list_keeps_legacy_query_semantics(self):
seed, _, _ = self.make_vault(FakeCursor())
cursor = FakeCursor(
many=(("Acme", seed.encode("login"), "2024-02-03 12:00:00"),)
)
vault, _, _ = self.make_vault(cursor)
items = vault.all()
self.assertEqual(items[0]["organization"], "Acme")
self.assertEqual(items[0]["myID"], "login")
self.assertIn("MAX(dateCreated)", cursor.calls[0][0])
self.assertEqual(cursor.calls[0][1], (0,))
def test_unsafe_table_identifier_is_rejected(self):
with self.assertRaises(InvalidCredentials):
Flower("alice`; DROP DATABASE Flowers", "legacy_pwd")
if __name__ == "__main__":
unittest.main()