Skip to content

Commit

Permalink
Merge pull request #75 from Oliver-Hanikel/image-improvements
Browse files Browse the repository at this point in the history
Image improvements
  • Loading branch information
Linbreux committed Sep 15, 2022
2 parents 0020ece + 68264b9 commit 4b6f0f0
Show file tree
Hide file tree
Showing 8 changed files with 233 additions and 49 deletions.
26 changes: 24 additions & 2 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,38 @@
REMOTE_URL_DEFAULT = ""

WIKI_DIRECTORY_DEFAULT = "wiki"
IMAGES_ROUTE_DEFAULT = "img"
HOMEPAGE_DEFAULT = "homepage.md"
HOMEPAGE_TITLE_DEFAULT = "homepage"
IMAGES_ROUTE_DEFAULT = "img"

PROTECT_EDIT_BY_PASSWORD = 0
PASSWORD_IN_SHA_256 = "0E9C700FAB2D5B03B0581D080E74A2D7428758FC82BD423824C6C11D6A7F155E" #pw: wikmd

# if False: Uses external CDNs to serve some files
LOCAL_MODE = False

IMAGE_ALLOWED_MIME_DEFAULT = ["image/gif", "image/jpeg", "image/png", "image/svg+xml", "image/webp"]
# you need to have cwebp installed for optimization to work
OPTIMIZE_IMAGES_DEFAULT = "no"

CACHE_DIR = "/dev/shm/wikmd/cache"
SEARCH_DIR = "/dev/shm/wikmd/searchindex"


def config_list(yaml_config, config_item_name, default_value):
"""
Function that gets a config item of type list.
Priority is in the following order either from environment variables or yaml file or default value.
"""
if os.getenv(config_item_name.upper()):
# Env Var in the form `EXAMPLE="a, b, c, d"` or `EXAMPLE="a,b,c,d"`
return [ext.strip() for ext in os.getenv(config_item_name.upper()).split(",")]
elif yaml_config[config_item_name.lower()]:
return yaml_config[config_item_name.lower()]
else:
return default_value


