Skip to content

Commit

Permalink
New git repo for version 2
Browse files Browse the repository at this point in the history
  • Loading branch information
vas3k committed Nov 26, 2015
0 parents commit d3f1cbb
Show file tree
Hide file tree
Showing 16 changed files with 525 additions and 0 deletions.
102 changes: 102 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Created by .ignore support plugin (hsz.mobi)
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

# C extensions
*.so

# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg

# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.cache
nosetests.xml
coverage.xml

# Translations
*.mo
*.pot

# Django stuff:
*.log

# Sphinx documentation
docs/_build/

# PyBuilder
target/


### JetBrains template
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm

*.iml

## Directory-based project format:
.idea

## File-based project format:
*.ipr
*.iws

## Plugin-specific files:

# IntelliJ
out/

# mpeltonen/sbt-idea plugin
.idea_modules/

# JIRA plugin
atlassian-ide-plugin.xml

# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties

### VirtualEnv template
# Virtualenv
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
.Python
[Bb]in
[Ii]nclude
[Ll]ib
[Ss]cripts
pyvenv.cfg
pip-selfcheck.json

.DS_Store
._.DS_Store


13 changes: 13 additions & 0 deletions README
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
i.vas3k.ru -- небольший скрипт на Python для загрузки изображений на сервер, созданный специально для твиттер-клиента tweetbot (но вроде как некоторые другие тоже умеют custom image uploading service). Использует Flask для http, PIL для работы с изображениями, PostgreSQL для хранения метаданных и nginx X-Accel-Redirect для генерации превью по любому заданном URL'у. Чисто из спортивного интереса располагается в одном файле (app.py). Ну почти.

Вы можете развернуть этот скрипт у себя на сервере и пользоваться так же как и я. У меня он развернут на поддомене i.vas3k.ru, откуда и получил такое название. Широко используется в моем блоге vas3k.ru для вывода отмасштабированных под размер блока изображений. Для загрузки фото через tweetbot в моем случае нужно указать URL: http://i.vas3k.ru/upload/, файл с именем "image" передается в POST, а так же заголовки для Twitter oAuth Echo. После загрузки возвращается { "url": "http://i.vas3k.ru/32p.jpg" }

Так же есть возможность загружать файлы через простой веб-интерфейс, который доступен при заходе в корень.

Иногда эта фигня кажется слишком простой и банальной, и меня порывает всё "правильно" переписать. Но она отлично работает, делает свое дело и за годы ни разу не сбоила. Значит не надо лезть со своим идеализмом и овердизайном сюда!

- http://i.vas3k.ru/32p.jpg — «каноническая» ссылка на файл. Чтобы не жрать трафик twitter-клиентов, является уменьшеной до 1200px по длинной стороне копией файла;
- http://i.vas3k.ru/full/32p.jpg — возвращает оригинал загруженной фотографии;
- http://i.vas3k.ru/500/32p.jpg — изменение размера до 500px по длинной стороне. Min = 50, max = 2000;
- http://i.vas3k.ru/width/500/32p.jpg — изменение размера до 500px по ширине. Этот и следующий URL сделаны для удобства встраивания на сайты, например, в таймлайны;
- http://i.vas3k.ru/square/500/32p.jpg — кроп до квадрата в центре изображение со стороной равной короткой стороне изображения. После кропа квадрат ресайзится до 500px.
187 changes: 187 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import io
import sys
import logging
from mimetypes import guess_type

from PIL import Image
from flask import Flask, redirect, request, render_template, Response
import psycopg2
import psycopg2.extras

from helpers import *
from settings import *

app = Flask(__name__)
app.debug = True

log = logging.getLogger(__name__)
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)


def x_accel_response(filepath):
# nginx'овый internal redirect
# очень магическая фигня, которая отдает статику nginx'ом, а не python'ом
# описание тут: http://kovyrin.net/2006/11/01/nginx-x-accel-redirect-php-rails/
redirect_response = Response(mimetype=guess_type(filepath)[0])
redirect_response.headers["X-Accel-Redirect"] = filepath
return redirect_response


