├── .gitignore ├── .gitmodules ├── LICENSE.txt ├── README.md ├── Tupfile ├── Tuprules.tup ├── admin └── index.html ├── bin ├── upload.py └── worker.py ├── config.moon ├── doc ├── INSTALL └── nginx.dev.conf ├── etc └── config.json.dist ├── footer.html ├── header.html ├── main.html ├── migrations.moon ├── mime.types ├── nginx.conf ├── photongx ├── Tupfile ├── app.moon ├── models.moon ├── schema.moon └── views │ ├── Tupfile │ ├── admin.moon │ ├── album.moon │ ├── albums.moon │ ├── error.moon │ ├── index.moon │ ├── layout.moon │ ├── upload.moon │ └── user_login.moon ├── scripts ├── Dockerfile ├── create-postgres-database.sql ├── postgresqlsetup.sh └── start.sh ├── static ├── Tupfile ├── admin.js ├── fileapi.js ├── lightbox.less ├── md5.js ├── pex.png ├── photongx.js ├── screen.less ├── spinner.png └── variables.less ├── uploaded.html ├── uploadform.html └── widgets ├── Tupfile └── base.moon /.gitignore: -------------------------------------------------------------------------------- 1 | *.lua 2 | logs/ 3 | nginx.conf.compiled 4 | config.moon 5 | .tup 6 | *.swp 7 | *.swo 8 | *.swn 9 | static/screen.css 10 | static/variables.css 11 | img 12 | etc/config.json 13 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "static/bootstrap"] 2 | path = static/bootstrap 3 | url = https://github.com/twitter/bootstrap 4 | [submodule "static/fa"] 5 | path = static/fa 6 | url = https://github.com/FortAwesome/Font-Awesome.git 7 | [submodule "static/wookmark"] 8 | path = static/wookmark 9 | url = git://github.com/GBKS/Wookmark-jQuery.git 10 | [submodule "static/smartresize"] 11 | path = static/smartresize 12 | url = https://github.com/louisremi/jquery-smartresize 13 | [submodule "static/imagesloaded"] 14 | path = static/imagesloaded 15 | url = https://github.com/desandro/imagesloaded 16 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Tor Hveem or Project Contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | * Neither the name of the AmatYr Project, Tor Hveem, nor the names 16 | of its contributors may be used to endorse or promote products 17 | derived from this software without specific prior written 18 | permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 21 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 22 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 23 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 24 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PEX - Photo Engine X 2 | ==================== 3 | 4 | A photo gallery with next to no chrome, written in lua deploying with nginx 5 | See demo at 6 | 7 | 8 | * Full width and responsive design 9 | * Simplistic interface 10 | * Expirable shareable URLs for albums to share with friends 11 | * Super fast 12 | * Open source 13 | * Backend in MoonScript (Lapis Framework) 14 | * Runs on nginx (openresty) 15 | * Postgres as database 16 | * Redis as queue 17 | * AngularJS admin panel 18 | * Download albums as ZIP archive 19 | * Multi user 20 | * Display exif info 21 | 22 | Features planned 23 | ================ 24 | 25 | * Anonymous galleries 26 | * Linux utility that uploads images based on foldernames using API 27 | * Share from android ? 28 | * Sorty/query exif 29 | * Show disk space used 30 | * Tags 31 | * Organize photos into albums/tags 32 | * Date picker to find photos in a given time period 33 | * A "drop box" for fast/easy upload 34 | 35 | Installation 36 | ================ 37 | 38 | *Warning*, this software project uses lots of uncommon requirements, so it can be a bit tricky to install. 39 | 40 | Watch Leafo's Lapis screencast to get familiar with Lapis 41 | It includes information that is relevant to this development process. 42 | 43 | ``` 44 | $ sudo apt-get install luajit 45 | $ sudo apt-get install luarocks 46 | $ sudo apt-get install postgresql 47 | 48 | $ luarocks install bcrypt 49 | $ luarocks install lapis 50 | $ luarocks install md5 51 | ``` 52 | 53 | 54 | Hacking 55 | ======= 56 | 57 | Docker lets you share a directory from the host to the container using the *-v* argument, so if you want to hack on the source you can check out the source on the host as you usually would do and then share the directory to the container like this: 58 | 59 | $ docker run -i -p 8080:8080 -v /home/tor/projects/pix:/pix -t torhve/pix 60 | 61 | This maps the directory /home/tor/projects/pix to the container directory /pix. 62 | 63 | Then you would want to install on the host to automatically compile MoonScript to Lua and LESS to CSS. 64 | I could not get tup working inside the docker since it requires FUSE filesystem which did not work in the container. 65 | 66 | Tup installation on Ubuntu: 67 | 68 | sudo apt-add-repository 'deb http://ppa.launchpad.net/anatol/tup/ubuntu lucid main' 69 | sudo apt-get update 70 | sudo apt-get install tup 71 | 72 | Tup first time: 73 | 74 | cd /home/tor/projects/pix 75 | tup init 76 | tup upd 77 | 78 | Tup each time: 79 | 80 | tup monitor -a 81 | 82 | 83 | If you do not want to run Redis and PostgreSQL inside the container, but rather on the host itself you have to get the postgresql daemon and the redis daemon to listen on the docker0 interface IP on the host. And then have the container configuration connect to the docker0 IP for redis and postgresql. 84 | 85 | 86 | 87 | PIX Configuration is in etc/config.json and config.moon 88 | 89 | Deployment 90 | ========== 91 | 92 | Use nginx with proxy_pass (or similar) to the exposed lapis port. 93 | You can then start a different lapis configuration like this: 94 | 95 | $ docker run -i -p 8181:8080 -v=/home/tor/src/pix:/pix -w=/pix -t torhve/pix lapis server production 96 | 97 | -------------------------------------------------------------------------------- /Tupfile: -------------------------------------------------------------------------------- 1 | include_rules 2 | -------------------------------------------------------------------------------- /Tuprules.tup: -------------------------------------------------------------------------------- 1 | : foreach *.moon |> moonc %f |> %B.lua 2 | : foreach *.less |> lessc -x %f > %o |> %B.css 3 | -------------------------------------------------------------------------------- /admin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Photo Engine X Administration 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 47 |
{{ error }}
48 | 65 |
66 | 67 |
68 |
69 |
70 | 121 |
122 |
123 |
124 |

This is the Photo Engine X administration pages

125 |

Working features as of now:

126 |
    127 |
  • Create new albums
  • 128 |
  • Modify existing album title
  • 129 |
  • Add images to existing album
  • 130 |
  • Delete pictures
  • 131 |
  • Delete albums
  • 132 |
  • Create new display links for shareable, time expireable links
  • 133 |
  • Display a simple timeline with all your images (Photo stream)
  • 134 |
  • Export albums as ZIP-archive
  • 135 |
136 |

Features being implemented short term:

137 |
    138 |
  • Searchable EXIF
  • 139 |
  • Organize photos from photo stream to albums
  • 140 |
  • A "Drop box" that you use to upload without first creating album
  • 141 |
  • Date pickers to find photos in a given time period
  • 142 |
143 | 144 | Please select an album to start. 145 |
146 |
147 |
148 |

Upload images to the album {{albumname}}

149 | 150 | 151 |
152 |

Drop files here to upload

153 |
154 | 155 | 156 |
157 | 158 |

159 |

160 |
161 | 162 |
163 |
164 |

165 |

166 | Upload files 167 | Clear list 168 |

