├── .codeclimate.yml ├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .whitesource ├── .yarnrc ├── LICENSE ├── Makefile ├── README.md ├── docker-compose-mysql.yml ├── docker-compose-postgres.yml ├── dockerfiles ├── alpine.dev.Dockerfile ├── alpine.dev.mysql.Dockerfile └── alpine.gunicorn.Dockerfile ├── docs └── swagger.yaml ├── environments ├── dev-docker-mysql.env └── dev-docker-postgres.env ├── package.json ├── pyproject.toml ├── requirements.dev.txt ├── requirements.mysql.txt ├── requirements.txt ├── shhh ├── __init__.py ├── adapters │ ├── __init__.py │ └── orm.py ├── api │ ├── __init__.py │ ├── api.py │ ├── handlers.py │ └── schemas.py ├── config.py ├── constants.py ├── domain │ ├── __init__.py │ └── model.py ├── entrypoint.py ├── extensions.py ├── liveness.py ├── migrations │ ├── 1730636773_initial.py │ ├── 1730637997_add_indexes.py │ └── script.py.mako ├── scheduler │ ├── __init__.py │ └── tasks.py ├── static │ ├── dist │ │ ├── css │ │ │ └── .gitkeep │ │ └── js │ │ │ └── .gitkeep │ ├── img │ │ └── logo.png │ ├── robots.txt │ └── src │ │ ├── css │ │ └── styles.css │ │ └── js │ │ ├── create.js │ │ ├── created.js │ │ └── read.js ├── templates │ ├── base.html │ ├── create.html │ ├── created.html │ ├── error.html │ └── read.html └── web │ ├── __init__.py │ └── web.py ├── tests ├── __init__.py ├── conftest.py ├── test_api.py ├── test_db_liveness.py ├── test_scheduler.py └── test_web.py ├── wsgi.py └── yarn.lock /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | checks: 3 | argument-count: 4 | enabled: false 5 | plugins: 6 | bandit: 7 | enabled: true 8 | exclude_patterns: 9 | - "**/.*" 10 | - "*.txt" 11 | - "*.json" 12 | - "Procfile" 13 | - "tests/" 14 | - "bin/" 15 | - "dockerenv/" 16 | - "postgres/" 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.db 2 | *.DS_Store 3 | *.log 4 | *.min.css 5 | *.min.js 6 | *.pid 7 | *.rdb 8 | 9 | .codeclimate.yml 10 | .coverage 11 | .coveragerc 12 | .gitignore 13 | .whitesource 14 | app.json 15 | docker-compose.yml 16 | Dockerfile 17 | Makefile 18 | Procfile 19 | README.md 20 | run-local.sh 21 | runtime.txt 22 | pyproject.toml 23 | 24 | .ruff_cache/ 25 | .mypy_cache/ 26 | .git/ 27 | .github/ 28 | __pycache__/ 29 | app.egg-info/ 30 | bin/ 31 | config/ 32 | docs/ 33 | environments/ 34 | env/ 35 | postgres/ 36 | shhh/static/vendor/ 37 | tests/ 38 | venv/ 39 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 20 8 | - package-ecosystem: npm 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | open-pull-requests-limit: 20 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | python-version: ["3.12"] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install -r requirements.txt -r requirements.dev.txt 23 | - name: Linting checks 24 | run: | 25 | # stop the build if there are any Python syntax errors 26 | ruff check shhh tests --exclude shhh/migrations/ --exclude shhh/static/ 27 | - name: SAST analysis 28 | run: | 29 | bandit -r shhh -x shhh/static 30 | - name: Test suite 31 | run: | 32 | pytest --cov=shhh tests 33 | - name: Upload coverage to Codecov 34 | uses: codecov/codecov-action@v3 35 | with: 36 | token: ${{ secrets.CODECOV_TOKEN }} 37 | fail_ci_if_error: false 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | *.DS_Store 3 | *.log 4 | *.min.css 5 | *.min.js 6 | *.pid 7 | *.rdb 8 | 9 | .coverage 10 | tags 11 | 12 | .ruff_cache/ 13 | .mypy_cache/ 14 | .vscode/ 15 | __pycache__/ 16 | env/ 17 | shhh/static/vendor/ 18 | venv/ 19 | -------------------------------------------------------------------------------- /.whitesource: -------------------------------------------------------------------------------- 1 | { 2 | "checkRunSettings": { 3 | "vulnerableCheckRunConclusionLevel": "failure" 4 | }, 5 | "issueSettings": { 6 | "minSeverityLevel": "LOW" 7 | } 8 | } -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | --modules-folder ./shhh/static/vendor 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Matthieu Petiteau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL = /bin/bash 2 | SRC_DIR = shhh 3 | TEST_DIR = tests 4 | 5 | .PHONY: help 6 | help: ## Show this help menu 7 | @echo "Usage: make [TARGET ...]" 8 | @echo "" 9 | @grep --no-filename -E '^[a-zA-Z_%-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ 10 | awk 'BEGIN {FS = ":.*?## "}; {printf "%-25s %s\n", $$1, $$2}' 11 | 12 | .PHONY: dc-start 13 | dc-start: dc-stop ## Start dev docker server 14 | @docker compose -f docker-compose-postgres.yml up --build --scale adminer=0 -d; 15 | 16 | .PHONY: dc-start-adminer 17 | dc-start-adminer: dc-stop ## Start dev docker server (with adminer) 18 | @docker compose -f docker-compose-postgres.yml up --build -d; 19 | 20 | .PHONY: dc-stop 21 | dc-stop: ## Stop dev docker server 22 | @docker compose -f docker-compose-postgres.yml stop; 23 | 24 | .PHONY: dc-start-mysql 25 | dc-start-mysql: dc-stop ## Start dev docker server using MySQL 26 | @docker compose -f docker-compose-mysql.yml up --build --scale adminer=0 -d; 27 | 28 | .PHONY: dc-start-adminer-mysql 29 | dc-start-adminer-mysql: dc-stop ## Start dev docker server using MySQL (with adminer) 30 | @docker compose -f docker-compose-mysql.yml up --build -d; 31 | 32 | .PHONY: dc-stop-mysql 33 | dc-stop-mysql: ## Stop dev docker server using MySQL 34 | @docker compose -f docker-compose-mysql.yml stop; 35 | 36 | VENV = .venv 37 | VENV_PYTHON = $(VENV)/bin/python 38 | SYSTEM_PYTHON = $(shell which python3.12) 39 | PYTHON = $(wildcard $(VENV_PYTHON)) 40 | 41 | $(VENV_PYTHON): 42 | rm -rf $(VENV) 43 | $(SYSTEM_PYTHON) -m venv $(VENV) 44 | 45 | .PHONY: venv 46 | venv: $(VENV_PYTHON) ## Create a Python virtual environment 47 | 48 | .PHONY: deps 49 | deps: ## Install Python requirements in virtual environment 50 | $(PYTHON) -m pip install --upgrade pip 51 | $(PYTHON) -m pip install --no-cache-dir -r requirements.txt -r requirements.dev.txt 52 | 53 | .PHONY: checks 54 | checks: tests ruff mypy bandit ## Run all checks (unit tests, ruff, mypy, bandit) 55 | 56 | .PHONY: tests 57 | tests: ## Run unit tests 58 | @echo "Running tests..." 59 | $(PYTHON) -m pytest --cov=shhh tests 60 | 61 | .PHONY: yapf 62 | yapf: ## Format python code with yapf 63 | @echo "Running Yapf..." 64 | $(PYTHON) -m yapf --recursive --in-place $(SRC_DIR) $(TEST_DIR) 65 | 66 | .PHONY: ruff 67 | ruff: ## Run ruff 68 | @echo "Running Ruff report..." 69 | $(PYTHON) -m ruff check $(SRC_DIR) $(TEST_DIR) --exclude shhh/migrations/ --exclude shhh/static/ 70 | 71 | .PHONY: mypy 72 | mypy: ## Run mypy 73 | @echo "Running Mypy report..." 74 | $(PYTHON) -m mypy $(SRC_DIR) 75 | 76 | .PHONY: bandit 77 | bandit: ## Run bandit 78 | @echo "Running Bandit report..." 79 | $(PYTHON) -m bandit -r $(SRC_DIR) -x $(SRC_DIR)/static 80 | 81 | .PHONY: yarn 82 | yarn: ## Install frontend deps using Yarn 83 | @echo "Installing yarn deps..." 84 | @yarn install >/dev/null 85 | 86 | .PHONY: shell 87 | shell: ## Pop up a Flask shell in Shhh 88 | docker exec -it shhh-app-1 flask shell 89 | 90 | .PHONY: db 91 | db: ## Run flask db command, ex: `make db c='--help'` 92 | docker exec -it shhh-app-1 flask db $(c) 93 | 94 | .PHONY: logs 95 | logs: ## Follow Flask logs 96 | docker logs shhh-app-1 -f -n 10 97 | 98 | .PHONY: db-logs 99 | db-logs: ## Follow database logs 100 | docker logs shhh-db-1 -f -n 10 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

Keep secrets out of emails and chat logs.

5 | 6 |

7 | 8 | 9 | 10 |

