├── .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 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
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 | copy
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 | Decrypt
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | Secret decrypted!
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
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 |
--------------------------------------------------------------------------------