-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit d3f1cbb
Showing
16 changed files
with
525 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
|
Oops, something went wrong.