11 | 12 | ## What is it? 13 | 14 | **Shhh** is a tiny Flask app to create encrypted secrets and share 15 | them securely with people. The goal of this application is to get rid 16 | of plain text sensitive information into emails or chat logs. 17 | 18 | Shhh is deployed [here](https://www.shhh-encrypt.com) (_temporary unavailable 19 | until new deployment solution_), but **it's better for organisations and people 20 | to deploy it on their own personal / private server** for even better security. 21 | You can find in this repo everything you need to host the app yourself. 22 | 23 | Or you can **one-click deploy to Heroku** using the below button. 24 | It will generate a fully configured private instance of Shhh 25 | immediately (using your own server running Flask behind Gunicorn and Nginx, 26 | and your own PostgreSQL database). You can see the Heroku configuration files [here](https://github.com/smallwat3r/shhh-heroku-deploy/tree/main). 27 | 28 | [![Deploy][heroku-shield]][heroku] (see [here](#initiate-the-database-tables) to 29 | initiate the db tables after deploying on Heroku) 30 | 31 | Also, checkout [shhh-cli](https://github.com/smallwat3r/shhh-cli), 32 | a Go client to interact with the Shhh API from the command line. 33 | 34 | ## How does it work? 35 | 36 | The sender has to set an expiration date along with a passphrase to 37 | protect the information he wants to share. 38 | 39 | A unique link is generated by Shhh that the sender can share with the 40 | receiver in an email, alongside the temporary passphrase he created 41 | in order to reveal the secret. 42 | 43 | The secret will be **permanently removed** from the database as soon 44 | as one of these events happens: 45 | 46 | * the expiration date has passed. 47 | * the receiver has decrypted the message. 48 | * the amount of tries to open the secret has exceeded. 49 | 50 | The secrets are encrypted in order to make the data anonymous, 51 | especially in the database, and the passphrases are not stored 52 | anywhere. 53 | 54 | _Encryption method used: Fernet with password, random salt value and 55 | strong iteration count (100 000)._ 56 | 57 | _Tip: for better security, avoid writing any info on how/where to use the secret you're sharing (like urls, websites or emails). Instead, explain this in your email or chat, with the link and passphrase generated from Shhh. So even if someone got access to your secret, there is no way for the attacker to know how and where to use it._ 58 | 59 | ## Is there an API? 60 | 61 | Yes, you can find some doc [here](https://app.swaggerhub.com/apis-docs/smallwat3r/shhh-api/1.0.0). 62 | 63 | ## How to launch Shhh? 64 | 65 | These instructions are for development purpose only. For production 66 | use you might want to use a more secure configuration. 67 | 68 | #### Deps 69 | 70 | The application will use the development env variables from [/environments/dev-docker-postgres.env](https://github.com/smallwat3r/shhh/blob/master/environments/dev-docker-postgres.env). 71 | 72 | #### Docker 73 | 74 | From the root of the repository, run 75 | 76 | ```sh 77 | make dc-start # to start the app 78 | make dc-start-adminer # to start the app with adminer (SQL editor) 79 | make dc-stop # to stop the app 80 | ``` 81 | 82 | Once the container image has finished building and has started, you 83 | can access: 84 | 85 | * Shhh at 86 | * Adminer at (if launched with `dc-start-adminer`) 87 | 88 | _You can find the development database credentials from the env file at [/environments/dev-docker-postgres.env](https://github.com/smallwat3r/shhh/blob/master/environments/dev-docker-postgres.env)._ 89 | 90 | You have also the option to use `MySQL` instead of `PostgreSQL`, using these commands: 91 | ```sh 92 | make dc-start-mysql # to start the app 93 | make dc-start-adminer-mysql # to start the app with adminer (SQL editor) 94 | make dc-stop-mysql # to stop the app 95 | ``` 96 | 97 | #### Migrations 98 | 99 | Run the migrations using: 100 | ``` sh 101 | make db c='upgrade' 102 | ``` 103 | 104 | If deployed on Heroku, you can run the migrations using: 105 | ``` sh 106 | heroku run --app= python3 -m flask db upgrade 107 | ``` 108 | 109 | This will ensure the necessary tables are created and up-to-date in the database, 110 | and make sure your deployed Shhh application works as expected. 111 | 112 | You can write a revision using: 113 | ``` sh 114 | make db c='revision "my revision"' 115 | ``` 116 | 117 | #### Development tools 118 | 119 | You can run tests and linting / security reports using the Makefile. 120 | 121 | Make sure you have `make`, `docker`, `yarn`, and a version of Python 3.12 installed on your machine. 122 | 123 | Tests, linting, security tools do not run from the Docker container, so you need to have a Python 124 | virtual environment configured locally. 125 | 126 | You can do so with the following command: 127 | ``` sh 128 | make venv deps 129 | ``` 130 | 131 | The following command will display all the commands available from the Makefile: 132 | ``` sh 133 | make help 134 | ``` 135 | 136 | * Enter a Flask shell (from the running shhh container) 137 | ``` sh 138 | make shell 139 | ``` 140 | 141 | * Run sanity checks 142 | ```sh 143 | make tests # run tests 144 | make ruff # run Ruff report 145 | make bandit # run Bandit report 146 | make mypy # run Mypy report 147 | ``` 148 | 149 | * Run code formatter 150 | ```sh 151 | make yapf # format code using Yapf 152 | ``` 153 | 154 | * Generate frontend lockfile 155 | ```sh 156 | make yarn # install the frontend deps using Yarn 157 | ``` 158 | 159 | ## Environment variables 160 | 161 | Bellow is the list of environment variables used by Shhh. 162 | 163 | #### Mandatory 164 | * `FLASK_ENV`: the environment config to load (`testing`, `dev-local`, `dev-docker`, `heroku`, `production`). 165 | * `DB_HOST`: Database hostname 166 | * `DB_USER`: Database username 167 | * `DB_PASSWORD`: Database password 168 | * `DB_NAME`: Database name 169 | * `DB_ENGINE`: Database engine to use (ex: `postgresql+psycopg2`, `mysql+pymysql`) 170 | 171 | Depending if you can use PostgreSQL or MySQL you might also need to set (these need to match the values 172 | you've specified as `DB_NAME`, `DB_PASSWORD` and `DB_NAME` above): 173 | 174 | * `POSTGRES_USER`: Postgresql username 175 | * `POSTGRES_PASSWORD`: Postgresql password 176 | * `POSTGRES_DB`: Postgresql database name 177 | 178 | or 179 | 180 | * `MYSQL_USER`: MySQL username 181 | * `MYSQL_PASSWORD`: MySQL password 182 | * `MYSQL_DATABASE`: MySQL database name 183 | 184 | #### Optional 185 | * `SHHH_HOST`: This variable can be used to specify a custom hostname to use as the 186 | domain URL when Shhh creates a secret (ex: `https://`). If not set, the hostname 187 | defaults to request.url_root, which should be fine in most cases. 188 | * `SHHH_SECRET_MAX_LENGTH`: This variable manages how long the secrets your share with Shhh can 189 | be. It defaults to 250 characters. 190 | * `SHHH_DB_LIVENESS_RETRY_COUNT`: This variable manages the number of tries to reach the database 191 | before performing a read or write operation. It could happens that the database is not reachable or is 192 | asleep (for instance this happens often on Heroku free plans). The default retry number is 5. 193 | * `SHHH_DB_LIVENESS_SLEEP_INTERVAL`: This variable manages the interval in seconds between the database 194 | liveness retries. The default value is 1 second. 195 | 196 | ## License 197 | 198 | See [LICENSE](https://github.com/smallwat3r/shhh/blob/master/LICENSE) file. 199 | 200 | ## Contact 201 | 202 | Please report issues or questions 203 | [here](https://github.com/smallwat3r/shhh/issues). 204 | 205 | 206 | [![Buy me a coffee][buymeacoffee-shield]][buymeacoffee] 207 | 208 | 209 | [buymeacoffee-shield]: https://www.buymeacoffee.com/assets/img/guidelines/download-assets-sm-2.svg 210 | [buymeacoffee]: https://www.buymeacoffee.com/smallwat3r 211 | 212 | [heroku-shield]: https://www.herokucdn.com/deploy/button.svg 213 | [heroku]: https://heroku.com/deploy?template=https://github.com/smallwat3r/shhh-heroku-deploy 214 | -------------------------------------------------------------------------------- /docker-compose-mysql.yml: -------------------------------------------------------------------------------- 1 | version: '3.2' 2 | services: 3 | db: 4 | image: mysql:8.2 5 | env_file: 6 | - ./environments/dev-docker-mysql.env 7 | ports: 8 | - 3306:3306 9 | app: 10 | build: 11 | context: . 12 | dockerfile: dockerfiles/alpine.dev.mysql.Dockerfile 13 | image: shhh 14 | depends_on: 15 | - db 16 | env_file: 17 | - ./environments/dev-docker-mysql.env 18 | ports: 19 | - 8081:8081 20 | volumes: 21 | - .:/opt/shhh:delegated 22 | adminer: 23 | image: adminer 24 | depends_on: 25 | - db 26 | ports: 27 | - 8082:8080 28 | volumes: 29 | mysql: 30 | -------------------------------------------------------------------------------- /docker-compose-postgres.yml: -------------------------------------------------------------------------------- 1 | version: '3.2' 2 | services: 3 | db: 4 | image: postgres:15.4-alpine3.18 5 | env_file: 6 | - ./environments/dev-docker-postgres.env 7 | ports: 8 | - 5432:5432 9 | app: 10 | build: 11 | context: . 12 | dockerfile: dockerfiles/alpine.dev.Dockerfile 13 | image: shhh 14 | depends_on: 15 | - db 16 | env_file: 17 | - ./environments/dev-docker-postgres.env 18 | ports: 19 | - 8081:8081 20 | volumes: 21 | - .:/opt/shhh:delegated 22 | adminer: 23 | image: adminer 24 | depends_on: 25 | - db 26 | ports: 27 | - 8082:8080 28 | volumes: 29 | postgres: 30 | -------------------------------------------------------------------------------- /dockerfiles/alpine.dev.Dockerfile: -------------------------------------------------------------------------------- 1 | # This dockerfile runs the application with the bare Flask 2 | # server. As it's for development only purposes. 3 | # 4 | # When using Gunicorn in a more prod-like config, multiple 5 | # workers would require to use the --preload option, else 6 | # the scheduler would spawn multiple scheduler instances. 7 | # 8 | # Note it would not be comptatible with Gunicorn --reload 9 | # flag, which is useful to reload the app on change, for 10 | # development purposes. 11 | # 12 | # Example: CMD gunicorn -b :8081 -w 3 wsgi:app --preload 13 | # 14 | # To use Gunicorn, please use: alpine.gunicorn.Dockerfile 15 | 16 | FROM python:3.12-alpine3.18 17 | 18 | RUN apk update \ 19 | && apk add --no-cache \ 20 | gcc \ 21 | g++ \ 22 | libffi-dev \ 23 | musl-dev \ 24 | postgresql-dev \ 25 | yarn \ 26 | && python -m pip install --upgrade pip 27 | 28 | ENV TZ UTC 29 | 30 | WORKDIR /opt/shhh 31 | 32 | ARG GROUP=app USER=shhh UID=1001 GID=1001 33 | 34 | RUN addgroup --gid "$GID" "$GROUP" \ 35 | && adduser --uid "$UID" --disabled-password --gecos "" \ 36 | --ingroup "$GROUP" "$USER" 37 | 38 | USER $USER 39 | ENV PATH="/home/$USER/.local/bin:${PATH}" 40 | 41 | ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1 42 | 43 | COPY requirements.txt . 44 | RUN pip install --no-cache-dir --no-warn-script-location \ 45 | --user -r requirements.txt 46 | 47 | COPY --chown=$USER:$GROUP . . 48 | 49 | RUN yarn install --modules-folder=shhh/static/vendor 50 | 51 | CMD flask run --host=0.0.0.0 --port 8081 --reload 52 | -------------------------------------------------------------------------------- /dockerfiles/alpine.dev.mysql.Dockerfile: -------------------------------------------------------------------------------- 1 | # This dockerfile runs the application with the bare Flask 2 | # server. As it's for development only purposes. 3 | # 4 | # When using Gunicorn in a more prod-like config, multiple 5 | # workers would require to use the --preload option, else 6 | # the scheduler would spawn multiple scheduler instances. 7 | # 8 | # Note it would not be comptatible with Gunicorn --reload 9 | # flag, which is useful to reload the app on change, for 10 | # development purposes. 11 | # 12 | # Example: CMD gunicorn -b :8081 -w 3 wsgi:app --preload 13 | # 14 | # To use Gunicorn, please use: alpine.gunicorn.Dockerfile 15 | 16 | FROM python:3.12-alpine3.18 17 | 18 | RUN apk update \ 19 | && apk add --no-cache \ 20 | gcc \ 21 | g++ \ 22 | libffi-dev \ 23 | musl-dev \ 24 | mariadb-dev \ 25 | yarn \ 26 | && python -m pip install --upgrade pip 27 | 28 | ENV TZ UTC 29 | 30 | WORKDIR /opt/shhh 31 | 32 | ARG GROUP=app USER=shhh UID=1001 GID=1001 33 | 34 | RUN addgroup --gid "$GID" "$GROUP" \ 35 | && adduser --uid "$UID" --disabled-password --gecos "" \ 36 | --ingroup "$GROUP" "$USER" 37 | 38 | USER $USER 39 | ENV PATH="/home/$USER/.local/bin:${PATH}" 40 | 41 | ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1 42 | 43 | COPY requirements.mysql.txt . 44 | RUN pip install --no-cache-dir --no-warn-script-location \ 45 | --user -r requirements.mysql.txt 46 | 47 | COPY --chown=$USER:$GROUP . . 48 | 49 | RUN yarn install --modules-folder=shhh/static/vendor 50 | 51 | CMD flask run --host=0.0.0.0 --port 8081 --reload 52 | -------------------------------------------------------------------------------- /dockerfiles/alpine.gunicorn.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-alpine3.18 2 | 3 | RUN apk update \ 4 | && apk add --no-cache \ 5 | gcc \ 6 | g++ \ 7 | libffi-dev \ 8 | musl-dev \ 9 | postgresql-dev \ 10 | yarn \ 11 | && python -m pip install --upgrade pip 12 | 13 | ENV TZ UTC 14 | 15 | WORKDIR /opt/shhh 16 | 17 | ARG GROUP=app USER=shhh UID=1001 GID=1001 18 | 19 | RUN addgroup --gid "$GID" "$GROUP" \ 20 | && adduser --uid "$UID" --disabled-password --gecos "" \ 21 | --ingroup "$GROUP" "$USER" 22 | 23 | USER $USER 24 | ENV PATH="/home/$USER/.local/bin:${PATH}" 25 | 26 | ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1 27 | 28 | COPY requirements.txt . 29 | RUN pip install --no-cache-dir --no-warn-script-location \ 30 | --user -r requirements.txt 31 | 32 | COPY --chown=$USER:$GROUP . . 33 | 34 | RUN yarn install --modules-folder=shhh/static/vendor 35 | 36 | CMD gunicorn -b :8081 -w 3 wsgi:app --preload 37 | -------------------------------------------------------------------------------- /docs/swagger.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | description: Share sensitive info without leaving a trace in your chat logs or email accounts. 4 | version: 1.0.0 5 | title: Shhh API documentation 6 | 7 | externalDocs: 8 | description: Source code repository 9 | url: https://github.com/smallwat3r/shhh 10 | 11 | paths: 12 | /api/secret: 13 | get: 14 | summary: Read a secret 15 | description: Decrypt a secret using a passphrase 16 | operationId: readSecret 17 | produces: 18 | - application/json 19 | parameters: 20 | - in: query 21 | type: string 22 | name: slug 23 | description: The secret Id 24 | required: true 25 | - in: query 26 | name: passphrase 27 | type: string 28 | description: The passphrase to decrypt the secret 29 | required: true 30 | responses: 31 | 200: 32 | description: OK 33 | 401: 34 | description: The passphrase is not valid 35 | 404: 36 | description: No secret found 37 | 422: 38 | description: There is an error in the querystring parameters 39 | post: 40 | summary: Create a secret 41 | description: Encrypt a secret using a passphrase, and set up an expiration policy 42 | operationId: createSecret 43 | consumes: 44 | - application/json 45 | produces: 46 | - application/json 47 | parameters: 48 | - in: body 49 | name: payload 50 | description: Secret 51 | schema: 52 | $ref: "#/definitions/createSecret" 53 | responses: 54 | 201: 55 | description: Created 56 | 422: 57 | description: There is an error in the request parameters 58 | 59 | definitions: 60 | createSecret: 61 | type: object 62 | required: 63 | - secret 64 | - passphrase 65 | properties: 66 | secret: 67 | type: string 68 | description: The secret message to encrypt (max. 250 chars) 69 | example: "This is a secret message" 70 | passphrase: 71 | type: string 72 | description: The passphrase to open the secret (min. 8 chars, 1 uppercase, 1 number) 73 | example: "HuDgde723g8f" 74 | expire: 75 | type: string 76 | default: 3d 77 | description: How long to keep the secret alive (10m, 30m, 1h, 3h, 6h, 1d, 2d, 3d, 5d or 7d) 78 | example: 30m 79 | tries: 80 | type: integer 81 | default: 5 82 | description: The number of tries to open the secret before it gets deleted (3, 5 or 10) 83 | example: 3 84 | -------------------------------------------------------------------------------- /environments/dev-docker-mysql.env: -------------------------------------------------------------------------------- 1 | # dev-docker-mysql env 2 | # env file for local development purposes only 3 | # please do not use this env file in production 4 | 5 | # [MANDATORY] 6 | 7 | FLASK_ENV=dev-docker 8 | FLASK_DEBUG=1 9 | 10 | MYSQL_RANDOM_ROOT_PASSWORD=true 11 | 12 | MYSQL_USER=shhh 13 | MYSQL_PASSWORD=dummypassword 14 | MYSQL_DATABASE=shhh 15 | 16 | DB_USER=$MYSQL_USER 17 | DB_PASSWORD=$MYSQL_PASSWORD 18 | DB_NAME=$MYSQL_DATABASE 19 | DB_HOST=db 20 | DB_PORT=3306 21 | DB_ENGINE=mysql+pymysql 22 | 23 | PGDATA=/data/postgres 24 | 25 | # [OPTIONAL] 26 | 27 | # This variable can be used to specify a custom hostname to use as the 28 | # domain URL when Shhh creates a secret (ex: https://). If not 29 | # set, the hostname defaults to request.url_root, which should be fine in 30 | # most cases. 31 | SHHH_HOST= 32 | 33 | # Default max secret length 34 | SHHH_SECRET_MAX_LENGTH= 35 | 36 | # Number of tries to reach the database before performing a read or write operation. It 37 | # could happens that the database is not reachable or is asleep (for instance this happens 38 | # often on Heroku free plans). The default retry number is 5. 39 | SHHH_DB_LIVENESS_RETRY_COUNT= 40 | 41 | # Sleep interval in seconds between database liveness retries. The default value is 1 second. 42 | SHHH_DB_LIVENESS_SLEEP_INTERVAL= 43 | -------------------------------------------------------------------------------- /environments/dev-docker-postgres.env: -------------------------------------------------------------------------------- 1 | # dev-docker-postgres env 2 | # env file for local development purposes only 3 | # please do not use this env file in production 4 | 5 | # [MANDATORY] 6 | 7 | FLASK_ENV=dev-docker 8 | FLASK_DEBUG=1 9 | 10 | POSTGRES_USER=shhh 11 | POSTGRES_PASSWORD=dummypassword 12 | POSTGRES_DB=shhh 13 | 14 | DB_USER=$POSTGRES_USER 15 | DB_PASSWORD=$POSTGRES_PASSWORD 16 | DB_NAME=$POSTGRES_DB 17 | DB_HOST=db 18 | DB_PORT=5432 19 | DB_ENGINE=postgresql+psycopg2 20 | 21 | PGDATA=/data/postgres 22 | 23 | # [OPTIONAL] 24 | 25 | # This variable can be used to specify a custom hostname to use as the 26 | # domain URL when Shhh creates a secret (ex: https://). If not 27 | # set, the hostname defaults to request.url_root, which should be fine in 28 | # most cases. 29 | SHHH_HOST= 30 | 31 | # Default max secret length 32 | SHHH_SECRET_MAX_LENGTH= 33 | 34 | # Number of tries to reach the database before performing a read or write operation. It 35 | # could happens that the database is not reachable or is asleep (for instance this happens 36 | # often on Heroku free plans). The default retry number is 5. 37 | SHHH_DB_LIVENESS_RETRY_COUNT= 38 | 39 | # Sleep interval in seconds between database liveness retries. The default value is 1 second. 40 | SHHH_DB_LIVENESS_SLEEP_INTERVAL= 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shhh", 3 | "description": "shhh", 4 | "version": "1.0.0", 5 | "license": "MIT", 6 | "dependencies": { 7 | "bulma": "^1.0.0", 8 | "feather-icons": "^4.29.0", 9 | "hack-font": "^3.3.0", 10 | "typeface-work-sans": "^1.1.13" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.mypy] 2 | python_version = "3.12" 3 | exclude = ["shhh/static"] 4 | warn_return_any = true 5 | warn_unused_configs = true 6 | ignore_missing_imports = true 7 | 8 | [tool.ruff] 9 | select = ["E", "F"] 10 | fixable = ["ALL"] 11 | exclude = [".eggs", ".git", ".mypy_cache", ".ruff_cache", "venv"] 12 | per-file-ignores = {} 13 | line-length = 79 14 | target-version = "py312" 15 | 16 | [tool.yapf] 17 | split_before_logical_operator = true 18 | allow_multiline_dictionary_keys = true 19 | allow_split_before_default_or_named_assigns = true 20 | allow_split_before_dict_value = true 21 | blank_line_before_nested_class_or_def = true 22 | coalesce_brackets = true 23 | column_limit = 79 24 | disable_ending_comma_heuristic = true 25 | each_dict_entry_on_separate_line = true 26 | indent_dictionary_value = true 27 | indent_closing_brackets = false 28 | join_multiple_lines = false 29 | spaces_before_comment = 2 30 | split_all_comma_separated_values = false 31 | split_all_top_level_comma_separated_values = true 32 | split_before_dict_set_generator = true 33 | split_before_dot = true 34 | split_complex_comprehension = true 35 | 36 | [tool.coverage.report] 37 | fail_under = 80 38 | exclude_also = [ 39 | "pragma: no cover", 40 | "if TYPE_CHECKING:" 41 | ] 42 | 43 | [tool.coverage.run] 44 | source = ["."] 45 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | bandit 2 | yapf 3 | mypy 4 | ruff 5 | pytest 6 | pytest-cov 7 | types-requests 8 | -------------------------------------------------------------------------------- /requirements.mysql.txt: -------------------------------------------------------------------------------- 1 | alembic==1.16.1 2 | APScheduler==3.11.0 3 | blinker==1.9.0 4 | certifi==2025.4.26 5 | cffi==1.17.1 6 | charset-normalizer==3.4.2 7 | click==8.1.8 8 | cryptography==45.0.3 9 | cssmin==0.2.0 10 | Flask==3.1.1 11 | Flask-Alembic==3.1.1 12 | Flask-APScheduler==1.13.1 13 | Flask-Assets==2.1.0 14 | Flask-SQLAlchemy==3.1.1 15 | gunicorn==23.0.0 16 | htmlmin==0.1.12 17 | idna==3.10 18 | itsdangerous==2.2.0 19 | Jinja2==3.1.6 20 | jsmin==3.0.1 21 | Mako==1.3.10 22 | MarkupSafe==3.0.2 23 | marshmallow==4.0.0 24 | packaging==25.0 25 | pycparser==2.22 26 | PyMySQL==1.1.1 27 | python-dateutil==2.9.0.post0 28 | pytz==2025.2 29 | requests==2.32.3 30 | six==1.17.0 31 | SQLAlchemy==2.0.41 32 | typing_extensions==4.13.2 33 | tzlocal==5.3.1 34 | urllib3==2.4.0 35 | webargs==8.7.0 36 | webassets==2.0 37 | Werkzeug==3.1.3 38 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.16.1 2 | APScheduler==3.11.0 3 | blinker==1.9.0 4 | certifi==2025.4.26 5 | cffi==1.17.1 6 | charset-normalizer==3.4.2 7 | click==8.1.8 8 | cryptography==45.0.3 9 | cssmin==0.2.0 10 | Flask==3.1.1 11 | Flask-Alembic==3.1.1 12 | Flask-APScheduler==1.13.1 13 | Flask-Assets==2.1.0 14 | Flask-SQLAlchemy==3.1.1 15 | gunicorn==23.0.0 16 | htmlmin==0.1.12 17 | idna==3.10 18 | itsdangerous==2.2.0 19 | Jinja2==3.1.6 20 | jsmin==3.0.1 21 | Mako==1.3.10 22 | MarkupSafe==3.0.2 23 | marshmallow==4.0.0 24 | packaging==25.0 25 | psycopg2-binary==2.9.10 26 | pycparser==2.22 27 | python-dateutil==2.9.0.post0 28 | pytz==2025.2 29 | requests==2.32.3 30 | six==1.17.0 31 | SQLAlchemy==2.0.41 32 | typing_extensions==4.13.2 33 | tzlocal==5.3.1 34 | urllib3==2.4.0 35 | webargs==8.7.0 36 | webassets==2.0 37 | Werkzeug==3.1.3 38 | -------------------------------------------------------------------------------- /shhh/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "3.2.2" 2 | -------------------------------------------------------------------------------- /shhh/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallwat3r/shhh/be3ea82608b64b786137b44c4e81c94090d463c9/shhh/adapters/__init__.py -------------------------------------------------------------------------------- /shhh/adapters/orm.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import registry 2 | 3 | from shhh.constants import DEFAULT_READ_TRIES_VALUE 4 | from shhh.domain import model 5 | from shhh.extensions import db 6 | 7 | metadata = db.MetaData() 8 | 9 | secret = db.Table( 10 | "secret", 11 | metadata, 12 | db.Column("id", db.Integer, primary_key=True, autoincrement=True), 13 | db.Column("encrypted_text", db.LargeBinary), 14 | db.Column("date_created", db.DateTime), 15 | db.Column("date_expires", db.DateTime), 16 | db.Column("external_id", db.String(20), nullable=False), 17 | db.Column("tries", db.Integer, default=DEFAULT_READ_TRIES_VALUE), 18 | db.Index("external_id_idx", "external_id"), 19 | db.Index("date_expires_idx", "date_expires"), 20 | ) 21 | 22 | 23 | def start_mappers() -> None: 24 | mapper_reg = registry() 25 | mapper_reg.map_imperatively(model.Secret, secret) 26 | -------------------------------------------------------------------------------- /shhh/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallwat3r/shhh/be3ea82608b64b786137b44c4e81c94090d463c9/shhh/api/__init__.py -------------------------------------------------------------------------------- /shhh/api/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | from typing import TYPE_CHECKING 5 | 6 | from flask import Blueprint 7 | from flask.views import MethodView 8 | from webargs.flaskparser import abort, parser, use_kwargs 9 | 10 | from shhh.api.handlers import ErrorHandler, ReadHandler, WriteHandler 11 | from shhh.api.schemas import ReadRequest, WriteRequest 12 | 13 | if TYPE_CHECKING: 14 | from typing import NoReturn 15 | 16 | from flask import Response 17 | from marshmallow import ValidationError 18 | 19 | 20 | @parser.error_handler 21 | def handle_parsing_error(err: ValidationError, *args, **kwargs) -> NoReturn: 22 | abort(ErrorHandler(err).make_response()) 23 | 24 | 25 | body = functools.partial(use_kwargs, location="json") 26 | query = functools.partial(use_kwargs, location="query") 27 | 28 | 29 | class Api(MethodView): 30 | 31 | @query(ReadRequest()) 32 | def get(self, *args, **kwargs) -> Response: 33 | return ReadHandler(*args, **kwargs).make_response() 34 | 35 | @body(WriteRequest()) 36 | def post(self, *args, **kwargs) -> Response: 37 | return WriteHandler(*args, **kwargs).make_response() 38 | 39 | 40 | api = Blueprint("api", __name__, url_prefix="/api") 41 | api.add_url_rule("/secret", view_func=Api.as_view("secret")) 42 | -------------------------------------------------------------------------------- /shhh/api/handlers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from http import HTTPStatus 5 | from typing import TYPE_CHECKING 6 | 7 | from cryptography.fernet import InvalidToken 8 | from flask import current_app as app, make_response 9 | from sqlalchemy.orm.exc import NoResultFound 10 | 11 | from shhh.api.schemas import ErrorResponse, ReadResponse, WriteResponse 12 | from shhh.constants import ClientType, Message, Status 13 | from shhh.domain import model 14 | from shhh.extensions import db 15 | from shhh.liveness import db_liveness_ping 16 | 17 | if TYPE_CHECKING: 18 | from flask import Response 19 | from marshmallow import ValidationError 20 | 21 | from shhh.api.schemas import CallableResponse 22 | 23 | 24 | class Handler(ABC): 25 | 26 | @abstractmethod 27 | def handle(self) -> tuple[CallableResponse, HTTPStatus]: 28 | pass # pragma: no cover 29 | 30 | def make_response(self) -> Response: 31 | response, code = self.handle() 32 | return make_response(response(), code) 33 | 34 | 35 | class ReadHandler(Handler): 36 | 37 | def __init__(self, external_id: str, passphrase: str) -> None: 38 | self.external_id = external_id 39 | self.passphrase = passphrase 40 | 41 | @db_liveness_ping(ClientType.WEB) 42 | def handle(self) -> tuple[ReadResponse, HTTPStatus]: 43 | try: 44 | secret = db.session.query(model.Secret).filter( 45 | model.Secret.has_external_id(self.external_id)).one() 46 | except NoResultFound: 47 | return (ReadResponse(Status.EXPIRED, Message.NOT_FOUND), 48 | HTTPStatus.NOT_FOUND) 49 | 50 | try: 51 | message = secret.decrypt(self.passphrase) 52 | except InvalidToken: 53 | remaining = secret.tries - 1 54 | if remaining == 0: 55 | # number of tries exceeded, delete secret 56 | app.logger.info("%s tries to open secret exceeded", 57 | str(secret)) 58 | db.session.delete(secret) 59 | db.session.commit() 60 | return (ReadResponse(Status.INVALID, Message.EXCEEDED), 61 | HTTPStatus.UNAUTHORIZED) 62 | 63 | secret.tries = remaining 64 | db.session.commit() 65 | app.logger.info( 66 | "%s wrong passphrase used. Number of tries remaining: %s", 67 | str(secret), 68 | remaining) 69 | return (ReadResponse( 70 | Status.INVALID, 71 | Message.INVALID.value.format(remaining=remaining)), 72 | HTTPStatus.UNAUTHORIZED) 73 | 74 | db.session.delete(secret) 75 | db.session.commit() 76 | app.logger.info("%s was decrypted and deleted", str(secret)) 77 | return ReadResponse(Status.SUCCESS, message), HTTPStatus.OK 78 | 79 | 80 | class WriteHandler(Handler): 81 | 82 | def __init__(self, passphrase: str, secret: str, expire: str, 83 | tries: int) -> None: 84 | self.passphrase = passphrase 85 | self.secret = secret 86 | self.expire = expire 87 | self.tries = tries 88 | 89 | @db_liveness_ping(ClientType.WEB) 90 | def handle(self) -> tuple[WriteResponse, HTTPStatus]: 91 | encrypted_secret = model.Secret.encrypt(message=self.secret, 92 | passphrase=self.passphrase, 93 | expire_code=self.expire, 94 | tries=self.tries) 95 | db.session.add(encrypted_secret) 96 | db.session.commit() 97 | app.logger.info("%s created", str(encrypted_secret)) 98 | return (WriteResponse(encrypted_secret.external_id, 99 | encrypted_secret.expires_on_text), 100 | HTTPStatus.CREATED) 101 | 102 | 103 | class ErrorHandler(Handler): 104 | 105 | def __init__(self, error_exc: ValidationError) -> None: 106 | self.error_exc = error_exc 107 | 108 | def handle(self) -> tuple[ErrorResponse, HTTPStatus]: 109 | messages = self.error_exc.normalized_messages() 110 | error = "" 111 | for source in ("json", "query"): 112 | for _, message in messages.get(source, {}).items(): 113 | error += f"{message[0]} " 114 | return ErrorResponse(error.strip()), HTTPStatus.UNPROCESSABLE_ENTITY 115 | -------------------------------------------------------------------------------- /shhh/api/schemas.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from dataclasses import dataclass, field, fields as dfields 5 | from urllib.parse import urljoin 6 | from typing import TYPE_CHECKING 7 | 8 | from flask import current_app as app, jsonify, request, url_for 9 | from marshmallow import Schema, ValidationError, fields, pre_load, validate 10 | 11 | from shhh.constants import (DEFAULT_EXPIRATION_TIME_VALUE, 12 | DEFAULT_READ_TRIES_VALUE, 13 | EXPIRATION_TIME_VALUES, 14 | READ_TRIES_VALUES, 15 | Message, 16 | Status) 17 | 18 | if TYPE_CHECKING: 19 | from flask import Response 20 | 21 | 22 | class ReadRequest(Schema): 23 | """Schema for inbound read requests.""" 24 | external_id = fields.Str(required=True) 25 | passphrase = fields.Str(required=True) 26 | 27 | 28 | def _passphrase_validator(passphrase: str) -> None: 29 | regex = re.compile(r"^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$") 30 | if not regex.search(passphrase): 31 | raise ValidationError("Sorry, your passphrase is too weak. It needs " 32 | "minimum 8 characters, with 1 number and 1 " 33 | "uppercase.") 34 | 35 | 36 | def _secret_validator(secret: str) -> None: 37 | max_length = app.config["SHHH_SECRET_MAX_LENGTH"] 38 | if len(secret) > max_length: 39 | raise ValidationError(f"The secret should not exceed {max_length} " 40 | "characters.") 41 | 42 | 43 | class WriteRequest(Schema): 44 | """Schema for inbound write requests.""" 45 | passphrase = fields.Str(required=True, validate=_passphrase_validator) 46 | secret = fields.Str(required=True, validate=_secret_validator) 47 | expire = fields.Str(load_default=DEFAULT_EXPIRATION_TIME_VALUE, 48 | validate=validate.OneOf( 49 | EXPIRATION_TIME_VALUES.values())) 50 | tries = fields.Int(load_default=DEFAULT_READ_TRIES_VALUE, 51 | validate=validate.OneOf(READ_TRIES_VALUES)) 52 | 53 | @pre_load 54 | def secret_sanitise_newline(self, data, **kwargs): 55 | if isinstance(data.get("secret"), str): 56 | data["secret"] = "\n".join(data["secret"].splitlines()) 57 | return data 58 | 59 | 60 | @dataclass 61 | class CallableResponse: 62 | 63 | def __call__(self) -> Response: 64 | return jsonify({ 65 | "response": { 66 | f.name: getattr(self, f.name) 67 | for f in dfields(self) 68 | } 69 | }) 70 | 71 | 72 | @dataclass 73 | class ReadResponse(CallableResponse): 74 | """Schema for outbound read responses.""" 75 | status: Status 76 | msg: str 77 | 78 | 79 | def _build_link_url(external_id: str) -> str: 80 | root_host = app.config.get("SHHH_HOST", request.url_root) 81 | return urljoin(root_host, url_for("web.read", external_id=external_id)) 82 | 83 | 84 | @dataclass 85 | class WriteResponse(CallableResponse): 86 | """Schema for outbound write responses.""" 87 | external_id: str 88 | expires_on: str 89 | link: str = field(init=False) 90 | status: Status = Status.CREATED 91 | details: Message = Message.CREATED 92 | 93 | def __post_init__(self): 94 | self.link = _build_link_url(self.external_id) 95 | 96 | 97 | @dataclass 98 | class ErrorResponse(CallableResponse): 99 | """Schema for outbound error responses.""" 100 | details: str 101 | status: Status = Status.ERROR 102 | -------------------------------------------------------------------------------- /shhh/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | class DefaultConfig: 8 | """Default config values (dev-local).""" 9 | 10 | DEBUG = True 11 | 12 | DB_HOST = os.environ.get("DB_HOST", "localhost") 13 | DB_USER = os.environ.get("DB_USER") 14 | DB_PASSWORD = os.environ.get("DB_PASSWORD") 15 | DB_PORT = os.environ.get("DB_PORT", 5432) 16 | DB_NAME = os.environ.get("DB_NAME", "shhh") 17 | DB_ENGINE = os.environ.get("DB_ENGINE", "postgresql+psycopg2") 18 | 19 | # SqlAlchemy 20 | SQLALCHEMY_ECHO = False 21 | SQLALCHEMY_TRACK_MODIFICATIONS = False 22 | SQLALCHEMY_DATABASE_URI = ( 23 | f"{DB_ENGINE}://{DB_USER}:{DB_PASSWORD}" 24 | f"@{DB_HOST}:{DB_PORT}/{DB_NAME}") 25 | 26 | # 27 | # Shhh optional custom configurations 28 | # 29 | 30 | # This variable can be used to specify a custom hostname to use as the 31 | # domain URL when Shhh creates a secret (ex: https://). 32 | # If not set, the hostname defaults to request.url_root, which should be 33 | # fine in most cases. 34 | SHHH_HOST = os.environ.get("SHHH_HOST") 35 | 36 | # Default max secret length 37 | try: 38 | SHHH_SECRET_MAX_LENGTH = int( 39 | os.environ.get("SHHH_SECRET_MAX_LENGTH", 250)) 40 | except (ValueError, TypeError): 41 | SHHH_SECRET_MAX_LENGTH = 250 42 | logger.warning( 43 | "Provided value for SHHH_SECRET_MAX_LENGTH is not " 44 | "valid, using default value of %s", 45 | SHHH_SECRET_MAX_LENGTH) 46 | 47 | # Number of tries to reach the database before performing a read or write 48 | # operation. It could happens that the database is not reachable or is 49 | # asleep (for instance this happens often on Heroku free plans). The 50 | # default retry number is 5. 51 | try: 52 | SHHH_DB_LIVENESS_RETRY_COUNT = int( 53 | os.environ.get("SHHH_DB_LIVENESS_RETRY_COUNT", 5)) 54 | except (ValueError, TypeError): 55 | SHHH_DB_LIVENESS_RETRY_COUNT = 5 56 | logger.warning( 57 | "Provided value for SHHH_DB_LIVENESS_RETRY_COUNT is not " 58 | "valid, using default value of %s", 59 | SHHH_DB_LIVENESS_RETRY_COUNT) 60 | 61 | # Sleep interval in seconds between database liveness retries. The default 62 | # value is 1 second. 63 | try: 64 | 65 | SHHH_DB_LIVENESS_SLEEP_INTERVAL = float( 66 | os.environ.get("SHHH_DB_LIVENESS_SLEEP_INTERVAL", 1)) 67 | except (ValueError, TypeError): 68 | SHHH_DB_LIVENESS_SLEEP_INTERVAL = 1 69 | logger.warning( 70 | "Provided value for SHHH_DB_LIVENESS_SLEEP_INTERVAL is not " 71 | "valid, using default value of %s", 72 | SHHH_DB_LIVENESS_SLEEP_INTERVAL) 73 | 74 | 75 | class TestConfig(DefaultConfig): 76 | """Testing configuration.""" 77 | 78 | DEBUG = False 79 | TESTING = True 80 | 81 | SQLALCHEMY_DATABASE_URI = "sqlite://" # in memory 82 | 83 | SHHH_HOST = "http://test.test" 84 | SHHH_SECRET_MAX_LENGTH = 20 85 | SHHH_DB_LIVENESS_RETRY_COUNT = 1 86 | SHHH_DB_LIVENESS_SLEEP_INTERVAL = 0.1 87 | 88 | 89 | class DevelopmentConfig(DefaultConfig): 90 | """Docker development configuration (dev-docker).""" 91 | 92 | SQLALCHEMY_ECHO = False 93 | 94 | 95 | class ProductionConfig(DefaultConfig): 96 | """Production configuration (production).""" 97 | 98 | DEBUG = False 99 | SQLALCHEMY_ECHO = False 100 | 101 | 102 | class HerokuConfig(ProductionConfig): 103 | """Heroku configuration (heroku). Only support PostgreSQL.""" 104 | 105 | # SQLAlchemy 1.4 removed the deprecated postgres dialect name, the name 106 | # postgresql must be used instead. This URL is automatically set on 107 | # Heroku, so change it from the code directly. 108 | SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "").replace( 109 | "postgres://", "postgresql://", 1) 110 | -------------------------------------------------------------------------------- /shhh/constants.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from enum import StrEnum 3 | 4 | READ_TRIES_VALUES = (3, 5, 10) 5 | DEFAULT_READ_TRIES_VALUE = 5 6 | 7 | EXPIRATION_TIME_VALUES = OrderedDict([ 8 | ("10 minutes", "10m"), ("30 minutes", "30m"), ("An hour", "1h"), 9 | ("3 hours", "3h"), ("6 hours", "6h"), ("A day", "1d"), ("2 days", "2d"), 10 | ("3 days", "3d"), ("5 days", "5d"), ("A week", "7d") 11 | ]) 12 | DEFAULT_EXPIRATION_TIME_VALUE = EXPIRATION_TIME_VALUES["3 days"] 13 | 14 | 15 | class ClientType(StrEnum): 16 | WEB = "web" 17 | TASK = "task" 18 | 19 | 20 | class EnvConfig(StrEnum): 21 | TESTING = "testing" 22 | DEV_LOCAL = "dev-local" 23 | DEV_DOCKER = "dev-docker" 24 | HEROKU = "heroku" 25 | PRODUCTION = "production" 26 | 27 | 28 | class Status(StrEnum): 29 | CREATED = "created" 30 | SUCCESS = "success" 31 | EXPIRED = "expired" 32 | INVALID = "invalid" 33 | ERROR = "error" 34 | 35 | 36 | class Message(StrEnum): 37 | NOT_FOUND = ("Sorry, we can't find a secret, it has expired, been deleted " 38 | "or has already been read.") 39 | EXCEEDED = ("The passphrase is not valid. You've exceeded the number of " 40 | "tries and the secret has been deleted.") 41 | INVALID = ("Sorry, the passphrase is not valid. Number of tries " 42 | "remaining: {remaining}") 43 | CREATED = "Secret successfully created." 44 | UNEXPECTED = "An unexpected error has occurred, please try again." 45 | -------------------------------------------------------------------------------- /shhh/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallwat3r/shhh/be3ea82608b64b786137b44c4e81c94090d463c9/shhh/domain/__init__.py -------------------------------------------------------------------------------- /shhh/domain/model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import secrets 4 | from base64 import urlsafe_b64decode, urlsafe_b64encode 5 | from datetime import datetime, timedelta, timezone 6 | from typing import TYPE_CHECKING 7 | 8 | from sqlalchemy.ext.hybrid import hybrid_method 9 | 10 | from cryptography.fernet import Fernet 11 | from cryptography.hazmat.backends import default_backend 12 | from cryptography.hazmat.primitives import hashes 13 | from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC 14 | 15 | from shhh.constants import DEFAULT_READ_TRIES_VALUE 16 | 17 | if TYPE_CHECKING: 18 | from typing import Self 19 | 20 | 21 | class Secret: 22 | """Domain model for secrets.""" 23 | 24 | def __init__(self, 25 | encrypted_text: bytes, 26 | date_created: datetime, 27 | date_expires: datetime, 28 | external_id: str, 29 | tries: int) -> None: 30 | self.encrypted_text = encrypted_text 31 | self.date_created = date_created 32 | self.date_expires = date_expires 33 | self.external_id = external_id 34 | self.tries = tries 35 | 36 | def __repr__(self) -> str: 37 | return f"" 38 | 39 | @staticmethod 40 | def _derive_key(passphrase: str, salt: bytes, iterations: int) -> bytes: 41 | """Derive a secret key from a given passphrase and salt.""" 42 | kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), 43 | length=32, 44 | salt=salt, 45 | iterations=iterations, 46 | backend=default_backend()) 47 | return urlsafe_b64encode(kdf.derive(passphrase.encode())) 48 | 49 | @staticmethod 50 | def _set_expiry_date(from_date: datetime, expire: str) -> datetime: 51 | units = {"m": "minutes", "h": "hours", "d": "days"} 52 | timedelta_parameters = {} 53 | for unit, parameter in units.items(): 54 | if not expire.endswith(unit): 55 | continue 56 | timedelta_parameters = {parameter: int(expire.split(unit)[0])} 57 | return from_date + timedelta(**timedelta_parameters) 58 | raise RuntimeError(f"Could not set expiry date for code {expire}") 59 | 60 | @classmethod 61 | def encrypt(cls, 62 | message: str, 63 | passphrase: str, 64 | expire_code: str, 65 | tries: int = DEFAULT_READ_TRIES_VALUE, 66 | iterations: int = 100_000) -> Self: 67 | salt = secrets.token_bytes(16) 68 | key = cls._derive_key(passphrase, salt, iterations) 69 | encrypted_text = urlsafe_b64encode( 70 | b"%b%b%b" % 71 | (salt, 72 | iterations.to_bytes(4, "big"), 73 | urlsafe_b64decode(Fernet(key).encrypt(message.encode())))) 74 | now = datetime.now(timezone.utc) 75 | return cls(encrypted_text=encrypted_text, 76 | date_created=now, 77 | date_expires=cls._set_expiry_date(from_date=now, 78 | expire=expire_code), 79 | external_id=secrets.token_urlsafe(15), 80 | tries=tries) 81 | 82 | def decrypt(self, passphrase: str) -> str: 83 | decoded = urlsafe_b64decode(self.encrypted_text) 84 | salt, iteration, message = ( 85 | decoded[:16], 86 | decoded[16:20], 87 | urlsafe_b64encode(decoded[20:]), 88 | ) 89 | iterations = int.from_bytes(iteration, "big") 90 | key = self._derive_key(passphrase, salt, iterations) 91 | return Fernet(key).decrypt(message).decode("utf-8") 92 | 93 | @property 94 | def expires_on_text(self) -> str: 95 | timez = datetime.now(timezone.utc).astimezone().tzname() 96 | return f"{self.date_expires.strftime('%B %d, %Y at %H:%M')} {timez}" 97 | 98 | @hybrid_method 99 | def has_expired(self) -> bool: 100 | return self.date_expires <= datetime.now() 101 | 102 | @hybrid_method 103 | def has_external_id(self, external_id: str) -> bool: 104 | return self.external_id == external_id 105 | -------------------------------------------------------------------------------- /shhh/entrypoint.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import gzip 4 | import logging 5 | from http import HTTPStatus 6 | from io import BytesIO 7 | from typing import TYPE_CHECKING 8 | 9 | from flask import Flask, Response, render_template as rt 10 | from flask_alembic import Alembic 11 | from flask_assets import Bundle 12 | from htmlmin.main import minify 13 | 14 | from shhh import __version__, config 15 | from shhh.adapters import orm 16 | from shhh.api.api import api 17 | from shhh.constants import EnvConfig 18 | from shhh.extensions import assets, db, scheduler 19 | from shhh.scheduler import tasks 20 | from shhh.web import web 21 | 22 | if TYPE_CHECKING: 23 | from flask_apscheduler import APScheduler 24 | from flask_assets import Environment 25 | from werkzeug.exceptions import NotFound, InternalServerError 26 | 27 | 28 | def create_app(env: EnvConfig) -> Flask: 29 | """Application factory.""" 30 | logging.basicConfig( 31 | level=logging.INFO, 32 | format=("[%(asctime)s] [sev %(levelno)s] [%(levelname)s] " 33 | "[%(name)s]> %(message)s"), 34 | datefmt="%a, %d %b %Y %H:%M:%S", 35 | ) 36 | app = Flask(__name__) 37 | config_obj = _get_config(env) 38 | app.config.from_object(config_obj) 39 | _register_extensions(app) 40 | 41 | with app.app_context(): 42 | _register_blueprints(app) 43 | orm.start_mappers() 44 | 45 | scheduler._scheduler.start() 46 | _add_scheduler_jobs(scheduler) 47 | 48 | assets.manifest = False 49 | assets.cache = False 50 | _compile_static_assets(assets) 51 | 52 | app.context_processor(_inject_global_vars) 53 | _register_after_request_handlers(app) 54 | _register_error_handlers(app) 55 | return app 56 | 57 | 58 | def _get_config(env: EnvConfig) -> type[config.DefaultConfig]: 59 | if env not in set(EnvConfig): 60 | raise RuntimeError(f"{env=} specified in FLASK_ENV is not supported") 61 | 62 | configurations = { 63 | EnvConfig.TESTING: config.TestConfig, 64 | EnvConfig.DEV_DOCKER: config.DevelopmentConfig, 65 | EnvConfig.HEROKU: config.HerokuConfig, 66 | EnvConfig.PRODUCTION: config.ProductionConfig, 67 | } 68 | return configurations[env] 69 | 70 | 71 | def _register_after_request_handlers(app: Flask) -> None: 72 | app.after_request(_optimize_response) 73 | app.after_request(_add_required_security_headers) 74 | 75 | 76 | def _register_error_handlers(app: Flask) -> None: 77 | app.register_error_handler(HTTPStatus.NOT_FOUND, _not_found_error) 78 | app.register_error_handler(HTTPStatus.INTERNAL_SERVER_ERROR, 79 | _internal_server_error) 80 | 81 | 82 | def _register_blueprints(app: Flask) -> None: 83 | app.register_blueprint(api) 84 | app.register_blueprint(web) 85 | 86 | 87 | def _register_extensions(app: Flask) -> None: 88 | alembic = Alembic(metadatas={"default": orm.metadata}) 89 | alembic.init_app(app) 90 | assets.init_app(app) 91 | db.init_app(app) 92 | scheduler.init_app(app) 93 | 94 | 95 | def _add_scheduler_jobs(scheduler: APScheduler) -> None: 96 | scheduler.add_job(id="delete_expired_records", 97 | func=tasks.delete_expired_records, 98 | trigger="interval", 99 | seconds=60) 100 | 101 | 102 | def _compile_static_assets(app_assets: Environment) -> None: 103 | assets_to_compile = (("js", ("create", "created", "read")), 104 | (("css", ("styles", )))) 105 | for k, v in assets_to_compile: 106 | for file in v: 107 | bundle = Bundle(f"src/{k}/{file}.{k}", 108 | filters=f"{k}min", 109 | output=f"dist/{k}/{file}.min.{k}") 110 | app_assets.register(file, bundle) 111 | bundle.build() 112 | 113 | 114 | def _inject_global_vars() -> dict[str, str]: 115 | return {"version": __version__} 116 | 117 | 118 | def _not_found_error(error: NotFound) -> tuple[str, HTTPStatus]: 119 | return rt("error.html", error=error), HTTPStatus.NOT_FOUND 120 | 121 | 122 | def _internal_server_error( 123 | error: InternalServerError) -> tuple[str, HTTPStatus]: 124 | return rt("error.html", error=error), HTTPStatus.INTERNAL_SERVER_ERROR 125 | 126 | 127 | def _optimize_response(response: Response) -> Response: 128 | """Minify HTML and use gzip compression.""" 129 | if response.mimetype == "text/html": 130 | response.set_data(minify(response.get_data(as_text=True))) 131 | 132 | # do not gzip below 500 bytes or on JSON content 133 | if (response.content_length < 500 134 | or response.mimetype == "application/json"): 135 | return response 136 | 137 | response.direct_passthrough = False 138 | 139 | gzip_buffer = BytesIO() 140 | gzip_file = gzip.GzipFile(mode="wb", compresslevel=6, fileobj=gzip_buffer) 141 | gzip_file.write(response.get_data()) 142 | gzip_file.close() 143 | 144 | response.set_data(gzip_buffer.getvalue()) 145 | response.headers.add("Content-Encoding", "gzip") 146 | return response 147 | 148 | 149 | def _add_required_security_headers(response: Response) -> Response: 150 | response.headers.add("X-Frame-Options", "SAMEORIGIN") 151 | response.headers.add("X-Content-Type-Options", "nosniff") 152 | response.headers.add("X-XSS-Protection", "1; mode=block") 153 | response.headers.add("Referrer-Policy", "no-referrer-when-downgrade") 154 | response.headers.add("Strict-Transport-Security", 155 | "max-age=63072000; includeSubdomains; preload") 156 | response.headers.add( 157 | "Content-Security-Policy", 158 | ("default-src 'self'; img-src 'self'; object-src 'self'; " 159 | "script-src 'self' 'unsafe-inline'; " 160 | "style-src 'self' 'unsafe-inline'")) 161 | response.headers.add( 162 | "feature-policy", 163 | ("accelerometer 'none'; camera 'none'; geolocation 'none'; " 164 | "gyroscope 'none'; magnetometer 'none'; microphone 'none'; " 165 | "payment 'none'; usb 'none'")) 166 | return response 167 | -------------------------------------------------------------------------------- /shhh/extensions.py: -------------------------------------------------------------------------------- 1 | from flask_apscheduler import APScheduler 2 | from flask_assets import Environment 3 | from flask_sqlalchemy import SQLAlchemy 4 | 5 | db = SQLAlchemy() 6 | assets = Environment() 7 | scheduler = APScheduler() 8 | -------------------------------------------------------------------------------- /shhh/liveness.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import time 5 | from http import HTTPStatus 6 | from typing import TYPE_CHECKING 7 | 8 | from flask import abort, current_app as app, make_response 9 | from sqlalchemy import text 10 | 11 | from shhh.api.schemas import ErrorResponse 12 | from shhh.constants import ClientType, Message 13 | from shhh.extensions import db, scheduler 14 | 15 | if TYPE_CHECKING: 16 | from typing import Callable, TypeVar 17 | 18 | from flask import Flask, Response 19 | 20 | RT = TypeVar('RT') 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | def _perform_db_connectivity_query() -> None: 26 | db.session.execute(text("SELECT 1;")) 27 | 28 | 29 | def _check_table_exists(table_name: str) -> bool: 30 | return bool(db.inspect(db.engine).has_table(table_name)) 31 | 32 | 33 | def _get_retries_configs(flask_app: Flask) -> tuple[int, float]: 34 | return (flask_app.config["SHHH_DB_LIVENESS_RETRY_COUNT"], 35 | flask_app.config["SHHH_DB_LIVENESS_SLEEP_INTERVAL"]) 36 | 37 | 38 | def _is_db_awake(flask_app: Flask) -> bool: 39 | retry_count, retry_sleep_interval_sec = _get_retries_configs(flask_app) 40 | 41 | for _ in range(retry_count): 42 | try: 43 | _perform_db_connectivity_query() 44 | return True 45 | except Exception as exc: 46 | logger.info("Retrying to reach database...") 47 | time.sleep(retry_sleep_interval_sec) 48 | exception = exc 49 | 50 | logger.critical("Could not reach the database, something is wrong " 51 | "with the database connection") 52 | logger.exception(exception) 53 | return False 54 | 55 | 56 | def _is_db_table_up(flask_app: Flask) -> bool: 57 | if _check_table_exists("secret"): 58 | return True 59 | logger.critical("Could not query required table 'secret', make sure it " 60 | "has been created on the database") 61 | return False 62 | 63 | 64 | def _is_db_healthy(flask_app: Flask) -> bool: 65 | return _is_db_awake(flask_app) and _is_db_table_up(flask_app) 66 | 67 | 68 | def _check_task_liveness(f: Callable[..., RT], *args, **kwargs) -> RT | None: 69 | scheduler_app = scheduler.app 70 | with scheduler_app.app_context(): 71 | if _is_db_healthy(scheduler_app): 72 | return f(*args, **kwargs) 73 | 74 | return None 75 | 76 | 77 | def _check_web_liveness(f: Callable[..., RT], *args, 78 | **kwargs) -> RT | Response: 79 | if _is_db_healthy(app): 80 | return f(*args, **kwargs) 81 | 82 | response = ErrorResponse(Message.UNEXPECTED) 83 | abort(make_response(response(), HTTPStatus.SERVICE_UNAVAILABLE)) 84 | 85 | 86 | def _check_liveness(client_type: ClientType, 87 | f: Callable[..., Callable[..., RT]], 88 | *args, 89 | **kwargs) -> Callable[..., RT] | Response | None: 90 | 91 | if client_type not in set(ClientType): 92 | raise RuntimeError(f"No implementation found for {client_type=}") 93 | 94 | factory = { 95 | ClientType.WEB: _check_web_liveness, 96 | ClientType.TASK: _check_task_liveness, 97 | } 98 | func = factory[client_type] 99 | return func(f, *args, **kwargs) 100 | 101 | 102 | def db_liveness_ping( 103 | client: ClientType 104 | ) -> Callable[[Callable[..., RT]], Callable[..., RT]]: 105 | """Database liveness ping decorator. 106 | 107 | Some database might go to sleep if no recent activity is recorded (for 108 | example this is the case on some Heroku free plans). This decorator is 109 | used to run a dummy query against the database to make sure it's up and 110 | running before starting processing requests. 111 | """ 112 | 113 | def inner(f): 114 | 115 | def wrapper(*args, **kwargs): 116 | return _check_liveness(client, f, *args, **kwargs) 117 | 118 | return wrapper 119 | 120 | return inner 121 | -------------------------------------------------------------------------------- /shhh/migrations/1730636773_initial.py: -------------------------------------------------------------------------------- 1 | """initial 2 | 3 | Revision ID: 1730636773 4 | Revises: 5 | Create Date: 2024-11-03 12:26:13.593575 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | from sqlalchemy.dialects import postgresql 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '1730636773' 16 | down_revision: Union[str, None] = None 17 | branch_labels: Union[str, Sequence[str], None] = ('default',) 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | op.create_table('secret', 23 | sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), 24 | sa.Column('encrypted_text', postgresql.BYTEA(), autoincrement=False, nullable=True), 25 | sa.Column('date_created', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), 26 | sa.Column('date_expires', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), 27 | sa.Column('external_id', sa.VARCHAR(length=20), autoincrement=False, nullable=False), 28 | sa.Column('tries', sa.INTEGER(), autoincrement=False, nullable=True), 29 | sa.PrimaryKeyConstraint('id', name='secret_pkey') 30 | ) 31 | 32 | 33 | def downgrade() -> None: 34 | op.drop_table('secret') 35 | -------------------------------------------------------------------------------- /shhh/migrations/1730637997_add_indexes.py: -------------------------------------------------------------------------------- 1 | """add indexes 2 | 3 | Revision ID: 1730637997 4 | Revises: 1730636773 5 | Create Date: 2024-11-03 12:46:37.418955 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision: str = '1730637997' 15 | down_revision: Union[str, None] = '1730636773' 16 | branch_labels: Union[str, Sequence[str], None] = () 17 | depends_on: Union[str, Sequence[str], None] = None 18 | 19 | 20 | def upgrade() -> None: 21 | op.create_index('date_expires_idx', 'secret', ['date_expires'], unique=False) 22 | op.create_index('external_id_idx', 'secret', ['external_id'], unique=False) 23 | 24 | 25 | def downgrade() -> None: 26 | op.drop_index('external_id_idx', table_name='secret') 27 | op.drop_index('date_expires_idx', table_name='secret') 28 | -------------------------------------------------------------------------------- /shhh/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /shhh/scheduler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallwat3r/shhh/be3ea82608b64b786137b44c4e81c94090d463c9/shhh/scheduler/__init__.py -------------------------------------------------------------------------------- /shhh/scheduler/tasks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING 5 | 6 | from shhh.constants import ClientType 7 | from shhh.domain import model 8 | from shhh.extensions import db, scheduler 9 | from shhh.liveness import db_liveness_ping 10 | 11 | if TYPE_CHECKING: 12 | from typing import Iterable 13 | 14 | logger = logging.getLogger("tasks") 15 | 16 | 17 | @db_liveness_ping(ClientType.TASK) 18 | def delete_expired_records() -> None: 19 | """Delete expired secrets from the database.""" 20 | with scheduler.app.app_context(): 21 | expired_secrets = db.session.query(model.Secret).filter( 22 | model.Secret.has_expired()).all() 23 | _delete_records(expired_secrets) 24 | logger.info("%s expired records have been deleted.", 25 | len(expired_secrets)) 26 | 27 | 28 | def _delete_records(records: Iterable[model.Secret]) -> None: 29 | for record in records: 30 | db.session.delete(record) 31 | db.session.commit() 32 | -------------------------------------------------------------------------------- /shhh/static/dist/css/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallwat3r/shhh/be3ea82608b64b786137b44c4e81c94090d463c9/shhh/static/dist/css/.gitkeep -------------------------------------------------------------------------------- /shhh/static/dist/js/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallwat3r/shhh/be3ea82608b64b786137b44c4e81c94090d463c9/shhh/static/dist/js/.gitkeep -------------------------------------------------------------------------------- /shhh/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallwat3r/shhh/be3ea82608b64b786137b44c4e81c94090d463c9/shhh/static/img/logo.png -------------------------------------------------------------------------------- /shhh/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | 3 | Disallow: /secret/ 4 | Disallow: /api/ 5 | -------------------------------------------------------------------------------- /shhh/static/src/css/styles.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Work Sans"; 3 | src: url("/static/vendor/typeface-work-sans/files/work-sans-latin-500.woff"); 4 | } 5 | 6 | @font-face { 7 | font-family: "Hack"; 8 | src: url("/static/vendor/hack-font/build/web/fonts/hack-regular.woff"); 9 | } 10 | 11 | body { 12 | font-family: "Work Sans", sans-serif; 13 | } 14 | 15 | hr { 16 | border: 0; 17 | height: 1px; 18 | background-image: linear-gradient( 19 | to right, 20 | rgba(0, 0, 0, 0.75), 21 | rgba(0, 0, 0, 0), 22 | rgba(0, 0, 0, 0.75) 23 | ); 24 | } 25 | 26 | .help { 27 | font-size: 0.9rem; 28 | } 29 | 30 | .hidden { 31 | visibility: hidden; 32 | } 33 | 34 | .is-family-monospace { 35 | font-family: "Hack", monospace !important; 36 | } 37 | 38 | .secret-message { 39 | white-space: pre-wrap; 40 | word-wrap: break-word; 41 | word-break: break-word; 42 | overflow-wrap: break-word; 43 | } 44 | 45 | .notification>.copy { 46 | right: .5em; 47 | position: absolute; 48 | cursor: pointer; 49 | top: .5em; 50 | } 51 | 52 | /* Icons */ 53 | .feather { 54 | width: 15px; 55 | height: 15px; 56 | stroke: currentColor; 57 | stroke-width: 2; 58 | stroke-linecap: round; 59 | stroke-linejoin: round; 60 | fill: none; 61 | } 62 | 63 | /* Pop animation */ 64 | .pop { 65 | animation: pop 0.25s ease-in-out; 66 | } 67 | @keyframes pop { 68 | 50% { 69 | transform: scale(1.25); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /shhh/static/src/js/create.js: -------------------------------------------------------------------------------- 1 | expiresValue.value = expiresValue.getAttribute("data-default"); 2 | maxTries.value = maxTries.getAttribute("data-default"); 3 | 4 | inputSecret.onkeyup = (_) => 5 | (document.getElementById("counter").textContent = 6 | "Characters left: " + (inputSecret.maxLength - inputSecret.value.length)); 7 | 8 | const status = { 9 | CREATED: "created", 10 | ERROR: "error" 11 | } 12 | 13 | createSecretForm.addEventListener("submit", (e) => { 14 | e.preventDefault(); 15 | createBtn.classList.add("is-loading"); 16 | 17 | let headers = new Headers([ 18 | ["Content-Type", "application/json"], 19 | ["Accept", "application/json"], 20 | ]); 21 | 22 | let formData = new FormData(createSecretForm); 23 | let object = {}; 24 | formData.forEach((value, key) => (object[key] = value)); 25 | 26 | createSecretFs.setAttribute("disabled", "disabled"); // lock form 27 | 28 | fetch(createSecretForm.getAttribute("action"), { 29 | method: createSecretForm.getAttribute("method"), 30 | headers: headers, 31 | body: JSON.stringify(object), 32 | cache: "no-store", 33 | }) 34 | .then((res) => res.json()) 35 | .then((data) => { 36 | switch (data.response.status) { 37 | case status.CREATED: 38 | successResponseHandler(data); 39 | break; 40 | case status.ERROR: 41 | errorResponseHandler(data); 42 | break; 43 | } 44 | }); 45 | }); 46 | 47 | function successResponseHandler(data) { 48 | let params = new URLSearchParams(); 49 | params.set("link", data.response.link); 50 | params.set("expires_on", data.response.expires_on); 51 | 52 | const redirect = createSecretForm.getAttribute("data-redirect"); 53 | window.location.href = `${redirect}?${params.toString()}`; 54 | } 55 | 56 | function errorResponseHandler(data) { 57 | const content = notificationTemplate.content.cloneNode(true); 58 | 59 | notification.innerHTML = ""; 60 | notification.appendChild(content); 61 | 62 | notificationContent.parentNode.classList.add("is-danger"); 63 | notificationContent.textContent = data.response.details; 64 | 65 | // Ability to close the notification 66 | (document.querySelectorAll(".notification .delete") || []).forEach((del) => { 67 | const notificationDeletion = del.parentNode; 68 | del.addEventListener("click", () => { 69 | notificationDeletion.parentNode.removeChild(notificationDeletion); 70 | }); 71 | }); 72 | 73 | createSecretFs.removeAttribute("disabled"); 74 | createBtn.classList.remove("is-loading"); 75 | } 76 | -------------------------------------------------------------------------------- /shhh/static/src/js/created.js: -------------------------------------------------------------------------------- 1 | copy.addEventListener("click", (_) => { 2 | link.select(); 3 | link.setSelectionRange(0, 99999); // mobile 4 | document.execCommand("copy"); 5 | copy.textContent = "copied"; 6 | copy.classList.replace("is-warning", "is-primary"); 7 | }); 8 | -------------------------------------------------------------------------------- /shhh/static/src/js/read.js: -------------------------------------------------------------------------------- 1 | const status = { 2 | INVALID: "invalid", 3 | EXPIRED: "expired", 4 | SUCCESS: "success" 5 | } 6 | 7 | readSecretForm.addEventListener("submit", (e) => { 8 | e.preventDefault(); 9 | 10 | decryptBtn.classList.add("is-loading"); 11 | 12 | let endpoint = readSecretForm.getAttribute("action"); 13 | let params = new URLSearchParams(new FormData(readSecretForm)).toString(); 14 | 15 | readSecretFs.setAttribute("disabled", "disabled"); // lock form 16 | 17 | fetch(`${endpoint}?${params}`, { 18 | method: readSecretForm.getAttribute("method"), 19 | cache: "no-store", 20 | }) 21 | .then((res) => res.json()) 22 | .then((data) => { 23 | switch (data.response.status) { 24 | case status.INVALID: 25 | errorResponseHandler(data, "is-danger"); 26 | readSecretFs.removeAttribute("disabled"); // let user retry 27 | break; 28 | case status.EXPIRED: 29 | errorResponseHandler(data, "is-warning"); 30 | break; 31 | case status.SUCCESS: 32 | successResponseHandler(data); 33 | makeCopyable(); 34 | break; 35 | } 36 | }); 37 | }); 38 | 39 | function successResponseHandler(data) { 40 | let content = notificationSecretTemplate.content.cloneNode(true); 41 | notification.innerHTML = ""; 42 | notification.appendChild(content); 43 | decryptBtn.classList.remove("is-loading"); 44 | passphrase.value = ""; 45 | notificationSecretContent.textContent = data.response.msg; 46 | feather.replace(); 47 | } 48 | 49 | function errorResponseHandler(data, notification_type) { 50 | let content = notificationTemplate.content.cloneNode(true); 51 | notification.innerHTML = ""; 52 | notification.appendChild(content); 53 | notificationContent.parentNode.classList.add(notification_type); 54 | decryptBtn.classList.remove("is-loading"); 55 | passphrase.value = ""; 56 | notificationContent.textContent = data.response.msg; 57 | } 58 | 59 | function makeCopyable() { 60 | copy.addEventListener("click", (e) => { 61 | e.preventDefault(); 62 | copy.classList.remove("pop"); 63 | let range = document.createRange(); 64 | range.selectNode(notificationSecretContent); 65 | window.getSelection().removeAllRanges(); 66 | window.getSelection().addRange(range); 67 | document.execCommand("copy"); 68 | copy.classList.add("pop"); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /shhh/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 21 | Shhh - Keep secrets out of emails and chat logs. 22 | 23 | 24 | {% assets "styles" %} 25 | 26 | {% endassets %} 27 | 28 | 29 | 30 |
31 |
32 |
33 |

