Compare commits
	
		
			26 Commits
		
	
	
		
			becc234263
			...
			834c236091
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 834c236091 | |||
| 5168e6cd73 | |||
| b3b188f370 | |||
| 3d1f21ffcb | |||
| ed9df4db6f | |||
| 61c8cadb87 | |||
| 94434d4f8e | |||
| 2f920f3b6f | |||
| 63bdf8d164 | |||
| 469785ee33 | |||
| eb0f19b109 | |||
| d7a0167cd6 | |||
| 5b0cd1d22f | |||
| cdeb87184a | |||
| fe2dcd23f1 | |||
| af0978c4e8 | |||
| 626ababdc3 | |||
| 2b122f6ab2 | |||
| cdebe081c3 | |||
| a8068c8578 | |||
| 52c1e056c1 | |||
| 9db540fc1c | |||
| 3162b662b5 | |||
| 76b20a3b38 | |||
| 426e917df9 | |||
| fed4454a05 | 
							
								
								
									
										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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										54
									
								
								proxy/conf
									
									
									
									
									
								
							
							
						
						
									
										54
									
								
								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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
									
								
							
							
						
						
									
										52
									
								
								proxy/sslconf
									
									
									
									
									
										Executable file
									
								
							@@ -0,0 +1,52 @@
 | 
			
		||||
#server {
 | 
			
		||||
#    listen       80;
 | 
			
		||||
#    server_name  localhost;
 | 
			
		||||
#    location / {
 | 
			
		||||
#        proxy_pass   http://backend:8000;
 | 
			
		||||
#    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# always redirect to https
 | 
			
		||||
server {
 | 
			
		||||
	listen 80 default_server;
 | 
			
		||||
	server_name _;
 | 
			
		||||
	return 301 https://$host$request_uri;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
server {
 | 
			
		||||
	listen 443 ssl http2;
 | 
			
		||||
	# use the certificates
 | 
			
		||||
	ssl_certificate /etc/letsencrypt/live/oily.dad/fullchain.pem;
 | 
			
		||||
	ssl_certificate_key /etc/letsencrypt/live/oily.dad/privkey.pem;
 | 
			
		||||
	server_name oily.dad www.oily.dad;
 | 
			
		||||
	root /var/www/html;
 | 
			
		||||
	index index.php index.html index.htm;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	location / {
 | 
			
		||||
		proxy_pass http://backend:8000/;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
server {
 | 
			
		||||
	listen 443 ssl http2;
 | 
			
		||||
	# use the certificates
 | 
			
		||||
	ssl_certificate /etc/letsencrypt/live/oily.dad/fullchain.pem;
 | 
			
		||||
	ssl_certificate_key /etc/letsencrypt/live/oily.dad/privkey.pem;
 | 
			
		||||
	server_name gut.oily.dad;
 | 
			
		||||
	root /var/www/html;
 | 
			
		||||
	index index.php index.html index.htm;
 | 
			
		||||
 | 
			
		||||
	location / {
 | 
			
		||||
		client_max_body_size 512M;
 | 
			
		||||
	        #proxy_pass http://localhost:3000;
 | 
			
		||||
	        proxy_set_header Connection $http_connection;
 | 
			
		||||
        	proxy_set_header Upgrade $http_upgrade;
 | 
			
		||||
	        proxy_set_header Host $host;
 | 
			
		||||
	        proxy_set_header X-Real-IP $remote_addr;
 | 
			
		||||
	        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 | 
			
		||||
	        proxy_set_header X-Forwarded-Proto $scheme;
 | 
			
		||||
		proxy_pass http://gitea:3000/;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user