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.

691 lines
20KB

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