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__/FlowerServices.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 *
import pickle, os
import datetime
"""Small file-backed login throttle.
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
class Access():
access_list = []
def __init__(self):
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
new_list = []
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
class Access:
limit = 3
window = timedelta(minutes=5)
def granted(self, ipaddress):
result = 0
for entry in self.access_list:
if entry['ip'] == ipaddress:
result += 1
def __init__(self, path: str = ACCESSFILE) -> None:
self.path = Path(path)
self.access_list = self._load()
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
Log.info("Access denied for {}".format(ipaddress))
self.deny(ipaddress)
Log.info(f"Access denied for {ipaddress}")
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):
now = datetime.datetime.now()
self.access_list.append({'ip': ipaddress, 'time': now})
with open(ACCESSFILE, 'wb') as pf:
pickle.dump(self.access_list, pf, 2)
def _load(self) -> list[dict[str, str]]:
cutoff = datetime.now(timezone.utc) - self.window
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
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'
DEBUG = 0
MAXTEXTLEN = 70
MINFIELDLEN = 3
INFO = 1
DONOTSETFILTER = 'DoNotSetFilterinCookie'
ACCESSFILE = '/tmp/wsgi_flower_accessfile'
URLPREFIX = '' #'/flowers'
Secrets are read from the git-ignored ``secrets.yaml`` file. Environment
variables take precedence, which is useful for container deployments.
"""
import os
import secrets
from pathlib import Path
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"
+303 -245
View File
@@ -1,269 +1,327 @@
from fernet import Fernet
from Log import Log
from Config import *
"""Database and encryption services for the legacy Flowers data format.
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
from fernet import Fernet
#encryption stuff
SECRET = b'XBxB603cX_mULEXxfavOg3FDc0Ox3gChwYEY-Uxd3tE='
from Config import (
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'
self.db_username = user + '_'
self.db_password = (pwd*10)[:43]+"="
class InvalidCredentials(ValueError):
"""The supplied username/password cannot address a Flowers vault."""
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.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):
if len(s)==0:
return ''
return self.fernet.encrypt(s.encode()).decode()
def decode(self, s):
if len(s)==0:
return ''
return self.fernet.decrypt(s.encode()).decode()
def add(self, organization, myname, myid, secret, deleted=0, timestamp=None):
''' POST a new flower
'''
if len(organization)>MINFIELDLEN and len(myid)>MINFIELDLEN and len(secret)>MINFIELDLEN:
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 all(self, isdeleted=0):
''' GET flowers list
'''
#sql = "SELECT organization, myID, myName, dateCreated FROM %s " % (self.customer_table)
sql = """
select uniqueflowers.organization, uniqueflowers.myID, uniqueflowers.dateCreated from {0} as uniqueflowers
inner join (
select organization, max(dateCreated) as lastCreated from {0} group by organization order by dateCreated DESC) allflowers
on allflowers.organization=uniqueflowers.organization and lastCreated=uniqueflowers.dateCreated where uniqueflowers.deleted={1}
""".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
def update_pwd(self, new_pwd):
long_newpwd = (new_pwd*10)[:43]+"="
self.new_fernet = Fernet(bytes(long_newpwd, 'ascii'))
def new_encode(s):
if len(s)==0:
return ''
return self.new_fernet.encrypt(s.encode()).decode()
end_result = "ERROR updating hushes\n"
sql = """
select myID, myName, mySecret, organization, dateCreated from {0}""".format(self.customer_table)
result, data = self.do_db_allrows(sql)
# Log.debug(sql_result)
if result == 1:
# convert all hushes
sql = ""
for row in data:
sql += "update {} set myID='{}', myName='{}', mySecret='{}' where organization='{}' and dateCreated='{}';\n".\
format(self.customer_table,
new_encode(self.decode(row[0])),
new_encode(self.decode(row[1])),
new_encode(self.decode(row[2])),row[3], row[4])
result, data = self.do_db_commit(sql)
if result == 1:
end_result = "ERROR updating user-password\n"
# update the password of the user
sql = "SET PASSWORD FOR '{}'@'localhost' = PASSWORD('{}'); ".format(self.db_username, long_newpwd)
result, data = self.do_db_commit(sql)
if result == 1:
end_result = "SUCCESSFULLY updated, now login again\n"
return end_result
def do_db_commit(self, sql):
Log.debug('INSc: '+sql)
result = 0
data = ''
con = None
@contextmanager
def _connection(self) -> Iterator[object]:
connection = None
try:
con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db);
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
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 con:
con.close()
return result, data
if connection is not None:
connection.close()
def encode(self, value: str) -> str:
if not value:
return ""
return self.fernet.encrypt(value.encode("utf-8")).decode("ascii")
def do_db_1row(self, sql):
Log.debug('1row sql: '+sql)
result = 0
con = None
def decode(self, value: str) -> str:
if not value:
return ""
return self.fernet.decrypt(value.encode("ascii")).decode("utf-8")
def authenticate(self) -> bool:
try:
con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db);
cur = con.cursor()
cur.execute(sql)
data = cur.fetchone()
if data:
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
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 do_db_allrows(self, sql):
Log.debug('all rows sql: '+sql)
result = 0
con = None
def numberOfEntries(self) -> int:
"""Compatibility method used by older clients and scripts."""
try:
con = mdb.connect(host='localhost', passwd=self.db_password, user=self.db_username, db=self.db);
cur = con.cursor()
cur.execute(sql)
data = cur.fetchall()
if data:
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
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(f"SELECT COUNT(*) FROM {self._table}")
row = cursor.fetchone()
return int(row[0]) if row else 0
except StorageError:
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
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)
sql = f"INSERT INTO {self._table} ({columns}) VALUES ({placeholders})"
self._execute_write(sql, values)
return self._item(organization, "STORED", myid, myname, secret)
def all(self, isdeleted: int = 0) -> list[dict]:
sql = f"""
SELECT latest.organization, latest.myID, latest.dateCreated
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
]
def one(self, organization: str, datetimestamp) -> dict:
sql = f"""
SELECT mySecret, myID, myName
FROM {self._table}
WHERE organization = %s AND dateCreated = %s
ORDER BY dateCreated DESC
LIMIT 1
"""
with self._connection() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, (organization, datetimestamp))
row = cursor.fetchone()
if row:
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 --")
def deactivate(self, organization: str, datetimestamp) -> dict:
sql = f"UPDATE {self._table} SET deleted = 1 WHERE organization = %s AND dateCreated = %s"
changed = self._execute_write(sql, (organization, datetimestamp))
message = "- DELETED -" if changed else "-- Not found --"
return self._item(organization, str(datetimestamp), "", "", message)
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"))
try:
with self._connection() as connection:
try:
with connection.cursor() as cursor:
cursor.execute(
f"SELECT myID, myName, mySecret, organization, dateCreated FROM {self._table}"
)
rows = cursor.fetchall()
update = f"""
UPDATE {self._table}
SET myID = %s, myName = %s, mySecret = %s
WHERE organization = %s AND dateCreated = %s
"""
for row in rows:
encrypted = [
new_fernet.encrypt(self.decode(value).encode("utf-8")).decode("ascii")
if value else ""
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 _execute_write(self, sql: str, parameters) -> int:
with self._connection() as connection:
try:
with connection.cursor() as cursor:
changed = cursor.execute(sql, parameters)
connection.commit()
return int(changed)
except Exception:
connection.rollback()
raise
@staticmethod
def _item(organization, timestamp, myid, myname, secret) -> dict:
return {
"organization": organization,
"dateCreated": timestamp,
"myID": myid,
"myName": myname,
"mySecret": secret,
}
class SuperFlower(Flower):
"""Provision a legacy per-user table and database account."""
def __init__(self, customer, pwd):
super().__init__(customer, pwd)
self.customer_name = customer + '_'
self.customer_pwd = (pwd*10)[:43]+"="
def __init__(self, customer: str, pwd: str, connection_factory=None) -> None:
super().__init__(customer, pwd, connection_factory)
self.customer_name = self.customer_table
self.customer_pwd = self.db_password
self.db_username = DB_ADMIN_USER
self.db_password = DB_ADMIN_PASSWORD
self.db_username = 'flower'
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)
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
except StorageError as exc:
Log.info(f"Could not create vault: {exc}")
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)
def flush_privs(self) -> int:
try:
self._execute_write("FLUSH PRIVILEGES", ())
return 1
except StorageError:
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()
return 1
def flush_privs(self):
sql = """flush privileges;"""
result, data = self.do_db_1row(sql)
if result < 0:
Log.info("SQL ERROR:")
Log.info(data)
return 0
return 1
if __name__ == '__main__':
print("this is a library, sorry dude")
if __name__ == "__main__":
print("This module is a library.")
+22 -15
View File
@@ -1,20 +1,27 @@
from sys import stderr
from Config import *
"""Compatibility logging facade."""
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:
@staticmethod
def info(someString):
if INFO:
f = open(LOGFILE, "a")
f.write( "flower: %s \n" % someString )
f.close()
if DEBUG:
print(someString)
def info(message) -> None:
_logger.info("%s", message)
@staticmethod
def debug(someString):
if DEBUG:
f = open(LOGFILE, "a")
f.write( "flower: %s \n" % someString )
f.close()
def debug(message) -> None:
_logger.debug("%s", message)
+87 -175
View File
@@ -1,230 +1,142 @@
# 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]
> 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.
## Existing database compatibility
## 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.
- Encrypts the login name, login ID, and secret with Fernet before storing them.
- Lists the most recent active record for each organization.
- Supports searching, viewing, adding, and soft-deleting records.
- Keeps older records when a new version of an organization is added.
- Locks an IP address out after three failed logins within five minutes.
- Exposes the same core operations through `POST /app` for a companion client.
- the `Flowers` database;
- one database account and table named `<username>_` per vault;
- the existing `organization`, `myID`, `myName`, `mySecret`, `dateCreated`, and `deleted` columns;
- the historical password transformation `(password * 10)[:43] + "="` for both the database password and Fernet key;
- Fernet ciphertext in the three protected columns;
- append-only edits, where the newest timestamp is the visible version; and
- `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
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
```
## What changed
### Data model
Each application username maps to:
- a database account named `<username>_`;
- a table named `<username>_` in the `Flowers` database; and
- a database password/Fernet key derived by repeating the application password and truncating it to 43 characters, then appending `=`.
Each table contains:
| 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.
- All stored values are passed to MariaDB as query parameters.
- Usernames are validated before being used as table/account identifiers.
- Database and runtime settings can be supplied through environment variables.
- Browser forms have CSRF protection.
- Database credentials are stored in server memory behind an opaque session token, not in Flask's signed browser cookie.
- Browser sessions expire after ten minutes of inactivity.
- 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.
- The Flask application factory and WSGI entry point both work.
- Compatibility tests cover legacy encryption, reads, writes, listing, and the old API format.
## Requirements
- Python 3
- MariaDB or MySQL running on `localhost`
- Python packages: Flask, Waitress, `fernet`, PyMySQL, and pytz
- 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
- Python 3.10 or newer
- MariaDB or MySQL on the configured host
- the packages in `requirements.txt`
```bash
python3 -m venv .venv
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
CREATE DATABASE Flowers;
CREATE USER 'flower'@'localhost'
IDENTIFIED BY '608f0b988db4a96066af7dd8870de96c';
CREATE USER 'flower'@'localhost' IDENTIFIED BY 'replace-this';
GRANT CREATE, SELECT, UPDATE, INSERT ON Flowers.*
TO 'flower'@'localhost' WITH GRANT OPTION;
GRANT CREATE USER, RELOAD ON *.* TO 'flower'@'localhost';
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`:
```python
URLPREFIX = '' # serve at http://127.0.0.1:5012/
# URLPREFIX = '/flowers' # serve below /flowers
```
### 4. Start the server
For local development:
Development:
```bash
python flowers.py
```
For a non-debug application server:
Production application server:
```bash
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
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:
The tests do not need a live database; they use the actual legacy Fernet implementation with a MariaDB-compatible fake connection.
```bash
curl -X POST http://127.0.0.1:5012/app \
--data-urlencode 'action=login' \
--data-urlencode 'name=demo' \
--data-urlencode 'password=replace-me'
pip install -r requirements-dev.txt
python -m pytest
```
A typical response is:
```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:
They can also run using only the standard library test runner:
```bash
sudo a2enmod proxy proxy_http
python -m unittest discover -s tests -v
```
```apache
ProxyPass "/flowers" "http://127.0.0.1:5012/flowers"
ProxyPassReverse "/flowers" "http://127.0.0.1:5012/flowers"
```
## Remaining security constraints
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 |
| --- | --- |
| `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.
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.
+310 -184
View File
@@ -1,202 +1,328 @@
from flask import Flask, Blueprint, send_from_directory
from Config import *
import sys
import datetime
import pytz
from FlowerServices import Flower,SuperFlower
from AccessControl import Access
"""HTTP routes for the Flowers browser UI and legacy form API."""
from __future__ import annotations
import json
from flask import Flask, request, session, g, redirect, url_for, abort, render_template, flash, json, make_response
from werkzeug.exceptions import HTTPException
import secrets
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
# configuration
# DEBUG = False
# SECRET_KEY = 'development key'
flower_bp = Blueprint("flower_vase", __name__, url_prefix=URLPREFIX)
from flask import (
Blueprint,
abort,
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__)
# app.config.from_object(__name__)
flower_bp = Blueprint(
"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():
print(request.remote_addr)
return render_template('index.html', name_suggestion="Hello", pwd_suggestion="Want to try your luck?")
return _login_page()
@flower_bp.route('/browser' , methods=['POST', 'GET'])
@flower_bp.route("/browser", methods=["GET", "POST"])
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':
return render_template('index.html', name_suggestion="Faded out", pwd_suggestion="Want to retry your luck?")
access = Access()
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()
if not access_ctrl.granted(request.remote_addr):
return render_template('index.html', name_suggestion="Sorry", pwd_suggestion="Access denied")
action = request.form.get("action", "")
if action == "login":
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']
filter=''
if 'filter' in session:
filter = session['filter']
vault = _vault_from_session()
if vault is None:
session.pop("auth_token", None)
flash("Your session expired. Please sign in again.", "error")
return redirect(url_for("flower_vase.index"))
if action == 'logout':
session['filter'] = request.form['filter']
session['timeout'] = now - datetime.timedelta(minutes=999)
return render_template('index.html', name_suggestion="Bye", pwd_suggestion="Come back any time")
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'})
selected_filter = session.get("filter", "")
form_filter = request.form.get("filter", "")
if len(form_filter) > 1 and form_filter != DONOTSETFILTER:
selected_filter = form_filter
session["filter"] = selected_filter
try:
access_ctrl = Access()
if not access_ctrl.granted(request.remote_addr):
result = json.dumps({'result': -1, 'message': 'Access denied'})
if action == "list":
return render_template("list.html", flowers=vault.all(), filter=selected_filter)
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:
#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
abort(400, description="Unknown action")
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 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.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)
return app
if __name__ == '__main__':
my_app = create_app()
# if needed - initialize data
my_app.run(debug=True, host='127.0.0.1', port=5012)
if __name__ == "__main__":
create_app().run(debug=DEBUG, host="127.0.0.1", port=5012)
+4 -2
View File
@@ -1,7 +1,9 @@
#!/usr/bin/python
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"
+113 -27
View File
@@ -1,31 +1,117 @@
tr.flowerlist:hover td {
background:#eee;
cursor: pointer;
:root {
--bg: #0b1010;
--panel: #131b1a;
--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 {
width: 100%;
.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); }
.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); }
.page-shell { width: min(1120px, calc(100% - 40px)); margin: 0 auto; padding: clamp(56px, 9vw, 112px) 0; min-height: calc(100vh - 150px); }
footer { padding: 24px; color: #65716e; text-align: center; font-size: .8rem; }
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; }
.auth-layout { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(320px, 430px); align-items: center; gap: clamp(44px, 9vw, 120px); }
.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; }
.button, .icon-button { border: 0; cursor: pointer; }
.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); }
.notices { position: fixed; z-index: 10; top: 88px; right: 24px; display: grid; gap: 8px; }
.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; }
.vault-header { display: flex; justify-content: space-between; align-items: end; gap: 32px; margin-bottom: 38px; }
.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; }
}
tr.flowerlist a {
color: inherit; /* blue colors for links too */
text-decoration: inherit; /* no underline */
}
td.white {
color: #eee
}
td.left {
text-align: left;
}
td.right {
text-align: right;
}
td.blue {
color: #208dd6;
}
+27 -65
View File
@@ -1,75 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1.0" />
<title>{% block title %}Flowers{% endblock %}</title>
<!-- 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>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>{% block title %}Flowers{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('flower_vase.static', filename='flower.css') }}">
</head>
<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 %}
<div class="header">
<div class="home-menu pure-menu pure-menu-horizontal pure-menu-fixed">
<a class="pure-menu-heading" href="">Flowers</a>
<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>
{% endblock headandmenu %}
{% 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 %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="notices" aria-live="polite">
{% for category, message in messages %}
<div class="notice notice-{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<main class="page-shell">
{% block content %}{% endblock %}
</main>
<footer>Private by design. Stored in your existing Flowers vault.</footer>
</body>
</html>
+21 -33
View File
@@ -1,35 +1,23 @@
{% extends "common.html" %}
{% block headandmenu %}
<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 title %}Create a vault · Flowers{% endblock %}
{% block menuoptions %}<a class="text-link" href="{{ url_for('flower_vase.index') }}">Sign in</a>{% endblock %}
{% block content %}
<div class="splash-container">
<div class="splash">
<form class="pure-form pure-form-aligned" action="{{ url_for('flower_vase.create') }}" method="post">
<fieldset>
<div class="pure-control-group">
<input maxlength="32" name="name" type="text" placeholder="make up your username">
</div>
<div class="pure-control-group">
<input maxlength="10" name="password" type="password" placeholder="make up you password, the longer the better">
</div>
<div class="pure-controls">
<button type="submit" class="pure-button pure-button-primary">Create Vase</button>
</div>
</fieldset>
</form>
{{ message }}
</div>
</div>
{% endblock content %}
<section class="auth-layout">
<div class="intro">
<p class="eyebrow">A vase of your own</p>
<h1>Create a vault.</h1>
<p>This uses the existing Flowers database structure so it remains readable by compatible installations.</p>
</div>
<form class="card form-card" action="{{ url_for('flower_vase.create') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h2>New vault</h2>
{% if message %}<p class="field-error">{{ message }}</p>{% endif %}
<label for="name">Username</label>
<input maxlength="31" id="name" name="name" type="text" pattern="[A-Za-z0-9_]+" autocomplete="username" required>
<label for="password">Password</label>
<input maxlength="43" id="password" name="password" type="password" pattern="[A-Za-z0-9_+/-]+" autocomplete="new-password" required>
<p class="field-help">Use Base64-compatible characters for legacy encryption compatibility.</p>
<button class="button primary" type="submit">Create vault</button>
</form>
</section>
{% endblock %}
+38 -42
View File
@@ -1,45 +1,41 @@
{% extends "common.html" %}
{% block title %}{% if flower.organization %}Edit{% else %}New entry{% endif %} · Flowers{% endblock %}
{% block menuoptions %}
<li class="pure-menu-item pure-menu-selected"><a href="javascript:$('#newFlower').submit()" class="pure-menu-link">Save</a></li>
<li class="pure-menu-item pure-menu-selected"><a href="javascript:DoSubmit('deactivate','{{ flower.organization }}','{{ flower.dateCreated }}','')" class="pure-menu-link">Delete</a></li>
<li class="pure-menu-item pure-menu-selected"><a href="javascript:DoSubmit('list','','','')" class="pure-menu-link">Cancel</a></li>
{% endblock menuoptions %}
{% 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>
</div>
</div>
<script language="javascript">
</script>
<form action="{{ url_for('flower_vase.application') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="list">
<button class="button ghost compact" type="submit">Cancel</button>
</form>
{% endblock %}
{% block content %}
<section class="editor-layout">
<div>
<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>
<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 %}
+22 -35
View File
@@ -1,37 +1,24 @@
{% extends "common.html" %}
{% block headandmenu %}
<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 title %}Sign in · Flowers{% endblock %}
{% block menuoptions %}
<a class="text-link" href="{{ url_for('flower_vase.create') }}">Create a vault</a>
{% endblock %}
{% block content %}
<div class="splash-container">
<div class="splash">
<form class="pure-form pure-form-aligned" action="{{ url_for('flower_vase.application') }}" method="post">
<fieldset>
<div class="pure-control-group">
<input name="name" type="text" placeholder="{{ name_suggestion }}">
</div>
<div class="pure-control-group">
<input name="password" type="password" placeholder="{{ pwd_suggestion }}">
</div>
<div class="pure-controls">
<button type="submit" class="pure-button pure-button-primary">Submit</button>
</div>
</fieldset>
<input type="hidden" id="action" name="action" value="login">
<input type="hidden" id="filter" name="filter" value="DoNotSetFilterinCookie">
</form>
</div>
</div>
{% endblock content %}
<section class="auth-layout">
<div class="intro">
<p class="eyebrow">Your private collection</p>
<h1>Credentials, kept close.</h1>
<p>Open your existing vault. Your encrypted records stay in the same database and format.</p>
</div>
<form class="card form-card" action="{{ url_for('flower_vase.application') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="login">
<h2>Sign in</h2>
<label for="name">Username</label>
<input id="name" name="name" type="text" autocomplete="username" placeholder="{{ name_suggestion }}" required autofocus>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" placeholder="{{ pwd_suggestion }}" required>
<button class="button primary" type="submit">Open vault</button>
</form>
</section>
{% endblock %}
+72 -45
View File
@@ -1,51 +1,78 @@
{% 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 %}
<section class="vault-header">
<div>
<p class="eyebrow">Active entries</p>
<h1>Your vault</h1>
</div>
<label class="search-field" for="search">
<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="content-wrapper">
<div class="content">
<div class="pure-g">
<div class="l-box pure-u-1 pure-u-md-1-2 pure-u-lg-1-5">
</div>
<div class="l-box pure-u-1 pure-u-md-1-2 pure-u-lg-3-5">
<table id="flowerlist" class="flowerlist pure-table pure-table-horizontal">
{% for flower in flowers %}
<tr class="flowerlist">
<td class="flowerlist">
<a href="javascript:DoSubmit('show', '{{flower.organization}}', '{{flower.dateCreated}}' , '')">
{{ flower.organization }}, {{ flower.myID }}
</a>
</td>
<td class="right"><span style="white-space: nowrap;">{{ flower.dateCreated[:10] }}</span></td>
</tr>
{% endfor %}
</table>
</div>
<div class="l-box pure-u-1 pure-u-md-1-2 pure-u-lg-1-5">
</div>
</div>
</div>
<div class="entry-list" id="entry-list">
{% for flower in flowers %}
<form class="entry-row" data-search="{{ flower.organization }} {{ flower.myID }}" action="{{ url_for('flower_vase.application') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="show">
<input type="hidden" name="organization" value="{{ flower.organization }}">
<input type="hidden" name="datetime" value="{{ flower.dateCreated }}">
<input type="hidden" name="filter" class="current-filter" value="{{ filter|default('') }}">
<button type="submit" class="entry-button">
<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 %}
</div>
<p class="empty-state filtered-empty" id="filtered-empty" hidden>No entries match that search.</p>
<script language="javascript">
var $rows = $('#flowerlist tr');
$('#search').keyup(function() {
var val = $.trim($(this).val()).replace(/ +/g, ' ').toLowerCase();
$rows.show().filter(function() {
var text = $(this).text().replace(/\s+/g, ' ').toLowerCase();
return !~text.indexOf(val);
}).hide();
});
$('#search').keyup();
<script>
const search = document.querySelector('#search');
const rows = [...document.querySelectorAll('.entry-row')];
const filteredEmpty = document.querySelector('#filtered-empty');
function filterRows() {
const query = search.value.trim().toLocaleLowerCase();
let visible = 0;
rows.forEach((row) => {
const match = row.dataset.search.toLocaleLowerCase().includes(query);
row.hidden = !match;
row.querySelector('.current-filter').value = search.value;
if (match) visible += 1;
});
filteredEmpty.hidden = visible !== 0 || rows.length === 0;
}
search.addEventListener('input', filterRows);
filterRows();
</script>
{% endblock content %}
{% endblock %}
+22 -38
View File
@@ -1,40 +1,24 @@
{% extends "common.html" %}
{% block headandmenu %}
<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 title %}Change password · Flowers{% endblock %}
{% block menuoptions %}<a class="text-link" href="{{ url_for('flower_vase.index') }}">Back to sign in</a>{% endblock %}
{% block content %}
<div class="splash-container">
<div class="splash">
<form class="pure-form pure-form-aligned" action="{{ url_for('flower_vase.update_pwd') }}" method="post">
<fieldset>
<div class="pure-control-group">
<input name="name" type="text" placeholder="your existing name">
</div>
<div class="pure-control-group">
<input name="old_password" type="password" placeholder="your existing password">
</div>
<div class="pure-control-group">
<input maxlength="10" name="new_password" type="password" placeholder="make up you password, the longer the better">
</div>
<div class="pure-controls">
<button type="submit" class="pure-button pure-button-primary">Update Password</button>
</div>
</fieldset>
</form>
{{ message }}
</div>
</div>
{% endblock content %}
<section class="auth-layout">
<div class="intro">
<p class="eyebrow">Re-encrypt your vault</p>
<h1>Change password.</h1>
<p>Every existing encrypted field will be rewritten with the new legacy-compatible key.</p>
</div>
<form class="card form-card" action="{{ url_for('flower_vase.update_pwd') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h2>Vault credentials</h2>
{% if message %}<p class="field-error">{{ message }}</p>{% endif %}
<label for="name">Username</label>
<input id="name" name="name" type="text" autocomplete="username" required>
<label for="old-password">Current password</label>
<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>
<button class="button primary" type="submit">Re-encrypt vault</button>
</form>
</section>
{% endblock %}
+58 -44
View File
@@ -1,47 +1,61 @@
{% extends "common.html" %}
{% block title %}{{ flower.organization }} · Flowers{% endblock %}
{% block menuoptions %}
<li class="pure-menu-item pure-menu-selected"><a href="javascript:editFlower()" class="pure-menu-link">Edit</a></li>
{% endblock menuoptions %}
{% block content %}
<div class="splash-container">
<div class="splash">
<table class="pure-table" style="width: 100%">
<tr><td class="white right">Org</td><td class="white left">{{ flower.organization }}</td> </tr>
<tr><td class="white right">You</td><td class="white left">{{ flower.myName }}</td> </tr>
<tr><td class="white right">Login</td><td class="white left">{{ flower.myID }}</td> </tr>
<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>
<tr><td class="white">{{ flower.dateCreated[:10] }}</td><td></td> </tr>
</table>
</div>
</div>
<script language="javascript">
setTimeout(function(){
DoSubmit('list', '{{flower.organization}}', '{{flower.dateCreated}}', 'DoNotSetFilterinCookie')
}, 5000);
function editFlower() {
DoSubmit('edit', '{{flower.organization}}', '{{flower.dateCreated}}', 'DoNotSetFilterinCookie')
}
function myCopyFunction() {
var copyText = document.getElementById("secret");
// Copy the text inside the text field
navigator.clipboard.writeText(copyText.getAttribute("value"));
// alert("Copied the text: "+copyText.getAttribute("value"));
$("#secrettd").removeClass("white");
$("#secrettd").addClass("blue");
}
</script>
<form action="{{ url_for('flower_vase.application') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="list">
<input type="hidden" name="filter" value="{{ filter|default('') }}">
<button class="button ghost compact" type="submit">Back to vault</button>
</form>
{% endblock %}
{% block content %}
<section class="detail-card card">
<div class="detail-heading">
<span class="entry-icon large" aria-hidden="true">{{ flower.organization[:1]|upper }}</span>
<div>
<p class="eyebrow">Credential</p>
<h1>{{ flower.organization }}</h1>
{% if flower.dateCreated not in ('STORED', 'FAILED') %}<p class="muted">Version from {{ flower.dateCreated[:10] }}</p>{% endif %}
</div>
</div>
<dl class="details">
<div><dt>Name</dt><dd>{{ flower.myName or '—' }}</dd></div>
<div><dt>Login</dt><dd>{{ flower.myID }}</dd></div>
<div>
<dt>Secret</dt>
<dd class="secret-line">
<span id="secret" data-secret="{{ flower.mySecret }}">••••••••••••</span>
<button class="icon-button" id="reveal" type="button">Reveal</button>
<button class="icon-button" id="copy" type="button">Copy</button>
</dd>
</div>
</dl>
{% if flower.dateCreated not in ('STORED', 'FAILED') %}
<div class="detail-actions">
<form action="{{ url_for('flower_vase.application') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="edit">
<input type="hidden" name="organization" value="{{ flower.organization }}">
<input type="hidden" name="datetime" value="{{ flower.dateCreated }}">
<button class="button primary" type="submit">Edit entry</button>
</form>
</div>
{% endif %}
</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 %}
+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()