A Python content management system designed for kirsle.net featuring a blog, comments and photo albums. https://rophako.kirsle.net/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

photo.py 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. # -*- coding: utf-8 -*-
  2. from __future__ import unicode_literals
  3. """Photo album models."""
  4. import os
  5. from flask import g
  6. import time
  7. import requests
  8. from PIL import Image
  9. import hashlib
  10. import random
  11. from rophako.settings import Config
  12. import rophako.jsondb as JsonDB
  13. from rophako.utils import sanitize_name, remote_addr
  14. from rophako.log import logger
  15. # Maps the friendly names of photo sizes with their pixel values from config.
  16. PHOTO_SCALES = dict(
  17. large=int(Config.photo.width_large),
  18. thumb=int(Config.photo.width_thumb),
  19. avatar=int(Config.photo.width_avatar),
  20. )
  21. def list_albums():
  22. """Retrieve a sorted list of the photo albums."""
  23. index = get_index()
  24. result = []
  25. # Missing settings?
  26. if not "settings" in index:
  27. index["settings"] = dict()
  28. for album in index["album-order"]:
  29. if not album in index["settings"]:
  30. # Need to initialize its settings.
  31. index["settings"][album] = dict(
  32. format="classic",
  33. description="",
  34. )
  35. write_index(index)
  36. cover = index["covers"][album]
  37. pic = index["albums"][album][cover]["thumb"]
  38. result.append(dict(
  39. name=album,
  40. format=index["settings"][album]["format"],
  41. description=index["settings"][album]["description"],
  42. cover=pic,
  43. data=index["albums"][album],
  44. ))
  45. return result
  46. def get_album(name):
  47. """Get details about an album."""
  48. index = get_index()
  49. if not name in index["albums"]:
  50. return None
  51. album = index["albums"][name]
  52. cover = index["covers"][name]
  53. return dict(
  54. name=name,
  55. format=index["settings"][name]["format"],
  56. description=index["settings"][name]["description"],
  57. cover=album[cover]["thumb"],
  58. )
  59. def rename_album(old_name, new_name):
  60. """Rename an existing photo album.
  61. Returns True on success, False if the new name conflicts with another
  62. album's name."""
  63. old_name = sanitize_name(old_name)
  64. new_name = sanitize_name(new_name)
  65. index = get_index()
  66. # New name is unique?
  67. if new_name in index["albums"]:
  68. logger.error("Can't rename album: new name already exists!")
  69. return False
  70. def transfer_key(obj, old_key, new_key):
  71. # Reusable function to do a simple move on a dict key.
  72. obj[new_key] = obj[old_key]
  73. del obj[old_key]
  74. # Simple moves.
  75. transfer_key(index["albums"], old_name, new_name)
  76. transfer_key(index["covers"], old_name, new_name)
  77. transfer_key(index["photo-order"], old_name, new_name)
  78. transfer_key(index["settings"], old_name, new_name)
  79. # Update the photo -> album maps.
  80. for photo in index["map"]:
  81. if index["map"][photo] == old_name:
  82. index["map"][photo] = new_name
  83. # Fix the album ordering.
  84. new_order = list()
  85. for name in index["album-order"]:
  86. if name == old_name:
  87. name = new_name
  88. new_order.append(name)
  89. index["album-order"] = new_order
  90. # And save.
  91. write_index(index)
  92. return True
  93. def list_photos(album):
  94. """List the photos in an album."""
  95. album = sanitize_name(album)
  96. index = get_index()
  97. if not album in index["albums"]:
  98. return None
  99. result = []
  100. for key in index["photo-order"][album]:
  101. data = index["albums"][album][key]
  102. result.append(dict(
  103. key=key,
  104. data=data,
  105. ))
  106. return result
  107. def photo_exists(key):
  108. """Query whether a photo exists in the album."""
  109. index = get_index()
  110. return key in index["map"]
  111. def get_photo(key):
  112. """Look up a photo by key. Returns None if not found."""
  113. index = get_index()
  114. photo = None
  115. if key in index["map"]:
  116. album = index["map"][key]
  117. if album in index["albums"] and key in index["albums"][album]:
  118. photo = index["albums"][album][key]
  119. else:
  120. # The map is wrong!
  121. logger.error("Photo album map is wrong; key {} not found in album {}!".format(key, album))
  122. del index["map"][key]
  123. write_index(index)
  124. if not photo:
  125. return None
  126. # Inject additional information about the photo:
  127. # What position it's at in the album, how many photos total, and the photo
  128. # IDs of its siblings.
  129. siblings = index["photo-order"][album]
  130. i = 0
  131. for pid in siblings:
  132. if pid == key:
  133. # We found us! Find the siblings.
  134. sprev, snext = None, None
  135. if i == 0:
  136. # We're the first photo. So previous is the last!
  137. sprev = siblings[-1]
  138. if len(siblings) > i+1:
  139. snext = siblings[i+1]
  140. else:
  141. snext = key
  142. elif i == len(siblings)-1:
  143. # We're the last. So next is first!
  144. sprev = siblings[i-1]
  145. snext = siblings[0]
  146. else:
  147. # Right in the middle.
  148. sprev = siblings[i-1]
  149. snext = siblings[i+1]
  150. # Inject.
  151. photo["album"] = album
  152. photo["position"] = i+1
  153. photo["siblings"] = len(siblings)
  154. photo["previous"] = sprev
  155. photo["next"] = snext
  156. i += 1
  157. return photo
  158. def get_image_dimensions(pic):
  159. """Use PIL to get the image's true dimensions."""
  160. filename = os.path.join(Config.photo.root_private, pic["large"])
  161. img = Image.open(filename)
  162. return img.size
  163. # def update_photo(album, key, data):
  164. # """Update photo meta-data in the album."""
  165. # index = get_index()
  166. # if not album in index["albums"]:
  167. # index["albums"][album] = {}
  168. # if not key in index["albums"][album]:
  169. # index["albums"][album][key] = {}
  170. # # Update!
  171. # index["albums"][album][key].update(data)
  172. # write_index(index)
  173. def crop_photo(key, x, y, length):
  174. """Change the crop coordinates of a photo and re-crop it."""
  175. index = get_index()
  176. if not key in index["map"]:
  177. raise Exception("Can't crop photo: doesn't exist!")
  178. album = index["map"][key]
  179. # Sanity check.
  180. if not album in index["albums"]:
  181. raise Exception("Can't find photo in album!")
  182. logger.debug("Recropping photo {}".format(key))
  183. # Delete all the images except the large one.
  184. for size in ["thumb", "avatar"]:
  185. pic = index["albums"][album][key][size]
  186. logger.debug("Delete {} size: {}".format(size, pic))
  187. os.unlink(os.path.join(Config.photo.root_private, pic))
  188. # Regenerate all the thumbnails.
  189. large = index["albums"][album][key]["large"]
  190. source = os.path.join(Config.photo.root_private, large)
  191. for size in ["thumb", "avatar"]:
  192. pic = resize_photo(source, size, crop=dict(
  193. x=x,
  194. y=y,
  195. length=length,
  196. ))
  197. index["albums"][album][key][size] = pic
  198. # Save changes.
  199. write_index(index)
  200. def set_album_cover(album, key):
  201. """Change the album's cover photo."""
  202. album = sanitize_name(album)
  203. index = get_index()
  204. logger.info("Changing album cover for {} to {}".format(album, key))
  205. if album in index["albums"] and key in index["albums"][album]:
  206. index["covers"][album] = key
  207. write_index(index)
  208. return
  209. logger.error("Failed to change album index! Album or photo not found.")
  210. def edit_photo(key, data):
  211. """Update a photo's data."""
  212. index = get_index()
  213. if not key in index["map"]:
  214. logger.warning("Tried to delete photo {} but it wasn't found?".format(key))
  215. return
  216. album = index["map"][key]
  217. logger.info("Updating data for the photo {} from album {}".format(key, album))
  218. index["albums"][album][key].update(data)
  219. write_index(index)
  220. def edit_album(album, data):
  221. """Update an album's settings (description, format, etc.)"""
  222. album = sanitize_name(album)
  223. index = get_index()
  224. if not album in index["albums"]:
  225. logger.error("Failed to edit album: not found!")
  226. return
  227. index["settings"][album].update(data)
  228. write_index(index)
  229. def rotate_photo(key, rotate):
  230. """Rotate a photo 90 degrees to the left or right."""
  231. photo = get_photo(key)
  232. if not photo: return
  233. # Degrees to rotate.
  234. degrees = None
  235. if rotate == "left":
  236. degrees = 90
  237. elif rotate == "right":
  238. degrees = -90
  239. else:
  240. degrees = 180
  241. new_names = dict()
  242. for size in ["large", "thumb", "avatar"]:
  243. fname = os.path.join(Config.photo.root_private, photo[size])
  244. logger.info("Rotating image {} by {} degrees.".format(fname, degrees))
  245. # Give it a new name.
  246. filetype = fname.split(".")[-1]
  247. outfile = random_name(filetype)
  248. new_names[size] = outfile
  249. img = Image.open(fname)
  250. img = img.rotate(degrees)
  251. img.save(os.path.join(Config.photo.root_private, outfile))
  252. # Delete the old name.
  253. os.unlink(fname)
  254. # Save the new image names.
  255. edit_photo(key, new_names)
  256. def delete_photo(key):
  257. """Delete a photo."""
  258. index = get_index()
  259. if not key in index["map"]:
  260. logger.warning("Tried to delete photo {} but it wasn't found?".format(key))
  261. return
  262. album = index["map"][key]
  263. logger.info("Completely deleting the photo {} from album {}".format(key, album))
  264. photo = index["albums"][album][key]
  265. # Delete all the images.
  266. for size in ["large", "thumb", "avatar"]:
  267. logger.info("Delete: {}".format(photo[size]))
  268. fname = os.path.join(Config.photo.root_private, photo[size])
  269. if os.path.isfile(fname):
  270. os.unlink(fname)
  271. # Delete it from the sort list.
  272. index["photo-order"][album].remove(key)
  273. del index["map"][key]
  274. del index["albums"][album][key]
  275. # Was this the album cover?
  276. if index["covers"][album] == key:
  277. # Try to pick a new one.
  278. if len(index["photo-order"][album]) > 0:
  279. index["covers"][album] = index["photo-order"][album][0]
  280. else:
  281. index["covers"][album] = ""
  282. # If the album is empty now too, delete it as well.
  283. if len(index["albums"][album].keys()) == 0:
  284. del index["albums"][album]
  285. del index["photo-order"][album]
  286. del index["covers"][album]
  287. index["album-order"].remove(album)
  288. write_index(index)
  289. def order_albums(order):
  290. """Reorder the albums according to the new order list."""
  291. index = get_index()
  292. # Sanity check, make sure all albums are included.
  293. if len(order) != len(index["album-order"]):
  294. logger.warning("Can't reorganize albums because the order lists don't match!")
  295. return None
  296. for album in index["album-order"]:
  297. if album not in order:
  298. logger.warning("Tried reorganizing albums, but {} was missing!".format(album))
  299. return None
  300. index["album-order"] = order
  301. write_index(index)
  302. def order_photos(album, order):
  303. """Reorder the photos according to the new order list."""
  304. index = get_index()
  305. if not album in index["albums"]:
  306. logger.warning("Album not found: {}".format(album))
  307. return None
  308. # Sanity check, make sure all albums are included.
  309. if len(order) != len(index["photo-order"][album]):
  310. logger.warning("Can't reorganize photos because the order lists don't match!")
  311. return None
  312. for key in index["photo-order"][album]:
  313. if key not in order:
  314. logger.warning("Tried reorganizing photos, but {} was missing!".format(key))
  315. return None
  316. index["photo-order"][album] = order
  317. write_index(index)
  318. def upload_from_pc(request):
  319. """Upload a photo from the user's filesystem.
  320. This requires the Flask `request` object. Returns a dict with the following
  321. keys:
  322. * success: True || False
  323. * error: if unsuccessful
  324. * photo: if successful
  325. """
  326. form = request.form
  327. count = 0
  328. status = None
  329. for upload in reversed(request.files.getlist("file")):
  330. count += 1
  331. # Make a temp filename for it.
  332. filetype = upload.filename.rsplit(".", 1)[-1]
  333. if not allowed_filetype(upload.filename):
  334. return dict(success=False, error="Unsupported file extension.")
  335. tempfile = "{}/rophako-photo-{}.{}".format(Config.site.tempdir, int(time.time()), filetype)
  336. logger.debug("Save incoming photo to: {}".format(tempfile))
  337. upload.save(tempfile)
  338. # All good so far. Process the photo.
  339. status = process_photo(form, tempfile)
  340. if not status["success"]:
  341. return status
  342. # Multi upload?
  343. if count > 1:
  344. status["multi"] = True
  345. else:
  346. status["multi"] = False
  347. return status
  348. def upload_from_www(form):
  349. """Upload a photo from the Internet.
  350. This requires the `form` object, but not necessarily Flask's. It just has to
  351. be a dict with the form keys for the upload.
  352. Returns the same structure as `upload_from_pc()`.
  353. """
  354. url = form.get("url")
  355. if not url or not allowed_filetype(url):
  356. return dict(success=False, error="Invalid file extension.")
  357. # Make a temp filename for it.
  358. filetype = url.rsplit(".", 1)[1]
  359. tempfile = "{}/rophako-photo-{}.{}".format(Config.site.tempdir, int(time.time()), filetype)
  360. logger.debug("Save incoming photo to: {}".format(tempfile))
  361. # Grab the file.
  362. try:
  363. data = requests.get(url).content
  364. except:
  365. return dict(success=False, error="Failed to get that URL.")
  366. fh = open(tempfile, "wb")
  367. fh.write(data)
  368. fh.close()
  369. # All good so far. Process the photo.
  370. return process_photo(form, tempfile)
  371. def process_photo(form, filename):
  372. """Formats an incoming photo."""
  373. # Resize the photo to each of the various sizes and collect their names.
  374. sizes = dict()
  375. for size in PHOTO_SCALES.keys():
  376. sizes[size] = resize_photo(filename, size)
  377. # Remove the temp file.
  378. os.unlink(filename)
  379. # What album are the photos going to?
  380. album = form.get("album", "")
  381. new_album = form.get("new-album", None)
  382. new_desc = form.get("new-description", None)
  383. if album == "" and new_album:
  384. album = new_album
  385. # Sanitize the name.
  386. album = sanitize_name(album)
  387. if album == "":
  388. logger.warning("Album name didn't pass sanitization! Fall back to default album name.")
  389. album = Config.photo.default_album
  390. # Make up a unique public key for this set of photos.
  391. key = random_hash()
  392. while photo_exists(key):
  393. key = random_hash()
  394. logger.debug("Photo set public key: {}".format(key))
  395. # Get the album index to manipulate ordering.
  396. index = get_index()
  397. # Update the photo data.
  398. if not album in index["albums"]:
  399. index["albums"][album] = {}
  400. if not "settings" in index:
  401. index["settings"] = dict()
  402. if not album in index["settings"]:
  403. index["settings"][album] = {
  404. "format": "classic",
  405. "description": new_desc,
  406. }
  407. index["albums"][album][key] = dict(
  408. ip=remote_addr(),
  409. author=g.info["session"]["uid"],
  410. uploaded=int(time.time()),
  411. caption=form.get("caption", ""),
  412. description=form.get("description", ""),
  413. **sizes
  414. )
  415. # Maintain a photo map to album.
  416. index["map"][key] = album
  417. # Add this pic to the front of the album.
  418. if not album in index["photo-order"]:
  419. index["photo-order"][album] = []
  420. index["photo-order"][album].insert(0, key)
  421. # If this is a new album, add it to the front of the album ordering.
  422. if not album in index["album-order"]:
  423. index["album-order"].insert(0, album)
  424. # Set the album cover for a new album.
  425. if not album in index["covers"] or len(index["covers"][album]) == 0:
  426. index["covers"][album] = key
  427. # Save changes to the index.
  428. write_index(index)
  429. return dict(success=True, photo=key)
  430. def allowed_filetype(filename):
  431. """Query whether the file extension is allowed."""
  432. return "." in filename and \
  433. filename.rsplit(".", 1)[1].lower() in ['jpeg', 'jpe', 'jpg', 'gif', 'png']
  434. def resize_photo(filename, size, crop=None):
  435. """Resize a photo from the target filename into the requested size.
  436. Optionally the photo can be cropped with custom parameters.
  437. """
  438. # Find the file type.
  439. filetype = filename.rsplit(".", 1)[1]
  440. if filetype == "jpeg": filetype = "jpg"
  441. # Open the image.
  442. img = Image.open(filename)
  443. # Make up a unique filename.
  444. outfile = random_name(filetype)
  445. target = os.path.join(Config.photo.root_private, outfile)
  446. logger.debug("Output file for {} scale: {}".format(size, target))
  447. # Get the image's dimensions.
  448. orig_width, orig_height = img.size
  449. new_width = PHOTO_SCALES[size]
  450. logger.debug("Original photo dimensions: {}x{}".format(orig_width, orig_height))
  451. # For the large version, only scale it, don't crop it.
  452. if size == "large":
  453. # Do we NEED to scale it?
  454. if orig_width <= new_width:
  455. logger.debug("Don't need to scale down the large image!")
  456. img.save(target)
  457. return outfile
  458. # Scale it down.
  459. ratio = float(new_width) / float(orig_width)
  460. new_height = int(float(orig_height) * float(ratio))
  461. logger.debug("New image dimensions: {}x{}".format(new_width, new_height))
  462. img = img.resize((new_width, new_height), Image.ANTIALIAS)
  463. img.save(target)
  464. return outfile
  465. # For all other versions, crop them into a square.
  466. x, y, length = 0, 0, 0
  467. # Use 0,0 and find the shortest dimension for the length.
  468. if orig_width > orig_height:
  469. length = orig_height
  470. else:
  471. length = orig_width
  472. # Did they give us crop coordinates?
  473. if crop is not None:
  474. x = crop["x"]
  475. y = crop["y"]
  476. if crop["length"] > 0:
  477. length = crop["length"]
  478. # Adjust the coords if they're impossible.
  479. if x < 0:
  480. logger.warning("X-Coord is less than 0; fixing!")
  481. x = 0
  482. if y < 0:
  483. logger.warning("Y-Coord is less than 0; fixing!")
  484. y = 0
  485. if x > orig_width:
  486. logger.warning("X-Coord is greater than image width; fixing!")
  487. x = orig_width - length
  488. if x < 0: x = 0
  489. if y > orig_height:
  490. logger.warning("Y-Coord is greater than image height; fixing!")
  491. y = orig_height - length
  492. if y < 0: y = 0
  493. # Make sure the crop box fits.
  494. if x + length > orig_width:
  495. diff = x + length - orig_width
  496. logger.warning("Crop box is outside the right edge of the image by {}px; fixing!".format(diff))
  497. length -= diff
  498. if y + length > orig_height:
  499. diff = y + length - orig_height
  500. logger.warning("Crop box is outside the bottom edge of the image by {}px; fixing!".format(diff))
  501. length -= diff
  502. # Do we need to scale?
  503. if new_width == length:
  504. logger.debug("Image doesn't need to be cropped or scaled!")
  505. img.save(target)
  506. return outfile
  507. # Crop to the requested box.
  508. logger.debug("Cropping the photo")
  509. img = img.crop((x, y, x+length, y+length))
  510. # Scale it to the proper dimensions.
  511. img = img.resize((new_width, new_width), Image.ANTIALIAS)
  512. img.save(target)
  513. return outfile
  514. def get_index():
  515. """Get the photo album index, or a new empty DB if it doesn't exist."""
  516. if JsonDB.exists("photos/index"):
  517. return JsonDB.get("photos/index")
  518. return {
  519. "albums": {}, # Album data
  520. "map": {}, # Map photo keys to albums
  521. "covers": {}, # Album cover photos
  522. "photo-order": {}, # Ordering of photos in albums
  523. "album-order": [], # Ordering of albums themselves
  524. }
  525. def write_index(index):
  526. """Save the index back to the DB."""
  527. return JsonDB.commit("photos/index", index)
  528. def random_name(filetype):
  529. """Get a random available file name to save a new photo."""
  530. filetype = filetype.lower()
  531. outfile = random_hash() + "." + filetype
  532. while os.path.isfile(os.path.join(Config.photo.root_private, outfile)):
  533. outfile = random_hash() + "." + filetype
  534. return outfile
  535. def random_hash():
  536. """Get a short random hash to use as the base name for a photo."""
  537. md5 = hashlib.md5()
  538. md5.update(str(random.randint(0, 1000000)).encode("utf-8"))
  539. return md5.hexdigest()[:8]