34 | ~/ 35 | Shhh, keep secrets out of emails and chat logs. 36 |

37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | {% block container %}{% endblock %} 45 |
46 |
47 |
48 |
49 | 62 |
63 | 64 | 65 | 66 | {% block js %}{% endblock %} 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /shhh/templates/create.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block container %} 4 |
11 |
12 | Encrypt a secret 13 |

14 | Tip: for better security, avoid writing any info on how/where to use the secret you're 15 | sharing (like urls, websites or emails). Instead, write this in your email or chat, 16 | with the link and passphrase generated from Shhh. So even if someone got access to your 17 | secret, there is no way for the attacker to know how and where to use it. 18 |

19 |
20 |
21 | 31 |
32 |

33 |
34 | 35 |
36 |
37 |

Destroy in

38 |
39 |
40 | 46 |
47 |
48 | 49 |
50 |
51 |
52 |
53 |

Destroy after

54 |
55 |
56 | 61 |
62 |
63 | 64 |
65 |
66 |
67 |
68 | 69 |

Passphrase to open secret

70 |

71 | This is a random passphrase for the recipient to be able to decrypt your secret. Remember 72 | and send this passphrase to your recipient with the link Shhh will generate (min. 8 73 | characters, 1 number and 1 uppercase). 74 |

75 |
76 |

