Initial commit
This commit is contained in:
commit
dc3e0bf03e
29 changed files with 991 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
blog.sqlite3
|
||||
.vscode/
|
4
config.py
Normal file
4
config.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
tCOMMON_URL_INTERNAL = "http://localhost:8888"
|
||||
tCOMMON_TOKEN = "Secret tCommon-specific token"
|
||||
|
||||
DB_PATH = "blog.sqlite3"
|
14
manage.py
Executable file
14
manage.py
Executable file
|
@ -0,0 +1,14 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tblog.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError("Couldn't import Django. Are you sure it's installed and available on your PYTHONPATH environment variable? Did you forget to activate a virtual environment?") from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
requests
|
||||
django
|
0
tblog/__init__.py
Normal file
0
tblog/__init__.py
Normal file
6
tblog/apps.py
Normal file
6
tblog/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DBConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "tblog"
|
6
tblog/asgi.py
Normal file
6
tblog/asgi.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tblog.settings")
|
||||
application = get_asgi_application()
|
36
tblog/migrations/0001_initial.py
Normal file
36
tblog/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
# Generated by Django 5.1.4 on 2024-12-31 23:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='tBUser',
|
||||
fields=[
|
||||
('username', models.CharField(max_length=30, primary_key=True, serialize=False, unique=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='tBPost',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.CharField(max_length=250)),
|
||||
('title', models.TextField(max_length=1000)),
|
||||
('content', models.TextField(max_length=500000)),
|
||||
('timestamp', models.IntegerField()),
|
||||
('text_format', models.CharField(max_length=10)),
|
||||
('u_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='posts', to='tblog.tbuser')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('u_by', 'url')},
|
||||
},
|
||||
),
|
||||
]
|
0
tblog/migrations/__init__.py
Normal file
0
tblog/migrations/__init__.py
Normal file
31
tblog/models.py
Normal file
31
tblog/models.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.contrib import admin as django_admin
|
||||
from django.contrib.admin.exceptions import AlreadyRegistered # type: ignore
|
||||
from django.db import models
|
||||
|
||||
|
||||
class tBUser(models.Model):
|
||||
username = models.CharField(max_length=30, unique=True, primary_key=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
posts = models.Manager["tBPost"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.username
|
||||
|
||||
class tBPost(models.Model):
|
||||
u_by = models.ForeignKey(tBUser, on_delete=models.CASCADE, related_name="posts")
|
||||
url = models.CharField(max_length=250)
|
||||
title = models.TextField(max_length=1_000)
|
||||
content = models.TextField(max_length=500_000)
|
||||
timestamp = models.IntegerField()
|
||||
text_format = models.CharField(max_length=10) # plain, mono, markdown, html ... (more to come?)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("u_by", "url")
|
||||
|
||||
try:
|
||||
django_admin.site.register((tBUser, tBPost))
|
||||
except AlreadyRegistered:
|
||||
...
|
79
tblog/settings.py
Normal file
79
tblog/settings.py
Normal file
|
@ -0,0 +1,79 @@
|
|||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from config import DB_PATH, tCOMMON_TOKEN, tCOMMON_URL_INTERNAL
|
||||
|
||||
config = requests.get(f"{tCOMMON_URL_INTERNAL}/api/initialize/", params={
|
||||
"token": tCOMMON_TOKEN
|
||||
}, allow_redirects=False).json()
|
||||
|
||||
if not config["success"]:
|
||||
raise ImportError("tCommon token doesn't match")
|
||||
|
||||
if not config["services"]["message"]:
|
||||
raise ImportError("tblog isn't registered in tCommon")
|
||||
|
||||
DEBUG = config["debug"]
|
||||
SECRET_KEY = config["services"]["message"]["token"]
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
STATIC_DIR = BASE_DIR / "tblog/static"
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
CSRF_TRUSTED_ORIGINS = [config["services"]["message"]["url"]["pub"]]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"tblog.apps.DBConfig"
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware"
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "tblog.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [
|
||||
BASE_DIR / "tblog/templates"
|
||||
],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "tblog.wsgi.application"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / DB_PATH,
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = []
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
213
tblog/static/css/ace.css
Normal file
213
tblog/static/css/ace.css
Normal file
|
@ -0,0 +1,213 @@
|
|||
.ace_editor {
|
||||
background-color: rgb(var(--crust));
|
||||
color: rgb(var(--text));
|
||||
}
|
||||
|
||||
.ace_editor .ace_gutter,
|
||||
.ace_editor .ace_gutter-cell {
|
||||
background: rgb(var(--mantle));
|
||||
color: rgb(var(--overlay1));
|
||||
}
|
||||
|
||||
.ace_editor .ace_print-margin {
|
||||
background: rgb(var(--mantle));
|
||||
}
|
||||
|
||||
.ace_editor .ace_marker-layer .ace_active-line {
|
||||
background-color: rgb(var(--mantle));
|
||||
}
|
||||
|
||||
.ace_editor .ace_marker-layer .highlight-line-error {
|
||||
background-color: rgba(var(--red), 20%);
|
||||
}
|
||||
|
||||
.ace_editor .ace_marker-layer .ace_bracket {
|
||||
border-color: rgb(var(--overlay1));
|
||||
}
|
||||
|
||||
.ace_editor .ace_doctype {
|
||||
color: rgb(var(--mauve));
|
||||
}
|
||||
|
||||
.ace_editor .ace_cursor,
|
||||
.ace_editor .ace_xml.ace_text {
|
||||
color: rgb(var(--text));
|
||||
}
|
||||
|
||||
.ace_editor .ace_heading.ace_1,
|
||||
.ace_editor .ace_heading.ace_1 + .ace_heading {
|
||||
color: rgb(var(--red));
|
||||
}
|
||||
|
||||
.ace_editor .ace_heading.ace_2,
|
||||
.ace_editor .ace_heading.ace_2 + .ace_heading {
|
||||
color: rgb(var(--peach));
|
||||
}
|
||||
|
||||
.ace_editor .ace_heading.ace_3,
|
||||
.ace_editor .ace_heading.ace_3 + .ace_heading {
|
||||
color: rgb(var(--yellow));
|
||||
}
|
||||
|
||||
.ace_editor .ace_heading.ace_4,
|
||||
.ace_editor .ace_heading.ace_4 + .ace_heading {
|
||||
color: rgb(var(--green));
|
||||
}
|
||||
|
||||
.ace_editor .ace_heading.ace_5,
|
||||
.ace_editor .ace_heading.ace_5 + .ace_heading {
|
||||
color: rgb(var(--blue));
|
||||
}
|
||||
|
||||
.ace_editor .ace_heading.ace_6,
|
||||
.ace_editor .ace_heading.ace_6 + .ace_heading {
|
||||
color: rgb(var(--mauve));
|
||||
}
|
||||
|
||||
.ace_editor .ace_list {
|
||||
color: rgb(var(--text));
|
||||
}
|
||||
|
||||
.ace_editor .ace_list.ace_markup {
|
||||
color: rgb(var(--sky));
|
||||
}
|
||||
|
||||
.ace_editor .ace_marker-layer .ace_selection {
|
||||
background: rgba(var(--accent), 30%);
|
||||
}
|
||||
|
||||
.ace-tm .ace_marker-layer .ace_selected-word {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ace_editor .ace_fold {
|
||||
background-color: rgb(var(--surface0));
|
||||
border-color: rgb(var(--surface1));
|
||||
}
|
||||
|
||||
.ace_editor .ace_constant.ace_language,
|
||||
.ace_editor .ace_keyword,
|
||||
.ace_editor .ace_meta {
|
||||
color: rgb(var(--mauve));
|
||||
}
|
||||
|
||||
.ace_editor .ace_xml,
|
||||
.ace_editor .ace_support.ace_class,
|
||||
.ace_editor .ace_support.ace_type {
|
||||
color: rgb(var(--yellow));
|
||||
}
|
||||
|
||||
.ace_editor .ace_line .ace_identifier:not(:first-of-type),
|
||||
.ace_editor .ace_entity.ace_name.ace_function,
|
||||
.ace_editor .ace_constant {
|
||||
color: rgb(var(--blue));
|
||||
}
|
||||
|
||||
.ace_editor .ace_paren,
|
||||
.ace_editor .ace_variable.ace_language {
|
||||
color: rgb(var(--red));
|
||||
}
|
||||
|
||||
.ace_editor .ace_constant.ace_numeric {
|
||||
color: rgb(var(--peach));
|
||||
}
|
||||
|
||||
.ace_editor .ace_entity.ace_other.ace_attribute-name,
|
||||
.ace_editor .ace_support.ace_constant,
|
||||
.ace_editor .ace_support.ace_function {
|
||||
color: rgb(var(--teal));
|
||||
}
|
||||
|
||||
.ace_editor .ace_entity.ace_name.ace_tag,
|
||||
.ace_editor .ace_variable {
|
||||
color: rgb(var(--blue));
|
||||
}
|
||||
|
||||
.ace_editor .ace_storage {
|
||||
color: rgb(var(--peach));
|
||||
}
|
||||
|
||||
.ace_editor .ace_string {
|
||||
color: rgb(var(--green));
|
||||
}
|
||||
|
||||
.ace_editor .ace_comment {
|
||||
color: rgb(var(--overlay2));
|
||||
}
|
||||
|
||||
.ace_editor .ace_indent-guide {
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg%20width%3D%221%22%20height%3D%222%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20stroke%3D%22rgb%28var%28--surface0%29%29%22%20d%3D%22M0%200v2%22%2F%3E%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
.ace_mobile-menu {
|
||||
background-color: rgb(var(--base));
|
||||
box-shadow: none;
|
||||
border-color: rgb(var(--surface0));
|
||||
color: rgb(var(--text));
|
||||
}
|
||||
|
||||
.ace_tooltip {
|
||||
background-color: rgb(var(--crust));
|
||||
color: rgb(var(--text));
|
||||
border-color: rgb(var(--surface0));
|
||||
}
|
||||
|
||||
#ace_settingsmenu {
|
||||
background-color: rgb(var(--base));
|
||||
box-shadow: none;
|
||||
color: rgb(var(--subtext0));
|
||||
}
|
||||
|
||||
#ace_settingsmenu .ace_optionsMenuEntry {
|
||||
transition: 0;
|
||||
}
|
||||
|
||||
#ace_settingsmenu .ace_optionsMenuEntry:hover {
|
||||
background-color: rgb(var(--mantle));
|
||||
}
|
||||
|
||||
.ace_optionsMenuEntry button,
|
||||
.ace_optionsMenuEntry button[ace_selected_button="true"],
|
||||
.ace_optionsMenuEntry button:hover {
|
||||
background-color: rgb(var(--crust));
|
||||
color: rgb(var(--text));
|
||||
border-color: rgb(var(--surface0));
|
||||
}
|
||||
|
||||
.ace_optionsMenuEntry button[ace_selected_button="true"] {
|
||||
border-color: rgb(var(--accent));
|
||||
}
|
||||
|
||||
.ace_prompt_container {
|
||||
background-color: rgb(var(--surface0));
|
||||
}
|
||||
|
||||
.ace_editor.ace_autocomplete {
|
||||
border-color: rgb(var(--surface0));
|
||||
box-shadow: none;
|
||||
background-color: rgb(var(--base));
|
||||
color: rgb(var(--text));
|
||||
}
|
||||
|
||||
.ace_completion-meta {
|
||||
opacity: 100%;
|
||||
color: rgb(var(--subtext0));
|
||||
}
|
||||
|
||||
.ace_editor.ace_autocomplete .ace_line-hover {
|
||||
border-color: rgb(var(--accent));
|
||||
background-color: rgb(var(--crust));
|
||||
}
|
||||
|
||||
.ace_editor.ace_autocomplete .ace_marker-layer .ace_active-line {
|
||||
background-color: rgb(var(--accent));
|
||||
}
|
||||
|
||||
.ace_editor.ace_autocomplete .ace_line.ace_selected {
|
||||
color: rgb(var(--crust));
|
||||
}
|
||||
|
||||
.ace_editor.ace_autocomplete .ace_line.ace_selected .ace_completion-meta {
|
||||
color: rgb(var(--surface0));
|
||||
}
|
76
tblog/static/css/write.css
Normal file
76
tblog/static/css/write.css
Normal file
|
@ -0,0 +1,76 @@
|
|||
.editor-object {
|
||||
display: inline-block;
|
||||
width: calc(50vw - 10px);
|
||||
height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
#editable-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#bottom-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
#editor-area:not([data-editor="ace"]) #ace-editor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#editor-area:not([data-editor="plain"]) #plain-editor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#ace-editor {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#plain-editor textarea {
|
||||
width: calc(100% - 12px);
|
||||
height: calc(100% - 8px);
|
||||
margin: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
#preview {
|
||||
outline: 1px solid rgb(var(--surface0));
|
||||
overflow: scroll;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
height: calc(100% - 20px);
|
||||
width: calc(100% - 20px);
|
||||
max-height: calc(100% - 20px);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#preview table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
#preview table th,
|
||||
#preview table td {
|
||||
border: 1px solid rgb(var(--surface2));
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
#preview table tr:nth-child(even) {
|
||||
background-color: rgb(var(--mantle));
|
||||
}
|
||||
|
||||
@media (max-width: 100vh) {
|
||||
.editor-object {
|
||||
width: calc(100vw - 20px);
|
||||
height: calc(50vh - 60px);
|
||||
}
|
||||
|
||||
#editable-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
57
tblog/static/js/write.js
Normal file
57
tblog/static/js/write.js
Normal file
|
@ -0,0 +1,57 @@
|
|||
let text = document.querySelector("#plain-editor textarea").value;
|
||||
let format = document.getElementById("format").value;
|
||||
|
||||
const modes = {
|
||||
markdown: "ace/mode/markdown",
|
||||
html: "ace/mode/html",
|
||||
plain: "ace/mode/text",
|
||||
mono: "ace/mode/text"
|
||||
}
|
||||
|
||||
function updatePreview(raw) {
|
||||
document.getElementById("preview").innerHTML = toHTML(format, raw);
|
||||
document.getElementById("character-count").innerText = (new Intl.NumberFormat).format(raw.length);
|
||||
|
||||
if (raw.length > 500_000) {
|
||||
document.getElementById("character-count").classList.add("error");
|
||||
document.getElementById("character-count").classList.remove("warning");
|
||||
} else if (raw.length >= 498_000) {
|
||||
document.getElementById("character-count").classList.remove("error");
|
||||
document.getElementById("character-count").classList.add("warning");
|
||||
} else {
|
||||
document.getElementById("character-count").classList.remove("error");
|
||||
document.getElementById("character-count").classList.remove("warning");
|
||||
}
|
||||
|
||||
text = raw;
|
||||
}
|
||||
|
||||
let editor = createEditor("ace-editor", text, format, (raw) => {
|
||||
updatePreview(raw);
|
||||
document.querySelector("#plain-editor textarea").value = raw;
|
||||
});
|
||||
|
||||
document.querySelector("#plain-editor textarea").addEventListener("input", function() {
|
||||
updatePreview(this.value);
|
||||
});
|
||||
|
||||
document.getElementById("use-plain-editor").addEventListener("input", function() {
|
||||
if (this.checked) {
|
||||
document.getElementById("editor-area").dataset.editor = "plain";
|
||||
} else {
|
||||
document.getElementById("editor-area").dataset.editor = "ace";
|
||||
editor.setValue(text);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("format").addEventListener("change", function() {
|
||||
format = this.value;
|
||||
editor.getSession().setMode(modes[format]);
|
||||
updatePreview(text);
|
||||
});
|
||||
|
||||
updatePreview(text);
|
||||
|
||||
window.onbeforeunload = (event) => {
|
||||
return "Your changes won't be saved.";
|
||||
}
|
9
tblog/templates/404.html
Normal file
9
tblog/templates/404.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Page Not Found - {% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1>Hmm. That doesn't look right.</h1>
|
||||
Make sure the URL is correct and try again.
|
||||
<small>(Error 404 - Page not found)</small>
|
||||
{% endblock %}
|
28
tblog/templates/base.html
Normal file
28
tblog/templates/base.html
Normal file
|
@ -0,0 +1,28 @@
|
|||
<!DOCTYPE html>
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title %}{% if title %}{{ title }} - {% endif %}{% endblock %}tBlog</title>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="pronouns" content="she/her">
|
||||
|
||||
<link id="favicon" rel="icon" href="{{ config.services.common.url.pub }}/favicon/dark/{{ accent }}/">
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
|
||||
<link rel="stylesheet" href="{{ config.services.common.url.pub }}/static/css/base.css?v={{ config.version_str }}">
|
||||
<style>html { --accent: var(--{{ accent }}); }</style>
|
||||
<script src="{{ config.services.common.url.pub }}/static/js/theme.js?v={{ config.version_str }}"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="container">
|
||||
<noscript>Please enable JavaScript!</noscript>
|
||||
{% block body %}
|
||||
Something went horribly wrong! Please contact us so we can fix this.
|
||||
{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
29
tblog/templates/blog.html
Normal file
29
tblog/templates/blog.html
Normal file
|
@ -0,0 +1,29 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
{% include "snippets/blog-imports.html" %}
|
||||
<script src="{{ config.services.common.url.pub }}/static/js/base.js?v={{ config.version_str }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1>{{ blog.title }}</h1>
|
||||
<div><b>By <a href="/blog/{{ blog.u_by.username }}">{{ blog.u_by.username }}</a></b></div>
|
||||
<small data-timestamp="{{ blog.timestamp }}"></small>
|
||||
{% if username == blog.u_by.username %}
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
<input type="checkbox" required id="confirm">
|
||||
<label data-fake-checkbox for="confirm">I understand this is irreversible</label><br>
|
||||
<input type="submit" value="Delete this post">
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
<hr>
|
||||
<div id="blog-container" style="max-width: 1000px; width: 90vw;" class="inline-block left" data-format="{{ blog.text_format }}" data-raw="{{ blog.content }}"><i>Loading...</i></div>
|
||||
|
||||
<script>
|
||||
const blog = document.getElementById("blog-container");
|
||||
blog.innerHTML = toHTML(blog.dataset.format, blog.dataset.raw);
|
||||
</script>
|
||||
{% endblock %}
|
13
tblog/templates/index.html
Normal file
13
tblog/templates/index.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block body %}
|
||||
<h1>Hey there, {{ username }}!</h1>
|
||||
<hr>
|
||||
<p><a href="/blog/{{ username }}/">Your blog posts</a> ({{ blog_count }})</p>
|
||||
<p>Write a <a href="/create/">new blog post</a></p>
|
||||
<hr class="sub">
|
||||
<small>
|
||||
<a href="{{ config.services.auth.url.pub }}/logout/?to=blog">Log out</a> -
|
||||
<a href="{{ config.services.auth.url.pub }}{{ login_token }}">Other services</a>
|
||||
</small>
|
||||
{% endblock %}
|
9
tblog/templates/noauth/index.html
Normal file
9
tblog/templates/noauth/index.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block body %}
|
||||
<h1>tBlog</h1>
|
||||
<div>Write things about... stuff</div>
|
||||
<p>
|
||||
<a href="{{ config.services.auth.url.pub }}/login/?to=blog">Log in</a>
|
||||
</p>
|
||||
{% endblock %}
|
39
tblog/templates/snippets/blog-imports.html
Normal file
39
tblog/templates/snippets/blog-imports.html
Normal file
|
@ -0,0 +1,39 @@
|
|||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||||
{% if editor %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.37.1/ace.min.js"></script>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function toHTML(format, raw) {
|
||||
if (format == "markdown") {
|
||||
return DOMPurify.sanitize(marked.parse(raw));
|
||||
} else if (format == "html") {
|
||||
return DOMPurify.sanitize(raw);
|
||||
} else if (format == "mono") {
|
||||
return `<pre class="no-margin">${raw.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """)}</pre>`;
|
||||
} else {
|
||||
return `<pre class="not-code no-margin">${raw.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """)}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
{% if editor %}
|
||||
function createEditor(element, content, format, onChange) {
|
||||
let editor = ace.edit(element);
|
||||
|
||||
if (format == "markdown") {
|
||||
editor.getSession().setMode("ace/mode/markdown");
|
||||
} else if (format == "html") {
|
||||
editor.getSession().setMode("ace/mode/html");
|
||||
}
|
||||
|
||||
editor.setTheme("ace/theme/xcode");
|
||||
editor.getSession().on("change", function() {
|
||||
onChange(editor.getValue());
|
||||
});
|
||||
editor.setValue(content, 1);
|
||||
|
||||
return editor;
|
||||
}
|
||||
{% endif %}
|
||||
</script>
|
40
tblog/templates/user.html
Normal file
40
tblog/templates/user.html
Normal file
|
@ -0,0 +1,40 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.blog-post {
|
||||
display: inline-block;
|
||||
width: calc(90vw - 20px);
|
||||
max-width: 600px;
|
||||
border: 1px solid rgb(var(--accent));
|
||||
border-radius: 10px;
|
||||
margin-top: 18px;
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
<script src="{{ config.services.common.url.pub }}/static/js/base.js?v={{ config.version_str }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1>{{ username }}'s Blog</h1>
|
||||
<p>
|
||||
{% if self_username %}
|
||||
<a href="/">Your Dashboard</a>
|
||||
{% else %}
|
||||
Nog logged in.
|
||||
{% if config.new_users %}<a href="{{ config.services.auth.url.pub }}/signup/?to=blog">Sign up</a>{% else %}<a href="{{ config.services.auth.url.pub }}/login/?to=blog">Log in</a>{% endif %}?
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% for post in posts %}
|
||||
<hr>
|
||||
<a class="fake-link blog-post" href="/blog/{{ username }}/{{ post.url }}/">
|
||||
<h2 class="no-margin">{{ post.title }}</h2>
|
||||
<small data-timestamp="{{ post.timestamp }}"></small>
|
||||
</a>
|
||||
{% empty %}
|
||||
<hr>
|
||||
<i>No posts yet</i>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
55
tblog/templates/write.html
Normal file
55
tblog/templates/write.html
Normal file
|
@ -0,0 +1,55 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
{% include "snippets/blog-imports.html" with editor=1 %}
|
||||
<script src="{{ config.services.common.url.pub }}/static/js/base.js?v={{ config.version_str }}"></script>
|
||||
<link rel="stylesheet" href="/static/css/ace.css?v={{ config.version_str }}">
|
||||
<link rel="stylesheet" href="/static/css/write.css?v={{ config.version_str }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<form method="POST" onsubmit="window.onbeforeunload = null;">
|
||||
{% csrf_token %}
|
||||
<div id="editable-container">
|
||||
<div id="editor-area" class="editor-object" data-editor="ace">
|
||||
<div id="ace-editor" class="editor-object"></div>
|
||||
<div id="plain-editor" class="editor-object"><textarea required name="raw" maxlength="500000" placeholder="Write here...">{{ repopulate.content }}</textarea></div>
|
||||
</div><div class="editor-object"><div id="preview"></div></div>
|
||||
</div>
|
||||
|
||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
|
||||
<p>Characters: <b><span id="character-count" data-localize-number="0">0</span>/<span data-localize-number="500000">500000</span></b></p>
|
||||
<p><b>Configuration:</b></p>
|
||||
|
||||
<p>
|
||||
<input type="checkbox" id="use-plain-editor">
|
||||
<label data-fake-checkbox for="use-plain-editor">Use alternate editor</label>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label for="format">Format:</label>
|
||||
<select id="format" name="format">
|
||||
<option {% if not repopulate.format or repopulate.format == "markdown" %}selected{% endif %} value="markdown">Markdown</option>
|
||||
<option {% if repopulate.format and repopulate.format == "html" %}selected{% endif %} value="html" >Raw HTML</option>
|
||||
<option {% if repopulate.format and repopulate.format == "plain" %}selected{% endif %} value="plain" >Plain text (sans-serif)</option>
|
||||
<option {% if repopulate.format and repopulate.format == "mono" %}selected{% endif %} value="mono" >Plain text (monospace)</option>
|
||||
</select>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label for="title">Title:</label>
|
||||
<input maxlength="1000" placeholder="Title..." required name="title" id="title" value="{{ repopulate.title }}">
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label for="url">{{ config.services.blog.url.pub }}/blog/{{ username }}/</label
|
||||
><input maxlength="1000" placeholder="blog-url" required name="url" id="url" value="{{ repopulate.url }}">
|
||||
</p>
|
||||
|
||||
<p><input type="submit" value="Create Blog Post"></p>
|
||||
<small>(blog posts can't be changed after posting)</small>
|
||||
</form>
|
||||
|
||||
<script src="/static/js/write.js?v={{ config.version_str }}"></script>
|
||||
{% endblock %}
|
14
tblog/urls.py
Normal file
14
tblog/urls.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
from .views import auth, index, user, view_blog, write
|
||||
|
||||
urlpatterns = [
|
||||
path("", index),
|
||||
path("auth/", auth),
|
||||
path("create/", write),
|
||||
path("blog/<str:username>/", user),
|
||||
path("blog/<str:username>/<str:url>/", view_blog),
|
||||
path("static/", include("tblog.views.static")),
|
||||
path("django-admin/", admin.site.urls)
|
||||
]
|
1
tblog/views/__init__.py
Normal file
1
tblog/views/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .templates import auth, index, user, view_blog, write # noqa: F401
|
2
tblog/views/api.py
Normal file
2
tblog/views/api.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from django.core.handlers.wsgi import WSGIRequest
|
||||
from django.http import HttpResponse
|
67
tblog/views/helper.py
Normal file
67
tblog/views/helper.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
import random
|
||||
from urllib.parse import quote as url_escape
|
||||
|
||||
import requests
|
||||
from django.core.handlers.wsgi import WSGIRequest
|
||||
from django.http import HttpResponse
|
||||
from django.template import loader
|
||||
|
||||
from tblog.models import tBUser
|
||||
from tblog.settings import config
|
||||
|
||||
COLORS = ["rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"]
|
||||
|
||||
def render_template(
|
||||
request: WSGIRequest,
|
||||
template: str,
|
||||
/, *,
|
||||
status: int=200,
|
||||
headers: dict[str, str]={},
|
||||
content_type: str="text/html",
|
||||
**context
|
||||
) -> HttpResponse:
|
||||
c = {
|
||||
"accent": random.choice(COLORS),
|
||||
"config": config,
|
||||
"login_token": f"/auth/?sessionid={request.COOKIES.get('session_id')}"
|
||||
}
|
||||
|
||||
for key, val in context.items():
|
||||
c[key] = val
|
||||
|
||||
resp = HttpResponse(
|
||||
loader.get_template(template).render(c, request),
|
||||
status=status,
|
||||
content_type=content_type
|
||||
)
|
||||
|
||||
for key, val in headers.items():
|
||||
resp[key] = val
|
||||
|
||||
return resp
|
||||
|
||||
def get_username(request: WSGIRequest) -> str | None:
|
||||
resp = requests.get(config["services"]["auth"]["url"]["int"] + f"/api/authenticated/?token={url_escape(config['services']['blog']['token'])}&service=blog", cookies={**request.COOKIES}).json()
|
||||
|
||||
if not resp["success"]:
|
||||
raise Exception("Unable to communicate with tAuth")
|
||||
|
||||
return resp["username"]
|
||||
|
||||
def username_exists(username: str) -> bool:
|
||||
resp = requests.get(config["services"]["auth"]["url"]["int"] + f"/api/username/?token={url_escape(config['services']['blog']['token'])}&service=blog&username={url_escape(username)}").json()
|
||||
|
||||
if not resp["success"]:
|
||||
raise Exception("Unable to communicate with tAuth")
|
||||
|
||||
return resp["exists"]
|
||||
|
||||
def get_user_object(username: str, *, i_promise_this_user_exists: bool=False) -> tBUser:
|
||||
if i_promise_this_user_exists or username_exists(username):
|
||||
try:
|
||||
return tBUser.objects.get(username=username)
|
||||
except tBUser.DoesNotExist:
|
||||
return tBUser.objects.create(username=username)
|
||||
|
||||
else:
|
||||
raise tBUser.DoesNotExist(f"tAuth doesn't know who {username} is")
|
27
tblog/views/static.py
Normal file
27
tblog/views/static.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
from django.http import HttpResponse
|
||||
from django.urls import path
|
||||
from django.views.decorators.cache import cache_control
|
||||
|
||||
from tblog.settings import STATIC_DIR
|
||||
|
||||
|
||||
def get_static_serve(path: str, content_type: str):
|
||||
def x(request):
|
||||
return HttpResponse(
|
||||
open(STATIC_DIR / path, "rb").read(),
|
||||
content_type=content_type
|
||||
)
|
||||
|
||||
x.__name__ = path
|
||||
return x
|
||||
|
||||
file_associations = {
|
||||
"css": "text/css",
|
||||
"js": "text/javascript"
|
||||
}
|
||||
|
||||
urlpatterns = [path(i, cache_control(**{"max-age": 60 * 60 * 24 * 30})(get_static_serve(i, file_associations[i.split(".")[-1]]))) for i in [
|
||||
"css/ace.css",
|
||||
"css/write.css",
|
||||
"js/write.js"
|
||||
]]
|
125
tblog/views/templates.py
Normal file
125
tblog/views/templates.py
Normal file
|
@ -0,0 +1,125 @@
|
|||
import math
|
||||
import time
|
||||
from urllib.parse import quote as escape_url
|
||||
|
||||
from django.core.handlers.wsgi import WSGIRequest
|
||||
from django.db import IntegrityError
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
|
||||
from tblog.models import tBPost
|
||||
|
||||
from .helper import get_user_object, get_username, render_template
|
||||
|
||||
|
||||
def auth(request: WSGIRequest) -> HttpResponseRedirect:
|
||||
resp = HttpResponseRedirect("/")
|
||||
if "remove" in request.GET:
|
||||
resp.set_cookie("session_id", "", max_age=0)
|
||||
else:
|
||||
resp.set_cookie("session_id", request.GET.get("sessionid") or "")
|
||||
|
||||
return resp
|
||||
|
||||
def index(request: WSGIRequest) -> HttpResponse:
|
||||
username = get_username(request)
|
||||
|
||||
if username:
|
||||
user = get_user_object(username, i_promise_this_user_exists=True)
|
||||
|
||||
return render_template(
|
||||
request, "index.html",
|
||||
username=username,
|
||||
blog_count=user.posts.count() # type: ignore
|
||||
)
|
||||
|
||||
return render_template(
|
||||
request, "noauth/index.html"
|
||||
)
|
||||
|
||||
def write(request: WSGIRequest) -> HttpResponse:
|
||||
username = get_username(request)
|
||||
if username:
|
||||
repopulate = {}
|
||||
error = None
|
||||
|
||||
if request.method == "POST":
|
||||
url = (request.POST.get("url") or "").strip().replace(" ", "-").lower()
|
||||
title = (request.POST.get("title") or "").strip()
|
||||
content = (request.POST.get("raw") or "").strip()
|
||||
fmt = request.POST.get("format")
|
||||
|
||||
repopulate = {
|
||||
"url": url,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"format": fmt
|
||||
}
|
||||
|
||||
if not (url and title and content) or fmt not in ["plain", "mono", "markdown", "html"] or len(url) > 250 or len(title) > 1_000 or len(content) > 500_000:
|
||||
error = "Invalid input(s)"
|
||||
|
||||
else:
|
||||
try:
|
||||
tBPost.objects.create(
|
||||
u_by=get_user_object(username, i_promise_this_user_exists=True),
|
||||
url=request.POST.get("url"),
|
||||
title=request.POST.get("title"),
|
||||
content=request.POST.get("raw"),
|
||||
timestamp=math.floor(time.time()),
|
||||
text_format=request.POST.get("format")
|
||||
)
|
||||
except IntegrityError:
|
||||
error = f"Url '{url}' already in use"
|
||||
else:
|
||||
return HttpResponseRedirect(f"/blog/{username}/{escape_url(url)}/")
|
||||
|
||||
return render_template(
|
||||
request, "write.html",
|
||||
title="Writing",
|
||||
username=username,
|
||||
error=error,
|
||||
repopulate=repopulate
|
||||
)
|
||||
|
||||
return render_template(
|
||||
request, "noauth/index.html"
|
||||
)
|
||||
|
||||
def view_blog(request: WSGIRequest, username: str, url: str) -> HttpResponse:
|
||||
try:
|
||||
blog = tBPost.objects.get(
|
||||
u_by=get_user_object(username),
|
||||
url=url
|
||||
)
|
||||
except tBPost.DoesNotExist:
|
||||
return render_template(
|
||||
request, "404.html"
|
||||
)
|
||||
|
||||
self_username = get_username(request)
|
||||
|
||||
if request.method == "POST" and username == self_username:
|
||||
blog.delete()
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
return render_template(
|
||||
request, "blog.html",
|
||||
blog=blog,
|
||||
title=blog.title + " by " + blog.u_by.username,
|
||||
username=self_username
|
||||
)
|
||||
|
||||
def user(request: WSGIRequest, username: str) -> HttpResponse:
|
||||
user = get_user_object(username)
|
||||
|
||||
if user:
|
||||
return render_template(
|
||||
request, "user.html",
|
||||
username=user.username,
|
||||
self_username=get_username(request),
|
||||
posts=user.posts.all() # type: ignore
|
||||
)
|
||||
|
||||
return render_template(
|
||||
request, "404.html"
|
||||
)
|
6
tblog/wsgi.py
Normal file
6
tblog/wsgi.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tblog.settings")
|
||||
application = get_wsgi_application()
|
Loading…
Reference in a new issue