169 |
170 | 171 |
172 | 173 |
174 |
    175 |
    176 |
    177 |
    178 |
    179 | 182 | 185 | 188 | 191 | 194 |
    195 |

    Photo stream

    196 |
    197 |
    198 |
    199 |

    {{ image.date | date }}

    200 |
    201 | 202 | 203 |
    204 |
    205 |
    206 | 209 | 212 | 215 | 218 | 221 |
    222 | 223 |
    224 |
    225 |
    226 | 233 |

    {{ selectedAlbum.title }} contains {{ images.images.length }} images

    234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 |
    tokencreatedexpiresaccessesalbum direct linkalbum portal link
    your{{ selectedAlbum.created_at }}never{{ selectedAlbum.views }} /albums/{{ selectedAlbum.token }}/{{ selectedAlbum.title }}/albums/
    {{ access.slug }}{{ access.created_at }}{{ access.expires_at }}{{ access.views }}/album/{{ access.slug }}/{{selectedAlbum.token}}/{{selectedAlbum.title}}//albums/{{ access.slug }}/
    264 |
    265 |
    266 | 267 | 268 | 269 |
    270 | {{ img }} 271 |
    272 | 280 |
    281 | {{ image.file_name }} 282 | {{ image.views }} views 283 |
    284 |
    285 |
    286 | 287 | 288 |
    289 |
    290 |
    291 |
    292 |
    293 | 294 | 295 | 347 | 348 | 368 | 369 | 388 | 389 | 414 | 415 | 416 | 417 |
    418 |
    419 |
    420 | © 2012-2013 Tor Hveem 421 |
    422 |
    423 |
    424 |
    425 |
    426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | -------------------------------------------------------------------------------- /bin/upload.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # CLI tool to upload images to a PEX installation 5 | # 6 | # author: @torhve 7 | 8 | import pycurl 9 | import sys 10 | import os 11 | from optparse import OptionParser 12 | from random import choice 13 | import hashlib 14 | 15 | 16 | class Upper(object): 17 | 18 | def __init__(self, host): 19 | self.host = host 20 | 21 | 22 | def generate_tag(self): 23 | tag = ''.join(choice('abcdefghijklmnopqrstuvxyz1234567890') for _ in xrange(6)) 24 | return tag 25 | 26 | def calcMD5(self, filename): 27 | return hashlib.md5(open(filename, 'r').read()).hexdigest() 28 | 29 | def upload(self, filename, album=None): 30 | fs = os.path.getsize(filename) 31 | fd = open(filename, 'r') 32 | if not album: 33 | album = os.path.dirname(fd.name).split('/')[-1] 34 | 35 | c = pycurl.Curl() 36 | values = [ 37 | "x-file-name: "+os.path.basename(filename), 38 | "X-Album: "+album, 39 | "X-Tag: "+self.generate_tag(), 40 | "X-Requested-With: XMLHttpRequest", 41 | "content-md5: "+self.calcMD5(filename) 42 | ] 43 | c.setopt(pycurl.HTTPHEADER, values) 44 | 45 | c.setopt(c.URL, self.host + "/upload/post/") 46 | c.setopt(c.POST, 1) 47 | # c.setopt(c.INFILESIZE, int(fs)) 48 | c.setopt(pycurl.POSTFIELDSIZE, fs) 49 | c.setopt(pycurl.READFUNCTION, fd.read) 50 | 51 | # c.setopt(c.VERBOSE, 1) 52 | 53 | print "Uploading %s with size %.2f MiB to album %s:"%(filename, float(fs)/1024/1024, album), 54 | c.perform() 55 | c.close() 56 | 57 | if __name__ == '__main__': 58 | parser = OptionParser() 59 | #parser.add_option('-r', '--recursive', dest="recurisve", action="store_true", 60 | # help="give a folder, recurse through and upload all found images") 61 | parser.add_option('-a', '--album', dest="album", 62 | help="name of album to upload to") 63 | (options, args) = parser.parse_args(sys.argv) 64 | 65 | 66 | if len(args) < 2: 67 | parser.error("incorrect number of arguments") 68 | 69 | u = Upper(host='http://pex.hveem.no') 70 | if not options.album: 71 | u.upload(args[1]) 72 | else: 73 | u.upload(args[1], options.album) 74 | -------------------------------------------------------------------------------- /bin/worker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Worker module for generating thumbs and pulling exif info off images 4 | # @author simeng 5 | # 6 | # -*- coding: utf-8 -*- 7 | 8 | from redis import Redis 9 | import os 10 | from os.path import sep 11 | import sys 12 | import json 13 | from time import time 14 | import signal 15 | from optparse import OptionParser 16 | import psycopg2 17 | import psycopg2.extras 18 | #from subprocess import check_call as run 19 | from subprocess import check_output as run 20 | from time import sleep 21 | 22 | class Database: 23 | def __init__(self, config): 24 | self.pg = psycopg2.connect(config['postgresql']['connstring']) 25 | psycopg2.extras.register_hstore(self.pg) # This is what forces psycopg2 to interface Dicts with hstores. 26 | self.cursor = self.pg.cursor(cursor_factory=psycopg2.extras.DictCursor) 27 | 28 | def get_image(self, token): 29 | self.cursor.execute('SELECT * from images where token = %s', (token,)) 30 | image = self.cursor.fetchone() 31 | 32 | return image 33 | 34 | def save_image_info(self, token, update): 35 | for key, val in update.items(): 36 | command = 'UPDATE images set '+key+' = %s WHERE token = %s' 37 | self.cursor.execute(command, (val, token)) 38 | self.pg.commit() 39 | 40 | def __del__(self): 41 | self.pg.close() 42 | 43 | class Worker: 44 | def __init__(self, config): 45 | self.config = config 46 | self.work_list = None 47 | if 'redis' in config: 48 | if 'host' in config["redis"] and 'port' in config['redis']: 49 | self.redis = Redis(host=config["redis"]["host"], port=config["redis"]["port"]) 50 | 51 | self.db = Database(config) 52 | 53 | 54 | def fetch_thumb_job(self): 55 | if self.config['fetch_mode'] == 'queue': 56 | return self.redis.brpop('pix:upload:queue')[1] 57 | else: 58 | if self.work_list == None: 59 | self.work_list = [] 60 | 61 | for album in self.redis.zrange('zalbums', 0, -1): 62 | for key in self.redis.zrange(album, 0, -1): 63 | self.work_list.append(key) 64 | 65 | return self.work_list.pop() 66 | 67 | def get_image_info(self, token): 68 | image = self.db.get_image(token) 69 | return image 70 | 71 | def save_image_info(self, imagekey, data): 72 | self.db.save_image_info(imagekey, data) 73 | 74 | def rotate(self, infile): 75 | ''' Lossless autoration based on exif using jhead/jpegtran ''' 76 | try: 77 | return run(['/usr/bin/jhead', '-autorot', infile]) 78 | except Exception, e: 79 | print e 80 | 81 | def get_exif(self, infile): 82 | ''' Return exif as json. Convert every value to str since we store it as hstore in postgresql which does not support numeric values ''' 83 | 84 | exif = json.loads(run(['/usr/bin/exiftool', '-json', infile]))[0] 85 | res = {} 86 | for key, val in exif.items(): 87 | try: 88 | res[key] = str(val) 89 | except Exception, e: 90 | print 'Error with exif parsing:',e 91 | return res 92 | 93 | def thumbnail(self, infile, outfile, size, quality, no_upscale=False): 94 | quality = str(quality) 95 | if infile.endswith('.gif') or no_upscale: 96 | size = size+'>' 97 | 98 | resize = run(['/usr/bin/convert', '-filter', 'catrom', '-interlace', "Plane", '-quality', quality, '-strip', '-thumbnail', size, infile, outfile]) 99 | 100 | # Converts raw filenames to jpg filenames 101 | def web_filename(filename): 102 | raw_types = ('.crw', '.raw', '.cr2') 103 | 104 | # Check if image is of type raw, then we convert from raw to JPG for web display 105 | for raw_type in raw_types: 106 | if filename.lower().endswith(raw_type): 107 | filename = filename[:-4] + '.jpg' 108 | break 109 | 110 | return filename 111 | 112 | if __name__ == '__main__': 113 | parser = OptionParser() 114 | parser.add_option('-a', '--all', dest="all", action="store_true", 115 | help="worker will ignore queue, process all images in database and exit") 116 | parser.add_option('-m', '--missing', dest="missing", action="store_true", 117 | help="only generate for missing thumbnails") 118 | (options, args) = parser.parse_args(sys.argv) 119 | 120 | BASE_DIR = os.path.dirname(__file__) + "/.." 121 | with open(BASE_DIR + sep + "etc" + sep + "config.json") as f: 122 | config = json.loads(f.read()) 123 | 124 | signal.signal(signal.SIGINT, lambda num, frame: sys.exit(0)) 125 | 126 | if options.all: 127 | config['fetch_mode'] = 'all' 128 | else: 129 | config['fetch_mode'] = 'queue' 130 | 131 | w = Worker(config) 132 | photoconf = config['photos'] 133 | thumb_max_size = "%dx%d" % ( photoconf['thumb_max'], photoconf['thumb_max'] ) 134 | huge_max_size = "%dx%d" % ( photoconf['huge_max'], photoconf['huge_max'] ) 135 | quality = '%d' % (photoconf['quality'] ) 136 | 137 | while True: 138 | try: 139 | key = w.fetch_thumb_job() 140 | #print 'Got key bun syncing', run('sync') 141 | except IndexError, e: 142 | break 143 | 144 | image = w.get_image_info(key) 145 | if not image: 146 | # Happens if user deletes image before queue gets to process it 147 | continue 148 | 149 | image['thumb_name'] = web_filename("t%d.%s" % ( photoconf['thumb_max'], image['file_name'] )) 150 | image['huge_name'] = web_filename("t%d.%s" % ( photoconf['huge_max'], image['file_name'] )) 151 | 152 | relbase = sep.join([BASE_DIR, config['path']['image'], str(image['user_id']), str(image['album_id']), str(image['id'])]) + sep 153 | 154 | infile = relbase + image['file_name'] 155 | thumb_outfile = relbase + image['thumb_name'] 156 | huge_outfile = relbase + image['huge_name'] 157 | 158 | try: 159 | # First, rotate the original 160 | success = w.rotate(infile) 161 | 162 | # Get Exif 163 | exif = w.get_exif(infile) 164 | 165 | update = { 166 | 'metadata': exif 167 | } 168 | 169 | if options.missing and os.path.exists(thumb_outfile): 170 | print 'Skipping existing thumbnail %s' %thumb_outfile 171 | continue 172 | else: 173 | print "Generating " + thumb_outfile, 174 | t = time() 175 | sys.stdout.flush() 176 | thumb = w.thumbnail(infile, thumb_outfile, thumb_max_size, quality) 177 | print "done (%d ms)" % ((time() - t) * 1000) 178 | 179 | update['thumb_name'] = image['thumb_name'], 180 | 181 | if options.missing and os.path.exists(huge_outfile): 182 | print 'Skipping existing hugenail %s' %huge_outfile 183 | continue 184 | else: 185 | print "Generating " + huge_outfile, 186 | t = time() 187 | sys.stdout.flush() 188 | huge = w.thumbnail(infile, huge_outfile, huge_max_size, quality, no_upscale=True) 189 | print "done (%d ms)" % ((time() - t) * 1000) 190 | 191 | update['huge_name'] = image['huge_name'] 192 | 193 | w.save_image_info(key, update) 194 | 195 | except Exception, e: 196 | print "ERROR", e 197 | print "Inffile:", infile 198 | print "Thumb Outfile:", thumb_outfile 199 | print "Huge Outfile:", huge_outfile 200 | raise 201 | 202 | -------------------------------------------------------------------------------- /config.moon: -------------------------------------------------------------------------------- 1 | import config from require "lapis.config" 2 | 3 | 4 | json_file = (fname) -> 5 | json = require "cjson" 6 | file = assert io.open fname 7 | with json.decode file\read "*a" 8 | file\close! 9 | 10 | 11 | config {"production", "development"}, -> 12 | for k,v in pairs json_file "etc/config.json" 13 | set k, v 14 | 15 | config "development", -> 16 | port 8080 17 | 18 | config "production", -> 19 | port 8080 20 | lua_code_cache on 21 | 22 | --c = require("lapis.config").get "development" 23 | --print c.site.name 24 | -------------------------------------------------------------------------------- /doc/INSTALL: -------------------------------------------------------------------------------- 1 | =============================== 2 | How to install Photo Engine X 3 | =============================== 4 | 5 | REQUIRED SOFTWARE 6 | 7 | - openresty (nginx+lua) 8 | - redis 9 | - imagemagick convert utility 10 | 11 | REQUIRED PACKAGES 12 | 13 | - python-pyexiv2 14 | - python-redis 15 | - python-pythonmagick 16 | 17 | PREPARATIONS 18 | 19 | - Set $INSTALL_ROOT/img/ folder writable by webserver user. 20 | - Check out submodules using: git submodule update --init 21 | - Bootstrap css needs to be downloaded seperately or generated using 22 | their provided makefile 23 | - Copy $INSTALL_ROOT/etc/config.json.dist to $INSTALL_ROOT/etc/config.json and 24 | update to reflect your settings 25 | 26 | -------------------------------------------------------------------------------- /doc/nginx.dev.conf: -------------------------------------------------------------------------------- 1 | # Example nginx.conf additions. Merge into your current nginx.conf 2 | 3 | http { 4 | # Where to look for lua modules to load 5 | lua_package_path '/home/src/photongx/?.lua;;'; 6 | 7 | # Allow some decently sized image 8 | client_max_body_size 50m; 9 | 10 | server { 11 | listen 80; 12 | server_name pex.mydomain.net; 13 | set $imgbase "/img/"; 14 | set $root /home/src/photongx; 15 | root $root; 16 | lua_code_cache on; # on for PROD, off for DEV 17 | location /static { 18 | root $root/; 19 | } 20 | # Server static stuff, or send to lua 21 | location / { 22 | try_files $uri @photongx; 23 | } 24 | location = /admin/ { 25 | try_files admin.html =404; 26 | } 27 | # Deny admin pages by IP 28 | location = /admin/api/ { 29 | content_by_lua_file /home/src/photongx/photongx.lua; 30 | } 31 | location /album/ { 32 | rewrite_by_lua_file /home/src/photongx/rewrite.lua; 33 | content_by_lua_file /home/src/photongx/photongx.lua; 34 | } 35 | location /img/ { 36 | try_files $uri @rewrite; 37 | expires max; 38 | add_header Pragma public; 39 | add_header Cache-Control "public"; 40 | } 41 | location @rewrite { 42 | internal; 43 | rewrite_by_lua_file /home/src/photongx/rewrite.lua; 44 | } 45 | 46 | # photongx 47 | location @photongx { 48 | content_by_lua_file /home/src/photongx/photongx.lua; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /etc/config.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "path": { 3 | "base": "/", 4 | "image": "img", 5 | "disk": "/pix/img" 6 | }, 7 | "redis": { 8 | "host": "127.0.0.1", 9 | "port": 6379, 10 | "prefix": "pix" 11 | }, 12 | "postgresql": { 13 | "connstring": "dbname='pix' user='pix' host='127.0.0.1' password='pixplzpixplzpixplz'" 14 | }, 15 | "photos": { 16 | "thumb_max": 640, 17 | "huge_max": 2000, 18 | "quality": 75 19 | }, 20 | "session": { 21 | "name": "pix_session" 22 | }, 23 | "secret": "photongx", 24 | "site": { 25 | "name": "pix.io" 26 | }, 27 | "postgresql_url": "postgres://pix:pixplzpixplzpixplz@127.0.0.1/pix", 28 | "port": 8080, 29 | "lua_code_cache": "off", 30 | "num_workers": 2, 31 | "upload_size": "64M", 32 | "admin": "myemail@persona" 33 | } 34 | -------------------------------------------------------------------------------- /footer.html: -------------------------------------------------------------------------------- 1 | 2 |
    3 |
    4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Photo Engine X {% if albumtitle then %}- Album: {{ albumtitle }} {% end %} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /main.html: -------------------------------------------------------------------------------- 1 | {( "header.html" )} 2 | 3 |
    4 |

    Photo Engine X the little gallery that could

    5 |
    6 |
    7 |

    Screenshot

    8 | 9 | 10 | 11 |

    12 |
    13 |

    Features

    14 |
      15 |
    • Full width thumbnails
    • 16 |
    • Responsive design
    • 17 |
    • Simplistic interface
    • 18 |
    • Expirable URLs for albums
    • 19 |
    • Super fast
    • 20 |
    • Open source
    • 21 |
    • Backend in lua
    • 22 |
    • Redis as database
    • 23 |
    • Runs on nginx (openresty)
    • 24 |
    • AngularJS admin panel
    • 25 |
    • Is awesome!
    • 26 |
    27 | 28 | 32 | 33 |
    34 |
    35 |
    36 | 39 |
    40 |
    41 | {( "footer.html" )} 42 | 43 | -------------------------------------------------------------------------------- /migrations.moon: -------------------------------------------------------------------------------- 1 | import add_column, create_index, types from require "lapis.db.schema" 2 | 3 | { 4 | [1455929083]: => 5 | add_column "users", "encrypted_password", types.text { default:'' } 6 | } 7 | -------------------------------------------------------------------------------- /mime.types: -------------------------------------------------------------------------------- 1 | types { 2 | text/html html htm shtml; 3 | text/css css; 4 | text/xml xml; 5 | image/gif gif; 6 | image/jpeg jpeg jpg; 7 | application/x-javascript js; 8 | application/atom+xml atom; 9 | application/rss+xml rss; 10 | 11 | text/mathml mml; 12 | text/plain txt; 13 | text/vnd.sun.j2me.app-descriptor jad; 14 | text/vnd.wap.wml wml; 15 | text/x-component htc; 16 | 17 | image/png png; 18 | image/tiff tif tiff; 19 | image/vnd.wap.wbmp wbmp; 20 | image/x-icon ico; 21 | image/x-jng jng; 22 | image/x-ms-bmp bmp; 23 | image/svg+xml svg svgz; 24 | image/webp webp; 25 | 26 | application/java-archive jar war ear; 27 | application/mac-binhex40 hqx; 28 | application/msword doc; 29 | application/pdf pdf; 30 | application/postscript ps eps ai; 31 | application/rtf rtf; 32 | application/vnd.ms-excel xls; 33 | application/vnd.ms-powerpoint ppt; 34 | application/vnd.wap.wmlc wmlc; 35 | application/vnd.google-earth.kml+xml kml; 36 | application/vnd.google-earth.kmz kmz; 37 | application/x-7z-compressed 7z; 38 | application/x-cocoa cco; 39 | application/x-java-archive-diff jardiff; 40 | application/x-java-jnlp-file jnlp; 41 | application/x-makeself run; 42 | application/x-perl pl pm; 43 | application/x-pilot prc pdb; 44 | application/x-rar-compressed rar; 45 | application/x-redhat-package-manager rpm; 46 | application/x-sea sea; 47 | application/x-shockwave-flash swf; 48 | application/x-stuffit sit; 49 | application/x-tcl tcl tk; 50 | application/x-x509-ca-cert der pem crt; 51 | application/x-xpinstall xpi; 52 | application/xhtml+xml xhtml; 53 | application/zip zip; 54 | 55 | application/octet-stream bin exe dll; 56 | application/octet-stream deb; 57 | application/octet-stream dmg; 58 | application/octet-stream eot; 59 | application/octet-stream iso img; 60 | application/octet-stream msi msp msm; 61 | 62 | audio/midi mid midi kar; 63 | audio/mpeg mp3; 64 | audio/ogg ogg; 65 | audio/x-m4a m4a; 66 | audio/x-realaudio ra; 67 | 68 | video/3gpp 3gpp 3gp; 69 | video/mp4 mp4; 70 | video/mpeg mpeg mpg; 71 | video/quicktime mov; 72 | video/webm webm; 73 | video/x-flv flv; 74 | video/x-m4v m4v; 75 | video/x-mng mng; 76 | video/x-ms-asf asx asf; 77 | video/x-ms-wmv wmv; 78 | video/x-msvideo avi; 79 | } 80 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | #worker_processes ${{NUM_WORKERS}}; 2 | worker_processes 1; 3 | error_log stderr notice; 4 | daemon off; 5 | 6 | events { 7 | worker_connections 1024; 8 | } 9 | 10 | http { 11 | variables_hash_max_size 1024; 12 | include mime.types; 13 | set_real_ip_from 127.0.0.1/8; 14 | set_real_ip_from 172.17.0.0/8; 15 | set_real_ip_from 10.0.3.0/24; 16 | real_ip_header X-Real-IP; 17 | # 10 MB page cache 18 | lua_shared_dict page_cache 10m; 19 | # 1 MB session cache 20 | lua_shared_dict session_cache 1m; 21 | 22 | # Load the luajit resty core 23 | #init_by_lua 'require "resty.core"'; 24 | 25 | upstream database { 26 | postgres_server ${{pg POSTGRESQL_URL}}; 27 | } 28 | 29 | 30 | server { 31 | listen ${{PORT}}; 32 | listen [::]:${{PORT}}; 33 | lua_code_cache ${{CODE_CACHE}}; 34 | client_max_body_size ${{UPLOAD_SIZE}}; 35 | 36 | 37 | location / { 38 | default_type text/html; 39 | content_by_lua ' 40 | require("lapis").serve("photongx.app") 41 | '; 42 | } 43 | location /realimg/ { 44 | internal; 45 | alias img/; 46 | } 47 | location /admin/ { 48 | alias admin/; 49 | } 50 | location /img/ { 51 | expires max; 52 | add_header Pragma public; 53 | add_header Cache-Control "public"; 54 | rewrite_by_lua ' 55 | require("lapis").serve("photongx.app") 56 | '; 57 | } 58 | 59 | 60 | location /static/ { 61 | alias static/; 62 | } 63 | 64 | location /favicon.ico { 65 | alias static/favicon.ico; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /photongx/Tupfile: -------------------------------------------------------------------------------- 1 | include_rules 2 | -------------------------------------------------------------------------------- /photongx/app.moon: -------------------------------------------------------------------------------- 1 | lapis = require "lapis" 2 | io = require "io" 3 | import json_params, respond_to, capture_errors, capture_errors_json, assert_error, yield_error from require "lapis.application" 4 | import validate, assert_valid from require "lapis.validate" 5 | import escape_pattern, trim_filter, from_json, to_json from require "lapis.util" 6 | db = require "lapis.db" 7 | import Redis, Users, Albums, Images, Accesstokens, generate_token, imagedatesql from require "photongx.models" 8 | config = require("lapis.config").get! 9 | 10 | require_login = (fn) -> 11 | => 12 | if @current_user 13 | fn @ 14 | else 15 | redirect_to: @url_for "admin" 16 | 17 | make_reader = (fname, timestamp) -> 18 | f = assert(io.open(fname, 'rb')) 19 | chunk_size = 1024 20 | desc = { 21 | ["istext"]: false, 22 | ["isfile"]: true, 23 | ["isdir"]: false, 24 | ["mtime"]: timestamp, 25 | } 26 | desc, desc.isfile and -> 27 | chunk = f\read(chunk_size) 28 | if chunk 29 | return chunk 30 | f\close! 31 | return nil 32 | 33 | class extends lapis.Application 34 | layout: require "photongx.views.layout" 35 | views_prefix: "photongx.views" 36 | handle_error: (err, trace) => 37 | print(err) 38 | print(trace) 39 | super err, trace 40 | 41 | @before_filter => 42 | @current_user = Users\read_session @ 43 | 44 | [index: "/"]: => 45 | if @current_user 46 | return redirect_to: @url_for "albums" 47 | render:true 48 | 49 | [albums: "/albums"]: require_login => 50 | @albums = Albums\select "where user_id = ? order by id DESC", @current_user.id 51 | --@albums = Albums\select "left join images on albums.id=images.album_id where albums.user_id = ? order by albums.id DESC, images.views DESC", @current_user.id, fields:"distinct on (albums.id) *" 52 | --images = Images\get_coverimages @albums 53 | for i=1, #@albums 54 | album = @albums[i] 55 | album.image = (Images\get_coverimage album.id)[1] 56 | album.url = @url_for("album", token:album.token, title:album.title) 57 | render: true 58 | 59 | [tokenalbums: "/albums/:slug/"]: capture_errors => 60 | @accesstokens = Accesstokens\for_slug @params.slug 61 | unless #@accesstokens > 0 62 | return render: "error", status: 404 63 | album_ids = [a.album_id for a in *@accesstokens] 64 | @albums = Albums\find_all album_ids 65 | 66 | -- FIXME improve SQL to single statement 67 | for album in *@albums 68 | images = Images\get_coverimage album.id 69 | album.image = images[1] 70 | -- Override the token with our given slug so the template generates the correct URLs 71 | album.slug = @params.slug 72 | album.url = @url_for("tokenalbum", slug:@params.slug, token:album.token, title:album.title) 73 | render: "albums" 74 | 75 | [album: "/album/:token/:title/"]: => 76 | if @current_user 77 | @album = Albums\find token:@params.token 78 | unless @album 79 | return render:"error", status:404 80 | unless @current_user.id == @album.user_id 81 | return render:"error", status:403 82 | unless @album return render:"error", status:403 83 | @album.views = @album.views + 1 84 | @album\update "views" 85 | @images = Images\select "where album_id = ? ORDER BY date, file_name", @album.id, fields: "*, "..imagedatesql 86 | @albumurl = @url_for('album', token:@params.token, title:@album.title) 87 | @albumsurl = @url_for('albums') 88 | @title = "Album #{@album.title}" 89 | render: true 90 | 91 | [tokenalbum: "/album/:slug/:token/:title/"]: => 92 | @album = Albums\find token:@params.token 93 | unless @album 94 | return render:"error", status:404 95 | valid_token = Accesstokens\validate_album @params.slug, @album.id 96 | unless valid_token 97 | return render:"error", status:410 98 | @albumurl = @url_for('tokenalbum', slug:@params.slug, token:@album.token, title:@album.title) 99 | @albumsurl = @url_for('tokenalbums', slug:@params.slug) 100 | @album.views = @album.views + 1 101 | @album\update "views" 102 | @images = Images\select "where album_id = ? ORDER BY date, file_name", @album.id, fields: "*, "..imagedatesql 103 | render: "album" 104 | 105 | 106 | -- 107 | -- This view gets called from a rewrite_by_lua handler so it exits 108 | -- because: "Note that when calling ngx.exit(ngx.OK) within a rewrite_by_lua handler, the nginx request processing control flow will still continue to the content handler." 109 | -- 110 | [img: "/img/:token/:filename"]: => 111 | 112 | -- Check if user has permission to see image or return 403 113 | -- Check if token is valid 114 | -- Set real url or return 404 115 | 116 | -- Simple auth check for now 117 | --unless @current_user 118 | -- ngx.exit(403) 119 | 120 | -- TODO maybe disallow original? 121 | 122 | @image = Images\find token:@params.token 123 | unless @image 124 | ngx.exit(404) 125 | 126 | -- Check access tokens and return status 410 if expired 127 | imguri = '/real' .. @image\file_path! .. '/' ..@params.filename 128 | ngx.req.set_uri imguri, true 129 | 130 | 131 | 132 | [zipalbum: "/archive/album/:token/:title/"]: require_login => 133 | if @current_user 134 | @album = Albums\find token:@params.token 135 | unless @album 136 | return render:"error", status:404 137 | unless @current_user.id == @album.user_id 138 | return render:"error", status:403 139 | unless @album return render:"error", status:403 140 | 141 | @images = Images\select "where album_id = ? ORDER BY date, file_name", @album.id, fields: "*, "..imagedatesql 142 | ngx.header['Content-Type'] = 'application/force-download' 143 | ngx.header['Content-Disposition'] = "attachment; filename=#{@album.title}.zip" 144 | 145 | ZipWriter = require 'ZipWriter' 146 | ZipStream = ZipWriter.new() 147 | 148 | writer = ZipWriter.new{ 149 | level: ZipWriter.COMPRESSION_LEVEL.DEFAULT, 150 | zip64: false, 151 | utf8: false, 152 | } 153 | writer\open_writer (path) -> 154 | ngx.print(path) 155 | ngx.flush(true) 156 | writerfunc = (chunk) -> 157 | if not chunk 158 | return 159 | ngx.print(chunk) 160 | readerfunc = (...) -> 161 | return 162 | return writerfunc, readerfunc 163 | 164 | for image in *@images 165 | size = image\get_file_size! 166 | url = image\real_file_name! 167 | name = image.file_name 168 | timestamp = image.date/1000 169 | writer\write name, make_reader(url, timestamp) 170 | 171 | writer\close! 172 | ngx.exit(200) 173 | --return layout:false, '' 174 | 175 | [user_login: "/api/user/login"]: respond_to { 176 | POST: json_params capture_errors_json => 177 | status, err = Users\login @params.username, @params.password 178 | if status 179 | user = err 180 | Users\write_session @, user 181 | return json: {status:'okay', email:user.email} 182 | json: {email:false, reason: err} 183 | } 184 | [user_status: "/api/user/status"]: respond_to { 185 | GET: => 186 | render:"error", status:404 187 | 188 | POST: capture_errors_json => 189 | cu = @current_user 190 | if cu 191 | return json:{email:cu.email} 192 | json:{email:false} 193 | } 194 | [user_logout: "/api/user/logout"]: => 195 | @session.user = false 196 | json: {email:false} 197 | 198 | -- ALBUMS API view - get albums or add new album 199 | [apialbums: "/api/albums"]: respond_to { 200 | GET: capture_errors_json require_login => 201 | albums = assert_error Albums\select "where user_id = ? ORDER BY id", @current_user.id 202 | json: {:albums} 203 | 204 | POST: capture_errors_json require_login json_params => 205 | 206 | assert_valid @params, { 207 | { "name", exists: true, min_length: 1 } 208 | } 209 | -- Get or create 210 | album = Albums\find user_id: @current_user.id, title:@params.name 211 | unless album 212 | album = Albums\create @current_user.id, @params.name 213 | json: { album: album } 214 | } 215 | 216 | "/api/albums/:album_id": respond_to { 217 | PUT: capture_errors_json require_login json_params => 218 | album = Albums\find id:@params.album_id, user_id: @current_user.id 219 | album.title = @params.title 220 | album\update "title" 221 | json: {:album} 222 | DELETE: capture_errors_json require_login => 223 | album = Albums\find id:@params.album_id, user_id: @current_user.id 224 | unless album 225 | return render:"error", status:404 226 | album\delete! 227 | json:album 228 | } 229 | 230 | "/api/images/:album_id": respond_to { 231 | 232 | GET: capture_errors_json require_login => 233 | album = Albums\find id:@params.album_id 234 | unless album 235 | return render:"error", status:404 236 | unless album.user_id == @current_user.id 237 | return render:"error", status:403 238 | images = assert_error Images\select "where album_id = ? ORDER BY date", @params.album_id, fields: "*, "..imagedatesql 239 | --- TODO maybe use raw query to cast hstore to json? 240 | -- Examples found:https://gist.github.com/WoLpH/2318757 241 | -- Or use postgresql 9.3 which can cast hstore to JSON 242 | for image in *images 243 | if image.metadata 244 | newstr, n, err = ngx.re.gsub(image.metadata, "=>", ":") 245 | image.metadata = from_json '{'..newstr..'}' 246 | json: {:images} 247 | } 248 | 249 | "/api/accesstokens/:album_id": respond_to { 250 | 251 | GET: capture_errors_json require_login => 252 | accesstokens = Accesstokens\select "where user_id = ? and album_id = ?", @current_user.id, @params.album_id 253 | json: {:accesstokens} 254 | 255 | DELETE: => 256 | accesstoken = Accesstokens\find id:@params.album_id, user_id:@current_user.id 257 | unless accesstoken 258 | return render:"error", status:404 259 | accesstoken\delete! 260 | json:{ :accesstoken } 261 | } 262 | 263 | 264 | [images: "/api/images"]: respond_to { 265 | GET:capture_errors_json require_login => 266 | images = assert_error Images\select "where user_id = ?", @current_user.id 267 | json: {:images} 268 | 269 | POST: capture_errors_json require_login => 270 | assert_valid @params, { 271 | {'upload', file_exists: true} 272 | {'filename', exists: true} 273 | {'title', exists: true} 274 | {'token', exists: true} 275 | {'checksum', exists: true} 276 | } 277 | {:upload, :title, :filename, :token, :checksum} = @params 278 | pattern = '\\.(jpe?g|gif|png|crw|raw)$' 279 | unless ngx.re.match(filename, pattern, "i") 280 | return status:403, json:{status:403, result:'Filename must be of image type'} 281 | file = @params.upload 282 | album = assert_error Albums\find user_id: @current_user.id, token:token 283 | success, image = pcall -> Images\create @current_user.id, album.id, filename 284 | -- Since there is a likely chance our silly token can be duplicate we just try to generate image again if it fails 285 | while not success 286 | success, image = pcall -> Images\create @current_user.id, album.id, filename 287 | content = file.content 288 | real_file_name = image\real_file_name! 289 | diskfile = io.open real_file_name, 'w' 290 | unless diskfile 291 | -- TODO delete created image from SQL 292 | return status:403, json:{status:403, error:"Permission denied"} 293 | diskfile\write file.content 294 | diskfile\flush! 295 | diskfile\close 296 | redis = Redis! 297 | queue = assert_error redis\queue image.token 298 | status:200, json:{status:200, result:'Image successfully uploaded'} 299 | } 300 | 301 | [photostreamimages: "/api/photostreamimages"]: respond_to { 302 | GET:capture_errors_json require_login => 303 | photostreamimages = assert_error Images\select "WHERE user_id = ? ORDER BY date DESC", @current_user.id, fields: "*, "..imagedatesql 304 | json: {:photostreamimages} 305 | } 306 | 307 | "/api/albumttl/:album_id": respond_to { 308 | POST: capture_errors_json require_login => 309 | assert_valid @req.params_post, { 310 | { "name", exists: true} 311 | { "ttl", exists: true} 312 | } 313 | ttl = tonumber @req.params_post.ttl 314 | -- TTL not a number ? Assume forever. 315 | if ttl == nil 316 | ttl = 2^32 -- ~150 years 317 | 318 | name = @req.params_post.name 319 | album = Albums\find id:@params.album_id, user_id: @current_user.id 320 | accesstoken = Accesstokens\create @current_user.id, album.id, name, ttl 321 | 322 | json: {:album,:accesstoken} 323 | } 324 | 325 | [admin: "/admin/"]: => 326 | layout:'admin' 327 | 328 | "/api/tag": => 329 | json: {token: generate_token 6} 330 | 331 | "/api/image/:image_id": respond_to { 332 | DELETE: capture_errors_json require_login => 333 | image = Images\find id:@params.image_id, user_id: @current_user.id 334 | unless image 335 | return render:"error", status:"404" 336 | image\delete! 337 | json:image 338 | } 339 | 340 | "/api/img/click": => 341 | assert_valid @params, { 342 | { "img", exists: true, min_length: 6 } 343 | } 344 | @image = Images\find token:@params.img 345 | unless @image 346 | return render: "error", status: 404 347 | @image.views = @image.views + 1 348 | @image\update "views" 349 | 350 | json: {views: @image.views} 351 | 352 | [adminapi: "/admin/api/all"]: => 353 | ok = "test" 354 | json: { status: ok} 355 | 356 | "/api/queue": require_login capture_errors_json => 357 | redis = assert_error Redis! 358 | queue = assert_error redis\queue_length! 359 | json: {counter:queue} 360 | 361 | "/db/make": => 362 | schema = require "photongx.schema" 363 | schema.make_schema! 364 | json: { status: "ok" } 365 | 366 | "/db/destroy": require_login => 367 | -- Hard coded to first user for now 368 | if @current_user.id == 1 369 | schema = require "photongx.schema" 370 | schema.destroy_schema! 371 | return json: { status: "ok" } 372 | json: status: 403 373 | 374 | "/db/migrate": require_login => 375 | -- Hard coded to first user for now 376 | if @current_user.id == 1 377 | import run_migrations from require "lapis.db.migrations" 378 | run_migrations require "photongx.migrations" 379 | return json: { status: "ok" } 380 | json: status: 403 381 | 382 | "/debug": => 383 | json: {vers:version, count: db.select 'count(*) from images'} 384 | -------------------------------------------------------------------------------- /photongx/models.moon: -------------------------------------------------------------------------------- 1 | db = require "lapis.db" 2 | redis = require "resty.redis" 3 | bcrypt = require 'bcrypt' 4 | config = require("lapis.config").get! 5 | os = require "os" 6 | import assert_error from require "lapis.application" 7 | import from_json, to_json from require "lapis.util" 8 | 9 | import execute from require "os" 10 | 11 | import Model from require "lapis.db.model" 12 | import underscore, slugify from require "lapis.util" 13 | 14 | 15 | local * 16 | 17 | format_date = (time) -> 18 | os.date "!%Y-%m-%d %H:%M:%S", time 19 | 20 | generate_token = do 21 | import random from math 22 | random_char = -> 23 | switch random 1,3 24 | when 1 25 | random 65, 90 26 | when 2 27 | random 97, 122 28 | when 3 29 | random 48, 57 30 | 31 | (length) -> 32 | string.char unpack [ random_char! for i=1,length ] 33 | 34 | secure_filename = (str) -> 35 | (str\gsub("%s+", "-")\gsub("%.+", ".")\gsub("[^%w%-_%.]+", "")) 36 | 37 | imagedatesql = do 38 | [[ 39 | date_part('epoch', 40 | COALESCE( 41 | to_timestamp(metadata->'DateTimeOriginal', 'YYYY:MM:DD HH24:MI:SS'), 42 | to_timestamp(metadata->'CreateDate', 'YYYY:MM:DD HH24:MI:SS'), 43 | created_at 44 | ))*1000 AS date 45 | ]] 46 | 47 | cache_session = (session) -> 48 | session_cache = ngx.shared.session_cache 49 | if session.email and session.id 50 | session_cache\set(session.email, to_json(session)) 51 | 52 | class Redis 53 | new: => 54 | math.randomseed os.time! 55 | @prefix = config.redis.prefix 56 | @queuekey = @prefix .. ':upload:queue' 57 | @red = redis\new! 58 | @red\connect config.redis.host, config.redis.port 59 | 60 | queue: (token) => 61 | @red\lpush @queuekey, token 62 | 63 | queue_length: => 64 | @red\llen @queuekey 65 | 66 | class Sessions extends Model 67 | @timestamp: true 68 | @create: (email) => 69 | 70 | -- Generate a hash with a 2^12 cost 71 | generate_hash = (s) -> 72 | bcrypt.digest(s, 12) 73 | 74 | class Users extends Model 75 | @timestamp: true 76 | 77 | @create: (email, password) => 78 | if @check_unique_constraint "email", email 79 | return nil, "Email already taken" 80 | 81 | token = generate_token 6 82 | while @check_unique_constraint "token", token 83 | token = generate_token 6 84 | 85 | name = "" 86 | 87 | encrypted_password = generate_hash email .. password .. config.bcrypt_token 88 | 89 | Model.create @, { 90 | :email, :token, :name, :encrypted_password 91 | } 92 | 93 | @login: (username, password) => 94 | user = @find email: username 95 | -- No user found with that username 96 | if not user 97 | return false, "err_invalid_user" 98 | verified = bcrypt.verify(username .. password .. config.bcrypt_token, user.encrypted_password) 99 | if verified 100 | return true, user 101 | return false, "err_invalid_user" 102 | @logout: => 103 | nil 104 | 105 | @read_session: (r) => 106 | if r.session.user 107 | -- First check session cache for user 108 | session_cache = ngx.shared.session_cache 109 | user = session_cache\get r.session.user.email 110 | if user 111 | return from_json user 112 | -- No cache hit, try database 113 | user = @find email: r.session.user.email 114 | -- No database hit, create a new user 115 | unless user 116 | user = @create r.session.user.email 117 | -- Write session to cache 118 | cache_session(user) 119 | user 120 | 121 | @write_session: (r, verification_data) => 122 | session = { 123 | email: verification_data.email 124 | } 125 | -- Write the cookie 126 | r.session.user = session 127 | 128 | class Albums extends Model 129 | @timestamp: true 130 | 131 | @create: (user_id, title) => 132 | token = generate_token 6 133 | while @check_unique_constraint "token", token 134 | token = generate_token 6 135 | 136 | Model.create @, { 137 | :user_id, :token, :title 138 | } 139 | 140 | delete: => 141 | 142 | -- Delete all images in album 143 | unless @id 144 | return nil, "Error: no id" 145 | images = Images\select "where album_id = ?", @id 146 | for image in *images 147 | image\delete! 148 | 149 | -- Delete folder 150 | execute 'rmdir ' .. table.concat { config.path.disk, @user_id, @id }, '/' 151 | 152 | super! 153 | 154 | class Images extends Model 155 | @timestamp: true 156 | 157 | file_path: => 158 | path = { 159 | config.path.image, 160 | @user_id, 161 | @album_id, 162 | @id 163 | } 164 | table.concat path, '/' 165 | 166 | real_file_path: => 167 | path = { 168 | config.path.disk, 169 | @user_id, 170 | @album_id, 171 | @id 172 | } 173 | table.concat path, '/' 174 | 175 | get_url: => 176 | '/'..@real_file_name! 177 | 178 | get_real_thumb_url: => 179 | if @thumb_name 180 | '/'..@file_path!..'/'..@thumb_name 181 | else 182 | '/'..@real_file_name! 183 | 184 | get_real_huge_url: => 185 | if @huge_name 186 | '/'..@file_path!..'/'..@huge_name 187 | else 188 | '/'..@real_file_name! 189 | 190 | 191 | get_huge_url: => 192 | --XXX url_for ? config? 193 | unless @huge_name == '' 194 | '/img/'..@token..'/'..@huge_name 195 | else 196 | '/img/'..@token..'/'..@file_name 197 | 198 | get_thumb_url: => 199 | --XXX url_for ? config? 200 | unless @thumb_name == '' 201 | '/img/'..@token..'/'..@thumb_name 202 | else 203 | '/img/'..@token..'/'..@file_name 204 | 205 | 206 | 207 | real_file_name: => 208 | @real_file_path! .. '/' .. @file_name 209 | 210 | get_file_size: => 211 | fp = io.open @real_file_name! 212 | if fp == nil then 213 | return 0 214 | filesize = fp\seek "end" 215 | fp\close() 216 | return filesize 217 | 218 | 219 | @create: (user_id, album_id, file_name) => 220 | token = generate_token 6 221 | while @check_unique_constraint "token", token 222 | token = generate_token 6 223 | 224 | 225 | image = assert_error Model.create @, { 226 | :user_id, :token, :album_id, file_name:secure_filename(file_name), title:file_name, thumb_name:'', huge_name:'' 227 | } 228 | if image 229 | execute "mkdir -p "..image\real_file_path! 230 | image 231 | 232 | delete: => 233 | -- Remove file from filesystem 234 | execute "rm " .. @real_file_path! .. '/' .. @file_name 235 | execute "rm " .. @real_file_path! .. '/' .. @huge_name 236 | execute "rm " .. @real_file_path! .. '/' .. @thumb_name 237 | execute "rmdir " .. @real_file_path! 238 | 239 | super! 240 | 241 | get_coverimage: (album_id) => 242 | Images\select "where album_id = ? order by views desc limit 1", album_id 243 | 244 | get_coverimages: (albums) => 245 | flat_ids = table.concat [db.escape_literal a.id for a in *albums], ", " 246 | Images\select "where album_id IN ("..flat_ids..") order by album_id DESC, views DESC", flat_ids, fields:"distinct on (album_id) *" 247 | 248 | 249 | class Accesstokens extends Model 250 | @timestamp: true 251 | 252 | @create: (user_id, album_id, name, expires_in) => 253 | slug = slugify name 254 | expires_at = format_date ngx.now! + expires_in 255 | accesstoken = Model.create @, { 256 | :user_id, :album_id, :slug, :expires_at 257 | } 258 | accesstoken 259 | 260 | @for_slug: (slug) => 261 | db.select "* from accesstokens where slug = ? and now() < expires_at", slug 262 | 263 | @validate_album: (slug, album_id) => 264 | res = db.select "* from accesstokens where slug = ? and album_id = ? and now() < expires_at", slug, album_id 265 | if #res > 0 266 | db.update "accesstokens", { 267 | views: db.raw"views + 1" 268 | }, { 269 | album_id:album_id, 270 | slug:slug 271 | } 272 | return true 273 | else 274 | return false 275 | 276 | { :Redis, :Users, :Albums, :Images, :Sessions, :Accesstokens, :generate_token, :imagedatesql } 277 | -------------------------------------------------------------------------------- /photongx/schema.moon: -------------------------------------------------------------------------------- 1 | db = require "lapis.nginx.postgres" 2 | schema = require "lapis.db.schema" 3 | migrations = require "lapis.db.migrations" 4 | 5 | import types, create_table, create_index, drop_table from schema 6 | 7 | make_schema = -> 8 | { 9 | :serial 10 | :varchar 11 | :text 12 | :time 13 | :integer 14 | :foreign_key 15 | :boolean 16 | } = schema.types 17 | 18 | -- Users 19 | create_table "users", { 20 | {"id", serial} 21 | {"token", "VARCHAR(6) NOT NULL"} 22 | {"email", text} 23 | {"name", text} 24 | {"encrypted_password", text} 25 | {"created_at", time} 26 | {"updated_at", time} 27 | 28 | "PRIMARY KEY (id)" 29 | } 30 | 31 | create_index "users", "email", unique: true 32 | create_index "users", "token", unique: true 33 | 34 | -- Albums 35 | create_table "albums", { 36 | {"id", serial} 37 | {"user_id", foreign_key} 38 | {"token", "VARCHAR(6) NOT NULL"} 39 | {"title", text} 40 | {"metadata", "hstore"} 41 | {"views", integer} 42 | {"created_at", time} 43 | {"updated_at", time} 44 | 45 | "PRIMARY KEY (id)" 46 | } 47 | 48 | create_index "albums", "user_id" 49 | create_index "albums", "token", unique: true 50 | 51 | -- Images 52 | create_table "images", { 53 | {"id", serial} 54 | {"user_id", foreign_key} 55 | {"album_id", foreign_key} 56 | {"token", "VARCHAR(6) NOT NULL"} 57 | {"title", text} 58 | {"file_name", text} 59 | {"thumb_name", text} 60 | {"huge_name", text} 61 | {"metadata", "hstore"} 62 | {"views", integer} 63 | {"created_at", time} 64 | {"updated_at", time} 65 | 66 | "PRIMARY KEY (id)" 67 | } 68 | 69 | create_index "images", "user_id" 70 | create_index "images", "album_id" 71 | create_index "images", "token", unique: true 72 | 73 | -- Access 74 | create_table "accesstokens", { 75 | {"id", serial} 76 | {"user_id", foreign_key} 77 | {"album_id", foreign_key} 78 | {"slug", text} 79 | {"views", integer} 80 | {"created_at", time} 81 | {"updated_at", time} 82 | {"expires_at", time} 83 | 84 | "PRIMARY KEY (id)" 85 | } 86 | create_index "accesstokens", "user_id" 87 | create_index "accesstokens", "album_id" 88 | 89 | --migrations.create_migrations_table! 90 | 91 | destroy_schema = -> 92 | tbls = { 93 | "users", "albums", "images", "accesstokens" 94 | } 95 | 96 | for t in *tbls 97 | drop_table t 98 | 99 | 100 | { :make_schema, :destroy_schema } 101 | -------------------------------------------------------------------------------- /photongx/views/Tupfile: -------------------------------------------------------------------------------- 1 | include_rules 2 | -------------------------------------------------------------------------------- /photongx/views/admin.moon: -------------------------------------------------------------------------------- 1 | import Widget from require "lapis.html" 2 | 3 | class Layout extends Widget 4 | content: => 5 | raw [[ 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Photo Engine X Administration 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 32 | 33 | 34 | 35 | 36 |
    {{ error }}
    37 | 40 |
    41 |
    42 |
    43 |
    44 |

    Photo Engine X Administration 45 | 46 | 47 | {{ images.nrofimages }} images in {{ images.albums.length }} albums. images in processing queue

    48 |
    49 |
    50 |
    51 |
    52 | 53 |
    54 |
    55 |
    56 | 96 |
    97 |
    98 |
    99 |

    Welcome to the Admin pages

    100 | Here you can 101 |
      102 |
    • Create new albums
    • 103 |
    • Modify existing albums (delete pictures, etc)
    • 104 |
    • Delete albums
    • 105 |
    • Create new display links for guests
    • 106 |
    107 | Please select an album to start. 108 |
    109 |
    110 |
    111 |
    112 | {{ album }} 113 |
    114 | 115 |
    116 |
    117 |
    118 |
    119 |
    120 |
    121 |

    Upload images to the album {{albumname}}

    122 | 123 | 124 |
    125 |

    Drop files here to upload

    126 |
    127 | 128 | 129 |
    130 | 131 |

    132 |

    133 |
    134 | 135 |
    136 |
    137 |

    138 |

    139 | Upload files 140 | Clear list 141 |

    142 |
    143 | 144 |
    145 | 146 |
    147 |
      148 |
      149 |
      150 |
      151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 |
      tagcreatedexpiresalbum direct linkalbum portal link
      admin/album/{{ images.tags[selectedAlbum]}}/{{selectedAlbum}}/
      {{ key}}{{ val[0] }}{{ val[1] }}/album/{{ key }}/{{selectedAlbum}}//albums/{{ key }}/
      174 |
      175 |
      176 | 177 | 178 | 179 |
      180 | {{ img }} 181 |
      182 |
      183 | 184 | 185 | 186 |
      187 |
      188 |
      189 |
      190 |
      191 |
      192 |
      193 |
      194 | 195 | 196 | 248 | 249 | 250 | 251 |
      252 |
      253 |
      254 | © 2012-2013 Tor Hveem 255 |
      256 |
      257 |
      258 |
      259 |
      260 | 261 | 262 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | ]] 275 | -------------------------------------------------------------------------------- /photongx/views/album.moon: -------------------------------------------------------------------------------- 1 | import Widget from require "lapis.html" 2 | class Album extends Widget 3 | content: => 4 | div class:"album-label", -> 5 | a href:@albumsurl, title:"Back to album list", -> 6 | i class:"fa fa-home" 7 | text " | " 8 | a href:@albumurl, -> 9 | i class:"fa fa-camera-retro" 10 | text " " ..@album.title 11 | div class:"items",-> 12 | for index, image in pairs @images 13 | div class:"item", -> 14 | a href:image\get_huge_url!, id:image.token, class:"lb", -> 15 | img src:image\get_thumb_url!, title:"#{image.file_name} - #{image.views} views" 16 | div class:"thumb-label", -> 17 | p image.file_name, -> 18 | span class:"pull-right", "#{image.views } views" 19 | 20 | div id:"spinner", class:"spinner", -> 21 | text "Loading ..." 22 | script -> 23 | raw [[ 24 | $(document).on("ready", function (evt) { 25 | pnx = photongx($('.items'), $('.item')); 26 | }); 27 | ]] 28 | -------------------------------------------------------------------------------- /photongx/views/albums.moon: -------------------------------------------------------------------------------- 1 | import Widget from require "lapis.html" 2 | class Albums extends Widget 3 | content: => 4 | if @current_user 5 | div class:"navbar navbar-inverse vertical-line", -> 6 | div class:"navbar-header", -> 7 | a class:"navbar-brand", href:'/', -> 8 | span class:"blue", -> 9 | text "Photo Engine" 10 | span class:"white", " X " 11 | ul class:"nav navbar-nav navbar-left", -> 12 | li -> 13 | a href:@url_for("admin"), -> 14 | text "Administration " 15 | ul class:"nav navbar-nav navbar-right", -> 16 | li -> 17 | a href:@url_for("admin"), -> 18 | text "Logged in as " .. @current_user.email 19 | 20 | div class:"items", -> 21 | for index, album in pairs @albums 22 | -- Skip albums with no images in them 23 | unless album.image 24 | continue 25 | div class:"item", -> 26 | a href:album.url, -> 27 | img src:album.image\get_thumb_url! 28 | div class:"album-label", -> 29 | a href:album.url, -> 30 | i class:"fa fa-camera-retro" 31 | text " "..album.title 32 | div id:"spinner", class:"spinner", -> 33 | text "Loading ..." 34 | raw [[ 35 | 40 | ]] 41 | -------------------------------------------------------------------------------- /photongx/views/error.moon: -------------------------------------------------------------------------------- 1 | import Widget from require "lapis.html" 2 | class Error extends Widget 3 | content: => 4 | div id:"about",class: "body container-fluid", -> 5 | h1 class:"white", -> 6 | text "Error #{@res.status}" 7 | h1 class:"white", -> 8 | text "Photo Engine" 9 | span class:"blue", -> 10 | text ' X ' 11 | div class:"row-fluid", -> 12 | if @res.status == 403 13 | h2 "I'm sorry Dave, I'm afraid I can't do that" 14 | if @res.status == 404 15 | h2 "I'm sorry. But this isn't a valid address." 16 | if @res.status == 410 17 | h2 "Something used to be here. But now it isn't. My apologies." 18 | -------------------------------------------------------------------------------- /photongx/views/index.moon: -------------------------------------------------------------------------------- 1 | import Widget from require "lapis.html" 2 | class Index extends Widget 3 | content: => 4 | div class:"navbar navbar-inverse vertical-line", -> 5 | div class:"navbar-header", -> 6 | a class:"navbar-brand", href:'/', -> 7 | span class:"white", -> 8 | text "Photo Engine" 9 | span class:"blue", " X " 10 | ul class:"nav navbar-nav navbar-right", -> 11 | a class:"btn btn-default navbar-btn", href:@url_for("admin"), -> 12 | text "Get started " 13 | i class:"fa fa-arrow-right" 14 | li -> 15 | text "" 16 | div id:"about", class: "container", -> 17 | div class: "row", -> 18 | div class: "col-md-12", -> 19 | h1 class:"", -> 20 | text "Photo Engine" 21 | span class:"blue", -> 22 | text ' X ' 23 | small "the little gallery that could" 24 | h3 "What is it ?" 25 | div -> 26 | p "A self hostable web gallery that lets you share your photos with the world." 27 | p "" 28 | h3 "Why ?" 29 | div -> 30 | p "Do not trust anyone but yourselv to keep your photos safe." 31 | raw [[ 32 |
      33 | Show me photos! 34 |
      35 | ]] 36 | div class:"row", -> 37 | div class:"col-sm-6 col-md-6", -> 38 | h2 "Screenshot of albums" 39 | a class:"thumbnail", href:'/albums/demo/', -> 40 | img src:"/static/pex.png" 41 | div class:"col-sm-6 col-md-6", -> 42 | h2 "Features" 43 | ul class:"unstyled", -> 44 | raw [[ 45 |
    • Full width responsive design
    • 46 |
    • Simplistic interface
    • 47 |
    • Expirable URLs for albums
    • 48 |
    • Drag & drop photo uploading
    • 49 |
    • Super fast
    • 50 |
    • Open source, free software
    • 51 |
    • Backend written in MoonScript/lua
    • 52 |
    • Lapis web framework
    • 53 |
    • PostgreSQL as database
    • 54 |
    • Redis for queue
    • 55 |
    • nginx (openresty) as the app server
    • 56 |
    • AngularJS admin dashboard
    • 57 |
    • Is awesome!
    • 58 | ]] 59 | raw [[ 60 | 64 | ]] 65 | div class:"row", -> 66 | footer class:"footer cold-md-12", -> 67 | raw [[ 68 | © 2012-2013 Tor Hveem 69 | ]] 70 | 71 | 72 | -------------------------------------------------------------------------------- /photongx/views/layout.moon: -------------------------------------------------------------------------------- 1 | import Widget from require "lapis.html" 2 | 3 | class Layout extends Widget 4 | content: => 5 | html_5 -> 6 | head -> 7 | meta charset: "utf-8" 8 | title -> 9 | if @title 10 | text "PIX #{@title}" 11 | else 12 | text "PIX" 13 | 14 | if @description 15 | meta name: "description", content: @description 16 | else 17 | meta name:"description", content:"Photo engine X" 18 | 19 | meta name:"viewport", content:"width=device-width, initial-scale=1.0, maximum-scale=1" 20 | link rel: "stylesheet", href: "/static/screen.css" 21 | meta name:"author", content:"Tor Hveem" 22 | link rel:"shortcut icon", type:"image/png", href:"/static/favicon.png" 23 | link rel:"icon", type:"image/png", href:"/static/favicon.png" 24 | 25 | link href:"/static/fa/css/font-awesome.css", rel:"stylesheet" 26 | script type: "text/javascript", src: "//ajax.googleapis.com/ajax/libs/jquery/2.0.3/jquery.min.js" 27 | body -> 28 | div class: "", -> 29 | @content_for "inner" 30 | script type:"application/javascript",src:"/static/smartresize/jquery.debouncedresize.js" 31 | script type:"application/javascript",src:"/static/imagesloaded/jquery.imagesloaded.min.js" 32 | script type:"application/javascript",src:"/static/wookmark/jquery.wookmark.min.js" 33 | script type:"application/javascript",src:"/static/photongx.js" 34 | 35 | -------------------------------------------------------------------------------- /photongx/views/upload.moon: -------------------------------------------------------------------------------- 1 | import Widget from require "lapis.html" 2 | class Index extends Widget 3 | content: => 4 | div class: "body", -> 5 | h1 @title 6 | -------------------------------------------------------------------------------- /photongx/views/user_login.moon: -------------------------------------------------------------------------------- 1 | class UserLogin extends require "widgets.base" 2 | content: => 3 | h2 "Login" 4 | @render_errors! 5 | 6 | form action: @url_for"user_login", method: "POST", class: "form", -> 7 | div class: "row", -> 8 | label for: "username_field", "Username" 9 | input type: "text", name: "username", id: "username_field" 10 | 11 | div class: "row", -> 12 | label for: "password_field", "Password" 13 | input type: "password", name: "password", id: "password_field" 14 | 15 | div class: "button_row", -> 16 | input type: "submit" 17 | raw " · " 18 | 19 | 20 | -------------------------------------------------------------------------------- /scripts/Dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # Dockerfile for photongx 3 | # 4 | # VERSION 0.0.2 5 | 6 | 7 | FROM ubuntu:12.04 8 | MAINTAINER Tor Hveem 9 | ENV REFRESHED_AT 2013-12-12 10 | 11 | RUN echo "deb-src http://archive.ubuntu.com/ubuntu precise main" >> /etc/apt/sources.list 12 | RUN sed 's/main$/main universe/' -i /etc/apt/sources.list 13 | RUN apt-get update 14 | RUN apt-get upgrade -y 15 | 16 | 17 | RUN apt-get -y install python-software-properties software-properties-common wget vim git inotify-tools pkg-config 18 | 19 | # Redis 20 | RUN apt-get -y install redis-server 21 | 22 | # PostgreSQL 23 | RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - 24 | RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ precise-pgdg main" > /etc/apt/sources.list.d/pgdg.list 25 | RUN apt-get update 26 | RUN apt-get -y install postgresql-9.3 postgresql-client-9.3 postgresql-contrib-9.3 libpq-dev 27 | 28 | # Openresty (Nginx) 29 | RUN git clone https://github.com/evanmiller/mod_zip 30 | RUN apt-get -y build-dep nginx 31 | RUN wget http://openresty.org/download/ngx_openresty-1.4.3.9.tar.gz 32 | RUN tar xvfz ngx_openresty-1.4.3.9.tar.gz 33 | RUN cd ngx_openresty-1.4.3.9 ; ./configure --with-luajit --with-http_addition_module --with-http_dav_module --with-http_geoip_module --with-http_gzip_static_module --with-http_image_filter_module --with-http_realip_module --with-http_stub_status_module --with-http_ssl_module --with-http_sub_module --with-http_xslt_module --with-ipv6 --with-http_postgres_module --with-pcre-jit --add-module=../mod_zip; make ; make install 34 | 35 | # LESS compiler 36 | RUN add-apt-repository -y ppa:chris-lea/node.js 37 | RUN apt-get update 38 | RUN apt-get install -y nodejs 39 | RUN npm install less -g 40 | 41 | RUN apt-get -y install libimage-exiftool-perl imagemagick jhead dcraw ufraw luarocks python-redis python-psycopg2 liblz-dev 42 | 43 | 44 | RUN luarocks install lzlib ZLIB_LIBDIR=/lib/x86_64-linux-gnu/ 45 | RUN luarocks install ZipWriter 46 | #RUN luarocks install --server=http://rocks.moonscript.org/manifests/leafo lapis 47 | # Need dev version of lapis until leafo cuts a new release 48 | RUN luarocks install http://github.com/leafo/lapis/raw/master/lapis-dev-1.rockspec 49 | RUN luarocks install --server=http://rocks.moonscript.org/manifests/leafo moonscript 50 | 51 | RUN git clone https://github.com/torhve/pix 52 | 53 | RUN cd pix; git submodule init; git submodule update 54 | # Compile MoonScript 55 | RUN cd pix; moonc *moon; moonc photongx/*moon; moonc photongx/views/*moon; moonc widgets/*moon 56 | # Compile LESS => CSS 57 | RUN cd pix/static/; lessc screen.less screen.css 58 | RUN lessc pix/static/bootstrap/less/bootstrap.less pix/static/bootstrap/css/bootstrap.min.css 59 | # Set up default configuration 60 | RUN cp pix/etc/config.json.dist pix/etc/config.json 61 | 62 | ADD postgresqlsetup.sh /pix/scripts/postgresqlsetup.sh 63 | RUN /pix/scripts/postgresqlsetup.sh 64 | 65 | ADD start.sh /start.sh 66 | 67 | EXPOSE 8080 68 | CMD /start.sh 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /scripts/create-postgres-database.sql: -------------------------------------------------------------------------------- 1 | UPDATE pg_database SET datistemplate = FALSE WHERE datname = 'template1'; 2 | DROP DATABASE template1; 3 | CREATE DATABASE template1 WITH TEMPLATE = template0 ENCODING = 'UTF-8'; 4 | UPDATE pg_database SET datistemplate = TRUE WHERE datname = 'template1'; 5 | UPDATE pg_database SET datallowconn = FALSE WHERE datname = 'template1'; 6 | 7 | CREATE EXTENSION hstore; 8 | 9 | DROP DATABASE IF EXISTS pix; 10 | CREATE DATABASE pix WITH ENCODING 'UTF8'; 11 | 12 | CREATE USER pix WITH PASSWORD 'pixplzpixplzpixplz'; 13 | 14 | GRANT ALL PRIVILEGES ON DATABASE pix to pix; 15 | -------------------------------------------------------------------------------- /scripts/postgresqlsetup.sh: -------------------------------------------------------------------------------- 1 | su postgres -c "/usr/lib/postgresql/9.3/bin/postgres -D /var/lib/postgresql/9.3/main -c config_file=/etc/postgresql/9.3/main/postgresql.conf" & 2 | sleep 1 3 | su postgres -c "psql < /pix/scripts/create-postgres-database.sql" 4 | su postgres -c 'echo "create extension hstore;" | psql pix' 5 | cd pix 6 | lapis server development & 7 | sleep 3 8 | wget -O/dev/stdout http://127.0.0.1:8080/db/make 9 | -------------------------------------------------------------------------------- /scripts/start.sh: -------------------------------------------------------------------------------- 1 | service postgresql start 2 | service redis-server start 3 | cd pix 4 | bin/worker.py & 5 | lapis server development 6 | -------------------------------------------------------------------------------- /static/Tupfile: -------------------------------------------------------------------------------- 1 | include_rules 2 | -------------------------------------------------------------------------------- /static/admin.js: -------------------------------------------------------------------------------- 1 | var pnxapp = angular.module('PNXApp', ['PNXApp.services']); 2 | 3 | pnxapp.filter('startFrom', function() { 4 | return function(input, start) { 5 | start = +start; //parse to int 6 | return input.slice(start); 7 | } 8 | }); 9 | 10 | 11 | pnxapp.controller('AlbumListCtrl', ['$rootScope', '$scope', '$http', '$filter', 'images', 'userSvc', function($rootScope, $scope, $http, $filter, images, userSvc) { 12 | $scope.images = images; 13 | $scope.selectedImages = []; 14 | $scope.selectedAlbum = false; 15 | $scope.hoverAlbum = false; 16 | $scope.verified = false; 17 | $scope.imageinfo = false; 18 | 19 | $scope.currentPage = 0; 20 | $scope.pageSize = 50; 21 | $scope.numberOfPages = function() { 22 | return Math.ceil(images.photostreamimages.length/$scope.pageSize); 23 | } 24 | 25 | if (typeof FileReader == "undefined") { 26 | alert ("Your browser is not supported. You will need to update to a modern browser with File API support to upload files."); 27 | } 28 | var fileCount = document.getElementById("fileCount"); 29 | var fileList = document.getElementById("fileList"); 30 | var fileDrop = document.getElementById("fileDrop"); 31 | var fileField = document.getElementById("fileField"); 32 | var fa = new FileAPI( 33 | fileCount, 34 | fileList, 35 | fileDrop, 36 | fileField 37 | ); 38 | fa.init(); 39 | 40 | // Automatically start upload when using the drop zone 41 | fileDrop.ondrop = fa.uploadQueue; 42 | 43 | var reset = document.getElementById("reset"); 44 | reset.onclick = fa.clearList; 45 | var upload = document.getElementById("upload"); 46 | upload.onclick = fa.uploadQueue; 47 | 48 | angular.extend($scope, { verified:false, error:false, email:"" }); 49 | 50 | $scope.login = function () { 51 | userSvc.login($scope.username, $scope.password).then(function (email) { 52 | angular.extend($scope, { verified:true, error:false, email:email }); 53 | $scope.status(); 54 | }, function (err) { 55 | angular.extend($scope, { verified:false, error:err}); 56 | }); 57 | }; 58 | 59 | $scope.logout = function () { 60 | userSvc.logout().then(function () { 61 | angular.extend($scope, { verified:false, error:false}); 62 | }, function (err) { 63 | $scope.error = err; 64 | }); 65 | }; 66 | 67 | $scope.status = function () { 68 | userSvc.status().then(function (data) { 69 | // in addition to email, everything else returned by persona/status will be added to the scope 70 | // this could be the chance to expose data from your local DB, for example 71 | angular.extend($scope, data, { error:false, verified:!!data.email, email:data.email }); 72 | // if we are verified refresh the item list 73 | // basicially means we just logged in 74 | if ($scope.verified) { 75 | $scope.init(); 76 | } 77 | }, function (err) { 78 | $scope.error = err; 79 | }); 80 | }; 81 | 82 | // setup; check status once on init 83 | $scope.status(); 84 | 85 | // Init function gets called from status function when user logs in 86 | $scope.init = function() { 87 | //images.getImagesFromBackend(); 88 | $rootScope.$emit('spin'); 89 | 90 | images.getAlbumsFromBackend(); 91 | images.getQueueCount(); 92 | // Update queue count on a timer 93 | var timer = setInterval(function() { 94 | images.getQueueCount(); 95 | $scope.$apply(); 96 | }, 6000); 97 | } 98 | 99 | 100 | // Spinner start event 101 | $rootScope.$on('spin', function() { 102 | $('.spinner').removeClass('hidden'); 103 | }); 104 | 105 | // Spinner end event 106 | $rootScope.$on('spun', function() { 107 | $('.spinner').addClass('hidden'); 108 | }); 109 | 110 | 111 | /* Fired after photostream navigation */ 112 | $scope.$watch('currentPage', function(current, previous, scope) { 113 | $rootScope.$emit('spin'); 114 | $('.items').imagesLoaded(function( $images, $proper, $broken ) { 115 | apnx = photongx($('.items'), $('.item')); 116 | $rootScope.$emit('spun'); 117 | }); 118 | }); 119 | 120 | $scope.$watch('images.albums', function() { 121 | $rootScope.$emit('spun'); 122 | }); 123 | 124 | /* Rewookmark when filter expression changes */ 125 | $scope.$watch('albumsearch', function() { 126 | setTimeout(function() { 127 | $('.aitems').imagesLoaded(function( $images, $proper, $broken ) { 128 | apnx = photongx($('.aitems'), $('.aitem')); 129 | }); 130 | }); 131 | }); 132 | 133 | 134 | 135 | $scope.mouseOverAlbum = function(album) { 136 | $scope.hoverAlbum = album.title; 137 | } 138 | $scope.mouseOverAlbumClear = function() { 139 | $scope.hoverAlbum = false; 140 | } 141 | 142 | $scope.clickAlbum = function(album) { 143 | $rootScope.$emit('spin'); 144 | images.getAccestokensFromBackend(album); 145 | images.getImagesFromBackend(album); 146 | $scope.uploading = false; 147 | $scope.selectedAlbum = album; 148 | images.photostreamimages = []; 149 | // Scroll top top, since we might be far down in the navigaiton list 150 | $("body").scrollTop(0); 151 | } 152 | $rootScope.$on('imagesLoaded', function() { 153 | // TODO make this clever? 154 | setTimeout(function() { 155 | $('.items').imagesLoaded(function( $images, $proper, $broken ) { 156 | apnx = photongx($('.items'), $('.item')); 157 | $rootScope.$emit('spun'); 158 | }); 159 | }); 160 | }); 161 | 162 | $scope.clickPhotoStream = function() { 163 | $rootScope.$emit('spin'); 164 | $scope.uploading = false; 165 | $scope.selectedAlbum = false; 166 | images.photostreamimages = []; 167 | images.getPhotoStreamFromBackend(); 168 | } 169 | $rootScope.$on('photostreamLoaded', function() { 170 | setTimeout(function() { 171 | $('.items').imagesLoaded(function( $images, $proper, $broken ) { 172 | apnx = photongx($('.items'), $('.item')); 173 | $rootScope.$emit('spun'); 174 | }); 175 | }); 176 | }); 177 | 178 | $scope.albumModify = function(album) { 179 | $('#albumtitlemodal').modal('show'); 180 | $scope.album = album; 181 | } 182 | 183 | $scope.submitNewAlbum = function() { 184 | $scope.uploading = true; 185 | $scope.selectedAlbum = false; 186 | $scope.photostreamimages = []; 187 | var album = $scope.albumname; 188 | 189 | // Create album 190 | $http.post('/api/albums', {name:album}).then(function(data) { 191 | // Refresh album list from backend 192 | images.getAlbumsFromBackend(); 193 | var album = data.data.album; 194 | fa.setAlbum(album); 195 | fa.clearList(); 196 | }); 197 | } 198 | $scope.albumLink = function(album) { 199 | console.log('albumLink', album); 200 | $scope.linkalbum = album; 201 | $('#input-album-id').val(album.id); 202 | $('#albumlinkmodal').modal('show'); 203 | return false; 204 | } 205 | $scope.albumAdd = function(album) { 206 | console.log('albumAdd', album); 207 | $scope.uploading = true; 208 | $scope.selectedAlbum = false; 209 | $scope.albumname = album.title; 210 | fa.setAlbum(album); 211 | $scope.submitNewAlbum(); 212 | return false; 213 | } 214 | $scope.submitAlbumLink = function() { 215 | var formData = $('#form-ttl').serialize(); 216 | var formUrl = "/api/albumttl/"+$('#input-album-id').val(); 217 | $http({ 218 | method: 'POST', 219 | url: formUrl, 220 | headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 221 | data: formData 222 | }).then(function(data) { 223 | $('#albumlinkmodal').modal('hide'); 224 | images.getAccestokensFromBackend($scope.linkalbum); 225 | $scope.linkalbum = ""; 226 | }); 227 | } 228 | $scope.submitAlbumTitle = function () { 229 | $('#albumtitlemodal').modal('hide'); 230 | $http.put('/api/albums/'+$scope.album.id, $scope.album).then(function(data) { 231 | console.log(data); 232 | images.getAlbumsFromBackend(); 233 | }); 234 | } 235 | $scope.imageRemove = function(image) { 236 | $http.delete('/api/image/'+image.id).then(function(data) { 237 | if (data.status == 200) { 238 | images.images.splice(images.images.indexOf(image), 1); 239 | }else { 240 | $scope.error = data.data; 241 | } 242 | }); 243 | } 244 | $scope.albumRemove = function(album) { 245 | $scope.albumremove = album; 246 | $('#albumremovemodal').modal('show'); 247 | 248 | }; 249 | $scope.submitAlbumRemove = function() { 250 | $('#albumremovemodal').modal('hide'); 251 | $http.delete('/api/albums/'+$scope.albumremove.id).then(function(data) { 252 | if (data.status == 200) { 253 | images.albums.splice(images.albums.indexOf($scope.albumremove), 1); 254 | // Unselect album if it was selected 255 | if($scope.albumremove == $scope.selectedAlbum) { 256 | $scope.selectedAlbum = false; 257 | } 258 | }else { 259 | $scope.error = data.data; 260 | } 261 | }); 262 | } 263 | $scope.accesstokenRemove = function(accesstoken) { 264 | $http.delete('/api/accesstokens/'+accesstoken.id).then(function(data) { 265 | if (data.status == 200) { 266 | images.accesstokens.splice(images.accesstokens.indexOf(accesstoken), 1); 267 | }else { 268 | $scope.error = data.data; 269 | } 270 | }); 271 | } 272 | $scope.imageInfo = function(image) { 273 | $scope.imageinfo = image; 274 | $('#imageinfomodal').modal('show'); 275 | } 276 | }]); 277 | 278 | 279 | function PImage(entry) { 280 | // TODO, set thumb-name to orig name if not set 281 | angular.extend(this, entry); 282 | } 283 | PImage.prototype.$$hashKey = function() { 284 | return this.id; 285 | } 286 | 287 | 288 | 289 | var services = angular.module('PNXApp.services', []); 290 | 291 | 292 | services.factory('images', ['$rootScope', '$http', function($rootScope, $http) { 293 | var images = { 294 | all: {}, 295 | albums: [], 296 | images: [], 297 | photostreamimages: [], 298 | imagecount: {}, 299 | accesstokens: [], 300 | nrofimages: 0, 301 | thumbs: {}, 302 | imagesarray: {}, 303 | tags: {}, 304 | queueCount: 0, 305 | getAlbumsFromBackend: function() { 306 | $http.get('/api/albums').then(function(data) { 307 | images.albums = data.data.albums; 308 | }); 309 | }, 310 | getImagesFromBackend: function(album) { 311 | $http.get('/api/images/'+album.id).then(function(data) { 312 | images.images = data.data.images; 313 | $rootScope.$emit('imagesLoaded'); 314 | }); 315 | }, 316 | getPhotoStreamFromBackend: function() { 317 | $http.get('/api/photostreamimages').then(function(data) { 318 | images.photostreamimages = data.data.photostreamimages; 319 | $rootScope.$emit('photostreamLoaded', ''); 320 | }); 321 | }, 322 | getQueueCount: function() { 323 | $http.get('/api/queue').then(function(data) { 324 | var counter = data.data['counter']; 325 | images.queueCount = counter; 326 | }); 327 | }, 328 | getAccestokensFromBackend: function(album) { 329 | $http.get('/api/accesstokens/'+album.id).then(function(data) { 330 | images.accesstokens = data.data.accesstokens; 331 | }); 332 | }, 333 | } 334 | return images; 335 | }]); 336 | 337 | services.factory("userSvc", ["$http", "$q", function ($http, $q) { 338 | 339 | return { 340 | login:function (username, password) { 341 | var deferred = $q.defer(); 342 | $http.post("/api/user/login", {'username':username, 'password':password}).then(function (response) { 343 | if (response.data.status === "okay") { 344 | deferred.resolve(response.data.email); 345 | } 346 | deferred.reject(response.data.reason); 347 | }); 348 | return deferred.promise; 349 | }, 350 | logout:function () { 351 | return $http.post("/api/user/logout").then(function (response) { 352 | if (response.data.status != "okay") { 353 | $q.reject(response.data.reason); 354 | } 355 | return response.data.email; 356 | }); 357 | }, 358 | status:function () { 359 | return $http.post("/api/user/status").then(function (response) { 360 | return response.data; 361 | }); 362 | } 363 | }; 364 | }]); 365 | 366 | //AlbumListCtrl.$inject = ["$scope", "userSvc"]; 367 | -------------------------------------------------------------------------------- /static/fileapi.js: -------------------------------------------------------------------------------- 1 | /* FileAPI from filebin.net (C) Espen Braastad */ 2 | /* Modifications by Tor Hveem */ 3 | var FileAPI = (function (c, t, d, f) { 4 | 5 | var fileCount = c, 6 | fileList = t, 7 | dropZone = d, 8 | fileField = f, 9 | counter_queue = 0, 10 | album, 11 | counter_uploading = 0, 12 | counter_completed = 0, 13 | fileQueue = new Array(), 14 | preview = null; 15 | 16 | this.init = function () { 17 | fileField.onchange = this.addFiles; 18 | dropZone.addEventListener("dragenter", this.stopProp, false); 19 | dropZone.addEventListener("dragleave", this.dragExit, false); 20 | dropZone.addEventListener("dragover", this.dragOver, false); 21 | dropZone.addEventListener("drop", this.showDroppedFiles, false); 22 | } 23 | 24 | this.setAlbum = function(a) { 25 | album = a; 26 | } 27 | 28 | this.addFiles = function () { 29 | addFileListItems(this.files); 30 | } 31 | 32 | function updateFileCount() { 33 | var text = "Status: " + counter_completed + " of " + counter_queue + " file"; 34 | if (counter_queue != 1){ 35 | text = text + "s"; 36 | } 37 | text = text + " uploaded"; 38 | 39 | if (counter_completed == counter_queue) { 40 | fileCount.textContent = text + ", all done!"; 41 | } else { 42 | fileCount.textContent = text + ", please wait ..."; 43 | } 44 | } 45 | 46 | this.showDroppedFiles = function (ev) { 47 | ev.stopPropagation(); 48 | ev.preventDefault(); 49 | 50 | dropZone.style["backgroundColor"] = "#d9edf7"; 51 | dropZone.style["borderColor"] = "#bce8f1"; 52 | dropZone.style["color"] = "#3a87ad"; 53 | 54 | var files = ev.dataTransfer.files; 55 | addFileListItems(files); 56 | } 57 | 58 | this.clearList = function (ev) { 59 | if (ev != undefined) 60 | ev.preventDefault(); 61 | while (fileList.childNodes.length > 0) { 62 | fileList.removeChild( 63 | fileList.childNodes[fileList.childNodes.length - 1] 64 | ); 65 | } 66 | } 67 | 68 | this.dragOver = function (ev) { 69 | ev.stopPropagation(); 70 | ev.preventDefault(); 71 | this.style["backgroundColor"] = "#f2dede"; 72 | this.style["borderColor"] = "#eed3d7"; 73 | this.style["color"] = "#b94a48"; 74 | } 75 | 76 | this.dragExit = function (ev) { 77 | ev.stopPropagation(); 78 | ev.preventDefault(); 79 | dropZone.style["backgroundColor"] = "#d9edf7"; 80 | dropZone.style["borderColor"] = "#bce8f1"; 81 | dropZone.style["color"] = "#3a87ad"; 82 | } 83 | 84 | this.stopProp = function (ev) { 85 | ev.stopPropagation(); 86 | ev.preventDefault(); 87 | } 88 | 89 | this.uploadQueue = function (ev) { 90 | ev.preventDefault(); 91 | while (fileQueue.length > 0) { 92 | var item = fileQueue.shift(); 93 | var div = document.createElement("div"); 94 | div.className = "loader"; 95 | var divText = document.createTextNode("Pending..."); 96 | div.appendChild(divText); 97 | item.li.appendChild(div); 98 | if (item.file.size < 32212254720) { 99 | div.style["color"] = "#3a87ad"; 100 | uploadFile(item.file, item.li); 101 | } else { 102 | div.textContent = "File to large (>30GB)"; 103 | div.style["color"] = "red"; 104 | } 105 | } 106 | } 107 | 108 | var addFileListItems = function (files) { 109 | counter_queue += files.length; 110 | updateFileCount(); 111 | 112 | for (var i = 0; i < files.length; i++) { 113 | var fr = new FileReader(); 114 | //fr.file = files[i]; 115 | //fr.onloadend = showFileInList; 116 | showFileInList(files[i]) 117 | //fr.readAsDataURL(files[i]); 118 | } 119 | } 120 | 121 | var showFileInList = function (file) { 122 | //var file = ev.target.file; 123 | if (file) { 124 | var li = document.createElement("li"); 125 | //if (file.type.search(/image\/.*/) != -1) { 126 | // var thumb = new Image(); 127 | // thumb.src = ev.target.result; 128 | // thumb.addEventListener("mouseover", showImagePreview, false); 129 | // thumb.addEventListener("mouseout", removePreview, false); 130 | // li.appendChild(thumb); 131 | //} 132 | var mime = file.type; 133 | if (mime.length == 0){ 134 | mime = "unknown"; 135 | } 136 | var pText = document.createTextNode( 137 | file.name + ", " + 138 | mime + ", size: " + 139 | Math.round(file.size / 1024 / 1024) + " MB" 140 | ); 141 | li.appendChild(pText); 142 | var divContainer = document.createElement("div"); 143 | divContainer.className = "progress"; 144 | var divLoader = document.createElement("div"); 145 | divLoader.className = "progress-bar"; 146 | li.appendChild(divContainer); 147 | divContainer.appendChild(divLoader); 148 | fileList.appendChild(li); 149 | 150 | counter_uploading += 1; 151 | updateFileCount(); 152 | 153 | fileQueue.push({ 154 | file : file, 155 | li : li 156 | }); 157 | } 158 | } 159 | 160 | //var showImagePreview = function (ev) { 161 | // var div = document.createElement("div"); 162 | // div.style["top"] = (ev.pageY + 10) + "px"; 163 | // div.style["left"] = (ev.pageX + 10) + "px"; 164 | // div.style["opacity"] = 0; 165 | // div.className = "imagePreview"; 166 | // var img = new Image(); 167 | // img.src = ev.target.src; 168 | // div.appendChild(img); 169 | // document.body.appendChild(div); 170 | // document.body.addEventListener("mousemove", movePreview, false); 171 | // preview = div; 172 | // fadePreviewIn(); 173 | //} 174 | 175 | //var movePreview = function (ev) { 176 | // if (preview) { 177 | // preview.style["top"] = (ev.pageY + 10) + "px"; 178 | // preview.style["left"] = (ev.pageX + 10) + "px"; 179 | // } 180 | //} 181 | 182 | //var removePreview = function (ev) { 183 | // document.body.removeEventListener("mousemove", movePreview, false); 184 | // document.body.removeChild(preview); 185 | //} 186 | 187 | //var fadePreviewIn = function () { 188 | // if (preview) { 189 | // var opacity = preview.style["opacity"]; 190 | // for (var i = 10; i < 250; i = i+10) { 191 | // (function () { 192 | // var level = i; 193 | // setTimeout(function () { 194 | // preview.style["opacity"] = opacity + level / 250; 195 | // }, level); 196 | // })(); 197 | // } 198 | // } 199 | //} 200 | 201 | function roundNumber(num, dec) { 202 | var result = Math.round(num*Math.pow(10,dec))/Math.pow(10,dec); 203 | return result; 204 | } 205 | 206 | var uploadFile = function (file, li) { 207 | if (li && file) { 208 | var xhr = new XMLHttpRequest(), 209 | upload = xhr.upload, 210 | fd = new FormData(); 211 | 212 | upload.addEventListener("progress", function (ev) { 213 | if (ev.lengthComputable) { 214 | var loader = li.getElementsByTagName("div")[1]; 215 | loader.style["width"] = (ev.loaded / ev.total) * 100 + "%"; 216 | var ps = li.getElementsByTagName("div"); 217 | for (var i = 0; i < ps.length; i++) { 218 | if (ps[i].className == "loader") { 219 | var percent = (ev.loaded / ev.total) * 100; 220 | ps[i].textContent = "Uploading... " + percent.toFixed(2) + "%"; 221 | ps[i].style["color"] = "#000000"; 222 | break; 223 | } 224 | } 225 | } 226 | }, false); 227 | upload.addEventListener("load", function (ev) { 228 | var ps = li.getElementsByTagName("div"); 229 | var divContainer = li.getElementsByTagName("div")[0]; 230 | var divBar = li.getElementsByTagName("div")[1]; 231 | divBar.style["width"] = "100%"; 232 | for (var i = 0; i < ps.length; i++) { 233 | if (ps[i].className == "loader") { 234 | counter_uploading -= 1; 235 | counter_completed += 1; 236 | updateFileCount(); 237 | 238 | ps[i].textContent = xhr.responseText; 239 | //ps[i].style["color"] = "white"; 240 | break; 241 | } 242 | } 243 | }, false); 244 | upload.addEventListener("error", function (ev) {console.log(ev);}, false); 245 | upload.addEventListener("abort", function (ev) {console.log(ev);}, false); 246 | fd.append("upload", file); 247 | xhr.open( 248 | "POST", 249 | "/api/images" 250 | ); 251 | xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); 252 | fd.append("filename", file.name); 253 | fd.append("size", file.size); 254 | fd.append("title", album.title); 255 | fd.append("token", album.token); 256 | // TODO use filereader to read file and check md5 257 | fd.append("checksum", calcMD5(file)); 258 | // Check upload respone and error message 259 | xhr.onload = function() { 260 | var ps = li.getElementsByTagName("div"); 261 | var divContainer = li.getElementsByTagName("div")[0]; 262 | var divBar = li.getElementsByTagName("div")[1]; 263 | if(xhr.status == 200) { 264 | divContainer.className = "progress"; 265 | divBar.className = "progress-bar progress-bar-success"; 266 | }else if(xhr.status == 403) { 267 | divBar.className = "progress-bar progress-bar-danger"; 268 | } 269 | divBar.textContent = JSON.parse(xhr.responseText).result; 270 | for (var i = 0; i < ps.length; i++) { 271 | if (ps[i].className == "loader") { 272 | //divContainer.removeChild(ps[i]); 273 | //ps[i].textContent = xhr.responseText; 274 | break; 275 | } 276 | } 277 | } 278 | xhr.send(fd); 279 | } 280 | } 281 | } 282 | ); 283 | -------------------------------------------------------------------------------- /static/lightbox.less: -------------------------------------------------------------------------------- 1 | @import "variables.less"; 2 | /* disable transitons 3 | .item { 4 | -webkit-transition-duration: 0.8s; 5 | -moz-transition-duration: 0.8s; 6 | -ms-transition-duration: 0.8s; 7 | -o-transition-duration: 0.8s; 8 | transition-duration: 0.8s; 9 | } 10 | 11 | .item { 12 | -webkit-transition-property: left, top; 13 | -moz-transition-property: left, top; 14 | -ms-transition-property: left, top; 15 | -o-transition-property: left, top; 16 | transition-property: left, top; 17 | } 18 | 19 | .item { 20 | -webkit-transition-property: -webkit-transform, opacity; 21 | -moz-transition-property: -moz-transform, opacity; 22 | -ms-transition-property: -ms-transform, opacity; 23 | -o-transition-property: -o-transform, opacity; 24 | transition-property: transform, opacity; 25 | } 26 | .transition { 27 | transition: all 500ms cubic-bezier(0.250, 0.100, 0.250, 1.000); // ease (default) 28 | } 29 | */ 30 | 31 | .backgrounded { 32 | -webkit-filter: blur(6px); 33 | -ms-filter: blur(6px); 34 | -o-filter: blur(6px); 35 | filter: blur(6px); 36 | filter: url("data:image/svg+xml;utf8,#blur"); /* Firefox 10+, Firefox on Android */ 37 | -webkit-transition: all 0.3s ease-out; 38 | -moz-transition: all 0.3s ease-out; 39 | -ms-transition: all 0.3s ease-out; 40 | -o-transition: all 0.3s ease-out; 41 | transition: all 0.3s ease-out; 42 | } 43 | 44 | #lightbox { 45 | position:fixed; /* keeps the lightbox window in the current viewport */ 46 | top: 0; 47 | left: 0; 48 | vertical-align: middle; 49 | text-align: center; 50 | width: 100%; 51 | height: auto; 52 | max-width: 100%; 53 | max-height: 100% !important; 54 | z-index: 2; 55 | 56 | .action-group { 57 | position:fixed; 58 | top: 0; 59 | right: 0; 60 | text-align:right; 61 | z-index: 5; 62 | font-size: 1.6em; 63 | padding: 5px; 64 | 65 | background: rgba(0, 0, 0, 0.4); 66 | -webkit-box-shadow: 0 2px 10px rgba(0,0,0,.25); 67 | -moz-box-shadow: 0 2px 10px rgba(0,0,0,.25); 68 | box-shadow: 0 2px 10px rgba(0,0,0,.25); 69 | 70 | a { 71 | margin: 5px; 72 | color: rgba(255,255,255,0.9); 73 | text-shadow: 0 -1px 0 rgba(0,0,0,.5); 74 | text-decoration: none; 75 | } 76 | } 77 | #prev { 78 | left: 0; 79 | } 80 | #prev, #next { 81 | width: 10%; 82 | height: 100%; 83 | position: fixed; 84 | color:#fff; 85 | z-index: 4; 86 | font-size: 2em; 87 | top: 0; 88 | padding: 0; 89 | margin: 0; 90 | color: transparent; 91 | 92 | } 93 | #next:hover, #prev:hover { 94 | background: rgba(0, 0, 0, 0.4); 95 | -webkit-box-shadow: 0 2px 10px rgba(0,0,0,.25); 96 | -moz-box-shadow: 0 2px 10px rgba(0,0,0,.25); 97 | box-shadow: 0 2px 10px rgba(0,0,0,.25); 98 | color: white; 99 | } 100 | #next { 101 | right: 0; 102 | } 103 | #next div, #prev div { 104 | position: fixed; 105 | top: 50%; 106 | padding: 4%; 107 | } 108 | // Position image in center 109 | img { 110 | position: absolute; 111 | margin: auto; 112 | right: 0; 113 | top: 0; 114 | left: 0; 115 | bottom: 0; 116 | max-height: 100%; 117 | max-width: 100%; 118 | } 119 | img { 120 | -webkit-box-shadow:0 0 120px black; 121 | -moz-box-shadow:0 0 120px black; 122 | box-shadow:0 0 120px black; 123 | border: 5px solid white; 124 | //height: 100%; 125 | //width: auto; 126 | } 127 | img:hover { 128 | } 129 | img { 130 | //height: auto; 131 | //width: 100%; 132 | } 133 | } 134 | #lbcontent { 135 | position:fixed; /* keeps the lightbox window in the current viewport */ 136 | top: 0; 137 | left: 0; 138 | right: 0; 139 | bottom: 0; 140 | // Fade whatever the lb is in top of 141 | background: rgba(0, 0, 0, 0.8); 142 | } 143 | #spinner { 144 | z-index: 3; 145 | position: fixed; 146 | top: 50%; 147 | left: 50%; 148 | margin: -100px 0 0 -100px; 149 | height: 200px; 150 | width: 200px; 151 | text-indent: 250px; 152 | white-space: nowrap; 153 | overflow: hidden; 154 | background-color: transparent; 155 | background: url(spinner.png); 156 | /* 157 | -webkit-mask-image: url(spinner.png); 158 | -moz-mask-image: url(spinner.png); 159 | */ 160 | -webkit-animation-name: spinnerRotate; 161 | -webkit-animation-duration: 2s; 162 | -webkit-animation-iteration-count: infinite; 163 | -webkit-animation-timing-function: linear; 164 | -moz-animation-name: spinnerRotate; 165 | -moz-animation-duration: 2s; 166 | -moz-animation-iteration-count: infinite; 167 | -moz-animation-timing-function: linear; 168 | } 169 | @-moz-keyframes spinnerRotate { 170 | from { 171 | -moz-transform:rotate(0deg); 172 | } 173 | to { 174 | -moz-transform:rotate(360deg); 175 | } 176 | } 177 | @-webkit-keyframes spinnerRotate { 178 | from { 179 | -webkit-transform:rotate(0deg); 180 | } 181 | to { 182 | -webkit-transform:rotate(360deg); 183 | } 184 | } 185 | 186 | .lbimg { 187 | -webkit-transition: opacity 0.3s ease-in; 188 | -moz-transition: opacity 0.3s ease-in; 189 | -o-transition: opacity 0.3s ease-in; 190 | transition: opacity 0.3s ease-in; 191 | 192 | -webkit-backface-visibility: hidden; 193 | -webkit-perspective: 1000; 194 | -moz-backface-visibility: hidden; 195 | -moz-perspective: 1000; 196 | -o-backface-visibility: hidden; 197 | -o-perspective: 1000; 198 | -backface-visibility: hidden; 199 | -perspective: 1000; 200 | } 201 | -------------------------------------------------------------------------------- /static/md5.js: -------------------------------------------------------------------------------- 1 | /* 2 | * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message 3 | * Digest Algorithm, as defined in RFC 1321. 4 | * Copyright (C) Paul Johnston 1999 - 2000. 5 | * Updated by Greg Holt 2000 - 2001. 6 | * See http://pajhome.org.uk/site/legal.html for details. 7 | */ 8 | 9 | /* 10 | * Convert a 32-bit number to a hex string with ls-byte first 11 | */ 12 | var hex_chr = "0123456789abcdef"; 13 | function rhex(num) 14 | { 15 | str = ""; 16 | for(j = 0; j <= 3; j++) 17 | str += hex_chr.charAt((num >> (j * 8 + 4)) & 0x0F) + 18 | hex_chr.charAt((num >> (j * 8)) & 0x0F); 19 | return str; 20 | } 21 | 22 | /* 23 | * Convert a string to a sequence of 16-word blocks, stored as an array. 24 | * Append padding bits and the length, as described in the MD5 standard. 25 | */ 26 | function str2blks_MD5(str) 27 | { 28 | nblk = ((str.length + 8) >> 6) + 1; 29 | blks = new Array(nblk * 16); 30 | for(i = 0; i < nblk * 16; i++) blks[i] = 0; 31 | for(i = 0; i < str.length; i++) 32 | blks[i >> 2] |= str.charCodeAt(i) << ((i % 4) * 8); 33 | blks[i >> 2] |= 0x80 << ((i % 4) * 8); 34 | blks[nblk * 16 - 2] = str.length * 8; 35 | return blks; 36 | } 37 | 38 | /* 39 | * Add integers, wrapping at 2^32. This uses 16-bit operations internally 40 | * to work around bugs in some JS interpreters. 41 | */ 42 | function add(x, y) 43 | { 44 | var lsw = (x & 0xFFFF) + (y & 0xFFFF); 45 | var msw = (x >> 16) + (y >> 16) + (lsw >> 16); 46 | return (msw << 16) | (lsw & 0xFFFF); 47 | } 48 | 49 | /* 50 | * Bitwise rotate a 32-bit number to the left 51 | */ 52 | function rol(num, cnt) 53 | { 54 | return (num << cnt) | (num >>> (32 - cnt)); 55 | } 56 | 57 | /* 58 | * These functions implement the basic operation for each round of the 59 | * algorithm. 60 | */ 61 | function cmn(q, a, b, x, s, t) 62 | { 63 | return add(rol(add(add(a, q), add(x, t)), s), b); 64 | } 65 | function ff(a, b, c, d, x, s, t) 66 | { 67 | return cmn((b & c) | ((~b) & d), a, b, x, s, t); 68 | } 69 | function gg(a, b, c, d, x, s, t) 70 | { 71 | return cmn((b & d) | (c & (~d)), a, b, x, s, t); 72 | } 73 | function hh(a, b, c, d, x, s, t) 74 | { 75 | return cmn(b ^ c ^ d, a, b, x, s, t); 76 | } 77 | function ii(a, b, c, d, x, s, t) 78 | { 79 | return cmn(c ^ (b | (~d)), a, b, x, s, t); 80 | } 81 | 82 | /* 83 | * Take a string and return the hex representation of its MD5. 84 | */ 85 | function calcMD5(str) 86 | { 87 | x = str2blks_MD5(str); 88 | a = 1732584193; 89 | b = -271733879; 90 | c = -1732584194; 91 | d = 271733878; 92 | 93 | for(i = 0; i < x.length; i += 16) 94 | { 95 | olda = a; 96 | oldb = b; 97 | oldc = c; 98 | oldd = d; 99 | 100 | a = ff(a, b, c, d, x[i+ 0], 7 , -680876936); 101 | d = ff(d, a, b, c, x[i+ 1], 12, -389564586); 102 | c = ff(c, d, a, b, x[i+ 2], 17, 606105819); 103 | b = ff(b, c, d, a, x[i+ 3], 22, -1044525330); 104 | a = ff(a, b, c, d, x[i+ 4], 7 , -176418897); 105 | d = ff(d, a, b, c, x[i+ 5], 12, 1200080426); 106 | c = ff(c, d, a, b, x[i+ 6], 17, -1473231341); 107 | b = ff(b, c, d, a, x[i+ 7], 22, -45705983); 108 | a = ff(a, b, c, d, x[i+ 8], 7 , 1770035416); 109 | d = ff(d, a, b, c, x[i+ 9], 12, -1958414417); 110 | c = ff(c, d, a, b, x[i+10], 17, -42063); 111 | b = ff(b, c, d, a, x[i+11], 22, -1990404162); 112 | a = ff(a, b, c, d, x[i+12], 7 , 1804603682); 113 | d = ff(d, a, b, c, x[i+13], 12, -40341101); 114 | c = ff(c, d, a, b, x[i+14], 17, -1502002290); 115 | b = ff(b, c, d, a, x[i+15], 22, 1236535329); 116 | 117 | a = gg(a, b, c, d, x[i+ 1], 5 , -165796510); 118 | d = gg(d, a, b, c, x[i+ 6], 9 , -1069501632); 119 | c = gg(c, d, a, b, x[i+11], 14, 643717713); 120 | b = gg(b, c, d, a, x[i+ 0], 20, -373897302); 121 | a = gg(a, b, c, d, x[i+ 5], 5 , -701558691); 122 | d = gg(d, a, b, c, x[i+10], 9 , 38016083); 123 | c = gg(c, d, a, b, x[i+15], 14, -660478335); 124 | b = gg(b, c, d, a, x[i+ 4], 20, -405537848); 125 | a = gg(a, b, c, d, x[i+ 9], 5 , 568446438); 126 | d = gg(d, a, b, c, x[i+14], 9 , -1019803690); 127 | c = gg(c, d, a, b, x[i+ 3], 14, -187363961); 128 | b = gg(b, c, d, a, x[i+ 8], 20, 1163531501); 129 | a = gg(a, b, c, d, x[i+13], 5 , -1444681467); 130 | d = gg(d, a, b, c, x[i+ 2], 9 , -51403784); 131 | c = gg(c, d, a, b, x[i+ 7], 14, 1735328473); 132 | b = gg(b, c, d, a, x[i+12], 20, -1926607734); 133 | 134 | a = hh(a, b, c, d, x[i+ 5], 4 , -378558); 135 | d = hh(d, a, b, c, x[i+ 8], 11, -2022574463); 136 | c = hh(c, d, a, b, x[i+11], 16, 1839030562); 137 | b = hh(b, c, d, a, x[i+14], 23, -35309556); 138 | a = hh(a, b, c, d, x[i+ 1], 4 , -1530992060); 139 | d = hh(d, a, b, c, x[i+ 4], 11, 1272893353); 140 | c = hh(c, d, a, b, x[i+ 7], 16, -155497632); 141 | b = hh(b, c, d, a, x[i+10], 23, -1094730640); 142 | a = hh(a, b, c, d, x[i+13], 4 , 681279174); 143 | d = hh(d, a, b, c, x[i+ 0], 11, -358537222); 144 | c = hh(c, d, a, b, x[i+ 3], 16, -722521979); 145 | b = hh(b, c, d, a, x[i+ 6], 23, 76029189); 146 | a = hh(a, b, c, d, x[i+ 9], 4 , -640364487); 147 | d = hh(d, a, b, c, x[i+12], 11, -421815835); 148 | c = hh(c, d, a, b, x[i+15], 16, 530742520); 149 | b = hh(b, c, d, a, x[i+ 2], 23, -995338651); 150 | 151 | a = ii(a, b, c, d, x[i+ 0], 6 , -198630844); 152 | d = ii(d, a, b, c, x[i+ 7], 10, 1126891415); 153 | c = ii(c, d, a, b, x[i+14], 15, -1416354905); 154 | b = ii(b, c, d, a, x[i+ 5], 21, -57434055); 155 | a = ii(a, b, c, d, x[i+12], 6 , 1700485571); 156 | d = ii(d, a, b, c, x[i+ 3], 10, -1894986606); 157 | c = ii(c, d, a, b, x[i+10], 15, -1051523); 158 | b = ii(b, c, d, a, x[i+ 1], 21, -2054922799); 159 | a = ii(a, b, c, d, x[i+ 8], 6 , 1873313359); 160 | d = ii(d, a, b, c, x[i+15], 10, -30611744); 161 | c = ii(c, d, a, b, x[i+ 6], 15, -1560198380); 162 | b = ii(b, c, d, a, x[i+13], 21, 1309151649); 163 | a = ii(a, b, c, d, x[i+ 4], 6 , -145523070); 164 | d = ii(d, a, b, c, x[i+11], 10, -1120210379); 165 | c = ii(c, d, a, b, x[i+ 2], 15, 718787259); 166 | b = ii(b, c, d, a, x[i+ 9], 21, -343485551); 167 | 168 | a = add(a, olda); 169 | b = add(b, oldb); 170 | c = add(c, oldc); 171 | d = add(d, oldd); 172 | } 173 | return rhex(a) + rhex(b) + rhex(c) + rhex(d); 174 | } 175 | 176 | 177 | -------------------------------------------------------------------------------- /static/pex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/torhve/pix/6ad69796e43cf8a93c33a898921d006bba5102a0/static/pex.png -------------------------------------------------------------------------------- /static/photongx.js: -------------------------------------------------------------------------------- 1 | var photongx = (function($container, $items) { 2 | var slideshow = false, 3 | currentimage = parseInt(window.location.hash.replace('#', ''), 10) || -1, 4 | slideshowtimer, 5 | slideinterval = 4000, 6 | offset = 4, 7 | startPos, 8 | endPos, 9 | lastPos; 10 | 11 | // If lightbox already exists it means that photngx was called 12 | // multiple times on the same page 13 | // In that case we remove the lightbox, since the lightbox contains 14 | // bindings that would trigger multiple times if not 15 | var lb = document.getElementById('lightbox'); 16 | if(lb != null) { 17 | // Lightbox should always be a child of body 18 | document.body.removeChild(lb); 19 | } 20 | // We also need to unbind our previously registered event handlers 21 | // or they would all trigger multiple times 22 | $(document).off('next_image prev_image keydown debouncedresize'); 23 | 24 | // Image preloading function 25 | $.fn.preload = function() { 26 | this.each(function(){ 27 | // Check if url starts with / 28 | if (this[0] == '/') { 29 | $('')[0].src = this; 30 | } 31 | }); 32 | } 33 | $(window).on("debouncedresize", function(event) { 34 | setColumnwidth(); 35 | $container.trigger('refreshWookmark'); 36 | }); 37 | 38 | // calculate and set optimal column size 39 | this.setColumnwidth = function() { 40 | var docwidth = document.body.clientWidth; 41 | var cwidth = 0; 42 | var columns = 0; 43 | // Decide on the best column width depending on docwidth 44 | // people with huge screens can tolerate more columns 45 | if (docwidth > 2500) 46 | columns = 5; 47 | else if (docwidth > 1900) 48 | columns = 4; 49 | else if (docwidth > 1000) 50 | columns = 3; 51 | else if (docwidth >= 790) 52 | columns = 2; 53 | else if (docwidth < 790) 54 | columns = 1; 55 | 56 | // also subtract columns*offsett, since every item got offset px margin, offset/2 on each side 57 | cwidth = docwidth / columns - (offset*(columns-1)); 58 | //$('.item').css('max-width', cwidth + 'px'); 59 | $items.find('img').css('width', cwidth + 'px'); 60 | //console.log('Decided on ', columns, ' columns with docwidth ', docwidth); 61 | } 62 | setColumnwidth(); 63 | 64 | this.wookmarkIt = function() { 65 | setColumnwidth(); 66 | $items.wookmark({ 67 | container: $container, 68 | autoResize: false, 69 | flexibleWidth: false, 70 | outerOffset: 0, 71 | align: 'left', 72 | offset: offset 73 | }); 74 | } 75 | 76 | 77 | $container.imagesLoaded(function( $images, $proper, $broken ) { 78 | //setColumnwidth(); 79 | 80 | wookmarkIt(); 81 | 82 | // We are loaded, so hide the spinner 83 | $('.spinner').addClass('hidden'); 84 | }); 85 | 86 | // We got a new browser state from pressing prev or next buttons 87 | /* 88 | window.addEventListener("popstate", function (evt) { 89 | if (evt.state && evt.state.image) { 90 | navigateImage(evt.state.image); 91 | } 92 | else { 93 | // Assume we got an url that doesn't show an image 94 | hideLB(); 95 | } 96 | }); 97 | */ 98 | 99 | $('.lb').click(function(e) { 100 | // Show spinner 101 | $('#spinner').removeClass('hidden'); 102 | 103 | //prevent default action (hyperlink) 104 | e.preventDefault(); 105 | 106 | createLB(); 107 | 108 | //Get clicked link href 109 | //var image_href = $(this).attr("href"); 110 | 111 | // Save the current image 112 | currentimage = $items.index($(this).parent()); 113 | 114 | navigateImage(currentimage); 115 | 116 | //showLB(); 117 | 118 | // Push inn // to history 119 | // FIXME history.pushState({ image: currentimage }, null, window.location.href + currentimage + "/"); 120 | 121 | /* 122 | If the lightbox window HTML already exists in document, 123 | change the img src to to match the href of whatever link was clicked 124 | 125 | If the lightbox window HTML doesn't exists, create it and insert it. 126 | (This will only happen the first time around) 127 | */ 128 | /* 129 | createLB(); 130 | 131 | setLBimage(image_href); 132 | countView($(this).attr('id')); 133 | */ 134 | 135 | //showLB(); 136 | }); 137 | 138 | this.createLB = function() { 139 | if ($('#lightbox').length == 0) { // #lightbox does not exist 140 | //create HTML markup for lightbox window 141 | var lightbox = 142 | ''; 153 | 154 | //var svg_image_blur = '' 155 | // insert svg image blur 156 | //$('body').append(svg_image_blur); 157 | 158 | // insert lightbox HTML into page 159 | $('body').append(lightbox); 160 | 161 | // Run the set here to, to trigger click 162 | $('#next').bind('click', function(e) { 163 | // Reset timer so we don't doubleskip 164 | if (slideshow) { 165 | pause(); 166 | play(); 167 | } 168 | $(document).trigger("next_image"); 169 | return false; 170 | }); 171 | // Handle clicks on the prev link 172 | $('#prev').bind('click', function(e) { 173 | if (slideshow) { 174 | // Reset timer so we don't doubleskip 175 | pause(); 176 | play(); 177 | } 178 | $(document).trigger("prev_image"); 179 | return false; 180 | }); 181 | 182 | // Handle clicks on the fs link 183 | $('#goFS').bind('click', function(e) { 184 | e.preventDefault(); 185 | goFS(); 186 | return false; 187 | }); 188 | 189 | // Handle clicks on the play link 190 | $('#play').bind('click', function(e) { 191 | if($('#play i').hasClass('fa-play')) { 192 | play(); 193 | }else { 194 | pause(); 195 | } 196 | e.preventDefault(); 197 | return false; 198 | }); 199 | 200 | // Handle all clicks in lb-mode 201 | $('#lightbox').bind('click', function(e) { 202 | var target = $(e.target); 203 | var id = target.attr('id'); 204 | hideLB(); 205 | return false; 206 | }); 207 | // Handle touch events in lb-mode 208 | $('#lightbox').bind('touchstart touchmove touchend', function(ev) { 209 | var e = ev.originalEvent; 210 | if(e.type == 'touchstart') { 211 | //record the start clientX 212 | startPos = e.touches[0].clientX; 213 | 214 | //lastPos is startPos at the beginning 215 | lastPos = startPos; 216 | 217 | //we'll keep track of direction as a signed integer. 218 | // -1 is left, 1 is right and 0 is staying still 219 | direction = 0; 220 | } 221 | else if(e.type == 'touchmove' ) { 222 | e.preventDefault(); 223 | 224 | //figure out the direction 225 | if(lastPos > startPos){ 226 | direction = -1; 227 | }else{ 228 | direction = 1; 229 | } 230 | //save the last position, we need it for touch end 231 | lastPos = e.touches[0].clientX; 232 | } 233 | else if(e.type == 'touchend'){ 234 | //figure out if we have moved left or right beyond a threshold 235 | //(50 pixels in this case) 236 | if(lastPos - startPos > 50){ 237 | $(document).trigger("prev_image"); 238 | } else if(lastPos - startPos < -50){ 239 | $(document).trigger("next_image"); 240 | }else{ 241 | //we are not advancing 242 | } 243 | } 244 | 245 | return false; 246 | }); 247 | } 248 | } 249 | 250 | var setLBimage = function(image_href) { 251 | 252 | var swapsrc = function(image_href) { 253 | var imgfront = document.getElementById('img-front'); 254 | // Save the old src so we can compare it to the new one, since the new vs old can use relative vs absolute URL. 255 | var oldsrc = imgfront.src; 256 | imgfront.src = image_href; 257 | var newsrc = imgfront.src; 258 | // The onload event will not fire if the src does not change so we check for this condition 259 | if (oldsrc == newsrc) { 260 | showLB(); 261 | }else { 262 | if(slideshow) { 263 | imgfront.onload = function() { 264 | $('#img-front').css('opacity', 0.999).one("transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd", function(){ 265 | $('#img-front').css('opacity', 1); 266 | }); 267 | } 268 | } 269 | else { 270 | imgfront.onload = function() { showLB(); }; 271 | } 272 | } 273 | } 274 | /* ANIM slideshow */ 275 | if(slideshow) { 276 | $('#img-front').css('opacity', 0).one("transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd", function(){ 277 | swapsrc(image_href); 278 | }); 279 | }else { 280 | swapsrc(image_href); 281 | } 282 | }; 283 | this.showLB = function() { 284 | $('#lbcontent').imagesLoaded(function( $images, $proper, $broken ) { 285 | // effects for background 286 | $container.addClass('backgrounded'); 287 | // We are loaded, so hide the spinner 288 | $('.spinner').addClass('hidden'); 289 | //show lightbox window - you could use .show('fast') for a transition 290 | $('#lightbox').show(); 291 | $('#img-front').css('opacity', 1); 292 | }); 293 | }; 294 | 295 | var hideLB = function() { 296 | if($('#goFS i').hasClass('fa fa-compress')) { 297 | document.cancelFullScreen(); 298 | } 299 | // effects for background 300 | $container.removeClass('backgrounded'); 301 | //$('#lightbox').hide(); 302 | $('#lightbox').hide(); 303 | // Stop any running slideshow; 304 | pause(); 305 | 306 | // Push away image number path 307 | /* 308 | var base_parts = window.location.href.split("/"); 309 | if (base_parts[base_parts.length-2] == currentimage) { 310 | base = base_parts.slice(0, base_parts.length - 2).join("/") + "/"; 311 | // FIXME history.pushState({ image: null }, null, base); 312 | } 313 | */ 314 | 315 | // Remove hash (and scroll to the current image) 316 | var link = $($items[currentimage]).find('a'); 317 | window.location.hash = link.attr('id'); 318 | }; 319 | 320 | document.cancelFullScreen = document.webkitExitFullscreen || document.mozCancelFullScreen || document.exitFullscreen; 321 | 322 | var goFS = function(e) { 323 | if($('#goFS i').hasClass('fa-arrows-alt')) { 324 | 325 | var elem = document.getElementById('lightbox'); 326 | 327 | if (elem.requestFullScreen) { 328 | elem.requestFullScreen(); 329 | } else if (elem.mozRequestFullScreen) { 330 | elem.mozRequestFullScreen(); 331 | } else if (elem.webkitRequestFullScreen) { 332 | elem.webkitRequestFullScreen(); 333 | } else if (elem.msRequestFullscreen) { 334 | elem.msRequestFullScreen(); 335 | } 336 | elem.onwebkitfullscreenchange = onFullScreenExit; 337 | elem.onmozfullscreenchange = onFullScreenExit; 338 | 339 | $('#goFS i').removeClass('fa-arrows-alt').addClass('fa-compress'); 340 | }else { 341 | document.cancelFullScreen(); 342 | $('#goFS i').removeClass('fa-compress').addClass('fa-arrows-alt'); 343 | } 344 | } 345 | var onFullScreenExit = function() { 346 | console.log('onFSExit'); 347 | } 348 | 349 | 350 | // Slideshow 351 | var play = function() { 352 | $('#play i').removeClass('fa-play').addClass('fa-pause'); 353 | slideshow = true; 354 | slideshowtimer = setInterval(function(){ $(document).trigger("next_image"); }, slideinterval); 355 | } 356 | // Slideshow 357 | var pause = function() { 358 | $('#play i').removeClass('fa-pause').addClass('fa-play'); 359 | slideshow = false; 360 | window.clearInterval(slideshowtimer); 361 | } 362 | 363 | // Clamp skip to images available 364 | var clampSkip = function (c) { 365 | if (c < 0) { 366 | // we are at the start, figure out the amount of items and 367 | // go to the end 368 | c = $items.length - 1 ; 369 | }else if (c > ($items.length-1)) { 370 | c = 0; 371 | } 372 | 373 | return c; 374 | } 375 | // Function responsible for swapping the current lightbox image 376 | // it wraps on start and end, and preloads 3 images in the current 377 | // scrolling direction 378 | this.navigateImage = function(c) { 379 | var link = $($items[c]).find('a'); 380 | var image_href = link.attr('href'); 381 | setLBimage(image_href); 382 | 383 | var cone = c+1, ctwo = c+2 , cthree = c+3; 384 | // We are going backwards 385 | if (c < currentimage) { 386 | cone = c-1, ctwo = c-2, cthree = c-3; 387 | } 388 | // Only load 1 image, faster swapping 389 | $([ 390 | $($items[parseInt(cone)]).find('a').attr('href'), 391 | ]).preload(); 392 | 393 | //$('#image-'+String(parseInt(ctwo))).attr('href'), 394 | //$('#image-'+String(parseInt(cthree))).attr('href') 395 | currentimage = c; 396 | 397 | 398 | // Update hash 399 | window.location.hash = '#' + c; 400 | 401 | countView(link.attr('id')); 402 | } 403 | 404 | // 405 | // Function responsible for counting clicks/views in the backend 406 | // It uses the HTML id of the image which the backend uses to increment the view 407 | // counter of the correct image 408 | // 409 | this.countView = function(file_name) { 410 | if (file_name == undefined || file_name == '') return; 411 | $.getJSON('/api/img/click', { 'img':file_name}, function(data) { 412 | if (!data.views > 0) { 413 | console.log('Error counting clicks. Response from backend was',data); 414 | } 415 | }); 416 | } 417 | 418 | $(document).on('next_image', function (evt) { 419 | // Get image number corrected for skipping passed last image 420 | var image_num = clampSkip(currentimage+1); 421 | 422 | // Cut out the image number we are at and replace with next image 423 | var base_parts = window.location.href.split("/"); 424 | if (base_parts[base_parts.length-2] == currentimage) 425 | base = base_parts.slice(0, base_parts.length - 2).join("/") + "/"; 426 | else 427 | base = window.location.href; 428 | 429 | // Push new url for to history for the image we are about to display 430 | // FIXME history.pushState({ image: image_num }, null, base + image_num + "/"); 431 | navigateImage(image_num); 432 | }); 433 | 434 | $(document).on('prev_image', function (evt) { 435 | // Get image number corrected for skipping passed first image 436 | var image_num = clampSkip(currentimage - 1); 437 | 438 | // Cut out the image number we are at and replace with next image 439 | var base_parts = window.location.href.split("/"); 440 | if (base_parts[base_parts.length-2] == currentimage) 441 | base = base_parts.slice(0, base_parts.length - 2).join("/") + "/"; 442 | else 443 | base = window.location.href; 444 | 445 | // Push new url for to history for the image we are about to display 446 | // FIXME history.pushState({ image: image_num }, null, base + image_num + "/"); 447 | navigateImage(image_num); 448 | }); 449 | 450 | $(document).keydown(function(e){ 451 | if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey ) return true; 452 | // Don't do anything if lightbox isn't there 453 | if (!document.getElementById('lightbox')) return true; 454 | // Don't do anything if lightbox isn't visible 455 | if (document.getElementById('lightbox').style.display == 'none') return true; 456 | 457 | if (e.keyCode == 27) { 458 | hideLB(); 459 | return false; 460 | } 461 | else if (e.keyCode == 37 || e.keyCode == 39) { 462 | if (slideshow) { 463 | // Reset timer so we don't doubleskip 464 | pause(); 465 | play(); 466 | } 467 | if (e.keyCode == 37) { 468 | $(document).trigger("prev_image"); 469 | } 470 | else if (e.keyCode == 39) { 471 | $(document).trigger("next_image"); 472 | } 473 | return false; 474 | } 475 | else if (e.keyCode == 70) { 476 | goFS(); 477 | } 478 | else if (e.keyCode == 32) { 479 | $('#play').click(); 480 | } 481 | }); 482 | 483 | 484 | // If currentImage is set, create lightbox and navigate to it 485 | if(currentimage > -1) { 486 | createLB(); 487 | navigateImage(currentimage); 488 | showLB(); 489 | } 490 | 491 | return this; 492 | }); 493 | -------------------------------------------------------------------------------- /static/screen.less: -------------------------------------------------------------------------------- 1 | // Core variables and mixins 2 | @import "bootstrap/less/variables.less"; 3 | @import "bootstrap/less/mixins.less"; 4 | 5 | // Reset 6 | @import "bootstrap/less/normalize.less"; 7 | @import "bootstrap/less/print.less"; 8 | 9 | // Core CSS 10 | @import "bootstrap/less/scaffolding.less"; 11 | @import "bootstrap/less/type.less"; 12 | @import "bootstrap/less/code.less"; 13 | @import "bootstrap/less/grid.less"; 14 | @import "bootstrap/less/tables.less"; 15 | @import "bootstrap/less/forms.less"; 16 | @import "bootstrap/less/buttons.less"; 17 | // Components 18 | @import "bootstrap/less/navs.less"; 19 | @import "bootstrap/less/navbar.less"; 20 | // Utility classes 21 | @import "bootstrap/less/utilities.less"; 22 | @import "bootstrap/less/responsive-utilities.less"; 23 | @import "variables.less"; 24 | 25 | 26 | :-webkit-full-screen { 27 | background: @background; 28 | } 29 | 30 | html, body, .modal-content { 31 | font-family: "Tahoma", sans-serif; 32 | background: @background; 33 | color: @fg; 34 | } 35 | a, a:hover, a:focus { 36 | color: @blue; 37 | text-decoration: none; 38 | } 39 | .btn:hover, .btn:focus, .btn.active { 40 | color: @fg; 41 | } 42 | .header, table { 43 | margin-bottom: 15px; 44 | } 45 | #about { 46 | ul { 47 | text-align: left; 48 | margin-bottom: 40px; 49 | margin: none; 50 | } 51 | li { 52 | font-size: larger; 53 | list-style-type: none; 54 | margin: 0; 55 | i { 56 | margin-right: 5px; 57 | } 58 | } 59 | .actions { 60 | text-align: justify; 61 | margin-top: 60px; 62 | margin-bottom: 60px; 63 | } 64 | .footer { 65 | text-align: left; 66 | color: #999; 67 | } 68 | img { 69 | max-width: 100%; 70 | } 71 | 72 | } 73 | .label-bg (@opacity: 0.25) { 74 | background: rgba(0, 0, 0, @opacity*1.5); 75 | -webkit-box-shadow: 0 2px 10px rgba(0,0,0, @opacity); 76 | -moz-box-shadow: 0 2px 10px rgba(0,0,0, @opacity); 77 | box-shadow: 0 2px 10px rgba(0,0,0, @opacity); 78 | color: white; 79 | } 80 | .album-label, .admin-album-label { 81 | .label-bg; 82 | position: absolute; 83 | top: 15px; 84 | left: 0; 85 | z-index: 1; 86 | } 87 | .album-label:hover, .item:hover .album-label { 88 | .label-bg(0.50); 89 | } 90 | .album-label, .album-label a, .album-label a:hover { 91 | color: rgba(255,255,255,0.9); 92 | text-shadow: 0 -1px 0 rgba(0,0,0,.5); 93 | font-size: 1.6em; 94 | padding: 15px; 95 | padding-left: 5px; 96 | text-transform: capitalize; 97 | } 98 | .thumb-label { 99 | display: none; 100 | position: absolute; 101 | bottom: 0; 102 | left: 0; 103 | z-index: 1; 104 | width: 100%; 105 | padding-left: 5px; 106 | padding-right: 5px; 107 | .label-bg(0.4); 108 | } 109 | .admin-album-label, .admin-label { 110 | color: white; 111 | z-index: 1; 112 | position: absolute; 113 | top: 0; 114 | } 115 | .item:hover .admin-label, .item:hover .thumb-label { 116 | display: block; 117 | } 118 | .admin-label { 119 | display:none; 120 | right:0; 121 | } 122 | .aitem { 123 | pointer: cursor; 124 | } 125 | .items { 126 | width: 100%; 127 | position: relative; 128 | } 129 | .item { 130 | float: left; 131 | } 132 | #fileDrop { 133 | color: #3a87ad; 134 | border: dashed 8px #333; 135 | background-color: #d9edf7; 136 | } 137 | #fileDrop p { 138 | padding: 95px; 139 | font-size: 1.6em; 140 | margin: 10px; 141 | } 142 | #fileList { 143 | list-style-type: none; 144 | margin: 0; 145 | padding: 0; 146 | } 147 | .progress { 148 | margin-bottom: 0; 149 | } 150 | .login { 151 | max-width: 330px; 152 | padding: 15px; 153 | padding-top: 40px; 154 | margin: 0 auto; 155 | } 156 | 157 | 158 | .blue { 159 | color: @blue; 160 | } 161 | .nav-pills > li.active > a, .nav-pills > li.active > a:hover, .nav-pills > li.active > a:focus { 162 | background-color: @blue; 163 | } 164 | .item:hover { 165 | -webkit-box-shadow: 0 0 10px @blue; 166 | -moz-box-shadow: 0 0 10px @blue; 167 | box-shadow: 0 0 10px @blue; 168 | } 169 | .gray { 170 | color: #333; 171 | } 172 | .white { 173 | color: white; 174 | text-shadow: 1px 0px 4px #ffffff; 175 | } 176 | 177 | .adminthumb { 178 | max-width:180px; 179 | } 180 | body.admin .item { 181 | margin-right: 4px; 182 | } 183 | 184 | /* TRANSITION */ 185 | .te-perspective{ 186 | -webkit-perspective: 1000; 187 | } 188 | .te-cover, 189 | .te-transition{ 190 | -webkit-transform-style: preserve-3d; 191 | } 192 | .te-cover.te-hide, 193 | .te-transition, 194 | .te-images{ display: none; } 195 | .te-transition.te-show{ display: block; } 196 | .te-cube-face{ 197 | position: absolute; 198 | display: block; 199 | overflow: hidden; 200 | } 201 | .te-back, 202 | .te-front{ 203 | position: absolute; 204 | -webkit-backface-visibility: hidden; 205 | } 206 | .te-front{ z-index: 2; } 207 | .te-back{ z-index: 1; } 208 | 209 | /***************** Cube4 ********************/ 210 | .te-cube4 .te-cube-front{ 211 | -webkit-transform: scale3d(.858,.858,.858) translate3d(0,0,164px); 212 | } 213 | .te-cube4 .te-cube-left{ 214 | -webkit-transform: scale3d(.858,.858,.858) rotate3d(0,1,0,-90deg) translate3d(0,0,164px); 215 | } 216 | .te-cube4 .te-cube-right{ 217 | -webkit-transform: scale3d(.858,.858,.858) rotate3d(0,1,0,90deg) translate3d(0,0,164px); 218 | } 219 | .te-cube4 .te-cube-top{ 220 | -webkit-transform: scale3d(.858,.858,.858) rotate3d(1,0,0,90deg) translate3d(0,0,164px); 221 | } 222 | .te-cube4 .te-cube-bottom{ 223 | -webkit-transform: scale3d(.858,.858,.858) rotate3d(0,1,0,-90deg) translate3d(0,0,164px); 224 | } 225 | .te-cube4.te-show{ 226 | -webkit-animation: cube4 0.5s ease-in-out; 227 | } 228 | @-webkit-keyframes cube4{ 229 | 0% { -webkit-transform: rotate3d(0,1,0,0); } 230 | 100% { -webkit-transform: rotate3d(0,1,0,90deg); } 231 | } 232 | .hidden { 233 | display: none; 234 | 235 | } 236 | .btn { 237 | border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); 238 | background: #505050; /* Old browsers */ 239 | background: -moz-linear-gradient(top, #505050 0%, #2b2b2b 100%); /* FF3.6+ */ 240 | background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#505050), color-stop(100%,#2b2b2b)); /* Chrome,Safari4+ */ 241 | background: -webkit-linear-gradient(top, #505050 0%,#2b2b2b 100%); /* Chrome10+,Safari5.1+ */ 242 | background: -o-linear-gradient(top, #505050 0%,#2b2b2b 100%); /* Opera 11.10+ */ 243 | background: -ms-linear-gradient(top, #505050 0%,#2b2b2b 100%); /* IE10+ */ 244 | background: linear-gradient(top, #505050 0%,#2b2b2b 100%); /* W3C */ 245 | filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#4f4f4f', endColorstr='#2b2b2b',GradientType=0 ); /* IE6-9 */ 246 | color: #999; 247 | } 248 | .btn-primary { 249 | color: @blue; 250 | } 251 | .btn-success { 252 | color: #47A447; 253 | } 254 | .btn-danger { 255 | color: #D9534F; 256 | } 257 | .btn-grey { 258 | background-color: #DDD; 259 | } 260 | input, .form-control, .badge { 261 | border: 0; 262 | border-radius: 0; 263 | color: #ccc; 264 | box-shadow: 0px 1px 0px rgba(255, 255, 255, 0.1), 0px 1px 7px 0px rgba(0, 0, 0, 0.8) inset; 265 | background: none repeat scroll 0% 0% rgba(0, 0, 0, 0.3); 266 | } 267 | body.admin table { 268 | width: 100%; 269 | margin-bottom: 15px; 270 | } 271 | td { 272 | padding: 5px; 273 | } 274 | .sidebar { 275 | background-color: #222; 276 | margin-bottom: 30px; 277 | } 278 | .sidebar, th { 279 | padding: 5px; 280 | } 281 | .sidebar-nav { 282 | padding-bottom: 30px; 283 | li a.btn { 284 | } 285 | li a:hover { 286 | color: #222; 287 | } 288 | } 289 | .li-nav-album, .li-nav-album>a { 290 | cursor: pointer; 291 | background: rgba(0, 0, 0, 0.4); 292 | color: @fg; 293 | font-weight: bold; 294 | } 295 | .nav-header { 296 | display: block; 297 | font-size: 11px; 298 | font-weight: bold; 299 | line-height: 20px; 300 | color: #999999; 301 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 302 | text-transform: uppercase; 303 | margin-bottom: 15px; 304 | } 305 | .vertical-line { 306 | -webkit-box-shadow: rgba(255, 255, 255, 0.07) 0 1px 0; 307 | -moz-box-shadow: rgba(255, 255, 255, 0.07) 0 1px 0; 308 | box-shadow: rgba(255, 255, 255, 0.07) 0 1px 0; 309 | border-bottom: 1px solid #121212; 310 | } 311 | .horizontal-line, .horizontal-line-top, .modal-header, th { 312 | -webkit-box-shadow: rgba(255, 255, 255, 0.07) 0 1px 0; 313 | -moz-box-shadow: rgba(255, 255, 255, 0.07) 0 1px 0; 314 | box-shadow: rgba(255, 255, 255, 0.07) 0 1px 0; 315 | } 316 | .horizontal-line, .modal-header, th { 317 | border-bottom: 1px solid #121212; 318 | } 319 | .horizontal-line-top, .modal-footer { 320 | border-top: 1px solid #121212; 321 | } 322 | 323 | .ph { 324 | padding: 15px; 325 | min-height: 50px; 326 | margin-bottom: 20px; 327 | background: rgba(0, 0, 0, 0.4); 328 | text-shadow: 0px -1px 0px rgba(0, 0, 0, 0.15); 329 | background-color: #563D7C; 330 | border-color: #463265; 331 | box-shadow: 0px 1px 0px rgba(255, 255, 255, 0.1); 332 | 333 | background-color: #222; 334 | border-color: #080808; 335 | border-width: 0px 0px 1px; 336 | } 337 | 338 | @import "lightbox.less"; 339 | -------------------------------------------------------------------------------- /static/spinner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/torhve/pix/6ad69796e43cf8a93c33a898921d006bba5102a0/static/spinner.png -------------------------------------------------------------------------------- /static/variables.less: -------------------------------------------------------------------------------- 1 | @blue: #3ea8f4; 2 | @background: #0e0e0e; 3 | @fg: #ddd; 4 | -------------------------------------------------------------------------------- /uploaded.html: -------------------------------------------------------------------------------- 1 | File successfully uploaded! 2 | -------------------------------------------------------------------------------- /uploadform.html: -------------------------------------------------------------------------------- 1 |

      Upload images to the album {{albumname}}

      2 | 3 | 4 |
      5 |

      Drop files here to upload

      6 |
      7 | 8 | 9 |
      10 | 11 |

      12 |

      13 |
      14 | 15 |
      16 |
      17 |

      18 |

      19 | Upload files 20 | Clear list 21 |

      22 |
      23 | 24 |
      25 | 26 |
      27 |
        28 |
        29 | -------------------------------------------------------------------------------- /widgets/Tupfile: -------------------------------------------------------------------------------- 1 | include_rules 2 | -------------------------------------------------------------------------------- /widgets/base.moon: -------------------------------------------------------------------------------- 1 | import Widget from require "lapis.html" 2 | 3 | class Base extends Widget 4 | render_errors: => 5 | if @errors 6 | div "Errors:" 7 | ul -> 8 | for e in *@errors 9 | li e 10 | --------------------------------------------------------------------------------