77 | 86 | 87 | 88 | 89 |

90 |
91 | 92 |
93 |
94 |
95 |
96 | 97 |
98 | 99 | 105 | {% endblock %} 106 | 107 | {% block js %} 108 | {% assets "create" %} 109 | 110 | {% endassets %} 111 | {% endblock %} 112 | -------------------------------------------------------------------------------- /shhh/templates/created.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block container %} 4 |

5 | 6 | Share this link and the passphrase 7 |

8 |

9 | Send this link and the passphrase you've previously created to your recipient. Bear in mind 10 | that as soon as the secret gets decrypted, it gets deleted forever. This link will expire 11 | on {{ expires_on }}. 12 |

13 |
14 |
15 | 16 |
17 |
18 | 19 |
20 |
21 | {% endblock %} 22 | 23 | {% block js %} 24 | {% assets "created" %} 25 | 26 | {% endassets %} 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /shhh/templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block container %} 4 | Take me home 5 |

{{ error }}

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /shhh/templates/read.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block container %} 4 |
10 |
11 | 12 | 13 | This secret is protected by a passphrase 14 | 15 |

16 | As soon as the message gets decrypted, it gets deleted forever, hence you will only be able 17 | to see it once. Make sure to save it in a secure place before leaving this page. 18 |

19 |
20 |

