Compare commits

...

26 Commits

Author SHA1 Message Date
834c236091 Merge pull request 'flask site buildout' (#2) from mgtut1 into master
Reviewed-on: #2
2024-08-05 08:41:02 +00:00
5168e6cd73 post c10 cleanup 2024-08-05 01:36:10 -07:00
b3b188f370 mgt c10 complete merge to put bootstrap css changes on their own fork 2024-08-05 01:28:09 -07:00
3d1f21ffcb mgt c10 checkpoint with debug 2024-08-05 00:59:01 -07:00
ed9df4db6f mgt c10 checkpoint 2024-08-04 22:00:07 -07:00
61c8cadb87 mgt c9 complete 2024-08-04 13:14:58 -07:00
94434d4f8e avatar tweaks 2024-08-04 10:55:28 -07:00
2f920f3b6f mgt c8 finish 2024-08-04 09:40:09 -07:00
63bdf8d164 mgt c8 checkpoint after tests 2024-08-04 05:48:02 -07:00
469785ee33 mgt c8 checkpoint at tests 2024-08-04 05:44:20 -07:00
eb0f19b109 pre-c8 checkpoint 2024-08-04 04:39:49 -07:00
d7a0167cd6 mgt c7 finish 2024-08-03 11:45:37 -07:00
5b0cd1d22f checkpoint before maillog attempt 2024-08-03 08:58:32 -07:00
cdeb87184a dockerized newsite mariadb setup 2024-08-03 08:37:27 -07:00
fe2dcd23f1 mgt c7 checkpoint 1 2024-08-03 06:31:19 -07:00
af0978c4e8 update readme 2024-08-03 05:38:58 -07:00
626ababdc3 mgt c6 done 2024-08-03 05:34:12 -07:00
2b122f6ab2 mgt c6 checkpoint 2.2 2024-08-03 04:59:42 -07:00
cdebe081c3 mgt c6 checkpoint 2024-08-03 02:14:20 -07:00
a8068c8578 mgt doc updates 2024-08-01 22:21:07 -07:00
52c1e056c1 minor db config 2024-08-01 17:48:00 -07:00
9db540fc1c login and register 2024-08-01 11:10:49 -07:00
3162b662b5 mgt c4 2024-08-01 06:35:28 -07:00
76b20a3b38 scss work 2024-08-01 03:53:33 -07:00
426e917df9 mg tut c3 2024-08-01 03:33:45 -07:00
fed4454a05 mg tut c2 2024-08-01 02:07:49 -07:00
46 changed files with 2336 additions and 118 deletions

2
.gitignore vendored
View File

@ -2,5 +2,5 @@ gitea/
.env
pmb-pf/
venv
zapp.db

View File

@ -2,12 +2,10 @@
### Sec:
* 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.
* Note app Dockerfile debug console, found at /console. Werkzeug/flask is WILDLY insecure if left in dev/dbg.
* Avoid docker socks stuff.
- 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.
- Note app Dockerfile debug console, found at /console. Werkzeug/flask is WILDLY insecure if left in dev/dbg.
- Avoid docker socks stuff.
### Install:
@ -49,19 +47,28 @@ set up cron job for script
pmb-pf - git clone of my mail thing
other - ref and non-sensitive files for dns
### Timeline:
### Setup cheat:
set up certbot dns\
see tar of cert dir with script
- set up certbot dns (prod)
- 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:
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:
Find in proxy/conf.\
Find in gitea conf.\
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
View File

@ -0,0 +1,4 @@
venv
migrations
zapp.db

View File

@ -1,2 +1,3 @@
FLASK_APP=microblog.py
FLASK_DEBUG=1

13
backend/Dockerfile Executable file → Normal file
View File

@ -1,14 +1,21 @@
# 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
COPY requirements.txt /code
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 . .
ENV FLASK_APP app.py
ENV FLASK_APP microblog.py
# This might be scary to leave on
#ENV FLASK_ENV development

View File

@ -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 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

View File

@ -1,6 +1,40 @@
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.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
View 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
View 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
View 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
View 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))

