diff --git a/.gitignore b/.gitignore index bd1860d..850c508 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,5 @@ gitea/ .env pmb-pf/ venv - +zapp.db diff --git a/README.md b/README.md index cf790f4..5a0baa3 100644 --- a/README.md +++ b/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 \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..a375c52 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +venv +migrations +zapp.db + diff --git a/backend/.flaskenv b/backend/.flaskenv index 046b50c..b85b65f 100644 --- a/backend/.flaskenv +++ b/backend/.flaskenv @@ -1,2 +1,3 @@ FLASK_APP=microblog.py +FLASK_DEBUG=1 diff --git a/backend/Dockerfile b/backend/Dockerfile old mode 100755 new mode 100644 index 6e581ef..51ad67d --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/README.md b/backend/README.md index c3fdd6b..198de84 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 5bdbcd0..758e496 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -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 diff --git a/backend/app/email.py b/backend/app/email.py new file mode 100644 index 0000000..d5db7f4 --- /dev/null +++ b/backend/app/email.py @@ -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)) + + + + diff --git a/backend/app/errors.py b/backend/app/errors.py new file mode 100644 index 0000000..4428603 --- /dev/null +++ b/backend/app/errors.py @@ -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 + diff --git a/backend/app/forms.py b/backend/app/forms.py new file mode 100644 index 0000000..d9a5c14 --- /dev/null +++ b/backend/app/forms.py @@ -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') + + diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..e93f60b --- /dev/null +++ b/backend/app/models.py @@ -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 ''.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 ''.format(self.body) + + +@login.user_loader +def load_user(id): + return db.session.get(User, int(id)) diff --git a/backend/app/routes.py b/backend/app/routes.py index 05a0bd4..c644031 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -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/', 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/') +@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/', 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/', 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) + diff --git a/backend/app/static/simple.css b/backend/app/static/simple.css new file mode 100644 index 0000000..8874b1a --- /dev/null +++ b/backend/app/static/simple.css @@ -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; +} diff --git a/backend/app/templates/404.html b/backend/app/templates/404.html new file mode 100644 index 0000000..3c74661 --- /dev/null +++ b/backend/app/templates/404.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block content %} + +

File Not Found

+

Back

+ +{% endblock %} diff --git a/backend/app/templates/500.html b/backend/app/templates/500.html new file mode 100644 index 0000000..699d215 --- /dev/null +++ b/backend/app/templates/500.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block content %} + +

An unexpected error has occurred.

+

Administrator has been notified.

+

Back

+ +{% endblock %} diff --git a/backend/app/templates/_post.html b/backend/app/templates/_post.html new file mode 100644 index 0000000..dd71e87 --- /dev/null +++ b/backend/app/templates/_post.html @@ -0,0 +1,13 @@ + + + + + +
+ + + + {{ post.author.username }} + says:
+ {{ post.body }} +
diff --git a/backend/app/templates/base.html b/backend/app/templates/base.html new file mode 100644 index 0000000..ff4f233 --- /dev/null +++ b/backend/app/templates/base.html @@ -0,0 +1,42 @@ + + + + + {% if title %} + {{ title }} - blogpage + {% else %} + Welcome to blog. + {% endif %} + + +
+ +

oily.dad

+

destroy me

+
+
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +

    {{ message }}

    + {% endfor %} +
+ {% endif %} + {% endwith %} + + + {% block content %}{% endblock %} + + + diff --git a/backend/app/templates/edit_profile.html b/backend/app/templates/edit_profile.html new file mode 100644 index 0000000..e347134 --- /dev/null +++ b/backend/app/templates/edit_profile.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block content %} +

Edit Profile

+
+ {{ form.hidden_tag() }} +

+ {{ form.username.label }} + {{ form.username(size=32) }} + {% for error in form.username.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.about_me.label }} + {{ form.about_me(cols=50, rows=4) }} + {% for error in form.about_me.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.submit }}

+
+ +{% endblock %} diff --git a/backend/app/templates/email/reset_password.html b/backend/app/templates/email/reset_password.html new file mode 100644 index 0000000..3f14636 --- /dev/null +++ b/backend/app/templates/email/reset_password.html @@ -0,0 +1,10 @@ + + + +

User {{ user.username }} requested password reset.

+

Reset link:

+

click here +

