Compare commits

..

No commits in common. "python3" and "patches" have entirely different histories.

33 changed files with 1170 additions and 1723 deletions

7
.gitignore vendored
View File

@ -1,8 +1 @@
*.swp *.swp
deployment-config.mk
scanner/venv
scanner/floatapp/app.cfg
*.pyc
*.min.css
*.min.js
.vscode

205
README.md
View File

@ -1,61 +1,48 @@
# subPhotoFloat A [Photofloat](https://git.zx2c4.com/PhotoFloat/) fork # PhotoFloat
## Web Photo Gallery via Static JSON & Dynamic Javascript with a Python Backend ### A Web 2.0 Photo Gallery Done Right via Static JSON & Dynamic Javascript
#### by Jason A. Donenfeld (<Jason@zx2c4.com>)
by Jason A. Donenfeld (<Jason@zx2c4.com>) ![Screenshot](http://data.zx2c4.com/photo-float-small.jpg)
with some changes by Markus Pawlata (<markus@derdritte.net>) and other [collaborators](https://git.jocke.no/photofloat/log/?h=patches).
![Screenshot](https://photos.derdritte.net/img/screenshot.png) PhotoFloat is an open source web photo gallery aimed at sleekness and speed. It keeps with an old hat mentality, preferring to work over directory structures rather than esoteric photo database management software. Everything it generates is static, which means it's extremely fast.
subPhotoFloat is an open source web photo gallery aimed at sleekness and speed. It keeps with a minimalist philosophy, preferring to work over directory structures rather than bloated photo database management software (there are good options available for that, if you want it). Everything it generates is static, which means it's extremely fast. [Check out a demo!](http://photos.jasondonenfeld.com/#santa_fe_and_telluride_8.19.10-8.27.10/western_202.jpg)
[Check out a demo!](https://photos.derdritte.net/#!/random)
## How It Works ## How It Works
subPhotoFloat consists of two main segments a Python script and a JavaScript application. PhotoFloat consists of two segments a Python script and a JavaScript application.
The Python script scans a directory tree of images, whereby each directory constitutes an album. It then populates a second folder, known as the cache folder with statically generated JSON files and thumbnails. The scanner extracts metadata from EXIF tags in JPEG photos. subPhotoFloat is smart about file and directory modification time, so you are free to run the scanner script as many times as you want, and it will be very fast if there are few or zero changes since the last time you ran it. The Python script scans a directory tree of images, whereby each directory constitutes an album. It then populates a second folder, known as the cache folder with statically generated JSON files and thumbnails. The scanner extracts metadata from EXIF tags in JPEG photos. PhotoFloat is smart about file and directory modification time, so you are free to run the scanner script as many times as you want, and it will be very fast if there are few or zero changes since the last time you ran it.
Also a part of the Python script is a small flask webapp which allows you to have authentication for certain albums/images and can start the scanner.
The JavaScript application consists of a single `index.html` file with a single `scripts.min.js` and a single `styles.min.css`. It fetches the statically generated JSON files and thumbnails on the fly from the `cache` folder to create a speedy interface. Features include: The JavaScript application consists of a single `index.html` file with a single `scripts.min.js` and a single `styles.min.css`. It fetches the statically generated JSON files and thumbnails on the fly from the `cache` folder to create a speedy interface. Features include:
* Animations to make the interface feel nice
* Separate album view and photo view * Separate album view and photo view
* Album metadata pre-fetching * Album metadata pre-fetching
* Photo pre-loading * Photo pre-loading
* Recursive async randomized tree walking album thumbnail algorithm
* Smooth up and down scaling * Smooth up and down scaling
* Mouse-wheel support * Mouse-wheel support
* Swipe support (on mobile)
* Metadata display * Metadata display
* Consistent hash url format * Consistant hash url format
* Linkable states via ajax urls * Linkable states via ajax urls
* Static rendering for googlebot conforming to the AJAX crawling spec.
* Facebook meta tags for thumbnail and post type
* Link to original images (can be turned off) * Link to original images (can be turned off)
* Optional Google Analytics integration * Optional Google Analytics integration
* Optional server-side authentication support * Optional server-side authentication support
* A thousand other tweaks here and there... * A thousand other tweaks here and there...
It is, essentially, a very slick and fast, fairly minimal but still well-featured photo gallery app. It is, essentially, the slickest and fastest, most minimal but still well-featured photo gallery app on the net.
## Dependencies
* python >= 2.6
* pillow >= 5.3.0
* nginx (or any webserver, really)
### Optional
* flask >= 0.11 (for authentication)
* flask-login >= 0.4.1 (for authentication)
* virtualenv (this is nice, [believe me](https://docs.python-guide.org/dev/virtualenvs/#lower-level-virtualenv))
* ffmpeg (for video conversion)
## Installation ## Installation
### Download the source code from the git repository #### Download the source code from the git repository:
$ git clone https://derdritte.net/gitea/markus/photofloat $ git clone git://git.zx2c4.com/PhotoFloat
$ cd photofloat $ cd PhotoFloat
### Change or delete the Google Analytics ID tracker #### Change or delete the Google Analytics ID tracker:
To delete: To delete:
@ -67,18 +54,18 @@ To change:
Modify the part that says UA-XXXXXX-X and put your own in there. Modify the part that says UA-XXXXXX-X and put your own in there.
### Tweak the index.html page to have a custom title or copyright notice #### Tweak the index.html page to have a custom title or copyright notice.
$ vim web/index.html $ vim web/index.html
### Build the web page #### Build the web page.
This simply runs all the javascript through Google Closure Compiler and all the CSS through YUI Compressor to minify and concatenate everything. Be sure you have java installed. This simply runs all the javascript through Google Closure Compiler and all the CSS through YUI Compressor to minify and concatenate everything. Be sure you have java installed.
$ cd web $ cd web
$ make $ make
### Generate the albums #### Generate the albums:
Now that we're in the web directory, let's make a folder for cache and a folder for the pictures: Now that we're in the web directory, let's make a folder for cache and a folder for the pictures:
@ -92,149 +79,89 @@ When you're done, fill albums with photos and directories of photos. You can als
After it finishes, you will be all set. Simply have your web server serve pages out of your web directory. You may want to do the scanning step in a cronjob, if you don't use the deployment makefiles mentioned below. After it finishes, you will be all set. Simply have your web server serve pages out of your web directory. You may want to do the scanning step in a cronjob, if you don't use the deployment makefiles mentioned below.
### Nginx configuration for static-only ## Optional: Server-side Authentication
Please keep in mind this will not provide any kind of access-restrictions.
server {
listen 80;
server_name photos.jasondonenfeld.com;
location / {
index index.html;
root /var/www/htdocs/photos.jasondonenfeld.com;
}
}
Now after deploying `/var/www/htdocs/photos.jasondonenfeld.com` should contain:
* index.html
* js/
* css/
* img/
* albums/
* cache/
You can easily manage that by creating a folder structure and copying relevant files over:
$ cd ..
photofloat ~ $ mkdir <deployment-folder>/js <deployment-folder>/css
$ cp web/js/scripts.min.js <deployment-folder>/js
$ cp web/css/styles.min.css <deployment-folder>/css
$ cp -a fonts img index.html <deployment-folder>/
For easy updates `albums` and `cache` can be set to also live in \<deployment-folder>, this is especially recommended if you are using the optional flask app mentioned in the following section.
Do **not** keep any of your config or python-files where the webserver can read or write to, the `deployment-config.mk` is most sensitive. If you only want the static html/json & javascript application you are done now!
## Optional: Server-side Authentication using flask
The JavaScript application uses a very simple API to determine if a photo can be viewed or not. If a JSON file returns error `403`, the album is hidden from view. To authenticate, `POST` a username and a password to `/auth`. If unsuccessful, `403` is returned. If successful, `200` is returned, and the previously denied json files may now be requested. If an unauthorized album is directly requested in a URL when the page loads, an authentication box is shown. The JavaScript application uses a very simple API to determine if a photo can be viewed or not. If a JSON file returns error `403`, the album is hidden from view. To authenticate, `POST` a username and a password to `/auth`. If unsuccessful, `403` is returned. If successful, `200` is returned, and the previously denied json files may now be requested. If an unauthorized album is directly requested in a URL when the page loads, an authentication box is shown.
subPhotoFloat ships with an optional server side component called FloatApp to facilitate this, which lives in `scanner/floatapp`. It is a simple Flask-based Python web application. PhotoFloat ships with an optional server side component called FloatApp to faciliate this, which lives in `scanner/floatapp`. It is a simple Flask-based Python web application.
### Installation #### Edit the app.cfg configuration file:
We need to install flask and other dependencies, ideally in a virtualenv, as this will keep your system-wide python installation clean and you can easily install more packages or different versions. $ cd scanner/floatapp
$ vim app.cfg
$ cd scanner Give this file a correct username and password, for both an admin user and a photo user, as well as a secret token. The admin user is allowed to call `/scan`, which automatically runs the scanner script mentioned in the previous section.
$ virtualenv venv
$ source venv/bin/activate
$ pip install -r requirements.txt
### Edit the app.cfg configuration file #### Decide which albums or photos are protected:
$ vim floatapp/app.cfg $ vim auth.txt
Give this file a correct username and password for an admin, as well as a secret token. The admin user is allowed to call `/scan`, which automatically runs the scanner script mentioned in the previous section. This file takes one path per line. It restricts access to all photos in this path. If the path is a single photo, then that single photo is restricted.
In `app.cfg` you may also add elements to the `PERMISSION_MAP` as follows. The dictionary takes any path (to either an album or an image) and restricts any album or image matching that path to the listed tokens. #### Configure nginx:
FloatApp makes use of `X-Accel-Buffering` and `X-Accel-Redirect` to force the server-side component to have minimal overhead. Here is an example nginx configuration that can be tweaked:
PERMISSION_MAP = {
'private': ['thisisatoken'],
'alsoprivate/butonlythis.jpg': ['morethan', 'onetoken'],
}
Tokens can contain anything you wish and you can add as many paths and tokes as you require. One match in the `PERMISSION_MAP` will allow access even if another rule would forbid it. The admin is allowed to see any album or image.
### Configure nginx
FloatApp makes use of `X-Accel-Buffering` and [X-Accel-Redirect](https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/) to force the server-side component to have minimal overhead when serving images via flask. Here is an example nginx configuration that can be tweaked:
server { server {
listen 80; listen 80;
server_name photos.jasondonenfeld.com; server_name photos.jasondonenfeld.com;
location / { location / {
index index.html; index index.html;
root /var/www/htdocs/photos.jasondonenfeld.com; root /var/www/htdocs/photos.jasondonenfeld.com;
} }
include uwsgi_params; include uwsgi_params;
location /albums/ { location /albums/ {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket; uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
} }
location /cache/ { location /cache/ {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket; uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
} }
location /scan { location /scan {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket; uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
} }
location /auth { location /auth {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket; uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
} }
location /photos { location /photos {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket; uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
} }
location /internal-cache/ { location /internal-cache/ {
internal; internal;
alias /var/www/uwsgi/photofloat/cache/; alias /var/www/uwsgi/photofloat/cache/;
} }
location /internal-albums/ { location /internal-albums/ {
internal; internal;
alias /var/www/uwsgi/photofloat/albums/; alias /var/www/uwsgi/photofloat/albums/;
} }
} }
Note that the `internal-*` paths must match that of `app.cfg`, since the flask app will redirect the "external" `/albums` and `/cache` paths to internal ones set in that config. Note that the `internal-*` paths must match that of `app.cfg`. This makes use of uwsgi for execution:
### Configure uwsgi (or use an alternate wsgi-provider) metheny ~ # cat /etc/uwsgi.d/photofloat.ini
$ cat /etc/uwsgi.d/photofloat.ini
[uwsgi] [uwsgi]
chdir = /var/www/uwsgi/%n chdir = /var/www/uwsgi/%n
master = true master = true
uid = %n uid = %n
gid = %n gid = %n
chmod-socket = 660 chmod-socket = 660
chown-socket = nginx:nginx chown-socket = %n:nginx
socket = /var/run/uwsgi-apps/%n.socket socket = /var/run/uwsgi-apps/%n.socket
logto = /var/log/uwsgi/%n.log logto = /var/log/uwsgi/%n.log
virtualenv = /var/www/uwsgi/photofloat/scanner/venv
processes = 4 processes = 4
idle = 1800 idle = 1800
die-on-idle = true die-on-idle = true
plugins = python27 plugins = python27
module = floatapp:app module = floatapp:app
Change the paths for chdir, socket, logto and virtualenv to your preference.
Naturally, you can use any of the options available to [deploy](http://flask.pocoo.org/docs/1.0/deploying/#self-hosted-options) the flask app.
## Optional: Deployment Makefiles ## Optional: Deployment Makefiles
Both the scanner and the webpage have a `make deploy` target, and the scanner has a `make scan` target, to automatically deploy assets to a remote server and run the scanner. For use, customize `deployment-config.mk` in the root of the project, and carefully read the `Makefile`s to learn what's happening. Both the scanner and the webpage have a `make deploy` target, and the scanner has a `make scan` target, to automatically deploy assets to a remote server and run the scanner. For use, customize `deployment-config.mk` in the root of the project, and carefully read the `Makefile`s to learn what's happening.
Be aware: you will very likely have to adapt the deploy-instructions to what you have deployed on your server.
If you are using the flask-app you will most likely not need the Makefiles.
## Mailing List & Suggestions ## Mailing List & Suggestions
If you have any suggestions, feel free to contact the subPhotoFloat community via [our mailing list](http://lists.zx2c4.com/mailman/listinfo/photofloat). We're open to adding all sorts of features and working on integration points with other pieces of software. If you have any suggestions, feel free to contact the PhotoFloat community via [our mailing list](http://lists.zx2c4.com/mailman/listinfo/photofloat). We're open to adding all sorts of features and working on integration points with other pieces of software.
Note: As the project is 8+ years old, the mailing list has slowed down a bit, if you do not get an answer immediately, please be patient and give other users some time to respond.
This app is also fairly small, so this might be the perfect project to try and add some small features yourself. For reference you may want to look at the flask & nginx documentation.
## License ## License

1
scanner/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.pyc

View File

@ -1,90 +1,49 @@
import os.path import os.path
from datetime import datetime from datetime import datetime
def message(category, text): def message(category, text):
if message.level <= 0: if message.level <= 0:
sep = " " sep = " "
else: else:
sep = "--" sep = "--"
print("%s %s%s[%s]%s%s" % ( print "%s %s%s[%s]%s%s" % (datetime.now().isoformat(), max(0, message.level) * " |", sep, category, max(1, (14 - len(category))) * " ", text)
datetime.now().isoformat(),
max(0, message.level) * " |",
sep,
category,
max(1, (14 - len(category))) * " ",
text))
message.level = -1 message.level = -1
def next_level(): def next_level():
message.level += 1 message.level += 1
def back_level(): def back_level():
message.level -= 1 message.level -= 1
def set_cache_path_base(base): def set_cache_path_base(base):
trim_base.base = base trim_base.base = base
def untrim_base(path): def untrim_base(path):
return os.path.join(trim_base.base, path) return os.path.join(trim_base.base, path)
def trim_base_custom(path, base): def trim_base_custom(path, base):
if path.startswith(base): if path.startswith(base):
path = path[len(base):] path = path[len(base):]
if path.startswith('/'): if path.startswith('/'):
path = path[1:] path = path[1:]
return path return path
def trim_base(path): def trim_base(path):
return trim_base_custom(path, trim_base.base) return trim_base_custom(path, trim_base.base)
def cache_base(path, filepath=False): def cache_base(path, filepath=False):
if len(path) == 0: if len(path) == 0:
return "root" return "root"
elif filepath and len(path.split(os.sep)) < 2: elif filepath and len(path.split(os.sep)) < 2:
path = "root-" + path path = "root-" + path
path = trim_base(path).replace( path = trim_base(path).replace('/', '-').replace(' ', '_').replace('(', '').replace('&', '').replace(',', '').replace(')', '').replace('#', '').replace('[', '').replace(']', '').replace('"', '').replace("'", '').replace('_-_', '-').lower()
'/', '-').replace( while path.find("--") != -1:
' ', '_').replace( path = path.replace("--", "-")
'(', '').replace( while path.find("__") != -1:
'&', '').replace( path = path.replace("__", "_")
',', '').replace( return path
')', '').replace(
'#', '').replace(
'[', '').replace(
']', '').replace(
'"', '').replace(
"'", '').replace(
'_-_', '-').lower()
while path.find("--") != -1:
path = path.replace("--", "-")
while path.find("__") != -1:
path = path.replace("__", "_")
return path
def json_cache(path): def json_cache(path):
return cache_base(path) + ".json" return cache_base(path) + ".json"
def image_cache(path, size, square=False): def image_cache(path, size, square=False):
if square: if square:
suffix = str(size) + "s" suffix = str(size) + "s"
else: else:
suffix = str(size) suffix = str(size)
return cache_base(path, True) + "_" + suffix + ".jpg" return cache_base(path, True) + "_" + suffix + ".jpg"
def video_cache(path): def video_cache(path):
return cache_base(path, True) + ".mp4" return cache_base(path, True) + ".mp4"
def file_mtime(path): def file_mtime(path):
return datetime.fromtimestamp(int(os.path.getmtime(path))) return datetime.fromtimestamp(int(os.path.getmtime(path)))

File diff suppressed because it is too large Load Diff

View File

@ -6,143 +6,129 @@ from PhotoAlbum import Photo, Album, PhotoAlbumEncoder
from CachePath import * from CachePath import *
import json import json
class TreeWalker: class TreeWalker:
def __init__(self, album_path, cache_path): def __init__(self, album_path, cache_path):
self.album_path = os.path.abspath( self.album_path = os.path.abspath(album_path).decode(sys.getfilesystemencoding())
album_path).decode(sys.getfilesystemencoding()) self.cache_path = os.path.abspath(cache_path).decode(sys.getfilesystemencoding())
self.cache_path = os.path.abspath( set_cache_path_base(self.album_path)
cache_path).decode(sys.getfilesystemencoding()) self.all_albums = list()
set_cache_path_base(self.album_path) self.all_photos = list()
self.all_albums = list() self.walk(self.album_path)
self.all_photos = list() self.big_lists()
self.walk(self.album_path) self.remove_stale()
self.big_lists() message("complete", "")
self.remove_stale() def walk(self, path):
message("complete", "") next_level()
if not os.access(path, os.R_OK | os.X_OK):
message("access denied", os.path.basename(path))
back_level()
return None
message("walking", os.path.basename(path))
cache = os.path.join(self.cache_path, json_cache(path))
cached = False
cached_album = None
if os.path.exists(cache):
try:
cached_album = Album.from_cache(cache)
if file_mtime(path) <= file_mtime(cache):
message("full cache", os.path.basename(path))
cached = True
album = cached_album
for photo in album.photos:
self.all_photos.append(photo)
else:
message("partial cache", os.path.basename(path))
except KeyboardInterrupt:
raise
except:
message("corrupt cache", os.path.basename(path))
cached_album = None
if not cached:
album = Album(path)
for entry in os.listdir(path):
if entry[0] == '.':
continue
try:
entry = entry.decode(sys.getfilesystemencoding())
except KeyboardInterrupt:
raise
except:
next_level()
message("unicode error", entry.decode(sys.getfilesystemencoding(), "replace"))
back_level()
continue
entry = os.path.join(path, entry)
if os.path.isdir(entry):
next_walked_album = self.walk(entry)
if next_walked_album is not None:
album.add_album(next_walked_album)
elif not cached and os.path.isfile(entry):
next_level()
cache_hit = False
if cached_album:
cached_photo = cached_album.photo_from_path(entry)
if cached_photo and file_mtime(entry) <= cached_photo.attributes["dateTimeFile"]:
cache_file = None
if "mediaType" in cached_photo.attributes:
if cached_photo.attributes["mediaType"] == "video":
# if video
cache_file = os.path.join(self.cache_path, video_cache(entry))
else:
# if image
cache_file = os.path.join(self.cache_path, image_cache(entry, 1024, False))
else:
# if image
cache_file = os.path.join(self.cache_path, image_cache(entry, 1024, False))
def walk(self, path): # at this point we have full path to cache image/video
next_level() # check if it actually exists
if not os.access(path, os.R_OK | os.X_OK): if os.path.exists(cache_file):
message("access denied", os.path.basename(path)) message("cache hit", os.path.basename(entry))
back_level() cache_hit = True
return None photo = cached_photo
message("walking", os.path.basename(path))
cache = os.path.join(self.cache_path, json_cache(path))
cached = False
cached_album = None
if os.path.exists(cache):
try:
cached_album = Album.from_cache(cache)
if file_mtime(path) <= file_mtime(cache):
message("full cache", os.path.basename(path))
cached = True
album = cached_album
for photo in album.photos:
self.all_photos.append(photo)
else:
message("partial cache", os.path.basename(path))
except KeyboardInterrupt:
raise
except:
message("corrupt cache", os.path.basename(path))
cached_album = None
if not cached:
album = Album(path)
for entry in os.listdir(path):
if entry[0] == '.':
continue
try:
entry = entry.decode(sys.getfilesystemencoding())
except KeyboardInterrupt:
raise
except:
next_level()
message("unicode error", entry.decode(
sys.getfilesystemencoding(), "replace"))
back_level()
continue
entry = os.path.join(path, entry)
if os.path.isdir(entry):
next_walked_album = self.walk(entry)
if next_walked_album is not None:
album.add_album(next_walked_album)
elif not cached and os.path.isfile(entry):
next_level()
cache_hit = False
if cached_album:
cached_photo = cached_album.photo_from_path(entry)
if (cached_photo and file_mtime(
entry) <= cached_photo.attributes["dateTimeFile"]):
cache_file = None
if "mediaType" in cached_photo.attributes:
if cached_photo.attributes["mediaType"] == "video":
# if video
cache_file = os.path.join(
self.cache_path, video_cache(entry))
else:
# if image
cache_file = os.path.join(
self.cache_path,
image_cache(entry, 1024, False))
else:
# if image
cache_file = os.path.join(
self.cache_path,
image_cache(entry, 1024, False))
# at this point we have full path to cache image/video if not cache_hit:
# check if it actually exists message("metainfo", os.path.basename(entry))
if os.path.exists(cache_file): photo = Photo(entry, self.cache_path)
message("cache hit", os.path.basename(entry)) if photo.is_valid:
cache_hit = True self.all_photos.append(photo)
photo = cached_photo album.add_photo(photo)
else:
if not cache_hit: message("unreadable", os.path.basename(entry))
message("metainfo", os.path.basename(entry)) back_level()
photo = Photo(entry, self.cache_path) if not album.empty:
if photo.is_valid: message("caching", os.path.basename(path))
self.all_photos.append(photo) album.cache(self.cache_path)
album.add_photo(photo) self.all_albums.append(album)
else: else:
message("unreadable", os.path.basename(entry)) message("empty", os.path.basename(path))
back_level() back_level()
if not album.empty: return album
message("caching", os.path.basename(path)) def big_lists(self):
album.cache(self.cache_path) photo_list = []
self.all_albums.append(album) self.all_photos.sort()
else: for photo in self.all_photos:
message("empty", os.path.basename(path)) photo_list.append(photo.path)
back_level() message("caching", "all photos path list")
return album fp = open(os.path.join(self.cache_path, "all_photos.json"), 'w')
json.dump(photo_list, fp, cls=PhotoAlbumEncoder)
def big_lists(self): fp.close()
photo_list = [] def remove_stale(self):
self.all_photos.sort() message("cleanup", "building stale list")
for photo in self.all_photos: all_cache_entries = { "all_photos.json": True, "latest_photos.json": True }
photo_list.append(photo.path) for album in self.all_albums:
message("caching", "all photos path list") all_cache_entries[album.cache_path] = True
fp = open(os.path.join(self.cache_path, "all_photos.json"), 'w') for photo in self.all_photos:
json.dump(photo_list, fp, cls=PhotoAlbumEncoder) for entry in photo.image_caches:
fp.close() all_cache_entries[entry] = True
message("cleanup", "searching for stale cache entries")
def remove_stale(self): for cache in os.listdir(self.cache_path):
message("cleanup", "building stale list") try:
all_cache_entries = {"all_photos.json": True, cache = cache.decode(sys.getfilesystemencoding())
"latest_photos.json": True} except KeyboardInterrupt:
for album in self.all_albums: raise
all_cache_entries[album.cache_path] = True except:
for photo in self.all_photos: pass
for entry in photo.image_caches: if cache not in all_cache_entries:
all_cache_entries[entry] = True message("cleanup", os.path.basename(cache))
message("cleanup", "searching for stale cache entries") os.unlink(os.path.join(self.cache_path, cache))
for cache in os.listdir(self.cache_path):
try:
cache = cache.decode(sys.getfilesystemencoding())
except KeyboardInterrupt:
raise
except:
pass
if cache not in all_cache_entries:
message("cleanup", os.path.basename(cache))
os.unlink(os.path.join(self.cache_path, cache))

View File

@ -2,49 +2,46 @@ from CachePath import message
import os import os
import subprocess import subprocess
class VideoToolWrapper(object): class VideoToolWrapper(object):
def call(self, *args): def call(self, *args):
path = args[-1] path = args[-1]
for tool in self.wrappers: for tool in self.wrappers:
try: try:
if self.check_output: if self.check_output:
p = subprocess.check_output((tool,) + args) p = subprocess.check_output((tool,) + args)
else: else:
p = subprocess.call((tool,) + args) p = subprocess.call((tool,) + args)
if p > 0: if p > 0:
return False return False
else: else:
return "SUCCESS" return "SUCCESS"
except KeyboardInterrupt: except KeyboardInterrupt:
if self.cleanup: if self.cleanup:
self.remove(path) self.remove(path)
raise raise
except OSError: except OSError:
continue continue
except: except:
if self.cleanup: if self.cleanup:
self.remove(path) self.remove(path)
continue continue
return p return p
return False return False
def remove(self, path):
try:
os.unlink(path)
except:
pass
def remove(self, path):
try:
os.unlink(path)
except:
pass
class VideoTranscodeWrapper(VideoToolWrapper): class VideoTranscodeWrapper(VideoToolWrapper):
def __init__(self): def __init__(self):
self.wrappers = ['avconv', 'ffmpeg'] self.wrappers = ['avconv', 'ffmpeg']
self.check_output = False self.check_output = False
self.cleanup = True self.cleanup = True
class VideoProbeWrapper(VideoToolWrapper): class VideoProbeWrapper(VideoToolWrapper):
def __init__(self): def __init__(self):
self.wrappers = ['avprobe', 'ffprobe'] self.wrappers = ['avprobe', 'ffprobe']
self.check_output = True self.check_output = True
self.cleanup = False self.cleanup = False

View File

@ -1,176 +1,10 @@
import os from flask import Flask
from TreeWalker import TreeWalker from flask_login import LoginManager
from functools import wraps import os.path
from mimetypes import guess_type
from random import shuffle
from flask import Flask, Response, abort, json, jsonify, request
from flask_login import current_user, login_user, logout_user
from .process import send_process
from .jsonp import jsonp
from .login import admin_user, load_user
from .login import login_manager
app = Flask(__name__) app = Flask(__name__)
app.config.from_pyfile( app.config.from_pyfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app.cfg"))
os.path.join(os.path.dirname(os.path.abspath(__file__)), "app.cfg")) login_manager = LoginManager()
import login
login_manager.setup_app(app) login_manager.setup_app(app)
import endpoints
cwd = os.path.dirname(os.path.abspath(__file__))
permission_map = app.config.get('PERMISSION_MAP', [])
def accel_redirect(internal, real, relative_name):
real_path = os.path.join(real, relative_name)
internal_path = os.path.join(internal, relative_name)
if not os.path.isfile(real_path):
abort(404)
mimetype = None
types = guess_type(real_path)
if len(types) != 0:
mimetype = types[0]
response = Response(mimetype=mimetype)
response.headers.add("X-Accel-Redirect", internal_path)
response.cache_control.public = True
if mimetype == "application/json":
response.cache_control.max_age = 3600
else:
response.cache_control.max_age = 29030400
return response
def cache_base(path):
path = path.replace(
'/', '-').replace(
' ', '_').replace(
'(', '').replace(
'&', '').replace(
',', '').replace(
')', '').replace(
'#', '').replace(
'[', '').replace(
']', '').replace(
'"', '').replace(
"'", '').replace(
'_-_', '-').lower()
while path.find("--") != -1:
path = path.replace("--", "-")
while path.find("__") != -1:
path = path.replace("__", "_")
if len(path) == 0:
path = "root"
return path
def has_permission(path):
if not current_user.is_anonymous and current_user.is_admin:
return True
for auth_path in permission_map.keys():
# this is a protected object
if (path.startswith(auth_path) or
path.startswith(cache_base(auth_path))):
if current_user.is_anonymous:
return False
if current_user.id in permission_map.get(auth_path, []):
return True
else:
return False
return True
def admin_required(fn):
@wraps(fn)
def decorated_view(*args, **kwargs):
if (query_is_admin_user(request.args) or
(current_user.is_authenticated and current_user.admin)):
return fn(*args, **kwargs)
return app.login_manager.unauthorized()
return decorated_view
def query_is_admin_user(query):
username = query.get("username", None)
password = query.get("password", None)
return (username == app.config["ADMIN_USERNAME"] and
password == app.config["ADMIN_PASSWORD"])
@app.route("/scan")
@admin_required
def scan_photos():
global cwd
response = send_process([
"stdbuf",
"-oL",
os.path.abspath(os.path.join(cwd, "../main.py")),
os.path.abspath(app.config["ALBUM_PATH"]),
os.path.abspath(app.config["CACHE_PATH"])
],
os.path.join(cwd, "scanner.pid"))
response.headers.add("X-Accel-Buffering", "no")
response.cache_control.no_cache = True
return response
@app.route("/auth")
def login():
if 'logout' in request.args:
logout_user()
if current_user.is_authenticated:
logout_user()
if (query_is_admin_user(request.form) or
query_is_admin_user(request.args)):
login_user(admin_user, remember=True)
else:
user_id = (request.form.get('username') or
request.args.get('username', None))
if user_id:
login_user(load_user(user_id), remember=True)
return 'You are now logged in.'
return ""
@app.route("/albums/<path:path>")
def albums(path):
if not has_permission(path):
abort(403)
return accel_redirect(
app.config["ALBUM_ACCEL"], app.config["ALBUM_PATH"], path)
@app.route("/cache/<path:path>")
def cache(path):
if not has_permission(path):
abort(403)
return accel_redirect(
app.config["CACHE_ACCEL"], app.config["CACHE_PATH"], path)
@app.route("/photos")
@jsonp
def photos():
f = open(os.path.join(app.config["CACHE_PATH"], "all_photos.json"), "r")
photos = json.load(f)
f.close()
photos = [photo for photo in photos if has_permission(photo)]
count = int(request.args.get("count", len(photos)))
random = request.args.get("random") == "true"
if random:
shuffle(photos)
else:
photos.reverse()
response = jsonify(photos=photos[0:count])
response.cache_control.no_cache = True
return response

View File

@ -1,9 +1,8 @@
ADMIN_USERNAME = "misterscanner" ADMIN_USERNAME = "misterscanner"
ADMIN_PASSWORD = "ilovescanning" ADMIN_PASSWORD = "ilovescanning"
PERMISSION_MAP = { PHOTO_USERNAME = "photos" # The GUI currently hardcodes 'photos', so don't change this
'album/or/image/path': ['token'] PHOTO_PASSWORD = "myphotopassword"
}
ALBUM_PATH = "/var/www/uwsgi/photofloat/albums" ALBUM_PATH = "/var/www/uwsgi/photofloat/albums"
ALBUM_ACCEL = "/internal-albums" ALBUM_ACCEL = "/internal-albums"

View File

@ -0,0 +1 @@
path/to/some/place

View File

@ -0,0 +1,118 @@
from floatapp import app
from floatapp.login import admin_required, login_required, is_authenticated, query_is_photo_user, query_is_admin_user, photo_user, admin_user
from floatapp.jsonp import jsonp
from process import send_process
from TreeWalker import TreeWalker
from flask import Response, abort, json, request, jsonify
from flask_login import login_user, current_user
from random import shuffle
import os
from mimetypes import guess_type
cwd = os.path.dirname(os.path.abspath(__file__))
@app.route("/scan")
@admin_required
def scan_photos():
global cwd
response = send_process([ "stdbuf", "-oL", os.path.abspath(os.path.join(cwd, "../main.py")),
os.path.abspath(app.config["ALBUM_PATH"]), os.path.abspath(app.config["CACHE_PATH"]) ],
os.path.join(cwd, "scanner.pid"))
response.headers.add("X-Accel-Buffering", "no")
response.cache_control.no_cache = True
return response
@app.route("/auth")
def login():
success = False
if current_user.is_authenticated():
success = True
elif query_is_photo_user(request.form) or query_is_photo_user(request.args):
success = login_user(photo_user, remember=True)
elif query_is_admin_user(request.form) or query_is_admin_user(request.args):
success = login_user(admin_user, remember=True)
if not success:
abort(403)
return ""
def cache_base(path):
path = path.replace('/', '-').replace(' ', '_').replace('(', '').replace('&', '').replace(',', '').replace(')', '').replace('#', '').replace('[', '').replace(']', '').replace('"', '').replace("'", '').replace('_-_', '-').lower()
while path.find("--") != -1:
path = path.replace("--", "-")
while path.find("__") != -1:
path = path.replace("__", "_")
if len(path) == 0:
path = "root"
return path
auth_list = [ ]
def read_auth_list():
global auth_list, cwd
f = open(os.path.join(cwd, "auth.txt"), "r")
paths = [ ]
for path in f:
path = path.strip()
paths.append(path)
paths.append(cache_base(path))
f.close()
auth_list = paths
# TODO: Make this run via inotify
read_auth_list()
def check_permissions(path):
if not is_authenticated():
for auth_path in auth_list:
if path.startswith(auth_path):
abort(403)
@app.route("/albums/<path:path>")
def albums(path):
check_permissions(path)
return accel_redirect(app.config["ALBUM_ACCEL"], app.config["ALBUM_PATH"], path)
@app.route("/cache/<path:path>")
def cache(path):
check_permissions(path)
return accel_redirect(app.config["CACHE_ACCEL"], app.config["CACHE_PATH"], path)
def accel_redirect(internal, real, relative_name):
real_path = os.path.join(real, relative_name)
internal_path = os.path.join(internal, relative_name)
if not os.path.isfile(real_path):
abort(404)
mimetype = None
types = guess_type(real_path)
if len(types) != 0:
mimetype = types[0]
response = Response(mimetype=mimetype)
response.headers.add("X-Accel-Redirect", internal_path)
response.cache_control.public = True
if mimetype == "application/json":
response.cache_control.max_age = 3600
else:
response.cache_control.max_age = 29030400
return response
@app.route("/photos")
@jsonp
def photos():
f = open(os.path.join(app.config["CACHE_PATH"], "all_photos.json"), "r")
photos = json.load(f)
f.close()
if not is_authenticated():
def allowed(photo):
for auth_path in auth_list:
if photo.startswith(auth_path):
return False
return True
photos = [photo for photo in photos if allowed(photo)]
count = int(request.args.get("count", len(photos)))
random = request.args.get("random") == "true"
if random:
shuffle(photos)
else:
photos.reverse()
response = jsonify(photos=photos[0:count])
response.cache_control.no_cache = True
return response

View File

@ -5,16 +5,14 @@ import re
jsonp_validator = re.compile("^[a-zA-Z0-9_\-.]{1,128}$") jsonp_validator = re.compile("^[a-zA-Z0-9_\-.]{1,128}$")
def jsonp(f): def jsonp(f):
"""Wraps JSONified output for JSONP""" """Wraps JSONified output for JSONP"""
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
callback = request.args.get('callback', False) callback = request.args.get('callback', False)
if callback and jsonp_validator.match(callback): if callback and jsonp_validator.match(callback):
content = str(callback) + '(' + str(f(*args, **kwargs).data) + ')' content = str(callback) + '(' + str(f(*args,**kwargs).data) + ')'
return current_app.response_class( return current_app.response_class(content, mimetype='application/javascript')
content, mimetype='application/javascript') else:
else: return f(*args, **kwargs)
return f(*args, **kwargs) return decorated_function
return decorated_function

View File

@ -1,35 +1,53 @@
from flask import abort from floatapp import app, login_manager
from flask_login import UserMixin, LoginManager from flask import request, abort
from flask_login import current_user, UserMixin
login_manager = LoginManager() from functools import wraps
class User(UserMixin): class User(UserMixin):
def __init__(self, id, admin=False): def __init__(self, id, admin=False):
self.admin = admin self.admin = admin
self.id = id self.id = id
def __unicode__(self):
return u"{}".format(self.id)
def __str__(self):
return str(self.id)
@property
def is_admin(self):
return self.admin
photo_user = User("user")
admin_user = User("admin", True) admin_user = User("admin", True)
@login_manager.user_loader @login_manager.user_loader
def load_user(id): def load_user(id):
if id == "admin": if id == "user":
return admin_user return photo_user
return User(id) elif id == "admin":
return admin_user
return None
@login_manager.unauthorized_handler @login_manager.unauthorized_handler
def unauthorized(): def unauthorized():
return abort(403) return abort(403)
def login_required(fn):
@wraps(fn)
def decorated_view(*args, **kwargs):
if query_is_admin_user(request.args) or query_is_photo_user(request.args) or current_user.is_authenticated():
return fn(*args, **kwargs)
return app.login_manager.unauthorized()
return decorated_view
def admin_required(fn):
@wraps(fn)
def decorated_view(*args, **kwargs):
if query_is_admin_user(request.args) or (current_user.is_authenticated() and current_user.admin):
return fn(*args, **kwargs)
return app.login_manager.unauthorized()
return decorated_view
def query_is_photo_user(query):
username = query.get("username", None)
password = query.get("password", None)
return username == app.config["PHOTO_USERNAME"] and password == app.config["PHOTO_PASSWORD"]
def query_is_admin_user(query):
username = query.get("username", None)
password = query.get("password", None)
return username == app.config["ADMIN_USERNAME"] and password == app.config["ADMIN_PASSWORD"]
def is_authenticated():
return query_is_admin_user(request.args) or query_is_photo_user(request.args) or current_user.is_authenticated()

View File

@ -1,63 +1,52 @@
from flask import Response from flask import Response
import subprocess import subprocess
import os import os
import sys
class ProcessWrapper(object): class ProcessWrapper(object):
def __init__(self, process, done): def __init__(self, process, done):
self.process = process self.process = process
self.done = done self.done = done
def close(self):
def close(self): self.done()
self.done() if self.process.returncode is not None:
if self.process.returncode is not None: return
return self.process.stdout.close()
self.process.stdout.close() self.process.terminate()
self.process.terminate() self.process.wait()
self.process.wait() def __iter__(self):
return self
def __iter__(self): def __del__(self):
return self self.close()
def next(self):
def __del__(self): try:
self.close() data = self.process.stdout.readline()
except:
def next(self): self.close()
try: raise StopIteration()
data = self.process.stdout.readline() if data:
except: return data
self.close() self.close()
raise StopIteration() raise StopIteration()
if data:
return data
self.close()
raise StopIteration()
__next__ = next
def send_process(args, pid_file): def send_process(args, pid_file):
def setup_proc(): def setup_proc():
f = open(pid_file, "w") f = open(pid_file, "w")
f.write(str(os.getpid())) f.write(str(os.getpid()))
f.close() f.close()
os.close(0) os.close(0)
os.dup2(1, 2) os.dup2(1, 2)
def tear_down_proc():
def tear_down_proc(): try:
try: os.unlink(pid_file)
os.unlink(pid_file) except:
except: pass
pass if os.path.exists(pid_file):
f = open(pid_file, "r")
if os.path.exists(pid_file): pid = f.read()
f = open(pid_file, "r") f.close()
pid = f.read() if os.path.exists("/proc/%s/status" % pid):
f.close() return Response("Scanner is already running.\n", mimetype="text/plain")
if os.path.exists("/proc/%s/status" % pid): process = subprocess.Popen(args, close_fds=True, stdout=subprocess.PIPE, preexec_fn=setup_proc)
return Response( response = ProcessWrapper(process, tear_down_proc)
"Scanner is already running.\n", mimetype="text/plain") return Response(response, direct_passthrough=True, mimetype="text/plain")
process = subprocess.Popen(
args, close_fds=True, stdout=subprocess.PIPE, preexec_fn=setup_proc)
response = ProcessWrapper(process, tear_down_proc)
return Response(response, direct_passthrough=True, mimetype="text/plain")

View File

@ -1,25 +1,23 @@
#!./venv/bin/python #!/usr/bin/env python2
from TreeWalker import TreeWalker from TreeWalker import TreeWalker
from CachePath import message from CachePath import message
import sys import sys
import os import os
def main(): def main():
reload(sys) reload(sys)
sys.setdefaultencoding("UTF-8") sys.setdefaultencoding("UTF-8")
if len(sys.argv) != 3: if len(sys.argv) != 3:
print("usage: %s ALBUM_PATH CACHE_PATH" % sys.argv[0]) print "usage: %s ALBUM_PATH CACHE_PATH" % sys.argv[0]
return return
try:
try: os.umask(022)
os.umask(0o22) TreeWalker(sys.argv[1], sys.argv[2])
TreeWalker(sys.argv[1], sys.argv[2]) except KeyboardInterrupt:
except KeyboardInterrupt: message("keyboard", "CTRL+C pressed, quitting.")
message("keyboard", "CTRL+C pressed, quitting.") sys.exit(-97)
sys.exit(-97)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,3 +0,0 @@
flask>=0.11
flask-login>=0.4.1
pillow>=5.3.0

19
web/.htaccess Executable file
View File

@ -0,0 +1,19 @@
AddOutputFilterByType DEFLATE text/text text/html text/plain text/xml text/css application/x-javascript application/javascript application/json
<FilesMatch "\.(jpg|otf|ico)$">
Header set Cache-Control "max-age=29030400, public"
</FilesMatch>
<FilesMatch "\.(css|js)$">
Header set Cache-Control "max-age=5184000, public"
</FilesMatch>
<FilesMatch "index.html">
Header set Cache-Control "max-age=2678400, public"
</FilesMatch>
<FilesMatch "\.json$">
Header set Cache-Control "max-age=3600, public"
</FilesMatch>
<FilesMatch "Makefile">
deny from all
</FilesMatch>

View File

@ -1,69 +0,0 @@
JS_DIR = js
CSS_DIR = css
JS_MIN = $(JS_DIR)/scripts.min.js
CSS_MIN = $(CSS_DIR)/styles.min.css
JS_MIN_FILES := $(sort $(patsubst %.js, %.min.js, $(filter-out %.min.js, $(wildcard $(JS_DIR)/*.js))))
CSS_MIN_FILES := $(sort $(patsubst %.css, %.min.css, $(filter-out %.min.css, $(wildcard $(CSS_DIR)/*.css))))
JS_COMPILER := java -jar bin/closure-compiler.jar --warning_level QUIET
CSS_COMPILER := java -jar bin/yui-compressor.jar --type css
DEBUG ?= 0
.PHONY: all deploy clean
all: $(JS_MIN) $(CSS_MIN)
ifeq ($(DEBUG),0)
%.min.js: %.js
@echo " JS " $@
@$(JS_COMPILER) --js $< --js_output_file $@
else
%.min.js: %.js
@echo " JS " $@
@cat $< > $@
endif
%.min.css: %.css
@echo " CSS " $@
@$(CSS_COMPILER) -o $@ $<
$(JS_MIN): $(JS_MIN_FILES)
@echo " CAT " $@
@cat $^ > $@
$(CSS_MIN): $(CSS_MIN_FILES)
@echo " CAT " $@
@cat $^ > $@
clean:
@echo " RM " $(JS_MIN) $(JS_MIN_FILES) $(CSS_MIN) $(CSS_MIN_FILES)
@rm -fv $(JS_MIN) $(JS_MIN_FILES) $(CSS_MIN) $(CSS_MIN_FILES)
include ../deployment-config.mk
SSH_OPTS := -q -o ControlMaster=auto -o ControlPath=.ssh-deployment.sock
deploy: all
@echo " SSH $(WEB_SERVER)"
@ssh $(SSH_OPTS) -Nf $(WEB_SERVER)
@echo " RSYNC . $(WEB_SERVER):$(HTDOCS_PATH)"
@ssh -t $(SSH_OPTS) $(WEB_SERVER) "sudo -u $(HTDOCS_USER) -v"
@rsync -aizm --delete-excluded --exclude=.ssh-deployment.sock --exclude=Makefile --exclude=*.swp \
--exclude=bin/ --include=scripts.min.js --include=styles.min.css \
--exclude=*.js --exclude=*.css --rsh="ssh $(SSH_OPTS)" \
--rsync-path="sudo -n -u $(HTDOCS_USER) rsync" \
. "$(WEB_SERVER):$(HTDOCS_PATH)"
@echo " CHOWN $(HTDOCS_USER):$(HTDOCS_USER) $(WEB_SERVER):$(HTDOCS_PATH)"
@ssh -t $(SSH_OPTS) $(WEB_SERVER) "sudo chown -R $(HTDOCS_USER):$(HTDOCS_USER) '$(HTDOCS_PATH)'"
@echo " CHMOD 750/640 $(WEB_SERVER):$(HTDOCS_PATH)"
@ssh -t $(SSH_OPTS) $(WEB_SERVER) "sudo find '$(HTDOCS_PATH)' -type f -exec chmod 640 {} \;; \
sudo find '$(HTDOCS_PATH)' -type d -exec chmod 750 {} \;;"
@echo " SSH $(WEB_SERVER)"
@ssh -O exit $(SSH_OPTS) $(WEB_SERVER)

Binary file not shown.

Binary file not shown.

1
web/css/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.min.css

3
web/css/.htaccess Executable file
View File

@ -0,0 +1,3 @@
<FilesMatch "(?<!min)\.css">
deny from all
</FilesMatch>

View File

@ -40,7 +40,7 @@ a:hover {
padding: 0; padding: 0;
} }
.current-thumb { .current-thumb {
border-top: 4px solid #FFAD27 !important; border-top: 1px solid #FFAD27 !important;
} }
#subalbums { #subalbums {
padding-top: 1.5em; padding-top: 1.5em;
@ -63,7 +63,7 @@ a:hover {
#next, #back { #next, #back {
position: absolute; position: absolute;
width: auto; width: auto;
font-size: 12em; font-size: 4.5em;
line-height: 0; line-height: 0;
top: 40%; top: 40%;
font-weight: bold; font-weight: bold;
@ -86,6 +86,7 @@ a:hover {
bottom: 150px; bottom: 150px;
top: 2.5em; top: 2.5em;
overflow: hidden; overflow: hidden;
margin-bottom: 0.5em;
left: 0; left: 0;
right: 0; right: 0;
text-align: center; text-align: center;
@ -101,9 +102,9 @@ a:hover {
#photo-links { #photo-links {
background-color: #000000; background-color: #000000;
font-weight: bold; font-weight: bold;
height: 12px; height: 10px;
font-size: 12px; font-size: 10px;
line-height: 10px; line-height: 7px;
padding-top: 3px; padding-top: 3px;
padding-bottom: 3px; padding-bottom: 3px;
padding-right: 10px; padding-right: 10px;
@ -166,7 +167,6 @@ a:hover {
white-space: nowrap; white-space: nowrap;
padding: 0 !important; padding: 0 !important;
text-align: center; text-align: center;
background-color: #999999;
} }
#powered-by { #powered-by {

12
web/css/css-minify.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
# minify all .css-files
ls -1 *.css|grep -Ev "min.css$" | while read cssfile; do
newfile="${cssfile%.*}.min.css"
echo "$cssfile --> $newfile"
curl -X POST -s --data-urlencode "input@$cssfile" http://cssminifier.com/raw > $newfile
done
# merge all into one single file
rm -f styles.min.css
cat *.min.css > styles.min.css

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

View File

@ -1,50 +1,44 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="medium" content="image" /> <meta name="medium" content="image" />
<title>Photos</title> <title>Photos</title>
<link href="css/styles.min.css" rel="stylesheet" type="text/css" /> <link href="css/styles.min.css" rel="stylesheet" type="text/css" />
<script src="js/scripts.min.js"></script> <script type="text/javascript" src="js/scripts.min.js"></script>
</head> </head>
<body> <body>
<div id="title">Photos</div> <div id="title">Photos</div>
<div id="photo-view"> <div id="photo-view">
<div id="photo-box"> <div id="photo-box">
<a id="next-photo"> <a id="next-photo"><img id="photo" /></a>
<img id="photo" alt="Dynamic image, no alt available" src="./img/image-placeholder.png" /> <div id="photo-bar">
</a> <div id="photo-links">
<div id="photo-bar"> <a id="metadata-link" href="javascript:void(0)">show metadata</a> | <a id="original-link" target="_blank">download original</a><span id="fullscreen-divider"> | </span><a id="fullscreen" href="javascript:void(0)">fullscreen</a>
<div id="photo-links"> </div>
<a id="metadata-link" href="javascript:void(0)">show metadata</a> | <a id="original-link">download original</a><span id="fullscreen-divider"> | </span><a id="fullscreen" href="javascript:void(0)">fullscreen</a> <div id="metadata"></div>
</div> </div>
<div id="metadata"></div> </div>
</div> <div id="video-box">
</div> <div id="video-box-inner">
<div id="video-box"> </div>
<div id="video-box-inner"> </div>
</div>
</div>
<a id="back">&lsaquo;</a>
<a id="next">&rsaquo;</a>
</div>
<div id="album-view"> <a id="back">&lsaquo;</a>
<div id="thumbs"> <a id="next">&rsaquo;</a>
<div id="loading">Loading...</div> </div>
</div> <div id="album-view">
<div id="subalbums"></div> <div id="thumbs">
<div id="powered-by">Powered by <a href="https://derdritte.net/gitea/markus/photofloat">subPhotoFloat</a></div> <div id="loading">Loading...</div>
</div> </div>
<div id="subalbums"></div>
<div id="powered-by">Powered by <a href="http://www.zx2c4.com/projects/photofloat/" target="_blank">PhotoFloat</a></div>
</div>
<div id="error-overlay"></div>
<div id="error-text">Forgot my camera.</div>
<div id="auth-text"><form id="auth-form"><input id="password" type="password" /><input type="submit" value="Login" /></form</div>
<div id="error-overlay"></div>
<div id="error-text">Forgot my camera.</div>
<div id="auth-text">
<form id="auth-form">
<input id="password" type="password" />
<input type="submit" value="Login" />
</form>
</div>
</body> </body>
</html> </html>

1
web/js/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.min.js

3
web/js/.htaccess Executable file
View File

@ -0,0 +1,3 @@
<FilesMatch "(?<!min)\.js">
deny from all
</FilesMatch>

View File

@ -92,7 +92,7 @@
$.ajax({ $.ajax({
type: "GET", type: "GET",
dataType: "text", dataType: "text",
url: "auth?username=" + password, url: "auth?username=photos&password=" + password,
success: function() { success: function() {
result(true); result(true);
}, },

View File

@ -164,9 +164,9 @@ $(document).ready(function() {
$(window).bind("resize", scaleImage); $(window).bind("resize", scaleImage);
container = $("#photo-view"); container = $("#photo-view");
if (image.css("width") !== "100%" && container.height() * image.attr("ratio") > container.width()) if (image.css("width") !== "100%" && container.height() * image.attr("ratio") > container.width())
image.css("width", "100%").css("height", "auto"); image.css("width", "100%").css("height", "auto").css("position", "absolute").css("bottom", 0);
else if (image.css("height") !== "100%") else if (image.css("height") !== "100%")
image.css("height", "100%").css("width", "auto"); image.css("height", "100%").css("width", "auto").css("position", "").css("bottom", "");
} }
function scaleVideo() { function scaleVideo() {
var video, container; var video, container;
@ -244,7 +244,7 @@ $(document).ready(function() {
$("#next-photo").attr("href", nextLink); $("#next-photo").attr("href", nextLink);
$("#next").attr("href", nextLink); $("#next").attr("href", nextLink);
$("#back").attr("href", "#!/" + photoFloat.photoHash(currentAlbum, previousPhoto)); $("#back").attr("href", "#!/" + photoFloat.photoHash(currentAlbum, previousPhoto));
$("#original-link").attr("href", photoFloat.originalPhotoPath(currentAlbum, currentPhoto)); $("#original-link").attr("target", "_blank").attr("href", photoFloat.originalPhotoPath(currentAlbum, currentPhoto));
text = "<table>"; text = "<table>";
if (typeof currentPhoto.make !== "undefined") text += "<tr><td>Camera Maker</td><td>" + currentPhoto.make + "</td></tr>"; if (typeof currentPhoto.make !== "undefined") text += "<tr><td>Camera Maker</td><td>" + currentPhoto.make + "</td></tr>";
@ -318,7 +318,6 @@ $(document).ready(function() {
photoFloat.parseHash(location.hash, hashParsed, die); photoFloat.parseHash(location.hash, hashParsed, die);
}); });
$(window).hashchange(); $(window).hashchange();
/* Keyboard: Left / Right */
$(document).keydown(function(e){ $(document).keydown(function(e){
if (currentPhoto === null) if (currentPhoto === null)
return true; return true;
@ -331,7 +330,6 @@ $(document).ready(function() {
} }
return true; return true;
}); });
/* Mousewheel */
$(document).mousewheel(function(event, delta) { $(document).mousewheel(function(event, delta) {
if (currentPhoto === null) if (currentPhoto === null)
return true; return true;
@ -344,16 +342,6 @@ $(document).ready(function() {
} }
return true; return true;
}); });
/* Swipe */
xwiper = new Xwiper('#photo-view');
xwiper.onSwipeLeft(function(event){
window.location.href = $("#next").attr("href");
});
xwiper.onSwipeRight(function(event){
window.location.href = $("#back").attr("href");
});
$("#photo-box").mouseenter(function() { $("#photo-box").mouseenter(function() {
$("#photo-links").stop().fadeTo("slow", 0.50).css("display", "inline"); $("#photo-links").stop().fadeTo("slow", 0.50).css("display", "inline");
}); });

View File

@ -1,135 +0,0 @@
/*
Xwiper
Provided by https://github.com/uxitten/xwiper/
*/
'use strict';
var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
var Xwiper = function () {
function Xwiper(element) {
_classCallCheck(this, Xwiper);
this.element = null;
this.touchStartX = 0;
this.touchStartY = 0;
this.touchEndX = 0;
this.touchEndY = 0;
this.sensitive = 50;
this.onSwipeLeftAgent = null;
this.onSwipeRightAgent = null;
this.onSwipeUpAgent = null;
this.onSwipeDownAgent = null;
this.onTapAgent = null;
this.onTouchStart = this.onTouchStart.bind(this);
this.onTouchEnd = this.onTouchEnd.bind(this);
this.onSwipeLeft = this.onSwipeLeft.bind(this);
this.onSwipeRight = this.onSwipeRight.bind(this);
this.onSwipeUp = this.onSwipeUp.bind(this);
this.onSwipeDown = this.onSwipeDown.bind(this);
this.onTap = this.onTap.bind(this);
this.destroy = this.destroy.bind(this);
this.handleGesture = this.handleGesture.bind(this);
this.element = document.querySelector(element);
this.element.addEventListener('touchstart', this.onTouchStart, false);
this.element.addEventListener('touchend', this.onTouchEnd, false);
}
_createClass(Xwiper, [{
key: 'onTouchStart',
value: function onTouchStart(event) {
this.touchStartX = event.changedTouches[0].screenX;
this.touchStartY = event.changedTouches[0].screenY;
}
}, {
key: 'onTouchEnd',
value: function onTouchEnd(event) {
this.touchEndX = event.changedTouches[0].screenX;
this.touchEndY = event.changedTouches[0].screenY;
this.handleGesture();
}
}, {
key: 'onSwipeLeft',
value: function onSwipeLeft(func) {
this.onSwipeLeftAgent = func;
}
}, {
key: 'onSwipeRight',
value: function onSwipeRight(func) {
this.onSwipeRightAgent = func;
}
}, {
key: 'onSwipeUp',
value: function onSwipeUp(func) {
this.onSwipeUpAgent = func;
}
}, {
key: 'onSwipeDown',
value: function onSwipeDown(func) {
this.onSwipeDownAgent = func;
}
}, {
key: 'onTap',
value: function onTap(func) {
this.onTapAgent = func;
}
}, {
key: 'destroy',
value: function destroy() {
this.element.removeEventListener('touchstart', this.onTouchStart);
this.element.removeEventListener('touchend', this.onTouchEnd);
}
}, {
key: 'handleGesture',
value: function handleGesture() {
/**
* swiped left
*/
if (this.touchEndX + this.sensitive <= this.touchStartX) {
this.onSwipeLeftAgent && this.onSwipeLeftAgent();
return 'swiped left';
}
/**
* swiped right
*/
if (this.touchEndX - this.sensitive >= this.touchStartX) {
this.onSwipeRightAgent && this.onSwipeRightAgent();
return 'swiped right';
}
/**
* swiped up
*/
if (this.touchEndY + this.sensitive <= this.touchStartY) {
this.onSwipeUpAgent && this.onSwipeUpAgent();
return 'swiped up';
}
/**
* swiped down
*/
if (this.touchEndY - this.sensitive >= this.touchStartY) {
this.onSwipeDownAgent && this.onSwipeDownAgent();
return 'swiped down';
}
/**
* tap
*/
if (this.touchEndY === this.touchStartY) {
this.onTapAgent && this.onTapAgent();
return 'tap';
}
}
}]);
return Xwiper;
}();

12
web/js/js-minify.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
# minify all .js-files
ls -1 *.js|grep -Ev "min.js$" | while read jsfile; do
newfile="${jsfile%.*}.min.js"
echo "$jsfile --> $newfile"
curl -X POST -s --data-urlencode "input@$jsfile" http://javascript-minifier.com/raw > $newfile
done
# merge all into one single file
rm -f scripts.min.js
cat *.min.js > scripts.min.js