View File

@ -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('/')
@app.route('/index')
from app import app, db
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():
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)

View 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;
}

View 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 %}

View 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 %}

View 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>

View 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>

View 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 %}

View 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>

View 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.

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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
View 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
View File

@ -0,0 +1,4 @@
#!/bin/bash
# Okay to publish -- creds are local dev only
mariadb -hdb -uflasku -pflaskp flask

View File

@ -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}

View File

@ -0,0 +1 @@
Single-database configuration for Flask.

View 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
View 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()

View 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"}

View 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 ###

View File

@ -1,8 +1,28 @@
alembic==1.13.2
blinker==1.8.2
click==8.1.7
dnspython==2.6.1
email_validator==2.2.0
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
Jinja2==3.1.4
Mako==1.3.5
mariadb==1.1.10
MarkupSafe==2.1.5
packaging==24.1
pillow==10.4.0
pydenticon==0.3.1
PyJWT==2.9.0
python-dotenv==1.0.1
SQLAlchemy==2.0.31
typing_extensions==4.12.2
uWSGI==2.0.26
Werkzeug==3.0.3
WTForms==3.1.2

View File

@ -1,11 +0,0 @@
<html>
<head>
<title></title>
</head>
<body>
<h1>About</h1>
</body>
</html>

View File

@ -1,9 +0,0 @@
<html>
<head>
<title></title>
</head>
<body>
</body>
</html>

View File

@ -1,11 +0,0 @@
<html>
<head>
<title></title>
</head>
<body>
<h1>Home</h1>
</body>
</html>

95
backend/tests.py Normal file
View 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)

View File

@ -1,12 +1,14 @@
services:
db:
image: mariadb:10-focal
image: mariadb:lts
restart: always
healthcheck:
test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 --password="${DOTENV_MYSQL_ROOT_PASSWORD}" --silent']
interval: 3s
#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
start_period: 30s
timeout: 5s
start_period: 10s
volumes:
- db-data:/var/lib/mysql
- ./db/init:/docker-entrypoint-initdb.d/
@ -26,15 +28,23 @@ services:
build:
context: backend
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)
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:
- MYSQL_USER=flasku
#- MYSQL_PASSWORD=flaskp
- MYSQL_PASSWORD=${DOTENV_MYSQL_FLASK_PASSWORD}
- TOKEN_I=${DOTENV_TOKEN_I}
- TOKEN_C=${DOTENV_TOKEN_C}
- 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:
@ -86,9 +96,9 @@ services:
proxy:
build: proxy
restart: always
volumes:
- /home/finn/d/cert/var/lib/letsencrypt:/var/lib/letsencrypt
- /home/finn/d/cert/etc/letsencrypt:/etc/letsencrypt
#volumes:
# - /home/finn/d/cert/var/lib/letsencrypt:/var/lib/letsencrypt
# - /home/finn/d/cert/etc/letsencrypt:/etc/letsencrypt
ports:
- 80:80
- 443:443

134
compose.yaml.local Normal file
View 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
View 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
View File

@ -5,7 +5,7 @@ DOTENV_MYSQL_ROOT_PASSWORD=rootp
DOTENV_MYSQL_GITEA_PASSWORD=giteap
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_GPG_PP=
@ -13,8 +13,15 @@ BUILD_GPG_PP=
# Backend:
FLASK_SECRET_KEY="changeme"
# Inconsequential token: minimal inconvenience if exposed
DOTENV_TOKEN_I=dti
FLASK_TOKEN_I=dti
# 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"

View File

@ -4,5 +4,9 @@ server {
location / {
proxy_pass http://backend:8000;
}
location /gutty{
proxy_pass http://gitea:3000;
}
}

View File

@ -1,52 +1,12 @@
#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;
listen 80;
server_name localhost;
location / {
proxy_pass http://backend:8000/;
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/;
location /gutty {
proxy_pass http://gitea:3000;
}
}
}

52
proxy/sslconf Executable file
View 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/;
}
}