21 | 30 | 31 | 32 | 33 |

34 | 35 |

36 | 37 |

38 |
39 |
40 |
41 | 42 |
43 | 44 | 51 | 52 | 57 | {% endblock %} 58 | 59 | {% block js %} 60 | {% assets "read" %} 61 | 62 | {% endassets %} 63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /shhh/web/__init__.py: -------------------------------------------------------------------------------- 1 | from .web import web # noqa: F401 2 | -------------------------------------------------------------------------------- /shhh/web/web.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask import current_app as app 3 | from flask import redirect 4 | from flask import render_template as rt 5 | from flask import request, send_from_directory, url_for 6 | 7 | from shhh.constants import (DEFAULT_EXPIRATION_TIME_VALUE, 8 | DEFAULT_READ_TRIES_VALUE, 9 | READ_TRIES_VALUES, 10 | EXPIRATION_TIME_VALUES) 11 | 12 | web = Blueprint("web", __name__, url_prefix="/") 13 | 14 | 15 | @web.get("/") 16 | def create(): 17 | return rt("create.html", 18 | secret_max_length=app.config["SHHH_SECRET_MAX_LENGTH"], 19 | expiration_time_values=EXPIRATION_TIME_VALUES, 20 | default_expiration_time_value=DEFAULT_EXPIRATION_TIME_VALUE, 21 | read_tries_values=READ_TRIES_VALUES, 22 | default_read_tries_value=DEFAULT_READ_TRIES_VALUE) 23 | 24 | 25 | @web.get("/secret") 26 | def created(): 27 | link, expires_on = request.args.get("link"), request.args.get("expires_on") 28 | if not link or not expires_on: 29 | return redirect(url_for("web.create")) 30 | return rt("created.html", link=link, expires_on=expires_on) 31 | 32 | 33 | @web.get("/secret/") 34 | def read(external_id: int): 35 | return rt("read.html", external_id=external_id) 36 | 37 | 38 | @web.get("/robots.txt") 39 | def robots_dot_txt(): 40 | return send_from_directory(app.static_folder, request.path[1:]) 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smallwat3r/shhh/be3ea82608b64b786137b44c4e81c94090d463c9/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from shhh.adapters import orm 4 | from shhh.constants import EnvConfig 5 | from shhh.entrypoint import create_app 6 | from shhh.extensions import db 7 | 8 | 9 | @pytest.fixture(scope="session", autouse=True) 10 | def app(): 11 | flask_app = create_app(env=EnvConfig.TESTING) 12 | db.app = flask_app 13 | 14 | with flask_app.app_context(): 15 | orm.metadata.create_all(db.engine) 16 | 17 | yield flask_app 18 | 19 | with flask_app.app_context(): 20 | orm.metadata.drop_all(db.engine) 21 | 22 | 23 | @pytest.fixture(autouse=True) 24 | def session(app): 25 | context = app.app_context() 26 | context.push() 27 | 28 | for table in reversed(orm.metadata.sorted_tables): 29 | db.session.execute(table.delete()) 30 | 31 | yield 32 | 33 | db.session.rollback() 34 | context.pop() 35 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from http import HTTPStatus 3 | from urllib.parse import urlparse 4 | 5 | import pytest 6 | from flask import url_for 7 | 8 | from shhh.constants import Message, Status 9 | from shhh.domain import model 10 | from shhh.extensions import db 11 | 12 | 13 | @pytest.fixture 14 | def post_payload() -> dict[str, str]: 15 | return {"secret": "message", "passphrase": "Hello123", "expire": "3d"} 16 | 17 | 18 | @pytest.fixture 19 | def secret(post_payload) -> model.Secret: 20 | secret = model.Secret.encrypt(message=post_payload["secret"], 21 | passphrase=post_payload["passphrase"], 22 | expire_code=post_payload["expire"]) 23 | db.session.add(secret) 24 | db.session.commit() 25 | return secret 26 | 27 | 28 | def test_api_post_create_secret(app, post_payload): 29 | with app.test_request_context(), app.test_client() as test_client: 30 | response = test_client.post(url_for("api.secret"), json=post_payload) 31 | data = response.get_json() 32 | assert response.status_code == HTTPStatus.CREATED 33 | 34 | # ensure all the keys are present in the response 35 | for field in ("status", "details", "external_id", "link", "expires_on"): 36 | assert field in data["response"].keys() 37 | 38 | # test status is correct 39 | assert data["response"]["status"] == Status.CREATED 40 | 41 | # test expiry date is correct 42 | assert data["response"]["expires_on"].split(" at ")[0] == ( 43 | datetime.now() + timedelta(days=3)).strftime("%B %d, %Y") 44 | 45 | # test the generated link uses the custom SHHH_HOST variable 46 | hostname = urlparse(data["response"]["link"]).netloc 47 | assert hostname == "test.test" 48 | 49 | # test the record is persisted 50 | external_id = data["response"]["external_id"] 51 | record = db.session.query(model.Secret).filter( 52 | model.Secret.has_external_id(external_id)).one_or_none() 53 | assert record is not None 54 | 55 | 56 | def test_api_post_wrong_expire_value(app, post_payload): 57 | post_payload["expire"] = "12m" 58 | with app.test_request_context(), app.test_client() as test_client: 59 | response = test_client.post(url_for("api.secret"), json=post_payload) 60 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 61 | data = response.get_json() 62 | assert data["response"]["status"] == Status.ERROR 63 | assert data["response"]["details"] == ("Must be one of: 10m, 30m, 1h, " 64 | "3h, 6h, 1d, 2d, 3d, 5d, 7d.") 65 | 66 | 67 | @pytest.mark.parametrize("field", ("passphrase", "secret")) 68 | def test_api_post_missing_required_field(app, post_payload, field): 69 | post_payload.pop(field) 70 | with app.test_request_context(), app.test_client() as test_client: 71 | response = test_client.post(url_for("api.secret"), json=post_payload) 72 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 73 | data = response.get_json() 74 | assert data["response"]["status"] == Status.ERROR 75 | assert data["response"]["details"] == "Missing data for required field." 76 | 77 | 78 | @pytest.mark.parametrize("passphrase", 79 | ("hello", "Hello", "Helloooo", "h3lloooo")) 80 | def test_api_post_weak_passphrase(app, post_payload, passphrase): 81 | post_payload["passphrase"] = passphrase 82 | with app.test_request_context(), app.test_client() as test_client: 83 | response = test_client.post(url_for("api.secret"), json=post_payload) 84 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 85 | data = response.get_json() 86 | assert data["response"]["status"] == Status.ERROR 87 | assert data["response"]["details"] == ( 88 | "Sorry, your passphrase is too weak. It needs minimum 8 " 89 | "characters, with 1 number and 1 uppercase.") 90 | 91 | 92 | def test_api_post_secret_too_long(app, post_payload): 93 | post_payload["secret"] = "MoreThan20Characters!" 94 | with app.test_request_context(), app.test_client() as test_client: 95 | response = test_client.post(url_for("api.secret"), json=post_payload) 96 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 97 | data = response.get_json() 98 | assert data["response"]["status"] == Status.ERROR 99 | assert data["response"]["details"] == ("The secret should not exceed " 100 | "20 characters.") 101 | 102 | 103 | def test_api_get_wrong_passphrase(app, secret): 104 | with app.test_request_context(), app.test_client() as test_client: 105 | response = test_client.get( 106 | url_for("api.secret", 107 | external_id=secret.external_id, 108 | passphrase="wrong!")) 109 | assert response.status_code == HTTPStatus.UNAUTHORIZED 110 | data = response.get_json() 111 | 112 | db.session.refresh(secret) 113 | 114 | assert data["response"]["status"] == Status.INVALID 115 | assert data["response"]["msg"] == Message.INVALID.format( 116 | remaining=secret.tries) 117 | 118 | 119 | def test_api_get_exceeded_tries(app, secret): 120 | # set only one try to remain on secret 121 | secret.tries = 1 122 | db.session.commit() 123 | 124 | external_id = secret.external_id 125 | 126 | with app.test_request_context(), app.test_client() as test_client: 127 | response = test_client.get( 128 | url_for("api.secret", 129 | external_id=secret.external_id, 130 | passphrase="wrong!")) 131 | assert response.status_code == HTTPStatus.UNAUTHORIZED 132 | data = response.get_json() 133 | assert data["response"]["status"] == Status.INVALID 134 | assert data["response"]["msg"] == Message.EXCEEDED 135 | 136 | secret = db.session.query(model.Secret).filter( 137 | model.Secret.has_external_id(external_id)).one_or_none() 138 | 139 | # the secret should have been deleted 140 | assert secret is None 141 | 142 | 143 | def test_api_message_expired(app): 144 | with app.test_request_context(), app.test_client() as test_client: 145 | response = test_client.get( 146 | url_for("api.secret", external_id="123456", passphrase="Hello123")) 147 | assert response.status_code == HTTPStatus.NOT_FOUND 148 | data = response.get_json() 149 | assert data["response"]["status"] == Status.EXPIRED 150 | assert data["response"]["msg"] == Message.NOT_FOUND 151 | 152 | 153 | def test_api_read_secret(app, secret, post_payload): 154 | external_id = secret.external_id 155 | with app.test_request_context(), app.test_client() as test_client: 156 | response = test_client.get( 157 | url_for("api.secret", 158 | external_id=external_id, 159 | passphrase=post_payload["passphrase"])) 160 | assert response.status_code == HTTPStatus.OK 161 | data = response.get_json() 162 | assert data["response"]["status"] == Status.SUCCESS 163 | assert data["response"]["msg"] == post_payload["secret"] 164 | 165 | secret = db.session.query(model.Secret).filter( 166 | model.Secret.has_external_id(external_id)).one_or_none() 167 | 168 | # the secret should have been deleted 169 | assert secret is None 170 | -------------------------------------------------------------------------------- /tests/test_db_liveness.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from unittest import mock 3 | 4 | from flask import url_for 5 | from sqlalchemy.exc import OperationalError 6 | 7 | 8 | @mock.patch("shhh.liveness._perform_db_connectivity_query", 9 | side_effect=OperationalError(None, None, None)) 10 | def test_db_liveness_cannot_be_reached(mock_perform_dummy_db_query, 11 | app, 12 | caplog): 13 | with app.test_request_context(), app.test_client() as test_client: 14 | response = test_client.post(url_for("api.secret"), 15 | json={ 16 | "secret": "secret message", 17 | "passphrase": "Hello123" 18 | }) 19 | 20 | assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE # 503 21 | assert ("Could not reach the database, something is wrong " 22 | "with the database connection") in caplog.text 23 | 24 | 25 | @mock.patch("shhh.liveness._check_table_exists", return_value=False) 26 | def test_db_liveness_table_does_not_exists(mock_check_table_exists, 27 | app, 28 | caplog): 29 | with app.test_request_context(), app.test_client() as test_client: 30 | response = test_client.post(url_for("api.secret"), 31 | json={ 32 | "secret": "secret message", 33 | "passphrase": "Hello123" 34 | }) 35 | 36 | assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE # 503 37 | assert ("Could not query required table 'secret', make sure it " 38 | "has been created on the database") in caplog.text 39 | -------------------------------------------------------------------------------- /tests/test_scheduler.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from shhh.domain import model 4 | from shhh.extensions import db, scheduler 5 | from shhh.scheduler import tasks 6 | 7 | 8 | def test_scheduler_setup(): 9 | jobs = scheduler.get_jobs() 10 | 11 | # check task name 12 | assert jobs[0].name == "delete_expired_records" 13 | 14 | # check that the task will run before the next minute 15 | scheduled = jobs[0].next_run_time.strftime("%Y-%m-%d %H:%M:%S") 16 | next_minute = (datetime.now() + 17 | timedelta(minutes=1)).strftime("%Y-%m-%d %H:%M:%S") 18 | assert scheduled <= next_minute 19 | 20 | 21 | def test_scheduler_job(): 22 | # pause the scheduler so we can trigger it on demand 23 | scheduler.pause_job("delete_expired_records") 24 | 25 | # create a secret, and make it outdated 26 | secret = model.Secret.encrypt(message="hello", 27 | passphrase="Hello123", 28 | expire_code="1d", 29 | tries=3) 30 | secret.date_expires = datetime.now() - timedelta(days=1) 31 | db.session.add(secret) 32 | db.session.commit() 33 | 34 | external_id = secret.external_id 35 | 36 | # run scheduler task 37 | tasks.delete_expired_records() 38 | 39 | secret = db.session.query(model.Secret).filter( 40 | model.Secret.has_external_id(external_id)).one_or_none() 41 | 42 | # the secret should have been deleted 43 | assert secret is None 44 | -------------------------------------------------------------------------------- /tests/test_web.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from flask import url_for 4 | 5 | 6 | def test_create_route(app): 7 | with app.test_request_context(), app.test_client() as test_client: 8 | response = test_client.get(url_for("web.create")) 9 | assert response.status_code == HTTPStatus.OK 10 | 11 | 12 | def test_robots_dot_txt_route(app): 13 | with app.test_request_context(), app.test_client() as test_client: 14 | response = test_client.get(url_for("web.robots_dot_txt")) 15 | response.close() # avoid unclosed file warning 16 | assert response.status_code == HTTPStatus.OK 17 | 18 | 19 | def test_read_route(app): 20 | with app.test_request_context(), app.test_client() as test_client: 21 | response = test_client.get( 22 | url_for("web.read", external_id="fK6YTEVO2bvOln7pHOFi")) 23 | assert response.status_code == HTTPStatus.OK 24 | 25 | 26 | def test_created_route(app): 27 | with app.test_request_context(), app.test_client() as test_client: 28 | response = test_client.get( 29 | url_for("web.created", 30 | link="https://test.test/r/z6HNg2dCcvvaOXli1z3x", 31 | expires_on="2020-05-01%20at%2022:28%20UTC")) 32 | assert response.status_code == HTTPStatus.OK 33 | 34 | 35 | def test_created_route_redirect(app): 36 | with app.test_request_context(), app.test_client() as test_client: 37 | response = test_client.get( 38 | url_for("web.created", 39 | link="https://test.test/r/z6HNg2dCcvvaOXli1z3x")) 40 | assert response.status_code == HTTPStatus.FOUND 41 | 42 | with app.test_request_context(), app.test_client() as test_client: 43 | response = test_client.get( 44 | url_for("web.created", expires_on="2020-05-01%20at%2022:28%20UTC")) 45 | assert response.status_code == HTTPStatus.FOUND 46 | 47 | 48 | def test_route_not_found(app): 49 | with app.test_request_context(), app.test_client() as test_client: 50 | response = test_client.get("/notfound") 51 | assert response.status_code == HTTPStatus.NOT_FOUND 52 | -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from shhh.entrypoint import create_app 4 | 5 | app = create_app(os.environ.get("FLASK_ENV")) 6 | 7 | if __name__ == "__main__": 8 | app.run() 9 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | bulma@^1.0.0: 6 | version "1.0.4" 7 | resolved "https://registry.yarnpkg.com/bulma/-/bulma-1.0.4.tgz#942dc017a3a201fa9f0e0c8db3dd52f3cff86712" 8 | integrity sha512-Ffb6YGXDiZYX3cqvSbHWqQ8+LkX6tVoTcZuVB3lm93sbAVXlO0D6QlOTMnV6g18gILpAXqkG2z9hf9z4hCjz2g== 9 | 10 | classnames@^2.2.5: 11 | version "2.2.6" 12 | resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" 13 | integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== 14 | 15 | core-js@^3.1.3: 16 | version "3.6.5" 17 | resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" 18 | integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== 19 | 20 | feather-icons@^4.29.0: 21 | version "4.29.2" 22 | resolved "https://registry.yarnpkg.com/feather-icons/-/feather-icons-4.29.2.tgz#b03a47588a1c400f215e884504db1c18860d89f8" 23 | integrity sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA== 24 | dependencies: 25 | classnames "^2.2.5" 26 | core-js "^3.1.3" 27 | 28 | hack-font@^3.3.0: 29 | version "3.3.0" 30 | resolved "https://registry.yarnpkg.com/hack-font/-/hack-font-3.3.0.tgz#be36ffe9cfc318c555aa04c8c26a48d0e53230d5" 31 | integrity sha512-RohrcAr3UaKiIoxDlOytCjObcUAucfFc6V5fKu6gBrvmvTfIXeBqZwR0Q5kb9qpbluThJWt326LClLKIGiFyug== 32 | 33 | typeface-work-sans@^1.1.13: 34 | version "1.1.13" 35 | resolved "https://registry.yarnpkg.com/typeface-work-sans/-/typeface-work-sans-1.1.13.tgz#344ac638cbebcd55611fc4c2d08c7c9b47792157" 36 | integrity sha512-LA5agpsXPLWFaUB88mhxsjtwius4Eu2AHuy5gaKeRY8RMHpEzJbDCES8DWbJ/vAYOljaE1OIoKeqrFdDqjKDtQ== 37 | --------------------------------------------------------------------------------