Merge pull request 'flask site buildout' (#2) from mgtut1 into master
Reviewed-on: finn/site#2
This commit is contained in:
		
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -2,5 +2,5 @@ gitea/
 | 
				
			|||||||
.env
 | 
					.env
 | 
				
			||||||
pmb-pf/
 | 
					pmb-pf/
 | 
				
			||||||
venv
 | 
					venv
 | 
				
			||||||
 | 
					zapp.db
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										29
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								README.md
									
									
									
									
									
								
							@@ -2,12 +2,10 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
### Sec:
 | 
					### Sec:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
* This repo is public. Mind cred slip-ups.
 | 
					- This repo is public. Mind cred slip-ups.
 | 
				
			||||||
* Please note changes to /etc/sshd/sshd_conf made by lll script. If different method is used, audit manually.
 | 
					- Please note changes to /etc/sshd/sshd_conf made by lll script. If different method is used, audit manually.
 | 
				
			||||||
* Note app Dockerfile debug console, found at /console. Werkzeug/flask is WILDLY insecure if left in dev/dbg.
 | 
					- Note app Dockerfile debug console, found at /console. Werkzeug/flask is WILDLY insecure if left in dev/dbg.
 | 
				
			||||||
* Avoid docker socks stuff.
 | 
					- Avoid docker socks stuff.
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Install:
 | 
					### Install:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -49,19 +47,28 @@ set up cron job for script
 | 
				
			|||||||
        pmb-pf - git clone of my mail thing
 | 
					        pmb-pf - git clone of my mail thing
 | 
				
			||||||
        other - ref and non-sensitive files for dns
 | 
					        other - ref and non-sensitive files for dns
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Timeline:
 | 
					### Setup cheat:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
set up certbot dns\
 | 
					- set up certbot dns (prod)
 | 
				
			||||||
see tar of cert dir with script
 | 
					- see tar of cert dir with script (prod)
 | 
				
			||||||
 | 
					- flask vs uwsgi in backend compose section (prod)
 | 
				
			||||||
 | 
					- build vs local image in pmb-pf compose section
 | 
				
			||||||
 | 
					- git clone pmb-pf
 | 
				
			||||||
 | 
					- copy example .env in root dir
 | 
				
			||||||
 | 
					- copy example .env in pmb-pf
 | 
				
			||||||
 | 
					- copy example conf in proxy
 | 
				
			||||||
 | 
					- do pmb-pf setup, and adjust root .env
 | 
				
			||||||
 | 
					- mind backend config db settings
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Notes:
 | 
					### Notes:
 | 
				
			||||||
This repo is minimally-sensitive. Falling outside the repo dir structure are reference awesome-compose files used as baseline -- nginx-flask-mysql -- and certs, containing letsencrypt script. Script may be backed up into repo carefully, sanitizing any tkens.
 | 
					This repo is minimally-sensitive. Falling outside the repo dir structure are reference awesome-compose files used as baseline -- nginx-flask-mysql -- and certs, containing letsencrypt script. Script may be backed up into repo carefully, sanitizing any tkens.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
TODO: gitea subdomain will require wildcard cert -- therefore "*.oily.dad" AND "oily.dad" DONE
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Changing gitea subdomain:
 | 
					### Changing gitea subdomain:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Find in proxy/conf.\
 | 
					Find in proxy/conf.\
 | 
				
			||||||
Find in gitea conf.\
 | 
					Find in gitea conf.\
 | 
				
			||||||
Rebuild images.
 | 
					Rebuild images.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Todo:
 | 
				
			||||||
 | 
					- gitea subdomain will require wildcard cert -- therefore "*.oily.dad" AND "oily.dad" DONE
 | 
				
			||||||
 | 
					- move more stuff from backend config into root .env
 | 
				
			||||||
							
								
								
									
										4
									
								
								backend/.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								backend/.dockerignore
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
				
			|||||||
 | 
					venv
 | 
				
			||||||
 | 
					migrations
 | 
				
			||||||
 | 
					zapp.db
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1,2 +1,3 @@
 | 
				
			|||||||
FLASK_APP=microblog.py
 | 
					FLASK_APP=microblog.py
 | 
				
			||||||
 | 
					FLASK_DEBUG=1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										13
									
								
								backend/Dockerfile
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										13
									
								
								backend/Dockerfile
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							@@ -1,14 +1,21 @@
 | 
				
			|||||||
# syntax=docker/dockerfile:1.4
 | 
					# syntax=docker/dockerfile:1.4
 | 
				
			||||||
FROM python:3-alpine AS builder
 | 
					FROM python:3-slim-bookworm AS builder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Second line optional/debug/qol
 | 
				
			||||||
 | 
					RUN apt update && apt install -y \
 | 
				
			||||||
 | 
					    libmariadb-dev gcc \
 | 
				
			||||||
 | 
					    mariadb-client
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
WORKDIR /code
 | 
					WORKDIR /code
 | 
				
			||||||
COPY requirements.txt /code
 | 
					COPY requirements.txt /code
 | 
				
			||||||
RUN target=/root/.cache/pip \
 | 
					RUN target=/root/.cache/pip \
 | 
				
			||||||
    pip3 install -r requirements.txt
 | 
					    pip3 install --root-user-action=ignore -q -r requirements.txt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Dockerignore has this skip migrations, venv, sqlite db
 | 
				
			||||||
COPY . .
 | 
					COPY . .
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ENV FLASK_APP app.py
 | 
					ENV FLASK_APP microblog.py
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# This might be scary to leave on
 | 
					# This might be scary to leave on
 | 
				
			||||||
#ENV FLASK_ENV development
 | 
					#ENV FLASK_ENV development
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,75 @@
 | 
				
			|||||||
 | 
					## Workflow:
 | 
				
			||||||
 | 
					- should work with flask run locally and dockerfile build
 | 
				
			||||||
 | 
					- local dev
 | 
				
			||||||
 | 
					- local / venv pip install
 | 
				
			||||||
 | 
					- record versionless pips manually here
 | 
				
			||||||
 | 
					- pip freeze snapshots into project requirements
 | 
				
			||||||
 | 
					- docker build then copies frozen requirements
 | 
				
			||||||
 | 
					
 | 
				
			||||||
pip:
 | 
					
 | 
				
			||||||
 | 
					## pip:
 | 
				
			||||||
 | 
					mariadb may take extra work: gcc, libmariadb-dev 
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
pip install flask
 | 
					pip install flask
 | 
				
			||||||
pip install python-dotenv
 | 
					pip install python-dotenv
 | 
				
			||||||
 | 
					pip install flask-wtf
 | 
				
			||||||
 | 
					pip install flask-sqlalchemy
 | 
				
			||||||
 | 
					pip install flask-migrate
 | 
				
			||||||
 | 
					pip install flask-login
 | 
				
			||||||
 | 
					pip install email-validator
 | 
				
			||||||
 | 
					pip install pydenticon
 | 
				
			||||||
 | 
					pip install flask-mail
 | 
				
			||||||
 | 
					pip install pyjwt
 | 
				
			||||||
 | 
					Prod only, require sys packages:
 | 
				
			||||||
 | 
					pip install mariadb
 | 
				
			||||||
 | 
					pip install uwsgi
 | 
				
			||||||
 | 
					...
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Freeze/requirements.txt. Better to audit this inside python:3-bookworm-slim container.
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					pip freeze > requirements.txt
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## db cheat:
 | 
				
			||||||
 | 
					After db schema change:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					flask db migrate -m "add users table"
 | 
				
			||||||
 | 
					flask db upgrade
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Dump data if db in good state:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					flask db downgrade base
 | 
				
			||||||
 | 
					flask db upgrade
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Full reset or maria init:
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					sql:
 | 
				
			||||||
 | 
					drop table users;
 | 
				
			||||||
 | 
					drop table posts;
 | 
				
			||||||
 | 
					rm app.db
 | 
				
			||||||
 | 
					rm -r migrations
 | 
				
			||||||
 | 
					flask db init
 | 
				
			||||||
 | 
					flask db migrate
 | 
				
			||||||
 | 
					flask db upgrade
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## build notes:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Dockerfile needs dockerignore or preferably explicitly defined copies for:
 | 
				
			||||||
 | 
					- app
 | 
				
			||||||
 | 
					- config
 | 
				
			||||||
 | 
					- project dir
 | 
				
			||||||
 | 
					- requirements
 | 
				
			||||||
 | 
					- not dotflaskenv, vars set with dockerfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## docker setup:
 | 
				
			||||||
 | 
					- py logger handler StreamHandler is buggy to the point of being useless. Use logfile and link to stdout in Docker build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## notes:
 | 
				
			||||||
 | 
					- compose has entry that overrides flask with uwsgi for prod
 | 
				
			||||||
 | 
					- miminal environment vars come through project env, pass through compose
 | 
				
			||||||
 | 
					- no dotenv here, dotflaskenv goes into image
 | 
				
			||||||
 | 
					- keep env untracked but templated, dotflaskenv is tracked and public
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,40 @@
 | 
				
			|||||||
from flask import Flask
 | 
					from flask import Flask
 | 
				
			||||||
 | 
					from config import Config
 | 
				
			||||||
 | 
					from flask_sqlalchemy import SQLAlchemy
 | 
				
			||||||
 | 
					from flask_migrate import Migrate
 | 
				
			||||||
 | 
					from flask_login import LoginManager
 | 
				
			||||||
 | 
					import logging, sys
 | 
				
			||||||
 | 
					from logging.handlers import SMTPHandler
 | 
				
			||||||
 | 
					from flask_mail import Mail
 | 
				
			||||||
 | 
					
 | 
				
			||||||
app = Flask(__name__)
 | 
					app = Flask(__name__)
 | 
				
			||||||
 | 
					app.config.from_object(Config)
 | 
				
			||||||
 | 
					db = SQLAlchemy(app)
 | 
				
			||||||
 | 
					migrate = Migrate(app, db)
 | 
				
			||||||
 | 
					login = LoginManager(app)
 | 
				
			||||||
 | 
					login.login_view = 'login'
 | 
				
			||||||
 | 
					mail=Mail(app)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from app import routes
 | 
					if not app.debug:
 | 
				
			||||||
 | 
					    if app.config['MAIL_SERVER']:
 | 
				
			||||||
 | 
					        auth = None
 | 
				
			||||||
 | 
					        secure = None
 | 
				
			||||||
 | 
					        mail_handler = SMTPHandler(
 | 
				
			||||||
 | 
					                mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']),
 | 
				
			||||||
 | 
					                fromaddr=app.config['FROM_ADDRESS'],
 | 
				
			||||||
 | 
					                toaddrs=app.config['ADMINS'], subject='MB failure.',
 | 
				
			||||||
 | 
					                credentials=auth, secure=secure)
 | 
				
			||||||
 | 
					        mail_handler.setLevel(logging.ERROR)
 | 
				
			||||||
 | 
					        app.logger.addHandler(mail_handler)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if app.config['DC_LOGGING']:
 | 
				
			||||||
 | 
					        print('#################### TEST PRINT STDERR DEBUG', file=sys.stderr)
 | 
				
			||||||
 | 
					        dclog = logging.StreamHandler(stream=sys.stderr)
 | 
				
			||||||
 | 
					        dclog.setLevel(logging.INFO)
 | 
				
			||||||
 | 
					        dclog.propagate = False
 | 
				
			||||||
 | 
					        app.logger.addHandler(dclog)
 | 
				
			||||||
 | 
					        app.logger.info('@@@@@@@@@@@@@@@@@@@@@ TEST LOGGER INFO MESSAGE')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from app import routes, models, errors
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										27
									
								
								backend/app/email.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								backend/app/email.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					from threading import Thread
 | 
				
			||||||
 | 
					from flask import render_template
 | 
				
			||||||
 | 
					from flask_mail import Message
 | 
				
			||||||
 | 
					from app import mail, app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def send_async_email(app, msg):
 | 
				
			||||||
 | 
					    with app.app_context():
 | 
				
			||||||
 | 
					        mail.send(msg)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def send_email(subject, sender, recipients, text_body, html_body):
 | 
				
			||||||
 | 
					    msg = Message(subject, sender=sender, recipients=recipients)
 | 
				
			||||||
 | 
					    msg.body = text_body
 | 
				
			||||||
 | 
					    msg.html = html_body
 | 
				
			||||||
 | 
					    Thread(target=send_async_email, args=(app, msg)).start()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def send_password_reset_email(user):
 | 
				
			||||||
 | 
					    token = user.get_reset_password_token()
 | 
				
			||||||
 | 
					    hostname = app.config['REAL_HOSTNAME']
 | 
				
			||||||
 | 
					    send_email('[Blog] Reset Password',
 | 
				
			||||||
 | 
					               sender=app.config['ADMINS'][0],
 | 
				
			||||||
 | 
					               recipients=[user.email],
 | 
				
			||||||
 | 
					               text_body=render_template('email/reset_password.txt', hostname=hostname, user=user, token=token),
 | 
				
			||||||
 | 
					               html_body=render_template('email/reset_password.html', hostname=hostname, user=user, token=token))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										13
									
								
								backend/app/errors.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								backend/app/errors.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					from flask import render_template
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from app import app, db
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.errorhandler(404)
 | 
				
			||||||
 | 
					def not_found_error(error):
 | 
				
			||||||
 | 
					    return render_template('404.html'), 404
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.errorhandler(500)
 | 
				
			||||||
 | 
					def internal_error(error):
 | 
				
			||||||
 | 
					    db.session.rollback()
 | 
				
			||||||
 | 
					    return render_template('500.html'), 500
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										60
									
								
								backend/app/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								backend/app/forms.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
				
			|||||||
 | 
					from flask_wtf import FlaskForm
 | 
				
			||||||
 | 
					from wtforms import StringField, PasswordField, BooleanField, SubmitField, TextAreaField
 | 
				
			||||||
 | 
					from wtforms.validators import DataRequired, ValidationError, Email, EqualTo, Length
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from app import db
 | 
				
			||||||
 | 
					from app.models import User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class LoginForm(FlaskForm):
 | 
				
			||||||
 | 
					    username = StringField('Username', validators=[DataRequired()])
 | 
				
			||||||
 | 
					    password = PasswordField('Pasword', validators=[DataRequired()])
 | 
				
			||||||
 | 
					    remember_me = BooleanField('Remember Me')
 | 
				
			||||||
 | 
					    submit = SubmitField('Sign In')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class RegistrationForm(FlaskForm):
 | 
				
			||||||
 | 
					    username = StringField('Username', validators=[DataRequired()])
 | 
				
			||||||
 | 
					    email = StringField('Email', validators=[DataRequired(), Email()])
 | 
				
			||||||
 | 
					    password = PasswordField('Password', validators=[DataRequired()])
 | 
				
			||||||
 | 
					    password2 = PasswordField('Repeat Password', validators=[DataRequired()])
 | 
				
			||||||
 | 
					    submit = SubmitField('Register')
 | 
				
			||||||
 | 
					    def validate_username(self, username):
 | 
				
			||||||
 | 
					        user = db.session.scalar(sa.select(User).where(User.username == username.data))
 | 
				
			||||||
 | 
					        if user is not None:
 | 
				
			||||||
 | 
					            raise ValidationError('Please use a different username.')
 | 
				
			||||||
 | 
					    def validate_email(self, email):
 | 
				
			||||||
 | 
					        user = db.session.scalar(sa.select(User).where(User.email == email.data))
 | 
				
			||||||
 | 
					        if user is not None:
 | 
				
			||||||
 | 
					            raise ValidationError('Please use a different email address.')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ResetPasswordForm(FlaskForm):
 | 
				
			||||||
 | 
					    password = PasswordField('Password', validators=[DataRequired()])
 | 
				
			||||||
 | 
					    password2 = PasswordField('Repeat Password', validators=[DataRequired()])
 | 
				
			||||||
 | 
					    submit = SubmitField('Request Reset')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class EditProfileForm(FlaskForm):
 | 
				
			||||||
 | 
					    username = StringField('Username', validators=[DataRequired()])
 | 
				
			||||||
 | 
					    about_me = TextAreaField('About me', validators=[Length(min=0, max=140)])
 | 
				
			||||||
 | 
					    submit = SubmitField('Update')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, original_username, *args, **kwargs):
 | 
				
			||||||
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
 | 
					        self.original_username = original_username
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def validate_username(self, username):
 | 
				
			||||||
 | 
					        if username.data != self.original_username:
 | 
				
			||||||
 | 
					            user = db.session.scalar(sa.select(User).where(User.username == username.data))
 | 
				
			||||||
 | 
					            if user is not None:
 | 
				
			||||||
 | 
					                raise ValidationError('Please use a different username.')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ResetPasswordRequestForm(FlaskForm):
 | 
				
			||||||
 | 
					    email = StringField('Email', validators=[DataRequired(), Email()])
 | 
				
			||||||
 | 
					    submit = SubmitField('Request Password Reset')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PostForm(FlaskForm):
 | 
				
			||||||
 | 
					    post = TextAreaField('Post:', validators=[DataRequired(), Length(min=1, max=140)])
 | 
				
			||||||
 | 
					    submit = SubmitField('Submit')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class EmptyForm(FlaskForm):
 | 
				
			||||||
 | 
					    submit = SubmitField('Submit')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										134
									
								
								backend/app/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								backend/app/models.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,134 @@
 | 
				
			|||||||
 | 
					from datetime import datetime, timezone
 | 
				
			||||||
 | 
					from typing import Optional
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					import sqlalchemy.orm as so
 | 
				
			||||||
 | 
					from werkzeug.security import generate_password_hash, check_password_hash
 | 
				
			||||||
 | 
					import os, pydenticon, hashlib, base64, jwt
 | 
				
			||||||
 | 
					from time import time
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from app import db, login, app
 | 
				
			||||||
 | 
					from flask_login import UserMixin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#debug
 | 
				
			||||||
 | 
					#import sys
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					followers = sa.Table(
 | 
				
			||||||
 | 
					        'followers',
 | 
				
			||||||
 | 
					        db.metadata,
 | 
				
			||||||
 | 
					        sa.Column('follower_id', sa.Integer, sa.ForeignKey('user.id'), primary_key=True),
 | 
				
			||||||
 | 
					        sa.Column('followed_id', sa.Integer, sa.ForeignKey('user.id'), primary_key=True)
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class User(UserMixin, db.Model):
 | 
				
			||||||
 | 
					    id: so.Mapped[int] = so.mapped_column(primary_key=True)
 | 
				
			||||||
 | 
					    username: so.Mapped[str] = so.mapped_column(sa.String(64), index=True, unique=True)
 | 
				
			||||||
 | 
					    email: so.Mapped[str] = so.mapped_column(sa.String(120), index=True, unique=True)
 | 
				
			||||||
 | 
					    password_hash: so.Mapped[Optional[str]] = so.mapped_column(sa.String(256))
 | 
				
			||||||
 | 
					    posts: so.WriteOnlyMapped['Post'] = so.relationship(back_populates='author')
 | 
				
			||||||
 | 
					    about_me: so.Mapped[Optional[str]] = so.mapped_column(sa.String(140))
 | 
				
			||||||
 | 
					    last_seen: so.Mapped[Optional[datetime]] = so.mapped_column(default=lambda: datetime.now(timezone.utc))
 | 
				
			||||||
 | 
					    following: so.WriteOnlyMapped['User'] = so.relationship(
 | 
				
			||||||
 | 
					            secondary=followers,
 | 
				
			||||||
 | 
					            primaryjoin=(followers.c.follower_id == id),
 | 
				
			||||||
 | 
					            secondaryjoin=(followers.c.followed_id == id),
 | 
				
			||||||
 | 
					            back_populates='followers')
 | 
				
			||||||
 | 
					    followers: so.WriteOnlyMapped['User'] = so.relationship(
 | 
				
			||||||
 | 
					            secondary=followers,
 | 
				
			||||||
 | 
					            primaryjoin=(followers.c.followed_id == id),
 | 
				
			||||||
 | 
					            secondaryjoin=(followers.c.follower_id == id),
 | 
				
			||||||
 | 
					            back_populates='following')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def set_password(self, password):
 | 
				
			||||||
 | 
					        self.password_hash = generate_password_hash(password)
 | 
				
			||||||
 | 
					    def check_password(self, password):
 | 
				
			||||||
 | 
					        return check_password_hash(self.password_hash, password)
 | 
				
			||||||
 | 
					    def get_reset_password_token(self, expires_in=600):
 | 
				
			||||||
 | 
					        token = jwt.encode({'reset_password': self.id, 'exp': time() + expires_in},
 | 
				
			||||||
 | 
					                          app.config['SECRET_KEY'], algorithm='HS256')
 | 
				
			||||||
 | 
					        return token
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def verify_reset_password_token(token):
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            id = jwt.decode(token,
 | 
				
			||||||
 | 
					                            app.config['SECRET_KEY'], algorithms='HS256')['reset_password']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        except:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        return db.session.get(User, id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def gen_avatar(self, write_png=True):
 | 
				
			||||||
 | 
					        foreground = ['#ACE1AF',
 | 
				
			||||||
 | 
					                      '#ACC4E1',
 | 
				
			||||||
 | 
					                      '#E1ACDE',
 | 
				
			||||||
 | 
					                      '#E1CAAC',
 | 
				
			||||||
 | 
					                      '#AFFF00',
 | 
				
			||||||
 | 
					                      '#00FFCF',
 | 
				
			||||||
 | 
					                      '#5000FF',
 | 
				
			||||||
 | 
					                      '#FF0030']
 | 
				
			||||||
 | 
					                      
 | 
				
			||||||
 | 
					        background = '#151515'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        digest = hashlib.md5(self.email.lower().encode('utf-8')).hexdigest()
 | 
				
			||||||
 | 
					        basedir = os.path.abspath(os.path.dirname(__file__))
 | 
				
			||||||
 | 
					        pngloc = os.path.join(basedir, 'usercontent', 'identicon', str(digest) + '.png')
 | 
				
			||||||
 | 
					        icongen = pydenticon.Generator(8, 5, digest=hashlib.md5, foreground=foreground, background=background)
 | 
				
			||||||
 | 
					        pngicon = icongen.generate(self.email, 150, 240, padding=(10, 10, 10, 10), inverted=False, output_format="png")
 | 
				
			||||||
 | 
					        if write_png:
 | 
				
			||||||
 | 
					            pngfile = open(pngloc, "wb")
 | 
				
			||||||
 | 
					            pngfile.write(pngicon)
 | 
				
			||||||
 | 
					            pngfile.close()
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            return str(base64.b64encode(pngicon))[2:-1]
 | 
				
			||||||
 | 
					    def avatar_path(self):
 | 
				
			||||||
 | 
					        digest = hashlib.md5(self.email.lower().encode('utf-8')).hexdigest()
 | 
				
			||||||
 | 
					        basedir = os.path.abspath(os.path.dirname(__file__))
 | 
				
			||||||
 | 
					        pngloc = os.path.join(basedir, 'usercontent', 'identicon', digest + '.png')
 | 
				
			||||||
 | 
					        return pngloc
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def follow(self, user):
 | 
				
			||||||
 | 
					        if not self.is_following(user):
 | 
				
			||||||
 | 
					            self.following.add(user)
 | 
				
			||||||
 | 
					    def unfollow(self, user):
 | 
				
			||||||
 | 
					        if self.is_following(user):
 | 
				
			||||||
 | 
					            self.following.remove(user)
 | 
				
			||||||
 | 
					    def is_following(self, user):
 | 
				
			||||||
 | 
					        query = self.following.select().where(User.id == user.id)
 | 
				
			||||||
 | 
					        return db.session.scalar(query) is not None
 | 
				
			||||||
 | 
					    def followers_count(self):
 | 
				
			||||||
 | 
					        query = sa.select(sa.func.count()).select_from(self.followers.select().subquery())
 | 
				
			||||||
 | 
					        return db.session.scalar(query)
 | 
				
			||||||
 | 
					    def following_count(self):
 | 
				
			||||||
 | 
					        query = sa.select(sa.func.count()).select_from(self.following.select().subquery())
 | 
				
			||||||
 | 
					        return db.session.scalar(query)
 | 
				
			||||||
 | 
					    def following_posts(self):
 | 
				
			||||||
 | 
					        Author = so.aliased(User)
 | 
				
			||||||
 | 
					        Follower = so.aliased(User)
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					                sa.select(Post)
 | 
				
			||||||
 | 
					                .join(Post.author.of_type(Author))
 | 
				
			||||||
 | 
					                .join(Author.followers.of_type(Follower), isouter=True)
 | 
				
			||||||
 | 
					                .where(sa.or_(
 | 
				
			||||||
 | 
					                    Follower.id == self.id,
 | 
				
			||||||
 | 
					                    Author.id == self.id
 | 
				
			||||||
 | 
					                ))
 | 
				
			||||||
 | 
					                .group_by(Post)
 | 
				
			||||||
 | 
					                .order_by(Post.timestamp.desc())
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __repr__(self):
 | 
				
			||||||
 | 
					        return '<User {}>'.format(self.username)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Post(db.Model):
 | 
				
			||||||
 | 
					    id: so.Mapped[int] = so.mapped_column(primary_key=True)
 | 
				
			||||||
 | 
					    body: so.Mapped[str] = so.mapped_column(sa.String(140))
 | 
				
			||||||
 | 
					    timestamp: so.Mapped[datetime] = so.mapped_column(index=True, default=lambda: datetime.now(timezone.utc))
 | 
				
			||||||
 | 
					    user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id), index=True)
 | 
				
			||||||
 | 
					    author: so.Mapped[User] = so.relationship(back_populates='posts')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __repr__(self):
 | 
				
			||||||
 | 
					        return '<Post {}>'.format(self.body)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@login.user_loader
 | 
				
			||||||
 | 
					def load_user(id):
 | 
				
			||||||
 | 
					    return db.session.get(User, int(id))
 | 
				
			||||||