@app.route("/")
def index():
return render_template("index.html")


@app.route("/upload/", methods=["POST"])
def upload():
image_file = request.files.get("media") or request.files.get("image")
data = request.form.get("media") or request.form.get("image")
if image_file:
image_extension = image_file.filename[image_file.filename.rfind(".") + 1:].lower()
data = image_file.read()
elif data:
data, image_extension = convert_param_to_data(data)
else:
return "No image"

if image_extension not in ALLOWED_EXTENSIONS:
return "%s is not allowed" % image_extension

db = psycopg2.connect(PSYCOPG_CONNECTION_STRING)
cursor = db.cursor(cursor_factory=psycopg2.extras.DictCursor)
cursor.execute("insert into images values (default, null, now()) returning id")
file_id = cursor.fetchone()[0]

image_code = base36_encode(file_id)
image_path = os.path.join(FULL_IMAGE_FILE_PATH, file_path("{}.{}".format(image_code, image_extension)))

image_dir = image_path[:image_path.rfind("/") + 1]
if not os.path.exists(image_dir):
os.makedirs(image_dir, mode=0o777)

try:
image_file = io.BytesIO(data)
image = Image.open(image_file)
except IOError as ex:
cursor.execute("delete from images where id = %s", [file_id])
db.commit()
cursor.close()
return "Image upload error (maybe not image?): {}".format(ex)

image_width = float(image.size[0])
image_height = float(image.size[1])
orig_save_size = get_fit_image_size(image_width, image_height, ORIGINAL_MAX_IMAGE_LENGTH)
image.thumbnail(orig_save_size, Image.ANTIALIAS)

try:
image = apply_rotation_by_exif(image)
except (IOError, KeyError, AttributeError) as ex:
log.error("Auto-rotation error: {}".format(ex))

image.save(image_path, quality=IMAGE_QUALITY)
image_name = "{}.{}".format(image_code, image_extension)
cursor.execute("update images set image = %s, file = %s where id = %s", [image_name, image_path, file_id])
db.commit()
cursor.close()
db.close()

nojson = request.form.get("nojson")
if nojson:
return redirect("{}/{}".format(BASE_URI, image_name))
else:
return '{"url": "{base_uri}/{image_name}", "name": "{image_name}"}'.format(
base_uri=BASE_URI,
image_name=image_name
)


@app.route("/<filename>", methods=["GET"])
def common_image(filename):
useragent = request.headers.get('User-Agent')
if "Facebot" in useragent or "Twitterbot" in useragent:
return render_template("meta.html", image="{}/800/{}".format(BASE_URI, filename))
return fit_image(COMMON_IMAGE_LENGTH, filename)


@app.route("/full/<filename>", methods=["GET"])
def full_image(filename):
return x_accel_response("/images/max/{}".format(file_path(filename)))


@app.route("/<int(min=50,max=2000):max_length>/<filename>", methods=["GET"])
def fit_image(max_length, filename):
ok_filepath = file_path(filename)
filepath = os.path.join(IMAGES_FILE_PATH, "resize/{}/{}".format(max_length, ok_filepath))
if not os.path.exists(filepath):
try:
image = Image.open(os.path.join(FULL_IMAGE_FILE_PATH, ok_filepath))
except IOError:
return "Not found", 404

image_dir = filepath[:filepath.rfind("/") + 1]
if not os.path.exists(image_dir):
os.makedirs(image_dir, mode=0o777)

image_width = float(image.size[0])
image_height = float(image.size[1])
if image_width > max_length or image_height > max_length:
new_width, new_height = get_fit_image_size(image_width, image_height, max_length)
image.thumbnail((new_width, new_height), Image.ANTIALIAS)
image.save(filepath, quality=IMAGE_QUALITY)

return x_accel_response("/images/resize/{}/{}".format(max_length, ok_filepath))