class WikmdConfig:
"""
Class that stores the configuration of wikmd.
Expand Down Expand Up @@ -62,13 +80,17 @@ def __init__(self):
self.remote_url = os.getenv("REMOTE_URL") or yaml_config["remote_url"] or REMOTE_URL_DEFAULT

self.wiki_directory = os.getenv("WIKI_DIRECTORY") or yaml_config["wiki_directory"] or WIKI_DIRECTORY_DEFAULT
self.images_route = os.getenv("IMAGES_ROUTE") or yaml_config["images_route"] or IMAGES_ROUTE_DEFAULT
self.homepage = os.getenv("HOMEPAGE") or yaml_config["homepage"] or HOMEPAGE_DEFAULT
self.homepage_title = os.getenv("HOMEPAGE_TITLE") or yaml_config["homepage_title"] or HOMEPAGE_TITLE_DEFAULT
self.images_route = os.getenv("IMAGES_ROUTE") or yaml_config["images_route"] or IMAGES_ROUTE_DEFAULT

self.protect_edit_by_password = os.getenv("PROTECT_EDIT_BY_PASSWORD") or yaml_config["protect_edit_by_password"] or PROTECT_EDIT_BY_PASSWORD
self.password_in_sha_256 = os.getenv("PASSWORD_IN_SHA_256") or yaml_config["password_in_sha_256"] or PASSWORD_IN_SHA_256

self.local_mode = (os.getenv("LOCAL_MODE") in ["True", "true", "Yes", "yes"]) or yaml_config["local_mode"] or LOCAL_MODE

self.image_allowed_mime = config_list(yaml_config, "IMAGE_ALLOWED_MIME", IMAGE_ALLOWED_MIME_DEFAULT)
self.optimize_images = os.getenv("OPTIMIZE_IMAGES") or yaml_config["optimize_images"] or OPTIMIZE_IMAGES_DEFAULT

self.cache_dir = os.getenv("CACHE_DIR") or yaml_config["cache_dir"] or CACHE_DIR
self.search_dir = os.getenv("SEARCH_DIR") or yaml_config["search_dir"] or SEARCH_DIR
31 changes: 31 additions & 0 deletions docs/environment variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,37 @@ Otherwise the CDNs jsdelivr, cloudflare, polyfill and unpkg will be used.
export LOCAL_MODE=True
```

## Optimize Images

If enabled optimizes images by converting them to webp files.
Allowed values are `no`, `lossless` and `lossy`.

| | `lossless` | `lossy` |
|-----|-----------------|----------|
| gif | lossless | lossless |
| jpg | _near_ lossless | lossy |
| png | lossless | lossless |

`Default = "no"`

```
export OPTIMIZE_IMAGES="lossy"
```

### How to install webp
You need to have the programs `cwebp` and `gif2webp` installed to use this feature.
Everyone not listed below has to get the binaries themselves: https://developers.google.com/speed/webp/docs/precompiled

| Operating System | How to install |
|------------------|--------------------------------|
| Arch & Manjaro | `pacman -S libwebp` |
| Alpine | `apk add libwebp-tools` |
| Debian & Ubuntu | `apt install webp` |
| Fedora | `dnf install libwebp-tools` |
| macOS homebrew | `brew install webp` |
| macOS MacPorts | `port install webp` |
| OpenSuse | `zypper install libwebp-tools` |

## Caching

By default wikmd will cache wiki pages to `/dev/shm/wikmd/cache`, changing this option changes
Expand Down
11 changes: 10 additions & 1 deletion docs/yaml-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ precedence.
## Configuration parameters

```yaml
# wikmd configuration file

wikmd_host: "0.0.0.0"
wikmd_port: 5000
wikmd_logging: 1
Expand All @@ -27,14 +29,21 @@ sync_with_remote: 0
remote_url: ""

wiki_directory: "wiki"
images_route: "img"
homepage: "homepage.md"
homepage_title: "homepage"
images_route: "img"
image_allowed_mime: ["image/gif", "image/jpeg", "image/png", "image/svg+xml", "image/webp"]

protect_edit_by_password: 0
password_in_sha_256: "0E9C700FAB2D5B03B0581D080E74A2D7428758FC82BD423824C6C11D6A7F155E" #ps: wikmd

local_mode: false

# Valid values are "no", "lossless" and "lossy"
optimize_images: "no"

cache_dir: "/dev/shm/wikmd/cache"
search_dir: "/dev/shm/wikmd/searchindex"
```

Please, refer to [environment variables](environment%20variables.md) for further parameters explanation.
128 changes: 128 additions & 0 deletions image_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import os
import re
import shutil
import tempfile
from base64 import b32encode
from hashlib import sha1

from werkzeug.utils import secure_filename


class ImageManager:
"""
Class that manages the images of the wiki.
It can save, optimize and delete images.
"""

def __init__(self, app, cfg):
self.logger = app.logger
self.cfg = cfg
self.images_path = os.path.join(self.cfg.wiki_directory, self.cfg.images_route)
self.temp_dir = "/tmp/wikmd/images"
# Execute the needed programs to check if they are available. Exit code 0 means the programs were executed successfully
self.logger.info("Checking if webp is available for image optimization ...")
self.can_optimize = os.system("cwebp -version") == 0 and os.system("gif2webp -version") == 0
if not self.can_optimize and self.cfg.optimize_images in ["lossless", "lossy"]:
self.logger.error("To use image optimization webp and gif2webp need to be installed and in the $PATH. They could not be found.")

def save_images(self, file):
"""
Saves the image from the filepond upload.
The image is renamed to the hash of the content, so the image is immutable.
This makes it possible to cache it indefinitely on the client side.
"""
img_file = file["filepond"]
original_file_name, img_extension = os.path.splitext(img_file.filename)

temp_file_handle, temp_file_path = tempfile.mkstemp()
img_file.save(temp_file_path)

if self.cfg.optimize_images in ["lossless", "lossy"] and self.can_optimize:
temp_file_handle, temp_file_path, img_extension = self.__optimize_image(temp_file_path, img_file.content_type)

# Does not matter if sha1 is secure or not. If someone has the right to edit they can already delete all pages.
hasher = sha1()
with open(temp_file_handle, "rb") as f:
data = f.read()
hasher.update(data)

# Using base32 instead of urlsafe base64, because the Windows file system is case-insensitive
img_digest = b32encode(hasher.digest()).decode("utf-8").lower()[:-4]
hash_file_name = secure_filename(f"{original_file_name}-{img_digest}{img_extension}")
hash_file_path = os.path.join(self.images_path, hash_file_name)

# We can skip writing the file if it already exists. It is the same file, because it has the same hash
if os.path.exists(hash_file_path):
self.logger.info(f"Image already exists '{img_file.filename}' as '{hash_file_name}'")
else:
self.logger.info(f"Saving image >>> '{img_file.filename}' as '{hash_file_name}' ...")
shutil.move(temp_file_path, hash_file_path)

return hash_file_name

def cleanup_images(self):
"""Deletes images not used by any page"""
saved_images = set(os.listdir(self.images_path))
# Don't delete .gitignore
saved_images.discard(".gitignore")

# Matches [*](/img/*) it does not matter if images_route is "/img" or "img"
image_link_pattern = fr"\[(.*?)\]\(({os.path.join('/', self.cfg.images_route)}.+?)\)"
image_link_regex = re.compile(image_link_pattern)
used_images = set()
# Searching for Markdown files
for root, sub_dir, files in os.walk(self.cfg.wiki_directory):
if os.path.join(self.cfg.wiki_directory, '.git') in root:
# We don't want to search there
continue
if self.images_path in root:
# Nothing interesting there too
continue
for filename in files:
path = os.path.join(root, filename)
with open(path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
matches = image_link_regex.findall(content)
for _caption, image_path in matches:
used_images.add(os.path.basename(image_path))

not_used_images = saved_images.difference(used_images)
for not_used_image in not_used_images:
self.delete_image(not_used_image)

def delete_image(self, image_name):
image_path = os.path.join(self.images_path, image_name)
self.logger.info(f"Deleting file >>> {image_path}")
try:
os.remove(image_path)
except IsADirectoryError | FileNotFoundError:
self.logger.error(f"Could not delete '{image_path}'")

def __optimize_image(self, temp_file_path_original, content_type):
"""
Optimizes gif, jpg and png by converting them to webp.
gif and png files are always converted lossless.
jpg files are either converted lossy or near lossless depending on cfg.optimize_images.
Uses the external binaries cwebp and gif2webp.
"""

temp_file_handle, temp_file_path = tempfile.mkstemp()
if content_type in ["image/gif", "image/png"]:
self.logger.info(f"Compressing image lossless ...")
if content_type == "image/gif":
os.system(f"gif2webp -quiet -m 6 {temp_file_path_original} -o {temp_file_path}")
else:
os.system(f"cwebp -quiet -lossless -z 9 {temp_file_path_original} -o {temp_file_path}")
os.remove(temp_file_path_original)

elif content_type in ["image/jpeg"]:
if self.cfg.optimize_images == "lossless":
self.logger.info(f"Compressing image near lossless ...")
os.system(f"cwebp -quiet -near_lossless -m 6 {temp_file_path_original} -o {temp_file_path}")
elif self.cfg.optimize_images == "lossy":
self.logger.info(f"Compressing image lossy ...")
os.system(f"cwebp -quiet -m 6 {temp_file_path_original} -o {temp_file_path}")
os.remove(temp_file_path_original)

return temp_file_handle, temp_file_path, ".webp"
6 changes: 4 additions & 2 deletions templates/new.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@
<script type="text/javascript" src="{{ system.web_deps["codemirror.min.js"] }}"></script>
<script type="text/javascript" src="{{ system.web_deps["markdown.min.js"] }}"></script>
<script type="text/javascript" src="{{ system.web_deps["filepond.js"] }}"></script>
<script type="text/javascript" src="{{ system.web_deps["filepond-plugin-file-validate-type.js"] }}"></script>

<script>
FilePond.registerPlugin(FilePondPluginFileValidateType);
const messagesElement = document.getElementById("messages");
const inputElement = document.querySelector('input[type="file"]');
FilePond.create( inputElement );
FilePond.create(inputElement, {acceptedFileTypes: {{ image_allowed_mime|safe }} }); // image_allowed_mime is provided by the config, so it is safe
FilePond.setOptions({
server: {
url:"/",
Expand All @@ -48,7 +50,7 @@
onload: (filename) => {
const md = `![caption](/{{upload_path}}/${filename})`;
let message = document.createElement("li");
message.innerHTML = `Use <b>${md}</b> inside your markdown file <a href="#" onclick=navigator.clipboard.writeText("${md}")>Copy</a>`;
message.innerHTML = `Use <b><code>${md}</code></b> inside your markdown file <a href="#" onclick=navigator.clipboard.writeText("${md}")>Copy</a>`;
messagesElement.appendChild(message);
}
}
Expand Down
4 changes: 4 additions & 0 deletions web_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@
"filepond.css": WebDependency(
local="/static/css/filepond.css",
external="https://unpkg.com/filepond/dist/filepond.css"
),
"filepond-plugin-file-validate-type.js": WebDependency(
local="/static/js/filepond-plugin-file-validate-type.js",
external="https://unpkg.com/filepond-plugin-file-validate-type@1.2.8/dist/filepond-plugin-file-validate-type.js"
)
}

Expand Down
Loading

0 comments on commit 4b6f0f0

Please sign in to comment.