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.

540 lines
16KB

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