@@ -1,7 +1,185 @@
 | 
				
			|||||||
from app import app
 | 
					from flask import render_template, flash, redirect, url_for, request
 | 
				
			||||||
 | 
					from flask_login import current_user, login_user, logout_user, login_required
 | 
				
			||||||
 | 
					from urllib.parse import urlsplit
 | 
				
			||||||
 | 
					from datetime import datetime, timezone
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@app.route('/')
 | 
					from app import app, db
 | 
				
			||||||
@app.route('/index')
 | 
					from app.forms import LoginForm, RegistrationForm, EditProfileForm, EmptyForm, PostForm, ResetPasswordRequestForm, ResetPasswordForm
 | 
				
			||||||
 | 
					from app.models import User, Post
 | 
				
			||||||
 | 
					from app.email import send_password_reset_email
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#debug:
 | 
				
			||||||
 | 
					#import sys
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.before_request
 | 
				
			||||||
 | 
					def before_request():
 | 
				
			||||||
 | 
					    if current_user.is_authenticated:
 | 
				
			||||||
 | 
					        current_user.last_seen = datetime.now(timezone.utc)
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/', methods=['GET', 'POST'])
 | 
				
			||||||
 | 
					@app.route('/index', methods=['GET', 'POST'])
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
def index():
 | 
					def index():
 | 
				
			||||||
    return "Hello, World!"
 | 
					    app.logger.info('@@@@@@@@@@@@@@@@@ INFO LOG TEST INDEX')
 | 
				
			||||||
 | 
					    form = PostForm()
 | 
				
			||||||
 | 
					    #user = {'username': 'aaa', 'email': 'a@a.a'}
 | 
				
			||||||
 | 
					    if form.validate_on_submit():
 | 
				
			||||||
 | 
					        post = Post(body=form.post.data, author=current_user)
 | 
				
			||||||
 | 
					        db.session.add(post)
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					        flash('Your post has been added.')
 | 
				
			||||||
 | 
					        return redirect(url_for('index'))
 | 
				
			||||||
 | 
					    #posts = db.session.scalars(current_user.following_posts()).all()
 | 
				
			||||||
 | 
					    page = request.args.get('page', 1, type=int)
 | 
				
			||||||
 | 
					    posts = db.paginate(current_user.following_posts(), page=page, per_page=app.config['POSTS_PER_PAGE'], error_out=False)
 | 
				
			||||||
 | 
					    next_url = url_for('index', page=posts.next_num) if posts.has_next else None
 | 
				
			||||||
 | 
					    prev_url = url_for('index', page=posts.prev_num) if posts.has_prev else None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return render_template('index.html', title='Home', form=form, posts=posts.items, next_url=next_url, prev_url=prev_url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/explore')
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def explore():
 | 
				
			||||||
 | 
					    query = sa.select(Post).order_by(Post.timestamp.desc())
 | 
				
			||||||
 | 
					    page = request.args.get('page', 1, type=int)
 | 
				
			||||||
 | 
					    posts = db.paginate(query, page=page, per_page=app.config['POSTS_PER_PAGE'], error_out=False)
 | 
				
			||||||
 | 
					    next_url = url_for('explore', page=posts.next_num) if posts.has_next else None
 | 
				
			||||||
 | 
					    prev_url = url_for('explore', page=posts.prev_num) if posts.has_prev else None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return render_template('index.html', title='Explore', posts=posts.items, next_url=next_url, prev_url=prev_url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/login', methods=['GET', 'POST'])
 | 
				
			||||||
 | 
					def login():
 | 
				
			||||||
 | 
					    if current_user.is_authenticated:
 | 
				
			||||||
 | 
					        return redirect(url_for('index'))
 | 
				
			||||||
 | 
					    form = LoginForm()
 | 
				
			||||||
 | 
					    if form.validate_on_submit():
 | 
				
			||||||
 | 
					        user = db.session.scalar(sa.select(User).where(User.username == form.username.data))
 | 
				
			||||||
 | 
					        if user is None or not user.check_password(form.password.data):
 | 
				
			||||||
 | 
					            flash('Invalid u or p')
 | 
				
			||||||
 | 
					            return redirect(url_for('login'))
 | 
				
			||||||
 | 
					        login_user(user, remember=form.remember_me.data)
 | 
				
			||||||
 | 
					        next_page = request.args.get('next')
 | 
				
			||||||
 | 
					        if not next_page or urlsplit(next_page).netloc != '':
 | 
				
			||||||
 | 
					            next_page = url_for('index')
 | 
				
			||||||
 | 
					        return redirect(next_page)    
 | 
				
			||||||
 | 
					    return render_template('login.html', title='Sign In', form=form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/logout')
 | 
				
			||||||
 | 
					def logout():
 | 
				
			||||||
 | 
					    logout_user()
 | 
				
			||||||
 | 
					    return redirect(url_for('index'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/register', methods=['GET', 'POST'])
 | 
				
			||||||
 | 
					def register():
 | 
				
			||||||
 | 
					    if current_user.is_authenticated:
 | 
				
			||||||
 | 
					        return redirect(url_for('index'))
 | 
				
			||||||
 | 
					    form = RegistrationForm()
 | 
				
			||||||
 | 
					    if form.validate_on_submit():
 | 
				
			||||||
 | 
					        user = User(username=form.username.data, email=form.email.data)
 | 
				
			||||||
 | 
					        user.set_password(form.password.data)
 | 
				
			||||||
 | 
					        db.session.add(user)
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					        #user.gen_avatar()
 | 
				
			||||||
 | 
					        flash('User has been created.')
 | 
				
			||||||
 | 
					        return redirect(url_for('login'))
 | 
				
			||||||
 | 
					    return render_template('register.html', title='Register', form=form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/reset_password/<token>', methods=['GET', 'POST'])
 | 
				
			||||||
 | 
					def reset_password(token):
 | 
				
			||||||
 | 
					    if current_user.is_authenticated:
 | 
				
			||||||
 | 
					        return redirect(url_for('index'))
 | 
				
			||||||
 | 
					    user = User.verify_reset_password_token(token)
 | 
				
			||||||
 | 
					    if not user:
 | 
				
			||||||
 | 
					        return redirect(url_for('index'))
 | 
				
			||||||
 | 
					    form = ResetPasswordForm()
 | 
				
			||||||
 | 
					    if form.validate_on_submit():
 | 
				
			||||||
 | 
					        user.set_password(form.password.data)
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					        flash('Your password has been reset.')
 | 
				
			||||||
 | 
					        return redirect(url_for('login'))
 | 
				
			||||||
 | 
					    return render_template('reset_password.html', form=form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/user/<username>')
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def user(username):
 | 
				
			||||||
 | 
					    user = db.first_or_404(sa.select(User).where(User.username == username))
 | 
				
			||||||
 | 
					    page = request.args.get('page', 1, type=int)
 | 
				
			||||||
 | 
					    query = user.posts.select().order_by(Post.timestamp.desc())
 | 
				
			||||||
 | 
					    posts = db.paginate(query, page=page, per_page=app.config['POSTS_PER_PAGE'], error_out=False)
 | 
				
			||||||
 | 
					    next_url = url_for('user', username=user.username, page=posts.next_num) if posts.has_next else None
 | 
				
			||||||
 | 
					    prev_url = url_for('user', username=user.username, page=posts.prev_num) if posts.has_prev else None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    form = EmptyForm()
 | 
				
			||||||
 | 
					    return render_template('user.html', user=user, posts=posts.items, next_url=next_url, prev_url=prev_url, form=form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/edit_profile', methods=['GET', 'POST'])
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def edit_profile():
 | 
				
			||||||
 | 
					    form = EditProfileForm(current_user.username)
 | 
				
			||||||
 | 
					    if form.validate_on_submit():
 | 
				
			||||||
 | 
					        current_user.username = form.username.data
 | 
				
			||||||
 | 
					        current_user.about_me = form.about_me.data
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					        flash('Profile changes have been saved.')
 | 
				
			||||||
 | 
					        return redirect(url_for('edit_profile'))
 | 
				
			||||||
 | 
					    elif request.method == 'GET':
 | 
				
			||||||
 | 
					        form.username.data = current_user.username
 | 
				
			||||||
 | 
					        form.about_me.data = current_user.about_me
 | 
				
			||||||
 | 
					    return render_template('edit_profile.html', title='Edit Profile', form=form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/follow/<username>', methods=['POST'])
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def follow(username):
 | 
				
			||||||
 | 
					    form = EmptyForm()
 | 
				
			||||||
 | 
					    if form.validate_on_submit():
 | 
				
			||||||
 | 
					        user = db.session.scalar(sa.select(User).where(User.username == username))
 | 
				
			||||||
 | 
					        if user is None:
 | 
				
			||||||
 | 
					            flash(f'User {username} not found.')
 | 
				
			||||||
 | 
					            return redirect(url_for('index'))
 | 
				
			||||||
 | 
					        if user == current_user:
 | 
				
			||||||
 | 
					            flash('You cannot follow yourself.')
 | 
				
			||||||
 | 
					            return redirect(url_for('user', username=username))
 | 
				
			||||||
 | 
					        current_user.follow(user)
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					        flash(f'You are now following {username}.')
 | 
				
			||||||
 | 
					        return redirect(url_for('user', username=username))
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        return redirect(url_for('index'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/unfollow/<username>', methods=['POST'])
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def unfollow(username):
 | 
				
			||||||
 | 
					    form = EmptyForm()
 | 
				
			||||||
 | 
					    if form.validate_on_submit():
 | 
				
			||||||
 | 
					        user = db.session.scalar(sa.select(User).where(User.username == username))
 | 
				
			||||||
 | 
					        if user is None:
 | 
				
			||||||
 | 
					            flash(f'User {username} not found.')
 | 
				
			||||||
 | 
					            return redirect(url_for('index'))
 | 
				
			||||||
 | 
					        if user == current_user:
 | 
				
			||||||
 | 
					            flash('You cannot unfollow yourself.')
 | 
				
			||||||
 | 
					            return redirect(url_for('user', username=username))
 | 
				
			||||||
 | 
					        current_user.unfollow(user)
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					        flash(f'You have unfollowed {username}.')
 | 
				
			||||||
 | 
					        return redirect(url_for('user', username=username))
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        return redirect(url_for('index'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.route('/reset_password_request', methods=['GET', 'POST'])
 | 
				
			||||||
 | 
					def reset_password_request():
 | 
				
			||||||
 | 
					    if current_user.is_authenticated:
 | 
				
			||||||
 | 
					        return redirect(url_for('index'))
 | 
				
			||||||
 | 
					    form = ResetPasswordRequestForm()
 | 
				
			||||||
 | 
					    if form.validate_on_submit():
 | 
				
			||||||
 | 
					        user = db.session.scalar(sa.select(User).where(User.email == form.email.data))
 | 
				
			||||||
 | 
					        if user:
 | 
				
			||||||
 | 
					            send_password_reset_email(user)
 | 
				
			||||||
 | 
					        flash('Password reset sent.')
 | 
				
			||||||
 | 
					        return redirect(url_for('login'))
 | 
				
			||||||
 | 
					    return render_template('reset_password_request.html', title='Reset Password', form=form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										715
									
								
								backend/app/static/simple.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										715
									
								
								backend/app/static/simple.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,715 @@
 | 
				
			|||||||
 | 
					/* Global variables. */
 | 
				
			||||||
 | 
					:root,
 | 
				
			||||||
 | 
					::backdrop {
 | 
				
			||||||
 | 
					  /* Set sans-serif & mono fonts */
 | 
				
			||||||
 | 
					  --sans-font: -apple-system, BlinkMacSystemFont, "Avenir Next", Avenir,
 | 
				
			||||||
 | 
					    "Nimbus Sans L", Roboto, "Noto Sans", "Segoe UI", Arial, Helvetica,
 | 
				
			||||||
 | 
					    "Helvetica Neue", sans-serif;
 | 
				
			||||||
 | 
					  --mono-font: Consolas, Menlo, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
 | 
				
			||||||
 | 
					  --standard-border-radius: 5px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  --bg: #212121;
 | 
				
			||||||
 | 
					  --accent-bg: #2b2b2b;
 | 
				
			||||||
 | 
					  --text: #dcdcdc;
 | 
				
			||||||
 | 
					  --text-light: #ababab;
 | 
				
			||||||
 | 
					  --border: #898EA4;
 | 
				
			||||||
 | 
					  /* --accent: #ffb300; */
 | 
				
			||||||
 | 
					  --accent: #ace1af;
 | 
				
			||||||
 | 
					  /* --accent-hover: #ffe099; */
 | 
				
			||||||
 | 
					  --accent-hover: #99dabe;
 | 
				
			||||||
 | 
					  --accent-text: var(--bg);
 | 
				
			||||||
 | 
					  --code: #f06292;
 | 
				
			||||||
 | 
					  --preformatted: #ccc;
 | 
				
			||||||
 | 
					  --marked: #ffdd33;
 | 
				
			||||||
 | 
					  --disabled: #111;
 | 
				
			||||||
 | 
					 
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Light theme */
 | 
				
			||||||
 | 
					@media (prefers-color-scheme: light) {
 | 
				
			||||||
 | 
					  :root,
 | 
				
			||||||
 | 
					  ::backdrop {
 | 
				
			||||||
 | 
					    color-scheme: light;
 | 
				
			||||||
 | 
					    /* Default (light) theme */
 | 
				
			||||||
 | 
					    --bg: #fff;
 | 
				
			||||||
 | 
					    --accent-bg: #f5f7ff;
 | 
				
			||||||
 | 
					    --text: #212121;
 | 
				
			||||||
 | 
					    --text-light: #585858;
 | 
				
			||||||
 | 
					    --accent: #0d47a1;
 | 
				
			||||||
 | 
					    --accent-hover: #1266e2;
 | 
				
			||||||
 | 
					    --accent-text: var(--bg);
 | 
				
			||||||
 | 
					    --code: #d81b60;
 | 
				
			||||||
 | 
					    --preformatted: #444;
 | 
				
			||||||
 | 
					    --disabled: #efefef;
 | 
				
			||||||
 | 
					 
 | 
				
			||||||
 | 
					 }
 | 
				
			||||||
 | 
					  /* Add a bit of transparency so light media isn't so glaring in dark mode */
 | 
				
			||||||
 | 
					  img,
 | 
				
			||||||
 | 
					  video {
 | 
				
			||||||
 | 
					    opacity: 0.8;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Reset box-sizing */
 | 
				
			||||||
 | 
					*, *::before, *::after {
 | 
				
			||||||
 | 
					  box-sizing: border-box;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Reset default appearance */
 | 
				
			||||||
 | 
					textarea,
 | 
				
			||||||
 | 
					select,
 | 
				
			||||||
 | 
					input,
 | 
				
			||||||
 | 
					progress {
 | 
				
			||||||
 | 
					  appearance: none;
 | 
				
			||||||
 | 
					  -webkit-appearance: none;
 | 
				
			||||||
 | 
					  -moz-appearance: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					html {
 | 
				
			||||||
 | 
					  /* Set the font globally */
 | 
				
			||||||
 | 
					  font-family: var(--sans-font);
 | 
				
			||||||
 | 
					  scroll-behavior: smooth;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Make the body a nice central block */
 | 
				
			||||||
 | 
					body {
 | 
				
			||||||
 | 
					  color: var(--text);
 | 
				
			||||||
 | 
					  background-color: var(--bg);
 | 
				
			||||||
 | 
					  font-size: 1.15rem;
 | 
				
			||||||
 | 
					  line-height: 1.5;
 | 
				
			||||||
 | 
					  display: grid;
 | 
				
			||||||
 | 
					  grid-template-columns: 1fr min(45rem, 90%) 1fr;
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					body > * {
 | 
				
			||||||
 | 
					  grid-column: 2;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Make the header bg full width, but the content inline with body */
 | 
				
			||||||
 | 
					body > header {
 | 
				
			||||||
 | 
					  background-color: var(--accent-bg);
 | 
				
			||||||
 | 
					  border-bottom: 1px solid var(--border);
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					  padding: 0 0.5rem 2rem 0.5rem;
 | 
				
			||||||
 | 
					  grid-column: 1 / -1;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body > header > *:only-child {
 | 
				
			||||||
 | 
					  margin-block-start: 2rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body > header h1 {
 | 
				
			||||||
 | 
					  max-width: 1200px;
 | 
				
			||||||
 | 
					  margin: 1rem auto;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body > header p {
 | 
				
			||||||
 | 
					  max-width: 40rem;
 | 
				
			||||||
 | 
					  margin: 1rem auto;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Add a little padding to ensure spacing is correct between content and header > nav */
 | 
				
			||||||
 | 
					main {
 | 
				
			||||||
 | 
					  padding-top: 1.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body > footer {
 | 
				
			||||||
 | 
					  margin-top: 4rem;
 | 
				
			||||||
 | 
					  padding: 2rem 1rem 1.5rem 1rem;
 | 
				
			||||||
 | 
					  color: var(--text-light);
 | 
				
			||||||
 | 
					  font-size: 0.9rem;
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					  border-top: 1px solid var(--border);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Format headers */
 | 
				
			||||||
 | 
					h1 {
 | 
				
			||||||
 | 
					  font-size: 3rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					h2 {
 | 
				
			||||||
 | 
					  font-size: 2.6rem;
 | 
				
			||||||
 | 
					  margin-top: 3rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					h3 {
 | 
				
			||||||
 | 
					  font-size: 2rem;
 | 
				
			||||||
 | 
					  margin-top: 3rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					h4 {
 | 
				
			||||||
 | 
					  font-size: 1.44rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					h5 {
 | 
				
			||||||
 | 
					  font-size: 1.15rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					h6 {
 | 
				
			||||||
 | 
					  font-size: 0.96rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					p {
 | 
				
			||||||
 | 
					  margin: 1.5rem 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Prevent long strings from overflowing container */
 | 
				
			||||||
 | 
					p, h1, h2, h3, h4, h5, h6 {
 | 
				
			||||||
 | 
					  overflow-wrap: break-word;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Fix line height when title wraps */
 | 
				
			||||||
 | 
					h1,
 | 
				
			||||||
 | 
					h2,
 | 
				
			||||||
 | 
					h3 {
 | 
				
			||||||
 | 
					  line-height: 1.1;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Reduce header size on mobile */
 | 
				
			||||||
 | 
					@media only screen and (max-width: 720px) {
 | 
				
			||||||
 | 
					  h1 {
 | 
				
			||||||
 | 
					    font-size: 2.5rem;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  h2 {
 | 
				
			||||||
 | 
					    font-size: 2.1rem;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  h3 {
 | 
				
			||||||
 | 
					    font-size: 1.75rem;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  h4 {
 | 
				
			||||||
 | 
					    font-size: 1.25rem;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Format links & buttons */
 | 
				
			||||||
 | 
					a,
 | 
				
			||||||
 | 
					a:visited {
 | 
				
			||||||
 | 
					  color: var(--accent);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					a:hover {
 | 
				
			||||||
 | 
					  text-decoration: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					button,
 | 
				
			||||||
 | 
					.button,
 | 
				
			||||||
 | 
					a.button, /* extra specificity to override a */
 | 
				
			||||||
 | 
					input[type="submit"],
 | 
				
			||||||
 | 
					input[type="reset"],
 | 
				
			||||||
 | 
					input[type="button"],
 | 
				
			||||||
 | 
					label[type="button"] {
 | 
				
			||||||
 | 
					  border: 1px solid var(--accent);
 | 
				
			||||||
 | 
					  background-color: var(--accent);
 | 
				
			||||||
 | 
					  color: var(--accent-text);
 | 
				
			||||||
 | 
					  padding: 0.5rem 0.9rem;
 | 
				
			||||||
 | 
					  text-decoration: none;
 | 
				
			||||||
 | 
					  line-height: normal;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.button[aria-disabled="true"], 
 | 
				
			||||||
 | 
					input:disabled,
 | 
				
			||||||
 | 
					textarea:disabled,
 | 
				
			||||||
 | 
					select:disabled,
 | 
				
			||||||
 | 
					button[disabled] {
 | 
				
			||||||
 | 
					  cursor: not-allowed;
 | 
				
			||||||
 | 
					  background-color: var(--disabled);
 | 
				
			||||||
 | 
					  border-color: var(--disabled);
 | 
				
			||||||
 | 
					  color: var(--text-light);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					input[type="range"] {
 | 
				
			||||||
 | 
					  padding: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Set the cursor to '?' on an abbreviation and style the abbreviation to show that there is more information underneath */
 | 
				
			||||||
 | 
					abbr[title] {
 | 
				
			||||||
 | 
					  cursor: help;
 | 
				
			||||||
 | 
					  text-decoration-line: underline;
 | 
				
			||||||
 | 
					  text-decoration-style: dotted;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					button:enabled:hover,
 | 
				
			||||||
 | 
					.button:not([aria-disabled="true"]):hover,
 | 
				
			||||||
 | 
					input[type="submit"]:enabled:hover,
 | 
				
			||||||
 | 
					input[type="reset"]:enabled:hover,
 | 
				
			||||||
 | 
					input[type="button"]:enabled:hover,
 | 
				
			||||||
 | 
					label[type="button"]:hover {
 | 
				
			||||||
 | 
					  background-color: var(--accent-hover);
 | 
				
			||||||
 | 
					  border-color: var(--accent-hover);
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.button:focus-visible,
 | 
				
			||||||
 | 
					button:focus-visible:where(:enabled),
 | 
				
			||||||
 | 
					input:enabled:focus-visible:where(
 | 
				
			||||||
 | 
					  [type="submit"],
 | 
				
			||||||
 | 
					  [type="reset"],
 | 
				
			||||||
 | 
					  [type="button"]
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  outline: 2px solid var(--accent);
 | 
				
			||||||
 | 
					  outline-offset: 1px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Format navigation */
 | 
				
			||||||
 | 
					header > nav {
 | 
				
			||||||
 | 
					  font-size: 1rem;
 | 
				
			||||||
 | 
					  line-height: 2;
 | 
				
			||||||
 | 
					  padding: 1rem 0 0 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Use flexbox to allow items to wrap, as needed */
 | 
				
			||||||
 | 
					header > nav ul,
 | 
				
			||||||
 | 
					header > nav ol {
 | 
				
			||||||
 | 
					  align-content: space-around;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: row;
 | 
				
			||||||
 | 
					  flex-wrap: wrap;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
 | 
					  list-style-type: none;
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  padding: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* List items are inline elements, make them behave more like blocks */
 | 
				
			||||||
 | 
					header > nav ul li,
 | 
				
			||||||
 | 
					header > nav ol li {
 | 
				
			||||||
 | 
					  display: inline-block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					header > nav a,
 | 
				
			||||||
 | 
					header > nav a:visited {
 | 
				
			||||||
 | 
					  margin: 0 0.5rem 1rem 0.5rem;
 | 
				
			||||||
 | 
					  border: 1px solid var(--border);
 | 
				
			||||||
 | 
					  border-radius: var(--standard-border-radius);
 | 
				
			||||||
 | 
					  color: var(--text);
 | 
				
			||||||
 | 
					  display: inline-block;
 | 
				
			||||||
 | 
					  padding: 0.1rem 1rem;
 | 
				
			||||||
 | 
					  text-decoration: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					header > nav a:hover,
 | 
				
			||||||
 | 
					header > nav a.current,
 | 
				
			||||||
 | 
					header > nav a[aria-current="page"] {
 | 
				
			||||||
 | 
					  border-color: var(--accent);
 | 
				
			||||||
 | 
					  color: var(--accent);
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Reduce nav side on mobile */
 | 
				
			||||||
 | 
					@media only screen and (max-width: 720px) {
 | 
				
			||||||
 | 
					  header > nav a {
 | 
				
			||||||
 | 
					    border: none;
 | 
				
			||||||
 | 
					    padding: 0;
 | 
				
			||||||
 | 
					    text-decoration: underline;
 | 
				
			||||||
 | 
					    line-height: 1;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Consolidate box styling */
 | 
				
			||||||
 | 
					aside, details, pre, progress {
 | 
				
			||||||
 | 
					  background-color: var(--accent-bg);
 | 
				
			||||||
 | 
					  border: 1px solid var(--border);
 | 
				
			||||||
 | 
					  border-radius: var(--standard-border-radius);
 | 
				
			||||||
 | 
					  margin-bottom: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					aside {
 | 
				
			||||||
 | 
					  font-size: 1rem;
 | 
				
			||||||
 | 
					  width: 30%;
 | 
				
			||||||
 | 
					  padding: 0 15px;
 | 
				
			||||||
 | 
					  margin-inline-start: 15px;
 | 
				
			||||||
 | 
					  float: right;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					*[dir="rtl"] aside {
 | 
				
			||||||
 | 
					  float: left;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Make aside full-width on mobile */
 | 
				
			||||||
 | 
					@media only screen and (max-width: 720px) {
 | 
				
			||||||
 | 
					  aside {
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					    float: none;
 | 
				
			||||||
 | 
					    margin-inline-start: 0;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					article, fieldset, dialog {
 | 
				
			||||||
 | 
					  border: 1px solid var(--border);
 | 
				
			||||||
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					  border-radius: var(--standard-border-radius);
 | 
				
			||||||
 | 
					  margin-bottom: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					article h2:first-child,
 | 
				
			||||||
 | 
					section h2:first-child,
 | 
				
			||||||
 | 
					article h3:first-child,
 | 
				
			||||||
 | 
					section h3:first-child {
 | 
				
			||||||
 | 
					  margin-top: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					section {
 | 
				
			||||||
 | 
					  border-top: 1px solid var(--border);
 | 
				
			||||||
 | 
					  border-bottom: 1px solid var(--border);
 | 
				
			||||||
 | 
					  padding: 2rem 1rem;
 | 
				
			||||||
 | 
					  margin: 3rem 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Don't double separators when chaining sections */
 | 
				
			||||||
 | 
					section + section,
 | 
				
			||||||
 | 
					section:first-child {
 | 
				
			||||||
 | 
					  border-top: 0;
 | 
				
			||||||
 | 
					  padding-top: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					section + section {
 | 
				
			||||||
 | 
					  margin-top: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					section:last-child {
 | 
				
			||||||
 | 
					  border-bottom: 0;
 | 
				
			||||||
 | 
					  padding-bottom: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					details {
 | 
				
			||||||
 | 
					  padding: 0.7rem 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					summary {
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  font-weight: bold;
 | 
				
			||||||
 | 
					  padding: 0.7rem 1rem;
 | 
				
			||||||
 | 
					  margin: -0.7rem -1rem;
 | 
				
			||||||
 | 
					  word-break: break-all;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					details[open] > summary + * {
 | 
				
			||||||
 | 
					  margin-top: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					details[open] > summary {
 | 
				
			||||||
 | 
					  margin-bottom: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					details[open] > :last-child {
 | 
				
			||||||
 | 
					  margin-bottom: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Format tables */
 | 
				
			||||||
 | 
					table {
 | 
				
			||||||
 | 
					  border-collapse: collapse;
 | 
				
			||||||
 | 
					  margin: 1.5rem 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					figure > table {
 | 
				
			||||||
 | 
					  width: max-content;
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					td,
 | 
				
			||||||
 | 
					th {
 | 
				
			||||||
 | 
					  border: 1px solid var(--border);
 | 
				
			||||||
 | 
					  text-align: start;
 | 
				
			||||||
 | 
					  padding: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					th {
 | 
				
			||||||
 | 
					  background-color: var(--accent-bg);
 | 
				
			||||||
 | 
					  font-weight: bold;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					tr:nth-child(even) {
 | 
				
			||||||
 | 
					  /* Set every other cell slightly darker. Improves readability. */
 | 
				
			||||||
 | 
					  background-color: var(--accent-bg);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					table caption {
 | 
				
			||||||
 | 
					  font-weight: bold;
 | 
				
			||||||
 | 
					  margin-bottom: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Format forms */
 | 
				
			||||||
 | 
					textarea,
 | 
				
			||||||
 | 
					select,
 | 
				
			||||||
 | 
					input,
 | 
				
			||||||
 | 
					button,
 | 
				
			||||||
 | 
					.button {
 | 
				
			||||||
 | 
					  font-size: inherit;
 | 
				
			||||||
 | 
					  font-family: inherit;
 | 
				
			||||||
 | 
					  padding: 0.5rem;
 | 
				
			||||||
 | 
					  margin-bottom: 0.5rem;
 | 
				
			||||||
 | 
					  border-radius: var(--standard-border-radius);
 | 
				
			||||||
 | 
					  box-shadow: none;
 | 
				
			||||||
 | 
					  max-width: 100%;
 | 
				
			||||||
 | 
					  display: inline-block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					textarea,
 | 
				
			||||||
 | 
					select,
 | 
				
			||||||
 | 
					input {
 | 
				
			||||||
 | 
					  color: var(--text);
 | 
				
			||||||
 | 
					  background-color: var(--bg);
 | 
				
			||||||
 | 
					  border: 1px solid var(--border);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					label {
 | 
				
			||||||
 | 
					  display: block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					textarea:not([cols]) {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Add arrow to drop-down */
 | 
				
			||||||
 | 
					select:not([multiple]) {
 | 
				
			||||||
 | 
					  background-image: linear-gradient(45deg, transparent 49%, var(--text) 51%),
 | 
				
			||||||
 | 
					    linear-gradient(135deg, var(--text) 51%, transparent 49%);
 | 
				
			||||||
 | 
					  background-position: calc(100% - 15px), calc(100% - 10px);
 | 
				
			||||||
 | 
					  background-size: 5px 5px, 5px 5px;
 | 
				
			||||||
 | 
					  background-repeat: no-repeat;
 | 
				
			||||||
 | 
					  padding-inline-end: 25px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					*[dir="rtl"] select:not([multiple]) {
 | 
				
			||||||
 | 
					  background-position: 10px, 15px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* checkbox and radio button style */
 | 
				
			||||||
 | 
					input[type="checkbox"],
 | 
				
			||||||
 | 
					input[type="radio"] {
 | 
				
			||||||
 | 
					  vertical-align: middle;
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  width: min-content;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					input[type="checkbox"] + label,
 | 
				
			||||||
 | 
					input[type="radio"] + label {
 | 
				
			||||||
 | 
					  display: inline-block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					input[type="radio"] {
 | 
				
			||||||
 | 
					  border-radius: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					input[type="checkbox"]:checked,
 | 
				
			||||||
 | 
					input[type="radio"]:checked {
 | 
				
			||||||
 | 
					  background-color: var(--accent);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					input[type="checkbox"]:checked::after {
 | 
				
			||||||
 | 
					  /* Creates a rectangle with colored right and bottom borders which is rotated to look like a check mark */
 | 
				
			||||||
 | 
					  content: " ";
 | 
				
			||||||
 | 
					  width: 0.18em;
 | 
				
			||||||
 | 
					  height: 0.32em;
 | 
				
			||||||
 | 
					  border-radius: 0;
 | 
				
			||||||
 | 
					  position: absolute;
 | 
				
			||||||
 | 
					  top: 0.05em;
 | 
				
			||||||
 | 
					  left: 0.17em;
 | 
				
			||||||
 | 
					  background-color: transparent;
 | 
				
			||||||
 | 
					  border-right: solid var(--bg) 0.08em;
 | 
				
			||||||
 | 
					  border-bottom: solid var(--bg) 0.08em;
 | 
				
			||||||
 | 
					  font-size: 1.8em;
 | 
				
			||||||
 | 
					  transform: rotate(45deg);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					input[type="radio"]:checked::after {
 | 
				
			||||||
 | 
					  /* creates a colored circle for the checked radio button  */
 | 
				
			||||||
 | 
					  content: " ";
 | 
				
			||||||
 | 
					  width: 0.25em;
 | 
				
			||||||
 | 
					  height: 0.25em;
 | 
				
			||||||
 | 
					  border-radius: 100%;
 | 
				
			||||||
 | 
					  position: absolute;
 | 
				
			||||||
 | 
					  top: 0.125em;
 | 
				
			||||||
 | 
					  background-color: var(--bg);
 | 
				
			||||||
 | 
					  left: 0.125em;
 | 
				
			||||||
 | 
					  font-size: 32px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Makes input fields wider on smaller screens */
 | 
				
			||||||
 | 
					@media only screen and (max-width: 720px) {
 | 
				
			||||||
 | 
					  textarea,
 | 
				
			||||||
 | 
					  select,
 | 
				
			||||||
 | 
					  input {
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Set a height for color input */
 | 
				
			||||||
 | 
					input[type="color"] {
 | 
				
			||||||
 | 
					  height: 2.5rem;
 | 
				
			||||||
 | 
					  padding:  0.2rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* do not show border around file selector button */
 | 
				
			||||||
 | 
					input[type="file"] {
 | 
				
			||||||
 | 
					  border: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Misc body elements */
 | 
				
			||||||
 | 
					hr {
 | 
				
			||||||
 | 
					  border: none;
 | 
				
			||||||
 | 
					  height: 1px;
 | 
				
			||||||
 | 
					  background: var(--border);
 | 
				
			||||||
 | 
					  margin: 1rem auto;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					mark {
 | 
				
			||||||
 | 
					  padding: 2px 5px;
 | 
				
			||||||
 | 
					  border-radius: var(--standard-border-radius);
 | 
				
			||||||
 | 
					  background-color: var(--marked);
 | 
				
			||||||
 | 
					  color: black;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					mark a {
 | 
				
			||||||
 | 
					  color: #0d47a1;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					img,
 | 
				
			||||||
 | 
					video {
 | 
				
			||||||
 | 
					  max-width: 100%;
 | 
				
			||||||
 | 
					  height: auto;
 | 
				
			||||||
 | 
					  border-radius: var(--standard-border-radius);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					figure {
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  display: block;
 | 
				
			||||||
 | 
					  overflow-x: auto;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					figure > img,
 | 
				
			||||||
 | 
					figure > picture > img {
 | 
				
			||||||
 | 
					  display: block;
 | 
				
			||||||
 | 
					  margin-inline: auto;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					figcaption {
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					  font-size: 0.9rem;
 | 
				
			||||||
 | 
					  color: var(--text-light);
 | 
				
			||||||
 | 
					  margin-block: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					blockquote {
 | 
				
			||||||
 | 
					  margin-inline-start: 2rem;
 | 
				
			||||||
 | 
					  margin-inline-end: 0;
 | 
				
			||||||
 | 
					  margin-block: 2rem;
 | 
				
			||||||
 | 
					  padding: 0.4rem 0.8rem;
 | 
				
			||||||
 | 
					  border-inline-start: 0.35rem solid var(--accent);
 | 
				
			||||||
 | 
					  color: var(--text-light);
 | 
				
			||||||
 | 
					  font-style: italic;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					cite {
 | 
				
			||||||
 | 
					  font-size: 0.9rem;
 | 
				
			||||||
 | 
					  color: var(--text-light);
 | 
				
			||||||
 | 
					  font-style: normal;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					dt {
 | 
				
			||||||
 | 
					    color: var(--text-light);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Use mono font for code elements */
 | 
				
			||||||
 | 
					code,
 | 
				
			||||||
 | 
					pre,
 | 
				
			||||||
 | 
					pre span,
 | 
				
			||||||
 | 
					kbd,
 | 
				
			||||||
 | 
					samp {
 | 
				
			||||||
 | 
					  font-family: var(--mono-font);
 | 
				
			||||||
 | 
					  color: var(--code);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					kbd {
 | 
				
			||||||
 | 
					  color: var(--preformatted);
 | 
				
			||||||
 | 
					  border: 1px solid var(--preformatted);
 | 
				
			||||||
 | 
					  border-bottom: 3px solid var(--preformatted);
 | 
				
			||||||
 | 
					  border-radius: var(--standard-border-radius);
 | 
				
			||||||
 | 
					  padding: 0.1rem 0.4rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pre {
 | 
				
			||||||
 | 
					  padding: 1rem 1.4rem;
 | 
				
			||||||
 | 
					  max-width: 100%;
 | 
				
			||||||
 | 
					  overflow: auto;
 | 
				
			||||||
 | 
					  color: var(--preformatted);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Fix embedded code within pre */
 | 
				
			||||||
 | 
					pre code {
 | 
				
			||||||
 | 
					  color: var(--preformatted);
 | 
				
			||||||
 | 
					  background: none;
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  padding: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Progress bars */
 | 
				
			||||||
 | 
					/* Declarations are repeated because you */
 | 
				
			||||||
 | 
					/* cannot combine vendor-specific selectors */
 | 
				
			||||||
 | 
					progress {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					progress:indeterminate {
 | 
				
			||||||
 | 
					  background-color: var(--accent-bg);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					progress::-webkit-progress-bar {
 | 
				
			||||||
 | 
					  border-radius: var(--standard-border-radius);
 | 
				
			||||||
 | 
					  background-color: var(--accent-bg);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					progress::-webkit-progress-value {
 | 
				
			||||||
 | 
					  border-radius: var(--standard-border-radius);
 | 
				
			||||||
 | 
					  background-color: var(--accent);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					progress::-moz-progress-bar {
 | 
				
			||||||
 | 
					  border-radius: var(--standard-border-radius);
 | 
				
			||||||
 | 
					  background-color: var(--accent);
 | 
				
			||||||
 | 
					  transition-property: width;
 | 
				
			||||||
 | 
					  transition-duration: 0.3s;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					progress:indeterminate::-moz-progress-bar {
 | 
				
			||||||
 | 
					  background-color: var(--accent-bg);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					dialog {
 | 
				
			||||||
 | 
					  max-width: 40rem;
 | 
				
			||||||
 | 
					  margin: auto;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					dialog::backdrop {
 | 
				
			||||||
 | 
					  background-color: var(--bg);
 | 
				
			||||||
 | 
					  opacity: 0.8;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@media only screen and (max-width: 720px) {
 | 
				
			||||||
 | 
					  dialog {
 | 
				
			||||||
 | 
					    max-width: 100%;
 | 
				
			||||||
 | 
					    margin: auto 1em;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Superscript & Subscript */
 | 
				
			||||||
 | 
					/* Prevent scripts from affecting line-height. */
 | 
				
			||||||
 | 
					sup, sub {
 | 
				
			||||||
 | 
					  vertical-align: baseline;
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					sup {
 | 
				
			||||||
 | 
					  top: -0.4em;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					sub { 
 | 
				
			||||||
 | 
					  top: 0.3em; 
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Classes for notices */
 | 
				
			||||||
 | 
					.notice {
 | 
				
			||||||
 | 
					  background: var(--accent-bg);
 | 
				
			||||||
 | 
					  border: 2px solid var(--border);
 | 
				
			||||||
 | 
					  border-radius: var(--standard-border-radius);
 | 
				
			||||||
 | 
					  padding: 1.5rem;
 | 
				
			||||||
 | 
					  margin: 2rem 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										8
									
								
								backend/app/templates/404.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								backend/app/templates/404.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					{% extends "base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						<h1>File Not Found</h1>
 | 
				
			||||||
 | 
						<p><a href="{{ url_for('index') }}">Back</a></p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										9
									
								
								backend/app/templates/500.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								backend/app/templates/500.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					{% extends "base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						<h1>An unexpected error has occurred.</h1>
 | 
				
			||||||
 | 
						<p>Administrator has been notified.</p>
 | 
				
			||||||
 | 
						<p><a href="{{ url_for('index') }}">Back</a></p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										13
									
								
								backend/app/templates/_post.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								backend/app/templates/_post.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
						<table>
 | 
				
			||||||
 | 
							<tr style="vertical-align: top;">
 | 
				
			||||||
 | 
								<td style="vertical-align: middle; width: 60px;">
 | 
				
			||||||
 | 
									<img style="vertical-align: middle; width: 40px;" src="data:image/png;base64,{{ post.author.gen_avatar(write_png=False) }}">
 | 
				
			||||||
 | 
								</td>
 | 
				
			||||||
 | 
								<td>
 | 
				
			||||||
 | 
									<a href="{{ url_for('user', username=post.author.username) }}">
 | 
				
			||||||
 | 
										{{ post.author.username }}
 | 
				
			||||||
 | 
									</a> says:<br>
 | 
				
			||||||
 | 
									{{ post.body }}
 | 
				
			||||||
 | 
								</td>
 | 
				
			||||||
 | 
							</tr>
 | 
				
			||||||
 | 
						</table>
 | 
				
			||||||
							
								
								
									
										42
									
								
								backend/app/templates/base.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								backend/app/templates/base.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					<!doctype html>
 | 
				
			||||||
 | 
					<html>
 | 
				
			||||||
 | 
						<head>
 | 
				
			||||||
 | 
							<link rel="stylesheet" href="{{ url_for('static', filename='simple.css') }}">
 | 
				
			||||||
 | 
							{% if title %}
 | 
				
			||||||
 | 
							<title>{{ title }} - blogpage</title>
 | 
				
			||||||
 | 
							{% else %}
 | 
				
			||||||
 | 
							<title>Welcome to blog.</title>
 | 
				
			||||||
 | 
							{% endif %}
 | 
				
			||||||
 | 
						</head>
 | 
				
			||||||
 | 
						<body>
 | 
				
			||||||
 | 
							<header>
 | 
				
			||||||
 | 
							<nav>
 | 
				
			||||||
 | 
								<a href="{{ url_for('index') }}">home</a>
 | 
				
			||||||
 | 
								<a href="{{ url_for('explore') }}">explore</a>
 | 
				
			||||||
 | 
								{% if current_user.is_anonymous %}
 | 
				
			||||||
 | 
								<a href="{{ url_for('login') }}">login</a>
 | 
				
			||||||
 | 
								{% else %}
 | 
				
			||||||
 | 
								<a href="{{ url_for('user', username=current_user.username) }}">profile</a>
 | 
				
			||||||
 | 
								<a href="{{ url_for('logout') }}">logout</a>
 | 
				
			||||||
 | 
								{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							</nav>
 | 
				
			||||||
 | 
							<h1>oily.dad</h1>
 | 
				
			||||||
 | 
							<h4>destroy me</h4>
 | 
				
			||||||
 | 
							</header>
 | 
				
			||||||
 | 
							<hr>
 | 
				
			||||||
 | 
							{% with messages = get_flashed_messages() %}
 | 
				
			||||||
 | 
							{% if messages %}
 | 
				
			||||||
 | 
							<ul>
 | 
				
			||||||
 | 
								{% for message in messages %}
 | 
				
			||||||
 | 
								<p class="notice">{{ message }}</p>
 | 
				
			||||||
 | 
								{% endfor %}
 | 
				
			||||||
 | 
							</ul>
 | 
				
			||||||
 | 
							{% endif %}
 | 
				
			||||||
 | 
							{% endwith %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							{% block content %}{% endblock %}
 | 
				
			||||||
 | 
						</body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										24
									
								
								backend/app/templates/edit_profile.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								backend/app/templates/edit_profile.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
				
			|||||||
 | 
					{% extends "base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
						<h1>Edit Profile</h1>
 | 
				
			||||||
 | 
						<form action="" method="post">
 | 
				
			||||||
 | 
							{{ form.hidden_tag() }}
 | 
				
			||||||
 | 
							<p>
 | 
				
			||||||
 | 
								{{ form.username.label }}
 | 
				
			||||||
 | 
								{{ form.username(size=32) }}
 | 
				
			||||||
 | 
								{% for error in form.username.errors %}
 | 
				
			||||||
 | 
								<span style="color: red;">[{{ error }}]</span>
 | 
				
			||||||
 | 
								{% endfor %}
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
							<p>
 | 
				
			||||||
 | 
								{{ form.about_me.label }}
 | 
				
			||||||
 | 
								{{ form.about_me(cols=50, rows=4) }}
 | 
				
			||||||
 | 
								{% for error in form.about_me.errors %}
 | 
				
			||||||
 | 
								<span style="color: red;">[{{ error }}]</span>
 | 
				
			||||||
 | 
								{% endfor %}
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
							<p>{{ form.submit }}</p>
 | 
				
			||||||
 | 
						</form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										10
									
								
								backend/app/templates/email/reset_password.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								backend/app/templates/email/reset_password.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					<!doctype html>
 | 
				
			||||||
 | 
					<html>
 | 
				
			||||||
 | 
						<body>
 | 
				
			||||||
 | 
							<p>User {{ user.username }} requested password reset.</p>
 | 
				
			||||||
 | 
							<p>Reset link:</p>
 | 
				
			||||||
 | 
							<p><a href="{{ hostname }}{{ url_for('reset_password', token=token) }}">click here</a>
 | 
				
			||||||
 | 
							<p>If you did not request this, ignore this message.</p>
 | 
				
			||||||
 | 
						</body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										7
									
								
								backend/app/templates/email/reset_password.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								backend/app/templates/email/reset_password.txt
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					User {{ user.username }} requested password reset.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Reset link follows.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{{ hostname }}{{ url_for('reset_password', token=token) }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					If you did not request this, ignore this message.
 | 
				
			||||||
							
								
								
									
										27
									
								
								backend/app/templates/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								backend/app/templates/index.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					{% extends "base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
						<h1>Hello, {{ current_user.username }}!</h1>
 | 
				
			||||||
 | 
						{% if form %}
 | 
				
			||||||
 | 
						<form action="" method="post">
 | 
				
			||||||
 | 
							{{ form.hidden_tag() }}
 | 
				
			||||||
 | 
							<p>
 | 
				
			||||||
 | 
								{{ form.post.label }}
 | 
				
			||||||
 | 
								{{ form.post(cols=32, rows=4) }}
 | 
				
			||||||
 | 
								{% for error in form.post.errors %}
 | 
				
			||||||
 | 
								<span style="color: red;">[{{ error }}]</span>
 | 
				
			||||||
 | 
								{% endfor %}
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
							<p>{{ form.submit() }}</p>
 | 
				
			||||||
 | 
						</form>
 | 
				
			||||||
 | 
						{% endif %}
 | 
				
			||||||
 | 
						{% for post in posts %}
 | 
				
			||||||
 | 
							{% include '_post.html' %}
 | 
				
			||||||
 | 
						{% endfor %}
 | 
				
			||||||
 | 
						{% if prev_url %}<a href="{{ prev_url }}">Newer Posts</a>{% endif %}
 | 
				
			||||||
 | 
						{% if next_url %}<a href="{{ next_url }}">Older Posts</a>{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										29
									
								
								backend/app/templates/login.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								backend/app/templates/login.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
				
			|||||||
 | 
					{% extends "base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
						<h1>Sign In</h1>
 | 
				
			||||||
 | 
						<form action="" method="post" novalidate>
 | 
				
			||||||
 | 
							{{ form.hidden_tag() }}
 | 
				
			||||||
 | 
							<p>
 | 
				
			||||||
 | 
								{{ form.username.label }}<br>
 | 
				
			||||||
 | 
								{{ form.username(size=32) }}<br>
 | 
				
			||||||
 | 
								{% for error in form.username.errors %}
 | 
				
			||||||
 | 
								<span style="color: red;">[{{ error }}]</span>
 | 
				
			||||||
 | 
								{% endfor %}
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
							<p>
 | 
				
			||||||
 | 
								{{ form.password.label }}<br>
 | 
				
			||||||
 | 
								{{ form.password(size=32) }}<br>
 | 
				
			||||||
 | 
								{% for error in form.password.errors %}
 | 
				
			||||||
 | 
								<span style="color: red;">[{{ error }}]</span>
 | 
				
			||||||
 | 
								{% endfor %}
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
							<p>{{ form.remember_me() }} {{ form.remember_me.label }}</p>
 | 
				
			||||||
 | 
							<p>{{ form.submit }}</p>
 | 
				
			||||||
 | 
						</form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						<p><a href="{{ url_for('register') }}">Register Here</a></p>
 | 
				
			||||||
 | 
						<p><a href="{{ url_for('reset_password_request') }}">Reset Password</a></p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										40
									
								
								backend/app/templates/register.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								backend/app/templates/register.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
				
			|||||||
 | 
					{% extends "base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						<h1>Register</h1>
 | 
				
			||||||
 | 
						<form action="" method="post">
 | 
				
			||||||
 | 
							{{ form.hidden_tag() }}
 | 
				
			||||||
 | 
							<p>
 | 
				
			||||||
 | 
								{{ form.username.label }}<br>
 | 
				
			||||||
 | 
								{{ form.username(size=32) }}<br>
 | 
				
			||||||
 | 
								{% for error in form.username.errors %}
 | 
				
			||||||
 | 
								<span style="color: red;">[{{ error }}]</span>
 | 
				
			||||||
 | 
								{% endfor %}
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
							<p>
 | 
				
			||||||
 | 
								{{ form.email.label }}<br>
 | 
				
			||||||
 | 
								{{ form.email(size=64) }}<br>
 | 
				
			||||||
 | 
								{% for error in form.email.errors %}
 | 
				
			||||||
 | 
								<span style="color: red;">[{{ error }}]</span>
 | 
				
			||||||
 | 
								{% endfor %}
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
							<p>
 | 
				
			||||||
 | 
								{{ form.password.label }}<br>
 | 
				
			||||||
 | 
								{{ form.password(size=64) }}<br>
 | 
				
			||||||
 | 
								{% for error in form.password.errors %}
 | 
				
			||||||
 | 
								<span style="color: red;">[{{ error }}]</span>
 | 
				
			||||||
 | 
								{% endfor %}
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
							<p>
 | 
				
			||||||
 | 
								{{ form.password2.label }}<br>
 | 
				
			||||||
 | 
								{{ form.password2(size=64) }}<br>
 | 
				
			||||||
 | 
								{% for error in form.password2.errors %}
 | 
				
			||||||
 | 
								<span style="color: red;">[{{ error }}]</span>
 | 
				
			||||||
 | 
								{% endfor %}
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
							<p>{{ form.submit }}</p>
 | 
				
			||||||
 | 
						</form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										26
									
								
								backend/app/templates/reset_password.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								backend/app/templates/reset_password.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					{% extends "base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						<h1>Reset Your Password</h1>
 | 
				
			||||||
 | 
						<form action="" method="post">
 | 
				
			||||||
 | 
							{{ form.hidden_tag() }}
 | 
				
			||||||
 | 
							<p>
 | 
				
			||||||
 | 
								{{ form.password.label }}
 | 
				
			||||||
 | 
								{{ form.password(size=32) }}
 | 
				
			||||||
 | 
								{% for error in form.password.errors %}
 | 
				
			||||||
 | 
								<span style="color: red;">[{{ error }}]</span>
 | 
				
			||||||
 | 
								{% endfor %}
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
							<p>
 | 
				
			||||||
 | 
								{{ form.password2.label }}
 | 
				
			||||||
 | 
								{{ form.password2(size=32) }}
 | 
				
			||||||
 | 
								{% for error in form.password2.errors %}
 | 
				
			||||||
 | 
								<span style="color: red;">[{{ error }}]</span>
 | 
				
			||||||
 | 
								{% endfor %}
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
							<p>{{ form.submit() }}</p>
 | 
				
			||||||
 | 
						</form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										17
									
								
								backend/app/templates/reset_password_request.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								backend/app/templates/reset_password_request.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					{% extends "base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
						<h1>Reset Password</h1>
 | 
				
			||||||
 | 
						<form action="" method="post">
 | 
				
			||||||
 | 
							{{ form.hidden_tag() }}
 | 
				
			||||||
 | 
							<p>
 | 
				
			||||||
 | 
								{{ form.email.label }}<br>
 | 
				
			||||||
 | 
								{{ form.email(size=64) }}<br>
 | 
				
			||||||
 | 
								{% for error in form.email.errors %}
 | 
				
			||||||
 | 
								<span style="color: red;">[{{ error }}]</span>
 | 
				
			||||||
 | 
								{% endfor %}
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
							<p>{{ form.submit() }}</p>
 | 
				
			||||||
 | 
						</form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										45
									
								
								backend/app/templates/user.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								backend/app/templates/user.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,45 @@
 | 
				
			|||||||
 | 
					{% extends "base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
						<table>
 | 
				
			||||||
 | 
							<tr valign="top">
 | 
				
			||||||
 | 
								<td style="vertical-align: middle; text-align: center;">
 | 
				
			||||||
 | 
									<img style="vertical-align: middle;" src="data:image/png;base64,{{ user.gen_avatar(write_png=False) }}">
 | 
				
			||||||
 | 
								</td>
 | 
				
			||||||
 | 
								<td>
 | 
				
			||||||
 | 
									<h1>User: {{ user.username }}</h1>
 | 
				
			||||||
 | 
									<h1>CUdebug: {{ current_user }}</h1>
 | 
				
			||||||
 | 
									<h1>Udebug: {{ user }}</h1>
 | 
				
			||||||
 | 
									{% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
 | 
				
			||||||
 | 
									{% if user.last_seen %}<p>Last activity:{{ user.last_seen }}</p>{% endif %}
 | 
				
			||||||
 | 
									{% if user == current_user %}
 | 
				
			||||||
 | 
									<p><a href="{{ url_for('edit_profile') }}">Edit Profile</a></p>
 | 
				
			||||||
 | 
									{% elif not current_user.is_following(user) %}
 | 
				
			||||||
 | 
									<p>
 | 
				
			||||||
 | 
										<form action="{{ url_for('follow', username=user.username) }}" method="post">
 | 
				
			||||||
 | 
											{{ form.hidden_tag() }}
 | 
				
			||||||
 | 
											{{ form.submit(value='Follow') }}
 | 
				
			||||||
 | 
										</form>
 | 
				
			||||||
 | 
									</p>
 | 
				
			||||||
 | 
									{% else %}
 | 
				
			||||||
 | 
									<p>
 | 
				
			||||||
 | 
										<form action="{{ url_for('unfollow', username=user.username) }}" method="post">
 | 
				
			||||||
 | 
											{{ form.hidden_tag() }}
 | 
				
			||||||
 | 
											{{ form.submit(value='Unfollow') }}
 | 
				
			||||||
 | 
										</form>
 | 
				
			||||||
 | 
									</p>
 | 
				
			||||||
 | 
									{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								</td>
 | 
				
			||||||
 | 
							</tr>
 | 
				
			||||||
 | 
						</table>
 | 
				
			||||||
 | 
						<hr>
 | 
				
			||||||
 | 
						{% for post in posts %}
 | 
				
			||||||
 | 
							{% include '_post.html' %}
 | 
				
			||||||
 | 
						{% endfor %}
 | 
				
			||||||
 | 
						{% if prev_url %}<a href="{{ prev_url }}">Newer Posts</a>{% endif %}
 | 
				
			||||||
 | 
						{% if next_url %}<a href="{{ next_url }}">Older Posts</a>{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										24
									
								
								backend/config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								backend/config.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
				
			|||||||
 | 
					import os
 | 
				
			||||||
 | 
					basedir = os.path.abspath(os.path.dirname(__file__))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Remove or fallbacks for prod
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Config:
 | 
				
			||||||
 | 
					    SECRET_KEY = os.environ.get('DOTENV_FLASK_SECRET_KEY')
 | 
				
			||||||
 | 
					    #SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'zapp.db')
 | 
				
			||||||
 | 
					    SQLALCHEMY_DATABASE_URI = 'mariadb+mariadbconnector://flasku:' + os.environ.get('DOTENV_MYSQL_PASSWORD') + '@db:3306/flask'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    MAIL_SERVER = 'pmb'
 | 
				
			||||||
 | 
					    MAIL_PORT = 25
 | 
				
			||||||
 | 
					    MAIL_USE_TLS = False
 | 
				
			||||||
 | 
					    MAIL_USERNAME = ''
 | 
				
			||||||
 | 
					    MAIL_PASSWORD = ''
 | 
				
			||||||
 | 
					    ADMINS = [os.environ.get('DOTENV_ADMIN_EMAIL')]
 | 
				
			||||||
 | 
					    FROM_ADDRESS = os.environ.get('DOTENV_FROM_ADDRESS')
 | 
				
			||||||
 | 
					    REAL_HOSTNAME = os.environ.get('DOTENV_REAL_HOSTNAME')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    DC_LOGGING = True
 | 
				
			||||||
 | 
					    POSTS_PER_PAGE=5
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										4
									
								
								backend/dbdb.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										4
									
								
								backend/dbdb.sh
									
									
									
									
									
										Executable file
									
								
							@@ -0,0 +1,4 @@
 | 
				
			|||||||
 | 
					#!/bin/bash
 | 
				
			||||||
 | 
					# Okay to publish -- creds are local dev only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					mariadb -hdb -uflasku -pflaskp flask
 | 
				
			||||||
@@ -1,2 +1,10 @@
 | 
				
			|||||||
from app import app
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					import sqlalchemy.orm as so
 | 
				
			||||||
 | 
					from app import app, db
 | 
				
			||||||
 | 
					from app.models import User, Post
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@app.shell_context_processor
 | 
				
			||||||
 | 
					def make_shell_context():
 | 
				
			||||||
 | 
					    return {'sa': sa, 'so': so, 'db': db, 'User': User, 'Post': Post}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										1
									
								
								backend/migrations/README
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								backend/migrations/README
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					Single-database configuration for Flask.
 | 
				
			||||||
							
								
								
									
										50
									
								
								backend/migrations/alembic.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								backend/migrations/alembic.ini
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,50 @@
 | 
				
			|||||||
 | 
					# A generic, single database configuration.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[alembic]
 | 
				
			||||||
 | 
					# template used to generate migration files
 | 
				
			||||||
 | 
					# file_template = %%(rev)s_%%(slug)s
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# set to 'true' to run the environment during
 | 
				
			||||||
 | 
					# the 'revision' command, regardless of autogenerate
 | 
				
			||||||
 | 
					# revision_environment = false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Logging configuration
 | 
				
			||||||
 | 
					[loggers]
 | 
				
			||||||
 | 
					keys = root,sqlalchemy,alembic,flask_migrate
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[handlers]
 | 
				
			||||||
 | 
					keys = console
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[formatters]
 | 
				
			||||||
 | 
					keys = generic
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[logger_root]
 | 
				
			||||||
 | 
					level = WARN
 | 
				
			||||||
 | 
					handlers = console
 | 
				
			||||||
 | 
					qualname =
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[logger_sqlalchemy]
 | 
				
			||||||
 | 
					level = WARN
 | 
				
			||||||
 | 
					handlers =
 | 
				
			||||||
 | 
					qualname = sqlalchemy.engine
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[logger_alembic]
 | 
				
			||||||
 | 
					level = INFO
 | 
				
			||||||
 | 
					handlers =
 | 
				
			||||||
 | 
					qualname = alembic
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[logger_flask_migrate]
 | 
				
			||||||
 | 
					level = INFO
 | 
				
			||||||
 | 
					handlers =
 | 
				
			||||||
 | 
					qualname = flask_migrate
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[handler_console]
 | 
				
			||||||
 | 
					class = StreamHandler
 | 
				
			||||||
 | 
					args = (sys.stderr,)
 | 
				
			||||||
 | 
					level = NOTSET
 | 
				
			||||||
 | 
					formatter = generic
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[formatter_generic]
 | 
				
			||||||
 | 
					format = %(levelname)-5.5s [%(name)s] %(message)s
 | 
				
			||||||
 | 
					datefmt = %H:%M:%S
 | 
				
			||||||
							
								
								
									
										113
									
								
								backend/migrations/env.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								backend/migrations/env.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,113 @@
 | 
				
			|||||||
 | 
					import logging
 | 
				
			||||||
 | 
					from logging.config import fileConfig
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from flask import current_app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from alembic import context
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# this is the Alembic Config object, which provides
 | 
				
			||||||
 | 
					# access to the values within the .ini file in use.
 | 
				
			||||||
 | 
					config = context.config
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Interpret the config file for Python logging.
 | 
				
			||||||
 | 
					# This line sets up loggers basically.
 | 
				
			||||||
 | 
					fileConfig(config.config_file_name)
 | 
				
			||||||
 | 
					logger = logging.getLogger('alembic.env')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_engine():
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        # this works with Flask-SQLAlchemy<3 and Alchemical
 | 
				
			||||||
 | 
					        return current_app.extensions['migrate'].db.get_engine()
 | 
				
			||||||
 | 
					    except (TypeError, AttributeError):
 | 
				
			||||||
 | 
					        # this works with Flask-SQLAlchemy>=3
 | 
				
			||||||
 | 
					        return current_app.extensions['migrate'].db.engine
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_engine_url():
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        return get_engine().url.render_as_string(hide_password=False).replace(
 | 
				
			||||||
 | 
					            '%', '%%')
 | 
				
			||||||
 | 
					    except AttributeError:
 | 
				
			||||||
 | 
					        return str(get_engine().url).replace('%', '%%')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# add your model's MetaData object here
 | 
				
			||||||
 | 
					# for 'autogenerate' support
 | 
				
			||||||
 | 
					# from myapp import mymodel
 | 
				
			||||||
 | 
					# target_metadata = mymodel.Base.metadata
 | 
				
			||||||
 | 
					config.set_main_option('sqlalchemy.url', get_engine_url())
 | 
				
			||||||
 | 
					target_db = current_app.extensions['migrate'].db
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# other values from the config, defined by the needs of env.py,
 | 
				
			||||||
 | 
					# can be acquired:
 | 
				
			||||||
 | 
					# my_important_option = config.get_main_option("my_important_option")
 | 
				
			||||||
 | 
					# ... etc.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_metadata():
 | 
				
			||||||
 | 
					    if hasattr(target_db, 'metadatas'):
 | 
				
			||||||
 | 
					        return target_db.metadatas[None]
 | 
				
			||||||
 | 
					    return target_db.metadata
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def run_migrations_offline():
 | 
				
			||||||
 | 
					    """Run migrations in 'offline' mode.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    This configures the context with just a URL
 | 
				
			||||||
 | 
					    and not an Engine, though an Engine is acceptable
 | 
				
			||||||
 | 
					    here as well.  By skipping the Engine creation
 | 
				
			||||||
 | 
					    we don't even need a DBAPI to be available.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Calls to context.execute() here emit the given string to the
 | 
				
			||||||
 | 
					    script output.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    url = config.get_main_option("sqlalchemy.url")
 | 
				
			||||||
 | 
					    context.configure(
 | 
				
			||||||
 | 
					        url=url, target_metadata=get_metadata(), literal_binds=True
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    with context.begin_transaction():
 | 
				
			||||||
 | 
					        context.run_migrations()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def run_migrations_online():
 | 
				
			||||||
 | 
					    """Run migrations in 'online' mode.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    In this scenario we need to create an Engine
 | 
				
			||||||
 | 
					    and associate a connection with the context.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # this callback is used to prevent an auto-migration from being generated
 | 
				
			||||||
 | 
					    # when there are no changes to the schema
 | 
				
			||||||
 | 
					    # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
 | 
				
			||||||
 | 
					    def process_revision_directives(context, revision, directives):
 | 
				
			||||||
 | 
					        if getattr(config.cmd_opts, 'autogenerate', False):
 | 
				
			||||||
 | 
					            script = directives[0]
 | 
				
			||||||
 | 
					            if script.upgrade_ops.is_empty():
 | 
				
			||||||
 | 
					                directives[:] = []
 | 
				
			||||||
 | 
					                logger.info('No changes in schema detected.')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    conf_args = current_app.extensions['migrate'].configure_args
 | 
				
			||||||
 | 
					    if conf_args.get("process_revision_directives") is None:
 | 
				
			||||||
 | 
					        conf_args["process_revision_directives"] = process_revision_directives
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    connectable = get_engine()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    with connectable.connect() as connection:
 | 
				
			||||||
 | 
					        context.configure(
 | 
				
			||||||
 | 
					            connection=connection,
 | 
				
			||||||
 | 
					            target_metadata=get_metadata(),
 | 
				
			||||||
 | 
					            **conf_args
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with context.begin_transaction():
 | 
				
			||||||
 | 
					            context.run_migrations()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if context.is_offline_mode():
 | 
				
			||||||
 | 
					    run_migrations_offline()
 | 
				
			||||||
 | 
					else:
 | 
				
			||||||
 | 
					    run_migrations_online()
 | 
				
			||||||
							
								
								
									
										24
									
								
								backend/migrations/script.py.mako
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								backend/migrations/script.py.mako
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
				
			|||||||
 | 
					"""${message}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Revision ID: ${up_revision}
 | 
				
			||||||
 | 
					Revises: ${down_revision | comma,n}
 | 
				
			||||||
 | 
					Create Date: ${create_date}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from alembic import op
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					${imports if imports else ""}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# revision identifiers, used by Alembic.
 | 
				
			||||||
 | 
					revision = ${repr(up_revision)}
 | 
				
			||||||
 | 
					down_revision = ${repr(down_revision)}
 | 
				
			||||||
 | 
					branch_labels = ${repr(branch_labels)}
 | 
				
			||||||
 | 
					depends_on = ${repr(depends_on)}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def upgrade():
 | 
				
			||||||
 | 
					    ${upgrades if upgrades else "pass"}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def downgrade():
 | 
				
			||||||
 | 
					    ${downgrades if downgrades else "pass"}
 | 
				
			||||||
							
								
								
									
										61
									
								
								backend/migrations/versions/4a2c3a72038e_.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								backend/migrations/versions/4a2c3a72038e_.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
				
			|||||||
 | 
					"""empty message
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Revision ID: 4a2c3a72038e
 | 
				
			||||||
 | 
					Revises: 
 | 
				
			||||||
 | 
					Create Date: 2024-08-03 05:02:15.935738
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from alembic import op
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# revision identifiers, used by Alembic.
 | 
				
			||||||
 | 
					revision = '4a2c3a72038e'
 | 
				
			||||||
 | 
					down_revision = None
 | 
				
			||||||
 | 
					branch_labels = None
 | 
				
			||||||
 | 
					depends_on = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def upgrade():
 | 
				
			||||||
 | 
					    # ### commands auto generated by Alembic - please adjust! ###
 | 
				
			||||||
 | 
					    op.create_table('user',
 | 
				
			||||||
 | 
					    sa.Column('id', sa.Integer(), nullable=False),
 | 
				
			||||||
 | 
					    sa.Column('username', sa.String(length=64), nullable=False),
 | 
				
			||||||
 | 
					    sa.Column('email', sa.String(length=120), nullable=False),
 | 
				
			||||||
 | 
					    sa.Column('password_hash', sa.String(length=256), nullable=True),
 | 
				
			||||||
 | 
					    sa.Column('about_me', sa.String(length=140), nullable=True),
 | 
				
			||||||
 | 
					    sa.Column('last_seen', sa.DateTime(), nullable=True),
 | 
				
			||||||
 | 
					    sa.PrimaryKeyConstraint('id')
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    with op.batch_alter_table('user', schema=None) as batch_op:
 | 
				
			||||||
 | 
					        batch_op.create_index(batch_op.f('ix_user_email'), ['email'], unique=True)
 | 
				
			||||||
 | 
					        batch_op.create_index(batch_op.f('ix_user_username'), ['username'], unique=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    op.create_table('post',
 | 
				
			||||||
 | 
					    sa.Column('id', sa.Integer(), nullable=False),
 | 
				
			||||||
 | 
					    sa.Column('body', sa.String(length=140), nullable=False),
 | 
				
			||||||
 | 
					    sa.Column('timestamp', sa.DateTime(), nullable=False),
 | 
				
			||||||
 | 
					    sa.Column('user_id', sa.Integer(), nullable=False),
 | 
				
			||||||
 | 
					    sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
 | 
				
			||||||
 | 
					    sa.PrimaryKeyConstraint('id')
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    with op.batch_alter_table('post', schema=None) as batch_op:
 | 
				
			||||||
 | 
					        batch_op.create_index(batch_op.f('ix_post_timestamp'), ['timestamp'], unique=False)
 | 
				
			||||||
 | 
					        batch_op.create_index(batch_op.f('ix_post_user_id'), ['user_id'], unique=False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # ### end Alembic commands ###
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def downgrade():
 | 
				
			||||||
 | 
					    # ### commands auto generated by Alembic - please adjust! ###
 | 
				
			||||||
 | 
					    with op.batch_alter_table('post', schema=None) as batch_op:
 | 
				
			||||||
 | 
					        batch_op.drop_index(batch_op.f('ix_post_user_id'))
 | 
				
			||||||
 | 
					        batch_op.drop_index(batch_op.f('ix_post_timestamp'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    op.drop_table('post')
 | 
				
			||||||
 | 
					    with op.batch_alter_table('user', schema=None) as batch_op:
 | 
				
			||||||
 | 
					        batch_op.drop_index(batch_op.f('ix_user_username'))
 | 
				
			||||||
 | 
					        batch_op.drop_index(batch_op.f('ix_user_email'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    op.drop_table('user')
 | 
				
			||||||
 | 
					    # ### end Alembic commands ###
 | 
				
			||||||
@@ -1,8 +1,28 @@
 | 
				
			|||||||
 | 
					alembic==1.13.2
 | 
				
			||||||
blinker==1.8.2
 | 
					blinker==1.8.2
 | 
				
			||||||
click==8.1.7
 | 
					click==8.1.7
 | 
				
			||||||
 | 
					dnspython==2.6.1
 | 
				
			||||||
 | 
					email_validator==2.2.0
 | 
				
			||||||
Flask==3.0.3
 | 
					Flask==3.0.3
 | 
				
			||||||
 | 
					Flask-Login==0.6.3
 | 
				
			||||||
 | 
					Flask-Mail==0.10.0
 | 
				
			||||||
 | 
					Flask-Migrate==4.0.7
 | 
				
			||||||
 | 
					Flask-SQLAlchemy==3.1.1
 | 
				
			||||||
 | 
					Flask-WTF==1.2.1
 | 
				
			||||||
 | 
					greenlet==3.0.3
 | 
				
			||||||
 | 
					idna==3.7
 | 
				
			||||||
itsdangerous==2.2.0
 | 
					itsdangerous==2.2.0
 | 
				
			||||||
Jinja2==3.1.4
 | 
					Jinja2==3.1.4
 | 
				
			||||||
 | 
					Mako==1.3.5
 | 
				
			||||||
 | 
					mariadb==1.1.10
 | 
				
			||||||
MarkupSafe==2.1.5
 | 
					MarkupSafe==2.1.5
 | 
				
			||||||
 | 
					packaging==24.1
 | 
				
			||||||
 | 
					pillow==10.4.0
 | 
				
			||||||
 | 
					pydenticon==0.3.1
 | 
				
			||||||
 | 
					PyJWT==2.9.0
 | 
				
			||||||
python-dotenv==1.0.1
 | 
					python-dotenv==1.0.1
 | 
				
			||||||
 | 
					SQLAlchemy==2.0.31
 | 
				
			||||||
 | 
					typing_extensions==4.12.2
 | 
				
			||||||
 | 
					uWSGI==2.0.26
 | 
				
			||||||
Werkzeug==3.0.3
 | 
					Werkzeug==3.0.3
 | 
				
			||||||
 | 
					WTForms==3.1.2
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +0,0 @@
 | 
				
			|||||||
<html>
 | 
					 | 
				
			||||||
<head>
 | 
					 | 
				
			||||||
	<title></title>
 | 
					 | 
				
			||||||
</head>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<body>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	<h1>About</h1>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
</body>
 | 
					 | 
				
			||||||
</html>
 | 
					 | 
				
			||||||
@@ -1,9 +0,0 @@
 | 
				
			|||||||
<html>
 | 
					 | 
				
			||||||
<head>
 | 
					 | 
				
			||||||
	<title></title>
 | 
					 | 
				
			||||||
</head>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<body>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
</body>
 | 
					 | 
				
			||||||
</html>
 | 
					 | 
				
			||||||
@@ -1,11 +0,0 @@
 | 
				
			|||||||
<html>
 | 
					 | 
				
			||||||
<head>
 | 
					 | 
				
			||||||
	<title></title>
 | 
					 | 
				
			||||||
</head>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<body>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	<h1>Home</h1>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
</body>
 | 
					 | 
				
			||||||
</html>
 | 
					 | 
				
			||||||
							
								
								
									
										95
									
								
								backend/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								backend/tests.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,95 @@
 | 
				
			|||||||
 | 
					import os
 | 
				
			||||||
 | 
					os.environ['DATABASE_URL'] = 'sqlite://'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from datetime import datetime, timezone, timedelta
 | 
				
			||||||
 | 
					import unittest
 | 
				
			||||||
 | 
					from app import app, db
 | 
				
			||||||
 | 
					from app.models import User, Post
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UserModelCase(unittest.TestCase):
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        self.app_context = app.app_context()
 | 
				
			||||||
 | 
					        self.app_context.push()
 | 
				
			||||||
 | 
					        db.create_all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def tearDown(self):
 | 
				
			||||||
 | 
					        db.session.remove()
 | 
				
			||||||
 | 
					        db.drop_all()
 | 
				
			||||||
 | 
					        self.app_context.pop()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_password_hashing(self):
 | 
				
			||||||
 | 
					        u = User(username='susan', email='susan@example.com')
 | 
				
			||||||
 | 
					        u.set_password('cat')
 | 
				
			||||||
 | 
					        self.assertFalse(u.check_password('dog'))
 | 
				
			||||||
 | 
					        self.assertTrue(u.check_password('cat'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_follow(self):
 | 
				
			||||||
 | 
					        u1 = User(username='john', email='john@example.com')
 | 
				
			||||||
 | 
					        u2 = User(username='susan', email='susan@example.com')
 | 
				
			||||||
 | 
					        db.session.add(u1)
 | 
				
			||||||
 | 
					        db.session.add(u2)
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					        following = db.session.scalars(u1.following.select()).all()
 | 
				
			||||||
 | 
					        followers = db.session.scalars(u2.followers.select()).all()
 | 
				
			||||||
 | 
					        self.assertEqual(following, [])
 | 
				
			||||||
 | 
					        self.assertEqual(followers, [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        u1.follow(u2)
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					        self.assertTrue(u1.is_following(u2))
 | 
				
			||||||
 | 
					        self.assertEqual(u1.following_count(), 1)
 | 
				
			||||||
 | 
					        self.assertEqual(u2.followers_count(), 1)
 | 
				
			||||||
 | 
					        u1_following = db.session.scalars(u1.following.select()).all()
 | 
				
			||||||
 | 
					        u2_followers = db.session.scalars(u2.followers.select()).all()
 | 
				
			||||||
 | 
					        self.assertEqual(u1_following[0].username, 'susan')
 | 
				
			||||||
 | 
					        self.assertEqual(u2_followers[0].username, 'john')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        u1.unfollow(u2)
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					        self.assertFalse(u1.is_following(u2))
 | 
				
			||||||
 | 
					        self.assertEqual(u1.following_count(), 0)
 | 
				
			||||||
 | 
					        self.assertEqual(u2.followers_count(), 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_follow_posts(self):
 | 
				
			||||||
 | 
					        # create four users
 | 
				
			||||||
 | 
					        u1 = User(username='john', email='john@example.com')
 | 
				
			||||||
 | 
					        u2 = User(username='susan', email='susan@example.com')
 | 
				
			||||||
 | 
					        u3 = User(username='mary', email='mary@example.com')
 | 
				
			||||||
 | 
					        u4 = User(username='david', email='david@example.com')
 | 
				
			||||||
 | 
					        db.session.add_all([u1, u2, u3, u4])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # create four posts
 | 
				
			||||||
 | 
					        now = datetime.now(timezone.utc)
 | 
				
			||||||
 | 
					        p1 = Post(body="post from john", author=u1,
 | 
				
			||||||
 | 
					                  timestamp=now + timedelta(seconds=1))
 | 
				
			||||||
 | 
					        p2 = Post(body="post from susan", author=u2,
 | 
				
			||||||
 | 
					                  timestamp=now + timedelta(seconds=4))
 | 
				
			||||||
 | 
					        p3 = Post(body="post from mary", author=u3,
 | 
				
			||||||
 | 
					                  timestamp=now + timedelta(seconds=3))
 | 
				
			||||||
 | 
					        p4 = Post(body="post from david", author=u4,
 | 
				
			||||||
 | 
					                  timestamp=now + timedelta(seconds=2))
 | 
				
			||||||
 | 
					        db.session.add_all([p1, p2, p3, p4])
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # setup the followers
 | 
				
			||||||
 | 
					        u1.follow(u2)  # john follows susan
 | 
				
			||||||
 | 
					        u1.follow(u4)  # john follows david
 | 
				
			||||||
 | 
					        u2.follow(u3)  # susan follows mary
 | 
				
			||||||
 | 
					        u3.follow(u4)  # mary follows david
 | 
				
			||||||
 | 
					        db.session.commit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # check the following posts of each user
 | 
				
			||||||
 | 
					        f1 = db.session.scalars(u1.following_posts()).all()
 | 
				
			||||||
 | 
					        f2 = db.session.scalars(u2.following_posts()).all()
 | 
				
			||||||
 | 
					        f3 = db.session.scalars(u3.following_posts()).all()
 | 
				
			||||||
 | 
					        f4 = db.session.scalars(u4.following_posts()).all()
 | 
				
			||||||
 | 
					        self.assertEqual(f1, [p2, p4, p1])
 | 
				
			||||||
 | 
					        self.assertEqual(f2, [p2, p3])
 | 
				
			||||||
 | 
					        self.assertEqual(f3, [p3, p4])
 | 
				
			||||||
 | 
					        self.assertEqual(f4, [p4])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == '__main__':
 | 
				
			||||||
 | 
					    unittest.main(verbosity=2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										34
									
								
								compose.yaml
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								compose.yaml
									
									
									
									
									
								
							@@ -1,12 +1,14 @@
 | 
				
			|||||||
services:
 | 
					services:
 | 
				
			||||||
  db:
 | 
					  db:
 | 
				
			||||||
    image: mariadb:10-focal
 | 
					    image: mariadb:lts
 | 
				
			||||||
    restart: always
 | 
					    restart: always
 | 
				
			||||||
    healthcheck:
 | 
					    healthcheck:
 | 
				
			||||||
      test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 --password="${DOTENV_MYSQL_ROOT_PASSWORD}" --silent']
 | 
					      #test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 --password="${DOTENV_MYSQL_ROOT_PASSWORD}" --silent']
 | 
				
			||||||
      interval: 3s
 | 
					      test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']
 | 
				
			||||||
 | 
					      interval: 10s
 | 
				
			||||||
      retries: 5
 | 
					      retries: 5
 | 
				
			||||||
      start_period: 30s
 | 
					      timeout: 5s
 | 
				
			||||||
 | 
					      start_period: 10s
 | 
				
			||||||
    volumes:
 | 
					    volumes:
 | 
				
			||||||
      - db-data:/var/lib/mysql
 | 
					      - db-data:/var/lib/mysql
 | 
				
			||||||
      - ./db/init:/docker-entrypoint-initdb.d/
 | 
					      - ./db/init:/docker-entrypoint-initdb.d/
 | 
				
			||||||
@@ -26,15 +28,23 @@ services:
 | 
				
			|||||||
    build:
 | 
					    build:
 | 
				
			||||||
      context: backend
 | 
					      context: backend
 | 
				
			||||||
      target: builder
 | 
					      target: builder
 | 
				
			||||||
    restart: always
 | 
					    # Next two are only debug, used without restart
 | 
				
			||||||
 | 
					    stdin_open: true
 | 
				
			||||||
 | 
					    tty: true
 | 
				
			||||||
 | 
					    #restart: always
 | 
				
			||||||
    # Comment following line to use flask (1worker, dev), uncomment to use uwsgi (wsgi)
 | 
					    # Comment following line to use flask (1worker, dev), uncomment to use uwsgi (wsgi)
 | 
				
			||||||
    command: ["uwsgi", "--http", "0.0.0.0:8000", "--master", "-p", "4", "-w", "app:server"]
 | 
					    #command: ["uwsgi", "--http", "0.0.0.0:8000", "--master", "-p", "4", "-w", "microblog:app"]
 | 
				
			||||||
    environment:
 | 
					    environment:
 | 
				
			||||||
      - MYSQL_USER=flasku
 | 
					      - MYSQL_USER=flasku
 | 
				
			||||||
      #- MYSQL_PASSWORD=flaskp
 | 
					      #- MYSQL_PASSWORD=flaskp
 | 
				
			||||||
      - MYSQL_PASSWORD=${DOTENV_MYSQL_FLASK_PASSWORD}
 | 
					      - DOTENV_MYSQL_PASSWORD=${DOTENV_MYSQL_FLASK_PASSWORD}
 | 
				
			||||||
      - TOKEN_I=${DOTENV_TOKEN_I}
 | 
					      - DOTENV_FLASK_SECRET_KEY=${FLASK_SECRET_KEY}
 | 
				
			||||||
      - TOKEN_C=${DOTENV_TOKEN_C}
 | 
					      - DOTENV_TOKEN_I=${FLASK_TOKEN_I}
 | 
				
			||||||
 | 
					      - DOTENV_TOKEN_C=${FLASK_TOKEN_C}
 | 
				
			||||||
 | 
					      - DOTENV_ADMIN_EMAIL=${FLASK_ADMIN_EMAIL}
 | 
				
			||||||
 | 
					      - DOTENV_FROM_ADDRESS=${FLASK_MAIL_FROM}
 | 
				
			||||||
 | 
					      - DOTENV_JWT_PHRASE=${FLASK_JWT_PHRASE}
 | 
				
			||||||
 | 
					      - DOTENV_REAL_HOSTNAME=${FLASK_REAL_HOSTNAME}
 | 
				
			||||||
    #ports:
 | 
					    #ports:
 | 
				
			||||||
    #  - 8000:8000
 | 
					    #  - 8000:8000
 | 
				
			||||||
    expose:
 | 
					    expose:
 | 
				
			||||||
@@ -86,9 +96,9 @@ services:
 | 
				
			|||||||
  proxy:
 | 
					  proxy:
 | 
				
			||||||
    build: proxy
 | 
					    build: proxy
 | 
				
			||||||
    restart: always
 | 
					    restart: always
 | 
				
			||||||
    volumes:
 | 
					    #volumes:
 | 
				
			||||||
      - /home/finn/d/cert/var/lib/letsencrypt:/var/lib/letsencrypt
 | 
					    #  - /home/finn/d/cert/var/lib/letsencrypt:/var/lib/letsencrypt
 | 
				
			||||||
      - /home/finn/d/cert/etc/letsencrypt:/etc/letsencrypt
 | 
					    #  - /home/finn/d/cert/etc/letsencrypt:/etc/letsencrypt
 | 
				
			||||||
    ports:
 | 
					    ports:
 | 
				
			||||||
      - 80:80
 | 
					      - 80:80
 | 
				
			||||||
      - 443:443
 | 
					      - 443:443
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										134
									
								
								compose.yaml.local
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								compose.yaml.local
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,134 @@
 | 
				
			|||||||
 | 
					services:
 | 
				
			||||||
 | 
					  db:
 | 
				
			||||||
 | 
					    image: mariadb:lts
 | 
				
			||||||
 | 
					    restart: always
 | 
				
			||||||
 | 
					    healthcheck:
 | 
				
			||||||
 | 
					      #test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 --password="${DOTENV_MYSQL_ROOT_PASSWORD}" --silent']
 | 
				
			||||||
 | 
					      test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']
 | 
				
			||||||
 | 
					      interval: 10s
 | 
				
			||||||
 | 
					      retries: 5
 | 
				
			||||||
 | 
					      timeout: 5s
 | 
				
			||||||
 | 
					      start_period: 10s
 | 
				
			||||||
 | 
					    volumes:
 | 
				
			||||||
 | 
					      - db-data:/var/lib/mysql
 | 
				
			||||||
 | 
					      - ./db/init:/docker-entrypoint-initdb.d/
 | 
				
			||||||
 | 
					    networks:
 | 
				
			||||||
 | 
					      - backnet
 | 
				
			||||||
 | 
					    environment:
 | 
				
			||||||
 | 
					      #- MYSQL_DATABASE=gitea
 | 
				
			||||||
 | 
					      #- MYSQL_USER=gitea
 | 
				
			||||||
 | 
					      #- MYSQL_PASSWORD=gitea
 | 
				
			||||||
 | 
					      #- MYSQL_ROOT_PASSWORD=rootpass
 | 
				
			||||||
 | 
					      - MYSQL_ROOT_PASSWORD=${DOTENV_MYSQL_ROOT_PASSWORD}
 | 
				
			||||||
 | 
					    expose:
 | 
				
			||||||
 | 
					      - 3306
 | 
				
			||||||
 | 
					      - 33060
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  backend:
 | 
				
			||||||
 | 
					    build:
 | 
				
			||||||
 | 
					      context: backend
 | 
				
			||||||
 | 
					      target: builder
 | 
				
			||||||
 | 
					    # Next two are only debug, used without restart
 | 
				
			||||||
 | 
					    stdin_open: true
 | 
				
			||||||
 | 
					    tty: true
 | 
				
			||||||
 | 
					    #restart: always
 | 
				
			||||||
 | 
					    # Comment following line to use flask (1worker, dev), uncomment to use uwsgi (wsgi)
 | 
				
			||||||
 | 
					    #command: ["uwsgi", "--http", "0.0.0.0:8000", "--master", "-p", "4", "-w", "microblog:app"]
 | 
				
			||||||
 | 
					    environment:
 | 
				
			||||||
 | 
					      - MYSQL_USER=flasku
 | 
				
			||||||
 | 
					      #- MYSQL_PASSWORD=flaskp
 | 
				
			||||||
 | 
					      - DOTENV_MYSQL_PASSWORD=${DOTENV_MYSQL_FLASK_PASSWORD}
 | 
				
			||||||
 | 
					      - DOTENV_FLASK_SECRET_KEY=${FLASK_SECRET_KEY}
 | 
				
			||||||
 | 
					      - DOTENV_TOKEN_I=${FLASK_TOKEN_I}
 | 
				
			||||||
 | 
					      - DOTENV_TOKEN_C=${FLASK_TOKEN_C}
 | 
				
			||||||
 | 
					      - DOTENV_ADMIN_EMAIL=${FLASK_ADMIN_EMAIL}
 | 
				
			||||||
 | 
					      - DOTENV_FROM_ADDRESS=${FLASK_MAIL_FROM}
 | 
				
			||||||
 | 
					      - DOTENV_JWT_PHRASE=${FLASK_JWT_PHRASE}
 | 
				
			||||||
 | 
					      - DOTENV_REAL_HOSTNAME=${FLASK_REAL_HOSTNAME}
 | 
				
			||||||
 | 
					    #ports:
 | 
				
			||||||
 | 
					    #  - 8000:8000
 | 
				
			||||||
 | 
					    expose:
 | 
				
			||||||
 | 
					      - 8000
 | 
				
			||||||
 | 
					    networks:
 | 
				
			||||||
 | 
					      - backnet
 | 
				
			||||||
 | 
					      - frontnet
 | 
				
			||||||
 | 
					    depends_on:
 | 
				
			||||||
 | 
					      db:
 | 
				
			||||||
 | 
					        condition: service_healthy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  gutsub:
 | 
				
			||||||
 | 
					    image: gitea/gitea:latest
 | 
				
			||||||
 | 
					    container_name: gitea
 | 
				
			||||||
 | 
					    restart: always
 | 
				
			||||||
 | 
					    environment:
 | 
				
			||||||
 | 
					      - USER_UID=1000
 | 
				
			||||||
 | 
					      - USER_GID=1000
 | 
				
			||||||
 | 
					      - GITEA__database__DB_TYPE=mysql
 | 
				
			||||||
 | 
					      - GITEA__database__HOST=db:3306
 | 
				
			||||||
 | 
					      - GITEA__database__NAME=gitea
 | 
				
			||||||
 | 
					      - GITEA__database__USER=gitea
 | 
				
			||||||
 | 
					      - GITEA__database__PASSWD=${DOTENV_MYSQL_GITEA_PASSWORD}
 | 
				
			||||||
 | 
					      - GITEA__repository__DEFAULT_BRANCH=master
 | 
				
			||||||
 | 
					      - GITEA__mailer__ENABLED=true
 | 
				
			||||||
 | 
					      - GITEA__mailer__FROM=${GITEA_MAIL_FROM}
 | 
				
			||||||
 | 
					      - GITEA__mailer__USER=
 | 
				
			||||||
 | 
					      - GITEA__mailer__PROTOCOL=smtp
 | 
				
			||||||
 | 
					      - GITEA__mailer__SMTP_ADDR=pmb
 | 
				
			||||||
 | 
					      - GITEA__mailer__SMTP_PORT=25
 | 
				
			||||||
 | 
					      - GITEA__service__REGISTER_EMAIL_CONFIRM=true
 | 
				
			||||||
 | 
					      - GITEA__service__ENABLE_NOTIFY_MAIL=true
 | 
				
			||||||
 | 
					      # To disable new users after setup:
 | 
				
			||||||
 | 
					      #- GITEA__service__DISABLE_REGISTRATION=false
 | 
				
			||||||
 | 
					    networks:
 | 
				
			||||||
 | 
					      - backnet
 | 
				
			||||||
 | 
					      - frontnet
 | 
				
			||||||
 | 
					    volumes:
 | 
				
			||||||
 | 
					      - ./gitea:/data
 | 
				
			||||||
 | 
					      - /etc/timezone:/etc/timezone:ro
 | 
				
			||||||
 | 
					      - /etc/localtime:/etc/localtime:ro
 | 
				
			||||||
 | 
					     #ports:
 | 
				
			||||||
 | 
					     # - "3000:3000"
 | 
				
			||||||
 | 
					     # - "222:22"
 | 
				
			||||||
 | 
					    depends_on:
 | 
				
			||||||
 | 
					      db:
 | 
				
			||||||
 | 
					        condition: service_healthy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  proxy:
 | 
				
			||||||
 | 
					    build: proxy
 | 
				
			||||||
 | 
					    restart: always
 | 
				
			||||||
 | 
					    #volumes:
 | 
				
			||||||
 | 
					    #  - /home/finn/d/cert/var/lib/letsencrypt:/var/lib/letsencrypt
 | 
				
			||||||
 | 
					    #  - /home/finn/d/cert/etc/letsencrypt:/etc/letsencrypt
 | 
				
			||||||
 | 
					    ports:
 | 
				
			||||||
 | 
					      - 80:80
 | 
				
			||||||
 | 
					      - 443:443
 | 
				
			||||||
 | 
					    depends_on: 
 | 
				
			||||||
 | 
					      - backend
 | 
				
			||||||
 | 
					    networks:
 | 
				
			||||||
 | 
					      - frontnet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  pmb:
 | 
				
			||||||
 | 
					    #build:
 | 
				
			||||||
 | 
					    #  args:
 | 
				
			||||||
 | 
					    #    GPG_PP: $BUILD_GPG_PP
 | 
				
			||||||
 | 
					    #  context: pmb-pf
 | 
				
			||||||
 | 
					    #  dockerfile: Dockerfile
 | 
				
			||||||
 | 
					    image: site_pmb:latest
 | 
				
			||||||
 | 
					    expose:
 | 
				
			||||||
 | 
					      - "25"
 | 
				
			||||||
 | 
					    env_file:
 | 
				
			||||||
 | 
					      - ./pmb-pf/.env
 | 
				
			||||||
 | 
					    restart: always
 | 
				
			||||||
 | 
					    volumes:
 | 
				
			||||||
 | 
					      - pmb-root:/root
 | 
				
			||||||
 | 
					      - /etc/localtime:/etc/localtime:ro
 | 
				
			||||||
 | 
					    networks:
 | 
				
			||||||
 | 
					      - backnet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					volumes:
 | 
				
			||||||
 | 
					  db-data:
 | 
				
			||||||
 | 
					  pmb-root:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					networks:
 | 
				
			||||||
 | 
					  backnet:
 | 
				
			||||||
 | 
					  frontnet:
 | 
				
			||||||
							
								
								
									
										134
									
								
								compose.yaml.prod
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								compose.yaml.prod
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,134 @@
 | 
				
			|||||||
 | 
					services:
 | 
				
			||||||
 | 
					  db:
 | 
				
			||||||
 | 
					    image: mariadb:lts
 | 
				
			||||||
 | 
					    restart: always
 | 
				
			||||||
 | 
					    healthcheck:
 | 
				
			||||||
 | 
					      #test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 --password="${DOTENV_MYSQL_ROOT_PASSWORD}" --silent']
 | 
				
			||||||
 | 
					      test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']
 | 
				
			||||||
 | 
					      interval: 10s
 | 
				
			||||||
 | 
					      retries: 5
 | 
				
			||||||
 | 
					      timeout: 5s
 | 
				
			||||||
 | 
					      start_period: 10s
 | 
				
			||||||
 | 
					    volumes:
 | 
				
			||||||
 | 
					      - db-data:/var/lib/mysql
 | 
				
			||||||
 | 
					      - ./db/init:/docker-entrypoint-initdb.d/
 | 
				
			||||||
 | 
					    networks:
 | 
				
			||||||
 | 
					      - backnet
 | 
				
			||||||
 | 
					    environment:
 | 
				
			||||||
 | 
					      #- MYSQL_DATABASE=gitea
 | 
				
			||||||
 | 
					      #- MYSQL_USER=gitea
 | 
				
			||||||
 | 
					      #- MYSQL_PASSWORD=gitea
 | 
				
			||||||
 | 
					      #- MYSQL_ROOT_PASSWORD=rootpass
 | 
				
			||||||
 | 
					      - MYSQL_ROOT_PASSWORD=${DOTENV_MYSQL_ROOT_PASSWORD}
 | 
				
			||||||
 | 
					    expose:
 | 
				
			||||||
 | 
					      - 3306
 | 
				
			||||||
 | 
					      - 33060
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  backend:
 | 
				
			||||||
 | 
					    build:
 | 
				
			||||||
 | 
					      context: backend
 | 
				
			||||||
 | 
					      target: builder
 | 
				
			||||||
 | 
					    # Next two are only debug, used without restart
 | 
				
			||||||
 | 
					    #stdin_open: true
 | 
				
			||||||
 | 
					    #tty: true
 | 
				
			||||||
 | 
					    restart: always
 | 
				
			||||||
 | 
					    # Comment following line to use flask (1worker, dev), uncomment to use uwsgi (wsgi)
 | 
				
			||||||
 | 
					    command: ["uwsgi", "--http", "0.0.0.0:8000", "--master", "-p", "4", "-w", "microblog:app"]
 | 
				
			||||||
 | 
					    environment:
 | 
				
			||||||
 | 
					      - MYSQL_USER=flasku
 | 
				
			||||||
 | 
					      #- MYSQL_PASSWORD=flaskp
 | 
				
			||||||
 | 
					      - DOTENV_MYSQL_PASSWORD=${DOTENV_MYSQL_FLASK_PASSWORD}
 | 
				
			||||||
 | 
					      - DOTENV_FLASK_SECRET_KEY=${FLASK_SECRET_KEY}
 | 
				
			||||||
 | 
					      - DOTENV_TOKEN_I=${FLASK_TOKEN_I}
 | 
				
			||||||
 | 
					      - DOTENV_TOKEN_C=${FLASK_TOKEN_C}
 | 
				
			||||||
 | 
					      - DOTENV_ADMIN_EMAIL=${FLASK_ADMIN_EMAIL}
 | 
				
			||||||
 | 
					      - DOTENV_FROM_ADDRESS=${FLASK_MAIL_FROM}
 | 
				
			||||||
 | 
					      - DOTENV_JWT_PHRASE=${FLASK_JWT_PHRASE}
 | 
				
			||||||
 | 
					      - DOTENV_REAL_HOSTNAME=${FLASK_REAL_HOSTNAME}
 | 
				
			||||||
 | 
					    #ports:
 | 
				
			||||||
 | 
					    #  - 8000:8000
 | 
				
			||||||
 | 
					    expose:
 | 
				
			||||||
 | 
					      - 8000
 | 
				
			||||||
 | 
					    networks:
 | 
				
			||||||
 | 
					      - backnet
 | 
				
			||||||
 | 
					      - frontnet
 | 
				
			||||||
 | 
					    depends_on:
 | 
				
			||||||
 | 
					      db:
 | 
				
			||||||
 | 
					        condition: service_healthy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  gutsub:
 | 
				
			||||||
 | 
					    image: gitea/gitea:latest
 | 
				
			||||||
 | 
					    container_name: gitea
 | 
				
			||||||
 | 
					    restart: always
 | 
				
			||||||
 | 
					    environment:
 | 
				
			||||||
 | 
					      - USER_UID=1000
 | 
				
			||||||
 | 
					      - USER_GID=1000
 | 
				
			||||||
 | 
					      - GITEA__database__DB_TYPE=mysql
 | 
				
			||||||
 | 
					      - GITEA__database__HOST=db:3306
 | 
				
			||||||
 | 
					      - GITEA__database__NAME=gitea
 | 
				
			||||||
 | 
					      - GITEA__database__USER=gitea
 | 
				
			||||||
 | 
					      - GITEA__database__PASSWD=${DOTENV_MYSQL_GITEA_PASSWORD}
 | 
				
			||||||
 | 
					      - GITEA__repository__DEFAULT_BRANCH=master
 | 
				
			||||||
 | 
					      - GITEA__mailer__ENABLED=true
 | 
				
			||||||
 | 
					      - GITEA__mailer__FROM=${GITEA_MAIL_FROM}
 | 
				
			||||||
 | 
					      - GITEA__mailer__USER=
 | 
				
			||||||
 | 
					      - GITEA__mailer__PROTOCOL=smtp
 | 
				
			||||||
 | 
					      - GITEA__mailer__SMTP_ADDR=pmb
 | 
				
			||||||
 | 
					      - GITEA__mailer__SMTP_PORT=25
 | 
				
			||||||
 | 
					      - GITEA__service__REGISTER_EMAIL_CONFIRM=true
 | 
				
			||||||
 | 
					      - GITEA__service__ENABLE_NOTIFY_MAIL=true
 | 
				
			||||||
 | 
					      # To disable new users after setup:
 | 
				
			||||||
 | 
					      #- GITEA__service__DISABLE_REGISTRATION=false
 | 
				
			||||||
 | 
					    networks:
 | 
				
			||||||
 | 
					      - backnet
 | 
				
			||||||
 | 
					      - frontnet
 | 
				
			||||||
 | 
					    volumes:
 | 
				
			||||||
 | 
					      - ./gitea:/data
 | 
				
			||||||
 | 
					      - /etc/timezone:/etc/timezone:ro
 | 
				
			||||||
 | 
					      - /etc/localtime:/etc/localtime:ro
 | 
				
			||||||
 | 
					     #ports:
 | 
				
			||||||
 | 
					     # - "3000:3000"
 | 
				
			||||||
 | 
					     # - "222:22"
 | 
				
			||||||
 | 
					    depends_on:
 | 
				
			||||||
 | 
					      db:
 | 
				
			||||||
 | 
					        condition: service_healthy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  proxy:
 | 
				
			||||||
 | 
					    build: proxy
 | 
				
			||||||
 | 
					    restart: always
 | 
				
			||||||
 | 
					    #volumes:
 | 
				
			||||||
 | 
					    #  - /home/finn/d/cert/var/lib/letsencrypt:/var/lib/letsencrypt
 | 
				
			||||||
 | 
					    #  - /home/finn/d/cert/etc/letsencrypt:/etc/letsencrypt
 | 
				
			||||||
 | 
					    ports:
 | 
				
			||||||
 | 
					      - 80:80
 | 
				
			||||||
 | 
					      - 443:443
 | 
				
			||||||
 | 
					    depends_on: 
 | 
				
			||||||
 | 
					      - backend
 | 
				
			||||||
 | 
					    networks:
 | 
				
			||||||
 | 
					      - frontnet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  pmb:
 | 
				
			||||||
 | 
					    #build:
 | 
				
			||||||
 | 
					    #  args:
 | 
				
			||||||
 | 
					    #    GPG_PP: $BUILD_GPG_PP
 | 
				
			||||||
 | 
					    #  context: pmb-pf
 | 
				
			||||||
 | 
					    #  dockerfile: Dockerfile
 | 
				
			||||||
 | 
					    image: site_pmb:latest
 | 
				
			||||||
 | 
					    expose:
 | 
				
			||||||
 | 
					      - "25"
 | 
				
			||||||
 | 
					    env_file:
 | 
				
			||||||
 | 
					      - ./pmb-pf/.env
 | 
				
			||||||
 | 
					    restart: always
 | 
				
			||||||
 | 
					    volumes:
 | 
				
			||||||
 | 
					      - pmb-root:/root
 | 
				
			||||||
 | 
					      - /etc/localtime:/etc/localtime:ro
 | 
				
			||||||
 | 
					    networks:
 | 
				
			||||||
 | 
					      - backnet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					volumes:
 | 
				
			||||||
 | 
					  db-data:
 | 
				
			||||||
 | 
					  pmb-root:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					networks:
 | 
				
			||||||
 | 
					  backnet:
 | 
				
			||||||
 | 
					  frontnet:
 | 
				
			||||||
							
								
								
									
										13
									
								
								dotenv
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								dotenv
									
									
									
									
									
								
							@@ -5,7 +5,7 @@ DOTENV_MYSQL_ROOT_PASSWORD=rootp
 | 
				
			|||||||
DOTENV_MYSQL_GITEA_PASSWORD=giteap
 | 
					DOTENV_MYSQL_GITEA_PASSWORD=giteap
 | 
				
			||||||
DOTENV_MYSQL_FLASK_PASSWORD=flaskp
 | 
					DOTENV_MYSQL_FLASK_PASSWORD=flaskp
 | 
				
			||||||
 | 
					
 | 
				
			||||||
GITEA_MAIL_FROM=
 | 
					GITEA_MAIL_FROM="git@changeme"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Build ARG GPG_PP. May still need to be empty to avoid breakage.
 | 
					# Build ARG GPG_PP. May still need to be empty to avoid breakage.
 | 
				
			||||||
BUILD_GPG_PP=
 | 
					BUILD_GPG_PP=
 | 
				
			||||||
@@ -13,8 +13,15 @@ BUILD_GPG_PP=
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# Backend:
 | 
					# Backend:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					FLASK_SECRET_KEY="changeme"
 | 
				
			||||||
# Inconsequential token: minimal inconvenience if exposed
 | 
					# Inconsequential token: minimal inconvenience if exposed
 | 
				
			||||||
DOTENV_TOKEN_I=dti
 | 
					FLASK_TOKEN_I=dti
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Consequential token: protect
 | 
					# Consequential token: protect
 | 
				
			||||||
DOTENV_TOKEN_C=dtc
 | 
					FLASK_TOKEN_C=dtc
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					FLASK_MAIL_FROM="git@changeme"
 | 
				
			||||||
 | 
					# admin email must be valid send from with mail subsystem
 | 
				
			||||||
 | 
					FLASK_ADMIN_EMAIL="git@changeme"
 | 
				
			||||||
 | 
					FLASK_JWT_PHRASE="jwtphrase"
 | 
				
			||||||
 | 
					FLASK_REAL_HOSTNAME="localhost"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,5 +4,9 @@ server {
 | 
				
			|||||||
    location / {
 | 
					    location / {
 | 
				
			||||||
        proxy_pass   http://backend:8000;
 | 
					        proxy_pass   http://backend:8000;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    location /gutty{
 | 
				
			||||||
 | 
					        proxy_pass   http://gitea:3000;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										50
									
								
								proxy/conf
									
									
									
									
									
								
							
							
						
						
									
										50
									
								
								proxy/conf
									
									
									
									
									
								
							@@ -1,52 +1,12 @@
 | 
				
			|||||||
#server {
 | 
					 | 
				
			||||||
#    listen       80;
 | 
					 | 
				
			||||||
#    server_name  localhost;
 | 
					 | 
				
			||||||
#    location / {
 | 
					 | 
				
			||||||
#        proxy_pass   http://backend:8000;
 | 
					 | 
				
			||||||
#    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# always redirect to https
 | 
					 | 
				
			||||||
server {
 | 
					server {
 | 
				
			||||||
	listen 80 default_server;
 | 
					    listen       80;
 | 
				
			||||||
	server_name _;
 | 
					    server_name  localhost;
 | 
				
			||||||
	return 301 https://$host$request_uri;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
server {
 | 
					 | 
				
			||||||
	listen 443 ssl http2;
 | 
					 | 
				
			||||||
	# use the certificates
 | 
					 | 
				
			||||||
	ssl_certificate /etc/letsencrypt/live/oily.dad/fullchain.pem;
 | 
					 | 
				
			||||||
	ssl_certificate_key /etc/letsencrypt/live/oily.dad/privkey.pem;
 | 
					 | 
				
			||||||
	server_name oily.dad www.oily.dad;
 | 
					 | 
				
			||||||
	root /var/www/html;
 | 
					 | 
				
			||||||
	index index.php index.html index.htm;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    location / {
 | 
					    location / {
 | 
				
			||||||
		proxy_pass http://backend:8000/;
 | 
					        proxy_pass   http://backend:8000;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    location /gutty {
 | 
				
			||||||
 | 
					        proxy_pass   http://gitea:3000;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
server {
 | 
					 | 
				
			||||||
	listen 443 ssl http2;
 | 
					 | 
				
			||||||
	# use the certificates
 | 
					 | 
				
			||||||
	ssl_certificate /etc/letsencrypt/live/oily.dad/fullchain.pem;
 | 
					 | 
				
			||||||
	ssl_certificate_key /etc/letsencrypt/live/oily.dad/privkey.pem;
 | 
					 | 
				
			||||||
	server_name gut.oily.dad;
 | 
					 | 
				
			||||||
	root /var/www/html;
 | 
					 | 
				
			||||||
	index index.php index.html index.htm;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	location / {
 | 
					 | 
				
			||||||
		client_max_body_size 512M;
 | 
					 | 
				
			||||||
	        #proxy_pass http://localhost:3000;
 | 
					 | 
				
			||||||
	        proxy_set_header Connection $http_connection;
 | 
					 | 
				
			||||||
        	proxy_set_header Upgrade $http_upgrade;
 | 
					 | 
				
			||||||
	        proxy_set_header Host $host;
 | 
					 | 
				
			||||||
	        proxy_set_header X-Real-IP $remote_addr;
 | 
					 | 
				
			||||||
	        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 | 
					 | 
				
			||||||
	        proxy_set_header X-Forwarded-Proto $scheme;
 | 
					 | 
				
			||||||
		proxy_pass http://gitea:3000/;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										52
									
								
								proxy/sslconf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										52
									
								
								proxy/sslconf
									
									
									
									
									
										Executable file
									
								
							@@ -0,0 +1,52 @@
 | 
				
			|||||||
 | 
					#server {
 | 
				
			||||||
 | 
					#    listen       80;
 | 
				
			||||||
 | 
					#    server_name  localhost;
 | 
				
			||||||
 | 
					#    location / {
 | 
				
			||||||
 | 
					#        proxy_pass   http://backend:8000;
 | 
				
			||||||
 | 
					#    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# always redirect to https
 | 
				
			||||||
 | 
					server {
 | 
				
			||||||
 | 
						listen 80 default_server;
 | 
				
			||||||
 | 
						server_name _;
 | 
				
			||||||
 | 
						return 301 https://$host$request_uri;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					server {
 | 
				
			||||||
 | 
						listen 443 ssl http2;
 | 
				
			||||||
 | 
						# use the certificates
 | 
				
			||||||
 | 
						ssl_certificate /etc/letsencrypt/live/oily.dad/fullchain.pem;
 | 
				
			||||||
 | 
						ssl_certificate_key /etc/letsencrypt/live/oily.dad/privkey.pem;
 | 
				
			||||||
 | 
						server_name oily.dad www.oily.dad;
 | 
				
			||||||
 | 
						root /var/www/html;
 | 
				
			||||||
 | 
						index index.php index.html index.htm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						location / {
 | 
				
			||||||
 | 
							proxy_pass http://backend:8000/;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					server {
 | 
				
			||||||
 | 
						listen 443 ssl http2;
 | 
				
			||||||
 | 
						# use the certificates
 | 
				
			||||||
 | 
						ssl_certificate /etc/letsencrypt/live/oily.dad/fullchain.pem;
 | 
				
			||||||
 | 
						ssl_certificate_key /etc/letsencrypt/live/oily.dad/privkey.pem;
 | 
				
			||||||
 | 
						server_name gut.oily.dad;
 | 
				
			||||||
 | 
						root /var/www/html;
 | 
				
			||||||
 | 
						index index.php index.html index.htm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						location / {
 | 
				
			||||||
 | 
							client_max_body_size 512M;
 | 
				
			||||||
 | 
						        #proxy_pass http://localhost:3000;
 | 
				
			||||||
 | 
						        proxy_set_header Connection $http_connection;
 | 
				
			||||||
 | 
					        	proxy_set_header Upgrade $http_upgrade;
 | 
				
			||||||
 | 
						        proxy_set_header Host $host;
 | 
				
			||||||
 | 
						        proxy_set_header X-Real-IP $remote_addr;
 | 
				
			||||||
 | 
						        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 | 
				
			||||||
 | 
						        proxy_set_header X-Forwarded-Proto $scheme;
 | 
				
			||||||
 | 
							proxy_pass http://gitea:3000/;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		Reference in New Issue
	
	Block a user