flask site buildout #2
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,5 +2,5 @@ gitea/
|
||||
.env
|
||||
pmb-pf/
|
||||
venv
|
||||
|
||||
zapp.db
|
||||
|
||||
|
29
README.md
29
README.md
@ -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
4
backend/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
||||
venv
|
||||
migrations
|
||||
zapp.db
|
||||
|
@ -1,2 +1,3 @@
|
||||
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
|
||||
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
|
||||
|
@ -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
|
||||
|
@ -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
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('/')
|
||||
@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)
|
||||
|
||||
|
||||
|
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
|
||||
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
|
||||
|
@ -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:
|
||||
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
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_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"
|
||||
|
@ -4,5 +4,9 @@ server {
|
||||
location / {
|
||||
proxy_pass http://backend:8000;
|
||||
}
|
||||
location /gutty{
|
||||
proxy_pass http://gitea:3000;
|
||||
}
|
||||
|
||||
|
||||
}
|
60
proxy/conf
60
proxy/conf
@ -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;
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
location / {
|
||||
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 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/;
|
||||
}
|
||||
}
|
||||
|
||||
|
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/;
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user