If you did not request this, ignore this message.

+ + + diff --git a/backend/app/templates/email/reset_password.txt b/backend/app/templates/email/reset_password.txt new file mode 100644 index 0000000..f6ec353 --- /dev/null +++ b/backend/app/templates/email/reset_password.txt @@ -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. diff --git a/backend/app/templates/index.html b/backend/app/templates/index.html new file mode 100644 index 0000000..c6d27f4 --- /dev/null +++ b/backend/app/templates/index.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block content %} +

Hello, {{ current_user.username }}!

+ {% if form %} +
+ {{ form.hidden_tag() }} +

+ {{ form.post.label }} + {{ form.post(cols=32, rows=4) }} + {% for error in form.post.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.submit() }}

+
+ {% endif %} + {% for post in posts %} + {% include '_post.html' %} + {% endfor %} + {% if prev_url %}Newer Posts{% endif %} + {% if next_url %}Older Posts{% endif %} + +{% endblock %} + + + diff --git a/backend/app/templates/login.html b/backend/app/templates/login.html new file mode 100644 index 0000000..9bb8d2d --- /dev/null +++ b/backend/app/templates/login.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block content %} +

Sign In

+
+ {{ form.hidden_tag() }} +

+ {{ form.username.label }}
+ {{ form.username(size=32) }}
+ {% for error in form.username.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.password.label }}
+ {{ form.password(size=32) }}
+ {% for error in form.password.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.remember_me() }} {{ form.remember_me.label }}

+

{{ form.submit }}

+
+ +

Register Here

+

Reset Password

+ + +{% endblock %} diff --git a/backend/app/templates/register.html b/backend/app/templates/register.html new file mode 100644 index 0000000..02b4226 --- /dev/null +++ b/backend/app/templates/register.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block content %} + +

Register

+
+ {{ form.hidden_tag() }} +

+ {{ form.username.label }}
+ {{ form.username(size=32) }}
+ {% for error in form.username.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.email.label }}
+ {{ form.email(size=64) }}
+ {% for error in form.email.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.password.label }}
+ {{ form.password(size=64) }}
+ {% for error in form.password.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.password2.label }}
+ {{ form.password2(size=64) }}
+ {% for error in form.password2.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.submit }}

+
+ + +{% endblock %} diff --git a/backend/app/templates/reset_password.html b/backend/app/templates/reset_password.html new file mode 100644 index 0000000..50f8dba --- /dev/null +++ b/backend/app/templates/reset_password.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block content %} + +

Reset Your Password

+
+ {{ form.hidden_tag() }} +

+ {{ form.password.label }} + {{ form.password(size=32) }} + {% for error in form.password.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.password2.label }} + {{ form.password2(size=32) }} + {% for error in form.password2.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.submit() }}

+
+ + +{% endblock %} diff --git a/backend/app/templates/reset_password_request.html b/backend/app/templates/reset_password_request.html new file mode 100644 index 0000000..da2b56e --- /dev/null +++ b/backend/app/templates/reset_password_request.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block content %} +

Reset Password

+
+ {{ form.hidden_tag() }} +

+ {{ form.email.label }}
+ {{ form.email(size=64) }}
+ {% for error in form.email.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.submit() }}

+
+ +{% endblock %} diff --git a/backend/app/templates/user.html b/backend/app/templates/user.html new file mode 100644 index 0000000..a4c5d8c --- /dev/null +++ b/backend/app/templates/user.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block content %} + + + + + +
+ + +

User: {{ user.username }}

+

CUdebug: {{ current_user }}

+

Udebug: {{ user }}

+ {% if user.about_me %}

{{ user.about_me }}

{% endif %} + {% if user.last_seen %}

Last activity:{{ user.last_seen }}

{% endif %} + {% if user == current_user %} +

Edit Profile

+ {% elif not current_user.is_following(user) %} +

+

+ {{ form.hidden_tag() }} + {{ form.submit(value='Follow') }} +
+

+ {% else %} +

+

+ {{ form.hidden_tag() }} + {{ form.submit(value='Unfollow') }} +
+