@app.route("/square/<int(min=50,max=2000):max_length>/<filename>", methods=["GET"])
def square_image(max_length, filename):
ok_filepath = file_path(filename)
filepath = os.path.join(IMAGES_FILE_PATH, "square/{}/{}".format(max_length, ok_filepath))
if not os.path.exists(filepath):
try:
image = Image.open(os.path.join(FULL_IMAGE_FILE_PATH, ok_filepath))
except IOError:
return "Not found", 404

image_dir = filepath[:filepath.rfind("/") + 1]
if not os.path.exists(image_dir):
os.makedirs(image_dir, mode=0o777)

image_width = float(image.size[0])
image_height = float(image.size[1])
image_square = int(min(image_width, image_height))
crop_coordinates_x = int(image_width / 2 - image_square / 2)
crop_coordinates_y = int(image_height / 2 - image_square / 2)
image = image.crop((crop_coordinates_x, crop_coordinates_y, crop_coordinates_x + image_square,
crop_coordinates_y + image_square))
image.thumbnail((max_length, max_length), Image.ANTIALIAS)
image.save(filepath, quality=IMAGE_QUALITY)

return x_accel_response("/images/square/{}/{}".format(max_length, ok_filepath))


@app.route("/width/<int(min=50,max=2000):max_length>/<filename>", methods=["GET"])
def width_image(max_length, filename):
ok_filepath = file_path(filename)
filepath = os.path.join(IMAGES_FILE_PATH, "width/{}/{}".format(max_length, ok_filepath))
if not os.path.exists(filepath):
try:
image = Image.open(os.path.join(FULL_IMAGE_FILE_PATH, ok_filepath))
except IOError:
return "Not found", 404

image_dir = filepath[:filepath.rfind("/") + 1]
if not os.path.exists(image_dir):
os.makedirs(image_dir, mode=0o777)

image_width = float(image.size[0])
image_height = float(image.size[1])
new_width = int(max_length)
new_height = int(new_width / image_width * image_height)
image.thumbnail((new_width, new_height), Image.ANTIALIAS)
image.save(filepath, quality=IMAGE_QUALITY)

return x_accel_response("/images/width/{}/{}".format(max_length, ok_filepath))


if __name__ == '__main__':
app.run()
29 changes: 29 additions & 0 deletions etc/nginx/i.vas3k.ru.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
upstream i_vas3k_ru_uwsgi {
server unix:/tmp/i.vas3k.ru.sock weight=1 max_fails=5 fail_timeout=30s;
}

server {
listen 8080;
server_name i.vas3k.ru;
charset utf-8;
client_max_body_size 100M;
uwsgi_buffers 128 16k;
real_ip_header X-Real-IP;

rewrite ^/favicon.ico$ http://vas3k.ru/static/images/favicon.ico;

location ~ ^/images/ {
root /home/vas3k/i.vas3k.ru;
gzip_static on;
access_log off;
expires max;
add_header Cache-Control "public";
internal;
break;
}

location / {
uwsgi_pass i_vas3k_ru_uwsgi;
include uwsgi_params;
}
}
17 changes: 17 additions & 0 deletions etc/uwsgi/i.vas3k.ru.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<uwsgi>
<plugins>python3,http</plugins>
<socket>/tmp/i.vas3k.ru.sock</socket>
<chdir>/home/vas3k/i.vas3k.ru</chdir>
<module>app</module>
<callable>app</callable>
<daemonize>/home/vas3k/i.vas3k.ru/logs/i.vas3k.ru.log</daemonize>
<harakiri>30</harakiri>
<buffer-size>32768</buffer-size>
<post-buffering>8192</post-buffering>
<post-buffering-bufsize>65536</post-buffering-bufsize>
<max-requests>1000</max-requests>
<master/>
<workers>5</workers>
<threads>10</threads>
</uwsgi>

Loading

0 comments on commit d3f1cbb

Please sign in to comment.