├── .dockerignore ├── .github └── workflows │ └── fly.yml ├── .gitignore ├── .prettierignore ├── Dockerfile ├── Makefile ├── README.md ├── etc └── litefs.yml ├── fly.toml ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 11f0efe169a2_.py │ └── 71cff8962617_.py ├── poetry.lock ├── pyproject.toml ├── render.yaml ├── requirements.txt ├── scripts └── app ├── server ├── __init__.py ├── bp_auth.py ├── bp_inside.py ├── bp_maintenance.py ├── bp_outside.py ├── create_app.py ├── db.py ├── default_settings.py ├── login_manager.py ├── static │ └── style.css └── templates │ ├── auth │ ├── login.html │ └── register.html │ ├── base.html │ ├── inside │ └── dashboard.html │ ├── maintenance │ └── index.html │ └── outside │ └── index.html └── workspace.code-workspace /.dockerignore: -------------------------------------------------------------------------------- 1 | fly.toml 2 | *.pyc 3 | *.pyo 4 | *.mo 5 | *.db 6 | *.css.map 7 | *.egg-info 8 | *.sql.gz 9 | .cache 10 | .project 11 | .idea 12 | .pydevproject 13 | .DS_Store 14 | .git/ 15 | .sass-cache 16 | .vagrant/ 17 | __pycache__ 18 | docs 19 | logs 20 | Dockerfile 21 | -------------------------------------------------------------------------------- /.github/workflows/fly.yml: -------------------------------------------------------------------------------- 1 | name: Fly Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | env: 7 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 8 | jobs: 9 | deploy: 10 | name: Deploy app 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: superfly/flyctl-actions/setup-flyctl@master 15 | - run: flyctl deploy --remote-only 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__/ 3 | 4 | instance/ 5 | 6 | .pytest_cache/ 7 | .coverage 8 | htmlcov/ 9 | 10 | dist/ 11 | build/ 12 | *.egg-info/ 13 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.html -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # pull official base image 2 | FROM python:3.9.13-alpine 3 | 4 | # install dependencies for maintenance (not needed to run the app) 5 | RUN apk add --no-cache make sqlite 6 | 7 | # set work directory 8 | WORKDIR /usr/src/app 9 | 10 | # set environment variables 11 | ENV PYTHONDONTWRITEBYTECODE 1 12 | ENV PYTHONUNBUFFERED 1 13 | 14 | # create the code directory - and switch to it 15 | RUN mkdir -p /code 16 | WORKDIR /code 17 | 18 | # install dependencies 19 | COPY requirements.txt /tmp/requirements.txt 20 | RUN set -ex && \ 21 | pip install --upgrade pip && \ 22 | pip install -r /tmp/requirements.txt && \ 23 | rm -rf /root/.cache/ 24 | 25 | # copy project 26 | COPY . /code/ 27 | 28 | EXPOSE 8080 29 | 30 | CMD ["gunicorn", "--bind", ":8080", "server:app"] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ### commands to run while in maintenance mode ### 2 | 3 | maintenance-db-upgrade: 4 | export FLASK_MAINTENANCE_MODE = 0 5 | flask --app server db upgrade 6 | 7 | ### commands to run locally ### 8 | 9 | local-serve: 10 | poetry run python -m flask --app server --debug run 11 | 12 | local-db-migrate: 13 | poetry run python -m flask --app server db migrate 14 | 15 | local-db-upgrade: 16 | poetry run python -m flask --app server db upgrade 17 | 18 | # you need to run this in order for prod to install the same dependencies you have in Poetry 19 | local-freeze-deps: 20 | rm -f requirements.txt 21 | poetry export --without-hashes > requirements.txt 22 | 23 | ### fly.io stuff (delete if you use render) ### 24 | 25 | fly-makevolume: 26 | # change 'cheapo' to your app name 27 | # change 'sjc' to your region 28 | fly volumes create -a cheapo -r sjc --size 1 data -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cheapo Website Boilerplate 2 | 3 | Hosting web sites with databases is too damn expensive if you follow the instructions on Render, Digital Ocean, Heroku, etc. They all suggest you connect a \$15+/month managed database to your rinky-dink Python app, and you end up paying like $25/month and still having strict limitations. Meanwhile, many people claim SQLite is a perfectly good production database for small web sites, but nobody tells you how to actually deploy it with persistent storage. 4 | 5 | Well, I figured it out. Here it is. Fork this repo, change the service name in `render.yaml`, modify the code to your heart's content, and deploy it to [render.com](https://render.com) for $8/mo. Or you can deploy to [Fly.io](https://fly.io) on the free tier, capped at $2/mo if you exceed it. 6 | 7 | The demo deployments (the lowest tiers of Render and Fly.io) can do 330 and 110 requests per second, respectively, measured from a home internet connection in San Francisco, CA using `apib`. These are honestly really horrible numbers, but probably just reflect the cheap vCPUs they are deployed on. 8 | 9 | **This setup does not do zero-downtime deployments. Your web site will go down for about a minute during each deploy. 😱‼️** 10 | 11 | **Although I've done my best to test this code and these instructions, it's still just a small weekend experiment, so there might be mistakes.** 12 | 13 | It's 95% Flask boilerplate. 14 | 15 | Features: 16 | 17 | - Basic Flask setup with blueprints 18 | - Flask-Login, Flask-SQLAlchemy, and Flask-Migrate are already configured 19 | - Basic login/register/logout functionality 20 | - Maintenance mode for running database migrations 21 | 22 | ## Development 23 | 24 | Common workflows are written as Make commands. These docs assume you're using macOS, but everything should translate to Linux other than some installation steps. 25 | 26 | ### 1. Set up Poetry 27 | 28 | Python dependencies are managed using [Poetry](https://python-poetry.org) in development, and using Pip in production. 29 | 30 | ```sh 31 | poetry init 32 | poetry install 33 | ``` 34 | 35 | ### 2. Run migrations 36 | 37 | Migrations are always applied on the command line, never automatically. 38 | 39 | ```sh 40 | make local-runmigrations 41 | ``` 42 | 43 | ### 2. Run the local development server 44 | 45 | ``` 46 | make serve 47 | ``` 48 | 49 | ## Deployment with Render 50 | 51 | ### First-time setup 52 | 53 | 1. Use Render's [Blueprints](https://dashboard.render.com/blueprints) feature. 54 | 2. Set some environment variables on the dashboard for your new web service: 55 | - `FLASK_SECRET_KEY`: a random string (https://www.uuidgenerator.net). 56 | - `FLASK_MAINTENANCE_MODE`: `1` (this will run your first deploy in maintenance mode so you can run migrations) 57 | 3. Use Render's in-browser SSH page to log in and run `make maintenance-runmigrations`. 58 | 4. Set `FLASK_MAINTENANCE_MODE` to `0`, and Render will redeploy the site. You should now be able to use the database. 59 | 60 | ### Database migrations 61 | 62 | Familiarize yourself with [Flask-Migrate](https://flask-migrate.readthedocs.io/en/latest/). Unfortunately for all us web backend developers, we can never escape database migrations, and we need to do them right. 63 | 64 | **Quick note: it's probably not necessary to use maintenance mode to run migrations, but I haven't had time to update and test new instructions yet.** 65 | 66 | Whenever you make a change to your database, follow these steps: 67 | 68 | 1. Make the change in your Python source code. Consider using [deferred column loading](https://docs.sqlalchemy.org/en/14/orm/loading_columns.html#deferred-column-loading) to eliminate runtime errors before your migration has been applied to your database. 69 | 2. Run `make local-db-migrate` (alias for `poetry run flask --app server db migrate`) to create the migration files. Check them by hand. 70 | 3. Run `make local-db-upgrade` (alias for `poetry run flask --app server db upgrade`) 71 | 4. Commit your changes. 72 | 5. Set the web site to maintenance mode (`FLASK_MAINTENANCE_MODE=1`). 73 | 6. Deploy your changes. 74 | 7. SSH into your service. 75 | 8. Run `make maintenance-db-upgrade`. 76 | 9. Set the web site back to normal mode (`FLASK_MAINTENANCE_MODE=0`). 77 | 10. If you used deferred column loading, you can now remove the `deferred()` wrappers. 78 | 79 | ## Deployment with Fly.io 80 | 81 | ### First-time setup 82 | 83 | 1. In `fly.toml`, set `FLASK_MAINTENANCE_MODE` to `1` (instead of `0`) so your first deploy runs in maintenance mode. 84 | 2. Run `fly deploy` to create and deploy an app. (You might need to use `fly launch` instead, I forget. Someone please send me a PR to update this sentence.) 85 | 3. Run `fly secrets set FLASK_SECRET_KEY=(random string)` (https://uuidgenerator.net). 86 | 4. Run `fly ssh console`. In the SSH session, `cd /code && make maintenance-db-upgrade`. (It should be possible to get this down to one line, but I'm having trouble with `fly ssh console -C`.) 87 | 5. In `fly.toml`, set `FLASK_MAINTENANCE_MODE` back to `0`. 88 | 6. Run `fly deploy` to redeploy the site without maintenance mode. 89 | 90 | ### Database migrations 91 | 92 | Familiarize yourself with [Flask-Migrate](https://flask-migrate.readthedocs.io/en/latest/). Unfortunately for all us web backend developers, we can never escape database migrations, and we need to do them right. 93 | 94 | **Quick note: it's probably not necessary to use maintenance mode to run migrations, but I haven't had time to update and test new instructions yet.** 95 | 96 | Whenever you make a change to your database, follow these steps: 97 | 98 | 1. Make the change in your Python source code. Consider using [deferred column loading](https://docs.sqlalchemy.org/en/14/orm/loading_columns.html#deferred-column-loading) to eliminate runtime errors before your migration has been applied to your database. 99 | 2. Run `make local-db-migrate` (alias for `poetry run flask --app server db migrate`) to create the migration files. Check them by hand. 100 | 3. Run `make local-db-upgrade` (alias for `poetry run flask --app server db upgrade`) 101 | 4. Commit your changes. 102 | 5. Set the web site to maintenance mode (`fly secrets set FLASK_MAINTENANCE_MODE=1`). 103 | 6. Run `fly ssh console`. In the SSH session, `cd /code && make maintenance-db-upgrade`. (It should be possible to get this down to one line, but I'm having trouble with `fly ssh console -C`.) 104 | 7. Set the web site back to normal mode (`fly secrets set FLASK_MAINTENANCE_MODE=0`). 105 | 8. If you used deferred column loading, you can now remove the `deferred()` wrappers. 106 | 107 | ## Organization 108 | 109 | All Python code is inside `server`, leaving you space to create a `client` directory for rich JS apps if you like. 110 | 111 | All view functions are inside Flask Blueprints. Each blueprint is defined in a file with a `bp_` prefix. I like this prefix because it keeps the directory flat and makes imports look really obvious, but of course you can rename the files if you want. 112 | 113 | `bp_maintenance.py` contains the routes for maintenance mode (every page will say "this web site is in maintenance mode"). You can remove this file and the call to it in `create_app.py` if you can handle the SQLite database being opened in read-only mode, which is probably nicer. 114 | 115 | `inside` refers to the logged-in-user-oriented views (like "dashboard"), and `outside` refers to logged-out-user-oriented views (like "index", the landing page). 116 | 117 | ## Backups??? 118 | 119 | Render automatically backs up the disk every day, so you have data from at most 24 hours ago. 120 | -------------------------------------------------------------------------------- /etc/litefs.yml: -------------------------------------------------------------------------------- 1 | # The fuse section describes settings for the FUSE file system. This file system 2 | # is used as a thin layer between the SQLite client in your application and the 3 | # storage on disk. It intercepts disk writes to determine transaction boundaries 4 | # so that those transactions can be saved and shipped to replicas. 5 | fuse: 6 | dir: "/litefs" 7 | 8 | # The data section describes settings for the internal LiteFS storage. We'll 9 | # mount a volume to the data directory so it can be persisted across restarts. 10 | # However, this data should not be accessed directly by the user application. 11 | data: 12 | dir: "/var/lib/litefs" 13 | 14 | # This flag ensure that LiteFS continues to run if there is an issue on starup. 15 | # It makes it easy to ssh in and debug any issues you might be having rather 16 | # than continually restarting on initialization failure. 17 | exit-on-error: false 18 | 19 | # The lease section specifies how the cluster will be managed. We're using the 20 | # "consul" lease type so that our application can dynamically change the primary. 21 | # 22 | # These environment variables will be available in your Fly.io application. 23 | # You must specify "experiement.enable_consul" for FLY_CONSUL_URL to be available. 24 | lease: 25 | type: "consul" 26 | advertise-url: "http://${HOSTNAME}.vm.${FLY_APP_NAME}.internal:20202" 27 | 28 | consul: 29 | url: "${FLY_CONSUL_URL}" 30 | key: "litefs/${FLY_APP_NAME}" 31 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for cheapo on 2023-02-04T13:17:53-08:00 2 | 3 | app = "cheapo" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | # [build] 9 | # This is what tells fly.io to use a Python config instead of generic Docker. 10 | # But don't use it, because it sucks. If you SSH in, then Python isn't even 11 | # available to run migrations. Boo. 12 | # builder = "paketobuildpacks/builder:full" 13 | 14 | [env] 15 | FLASK_SQLALCHEMY_DATABASE_URI = "sqlite:////var/lib/data/app.db" 16 | FLASK_MAINTENANCE_MODE = 0 17 | PORT = "8080" 18 | 19 | [experimental] 20 | auto_rollback = true 21 | 22 | [mounts] 23 | destination = "/var/lib/data" 24 | source = "data" 25 | 26 | [[services]] 27 | internal_port = 8080 28 | processes = ["app"] 29 | protocol = "tcp" 30 | script_checks = [] 31 | [services.concurrency] 32 | hard_limit = 25 33 | soft_limit = 20 34 | type = "connections" 35 | 36 | [[services.http_checks]] 37 | grace_period = "5s" 38 | interval = 10000 39 | method = "get" 40 | path = "/_health" 41 | protocol = "http" 42 | restart_limit = 0 43 | timeout = 2000 44 | tls_skip_verify = false 45 | [services.http_checks.headers] 46 | 47 | [[services.ports]] 48 | force_https = true 49 | handlers = ["http"] 50 | port = 80 51 | 52 | [[services.ports]] 53 | handlers = ["tls", "http"] 54 | port = 443 55 | 56 | [[services.tcp_checks]] 57 | grace_period = "1s" 58 | interval = "15s" 59 | restart_limit = 0 60 | timeout = "2s" 61 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Single-database configuration for Flask. 2 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic,flask_migrate 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [logger_flask_migrate] 38 | level = INFO 39 | handlers = 40 | qualname = flask_migrate 41 | 42 | [handler_console] 43 | class = StreamHandler 44 | args = (sys.stderr,) 45 | level = NOTSET 46 | formatter = generic 47 | 48 | [formatter_generic] 49 | format = %(levelname)-5.5s [%(name)s] %(message)s 50 | datefmt = %H:%M:%S 51 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from flask import current_app 7 | 8 | from alembic import context 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | fileConfig(config.config_file_name) 17 | logger = logging.getLogger('alembic.env') 18 | 19 | 20 | def get_engine(): 21 | try: 22 | # this works with Flask-SQLAlchemy<3 and Alchemical 23 | return current_app.extensions['migrate'].db.get_engine() 24 | except TypeError: 25 | # this works with Flask-SQLAlchemy>=3 26 | return current_app.extensions['migrate'].db.engine 27 | 28 | 29 | # add your model's MetaData object here 30 | # for 'autogenerate' support 31 | # from myapp import mymodel 32 | # target_metadata = mymodel.Base.metadata 33 | config.set_main_option( 34 | 'sqlalchemy.url', str(get_engine().url).replace('%', '%%')) 35 | target_db = current_app.extensions['migrate'].db 36 | 37 | # other values from the config, defined by the needs of env.py, 38 | # can be acquired: 39 | # my_important_option = config.get_main_option("my_important_option") 40 | # ... etc. 41 | 42 | 43 | def get_metadata(): 44 | if hasattr(target_db, 'metadatas'): 45 | return target_db.metadatas[None] 46 | return target_db.metadata 47 | 48 | 49 | def run_migrations_offline(): 50 | """Run migrations in 'offline' mode. 51 | 52 | This configures the context with just a URL 53 | and not an Engine, though an Engine is acceptable 54 | here as well. By skipping the Engine creation 55 | we don't even need a DBAPI to be available. 56 | 57 | Calls to context.execute() here emit the given string to the 58 | script output. 59 | 60 | """ 61 | url = config.get_main_option("sqlalchemy.url") 62 | context.configure( 63 | url=url, target_metadata=get_metadata(), literal_binds=True 64 | ) 65 | 66 | with context.begin_transaction(): 67 | context.run_migrations() 68 | 69 | 70 | def run_migrations_online(): 71 | """Run migrations in 'online' mode. 72 | 73 | In this scenario we need to create an Engine 74 | and associate a connection with the context. 75 | 76 | """ 77 | 78 | # this callback is used to prevent an auto-migration from being generated 79 | # when there are no changes to the schema 80 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 81 | def process_revision_directives(context, revision, directives): 82 | if getattr(config.cmd_opts, 'autogenerate', False): 83 | script = directives[0] 84 | if script.upgrade_ops.is_empty(): 85 | directives[:] = [] 86 | logger.info('No changes in schema detected.') 87 | 88 | connectable = get_engine() 89 | 90 | with connectable.connect() as connection: 91 | context.configure( 92 | connection=connection, 93 | target_metadata=get_metadata(), 94 | process_revision_directives=process_revision_directives, 95 | **current_app.extensions['migrate'].configure_args 96 | ) 97 | 98 | with context.begin_transaction(): 99 | context.run_migrations() 100 | 101 | 102 | if context.is_offline_mode(): 103 | run_migrations_offline() 104 | else: 105 | run_migrations_online() 106 | -------------------------------------------------------------------------------- /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 alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/11f0efe169a2_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 11f0efe169a2 4 | Revises: 71cff8962617 5 | Create Date: 2023-02-05 12:12:11.554266 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "11f0efe169a2" 14 | down_revision = "71cff8962617" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table("user", schema=None) as batch_op: 22 | batch_op.add_column( 23 | sa.Column("name", sa.String(), nullable=False, server_default="") 24 | ) 25 | batch_op.add_column( 26 | sa.Column("pronouns", sa.String(), nullable=False, server_default="") 27 | ) 28 | 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade(): 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | with op.batch_alter_table("user", schema=None) as batch_op: 35 | batch_op.drop_column("pronouns") 36 | batch_op.drop_column("name") 37 | 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /migrations/versions/71cff8962617_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 71cff8962617 4 | Revises: 5 | Create Date: 2023-01-31 17:41:38.516384 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '71cff8962617' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('user', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('email', sa.String(), nullable=False), 24 | sa.Column('password_hash', sa.String(), nullable=False), 25 | sa.PrimaryKeyConstraint('id'), 26 | sa.UniqueConstraint('password_hash') 27 | ) 28 | with op.batch_alter_table('user', schema=None) as batch_op: 29 | batch_op.create_index(batch_op.f('ix_user_email'), ['email'], unique=True) 30 | 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | with op.batch_alter_table('user', schema=None) as batch_op: 37 | batch_op.drop_index(batch_op.f('ix_user_email')) 38 | 39 | op.drop_table('user') 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "alembic" 3 | version = "1.9.2" 4 | description = "A database migration tool for SQLAlchemy." 5 | category = "main" 6 | optional = false 7 | python-versions = ">=3.7" 8 | 9 | [package.dependencies] 10 | Mako = "*" 11 | SQLAlchemy = ">=1.3.0" 12 | 13 | [package.extras] 14 | tz = ["python-dateutil"] 15 | 16 | [[package]] 17 | name = "black" 18 | version = "22.12.0" 19 | description = "The uncompromising code formatter." 20 | category = "dev" 21 | optional = false 22 | python-versions = ">=3.7" 23 | 24 | [package.dependencies] 25 | click = ">=8.0.0" 26 | mypy-extensions = ">=0.4.3" 27 | pathspec = ">=0.9.0" 28 | platformdirs = ">=2" 29 | tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} 30 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 31 | 32 | [package.extras] 33 | colorama = ["colorama (>=0.4.3)"] 34 | d = ["aiohttp (>=3.7.4)"] 35 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 36 | uvloop = ["uvloop (>=0.15.2)"] 37 | 38 | [[package]] 39 | name = "click" 40 | version = "8.1.3" 41 | description = "Composable command line interface toolkit" 42 | category = "main" 43 | optional = false 44 | python-versions = ">=3.7" 45 | 46 | [package.dependencies] 47 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 48 | 49 | [[package]] 50 | name = "colorama" 51 | version = "0.4.6" 52 | description = "Cross-platform colored terminal text." 53 | category = "main" 54 | optional = false 55 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 56 | 57 | [[package]] 58 | name = "flask" 59 | version = "2.2.2" 60 | description = "A simple framework for building complex web applications." 61 | category = "main" 62 | optional = false 63 | python-versions = ">=3.7" 64 | 65 | [package.dependencies] 66 | click = ">=8.0" 67 | importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} 68 | itsdangerous = ">=2.0" 69 | Jinja2 = ">=3.0" 70 | Werkzeug = ">=2.2.2" 71 | 72 | [package.extras] 73 | async = ["asgiref (>=3.2)"] 74 | dotenv = ["python-dotenv"] 75 | 76 | [[package]] 77 | name = "flask-login" 78 | version = "0.6.2" 79 | description = "User authentication and session management for Flask." 80 | category = "main" 81 | optional = false 82 | python-versions = ">=3.7" 83 | 84 | [package.dependencies] 85 | Flask = ">=1.0.4" 86 | Werkzeug = ">=1.0.1" 87 | 88 | [[package]] 89 | name = "flask-migrate" 90 | version = "4.0.2" 91 | description = "SQLAlchemy database migrations for Flask applications using Alembic." 92 | category = "main" 93 | optional = false 94 | python-versions = ">=3.6" 95 | 96 | [package.dependencies] 97 | alembic = ">=1.9.0" 98 | Flask = ">=0.9" 99 | Flask-SQLAlchemy = ">=1.0" 100 | 101 | [[package]] 102 | name = "flask-sqlalchemy" 103 | version = "3.0.2" 104 | description = "Add SQLAlchemy support to your Flask application." 105 | category = "main" 106 | optional = false 107 | python-versions = ">=3.7" 108 | 109 | [package.dependencies] 110 | Flask = ">=2.2" 111 | SQLAlchemy = ">=1.4.18" 112 | 113 | [[package]] 114 | name = "greenlet" 115 | version = "2.0.2" 116 | description = "Lightweight in-process concurrent programming" 117 | category = "main" 118 | optional = false 119 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" 120 | 121 | [package.extras] 122 | docs = ["sphinx", "docutils (<0.18)"] 123 | test = ["objgraph", "psutil"] 124 | 125 | [[package]] 126 | name = "gunicorn" 127 | version = "20.1.0" 128 | description = "WSGI HTTP Server for UNIX" 129 | category = "main" 130 | optional = false 131 | python-versions = ">=3.5" 132 | 133 | [package.extras] 134 | eventlet = ["eventlet (>=0.24.1)"] 135 | gevent = ["gevent (>=1.4.0)"] 136 | setproctitle = ["setproctitle"] 137 | tornado = ["tornado (>=0.2)"] 138 | 139 | [[package]] 140 | name = "importlib-metadata" 141 | version = "6.0.0" 142 | description = "Read metadata from Python packages" 143 | category = "main" 144 | optional = false 145 | python-versions = ">=3.7" 146 | 147 | [package.dependencies] 148 | zipp = ">=0.5" 149 | 150 | [package.extras] 151 | docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"] 152 | perf = ["ipython"] 153 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8", "importlib-resources (>=1.3)"] 154 | 155 | [[package]] 156 | name = "itsdangerous" 157 | version = "2.1.2" 158 | description = "Safely pass data to untrusted environments and back." 159 | category = "main" 160 | optional = false 161 | python-versions = ">=3.7" 162 | 163 | [[package]] 164 | name = "jinja2" 165 | version = "3.1.2" 166 | description = "A very fast and expressive template engine." 167 | category = "main" 168 | optional = false 169 | python-versions = ">=3.7" 170 | 171 | [package.dependencies] 172 | MarkupSafe = ">=2.0" 173 | 174 | [package.extras] 175 | i18n = ["Babel (>=2.7)"] 176 | 177 | [[package]] 178 | name = "mako" 179 | version = "1.2.4" 180 | description = "A super-fast templating language that borrows the best ideas from the existing templating languages." 181 | category = "main" 182 | optional = false 183 | python-versions = ">=3.7" 184 | 185 | [package.dependencies] 186 | MarkupSafe = ">=0.9.2" 187 | 188 | [package.extras] 189 | babel = ["babel"] 190 | lingua = ["lingua"] 191 | testing = ["pytest"] 192 | 193 | [[package]] 194 | name = "markupsafe" 195 | version = "2.1.2" 196 | description = "Safely add untrusted strings to HTML/XML markup." 197 | category = "main" 198 | optional = false 199 | python-versions = ">=3.7" 200 | 201 | [[package]] 202 | name = "mypy-extensions" 203 | version = "0.4.3" 204 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 205 | category = "dev" 206 | optional = false 207 | python-versions = "*" 208 | 209 | [[package]] 210 | name = "pathspec" 211 | version = "0.11.0" 212 | description = "Utility library for gitignore style pattern matching of file paths." 213 | category = "dev" 214 | optional = false 215 | python-versions = ">=3.7" 216 | 217 | [[package]] 218 | name = "platformdirs" 219 | version = "2.6.2" 220 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 221 | category = "dev" 222 | optional = false 223 | python-versions = ">=3.7" 224 | 225 | [package.extras] 226 | docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.19.5)", "sphinx (>=5.3)"] 227 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest (>=7.2)"] 228 | 229 | [[package]] 230 | name = "sqlalchemy" 231 | version = "2.0.0" 232 | description = "Database Abstraction Library" 233 | category = "main" 234 | optional = false 235 | python-versions = ">=3.7" 236 | 237 | [package.dependencies] 238 | greenlet = {version = "!=0.4.17", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} 239 | typing-extensions = ">=4.2.0" 240 | 241 | [package.extras] 242 | aiomysql = ["greenlet (!=0.4.17)", "aiomysql"] 243 | aiosqlite = ["greenlet (!=0.4.17)", "aiosqlite", "typing-extensions (!=3.10.0.1)"] 244 | asyncio = ["greenlet (!=0.4.17)"] 245 | asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"] 246 | mariadb_connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] 247 | mssql = ["pyodbc"] 248 | mssql_pymssql = ["pymssql"] 249 | mssql_pyodbc = ["pyodbc"] 250 | mypy = ["mypy (>=0.910)"] 251 | mysql = ["mysqlclient (>=1.4.0)"] 252 | mysql_connector = ["mysql-connector-python"] 253 | oracle = ["cx-oracle (>=7)"] 254 | oracle_oracledb = ["oracledb (>=1.0.1)"] 255 | postgresql = ["psycopg2 (>=2.7)"] 256 | postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"] 257 | postgresql_pg8000 = ["pg8000 (>=1.29.1)"] 258 | postgresql_psycopg = ["psycopg (>=3.0.7)"] 259 | postgresql_psycopg2binary = ["psycopg2-binary"] 260 | postgresql_psycopg2cffi = ["psycopg2cffi"] 261 | pymysql = ["pymysql"] 262 | sqlcipher = ["sqlcipher3-binary"] 263 | 264 | [[package]] 265 | name = "tomli" 266 | version = "2.0.1" 267 | description = "A lil' TOML parser" 268 | category = "dev" 269 | optional = false 270 | python-versions = ">=3.7" 271 | 272 | [[package]] 273 | name = "typing-extensions" 274 | version = "4.4.0" 275 | description = "Backported and Experimental Type Hints for Python 3.7+" 276 | category = "main" 277 | optional = false 278 | python-versions = ">=3.7" 279 | 280 | [[package]] 281 | name = "werkzeug" 282 | version = "2.2.2" 283 | description = "The comprehensive WSGI web application library." 284 | category = "main" 285 | optional = false 286 | python-versions = ">=3.7" 287 | 288 | [package.dependencies] 289 | MarkupSafe = ">=2.1.1" 290 | 291 | [package.extras] 292 | watchdog = ["watchdog"] 293 | 294 | [[package]] 295 | name = "zipp" 296 | version = "3.12.0" 297 | description = "Backport of pathlib-compatible object wrapper for zip files" 298 | category = "main" 299 | optional = false 300 | python-versions = ">=3.7" 301 | 302 | [package.extras] 303 | docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"] 304 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "jaraco.functools", "more-itertools", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8"] 305 | 306 | [metadata] 307 | lock-version = "1.1" 308 | python-versions = "^3.9" 309 | content-hash = "143440e9e0b37539477819421a7d3c9b2f3a9896ebe03f93979a1cf6edf7c898" 310 | 311 | [metadata.files] 312 | alembic = [] 313 | black = [] 314 | click = [] 315 | colorama = [] 316 | flask = [] 317 | flask-login = [] 318 | flask-migrate = [] 319 | flask-sqlalchemy = [] 320 | greenlet = [] 321 | gunicorn = [ 322 | {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, 323 | {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, 324 | ] 325 | importlib-metadata = [] 326 | itsdangerous = [] 327 | jinja2 = [] 328 | mako = [] 329 | markupsafe = [] 330 | mypy-extensions = [ 331 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 332 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 333 | ] 334 | pathspec = [] 335 | platformdirs = [] 336 | sqlalchemy = [] 337 | tomli = [] 338 | typing-extensions = [] 339 | werkzeug = [] 340 | zipp = [] 341 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "cheapo" 3 | version = "0.1.0" 4 | description = "Scaffolding for a web site that costs less than $10/mo to host on Render" 5 | authors = ["Steve Landey "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.9" 10 | Flask = "2.2.2" 11 | flask-sqlalchemy = "3.0.2" 12 | gunicorn = "20.1.0" 13 | Flask-Migrate = "4.0.2" 14 | Flask-Login = "0.6.2" 15 | 16 | [tool.poetry.dev-dependencies] 17 | black = "^22.12.0" 18 | 19 | [build-system] 20 | requires = ["poetry-core>=1.0.0"] 21 | build-backend = "poetry.core.masonry.api" 22 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | # This file determines how the web site is configured on Render.com. 2 | # For fly.io, see Procfile and fly.toml. 3 | services: 4 | - type: web 5 | name: cheapo # update this to your own app name 6 | env: python 7 | plan: starter 8 | buildCommand: pip install -r requirements.txt 9 | startCommand: gunicorn server:app 10 | healthCheckPath: /_health 11 | disk: 12 | name: db-data 13 | mountPath: /data 14 | sizeGB: 1 15 | envVars: 16 | - key: FLASK_SQLALCHEMY_DATABASE_URI 17 | value: "sqlite:////data/app.db" 18 | - key: PYTHON_VERSION 19 | value: 3.9.13 20 | # Be sure to set FLASK_SECRET_KEY in Render! 21 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.9.2 ; python_version >= "3.9" and python_version < "4.0" 2 | click==8.1.3 ; python_version >= "3.9" and python_version < "4.0" 3 | colorama==0.4.6 ; python_version >= "3.9" and python_version < "4.0" and platform_system == "Windows" 4 | flask-login==0.6.2 ; python_version >= "3.9" and python_version < "4.0" 5 | flask-migrate==4.0.2 ; python_version >= "3.9" and python_version < "4.0" 6 | flask-sqlalchemy==3.0.2 ; python_version >= "3.9" and python_version < "4.0" 7 | flask==2.2.2 ; python_version >= "3.9" and python_version < "4.0" 8 | greenlet==2.0.2 ; python_version >= "3.9" and python_version < "4.0" and platform_machine == "aarch64" or python_version >= "3.9" and python_version < "4.0" and platform_machine == "ppc64le" or python_version >= "3.9" and python_version < "4.0" and platform_machine == "x86_64" or python_version >= "3.9" and python_version < "4.0" and platform_machine == "amd64" or python_version >= "3.9" and python_version < "4.0" and platform_machine == "AMD64" or python_version >= "3.9" and python_version < "4.0" and platform_machine == "win32" or python_version >= "3.9" and python_version < "4.0" and platform_machine == "WIN32" 9 | gunicorn==20.1.0 ; python_version >= "3.9" and python_version < "4.0" 10 | importlib-metadata==6.0.0 ; python_version >= "3.9" and python_version < "3.10" 11 | itsdangerous==2.1.2 ; python_version >= "3.9" and python_version < "4.0" 12 | jinja2==3.1.2 ; python_version >= "3.9" and python_version < "4.0" 13 | mako==1.2.4 ; python_version >= "3.9" and python_version < "4.0" 14 | markupsafe==2.1.2 ; python_version >= "3.9" and python_version < "4.0" 15 | sqlalchemy==2.0.0 ; python_version >= "3.9" and python_version < "4.0" 16 | typing-extensions==4.4.0 ; python_version >= "3.9" and python_version < "4.0" 17 | werkzeug==2.2.2 ; python_version >= "3.9" and python_version < "4.0" 18 | zipp==3.12.0 ; python_version >= "3.9" and python_version < "3.10" 19 | -------------------------------------------------------------------------------- /scripts/app: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # When some tutorial says to run "flask X", run "scripts/app" instead, otherwise 3 | # your dependencies won't be available outside Poetry. 4 | poetry run python -m flask --app server "$@" -------------------------------------------------------------------------------- /server/__init__.py: -------------------------------------------------------------------------------- 1 | from server.create_app import create_app 2 | 3 | app = create_app() 4 | -------------------------------------------------------------------------------- /server/bp_auth.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, flash, redirect, render_template, request, url_for 2 | from flask_login import login_user, login_required, logout_user 3 | from server.db import User, db 4 | 5 | bp = Blueprint("auth", "auth", url_prefix="/auth") 6 | 7 | 8 | @bp.route("/register", methods=("GET", "POST")) 9 | def register(): 10 | if request.method == "POST": 11 | email = request.form["email"] 12 | password = request.form["password"] 13 | error = None 14 | 15 | if not email: 16 | error = "Email is required." 17 | elif not password: 18 | error = "Password is required." 19 | elif User.query.filter_by(email=email).first() is not None: 20 | error = "That email is already associated with an account." 21 | 22 | if error is None: 23 | user = User(email=email) 24 | user.set_password(password) 25 | db.session.add(user) 26 | db.session.commit() 27 | return redirect(url_for("auth.login")) 28 | 29 | flash(error) 30 | 31 | return render_template("auth/register.html") 32 | 33 | 34 | @bp.route("/login", methods=["GET", "POST"]) 35 | def login(): 36 | if request.method == "POST": 37 | email = request.form["email"] 38 | password = request.form["password"] 39 | error = None 40 | 41 | user = User.query.filter_by(email=email).first() 42 | if user is None: 43 | error = "No such user" 44 | elif not user.check_password(password): 45 | error = "Incorrect username or password" 46 | 47 | next_url = url_for("inside.dashboard") 48 | 49 | if error is None: 50 | user = User.query.filter_by(email=email).first() 51 | login_user(user) 52 | return redirect(next_url) 53 | 54 | flash(error) 55 | return render_template("auth/login.html") 56 | 57 | 58 | @bp.route("/logout") 59 | @login_required 60 | def logout(): 61 | logout_user() 62 | return redirect(url_for("outside.index")) 63 | -------------------------------------------------------------------------------- /server/bp_inside.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template 2 | from flask_login import login_required 3 | 4 | bp = Blueprint("inside", "inside", url_prefix="") 5 | 6 | 7 | @bp.route("/dashboard", methods=["GET"]) 8 | @login_required 9 | def dashboard(): 10 | return render_template("inside/dashboard.html") 11 | -------------------------------------------------------------------------------- /server/bp_maintenance.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template 2 | 3 | bp = Blueprint("maintenance", "maintenance", url_prefix="") 4 | 5 | 6 | @bp.route("/_health", methods=["GET"]) 7 | def _health(): 8 | # This route is used during deployment to check that the server process is running. 9 | return "OK" 10 | 11 | 12 | @bp.route("/", methods=["GET"], defaults={"path": ""}) 13 | @bp.route("/", methods=["GET"]) 14 | def index(path): 15 | return render_template("maintenance/index.html") 16 | -------------------------------------------------------------------------------- /server/bp_outside.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template, g, redirect, url_for, current_app 2 | from flask_login import current_user 3 | 4 | bp = Blueprint("outside", "outside", url_prefix="") 5 | 6 | 7 | @bp.route("/", methods=["GET"]) 8 | def index(): 9 | if not current_user.is_anonymous: 10 | return redirect(url_for("inside.dashboard")) 11 | return render_template("outside/index.html") 12 | 13 | 14 | @bp.route("/_health", methods=["GET"]) 15 | def _health(): 16 | # This route is used during deployment to check that the server process is running. 17 | return "OK" 18 | -------------------------------------------------------------------------------- /server/create_app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from flask import Flask, g 5 | from flask_migrate import Migrate 6 | 7 | from server.db import User, db 8 | from server.login_manager import login_manager 9 | 10 | 11 | def create_app(): 12 | is_gunicorn = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "") 13 | app = Flask(__name__, instance_relative_config=True) 14 | app.config.from_object("server.default_settings") 15 | app.config.from_prefixed_env() 16 | 17 | is_maintenance = app.config.get("MAINTENANCE_MODE", False) 18 | 19 | if is_gunicorn: 20 | gunicorn_logger = logging.getLogger("gunicorn.error") 21 | app.logger.handlers.extend(gunicorn_logger.handlers) 22 | 23 | if os.environ.get("IS_SUBPROCESS"): 24 | app.logger.setLevel(logging.ERROR) 25 | else: 26 | app.logger.setLevel(logging.INFO) 27 | 28 | if is_maintenance: 29 | app.logger.info("Running in maintenance mode") 30 | # load sqlalchemy read-only. 31 | app.config["SQLALCHEMY_DATABASE_URI"] += "?mode=ro" 32 | 33 | # if your app supports read-only database connections in general, for example if 34 | # almost all routes are for reads, then you could remove the special maintenance 35 | # blueprint and just serve your normal routes. 36 | from . import bp_maintenance 37 | 38 | app.register_blueprint(bp_maintenance.bp) 39 | 40 | # Initialize database stuff so we can run migrations on the command line 41 | db.init_app(app) 42 | Migrate(app, db) 43 | 44 | return app 45 | 46 | if is_gunicorn: 47 | app.logger.info("Running in gunicorn") 48 | else: 49 | app.logger.info("Running in the Flask debug server") 50 | 51 | try: 52 | os.makedirs(app.instance_path) 53 | except OSError: 54 | pass 55 | 56 | db.init_app(app) 57 | Migrate(app, db) 58 | 59 | login_manager.init_app(app) 60 | login_manager.login_view = "login" 61 | 62 | @login_manager.user_loader 63 | def load_user(user_id): 64 | user = User.query.get(int(user_id)) 65 | g.current_user = user 66 | return user 67 | 68 | ### blueprints ### 69 | 70 | from . import bp_auth, bp_inside, bp_outside 71 | 72 | app.register_blueprint(bp_auth.bp) 73 | app.register_blueprint(bp_inside.bp) 74 | app.register_blueprint(bp_outside.bp) 75 | 76 | ### commands ### 77 | 78 | app.logger.info("Database URI: " + app.config["SQLALCHEMY_DATABASE_URI"]) 79 | 80 | return app 81 | -------------------------------------------------------------------------------- /server/db.py: -------------------------------------------------------------------------------- 1 | from flask_login import UserMixin 2 | from flask_sqlalchemy import SQLAlchemy 3 | from sqlalchemy.engine import Engine 4 | from sqlalchemy import event 5 | from sqlalchemy.orm import deferred 6 | from werkzeug.security import generate_password_hash, check_password_hash 7 | 8 | 9 | @event.listens_for(Engine, "connect") 10 | def set_sqlite_pragma(dbapi_connection, connection_record): 11 | cursor = dbapi_connection.cursor() 12 | cursor.execute("PRAGMA journal_mode=WAL") 13 | cursor.close() 14 | 15 | 16 | db = SQLAlchemy() 17 | 18 | 19 | class User(db.Model, UserMixin): 20 | __tablename__ = "user" 21 | 22 | id = db.Column(db.Integer, primary_key=True) 23 | email = db.Column(db.String, unique=True, nullable=False, index=True) 24 | password_hash = db.Column(db.String, unique=True, nullable=False) 25 | 26 | # If you don't need these, feel free to delete and run the migration workflow, or 27 | # ignore them indefinitely. 28 | # Just remember: https://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/ 29 | name = deferred(db.Column(db.String, nullable=False, server_default="")) 30 | pronouns = deferred(db.Column(db.String, nullable=False, server_default="")) 31 | 32 | @property 33 | def is_anonymous(self): 34 | return False 35 | 36 | def get_id(self): 37 | return str(self.id) 38 | 39 | def set_password(self, password): 40 | self.password_hash = generate_password_hash(password) 41 | 42 | def check_password(self, password): 43 | return check_password_hash(self.password_hash, password) 44 | -------------------------------------------------------------------------------- /server/default_settings.py: -------------------------------------------------------------------------------- 1 | LOG_WITH_GUNICORN = (False,) 2 | SECRET_KEY = "dev" 3 | SQLALCHEMY_DATABASE_URI = "sqlite:///project.db" 4 | MAINTENANCE_MODE = False 5 | -------------------------------------------------------------------------------- /server/login_manager.py: -------------------------------------------------------------------------------- 1 | from flask_login import LoginManager 2 | login_manager = LoginManager() 3 | -------------------------------------------------------------------------------- /server/static/style.css: -------------------------------------------------------------------------------- 1 | /* CSS Micro Reset: make browsers behave consistently */ 2 | 3 | * { 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | margin: 0; 9 | } 10 | 11 | h1, 12 | h2, 13 | h3, 14 | h4, 15 | h5, 16 | h6 { 17 | font-weight: normal; 18 | } 19 | 20 | table { 21 | border-collapse: collapse; 22 | border-spacing: 0; 23 | } 24 | 25 | th, 26 | td { 27 | text-align: left; 28 | vertical-align: top; 29 | } 30 | 31 | img, 32 | iframe { 33 | border: 0; 34 | } 35 | 36 | /* the rest is just for fun */ 37 | 38 | main.cheapo { 39 | background-color: gold; 40 | width: 100%; 41 | min-height: 100vh; 42 | 43 | font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, 44 | helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, 45 | sans-serif; 46 | 47 | display: flex; 48 | flex-direction: column; 49 | align-items: center; 50 | } 51 | 52 | main.cheapo h1, 53 | main.cheapo h2 { 54 | text-align: center; 55 | } 56 | 57 | main.cheapo h1 { 58 | font-weight: 800; 59 | font-size: 4rem; 60 | padding: 0 1rem; 61 | 62 | text-shadow: 0.3rem 0.3rem white; 63 | } 64 | 65 | main.cheapo h2 { 66 | font-weight: 800; 67 | font-size: 3rem; 68 | padding: 0 1rem; 69 | 70 | text-shadow: 0.2rem 0.2rem white; 71 | } 72 | 73 | main.cheapo p, 74 | main.cheapo ol { 75 | max-width: 40rem; 76 | padding: 0 1rem; 77 | } 78 | 79 | main.cheapo p { 80 | line-height: 1.4em; 81 | } 82 | 83 | main.cheapo ol, 84 | main.cheapo ul { 85 | line-height: 2em; 86 | margin-left: 1rem; 87 | } 88 | -------------------------------------------------------------------------------- /server/templates/auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% block content %} 2 | {% with messages = get_flashed_messages() %} 3 | {% if messages %} 4 | 9 | {% endif %} 10 | {% endwith %} 11 |
12 | 13 | 14 | 15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /server/templates/auth/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% block content %} 2 |
3 | 4 | 5 | 6 |
7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /server/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Cheapo Website 5 | 6 | 8 | 9 | 10 | 11 | 12 | {% block content %}{% endblock %} 13 | 14 | 15 | -------------------------------------------------------------------------------- /server/templates/inside/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 | 4 |
5 |

