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