summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsyn <isaqtm@gmail.com>2020-03-16 22:17:48 +0300
committersyn <isaqtm@gmail.com>2020-03-16 22:17:48 +0300
commit977c7c38ff4196f43c4784308d81fd7e6a471511 (patch)
tree3e53843c181a60ec7d2b3b1d4dfdf4868859c3a7
downloadblure-977c7c38ff4196f43c4784308d81fd7e6a471511.tar.gz
Init commit
-rw-r--r--app/__init__.py38
-rw-r--r--app/imutil.py61
-rw-r--r--app/in_log.py57
-rw-r--r--app/request_routine.py26
-rw-r--r--app/schema.py37
-rw-r--r--app/static/css/blure.css127
-rw-r--r--app/static/css/eternal.css81
-rw-r--r--app/static/js/eternalload.js120
-rw-r--r--app/static/js/load.js6
-rw-r--r--app/static/js/push.js74
-rw-r--r--app/static/sass/blure.sass119
-rw-r--r--app/static/sass/eternal.sass70
-rw-r--r--app/templates/base.html.j218
-rw-r--r--app/templates/index.html.j221
-rw-r--r--app/templates/profile.html.j26
-rw-r--r--app/util.py66
-rw-r--r--app/views.py83
-rw-r--r--config.py23
-rw-r--r--deploy/Dockerfile.base12
-rw-r--r--deploy/Dockerfile.dev19
-rw-r--r--deploy/docker-compose.yml50
-rw-r--r--deploy/nginx.conf36
-rw-r--r--not-found.jpgbin0 -> 12365 bytes
-rw-r--r--run.py5
24 files changed, 1155 insertions, 0 deletions
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..3fb828f
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,38 @@
+from sanic import Sanic
+import config
+from asyncpg import create_pool
+import jinja2_sanic
+from jinja2 import FileSystemLoader
+import logging
+from .in_log import LOG_SETTINGS
+from .util import URLCoder
+from .schema import schema_up
+
+blure = Sanic(__name__, log_config=LOG_SETTINGS)
+log = logging.getLogger('blure')
+
+jinja2_sanic.setup(blure, loader=FileSystemLoader('app/templates'))
+blure.config.from_object(config)
+blure.static('/static', './app/static')
+blure.url = URLCoder(blure.config.APP_SECRET[::2]) # cut secret, because it is too long
+
+
+@blure.listener('before_server_start')
+async def setup_db(_app, loop):
+ _app.pool = await create_pool(_app.config.PG_URI, loop=loop)
+ async with _app.pool.acquire() as conn:
+ await create_tables(conn)
+
+
+@blure.listener('after_server_stop')
+async def close_db_conns(_app, loop):
+ await _app.pool.close()
+
+
+from . import views # noqa # unused-import, E402
+
+
+async def create_tables(conn):
+ async with conn.transaction():
+ for statement in schema_up:
+ await conn.execute(statement)
diff --git a/app/imutil.py b/app/imutil.py
new file mode 100644
index 0000000..ec16dd1
--- /dev/null
+++ b/app/imutil.py
@@ -0,0 +1,61 @@
+from PIL import Image
+from app import blure
+from sanic.response import raw
+from pathlib import Path
+from io import BytesIO
+
+_IMAGE_URL = blure.config.NGX_IMAGE_URL
+_IMAGE_PATH = blure.config.NGX_IMAGE_PATH
+_NOT_FOUND_IMAGE = blure.config.NOT_FOUND_IMAGE
+_NOT_FOUND_IMAGE_CONTENT_TYPE = blure.config.NOT_FOUND_IMAGE_CONTENT_TYPE
+
+
+class NGXImage:
+ def __init__(self, id: int):
+ self.filename = blure.url.to_url(id)
+
+ @staticmethod
+ def _load_pic(filename):
+ p = Path(_IMAGE_PATH.format(filename))
+ if not p.is_file():
+ return NGXImage.not_found()
+
+ return raw(b'',
+ content_type='image',
+ headers={'X-Accel-Redirect': _IMAGE_URL.format(filename)},
+ status=200)
+
+ def orig(self):
+ return self._load_pic(self.filename)
+
+ def thumb(self):
+ return self._load_pic(self.filename + '_thumb')
+
+ @staticmethod
+ def not_found():
+ return raw(_NOT_FOUND_IMAGE,
+ content_type=_NOT_FOUND_IMAGE_CONTENT_TYPE,
+ status=404)
+
+ def save(self, body: BytesIO):
+ image_path = Path(_IMAGE_PATH.format(self.filename))
+ thumb_path = Path(_IMAGE_PATH.format(self.filename + '_thumb'))
+
+ with image_path.open('wb') as f:
+ f.write(body.getvalue())
+
+ with thumb_path.open('wb') as f:
+ im = Image.open(body)
+ thumb_stream = BytesIO()
+ im.thumbnail(blure.config.CUT_SIZES[2])
+ im.save(thumb_stream, format='JPEG')
+ f.write(thumb_stream.getvalue())
+
+ def delete_from_disk(self):
+ image_path = Path(_IMAGE_PATH.format(self.filename))
+ thumb_path = Path(_IMAGE_PATH.format(self.filename + '_thumb'))
+
+ if image_path.exists():
+ image_path.unlink()
+ if thumb_path.exists():
+ thumb_path.unlink()
diff --git a/app/in_log.py b/app/in_log.py
new file mode 100644
index 0000000..77c85d6
--- /dev/null
+++ b/app/in_log.py
@@ -0,0 +1,57 @@
+time_with_col = '\033[96m%(asctime)s\033[0m '
+
+LOG_SETTINGS = {
+ 'version': 1,
+ 'disable_existing_loggers': False,
+ 'handlers': {
+ 'default': {
+ 'class': 'logging.StreamHandler',
+ 'level': 'DEBUG',
+ 'formatter': 'default',
+ },
+ 'access_log': {
+ 'class': 'logging.StreamHandler',
+ 'level': 'DEBUG',
+ 'formatter': 'access'
+ },
+ 'blure': {
+ 'class': 'logging.StreamHandler',
+ 'level': 'DEBUG',
+ 'formatter': 'default',
+ 'filters': []
+ },
+ },
+ 'filters': {},
+ 'formatters': {
+ 'default': {
+ 'format': time_with_col + '%(levelname)-7s %(name)-12s | %(message)s',
+ 'datefmt': '%Y-%m-%d %H:%M:%S %z',
+ },
+ 'access': {
+ 'format': time_with_col + 'ACCESS [%(host)s] \033[94m%(status)d\033[0m <- %(request)s',
+ 'datefmt': '%Y-%m-%d %H:%M:%S %z',
+ },
+ },
+ 'loggers': {
+ 'sanic.access': {
+ 'level': 'DEBUG',
+ 'handlers': ['access_log'],
+ 'propagate': False
+ },
+ 'sanic.error': {
+ 'level': 'DEBUG',
+ 'handlers': ['default'],
+ 'propagate': True
+ },
+ 'sanic.root': {
+ 'level': 'DEBUG',
+ 'handlers': ['default'],
+ 'propagate': True
+ },
+ 'blure': {
+ 'level': 'DEBUG',
+ 'handlers': ['blure'],
+ 'propagate': True
+ }
+ }
+}
diff --git a/app/request_routine.py b/app/request_routine.py
new file mode 100644
index 0000000..821272f
--- /dev/null
+++ b/app/request_routine.py
@@ -0,0 +1,26 @@
+from functools import wraps
+from collections import namedtuple
+from app import blure as app
+
+Context = namedtuple('Context', [
+ 'r', # request
+ 'pg', # pg connection
+ 'app' # r.app
+])
+
+
+def db_route(route, pool=True, **route_kwargs):
+ def proxy_wrapper(f):
+ @app.route(route, **route_kwargs)
+ @wraps(f)
+ async def wrapper(request, *args, **kwargs):
+ if not pool:
+ ctx = Context(r=request, pg=None, app=request.app)
+ response = await f(ctx, *args, **kwargs)
+ else:
+ async with app.pool.acquire() as conn:
+ ctx = Context(r=request, pg=conn, app=request.app)
+ response = await f(ctx, *args, **kwargs)
+ return response
+ return wrapper
+ return proxy_wrapper
diff --git a/app/schema.py b/app/schema.py
new file mode 100644
index 0000000..247638f
--- /dev/null
+++ b/app/schema.py
@@ -0,0 +1,37 @@
+schema_up = [
+ '''
+ CREATE TABLE IF NOT EXISTS pics (
+ id SERIAL PRIMARY KEY UNIQUE,
+ src_url VARCHAR(1024),
+ ext VARCHAR(10),
+ deleted BOOLEAN
+ );
+ ''',
+ '''
+ CREATE TABLE IF NOT EXISTS tags (
+ id SERIAL NOT NULL PRIMARY KEY,
+ tag_name VARCHAR(128)
+ );
+ ''',
+ '''
+ CREATE TABLE IF NOT EXISTS tags_pics (
+ pic_id INT NOT NULL,
+ tag_id INT NOT NULL,
+ PRIMARY KEY (pic_id, tag_id),
+ FOREIGN KEY (pic_id) REFERENCES pics(id) ON UPDATE CASCADE,
+ FOREIGN KEY (tag_id) REFERENCES tags(id) ON UPDATE CASCADE
+ );
+ '''
+]
+
+schema_down = [
+ 'DROP TABLE tags_pics',
+ 'DROP TABLE tags',
+ 'DROP TABLE pics'
+]
+
+schema_delete = [
+ 'DELETE * FROM tags_pics'
+ 'DELETE * FROM tags'
+ 'DELETE * FROM pics'
+]
diff --git a/app/static/css/blure.css b/app/static/css/blure.css
new file mode 100644
index 0000000..acc1aab
--- /dev/null
+++ b/app/static/css/blure.css
@@ -0,0 +1,127 @@
+body {
+ background-color: #182028 !important;
+}
+
+#root {
+ font-family: "Inconsolata";
+ font-size: 18px;
+ margin: auto;
+ max-width: 800px;
+ padding: 0 1em;
+ text-align: justify;
+ color: #eee;
+}
+#root img {
+ width: 100%;
+ margin-bottom: 0.5rem;
+}
+
+a {
+ color: #846;
+ text-decoration: none;
+}
+
+a:hover {
+ box-shadow: inset 0 0px 0 white, inset 0 -1px 0 #456;
+}
+
+.profile-description {
+ padding: 0.5rem;
+ white-space: pre;
+}
+
+.btn {
+ background: #283038;
+ color: #f6c;
+ border: 0px;
+ border-radius: 2px;
+ padding: 0.2em 0.4em;
+ font-size: 1em;
+ font-family: "Inconsolata";
+ cursor: pointer;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 800;
+}
+
+.progress-bg {
+ background: none;
+ filter: blur(3px);
+ -moz-filter: blur(3px);
+ -webkit-filter: blur(3px);
+ position: absolute;
+ width: 100px;
+ height: 100px;
+}
+
+.progress-svg {
+ animation: progress-rotate 2s linear infinite;
+ height: 100px;
+ width: 100px;
+ position: relative;
+}
+
+.progress-path {
+ stroke-dasharray: 0, calc(20 * 7);
+ stroke-linecap: round;
+ stroke: #888a;
+ -moz-transition: stroke-dasharray 0.3s ease;
+ -webkit-transition: stroke-dasharray 0.3s ease;
+ transition: stroke-dasharray 0.3s ease;
+}
+
+@keyframes progress-rotate {
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+.progress-wrapper {
+ display: inline-block;
+}
+
+#push-url {
+ position: relative;
+ display: inline;
+}
+
+#push-url-input {
+ min-width: 40%;
+ color: white;
+ background-color: transparent;
+ border: 1px solid transparent;
+ border-bottom-color: rgba(252, 49, 113, 0.2);
+}
+
+#push-url-input:focus {
+ outline: none;
+}
+
+#push-url-input::placeholder {
+ color: rgba(255, 255, 255, 0.3);
+}
+
+#push-url-span {
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ width: 100%;
+ height: 1px;
+ opacity: 0;
+ background-color: #fc2f70;
+ transform-origin: center;
+ transform: translate(-50%, 0) scaleX(0);
+ transition: all 0.3s ease;
+}
+
+#push-url-input:focus ~ span {
+ transform: translate(-50%, 0) scaleX(1);
+ opacity: 1;
+}
+
+.progress-status {
+ width: 100%;
+ text-align: center;
+ font-size: 0.9em;
+}
+
+/*# sourceMappingURL=blure.css.map */
diff --git a/app/static/css/eternal.css b/app/static/css/eternal.css
new file mode 100644
index 0000000..e2c5895
--- /dev/null
+++ b/app/static/css/eternal.css
@@ -0,0 +1,81 @@
+.eternal-progress-bg {
+ background: none;
+ filter: blur(3px);
+ -moz-filter: blur(3px);
+ -webkit-filter: blur(3px);
+ position: absolute;
+ width: 100px;
+ height: 100px;
+}
+
+.eternal-progress-svg {
+ animation: progress-rotate 2s linear infinite;
+ height: 100px;
+ width: 100px;
+ position: relative;
+}
+
+.eternal-progress-path {
+ stroke-dasharray: 0, calc(20 * 7);
+ stroke-linecap: round;
+ stroke: #888a;
+ -moz-transition: stroke-dasharray 0.3s ease;
+ -webkit-transition: stroke-dasharray 0.3s ease;
+ transition: stroke-dasharray 0.3s ease;
+}
+
+@keyframes progress-rotate {
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+.eternal-progress-wrapper {
+ display: inline-block;
+}
+
+.eternal-progress-status {
+ width: 100%;
+ text-align: center;
+ font-size: 0.9em;
+}
+
+.eternal-url {
+ position: relative;
+ display: inline;
+}
+
+.eternal-url-input {
+ min-width: 40%;
+ color: white;
+ background-color: transparent;
+ border: 1px solid transparent;
+ border-bottom-color: rgba(252, 49, 113, 0.2);
+}
+
+.eternal-url-input:focus {
+ outline: none;
+}
+
+.eternal-url-input::placeholder {
+ color: rgba(255, 255, 255, 0.3);
+}
+
+.eternal-url-input ~ span {
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ width: 100%;
+ height: 1px;
+ opacity: 0;
+ background-color: #fc2f70;
+ transform-origin: center;
+ transform: translate(-50%, 0) scaleX(0);
+ transition: all 0.3s ease;
+}
+
+.eternal-url-input:focus ~ span {
+ transform: translate(-50%, 0) scaleX(1);
+ opacity: 1;
+}
+
+/*# sourceMappingURL=eternal.css.map */
diff --git a/app/static/js/eternalload.js b/app/static/js/eternalload.js
new file mode 100644
index 0000000..fe23c98
--- /dev/null
+++ b/app/static/js/eternalload.js
@@ -0,0 +1,120 @@
+const progress_width = 100;
+const progress_radius = 20;
+const progress_len = progress_radius * 2 * Math.PI;
+
+const PREVIEW_TEMPLATE = `
+<div class="eternal-progress-wrapper">
+ <div class="eternal-progress-bg"></div>
+ <svg class="eternal-progress-svg">
+ <circle class="eternal-progress-path"
+ cx="${progress_width / 2}"
+ cy="${progress_width / 2}"
+ r="${progress_radius}"
+ fill="none"
+ stroke-width="5"
+ stroke-miterlimit="10" />
+ </svg>
+ <pre class="eternal-progress-status"></pre>
+</div>
+`
+
+const REMOTE_LOAD_TEMPLATE = `
+<form method='POST' autocomplete="off">
+ <input class="eternal-url-input" type="text" placeholder="Remote image">
+ <span class="eternal-url-span"></span>
+</form>
+`.trim();
+
+function append_template_for_file (node, file, fileno) {
+ const imageurl = URL.createObjectURL(file);
+ node.insertAdjacentHTML('beforeend', PREVIEW_TEMPLATE);
+ let newnode = node.lastChild.previousSibling;
+ console.log(newnode);
+ let style = newnode.querySelector('.eternal-progress-bg').style;
+ style.background = 'url(' + imageurl + ')';
+ style.backgroundSize = 'cover';
+ style.backgroundPosition = 'center';
+ style.backgroundRepeat = 'no-repeat';
+ newnode.setAttribute('id', 'progress-files-' + fileno.toString());
+
+ return newnode;
+}
+
+class EternalLoad {
+ constructor (node) {
+ this.root = document.createElement('div');
+ this.root.setAttribute('id', 'eternal-root');
+ this.local = {};
+ this.remote = {};
+ }
+
+ attach (target) {
+ if (this.local.btn)
+ this.root.appendChild(this.local.btn);
+ target.appendChild(this.root);
+
+ return this;
+ }
+
+ use_local (url) {
+ this.local.btn = document.createElement('button');
+ this.local.btn.setAttribute('class', 'btn');
+ this.local.btn.textContent = 'local file';
+
+ this.local.total = 0;
+ this.local.processed = 0;
+ this.local.page_hard_lock = 1;
+
+ this.local.btn.addEventListener('click', (e) => {
+ let input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+ input.multiple = true;
+ input.addEventListener('change', (input_event) => {
+ Array.prototype.forEach.call(input_event.target.files, file => {
+ let data = new FormData();
+ data.append('im', file);
+
+ let fileno = this.local.total;
+ this.local.total += 1;
+ let wrapper = append_template_for_file (this.root, file, fileno);
+ let progress = wrapper.querySelector('.eternal-progress-path');
+ let status = wrapper.querySelector('.eternal-progress-status');
+
+ let req = new XMLHttpRequest();
+ req.addEventListener('load', (e) => {
+ let bg = wrapper.querySelector('.eternal-progress-bg');
+ bg.style.filter = 'none';
+ progress.style.stroke = '#0000';
+
+ this.local.processed += 1;
+ if (this.local.processed == this.local.total && !this.local.page_hard_lock)
+ location.reload();
+ });
+ req.upload.addEventListener('progress', (e) => {
+ let complete = 0;
+ if (e.lengthComputable)
+ complete = e.loaded / e.total;
+ status.innerHTML = `${(e.loaded / 1000000).toFixed(2)}/${(e.total / 1000000).toFixed(2)}M`;
+ progress.style.strokeDasharray = `${(complete * Math.PI * 2 * progress_radius)},${progress_len}`;
+
+ });
+
+ req.open('POST', url);
+ req.send(data);
+ });
+ this.local.page_hard_lock = 0;
+ });
+
+ input.click();
+ });
+
+ return this;
+ }
+
+ use_remote (url) {
+ return this;
+ }
+}
+
+export default EternalLoad;
diff --git a/app/static/js/load.js b/app/static/js/load.js
new file mode 100644
index 0000000..574841b
--- /dev/null
+++ b/app/static/js/load.js
@@ -0,0 +1,6 @@
+import EternalLoad from './eternalload.js'
+
+let load = new EternalLoad()
+ .use_local('/c/push')
+ .use_remote('/c/push_url')
+ .attach(document.querySelector('#template-goes-here'));
diff --git a/app/static/js/push.js b/app/static/js/push.js
new file mode 100644
index 0000000..e6916c8
--- /dev/null
+++ b/app/static/js/push.js
@@ -0,0 +1,74 @@
+const progress_radius = 20;
+const progress_len = progress_radius * 2 * Math.PI;
+
+window.addEventListener('DOMContentLoaded', function() {
+ let pusher = document.getElementById('push-image-btn');
+
+ let total = 0;
+ let processed = 0;
+ let page_hard_lock = 1;
+
+ pusher.addEventListener('click', function(e) {
+ let input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+ input.multiple = true;
+ input.addEventListener('change', (input_event) => {
+ console.log(input_event.target.files);
+ Array.prototype.forEach.call(input_event.target.files, file => {
+ let data = new FormData();
+ data.append('im', file);
+
+ let fileno = total;
+ total += 1;
+ let wrapper = append_template_for_file (file, fileno);
+ let progress = wrapper.querySelector('.progress-path');
+ let status = wrapper.querySelector('.progress-status');
+
+ let req = new XMLHttpRequest();
+ req.addEventListener('load', (e) => {
+ let bg = wrapper.querySelector('.progress-bg');
+ bg.style.filter = 'none';
+ progress.style.stroke = '#0000';
+
+ processed += 1;
+ if (processed == total && !page_hard_lock) {
+ location.reload();
+ }
+ });
+ req.upload.addEventListener('progress', (e) => {
+ let complete = 0;
+ if (e.lengthComputable) {
+ complete = e.loaded / e.total;
+ }
+ status.innerHTML = (e.loaded / 1000000).toFixed(2) + '/' + (e.total / 1000000).toFixed(2) + 'M';
+ progress.style.strokeDasharray = (complete * Math.PI * 2 * progress_radius).toString () + ',' + progress_len.toString();
+
+ });
+
+ req.open('POST', '/c/push');
+ req.send(data);
+ });
+ page_hard_lock = 0;
+ });
+
+ input.click();
+ });
+});
+
+function append_template_for_file (file, fileno) {
+ let imageurl = URL.createObjectURL(file);
+ let tmpl = document.querySelector('#progress-template');
+ let root = document.querySelector('#template-goes-here');
+ let newnode = document.importNode(tmpl.content, true);
+ let style = newnode.querySelector('.progress-bg').style;
+ style.background = 'url(' + imageurl + ')';
+ style.backgroundSize = 'cover';
+ style.backgroundPosition = 'center';
+ style.backgroundRepeat = 'no-repeat';
+ root.appendChild(newnode);
+ let appended = root.lastChild.previousSibling;
+ appended.setAttribute('id', 'progress-files-' + fileno.toString());
+
+ return appended;
+}
diff --git a/app/static/sass/blure.sass b/app/static/sass/blure.sass
new file mode 100644
index 0000000..e472fb7
--- /dev/null
+++ b/app/static/sass/blure.sass
@@ -0,0 +1,119 @@
+$sidenav-width: 200px
+$primary-fg: #fff
+$primary-bg: #182028
+$pink: #f6c
+
+$preview-size: 100px
+$progress-radius: 20
+
+$monospace: 'Inconsolata'
+
+body
+ background-color: $primary-bg !important
+
+#root
+ font-family: $monospace
+ font-size: 18px
+ margin: auto
+ max-width: 800px
+ padding: 0 1em
+ text-align: justify
+ color: #eee
+
+ img
+ width: 100%
+ margin-bottom: .5rem
+
+a
+ color: #846
+ text-decoration: none
+
+a:hover
+ box-shadow: inset 0 0px 0 white, inset 0 -1px 0 #456
+ //border-bottom: 1px solid black
+
+.profile-description
+ padding: .5rem
+ white-space: pre
+
+.btn
+ background: #283038
+ color: $pink
+ border: 0px
+ border-radius: 2px
+ padding: .2em .4em
+ font-size: 1em
+ font-family: $monospace
+ cursor: pointer
+ text-transform: uppercase
+ letter-spacing: .05em
+ font-weight: 800
+
+.progress-bg
+ background: none
+ filter: blur(3px)
+ -moz-filter: blur(3px)
+ -webkit-filter: blur(3px)
+ position: absolute
+ width: $preview-size
+ height: $preview-size
+
+.progress-svg
+ animation: progress-rotate 2s linear infinite
+ height: $preview-size
+ width: $preview-size
+ position: relative
+
+
+.progress-path
+ stroke-dasharray: 0, calc(#{$progress-radius} * 7)
+ stroke-linecap: round
+ stroke: #888a
+ -moz-transition: stroke-dasharray 0.3s ease
+ -webkit-transition: stroke-dasharray 0.3s ease
+ transition: stroke-dasharray 0.3s ease
+
+@keyframes progress-rotate
+ 100%
+ transform: rotate(360deg)
+
+.progress-wrapper
+ display: inline-block
+
+#push-url
+ position: relative
+ display: inline
+
+#push-url-input
+ min-width: 40%
+ color: white
+ background-color: transparent
+ border: 1px solid transparent
+ border-bottom-color: hsla(341, 97%, 59%, 0.2)
+
+#push-url-input:focus
+ outline: none
+
+#push-url-input::placeholder
+ color: hsla(0, 0%, 100%, 0.3)
+
+#push-url-span
+ position: absolute
+ bottom: 0
+ left: 50%
+ width: 100%
+ height: 1px
+ opacity: 0
+ background-color: #fc2f70
+ transform-origin: center
+ transform: translate(-50%, 0) scaleX(0)
+ transition: all 0.3s ease
+
+#push-url-input:focus ~ span
+ transform: translate(-50%, 0) scaleX(1)
+ opacity: 1
+
+.progress-status
+ width: 100%
+ text-align: center
+ font-size: 0.9em \ No newline at end of file
diff --git a/app/static/sass/eternal.sass b/app/static/sass/eternal.sass
new file mode 100644
index 0000000..e6e1b56
--- /dev/null
+++ b/app/static/sass/eternal.sass
@@ -0,0 +1,70 @@
+$preview-size: 100px
+$progress-radius: 20
+
+.eternal-progress-bg
+ background: none
+ filter: blur(3px)
+ -moz-filter: blur(3px)
+ -webkit-filter: blur(3px)
+ position: absolute
+ width: $preview-size
+ height: $preview-size
+
+.eternal-progress-svg
+ animation: progress-rotate 2s linear infinite
+ height: $preview-size
+ width: $preview-size
+ position: relative
+
+.eternal-progress-path
+ stroke-dasharray: 0, calc(#{$progress-radius} * 7)
+ stroke-linecap: round
+ stroke: #888a
+ -moz-transition: stroke-dasharray 0.3s ease
+ -webkit-transition: stroke-dasharray 0.3s ease
+ transition: stroke-dasharray 0.3s ease
+
+@keyframes progress-rotate
+ 100%
+ transform: rotate(360deg)
+
+.eternal-progress-wrapper
+ display: inline-block
+
+.eternal-progress-status
+ width: 100%
+ text-align: center
+ font-size: 0.9em
+
+.eternal-url
+ position: relative
+ display: inline
+
+.eternal-url-input
+ min-width: 40%
+ color: white
+ background-color: transparent
+ border: 1px solid transparent
+ border-bottom-color: hsla(341, 97%, 59%, 0.2)
+
+.eternal-url-input:focus
+ outline: none
+
+.eternal-url-input::placeholder
+ color: hsla(0, 0%, 100%, 0.3)
+
+.eternal-url-input ~ span
+ position: absolute
+ bottom: 0
+ left: 50%
+ width: 100%
+ height: 1px
+ opacity: 0
+ background-color: #fc2f70
+ transform-origin: center
+ transform: translate(-50%, 0) scaleX(0)
+ transition: all 0.3s ease
+
+.eternal-url-input:focus ~ span
+ transform: translate(-50%, 0) scaleX(1)
+ opacity: 1
diff --git a/app/templates/base.html.j2 b/app/templates/base.html.j2
new file mode 100644
index 0000000..b2c0957
--- /dev/null
+++ b/app/templates/base.html.j2
@@ -0,0 +1,18 @@
+<html>
+ <head>
+ <title>
+ Eternal Blume
+ </title>
+ <link href="/static/css/blure.css" rel="stylesheet">
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons|Inconsolata" rel="stylesheet">
+ {% block load_scripts %}
+ {% endblock %}
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ </head>
+ <body>
+ <div id="root">
+ {% block content %}
+ {% endblock %}
+ </div>
+ </body>
+</html>
diff --git a/app/templates/index.html.j2 b/app/templates/index.html.j2
new file mode 100644
index 0000000..f7d9746
--- /dev/null
+++ b/app/templates/index.html.j2
@@ -0,0 +1,21 @@
+{% extends "base.html.j2" %}
+
+{% block load_scripts %}
+<script type="module" src="/static/js/eternalload.js"></script>
+<script type="module" src="/static/js/load.js"></script>
+<link href="/static/css/eternal.css" rel="stylesheet">
+{% endblock %}
+
+{% block content %}
+
+<div id="template-goes-here">
+</div>
+
+{% for url in picurls %}
+ <a href="/p/{{ url }}">
+ <img src="/t/{{ url }}" alt="{{ url }}" /></a>
+ <form action="/c/delete/{{ url }}" method="POST">
+ <button class="btn" type="submit">rm</button>
+ </form>
+{% endfor %}
+{% endblock %}
diff --git a/app/templates/profile.html.j2 b/app/templates/profile.html.j2
new file mode 100644
index 0000000..d4da6c4
--- /dev/null
+++ b/app/templates/profile.html.j2
@@ -0,0 +1,6 @@
+{% extends "base.html.j2" %}
+{% block content %}
+<img src="/i/{{ url }}" />
+<div class="profile-description">url: <a href="/i/{{ url }}">{{ '/i/' + url }}</a>
+date: 1986-04-26T21:23+0000</div>
+{% endblock %} \ No newline at end of file
diff --git a/app/util.py b/app/util.py
new file mode 100644
index 0000000..69b3ea7
--- /dev/null
+++ b/app/util.py
@@ -0,0 +1,66 @@
+from base64 import b32encode, b32decode
+import logging
+from hashlib import blake2b # because it is fast as fuck
+
+log = logging.getLogger('blure')
+
+
+class URLCoder:
+ '''
+ URLCoder shuffles sequential ids into non-sequential strings,
+ that can be used as urls
+ '''
+ _pad = 1
+ _bs = 2
+ _bound = 2 ** (8 * _bs * 2)
+ _endian = 'big'
+
+ @classmethod
+ def _humanify(self, data: bytes):
+ b32 = b32encode(data).decode('ascii')
+ if self._pad > 0:
+ return b32[:-self._pad]
+ else:
+ return b32
+
+ @classmethod
+ def _dehumanify(self, data: str):
+ binary = b32decode(data + '=' * self._pad)
+ assert len(binary) == self._bs * 2
+ return binary
+
+ def __init__(self, secret: bytes):
+ assert len(secret) % (self._bs) == 0
+
+ self.keys = [
+ secret[key_start:key_start + self._bs]
+ for key_start in range(0, len(secret), self._bs)
+ ]
+ self.reverse_keys = self.keys[::-1]
+
+ def to_url(self, id: int) -> str:
+ assert 0 <= id and id <= self._bound
+ as_bytes = int.to_bytes(id, self._bs * 2, self._endian)
+ encoded = self._blake_enc(as_bytes, self.keys)
+ return self._humanify(encoded)
+
+ def to_id(self, url: str) -> int:
+ binary = self._dehumanify(url.upper())
+ as_bytes = self._blake_enc(binary, self.reverse_keys)
+ return int.from_bytes(as_bytes, self._endian)
+
+ def _blake_enc(self, data: bytes, keys: bytes):
+ bs = self._bs
+
+ def xor(bytes1, bytes2):
+ return bytes([b1 ^ b2 for b1, b2 in zip(bytes1, bytes2)])
+
+ def blake_round(data, key):
+ return blake2b(data, digest_size=bs, key=key).digest()
+
+ left = data[:bs]
+ right = data[bs:]
+ for key in keys:
+ left, right = right, xor(left, blake_round(right, key))
+
+ return right + left
diff --git a/app/views.py b/app/views.py
new file mode 100644
index 0000000..8ffb946
--- /dev/null
+++ b/app/views.py
@@ -0,0 +1,83 @@
+from io import BytesIO
+from .request_routine import db_route
+from sanic.response import text, redirect, json
+from jinja2_sanic import render_template
+from app import log
+from requests import get as fetch_url
+from .imutil import NGXImage
+
+
+@db_route('/')
+async def index(ctx):
+ records = await ctx.pg.fetch('SELECT id FROM pics')
+ picurls = [ctx.app.url.to_url(rec['id']) for rec in records]
+ return render_template('index.html.j2', ctx.r, dict(picurls=picurls))
+
+
+@db_route('/i/<url>')
+async def raw_image(ctx, url):
+ id = ctx.app.url.to_id(url)
+ if id is None:
+ return NGXImage.not_found()
+
+ name = await ctx.pg.fetchval('SELECT 1 FROM pics WHERE id=$1', id)
+ if name is None:
+ log.error('Image not in db')
+ return NGXImage.not_found()
+
+ return NGXImage(id).orig()
+
+
+@db_route('/t/<url>')
+async def thumb_image(ctx, url):
+ id = ctx.app.url.to_id(url)
+ if id is None:
+ return NGXImage.not_found()
+
+ name = await ctx.pg.fetchval('SELECT 1 FROM pics WHERE id=$1', id)
+ if name is None:
+ log.error('Image not in db')
+ return NGXImage.not_found()
+
+ return NGXImage(id).thumb()
+
+
+@db_route('/p/<url>')
+async def pic_profile(ctx, url):
+ return render_template('profile.html.j2', ctx.r, dict(url=url, tags=[]))
+
+
+@db_route('/c/push', methods=['POST'])
+async def pic_push(ctx):
+ try:
+ file = ctx.r.files['im'][0]
+ image_stream = BytesIO(file.body)
+ ext = file.name.split('.')[-1]
+ id = await ctx.pg.fetchval(
+ 'INSERT INTO pics(src_url, ext) VALUES ($1, $2) RETURNING id', '', ext
+ )
+
+ im = NGXImage(id)
+ im.save(image_stream)
+
+ return text('new url is ' + ctx.app.url.to_url(id))
+ except KeyError:
+ return text('you did not post anything')
+
+
+@db_route('/c/push_url', methods=['POST'])
+async def push_url(ctx):
+ return redirect(ctx.app.url_for('index'))
+ return json({'err': 'not_implemented'})
+ response = fetch_url(ctx.r.json['url'])
+ if response is None:
+ return json({'err': 'fetch'})
+
+
+@db_route('/c/delete/<url>', methods=['POST'])
+async def delete_pic(ctx, url):
+ id = ctx.app.url.to_id(url)
+ await ctx.pg.execute('DELETE FROM pics WHERE id=$1', id)
+ im = NGXImage(id)
+ im.delete_from_disk()
+ return redirect(ctx.app.url_for('index'))
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..ef895ba
--- /dev/null
+++ b/config.py
@@ -0,0 +1,23 @@
+from uuid import UUID
+
+host = '0.0.0.0'
+port = 80
+debug = True
+
+PG_URI = 'postgres://postgres:secure@pg'
+
+APP_SECRET = UUID('8036587d-11ea-4c59-af9b-9da52eded1bc').bytes
+
+NGX_IMAGE_PATH = '/var/ngx_img/{}'
+NGX_IMAGE_URL = '/ngx_img/{}'
+CUT_SIZES = [
+ (256, 256),
+ (512, 512),
+ (1024, 1024),
+ (2048, 2048),
+]
+DELETE_FILE_ON_DELETE = False
+
+NOT_FOUND_IMAGE_CONTENT_TYPE = 'image/jpeg'
+with open('not-found.jpg', 'rb') as f:
+ NOT_FOUND_IMAGE = f.read()
diff --git a/deploy/Dockerfile.base b/deploy/Dockerfile.base
new file mode 100644
index 0000000..3df944d
--- /dev/null
+++ b/deploy/Dockerfile.base
@@ -0,0 +1,12 @@
+FROM python:3-alpine
+
+RUN apk add gcc g++ musl-dev make bash python3-dev zlib-dev jpeg-dev
+
+# pip installations are slow, so separate them to cache
+RUN pip3 install sanic
+RUN pip3 install jinja2-sanic
+RUN pip3 install requests
+RUN pip3 install asyncpg
+RUN pip3 install Pillow
+
+CMD bash
diff --git a/deploy/Dockerfile.dev b/deploy/Dockerfile.dev
new file mode 100644
index 0000000..7fa7af0
--- /dev/null
+++ b/deploy/Dockerfile.dev
@@ -0,0 +1,19 @@
+FROM python:3-alpine
+
+RUN apk add gcc g++ musl-dev make bash python3-dev zlib-dev jpeg-dev
+
+# pip installations are slow, so separate them to cache
+RUN pip3 install sanic
+RUN pip3 install jinja2-sanic
+RUN pip3 install requests
+RUN pip3 install asyncpg
+RUN pip3 install Pillow
+
+
+RUN mkdir /usr/src/app
+
+WORKDIR /usr/src/app
+
+EXPOSE 80
+
+CMD ["python", "run.py"]
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
new file mode 100644
index 0000000..8774b81
--- /dev/null
+++ b/deploy/docker-compose.yml
@@ -0,0 +1,50 @@
+version: '3'
+services:
+ app:
+ container_name: app
+ build:
+ context: ./..
+ dockerfile: deploy/Dockerfile.dev
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-postgres}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
+ networks:
+ - ntwrk
+ depends_on:
+ - pg
+ - ngx
+ volumes:
+ - ./..:/usr/src/app
+ - ngx_img:/var/ngx_img
+ cap_add:
+ - SYS_PTRACE
+
+ pg:
+ container_name: pg
+ image: postgres:alpine
+ volumes:
+ - db_data:/var/lib/postgresql/data
+ networks:
+ - ntwrk
+ ports:
+ - 5432:5432
+ restart: always
+
+ ngx:
+ container_name: ngx
+ image: nginx:alpine
+ networks:
+ - ntwrk
+ ports:
+ - 4000:80
+ restart: always
+ volumes:
+ - ./nginx.conf:/etc/nginx/nginx.conf
+ - ngx_img:/var/ngx_img
+
+networks:
+ ntwrk:
+
+volumes:
+ db_data:
+ ngx_img:
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
new file mode 100644
index 0000000..8befc43
--- /dev/null
+++ b/deploy/nginx.conf
@@ -0,0 +1,36 @@
+worker_processes 1;
+load_module modules/ngx_http_image_filter_module.so;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ sendfile on;
+ keepalive_timeout 65;
+
+ server {
+ client_max_body_size 200M; # FIXME
+ listen 80;
+ server_name localhost;
+
+ image_filter_buffer 100M;
+
+ location / {
+ proxy_pass http://app;
+ proxy_redirect off;
+
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+
+ add_header Cache-Control "no-cache, must-revalidate";
+ }
+
+ location /ngx_img/ {
+ internal;
+ root /var/;
+ # image_filter resize 512 512;
+ }
+ }
+}
diff --git a/not-found.jpg b/not-found.jpg
new file mode 100644
index 0000000..8a76df9
--- /dev/null
+++ b/not-found.jpg
Binary files differ
diff --git a/run.py b/run.py
new file mode 100644
index 0000000..07b966f
--- /dev/null
+++ b/run.py
@@ -0,0 +1,5 @@
+from app import blure
+from config import host, port, debug
+
+if __name__ == '__main__':
+ blure.go_fast(host=host, port=port, debug=debug)