You logged in!!!

6 |

7 | You are {{ current_user.email }}. 8 | Log out 9 |

10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /server/templates/maintenance/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |

The web site is in maintenance mode. Please check back again later.

4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /server/templates/outside/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |
4 |

The Cheapo Website Template

5 |

This is the demo deploy of the Cheapo Website Template. 6 | It doesn't do much, but you can 7 | log in or 8 | register to test the user account feature. 9 |

10 | 11 |

PostgreSQL is a scam!!!

12 | 13 |

Don't let Big Tech sell you an 18 wheeler when all you need is a bicycle. 14 | Your side project doesn't need a $15/mo Postgres instance. Those 2,000 Medium articles 15 | about how to deploy your bootcamp app with a Professional Database are trying to bleed you 16 | dry now that Heroku hates freedom. Keep that storage free, comrade—SQLite is the People's Database!

17 | 18 |

Features

19 | 20 |
    21 |
  1. Deploys with just a little downtime, as a treat. Give servers the naps they deserve.
  2. 22 |
  3. Potentially zero dollars to host unless you get popular in which case more like two dollars.
  4. 23 |
  5. Maybe scalable? If not, it's probably your fault!
  6. 24 |
  7. 100% unproven but pretty likely to work OK.
  8. 25 |
26 | 27 |

Technology???

28 | 29 |
    30 |
  1. SQLite in production 😈
  2. 31 |
  3. Render ($7/mo) or Fly.io (basically free)
  4. 32 |
  5. Python, Flask, and boilerplate for stuff everybody wants but Flask is too hipster to include by default (Flask-SQLAlchemy, Flask-Login, Flask-Migrate)
  6. 33 |
34 | 35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /workspace.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "[python]": { 9 | "editor.insertSpaces": true, 10 | "editor.tabSize": 4 11 | }, 12 | "emmet.includeLanguages": { "jinja-html": "html" } 13 | } 14 | } 15 | --------------------------------------------------------------------------------