+ {% endif %} + + +
+
+ {% for post in posts %} + {% include '_post.html' %} + {% endfor %} + {% if prev_url %}Newer Posts{% endif %} + {% if next_url %}Older Posts{% endif %} + +{% endblock %} + diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..94539de --- /dev/null +++ b/backend/config.py @@ -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 + + diff --git a/backend/dbdb.sh b/backend/dbdb.sh new file mode 100755 index 0000000..4c3930b --- /dev/null +++ b/backend/dbdb.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Okay to publish -- creds are local dev only + +mariadb -hdb -uflasku -pflaskp flask diff --git a/backend/microblog.py b/backend/microblog.py index f099917..3bf8aa5 100644 --- a/backend/microblog.py +++ b/backend/microblog.py @@ -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} + diff --git a/backend/migrations/README b/backend/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/backend/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/backend/migrations/alembic.ini b/backend/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/backend/migrations/alembic.ini @@ -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 diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/backend/migrations/env.py @@ -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() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -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"} diff --git a/backend/migrations/versions/4a2c3a72038e_.py b/backend/migrations/versions/4a2c3a72038e_.py new file mode 100644 index 0000000..582776d --- /dev/null +++ b/backend/migrations/versions/4a2c3a72038e_.py @@ -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 ### diff --git a/backend/requirements.txt b/backend/requirements.txt index c1e99d5..aa2e006 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/templates/about.html b/backend/templates/about.html deleted file mode 100644 index 7318c07..0000000 --- a/backend/templates/about.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - -

About

- - - diff --git a/backend/templates/basictemp.html b/backend/templates/basictemp.html deleted file mode 100644 index cbbcf44..0000000 --- a/backend/templates/basictemp.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/backend/templates/home.html b/backend/templates/home.html deleted file mode 100644 index fa70d65..0000000 --- a/backend/templates/home.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - -

Home

- - - diff --git a/backend/tests.py b/backend/tests.py new file mode 100644 index 0000000..305ed03 --- /dev/null +++ b/backend/tests.py @@ -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) + diff --git a/compose.yaml b/compose.yaml index 27b9454..88cff10 100644 --- a/compose.yaml +++ b/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 diff --git a/compose.yaml.local b/compose.yaml.local new file mode 100644 index 0000000..88cff10 --- /dev/null +++ b/compose.yaml.local @@ -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: diff --git a/compose.yaml.prod b/compose.yaml.prod new file mode 100644 index 0000000..98403d8 --- /dev/null +++ b/compose.yaml.prod @@ -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: diff --git a/dotenv b/dotenv index d5122f5..d93dd76 100644 --- a/dotenv +++ b/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" diff --git a/proxy/oldconf b/proxy/baseconf similarity index 64% rename from proxy/oldconf rename to proxy/baseconf index f6d2195..04abcd7 100755 --- a/proxy/oldconf +++ b/proxy/baseconf @@ -4,5 +4,9 @@ server { location / { proxy_pass http://backend:8000; } + location /gutty{ + proxy_pass http://gitea:3000; + } + } diff --git a/proxy/conf b/proxy/conf index 80f6015..fe29397 100755 --- a/proxy/conf +++ b/proxy/conf @@ -1,52 +1,12 @@ -#server { -# listen 80; -# server_name localhost; -# location / { -# proxy_pass http://backend:8000; -# } - - -# always redirect to https server { - listen 80 default_server; - server_name _; - return 301 https://$host$request_uri; + listen 80; + server_name localhost; + location / { + proxy_pass http://backend:8000; + } + location /gutty { + proxy_pass http://gitea:3000; + } + + } - -server { - listen 443 ssl http2; - # use the certificates - ssl_certificate /etc/letsencrypt/live/oily.dad/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/oily.dad/privkey.pem; - server_name oily.dad www.oily.dad; - root /var/www/html; - index index.php index.html index.htm; - - - location / { - proxy_pass http://backend:8000/; - } -} - -server { - listen 443 ssl http2; - # use the certificates - ssl_certificate /etc/letsencrypt/live/oily.dad/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/oily.dad/privkey.pem; - server_name gut.oily.dad; - root /var/www/html; - index index.php index.html index.htm; - - location / { - client_max_body_size 512M; - #proxy_pass http://localhost:3000; - proxy_set_header Connection $http_connection; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_pass http://gitea:3000/; - } -} - diff --git a/proxy/sslconf b/proxy/sslconf new file mode 100755 index 0000000..80f6015 --- /dev/null +++ b/proxy/sslconf @@ -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/; + } +} +